@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,535 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { bundledDrivers } from '../appium/drivers.js';
|
|
5
|
+
import { AppiumUnavailable, bundledAppium, MJPEG_PORTS, PortRange } from '../appium/server.js';
|
|
6
|
+
import { appExecutable, appPlatform, iosBuildDecision, unpackAppZip } from '../builds/ios-app.js';
|
|
7
|
+
import { AppNotAvailable, resolveAppFile } from '../builds/install.js';
|
|
8
|
+
import { leaseIosDevice, listIosDevices, Simctl } from '../devices/ios/index.js';
|
|
9
|
+
import { SimulatorRecorder } from '../devices/ios/record.js';
|
|
10
|
+
import { loadIosSettings } from '../devices/ios/settings.js';
|
|
11
|
+
import { assertBundleId } from '../devices/ios/simctl.js';
|
|
12
|
+
import { parseDeviceSyslogLine, SimulatorLogSink, SyslogCollector } from '../devices/ios/syslog.js';
|
|
13
|
+
import { developerDir, isCommandLineToolsOnly, wdaRealDeviceDir, wdaStatus } from '../devices/ios/wda.js';
|
|
14
|
+
import { homePaths } from '../home.js';
|
|
15
|
+
import { renderTemplate } from '../template.js';
|
|
16
|
+
import { AndroidAppiumDriver, androidSdkFor, APPIUM_LOG_MAX_BYTES, appiumLogLines, configuredSdkPath, LOCAL_PATHS_REFUSED, sharedAppiumServer } from './android-appium.js';
|
|
17
|
+
import { Blocked, LaunchRefused, } from './driver.js';
|
|
18
|
+
import { emptyScreen } from './snapshot/format.js';
|
|
19
|
+
import { IOS_ACTIONS, runIosAction } from './webdriver/ios-actions.js';
|
|
20
|
+
import { IOS_LOCATORS, iosFindAll } from './webdriver/ios-locate.js';
|
|
21
|
+
import { candidates } from './webdriver/locate.js';
|
|
22
|
+
import { mobile, openSession, webdriverMessage, xcuitestCapabilities } from './webdriver/session.js';
|
|
23
|
+
import { iosSnapshotFromXml } from './webdriver/xml-tree-ios.js';
|
|
24
|
+
/** WebDriverAgent ports on this Mac, one per device session. */
|
|
25
|
+
export const WDA_PORTS = new PortRange(8100, 8199);
|
|
26
|
+
const RECORDING_LIMIT_SECS = 1_800;
|
|
27
|
+
export const IOS_NEEDS_MAC = 'iOS jobs need a Mac with Xcode';
|
|
28
|
+
export const TEAM_ID_MISSING = 'real iOS devices need your Apple Developer Team ID to sign WebDriverAgent: run `leera-qa-runner config set ios.team_id <TEAM ID>` (or set RUNNER_IOS_TEAM_ID)';
|
|
29
|
+
/** Labels for the iOS devices a runner can use now (available, or simulators it can boot). */
|
|
30
|
+
export function iosDeviceLabels(devices) {
|
|
31
|
+
const labels = new Set();
|
|
32
|
+
for (const device of devices) {
|
|
33
|
+
if (device.platform !== 'ios' || device.state !== 'available')
|
|
34
|
+
continue;
|
|
35
|
+
for (const label of device.labels)
|
|
36
|
+
labels.add(label);
|
|
37
|
+
}
|
|
38
|
+
return [...labels];
|
|
39
|
+
}
|
|
40
|
+
export class IosAppiumDriver {
|
|
41
|
+
ctx;
|
|
42
|
+
appium;
|
|
43
|
+
simctl;
|
|
44
|
+
platform = 'ios';
|
|
45
|
+
capabilities = { actions: IOS_ACTIONS, targets: [...IOS_LOCATORS], video: 'mp4' };
|
|
46
|
+
client = null;
|
|
47
|
+
lease = null;
|
|
48
|
+
simLog = null;
|
|
49
|
+
realLog = null;
|
|
50
|
+
wdaPort = null;
|
|
51
|
+
mjpegPort = null;
|
|
52
|
+
releaseBuild = null;
|
|
53
|
+
unpacked = null;
|
|
54
|
+
/** The `.app` directory or `.ipa` installed for this job, when the app came from a build. */
|
|
55
|
+
installPath = null;
|
|
56
|
+
recording = false;
|
|
57
|
+
/** Simulators record with simctl (no ffmpeg needed); real devices through Appium. */
|
|
58
|
+
recorder = null;
|
|
59
|
+
videoPath;
|
|
60
|
+
videoStopped = false;
|
|
61
|
+
startedAt = Date.now();
|
|
62
|
+
offsets = new Map();
|
|
63
|
+
appiumLogOffsets = new Map();
|
|
64
|
+
lastRefs = {};
|
|
65
|
+
scale = null;
|
|
66
|
+
disposed = false;
|
|
67
|
+
constructor(ctx, appium, simctl = new Simctl()) {
|
|
68
|
+
this.ctx = ctx;
|
|
69
|
+
this.appium = appium;
|
|
70
|
+
this.simctl = simctl;
|
|
71
|
+
}
|
|
72
|
+
get bundleId() {
|
|
73
|
+
const id = this.ctx.job.app?.id;
|
|
74
|
+
if (!id)
|
|
75
|
+
throw new Blocked('the job names no iOS app (set the bundle id in the environment)');
|
|
76
|
+
return id;
|
|
77
|
+
}
|
|
78
|
+
get paths() {
|
|
79
|
+
return this.ctx.paths ?? homePaths(process.env);
|
|
80
|
+
}
|
|
81
|
+
async launch() {
|
|
82
|
+
const app = this.ctx.job.app;
|
|
83
|
+
if (!app?.id)
|
|
84
|
+
throw new Blocked('the job names no iOS app (set the bundle id in the environment)');
|
|
85
|
+
try {
|
|
86
|
+
assertBundleId(app.id);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
throw new LaunchRefused(webdriverMessage(error));
|
|
90
|
+
}
|
|
91
|
+
if (process.platform !== 'darwin')
|
|
92
|
+
throw new LaunchRefused(IOS_NEEDS_MAC);
|
|
93
|
+
if (app.install === 'path' && !this.ctx.allowLocalPaths)
|
|
94
|
+
throw new LaunchRefused(LOCAL_PATHS_REFUSED);
|
|
95
|
+
const claimed = this.ctx.job.device;
|
|
96
|
+
const device = this.ctx.lease?.device ?? (claimed ? { id: claimed.id, ...(claimed.kind === 'real' ? { kind: 'real' } : {}) } : null);
|
|
97
|
+
if (!device)
|
|
98
|
+
throw new Blocked('no iOS device was assigned to this job');
|
|
99
|
+
const kind = device.kind === 'real' ? 'real' : 'simulator';
|
|
100
|
+
// Refuse a build of the wrong kind before booting anything (contracts §I).
|
|
101
|
+
const namedFile = app.build?.file_name ?? (app.install === 'path' ? app.path : null);
|
|
102
|
+
if (app.install !== 'installed' && namedFile) {
|
|
103
|
+
const decision = iosBuildDecision(namedFile, kind);
|
|
104
|
+
if (!decision.ok)
|
|
105
|
+
throw new LaunchRefused(decision.message);
|
|
106
|
+
}
|
|
107
|
+
const settings = loadIosSettings(process.env, this.paths);
|
|
108
|
+
if (kind === 'real' && !settings.teamId)
|
|
109
|
+
throw new LaunchRefused([TEAM_ID_MISSING, ...settings.problems].join('; '));
|
|
110
|
+
try {
|
|
111
|
+
this.lease = await leaseIosDevice(device, {
|
|
112
|
+
simctl: this.simctl,
|
|
113
|
+
headed: !this.ctx.headless,
|
|
114
|
+
bootTimeoutMs: settings.simulatorBootTimeoutMs,
|
|
115
|
+
log: (message, fields) => this.ctx.log.info(message, fields),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
throw new Blocked(`the iOS device is not ready: ${webdriverMessage(error)}`);
|
|
120
|
+
}
|
|
121
|
+
const udid = this.lease.udid;
|
|
122
|
+
for (const problem of settings.problems)
|
|
123
|
+
this.ctx.log.warn('ios settings', { problem });
|
|
124
|
+
for (const warning of this.lease.warnings)
|
|
125
|
+
this.ctx.log.warn('simulator preparation', { udid, warning });
|
|
126
|
+
// The app under test.
|
|
127
|
+
let file = null;
|
|
128
|
+
try {
|
|
129
|
+
const resolved = await resolveAppFile({
|
|
130
|
+
app,
|
|
131
|
+
paths: this.paths,
|
|
132
|
+
allowLocalPaths: this.ctx.allowLocalPaths ?? false,
|
|
133
|
+
getUrl: (buildId) => {
|
|
134
|
+
if (!this.ctx.buildDownloadUrl)
|
|
135
|
+
throw new Blocked('this runner cannot download builds for this job');
|
|
136
|
+
return this.ctx.buildDownloadUrl(buildId);
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
this.releaseBuild = resolved.release;
|
|
140
|
+
file = resolved.path;
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
const message = `the app build is not available: ${webdriverMessage(error)}`;
|
|
144
|
+
throw error instanceof AppNotAvailable ? new LaunchRefused(message) : new Blocked(message);
|
|
145
|
+
}
|
|
146
|
+
let executable = null;
|
|
147
|
+
if (file) {
|
|
148
|
+
const decision = iosBuildDecision(app.build?.file_name ?? basename(file), kind);
|
|
149
|
+
if (!decision.ok)
|
|
150
|
+
throw new LaunchRefused(decision.message);
|
|
151
|
+
if (decision.kind === 'app_zip') {
|
|
152
|
+
try {
|
|
153
|
+
this.unpacked = unpackAppZip(file);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
throw new LaunchRefused(`the simulator build could not be unpacked: ${webdriverMessage(error)}`);
|
|
157
|
+
}
|
|
158
|
+
if (appPlatform(this.unpacked.app) === 'device') {
|
|
159
|
+
throw new LaunchRefused('this .app was built for devices; simulators need a zipped .app built with -sdk iphonesimulator');
|
|
160
|
+
}
|
|
161
|
+
this.installPath = this.unpacked.app;
|
|
162
|
+
executable = appExecutable(this.unpacked.app);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
this.installPath = file;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// One Appium session per device.
|
|
169
|
+
const wda = await wdaStatus(this.paths);
|
|
170
|
+
if (!wda.dir)
|
|
171
|
+
throw new Blocked('WebDriverAgent cannot be built: Xcode or the XCUITest driver is missing (run `leera-qa-runner setup ios`)');
|
|
172
|
+
const prebuilt = kind === 'simulator' && wda.built;
|
|
173
|
+
if (kind === 'simulator' && !wda.built) {
|
|
174
|
+
this.ctx.log.warn('WebDriverAgent is not prebuilt; Appium builds it now (run `leera-qa-runner setup ios` once to skip this)', { dir: wda.dir });
|
|
175
|
+
}
|
|
176
|
+
const logOffset = this.appium.logOffset();
|
|
177
|
+
const sessionStarted = Date.now();
|
|
178
|
+
try {
|
|
179
|
+
const url = await this.appium.ensure();
|
|
180
|
+
this.wdaPort = await WDA_PORTS.take();
|
|
181
|
+
this.mjpegPort = await MJPEG_PORTS.take();
|
|
182
|
+
this.client = await openSession(url, xcuitestCapabilities({
|
|
183
|
+
udid,
|
|
184
|
+
simulator: kind === 'simulator',
|
|
185
|
+
platformVersion: this.lease.osVersion,
|
|
186
|
+
wdaLocalPort: this.wdaPort,
|
|
187
|
+
mjpegServerPort: this.mjpegPort,
|
|
188
|
+
derivedDataPath: kind === 'simulator' ? wda.dir : wdaRealDeviceDir(wda.dir),
|
|
189
|
+
usePrebuiltWDA: prebuilt,
|
|
190
|
+
newCommandTimeoutSecs: Math.max(300, Math.ceil((this.ctx.session?.idle_timeout_seconds ?? 0) + 60)),
|
|
191
|
+
isHeadless: !this.lease.uiClientRunning,
|
|
192
|
+
simulatorStartupTimeoutMs: settings.simulatorBootTimeoutMs,
|
|
193
|
+
teamId: settings.teamId,
|
|
194
|
+
signingId: settings.signingId,
|
|
195
|
+
wdaBundleId: settings.wdaBundleId,
|
|
196
|
+
}), prebuilt ? 300_000 : 1_200_000);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
const log = this.appium.logSince(logOffset, 2_000).trim().split('\n').slice(-5).join(' | ');
|
|
200
|
+
const reason = error instanceof AppiumUnavailable ? error.message : webdriverMessage(error);
|
|
201
|
+
throw new Blocked(`Appium could not start a session on ${udid}: ${reason}${log ? ` (appium: ${log})` : ''}`);
|
|
202
|
+
}
|
|
203
|
+
this.ctx.log.info('Appium session started', { udid, ms: Date.now() - sessionStarted, headless_simulator: kind === 'simulator' ? !this.lease.uiClientRunning : null });
|
|
204
|
+
// Per-job isolation: stop the app, then a fresh install from the build (or the installed app as it is).
|
|
205
|
+
const client = this.client;
|
|
206
|
+
await mobile(client, 'terminateApp', { bundleId: app.id }).catch(() => undefined);
|
|
207
|
+
if (this.installPath) {
|
|
208
|
+
try {
|
|
209
|
+
await this.reinstall();
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
throw new Blocked(`the app could not be installed on ${udid}: ${webdriverMessage(error)}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const installed = await mobile(client, 'isAppInstalled', { bundleId: app.id }).catch(() => true);
|
|
217
|
+
if (installed === false) {
|
|
218
|
+
throw new Blocked(`${app.id} is not installed on ${udid}; install it or let the environment install a build`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Device log: the simulator's unified log for the app's process; Appium's syslog on real devices.
|
|
222
|
+
executable ??= kind === 'simulator' ? await this.simctl.executableName(udid, app.id) : null;
|
|
223
|
+
if (executable) {
|
|
224
|
+
try {
|
|
225
|
+
if (kind === 'simulator') {
|
|
226
|
+
this.simLog = new SimulatorLogSink(udid, executable, this.ctx.redact);
|
|
227
|
+
this.simLog.start();
|
|
228
|
+
}
|
|
229
|
+
else if (client.getLogs) {
|
|
230
|
+
this.realLog = new SyslogCollector(executable, this.ctx.redact);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
this.ctx.log.warn('the device log cannot be followed', { error: webdriverMessage(error) });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (this.ctx.video !== 'off') {
|
|
238
|
+
try {
|
|
239
|
+
if (kind === 'simulator') {
|
|
240
|
+
const recorder = new SimulatorRecorder(udid);
|
|
241
|
+
this.recorder = recorder;
|
|
242
|
+
await recorder.start();
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
await client.startRecordingScreen({ timeLimit: RECORDING_LIMIT_SECS, videoType: 'h264', forceRestart: true });
|
|
246
|
+
}
|
|
247
|
+
this.recording = true;
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
// Real devices need ffmpeg on this Mac; the job runs without a video.
|
|
251
|
+
this.recorder = null;
|
|
252
|
+
this.ctx.log.warn('screen recording could not start', { error: webdriverMessage(error) });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
this.startedAt = Date.now();
|
|
256
|
+
try {
|
|
257
|
+
await mobile(client, 'launchApp', { bundleId: app.id });
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
throw new Blocked(`the app could not be launched: ${webdriverMessage(error)}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** Uninstalls the app and installs the job's build again. */
|
|
264
|
+
async reinstall() {
|
|
265
|
+
const client = this.requireClient();
|
|
266
|
+
await mobile(client, 'removeApp', { bundleId: this.bundleId }).catch(() => undefined);
|
|
267
|
+
await mobile(client, 'installApp', { app: this.installPath, timeoutMs: 300_000 });
|
|
268
|
+
}
|
|
269
|
+
/** `reset`: a fresh install from the build, or (simulators, installed apps) cleared app data. */
|
|
270
|
+
async resetApp() {
|
|
271
|
+
if (this.installPath)
|
|
272
|
+
return this.reinstall();
|
|
273
|
+
if (this.lease?.kind === 'simulator') {
|
|
274
|
+
await mobile(this.requireClient(), 'clearApp', { bundleId: this.bundleId });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
throw new Blocked('reset needs the app to come from a build on real devices (the environment uses an installed app)');
|
|
278
|
+
}
|
|
279
|
+
requireClient() {
|
|
280
|
+
if (!this.client)
|
|
281
|
+
throw new Blocked('the app is not running');
|
|
282
|
+
return this.client;
|
|
283
|
+
}
|
|
284
|
+
actionContext() {
|
|
285
|
+
return {
|
|
286
|
+
client: this.requireClient(),
|
|
287
|
+
bundleId: this.bundleId,
|
|
288
|
+
render: (text) => renderTemplate(text, this.ctx.template),
|
|
289
|
+
defaultTimeoutMs: this.ctx.timeouts.actionMs,
|
|
290
|
+
resetApp: () => this.resetApp(),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
async run(action) {
|
|
294
|
+
const prepared = action.target ? { ...action, target: AndroidAppiumDriver.resolveRefTarget(action.target) } : action;
|
|
295
|
+
try {
|
|
296
|
+
return await runIosAction(this.actionContext(), prepared);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
throw this.translate(error);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/** A lost Appium/WebDriverAgent session is an environment problem, not a failed check. */
|
|
303
|
+
translate(error) {
|
|
304
|
+
const message = webdriverMessage(error);
|
|
305
|
+
if (/invalid session id|session is either terminated|ECONNREFUSED|socket hang up|WebDriverAgent|xcodebuild failed/i.test(message)) {
|
|
306
|
+
return new Blocked(`the device connection was lost: ${message}`);
|
|
307
|
+
}
|
|
308
|
+
return error;
|
|
309
|
+
}
|
|
310
|
+
async find(target) {
|
|
311
|
+
const client = this.requireClient();
|
|
312
|
+
const render = (text) => renderTemplate(text, this.ctx.template);
|
|
313
|
+
for (const candidate of candidates(AndroidAppiumDriver.resolveRefTarget(target))) {
|
|
314
|
+
const ids = await iosFindAll(client, candidate, render);
|
|
315
|
+
if (ids.length === 0)
|
|
316
|
+
continue;
|
|
317
|
+
const refs = [];
|
|
318
|
+
for (const id of ids.slice(0, 20)) {
|
|
319
|
+
const rect = await client.getElementRect(id).catch(() => null);
|
|
320
|
+
if (!rect)
|
|
321
|
+
continue;
|
|
322
|
+
const ref = Object.entries(this.lastRefs).find(([, value]) => value.bounds &&
|
|
323
|
+
value.bounds.x === Math.round(rect.x) &&
|
|
324
|
+
value.bounds.y === Math.round(rect.y) &&
|
|
325
|
+
value.bounds.width === Math.round(rect.width) &&
|
|
326
|
+
value.bounds.height === Math.round(rect.height));
|
|
327
|
+
if (ref && !refs.includes(ref[0]))
|
|
328
|
+
refs.push(ref[0]);
|
|
329
|
+
}
|
|
330
|
+
return { count: ids.length, refs };
|
|
331
|
+
}
|
|
332
|
+
return { count: 0, refs: [] };
|
|
333
|
+
}
|
|
334
|
+
async snapshot(options = {}) {
|
|
335
|
+
const client = this.requireClient();
|
|
336
|
+
const xml = await client.getPageSource();
|
|
337
|
+
const tree = iosSnapshotFromXml(xml, {
|
|
338
|
+
...(options.depth !== undefined ? { depth: options.depth } : {}),
|
|
339
|
+
...(options.includeHidden !== undefined ? { includeHidden: options.includeHidden } : {}),
|
|
340
|
+
...(options.maxChars !== undefined ? { maxChars: options.maxChars } : {}),
|
|
341
|
+
});
|
|
342
|
+
this.lastRefs = tree.refs;
|
|
343
|
+
const screen = await this.screen().catch(() => emptyScreen());
|
|
344
|
+
return {
|
|
345
|
+
platform: 'ios',
|
|
346
|
+
taken_at: new Date().toISOString(),
|
|
347
|
+
screen,
|
|
348
|
+
app: { id: tree.bundleId ?? this.bundleId, activity: null },
|
|
349
|
+
root: tree.root,
|
|
350
|
+
text: tree.text,
|
|
351
|
+
refs: tree.refs,
|
|
352
|
+
element_count: tree.element_count,
|
|
353
|
+
truncated: tree.truncated,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
async screenshot() {
|
|
357
|
+
return Buffer.from(await this.requireClient().takeScreenshot(), 'base64');
|
|
358
|
+
}
|
|
359
|
+
source() {
|
|
360
|
+
return this.requireClient().getPageSource();
|
|
361
|
+
}
|
|
362
|
+
async screen() {
|
|
363
|
+
const client = this.requireClient();
|
|
364
|
+
const [rect, orientation, active] = await Promise.all([
|
|
365
|
+
client.getWindowRect().catch(() => null),
|
|
366
|
+
client.getOrientation().catch(() => null),
|
|
367
|
+
mobile(client, 'activeAppInfo').catch(() => null),
|
|
368
|
+
]);
|
|
369
|
+
if (this.scale === null) {
|
|
370
|
+
const info = (await mobile(client, 'deviceScreenInfo').catch(() => null));
|
|
371
|
+
this.scale = typeof info?.scale === 'number' && info.scale > 0 ? info.scale : 0;
|
|
372
|
+
}
|
|
373
|
+
const bundle = active?.bundleId;
|
|
374
|
+
return {
|
|
375
|
+
width: rect?.width ?? null,
|
|
376
|
+
height: rect?.height ?? null,
|
|
377
|
+
scale: this.scale || null,
|
|
378
|
+
orientation: orientation ? (orientation.toLowerCase() === 'landscape' ? 'landscape' : 'portrait') : null,
|
|
379
|
+
activity: null,
|
|
380
|
+
url: null,
|
|
381
|
+
title: null,
|
|
382
|
+
package: typeof bundle === 'string' ? bundle : null,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
/** Taps at screen coordinates in points (the snapshot's bounds). */
|
|
386
|
+
async tapAt(x, y) {
|
|
387
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
388
|
+
throw new Error('coordinates must be numbers');
|
|
389
|
+
await mobile(this.requireClient(), 'tap', { x: Math.round(x), y: Math.round(y) });
|
|
390
|
+
}
|
|
391
|
+
async home() {
|
|
392
|
+
await mobile(this.requireClient(), 'pressButton', { name: 'home' });
|
|
393
|
+
}
|
|
394
|
+
/** Real devices: Appium's syslog since the last read, fed to the collector. */
|
|
395
|
+
pollRealLog() {
|
|
396
|
+
const collector = this.realLog;
|
|
397
|
+
const client = this.client;
|
|
398
|
+
if (!collector || !client?.getLogs)
|
|
399
|
+
return;
|
|
400
|
+
void client
|
|
401
|
+
.getLogs('syslog')
|
|
402
|
+
.then((entries) => {
|
|
403
|
+
for (const entry of entries) {
|
|
404
|
+
const message = entry?.message;
|
|
405
|
+
if (typeof message === 'string')
|
|
406
|
+
collector.accept(parseDeviceSyslogLine(message));
|
|
407
|
+
}
|
|
408
|
+
})
|
|
409
|
+
.catch(() => undefined);
|
|
410
|
+
}
|
|
411
|
+
get collector() {
|
|
412
|
+
return this.simLog?.collector ?? this.realLog;
|
|
413
|
+
}
|
|
414
|
+
drainLogs() {
|
|
415
|
+
this.simLog?.checkCrashes();
|
|
416
|
+
this.pollRealLog();
|
|
417
|
+
return this.collector?.drain() ?? [];
|
|
418
|
+
}
|
|
419
|
+
beginStep(index) {
|
|
420
|
+
this.simLog?.checkCrashes();
|
|
421
|
+
this.pollRealLog();
|
|
422
|
+
this.offsets.set(index, Math.round((Date.now() - this.startedAt) / 100) / 10);
|
|
423
|
+
this.appiumLogOffsets.set(index, this.appium.logOffset());
|
|
424
|
+
this.collector?.begin(index);
|
|
425
|
+
}
|
|
426
|
+
debug(index, result) {
|
|
427
|
+
this.simLog?.checkCrashes();
|
|
428
|
+
const captured = this.collector?.for(index);
|
|
429
|
+
const appium = result === 'failed' || result === 'blocked' ? this.appiumLogFor(index) : null;
|
|
430
|
+
if (!captured && !appium)
|
|
431
|
+
return undefined;
|
|
432
|
+
const offset = this.offsets.get(index);
|
|
433
|
+
const debug = { ...captured, ...(offset !== undefined && this.recording ? { video_offset_secs: offset } : {}) };
|
|
434
|
+
if (appium && appium.lines.length > 0) {
|
|
435
|
+
debug.appium_log = appium.lines;
|
|
436
|
+
if (appium.dropped > 0)
|
|
437
|
+
debug.dropped = { ...debug.dropped, appium_log: appium.dropped };
|
|
438
|
+
}
|
|
439
|
+
return debug;
|
|
440
|
+
}
|
|
441
|
+
appiumLogFor(index) {
|
|
442
|
+
const start = this.appiumLogOffsets.get(index);
|
|
443
|
+
if (start === undefined)
|
|
444
|
+
return null;
|
|
445
|
+
const later = [...this.appiumLogOffsets.entries()].filter(([i]) => i > index).map(([, offset]) => offset);
|
|
446
|
+
const end = later.length > 0 ? Math.min(...later) : undefined;
|
|
447
|
+
return appiumLogLines(this.appium.logSince(start, APPIUM_LOG_MAX_BYTES, end), this.ctx.redact);
|
|
448
|
+
}
|
|
449
|
+
async stopRecording() {
|
|
450
|
+
if (!this.recording || this.videoStopped)
|
|
451
|
+
return this.videoPath;
|
|
452
|
+
this.videoStopped = true;
|
|
453
|
+
if (this.recorder) {
|
|
454
|
+
this.videoPath = await this.recorder.stop();
|
|
455
|
+
return this.videoPath;
|
|
456
|
+
}
|
|
457
|
+
if (!this.client)
|
|
458
|
+
return undefined;
|
|
459
|
+
try {
|
|
460
|
+
const base64 = await this.client.stopRecordingScreen();
|
|
461
|
+
if (!base64)
|
|
462
|
+
return undefined;
|
|
463
|
+
const dir = mkdtempSync(join(tmpdir(), 'qa-video-'));
|
|
464
|
+
this.videoPath = join(dir, 'video.mp4');
|
|
465
|
+
writeFileSync(this.videoPath, Buffer.from(base64, 'base64'));
|
|
466
|
+
return this.videoPath;
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
this.ctx.log.warn('screen recording could not be saved', { error: webdriverMessage(error) });
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
async dispose() {
|
|
474
|
+
if (this.disposed)
|
|
475
|
+
return;
|
|
476
|
+
this.disposed = true;
|
|
477
|
+
this.simLog?.stop();
|
|
478
|
+
if (this.recorder && !this.videoStopped) {
|
|
479
|
+
this.videoStopped = true;
|
|
480
|
+
await this.recorder.stop();
|
|
481
|
+
this.recorder.cleanup();
|
|
482
|
+
}
|
|
483
|
+
const client = this.client;
|
|
484
|
+
this.client = null;
|
|
485
|
+
const bundleId = this.ctx.job.app?.id;
|
|
486
|
+
if (client && bundleId) {
|
|
487
|
+
await mobile(client, 'terminateApp', { bundleId }).catch(() => undefined);
|
|
488
|
+
if (this.installPath)
|
|
489
|
+
await mobile(client, 'removeApp', { bundleId }).catch(() => undefined);
|
|
490
|
+
}
|
|
491
|
+
if (client)
|
|
492
|
+
await client.deleteSession().catch(() => undefined);
|
|
493
|
+
if (this.wdaPort !== null)
|
|
494
|
+
WDA_PORTS.release(this.wdaPort);
|
|
495
|
+
if (this.mjpegPort !== null)
|
|
496
|
+
MJPEG_PORTS.release(this.mjpegPort);
|
|
497
|
+
this.releaseBuild?.();
|
|
498
|
+
this.unpacked?.cleanup();
|
|
499
|
+
await this.lease?.release().catch(() => undefined);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
export const iosDriverFactory = {
|
|
503
|
+
platform: 'ios',
|
|
504
|
+
async availability(host) {
|
|
505
|
+
if (host.os !== 'macos')
|
|
506
|
+
return { ok: false, labels: [], devices: [], reasons: [IOS_NEEDS_MAC] };
|
|
507
|
+
const reasons = [];
|
|
508
|
+
if (!bundledAppium())
|
|
509
|
+
reasons.push('Appium is not installed with this runner (optional dependencies were omitted)');
|
|
510
|
+
else if (bundledDrivers().every((d) => !d.platforms.includes('ios')))
|
|
511
|
+
reasons.push('the Appium XCUITest driver is not installed with this runner');
|
|
512
|
+
const dir = await developerDir();
|
|
513
|
+
if (!dir || isCommandLineToolsOnly(dir)) {
|
|
514
|
+
reasons.push('Xcode is not selected (install Xcode from the App Store, then run `sudo xcode-select -s /Applications/Xcode.app`)');
|
|
515
|
+
return { ok: false, labels: [], devices: [], reasons };
|
|
516
|
+
}
|
|
517
|
+
const inventory = await listIosDevices();
|
|
518
|
+
// devicectl missing (old Xcode) is not fatal when simulators are listed.
|
|
519
|
+
if (inventory.devices.length === 0)
|
|
520
|
+
reasons.push(...inventory.errors);
|
|
521
|
+
if (reasons.length > 0)
|
|
522
|
+
return { ok: false, labels: [], devices: [], reasons };
|
|
523
|
+
const usable = inventory.devices.filter((device) => device.state === 'available');
|
|
524
|
+
if (usable.length === 0) {
|
|
525
|
+
return { ok: false, labels: [], devices: inventory.devices, reasons: ['no iOS simulator exists and no iPhone or iPad is ready', ...inventory.notReady] };
|
|
526
|
+
}
|
|
527
|
+
return { ok: true, labels: iosDeviceLabels(usable), devices: inventory.devices, reasons: [] };
|
|
528
|
+
},
|
|
529
|
+
async create(ctx, shared) {
|
|
530
|
+
const paths = ctx.paths ?? homePaths(process.env);
|
|
531
|
+
// The same process-wide server serves Android and iOS; it keeps the Android SDK in its environment.
|
|
532
|
+
const appium = shared.appium ?? sharedAppiumServer(paths, androidSdkFor(process.env, ctx.androidSdkPath ?? configuredSdkPath(paths)));
|
|
533
|
+
return new IosAppiumDriver(ctx, appium);
|
|
534
|
+
},
|
|
535
|
+
};
|