@leera.io/qa-runner 1.0.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/LICENSE +64 -0
- package/README.md +333 -0
- package/dist/src/appium/drivers.js +60 -0
- package/dist/src/appium/home.js +60 -0
- package/dist/src/appium/server.js +237 -0
- package/dist/src/builds/cache.js +133 -0
- package/dist/src/builds/cleanup.js +208 -0
- package/dist/src/builds/download.js +64 -0
- package/dist/src/builds/install.js +75 -0
- package/dist/src/builds/ios-app.js +99 -0
- package/dist/src/builds/unpack.js +347 -0
- package/dist/src/capabilities.js +93 -0
- package/dist/src/ci/builds.js +68 -0
- package/dist/src/ci/client.js +90 -0
- package/dist/src/ci/env.js +118 -0
- package/dist/src/ci/junit.js +79 -0
- package/dist/src/ci/run.js +320 -0
- package/dist/src/ci/summary.js +126 -0
- package/dist/src/cli/commands/builds.js +104 -0
- package/dist/src/cli/commands/ci.js +167 -0
- package/dist/src/cli/commands/config.js +83 -0
- package/dist/src/cli/commands/connect.js +70 -0
- package/dist/src/cli/commands/doctor.js +57 -0
- package/dist/src/cli/commands/service.js +165 -0
- package/dist/src/cli/commands/setup.js +193 -0
- package/dist/src/cli/commands/start.js +94 -0
- package/dist/src/cli/commands/update.js +189 -0
- package/dist/src/cli/commands/version.js +28 -0
- package/dist/src/cli/main.js +59 -0
- package/dist/src/cli/output.js +67 -0
- package/dist/src/cli.js +40 -0
- package/dist/src/client.js +146 -0
- package/dist/src/config.js +260 -0
- package/dist/src/debug.js +137 -0
- package/dist/src/devices/android/adb.js +251 -0
- package/dist/src/devices/android/avd.js +95 -0
- package/dist/src/devices/android/emulator.js +153 -0
- package/dist/src/devices/android/index.js +133 -0
- package/dist/src/devices/android/logcat.js +205 -0
- package/dist/src/devices/android/prepare.js +40 -0
- package/dist/src/devices/android/setup.js +162 -0
- package/dist/src/devices/android/system-dialog.js +78 -0
- package/dist/src/devices/desktop.js +145 -0
- package/dist/src/devices/ios/devicectl.js +87 -0
- package/dist/src/devices/ios/index.js +130 -0
- package/dist/src/devices/ios/prepare.js +39 -0
- package/dist/src/devices/ios/record.js +122 -0
- package/dist/src/devices/ios/settings.js +55 -0
- package/dist/src/devices/ios/setup.js +186 -0
- package/dist/src/devices/ios/simctl.js +201 -0
- package/dist/src/devices/ios/syslog.js +185 -0
- package/dist/src/devices/ios/wda.js +108 -0
- package/dist/src/devices/macos.js +248 -0
- package/dist/src/devices/manager.js +206 -0
- package/dist/src/devices/screen-record.js +95 -0
- package/dist/src/devices/tauri.js +189 -0
- package/dist/src/devices/types.js +6 -0
- package/dist/src/devices/windows.js +362 -0
- package/dist/src/doctor.js +394 -0
- package/dist/src/download.js +39 -0
- package/dist/src/drivers/android-appium.js +525 -0
- package/dist/src/drivers/common.js +64 -0
- package/dist/src/drivers/desktop-appium.js +282 -0
- package/dist/src/drivers/driver.js +15 -0
- package/dist/src/drivers/electron-playwright.js +566 -0
- package/dist/src/drivers/ios-appium.js +535 -0
- package/dist/src/drivers/mac2-appium.js +313 -0
- package/dist/src/drivers/registry.js +47 -0
- package/dist/src/drivers/snapshot/aria.js +351 -0
- package/dist/src/drivers/snapshot/format.js +106 -0
- package/dist/src/drivers/tauri-webdriver.js +678 -0
- package/dist/src/drivers/web-playwright.js +403 -0
- package/dist/src/drivers/webdriver/actions.js +328 -0
- package/dist/src/drivers/webdriver/desktop-keys.js +141 -0
- package/dist/src/drivers/webdriver/dom-locate.js +113 -0
- package/dist/src/drivers/webdriver/dom-snapshot.js +376 -0
- package/dist/src/drivers/webdriver/dom-tree.js +118 -0
- package/dist/src/drivers/webdriver/dom.js +213 -0
- package/dist/src/drivers/webdriver/ios-actions.js +299 -0
- package/dist/src/drivers/webdriver/ios-locate.js +121 -0
- package/dist/src/drivers/webdriver/locate.js +136 -0
- package/dist/src/drivers/webdriver/native-actions.js +175 -0
- package/dist/src/drivers/webdriver/native-locate.js +273 -0
- package/dist/src/drivers/webdriver/session.js +105 -0
- package/dist/src/drivers/webdriver/xml-tree-desktop.js +425 -0
- package/dist/src/drivers/webdriver/xml-tree-ios.js +314 -0
- package/dist/src/drivers/webdriver/xml-tree.js +393 -0
- package/dist/src/drivers/windows-appium.js +319 -0
- package/dist/src/errors.js +21 -0
- package/dist/src/executor.js +189 -0
- package/dist/src/home.js +76 -0
- package/dist/src/index.js +22 -0
- package/dist/src/log.js +69 -0
- package/dist/src/runner.js +498 -0
- package/dist/src/service/index.js +64 -0
- package/dist/src/service/launchd.js +85 -0
- package/dist/src/service/names.js +2 -0
- package/dist/src/service/schtasks.js +128 -0
- package/dist/src/service/systemd.js +65 -0
- package/dist/src/session/commands.js +165 -0
- package/dist/src/session/execute.js +232 -0
- package/dist/src/session/loop.js +148 -0
- package/dist/src/template.js +62 -0
- package/dist/src/types.js +7 -0
- package/dist/src/update/apply.js +223 -0
- package/dist/src/update/check.js +59 -0
- package/dist/src/update/manifest.js +93 -0
- package/dist/src/update/verify.js +65 -0
- package/dist/src/version.js +42 -0
- package/package.json +44 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { closeSync, mkdirSync, openSync, rmSync } from 'node:fs';
|
|
3
|
+
import { bundledDrivers } from '../../appium/drivers.js';
|
|
4
|
+
import { appiumHomeDir, seedAppiumHome } from '../../appium/home.js';
|
|
5
|
+
import { bundledAppium } from '../../appium/server.js';
|
|
6
|
+
import { ExitCode, messageOf } from '../../errors.js';
|
|
7
|
+
import { ensureHome } from '../../home.js';
|
|
8
|
+
import { runTool } from '../android/adb.js';
|
|
9
|
+
import { appiumDriverListArgv } from '../android/setup.js';
|
|
10
|
+
import { listIosDevices } from './index.js';
|
|
11
|
+
import { loadIosSettings } from './settings.js';
|
|
12
|
+
import { bundledWda, developerDir, isCommandLineToolsOnly, isWdaSimulatorBuilt, wdaCacheDir, wdaSimulatorBuildArgv, xcodeInfo } from './wda.js';
|
|
13
|
+
const XCODE_FIX = 'Install Xcode from the App Store, open it once, then run `sudo xcode-select -s /Applications/Xcode.app`';
|
|
14
|
+
function spawnLogged(file, args, logFile, env) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const fd = openSync(logFile, 'w', 0o600);
|
|
17
|
+
const done = (code) => {
|
|
18
|
+
closeSync(fd);
|
|
19
|
+
resolve(code);
|
|
20
|
+
};
|
|
21
|
+
const child = spawn(file, args, { stdio: ['ignore', fd, fd], env });
|
|
22
|
+
child.on('error', () => done(1));
|
|
23
|
+
child.on('close', (code) => done(code ?? 1));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* `setup ios`: checks for full Xcode, seeds Appium's home with the bundled XCUITest driver,
|
|
28
|
+
* prebuilds WebDriverAgent for simulators into the runner home, then lists devices.
|
|
29
|
+
*/
|
|
30
|
+
export async function setupIos(io, paths, options = {}) {
|
|
31
|
+
if (io.platform !== 'darwin') {
|
|
32
|
+
io.stderr('iOS jobs need a Mac with Xcode; this computer cannot run them.');
|
|
33
|
+
return ExitCode.environmentNotReady;
|
|
34
|
+
}
|
|
35
|
+
ensureHome(paths);
|
|
36
|
+
let ok = true;
|
|
37
|
+
// 1. Xcode.
|
|
38
|
+
const dir = await developerDir();
|
|
39
|
+
if (!dir || isCommandLineToolsOnly(dir)) {
|
|
40
|
+
io.stderr(`Full Xcode is needed (found ${dir ?? 'no developer directory'}). ${XCODE_FIX}.`);
|
|
41
|
+
return ExitCode.environmentNotReady;
|
|
42
|
+
}
|
|
43
|
+
const xcode = await xcodeInfo();
|
|
44
|
+
if (!xcode) {
|
|
45
|
+
io.stderr(`xcodebuild does not run. Open Xcode once to accept its licence and install its components. ${XCODE_FIX}.`);
|
|
46
|
+
return ExitCode.environmentNotReady;
|
|
47
|
+
}
|
|
48
|
+
io.stdout(`Xcode ${xcode.version}${xcode.build ? ` (${xcode.build})` : ''} at ${dir}`);
|
|
49
|
+
// 2. Appium and its driver.
|
|
50
|
+
const appium = bundledAppium();
|
|
51
|
+
const drivers = bundledDrivers(io.platform);
|
|
52
|
+
const wdaSources = bundledWda();
|
|
53
|
+
if (!appium || !drivers.some((d) => d.platforms.includes('ios')) || !wdaSources) {
|
|
54
|
+
io.stderr('Appium and its XCUITest driver are not installed with this runner. Install the runner with its installer, or with npm without --omit=optional.');
|
|
55
|
+
return ExitCode.environmentNotReady;
|
|
56
|
+
}
|
|
57
|
+
const home = appiumHomeDir(paths);
|
|
58
|
+
const seeded = seedAppiumHome(home, appium, drivers);
|
|
59
|
+
io.stdout(`Appium ${appium.version} home: ${home}${seeded.changed ? ' (updated)' : ''}`);
|
|
60
|
+
try {
|
|
61
|
+
const argv = appiumDriverListArgv(process.execPath, appium.dir);
|
|
62
|
+
const { stdout } = await runTool(argv[0], argv.slice(1), { timeoutMs: 120_000, env: { ...io.env, APPIUM_HOME: home } });
|
|
63
|
+
const listed = JSON.parse(stdout.slice(stdout.indexOf('{')));
|
|
64
|
+
const found = listed.xcuitest;
|
|
65
|
+
if (found)
|
|
66
|
+
io.stdout(` driver xcuitest ${found.version ?? wdaSources.driverVersion}: installed`);
|
|
67
|
+
else {
|
|
68
|
+
ok = false;
|
|
69
|
+
io.stderr(` driver xcuitest: Appium does not see it in ${home}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
ok = false;
|
|
74
|
+
io.stderr(`Appium could not list its drivers: ${messageOf(error)}`);
|
|
75
|
+
}
|
|
76
|
+
// 3. WebDriverAgent for simulators.
|
|
77
|
+
const wdaDir = wdaCacheDir(paths, wdaSources.driverVersion, xcode.version);
|
|
78
|
+
if (isWdaSimulatorBuilt(wdaDir) && !options.rebuild) {
|
|
79
|
+
io.stdout(`WebDriverAgent for simulators: built (${wdaDir})`);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
rmSync(wdaDir, { recursive: true, force: true });
|
|
83
|
+
mkdirSync(wdaDir, { recursive: true, mode: 0o700 });
|
|
84
|
+
const logFile = `${wdaDir}.build.log`;
|
|
85
|
+
io.stdout(`Building WebDriverAgent for simulators into ${wdaDir} (this takes a few minutes; log: ${logFile}) ...`);
|
|
86
|
+
const started = Date.now();
|
|
87
|
+
const code = await spawnLogged('xcodebuild', wdaSimulatorBuildArgv(wdaSources.project, wdaDir), logFile, io.env);
|
|
88
|
+
if (code !== 0 || !isWdaSimulatorBuilt(wdaDir)) {
|
|
89
|
+
ok = false;
|
|
90
|
+
io.stderr(`WebDriverAgent did not build (xcodebuild exit ${code}); see ${logFile}.`);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
io.stdout(`WebDriverAgent built in ${Math.round((Date.now() - started) / 1000)} s.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// 4. Devices.
|
|
97
|
+
const inventory = await listIosDevices();
|
|
98
|
+
for (const error of inventory.errors)
|
|
99
|
+
io.stderr(` ${error}`);
|
|
100
|
+
if (inventory.devices.length === 0)
|
|
101
|
+
io.stdout('Devices: none (create a simulator in Xcode > Window > Devices and Simulators)');
|
|
102
|
+
else {
|
|
103
|
+
io.stdout('Devices:');
|
|
104
|
+
for (const device of inventory.devices) {
|
|
105
|
+
io.stdout(` ${device.id} ${device.name} ${device.kind}${device.os_version ? ` iOS ${device.os_version}` : ''} ${device.state}${device.can_boot ? ' (booted when a job needs it)' : ''}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const reason of inventory.notReady)
|
|
109
|
+
io.stdout(` ${reason}`);
|
|
110
|
+
const settings = loadIosSettings(io.env, paths);
|
|
111
|
+
for (const problem of settings.problems)
|
|
112
|
+
io.stderr(` ${problem}`);
|
|
113
|
+
if (inventory.devices.some((d) => d.kind === 'real') && !settings.teamId) {
|
|
114
|
+
io.stdout(' Real devices need your Apple Developer Team ID to sign WebDriverAgent: leera-qa-runner config set ios.team_id <TEAM ID>');
|
|
115
|
+
}
|
|
116
|
+
io.stdout(ok ? 'iOS is set up. Check everything with `leera-qa-runner doctor --platform ios`.' : 'iOS setup is incomplete; see the messages above.');
|
|
117
|
+
return ok ? ExitCode.ok : ExitCode.environmentNotReady;
|
|
118
|
+
}
|
|
119
|
+
/** `doctor` checks for iOS: full Xcode, the driver, the WebDriverAgent build, simulators, real-device readiness and signing. */
|
|
120
|
+
export async function iosChecks(env, paths, deps = {}) {
|
|
121
|
+
const platform = deps.platform ?? process.platform;
|
|
122
|
+
if (platform !== 'darwin') {
|
|
123
|
+
return [{ id: 'ios.host', title: 'iOS: host', status: 'warn', detail: 'iOS jobs need a Mac with Xcode' }];
|
|
124
|
+
}
|
|
125
|
+
const checks = [];
|
|
126
|
+
const dir = await developerDir();
|
|
127
|
+
const xcode = await xcodeInfo();
|
|
128
|
+
if (!dir || isCommandLineToolsOnly(dir) || !xcode) {
|
|
129
|
+
checks.push({
|
|
130
|
+
id: 'ios.xcode',
|
|
131
|
+
title: 'iOS: Xcode',
|
|
132
|
+
status: 'fail',
|
|
133
|
+
detail: dir && isCommandLineToolsOnly(dir) ? `xcode-select points at the Command Line Tools (${dir}); full Xcode is needed` : 'Xcode is not installed or xcodebuild does not run',
|
|
134
|
+
fix: XCODE_FIX,
|
|
135
|
+
});
|
|
136
|
+
return checks;
|
|
137
|
+
}
|
|
138
|
+
checks.push({ id: 'ios.xcode', title: 'iOS: Xcode', status: 'ok', detail: `Xcode ${xcode.version} (${dir})` });
|
|
139
|
+
const wdaSources = bundledWda();
|
|
140
|
+
if (!bundledAppium() || !wdaSources) {
|
|
141
|
+
checks.push({ id: 'ios.appium', title: 'iOS: Appium', status: 'fail', detail: 'the XCUITest driver is not installed with this runner' });
|
|
142
|
+
return checks;
|
|
143
|
+
}
|
|
144
|
+
checks.push({ id: 'ios.appium', title: 'iOS: Appium', status: 'ok', detail: `XCUITest driver ${wdaSources.driverVersion}` });
|
|
145
|
+
const wdaDir = wdaCacheDir(paths, wdaSources.driverVersion, xcode.version);
|
|
146
|
+
checks.push(isWdaSimulatorBuilt(wdaDir)
|
|
147
|
+
? { id: 'ios.wda', title: 'iOS: WebDriverAgent', status: 'ok', detail: `built for simulators (${wdaDir})` }
|
|
148
|
+
: {
|
|
149
|
+
id: 'ios.wda',
|
|
150
|
+
title: 'iOS: WebDriverAgent',
|
|
151
|
+
status: 'warn',
|
|
152
|
+
detail: 'not prebuilt for this driver and Xcode; the first simulator job builds it (several minutes)',
|
|
153
|
+
fix: 'leera-qa-runner setup ios',
|
|
154
|
+
});
|
|
155
|
+
const inventory = await listIosDevices();
|
|
156
|
+
const simulators = inventory.devices.filter((d) => d.kind === 'simulator');
|
|
157
|
+
const real = inventory.devices.filter((d) => d.kind === 'real');
|
|
158
|
+
checks.push(simulators.length > 0
|
|
159
|
+
? { id: 'ios.simulators', title: 'iOS: simulators', status: 'ok', detail: `${simulators.length} (${[...new Set(simulators.map((d) => `iOS ${d.os_version}`))].join(', ')})` }
|
|
160
|
+
: {
|
|
161
|
+
id: 'ios.simulators',
|
|
162
|
+
title: 'iOS: simulators',
|
|
163
|
+
status: 'warn',
|
|
164
|
+
detail: inventory.errors.join('; ') || 'no iOS simulator exists',
|
|
165
|
+
fix: 'Add an iOS simulator runtime in Xcode > Settings > Components',
|
|
166
|
+
});
|
|
167
|
+
const settings = loadIosSettings(env, paths);
|
|
168
|
+
if (real.length > 0 || inventory.notReady.length > 0) {
|
|
169
|
+
checks.push(inventory.notReady.length > 0
|
|
170
|
+
? { id: 'ios.devices', title: 'iOS: real devices', status: 'warn', detail: inventory.notReady.join('; ') }
|
|
171
|
+
: { id: 'ios.devices', title: 'iOS: real devices', status: 'ok', detail: real.map((d) => `${d.name} (iOS ${d.os_version ?? '?'})`).join(', ') });
|
|
172
|
+
checks.push(settings.teamId
|
|
173
|
+
? { id: 'ios.signing', title: 'iOS: signing', status: 'ok', detail: `team ${settings.teamId}${settings.wdaBundleId ? `, WebDriverAgent as ${settings.wdaBundleId}` : ''}` }
|
|
174
|
+
: {
|
|
175
|
+
id: 'ios.signing',
|
|
176
|
+
title: 'iOS: signing',
|
|
177
|
+
status: 'warn',
|
|
178
|
+
detail: ['real devices need a Team ID to sign WebDriverAgent', ...settings.problems].join('; '),
|
|
179
|
+
fix: 'leera-qa-runner config set ios.team_id <TEAM ID>',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
else if (settings.problems.length > 0) {
|
|
183
|
+
checks.push({ id: 'ios.signing', title: 'iOS: signing', status: 'warn', detail: settings.problems.join('; ') });
|
|
184
|
+
}
|
|
185
|
+
return checks;
|
|
186
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { runTool, ToolError } from '../android/adb.js';
|
|
2
|
+
/** A simulator UDID as CoreSimulator writes it; checked before it goes into any argv. */
|
|
3
|
+
export const UDID_RE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
|
|
4
|
+
export function assertSimUdid(udid) {
|
|
5
|
+
if (!UDID_RE.test(udid))
|
|
6
|
+
throw new Error(`'${udid}' is not a simulator UDID`);
|
|
7
|
+
return udid;
|
|
8
|
+
}
|
|
9
|
+
/** `com.apple.CoreSimulator.SimRuntime.iOS-26-5` → `{platform: 'iOS', version: '26.5'}`. */
|
|
10
|
+
export function parseRuntimeId(runtime) {
|
|
11
|
+
const match = /SimRuntime\.([A-Za-z]+)-(\d+(?:-\d+)*)$/.exec(runtime);
|
|
12
|
+
if (!match)
|
|
13
|
+
return null;
|
|
14
|
+
return { platform: match[1], version: match[2].split('-').join('.') };
|
|
15
|
+
}
|
|
16
|
+
/** Parses `simctl list devices --json`: the iOS simulators, available or not. */
|
|
17
|
+
export function parseSimctlDevices(json) {
|
|
18
|
+
const doc = (typeof json === 'string' ? JSON.parse(json) : json);
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const [runtime, list] of Object.entries(doc.devices ?? {})) {
|
|
21
|
+
const parsed = parseRuntimeId(runtime);
|
|
22
|
+
if (parsed?.platform !== 'iOS' || !Array.isArray(list))
|
|
23
|
+
continue;
|
|
24
|
+
for (const raw of list) {
|
|
25
|
+
const udid = typeof raw.udid === 'string' ? raw.udid : '';
|
|
26
|
+
const name = typeof raw.name === 'string' ? raw.name : '';
|
|
27
|
+
if (!UDID_RE.test(udid) || !name)
|
|
28
|
+
continue;
|
|
29
|
+
out.push({
|
|
30
|
+
udid,
|
|
31
|
+
name,
|
|
32
|
+
state: typeof raw.state === 'string' ? raw.state : 'Shutdown',
|
|
33
|
+
runtime,
|
|
34
|
+
osVersion: parsed.version,
|
|
35
|
+
deviceType: typeof raw.deviceTypeIdentifier === 'string' ? raw.deviceTypeIdentifier : null,
|
|
36
|
+
available: raw.isAvailable !== false,
|
|
37
|
+
lastBootedAt: typeof raw.lastBootedAt === 'string' ? raw.lastBootedAt : null,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
export function simctlListArgv() {
|
|
44
|
+
return ['simctl', 'list', 'devices', '--json'];
|
|
45
|
+
}
|
|
46
|
+
export function simctlBootArgv(udid) {
|
|
47
|
+
return ['simctl', 'boot', assertSimUdid(udid)];
|
|
48
|
+
}
|
|
49
|
+
/** Waits until the simulator finished booting (`-b` boots it first when it is shut down). */
|
|
50
|
+
export function simctlBootstatusArgv(udid) {
|
|
51
|
+
return ['simctl', 'bootstatus', assertSimUdid(udid), '-b'];
|
|
52
|
+
}
|
|
53
|
+
export function simctlShutdownArgv(udid) {
|
|
54
|
+
return ['simctl', 'shutdown', assertSimUdid(udid)];
|
|
55
|
+
}
|
|
56
|
+
/** An executable name that is safe inside a log predicate string. */
|
|
57
|
+
export const PROCESS_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._+-]{0,99}$/;
|
|
58
|
+
/**
|
|
59
|
+
* Follows the simulator's unified log for one process, compact style. The predicate is one argv
|
|
60
|
+
* element (no shell) and the name is validated first.
|
|
61
|
+
*/
|
|
62
|
+
export function simctlLogStreamArgv(udid, processName) {
|
|
63
|
+
if (!PROCESS_NAME_RE.test(processName))
|
|
64
|
+
throw new Error(`'${processName}' is not a process name`);
|
|
65
|
+
return ['simctl', 'spawn', assertSimUdid(udid), 'log', 'stream', '--style', 'compact', '--level', 'default', '--predicate', `process == "${processName}"`];
|
|
66
|
+
}
|
|
67
|
+
/** `simctl appinfo <udid> <bundle id>` (a plist-like dump with `CFBundleExecutable`). */
|
|
68
|
+
export function simctlAppinfoArgv(udid, bundleId) {
|
|
69
|
+
return ['simctl', 'appinfo', assertSimUdid(udid), assertBundleId(bundleId)];
|
|
70
|
+
}
|
|
71
|
+
/** Reads `CFBundleExecutable = Preferences;` (or `"My App"`) from `simctl appinfo` output. */
|
|
72
|
+
export function parseAppinfoExecutable(output) {
|
|
73
|
+
const match = /CFBundleExecutable\s*=\s*(?:"([^"]+)"|([^;\s]+))\s*;/.exec(output);
|
|
74
|
+
return match ? (match[1] ?? match[2] ?? null) : null;
|
|
75
|
+
}
|
|
76
|
+
export const BUNDLE_ID_RE = /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/;
|
|
77
|
+
export function assertBundleId(bundleId) {
|
|
78
|
+
if (!BUNDLE_ID_RE.test(bundleId) || bundleId.length > 255)
|
|
79
|
+
throw new Error(`'${bundleId}' is not a valid iOS bundle id`);
|
|
80
|
+
return bundleId;
|
|
81
|
+
}
|
|
82
|
+
/** Default time a simulator gets to finish booting (config `ios.simulator_boot_timeout_secs`). */
|
|
83
|
+
export const DEFAULT_SIMULATOR_BOOT_TIMEOUT_MS = 300_000;
|
|
84
|
+
/** `simctl bootstatus` did not report the simulator booted within the timeout. */
|
|
85
|
+
export class SimulatorBootTimeout extends Error {
|
|
86
|
+
udid;
|
|
87
|
+
timeoutMs;
|
|
88
|
+
constructor(udid, timeoutMs, detail) {
|
|
89
|
+
super(`the simulator ${udid} did not finish booting within ${Math.round(timeoutMs / 1000)}s${detail ? ` (${detail})` : ''}`);
|
|
90
|
+
this.udid = udid;
|
|
91
|
+
this.timeoutMs = timeoutMs;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** `simctl boot` refuses a simulator that is already booted or booting; that is not a failure. */
|
|
95
|
+
export function isAlreadyBootingError(message) {
|
|
96
|
+
return /current state:\s*(Booted|Booting)/i.test(message);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Boots a simulator and waits until it has finished booting. A slow Mac (or a busy CI host) can
|
|
100
|
+
* leave a first boot stuck, for example "Waiting on BackBoard"; a boot that times out is shut
|
|
101
|
+
* down and tried again, `retries` times. Errors other than a timeout are not retried; an error from
|
|
102
|
+
* `simctl boot` alone is logged and the wait (`bootstatus -b`, which boots too) decides.
|
|
103
|
+
*/
|
|
104
|
+
export async function bootSimulator(udid, ops, options = {}) {
|
|
105
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_SIMULATOR_BOOT_TIMEOUT_MS;
|
|
106
|
+
const retries = Math.max(0, options.retries ?? 1);
|
|
107
|
+
const now = options.now ?? Date.now;
|
|
108
|
+
const log = options.log ?? (() => undefined);
|
|
109
|
+
const attempts = [];
|
|
110
|
+
for (let attempt = 1;; attempt++) {
|
|
111
|
+
const started = now();
|
|
112
|
+
log('booting the simulator', { udid, attempt, timeout_ms: timeoutMs });
|
|
113
|
+
try {
|
|
114
|
+
try {
|
|
115
|
+
await ops.boot(udid);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
// `bootstatus -b` boots the simulator itself and fails for real when it cannot; `simctl boot`
|
|
119
|
+
// can also refuse transiently (NSPOSIXErrorDomain code 22), so its error alone is not fatal.
|
|
120
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
121
|
+
if (!isAlreadyBootingError(message))
|
|
122
|
+
log('simctl boot failed; waiting with bootstatus -b', { udid, attempt, error: message });
|
|
123
|
+
}
|
|
124
|
+
await ops.waitForBoot(udid, timeoutMs);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
const ms = now() - started;
|
|
128
|
+
const timedOut = error instanceof SimulatorBootTimeout;
|
|
129
|
+
attempts.push({ attempt, ms, outcome: timedOut ? 'timeout' : 'error' });
|
|
130
|
+
if (!timedOut || attempt > retries) {
|
|
131
|
+
log('the simulator did not boot', { udid, attempt, ms, error: error instanceof Error ? error.message : String(error) });
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
log('the simulator boot timed out; shutting it down and trying again', { udid, attempt, ms });
|
|
135
|
+
await ops.shutdown(udid);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const ms = now() - started;
|
|
139
|
+
attempts.push({ attempt, ms, outcome: 'booted' });
|
|
140
|
+
log('the simulator booted', { udid, attempt, ms });
|
|
141
|
+
return attempts;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Thin wrapper over `xcrun simctl`. */
|
|
145
|
+
export class Simctl {
|
|
146
|
+
xcrun;
|
|
147
|
+
constructor(xcrun = 'xcrun') {
|
|
148
|
+
this.xcrun = xcrun;
|
|
149
|
+
}
|
|
150
|
+
run(args, timeoutMs = 60_000) {
|
|
151
|
+
return runTool(this.xcrun, args, { timeoutMs }).then((r) => r.stdout);
|
|
152
|
+
}
|
|
153
|
+
async list() {
|
|
154
|
+
return parseSimctlDevices(await this.run(simctlListArgv(), 30_000));
|
|
155
|
+
}
|
|
156
|
+
async boot(udid) {
|
|
157
|
+
await this.run(simctlBootArgv(udid), 120_000);
|
|
158
|
+
}
|
|
159
|
+
async waitForBoot(udid, timeoutMs) {
|
|
160
|
+
const started = Date.now();
|
|
161
|
+
try {
|
|
162
|
+
await this.run(simctlBootstatusArgv(udid), timeoutMs);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
// execFile kills bootstatus when the timeout passes; its last status line says where it was stuck.
|
|
166
|
+
const elapsed = Date.now() - started;
|
|
167
|
+
if (elapsed >= timeoutMs - 1_000 || (error instanceof ToolError && error.code === null)) {
|
|
168
|
+
const detail = error instanceof Error ? error.message.replace(/^.*? failed: /, '') : '';
|
|
169
|
+
throw new SimulatorBootTimeout(udid, timeoutMs, detail.slice(0, 200));
|
|
170
|
+
}
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Boots the simulator and waits for it, retrying once after a timeout. */
|
|
175
|
+
bootAndWait(udid, options = {}) {
|
|
176
|
+
return bootSimulator(udid, this, options);
|
|
177
|
+
}
|
|
178
|
+
async shutdown(udid) {
|
|
179
|
+
await this.run(simctlShutdownArgv(udid), 60_000).catch(() => undefined);
|
|
180
|
+
}
|
|
181
|
+
async executableName(udid, bundleId) {
|
|
182
|
+
try {
|
|
183
|
+
return parseAppinfoExecutable(await this.run(simctlAppinfoArgv(udid, bundleId), 20_000));
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** The Simulator UI app's bundle id (Appium restarts a headless simulator when this runs, or vice versa). */
|
|
191
|
+
export const SIMULATOR_APP_BUNDLE_ID = 'com.apple.iphonesimulator';
|
|
192
|
+
/** True when Simulator.app is running on this Mac (`lsappinfo`, as Appium checks it). */
|
|
193
|
+
export async function simulatorAppRunning() {
|
|
194
|
+
try {
|
|
195
|
+
const { stdout } = await runTool('lsappinfo', ['info', '-only', 'pid', SIMULATOR_APP_BUNDLE_ID], { timeoutMs: 10_000 });
|
|
196
|
+
return /"pid"\s*=\s*\d+/.test(stdout);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import { PROCESS_NAME_RE, simctlLogStreamArgv } from './simctl.js';
|
|
7
|
+
/** Same limits as the Android logcat sink, so step debug looks the same on both platforms. */
|
|
8
|
+
export const SYSLOG_LIMITS = { linesPerStep: 200, crashesPerStep: 10, lineChars: 500, crashChars: 8_000, drain: 50 };
|
|
9
|
+
/** Parses one `log stream --style compact` line: `2026-09-17 10:12:01.123 E Preferences[1234:5678] [sub:cat] message`. */
|
|
10
|
+
export function parseCompactLogLine(line) {
|
|
11
|
+
const match = /^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d+)\s+([A-Za-z]{1,2})\s+([^[\]]+?)\[(\d+):[0-9a-fx]+\]\s+(.*)$/.exec(line);
|
|
12
|
+
if (!match)
|
|
13
|
+
return null;
|
|
14
|
+
return { time: match[1], type: match[2], process: match[3].trim(), pid: Number(match[4]), message: match[5] };
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Parses a real-device syslog line as Appium's `syslog` log returns it:
|
|
18
|
+
* `Sep 17 10:12:01 iPhone Preferences(UIKitCore)[123] <Error>: message`.
|
|
19
|
+
*/
|
|
20
|
+
export function parseDeviceSyslogLine(line) {
|
|
21
|
+
const match = /^(\w{3}\s+\d+\s+\d\d:\d\d:\d\d)\s+\S+\s+([^([\s]+)(?:\([^)]*\))?\[(\d+)\]\s+<(\w+)>:\s?(.*)$/.exec(line);
|
|
22
|
+
if (!match)
|
|
23
|
+
return null;
|
|
24
|
+
const level = match[4].toLowerCase();
|
|
25
|
+
const type = level === 'error' ? 'E' : level === 'fault' ? 'F' : level === 'notice' ? 'Df' : level === 'debug' ? 'Db' : 'I';
|
|
26
|
+
return { time: match[1], process: match[2], pid: Number(match[3]), type, message: match[5] };
|
|
27
|
+
}
|
|
28
|
+
export function formatSyslog(line) {
|
|
29
|
+
return `${line.type}/${line.process}: ${line.message}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Files the app's error and fault log lines, and crash reports, under the step that was
|
|
33
|
+
* running (the iOS counterpart of `LogcatCollector`).
|
|
34
|
+
*/
|
|
35
|
+
export class SyslogCollector {
|
|
36
|
+
processName;
|
|
37
|
+
redact;
|
|
38
|
+
steps = new Map();
|
|
39
|
+
current = null;
|
|
40
|
+
pending = [];
|
|
41
|
+
constructor(processName, redact = (t) => t) {
|
|
42
|
+
this.processName = processName;
|
|
43
|
+
this.redact = redact;
|
|
44
|
+
}
|
|
45
|
+
begin(index) {
|
|
46
|
+
this.current = index;
|
|
47
|
+
}
|
|
48
|
+
get step() {
|
|
49
|
+
return this.current;
|
|
50
|
+
}
|
|
51
|
+
drain() {
|
|
52
|
+
const out = this.pending;
|
|
53
|
+
this.pending = [];
|
|
54
|
+
return out.slice(-SYSLOG_LIMITS.drain);
|
|
55
|
+
}
|
|
56
|
+
for(index) {
|
|
57
|
+
const debug = this.steps.get(index);
|
|
58
|
+
if (!debug || (!debug.device_log?.length && !debug.crashes?.length))
|
|
59
|
+
return undefined;
|
|
60
|
+
return debug;
|
|
61
|
+
}
|
|
62
|
+
accept(line) {
|
|
63
|
+
if (!line || line.process !== this.processName)
|
|
64
|
+
return;
|
|
65
|
+
if (line.type !== 'E' && line.type !== 'F')
|
|
66
|
+
return;
|
|
67
|
+
const text = this.redact(formatSyslog(line)).slice(0, SYSLOG_LIMITS.lineChars);
|
|
68
|
+
this.pending.push(text);
|
|
69
|
+
if (this.pending.length > SYSLOG_LIMITS.drain * 2)
|
|
70
|
+
this.pending.splice(0, this.pending.length - SYSLOG_LIMITS.drain);
|
|
71
|
+
if (this.current === null)
|
|
72
|
+
return;
|
|
73
|
+
const debug = this.entry(this.current);
|
|
74
|
+
const list = (debug.device_log ??= []);
|
|
75
|
+
if (list.length < SYSLOG_LIMITS.linesPerStep)
|
|
76
|
+
list.push(text);
|
|
77
|
+
else {
|
|
78
|
+
debug.dropped ??= {};
|
|
79
|
+
debug.dropped.device_log = (debug.dropped.device_log ?? 0) + 1;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
addCrash(step, report) {
|
|
83
|
+
const redacted = this.redact(report).slice(0, SYSLOG_LIMITS.crashChars);
|
|
84
|
+
this.pending.push(redacted.split('\n').slice(0, 3).join(' | ').slice(0, SYSLOG_LIMITS.lineChars));
|
|
85
|
+
if (step === null)
|
|
86
|
+
return;
|
|
87
|
+
const debug = this.entry(step);
|
|
88
|
+
const list = (debug.crashes ??= []);
|
|
89
|
+
if (list.length < SYSLOG_LIMITS.crashesPerStep)
|
|
90
|
+
list.push(redacted);
|
|
91
|
+
else {
|
|
92
|
+
debug.dropped ??= {};
|
|
93
|
+
debug.dropped.crashes = (debug.dropped.crashes ?? 0) + 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
entry(index) {
|
|
97
|
+
let debug = this.steps.get(index);
|
|
98
|
+
if (!debug) {
|
|
99
|
+
debug = {};
|
|
100
|
+
this.steps.set(index, debug);
|
|
101
|
+
}
|
|
102
|
+
return debug;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Where macOS writes crash reports of simulator apps (`<Process>-<date>.ips`). */
|
|
106
|
+
export function diagnosticReportsDir(home = homedir()) {
|
|
107
|
+
return join(home, 'Library', 'Logs', 'DiagnosticReports');
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Crash reports of one process newer than a point in time; each is returned once. Simulator app
|
|
111
|
+
* crashes land in the host's DiagnosticReports folder.
|
|
112
|
+
*/
|
|
113
|
+
export class CrashReportWatcher {
|
|
114
|
+
processName;
|
|
115
|
+
since;
|
|
116
|
+
dir;
|
|
117
|
+
seen = new Set();
|
|
118
|
+
constructor(processName, since, dir = diagnosticReportsDir()) {
|
|
119
|
+
this.processName = processName;
|
|
120
|
+
this.since = since;
|
|
121
|
+
this.dir = dir;
|
|
122
|
+
}
|
|
123
|
+
collect() {
|
|
124
|
+
let names;
|
|
125
|
+
try {
|
|
126
|
+
names = readdirSync(this.dir);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const name of names) {
|
|
133
|
+
if (!name.startsWith(`${this.processName}-`) || !/\.(ips|crash)$/.test(name) || this.seen.has(name))
|
|
134
|
+
continue;
|
|
135
|
+
const path = join(this.dir, name);
|
|
136
|
+
try {
|
|
137
|
+
if (statSync(path).mtimeMs < this.since)
|
|
138
|
+
continue;
|
|
139
|
+
this.seen.add(name);
|
|
140
|
+
out.push(readFileSync(path, 'utf8').slice(0, SYSLOG_LIMITS.crashChars));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// unreadable report: skip
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Follows a simulator's unified log for the app's process with `simctl spawn … log stream`. */
|
|
150
|
+
export class SimulatorLogSink {
|
|
151
|
+
udid;
|
|
152
|
+
processName;
|
|
153
|
+
xcrun;
|
|
154
|
+
child = null;
|
|
155
|
+
collector;
|
|
156
|
+
crashes;
|
|
157
|
+
constructor(udid, processName, redact, xcrun = 'xcrun') {
|
|
158
|
+
this.udid = udid;
|
|
159
|
+
this.processName = processName;
|
|
160
|
+
this.xcrun = xcrun;
|
|
161
|
+
if (!PROCESS_NAME_RE.test(processName))
|
|
162
|
+
throw new Error(`'${processName}' is not a process name`);
|
|
163
|
+
this.collector = new SyslogCollector(processName, redact);
|
|
164
|
+
this.crashes = new CrashReportWatcher(processName, Date.now());
|
|
165
|
+
}
|
|
166
|
+
start() {
|
|
167
|
+
if (this.child)
|
|
168
|
+
return;
|
|
169
|
+
const child = spawn(this.xcrun, simctlLogStreamArgv(this.udid, this.processName), { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
|
|
170
|
+
child.on('error', () => undefined);
|
|
171
|
+
this.child = child;
|
|
172
|
+
if (child.stdout) {
|
|
173
|
+
createInterface({ input: child.stdout }).on('line', (line) => this.collector.accept(parseCompactLogLine(line)));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** Files crash reports written since the last check under the current step. */
|
|
177
|
+
checkCrashes() {
|
|
178
|
+
for (const report of this.crashes.collect())
|
|
179
|
+
this.collector.addCrash(this.collector.step, report);
|
|
180
|
+
}
|
|
181
|
+
stop() {
|
|
182
|
+
this.child?.kill();
|
|
183
|
+
this.child = null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { resolvePackage } from '../../appium/drivers.js';
|
|
5
|
+
import { runTool } from '../android/adb.js';
|
|
6
|
+
/**
|
|
7
|
+
* WebDriverAgent (the XCTest runner Appium's XCUITest driver talks to), prebuilt once per
|
|
8
|
+
* driver version and Xcode version into `~/.leera-qa-runner/wda/<driver>-<xcode>` so jobs use
|
|
9
|
+
* `usePrebuiltWDA` + `derivedDataPath` instead of compiling it on every session (contracts §I).
|
|
10
|
+
*/
|
|
11
|
+
export const XCUITEST_PACKAGE = 'appium-xcuitest-driver';
|
|
12
|
+
/** Parses `xcodebuild -version` ("Xcode 26.6\nBuild version 17F113"). */
|
|
13
|
+
export function parseXcodebuildVersion(output) {
|
|
14
|
+
const version = /^Xcode\s+(\d+(?:\.\d+)*)/m.exec(output);
|
|
15
|
+
if (!version)
|
|
16
|
+
return null;
|
|
17
|
+
const build = /^Build version\s+(\S+)/m.exec(output);
|
|
18
|
+
return { version: version[1], build: build?.[1] ?? null };
|
|
19
|
+
}
|
|
20
|
+
export async function xcodeInfo() {
|
|
21
|
+
try {
|
|
22
|
+
const { stdout } = await runTool('xcodebuild', ['-version'], { timeoutMs: 30_000 });
|
|
23
|
+
return parseXcodebuildVersion(stdout);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** `xcode-select -p` pointing at the Command Line Tools instead of Xcode.app cannot build or run WDA. */
|
|
30
|
+
export function isCommandLineToolsOnly(developerDir) {
|
|
31
|
+
return /CommandLineTools\/?$/.test(developerDir.trim());
|
|
32
|
+
}
|
|
33
|
+
export async function developerDir() {
|
|
34
|
+
try {
|
|
35
|
+
return (await runTool('xcode-select', ['-p'], { timeoutMs: 10_000 })).stdout.trim() || null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function safeSegment(value) {
|
|
42
|
+
return value.replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 40) || 'unknown';
|
|
43
|
+
}
|
|
44
|
+
/** `~/.leera-qa-runner/wda/<xcuitest-driver-version>-<xcode-version>`. */
|
|
45
|
+
export function wdaCacheDir(paths, driverVersion, xcodeVersion) {
|
|
46
|
+
return join(paths.root, 'wda', `${safeSegment(driverVersion)}-${safeSegment(xcodeVersion)}`);
|
|
47
|
+
}
|
|
48
|
+
/** Where real-device WDA builds go (signed with the team's identity; built by the driver on first use). */
|
|
49
|
+
export function wdaRealDeviceDir(simulatorDir) {
|
|
50
|
+
return `${simulatorDir}-real`;
|
|
51
|
+
}
|
|
52
|
+
/** The bundled XCUITest driver and the WebDriverAgent project it ships with, or null when not installed. */
|
|
53
|
+
export function bundledWda(resolve) {
|
|
54
|
+
const driver = resolvePackage(XCUITEST_PACKAGE, resolve);
|
|
55
|
+
if (!driver)
|
|
56
|
+
return null;
|
|
57
|
+
try {
|
|
58
|
+
const require = createRequire(join(driver.dir, 'package.json'));
|
|
59
|
+
const agent = dirname(require.resolve('appium-webdriveragent/package.json'));
|
|
60
|
+
const project = join(agent, 'WebDriverAgent.xcodeproj');
|
|
61
|
+
return existsSync(project) ? { driverVersion: driver.version, project } : null;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** The fixed argv that builds WebDriverAgent for any iOS simulator, unsigned, into `derivedDataPath`. */
|
|
68
|
+
export function wdaSimulatorBuildArgv(project, derivedDataPath) {
|
|
69
|
+
if (!project.endsWith('WebDriverAgent.xcodeproj'))
|
|
70
|
+
throw new Error(`'${project}' is not the WebDriverAgent project`);
|
|
71
|
+
return [
|
|
72
|
+
'build-for-testing',
|
|
73
|
+
'-project',
|
|
74
|
+
project,
|
|
75
|
+
'-scheme',
|
|
76
|
+
'WebDriverAgentRunner',
|
|
77
|
+
'-destination',
|
|
78
|
+
'generic/platform=iOS Simulator',
|
|
79
|
+
'-derivedDataPath',
|
|
80
|
+
derivedDataPath,
|
|
81
|
+
'CODE_SIGNING_ALLOWED=NO',
|
|
82
|
+
'GCC_TREAT_WARNINGS_AS_ERRORS=0',
|
|
83
|
+
'COMPILER_INDEX_STORE_ENABLE=NO',
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
/** True when `derivedDataPath` holds a simulator WDA build (`*.xctestrun` + the runner app). */
|
|
87
|
+
export function isWdaSimulatorBuilt(derivedDataPath, list = safeList, exists = existsSync) {
|
|
88
|
+
const products = join(derivedDataPath, 'Build', 'Products');
|
|
89
|
+
const xctestrun = list(products).some((name) => name.endsWith('.xctestrun') && name.includes('iphonesimulator'));
|
|
90
|
+
return xctestrun && exists(join(products, 'Debug-iphonesimulator', 'WebDriverAgentRunner-Runner.app'));
|
|
91
|
+
}
|
|
92
|
+
function safeList(dir) {
|
|
93
|
+
try {
|
|
94
|
+
return readdirSync(dir);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Where this runner's simulator WDA build is (or would be) and whether it exists. */
|
|
101
|
+
export async function wdaStatus(paths) {
|
|
102
|
+
const sources = bundledWda();
|
|
103
|
+
const xcode = await xcodeInfo();
|
|
104
|
+
if (!sources || !xcode)
|
|
105
|
+
return { sources, xcode, dir: null, built: false };
|
|
106
|
+
const dir = wdaCacheDir(paths, sources.driverVersion, xcode.version);
|
|
107
|
+
return { sources, xcode, dir, built: isWdaSimulatorBuilt(dir) };
|
|
108
|
+
}
|