@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,525 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { appiumHomeDir } from '../appium/home.js';
|
|
5
|
+
import { AppiumServer, AppiumUnavailable, bundledAppium, MJPEG_PORTS, SYSTEM_PORTS } from '../appium/server.js';
|
|
6
|
+
import { bundledDrivers } from '../appium/drivers.js';
|
|
7
|
+
import { AppNotAvailable, resolveAppFile } from '../builds/install.js';
|
|
8
|
+
import { androidInstallable } from '../builds/unpack.js';
|
|
9
|
+
import { Adb, resolveAndroidSdk } from '../devices/android/adb.js';
|
|
10
|
+
import { leaseAndroidDevice, listAndroidDevices } from '../devices/android/index.js';
|
|
11
|
+
import { LogcatSink } from '../devices/android/logcat.js';
|
|
12
|
+
import { dismissSystemErrorDialog } from '../devices/android/system-dialog.js';
|
|
13
|
+
import { homePaths, readJsonFile } from '../home.js';
|
|
14
|
+
import { renderTemplate } from '../template.js';
|
|
15
|
+
import { Blocked, LaunchRefused, NotFound, } from './driver.js';
|
|
16
|
+
import { emptyScreen } from './snapshot/format.js';
|
|
17
|
+
import { ANDROID_ACTIONS, androidKeyCode, runAndroidAction } from './webdriver/actions.js';
|
|
18
|
+
import { ANDROID_LOCATORS, candidates, elementIdOf, findAll } from './webdriver/locate.js';
|
|
19
|
+
import { mobile, openSession, uiautomator2Capabilities, webdriverMessage } from './webdriver/session.js';
|
|
20
|
+
import { snapshotFromXml } from './webdriver/xml-tree.js';
|
|
21
|
+
/** The screen recording limit Appium allows (30 min); longer jobs keep the last part only without ffmpeg. */
|
|
22
|
+
const RECORDING_LIMIT_SECS = 1_800;
|
|
23
|
+
export const LOCAL_PATHS_REFUSED = 'the environment installs the app from a file path on the runner, but this runner does not allow local paths (set builds.allow_local_paths to true, or RUNNER_ALLOW_LOCAL_PATHS=true)';
|
|
24
|
+
export const APPIUM_LOG_MAX_LINES = 50;
|
|
25
|
+
export const APPIUM_LOG_MAX_CHARS = 500;
|
|
26
|
+
export const APPIUM_LOG_MAX_BYTES = 64_000;
|
|
27
|
+
/** The last 50 non-empty lines of an Appium log slice, each cut to 500 characters and redacted. */
|
|
28
|
+
export function appiumLogLines(text, redact) {
|
|
29
|
+
const all = text.split(/\r?\n/).filter((line) => line.trim() !== '');
|
|
30
|
+
const kept = all.slice(-APPIUM_LOG_MAX_LINES).map((line) => {
|
|
31
|
+
const clean = redact(line);
|
|
32
|
+
return clean.length > APPIUM_LOG_MAX_CHARS ? `${clean.slice(0, APPIUM_LOG_MAX_CHARS - 1)}…` : clean;
|
|
33
|
+
});
|
|
34
|
+
return { lines: kept, dropped: all.length - kept.length };
|
|
35
|
+
}
|
|
36
|
+
/** `android.sdk_path` from config.json, when the runner config has one. */
|
|
37
|
+
export function configuredSdkPath(paths) {
|
|
38
|
+
try {
|
|
39
|
+
const file = readJsonFile(paths.config);
|
|
40
|
+
return file?.android?.sdk_path?.trim() || null;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function androidSdkFor(env, configured) {
|
|
47
|
+
return resolveAndroidSdk({ env, ...(configured ? { configured } : {}) });
|
|
48
|
+
}
|
|
49
|
+
/** Environment for Appium and its driver: the SDK they need, whatever the service started with. */
|
|
50
|
+
export function appiumEnv(env, sdk) {
|
|
51
|
+
const out = { ...env };
|
|
52
|
+
if (sdk?.root) {
|
|
53
|
+
out.ANDROID_HOME = sdk.root;
|
|
54
|
+
out.ANDROID_SDK_ROOT = sdk.root;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
let processAppium = null;
|
|
59
|
+
/** The process-wide Appium server used when the runner does not pass one in `SharedResources`. */
|
|
60
|
+
export function sharedAppiumServer(paths, sdk) {
|
|
61
|
+
processAppium ??= new AppiumServer({
|
|
62
|
+
home: appiumHomeDir(paths),
|
|
63
|
+
logFile: join(paths.logs, 'appium.log'),
|
|
64
|
+
env: appiumEnv(process.env, sdk),
|
|
65
|
+
});
|
|
66
|
+
return processAppium;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Stops the process-wide Appium server if one was started. Its child process keeps the event
|
|
70
|
+
* loop alive, so a runner that stops without calling this never exits.
|
|
71
|
+
*/
|
|
72
|
+
export async function stopSharedAppiumServer() {
|
|
73
|
+
const server = processAppium;
|
|
74
|
+
processAppium = null;
|
|
75
|
+
await server?.stop();
|
|
76
|
+
}
|
|
77
|
+
/** Labels for the devices a runner can use now (connected and authorised, or bootable AVDs). */
|
|
78
|
+
export function androidDeviceLabels(devices) {
|
|
79
|
+
const labels = new Set();
|
|
80
|
+
for (const device of devices) {
|
|
81
|
+
if (device.platform !== 'android' || device.state !== 'available')
|
|
82
|
+
continue;
|
|
83
|
+
for (const label of device.labels)
|
|
84
|
+
labels.add(label);
|
|
85
|
+
}
|
|
86
|
+
return [...labels];
|
|
87
|
+
}
|
|
88
|
+
export class AndroidAppiumDriver {
|
|
89
|
+
ctx;
|
|
90
|
+
sdk;
|
|
91
|
+
appium;
|
|
92
|
+
platform = 'android';
|
|
93
|
+
capabilities = { actions: ANDROID_ACTIONS, targets: [...ANDROID_LOCATORS], video: 'mp4' };
|
|
94
|
+
client = null;
|
|
95
|
+
lease = null;
|
|
96
|
+
adb = null;
|
|
97
|
+
logcat = null;
|
|
98
|
+
systemPort = null;
|
|
99
|
+
mjpegPort = null;
|
|
100
|
+
releaseBuild = null;
|
|
101
|
+
recording = false;
|
|
102
|
+
videoPath;
|
|
103
|
+
videoStopped = false;
|
|
104
|
+
startedAt = Date.now();
|
|
105
|
+
offsets = new Map();
|
|
106
|
+
/** Appium log file size when each step began, to slice the log for failed steps. */
|
|
107
|
+
appiumLogOffsets = new Map();
|
|
108
|
+
lastRefs = {};
|
|
109
|
+
density = null;
|
|
110
|
+
installedFromFile = false;
|
|
111
|
+
disposed = false;
|
|
112
|
+
/** A step began: look for a system error dialog before its first action. */
|
|
113
|
+
dialogCheckPending = false;
|
|
114
|
+
constructor(ctx, sdk, appium) {
|
|
115
|
+
this.ctx = ctx;
|
|
116
|
+
this.sdk = sdk;
|
|
117
|
+
this.appium = appium;
|
|
118
|
+
}
|
|
119
|
+
get appId() {
|
|
120
|
+
const id = this.ctx.job.app?.id;
|
|
121
|
+
if (!id)
|
|
122
|
+
throw new Blocked('the job names no Android app (set the app in the environment)');
|
|
123
|
+
return id;
|
|
124
|
+
}
|
|
125
|
+
get paths() {
|
|
126
|
+
return this.ctx.paths ?? homePaths(process.env);
|
|
127
|
+
}
|
|
128
|
+
async launch() {
|
|
129
|
+
const app = this.ctx.job.app;
|
|
130
|
+
if (!app?.id)
|
|
131
|
+
throw new Blocked('the job names no Android app (set the app in the environment)');
|
|
132
|
+
// Refuse before booting a device for nothing.
|
|
133
|
+
if (app.install === 'path' && !this.ctx.allowLocalPaths)
|
|
134
|
+
throw new LaunchRefused(LOCAL_PATHS_REFUSED);
|
|
135
|
+
const device = this.ctx.lease?.device ?? (this.ctx.job.device ? { id: this.ctx.job.device.id } : null);
|
|
136
|
+
if (!device)
|
|
137
|
+
throw new Blocked('no Android device was assigned to this job');
|
|
138
|
+
this.adb = new Adb(this.sdk.adb);
|
|
139
|
+
try {
|
|
140
|
+
this.lease = await leaseAndroidDevice(device, {
|
|
141
|
+
sdk: this.sdk,
|
|
142
|
+
headless: this.ctx.headless,
|
|
143
|
+
logFile: join(this.paths.logs, `emulator-${device.id.replace(/[^A-Za-z0-9_.-]/g, '_')}.log`),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
throw new Blocked(`the Android device is not ready: ${webdriverMessage(error)}`);
|
|
148
|
+
}
|
|
149
|
+
const serial = this.lease.serial;
|
|
150
|
+
// A booted AVD shows up as emulator-NNNN: keep it leased under that id too.
|
|
151
|
+
if (serial !== device.id)
|
|
152
|
+
this.ctx.lease?.bind?.(serial);
|
|
153
|
+
for (const warning of this.lease.warnings)
|
|
154
|
+
this.ctx.log.warn('device preparation', { serial, warning });
|
|
155
|
+
// Install the app under test.
|
|
156
|
+
let file = null;
|
|
157
|
+
try {
|
|
158
|
+
const resolved = await resolveAppFile({
|
|
159
|
+
app,
|
|
160
|
+
paths: this.paths,
|
|
161
|
+
allowLocalPaths: this.ctx.allowLocalPaths ?? false,
|
|
162
|
+
getUrl: (buildId) => {
|
|
163
|
+
if (!this.ctx.buildDownloadUrl)
|
|
164
|
+
throw new Blocked('this runner cannot download builds for this job');
|
|
165
|
+
return this.ctx.buildDownloadUrl(buildId);
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
this.releaseBuild = resolved.release;
|
|
169
|
+
file = resolved.path;
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
const message = `the app build is not available: ${webdriverMessage(error)}`;
|
|
173
|
+
throw error instanceof AppNotAvailable ? new LaunchRefused(message) : new Blocked(message);
|
|
174
|
+
}
|
|
175
|
+
if (file) {
|
|
176
|
+
try {
|
|
177
|
+
androidInstallable(file);
|
|
178
|
+
await this.adb.install(serial, file);
|
|
179
|
+
this.installedFromFile = true;
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
throw new Blocked(`the app could not be installed on ${serial}: ${webdriverMessage(error)}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (!(await this.adb.isInstalled(serial, app.id))) {
|
|
186
|
+
throw new Blocked(`${app.id} is not installed on ${serial}; install it or let the environment install a build`);
|
|
187
|
+
}
|
|
188
|
+
// One Appium session per device.
|
|
189
|
+
const logOffset = this.appium.logOffset();
|
|
190
|
+
try {
|
|
191
|
+
const url = await this.appium.ensure();
|
|
192
|
+
this.systemPort = await SYSTEM_PORTS.take();
|
|
193
|
+
this.mjpegPort = await MJPEG_PORTS.take();
|
|
194
|
+
this.client = await openSession(url, uiautomator2Capabilities({
|
|
195
|
+
serial,
|
|
196
|
+
appPackage: app.id,
|
|
197
|
+
appActivity: app.launch_activity,
|
|
198
|
+
systemPort: this.systemPort,
|
|
199
|
+
mjpegServerPort: this.mjpegPort,
|
|
200
|
+
newCommandTimeoutSecs: Math.max(300, Math.ceil((this.ctx.session?.idle_timeout_seconds ?? 0) + 60)),
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
const log = this.appium.logSince(logOffset, 2_000).trim().split('\n').slice(-5).join(' | ');
|
|
205
|
+
const reason = error instanceof AppiumUnavailable ? error.message : webdriverMessage(error);
|
|
206
|
+
throw new Blocked(`Appium could not start a session on ${serial}: ${reason}${log ? ` (appium: ${log})` : ''}`);
|
|
207
|
+
}
|
|
208
|
+
// Per-job isolation: no data from a previous job.
|
|
209
|
+
await mobile(this.client, 'clearApp', { appId: app.id }).catch((error) => {
|
|
210
|
+
throw new Blocked(`the app's data could not be cleared: ${webdriverMessage(error)}`);
|
|
211
|
+
});
|
|
212
|
+
this.logcat = new LogcatSink(this.adb, serial, app.id, this.ctx.redact);
|
|
213
|
+
this.logcat.start();
|
|
214
|
+
if (this.ctx.video !== 'off') {
|
|
215
|
+
try {
|
|
216
|
+
await this.client.startRecordingScreen({
|
|
217
|
+
timeLimit: RECORDING_LIMIT_SECS,
|
|
218
|
+
bitRate: 2_000_000,
|
|
219
|
+
forceRestart: true,
|
|
220
|
+
});
|
|
221
|
+
this.recording = true;
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
this.ctx.log.warn('screen recording could not start', { error: webdriverMessage(error) });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
this.startedAt = Date.now();
|
|
228
|
+
await this.answerSystemDialog('before launch');
|
|
229
|
+
try {
|
|
230
|
+
if (app.launch_activity)
|
|
231
|
+
await runAndroidAction(this.actionContext(), { type: 'launch', activity: app.launch_activity });
|
|
232
|
+
else
|
|
233
|
+
await mobile(this.client, 'activateApp', { appId: app.id });
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
throw new Blocked(`the app could not be launched: ${webdriverMessage(error)}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
requireClient() {
|
|
240
|
+
if (!this.client)
|
|
241
|
+
throw new Blocked('the app is not running');
|
|
242
|
+
return this.client;
|
|
243
|
+
}
|
|
244
|
+
actionContext() {
|
|
245
|
+
return {
|
|
246
|
+
client: this.requireClient(),
|
|
247
|
+
appId: this.appId,
|
|
248
|
+
render: (text) => renderTemplate(text, this.ctx.template),
|
|
249
|
+
defaultTimeoutMs: this.ctx.timeouts.actionMs,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/** A ref's target: its suggestion, falling back to the exact element the snapshot saw. */
|
|
253
|
+
static resolveRefTarget(target) {
|
|
254
|
+
const { locate, ...rest } = target;
|
|
255
|
+
const xpath = typeof locate?.xpath === 'string' ? locate.xpath : null;
|
|
256
|
+
if (!xpath)
|
|
257
|
+
return rest;
|
|
258
|
+
return { ...rest, fallbacks: [...(rest.fallbacks ?? []), { xpath }] };
|
|
259
|
+
}
|
|
260
|
+
async run(action) {
|
|
261
|
+
const prepared = action.target ? { ...action, target: AndroidAppiumDriver.resolveRefTarget(action.target) } : action;
|
|
262
|
+
if (this.dialogCheckPending) {
|
|
263
|
+
this.dialogCheckPending = false;
|
|
264
|
+
await this.answerSystemDialog('before the step');
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
return await runAndroidAction(this.actionContext(), prepared);
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
// A system dialog (System UI or the launcher not responding) may cover the app: answer it and retry once.
|
|
271
|
+
if (error instanceof NotFound && (await this.answerSystemDialog('after a failed locate'))) {
|
|
272
|
+
try {
|
|
273
|
+
return await runAndroidAction(this.actionContext(), prepared);
|
|
274
|
+
}
|
|
275
|
+
catch (retryError) {
|
|
276
|
+
throw this.translate(retryError);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
throw this.translate(error);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Answers a system "isn't responding" / "keeps stopping" dialog that is not about the app under
|
|
284
|
+
* test (Wait, or Close app) and notes it in the step's device log. Dialogs about the app under
|
|
285
|
+
* test are left for the step to fail on. True when a dialog was answered.
|
|
286
|
+
*/
|
|
287
|
+
async answerSystemDialog(when) {
|
|
288
|
+
const client = this.client;
|
|
289
|
+
const adb = this.adb;
|
|
290
|
+
const serial = this.lease?.serial;
|
|
291
|
+
if (!client || !adb || !serial)
|
|
292
|
+
return false;
|
|
293
|
+
const note = await dismissSystemErrorDialog(this.ctx.job.app?.id ?? null, {
|
|
294
|
+
pageSource: () => client.getPageSource(),
|
|
295
|
+
dumpsysWindows: () => adb.shell(serial, ['dumpsys', 'window', 'windows'], 15_000),
|
|
296
|
+
tapId: async (resourceId) => {
|
|
297
|
+
const found = await client.findElements('id', resourceId);
|
|
298
|
+
if (found.length === 0)
|
|
299
|
+
return false;
|
|
300
|
+
await client.elementClick(elementIdOf(found[0]));
|
|
301
|
+
return true;
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
if (!note)
|
|
305
|
+
return false;
|
|
306
|
+
const text = `${note} ${when}`;
|
|
307
|
+
this.logcat?.collector.note(text);
|
|
308
|
+
this.ctx.log.warn(text);
|
|
309
|
+
// Let the dialog close and the app underneath redraw.
|
|
310
|
+
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
/** A lost Appium/UiAutomator2 session is an environment problem, not a failed check. */
|
|
314
|
+
translate(error) {
|
|
315
|
+
const message = webdriverMessage(error);
|
|
316
|
+
if (/invalid session id|session is either terminated|ECONNREFUSED|socket hang up|instrumentation process is not running|UiAutomator2 server/i.test(message)) {
|
|
317
|
+
return new Blocked(`the device connection was lost: ${message}`);
|
|
318
|
+
}
|
|
319
|
+
return error;
|
|
320
|
+
}
|
|
321
|
+
async find(target) {
|
|
322
|
+
const found = await this.findOnce(target);
|
|
323
|
+
if (found.count === 0 && (await this.answerSystemDialog('after a failed find')))
|
|
324
|
+
return this.findOnce(target);
|
|
325
|
+
return found;
|
|
326
|
+
}
|
|
327
|
+
async findOnce(target) {
|
|
328
|
+
const client = this.requireClient();
|
|
329
|
+
const render = (text) => renderTemplate(text, this.ctx.template);
|
|
330
|
+
for (const candidate of candidates(AndroidAppiumDriver.resolveRefTarget(target))) {
|
|
331
|
+
const ids = await findAll(client, candidate, render);
|
|
332
|
+
if (ids.length === 0)
|
|
333
|
+
continue;
|
|
334
|
+
const refs = [];
|
|
335
|
+
for (const id of ids.slice(0, 20)) {
|
|
336
|
+
const rect = await client.getElementRect(id).catch(() => null);
|
|
337
|
+
if (!rect)
|
|
338
|
+
continue;
|
|
339
|
+
const ref = Object.entries(this.lastRefs).find(([, value]) => value.bounds &&
|
|
340
|
+
value.bounds.x === rect.x &&
|
|
341
|
+
value.bounds.y === rect.y &&
|
|
342
|
+
value.bounds.width === rect.width &&
|
|
343
|
+
value.bounds.height === rect.height);
|
|
344
|
+
if (ref && !refs.includes(ref[0]))
|
|
345
|
+
refs.push(ref[0]);
|
|
346
|
+
}
|
|
347
|
+
return { count: ids.length, refs };
|
|
348
|
+
}
|
|
349
|
+
return { count: 0, refs: [] };
|
|
350
|
+
}
|
|
351
|
+
async snapshot(options = {}) {
|
|
352
|
+
const client = this.requireClient();
|
|
353
|
+
const xml = await client.getPageSource();
|
|
354
|
+
const tree = snapshotFromXml(xml, {
|
|
355
|
+
appPackage: this.appId,
|
|
356
|
+
...(options.depth !== undefined ? { depth: options.depth } : {}),
|
|
357
|
+
...(options.includeHidden !== undefined ? { includeHidden: options.includeHidden } : {}),
|
|
358
|
+
...(options.maxChars !== undefined ? { maxChars: options.maxChars } : {}),
|
|
359
|
+
});
|
|
360
|
+
this.lastRefs = tree.refs;
|
|
361
|
+
const screen = await this.screen().catch(() => emptyScreen());
|
|
362
|
+
return {
|
|
363
|
+
platform: 'android',
|
|
364
|
+
taken_at: new Date().toISOString(),
|
|
365
|
+
screen,
|
|
366
|
+
app: { id: this.appId, activity: screen.activity },
|
|
367
|
+
root: tree.root,
|
|
368
|
+
text: tree.text,
|
|
369
|
+
refs: tree.refs,
|
|
370
|
+
element_count: tree.element_count,
|
|
371
|
+
truncated: tree.truncated,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
async screenshot() {
|
|
375
|
+
return Buffer.from(await this.requireClient().takeScreenshot(), 'base64');
|
|
376
|
+
}
|
|
377
|
+
source() {
|
|
378
|
+
return this.requireClient().getPageSource();
|
|
379
|
+
}
|
|
380
|
+
async screen() {
|
|
381
|
+
const client = this.requireClient();
|
|
382
|
+
const [rect, orientation, activity, pkg] = await Promise.all([
|
|
383
|
+
client.getWindowRect().catch(() => null),
|
|
384
|
+
client.getOrientation().catch(() => null),
|
|
385
|
+
mobile(client, 'getCurrentActivity').catch(() => null),
|
|
386
|
+
mobile(client, 'getCurrentPackage').catch(() => null),
|
|
387
|
+
]);
|
|
388
|
+
if (this.density === null) {
|
|
389
|
+
const density = await mobile(client, 'getDisplayDensity').catch(() => null);
|
|
390
|
+
this.density = typeof density === 'number' && density > 0 ? density : 0;
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
width: rect?.width ?? null,
|
|
394
|
+
height: rect?.height ?? null,
|
|
395
|
+
scale: this.density ? Math.round((this.density / 160) * 1000) / 1000 : null,
|
|
396
|
+
orientation: orientation ? (orientation.toLowerCase() === 'landscape' ? 'landscape' : 'portrait') : null,
|
|
397
|
+
activity: typeof activity === 'string' ? activity : null,
|
|
398
|
+
url: null,
|
|
399
|
+
title: null,
|
|
400
|
+
package: typeof pkg === 'string' ? pkg : null,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
async tapAt(x, y) {
|
|
404
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
405
|
+
throw new Error('coordinates must be numbers');
|
|
406
|
+
await mobile(this.requireClient(), 'clickGesture', { x: Math.round(x), y: Math.round(y) });
|
|
407
|
+
}
|
|
408
|
+
async back() {
|
|
409
|
+
await mobile(this.requireClient(), 'pressKey', { keycode: androidKeyCode('back') });
|
|
410
|
+
}
|
|
411
|
+
async home() {
|
|
412
|
+
await mobile(this.requireClient(), 'pressKey', { keycode: androidKeyCode('home') });
|
|
413
|
+
}
|
|
414
|
+
drainLogs() {
|
|
415
|
+
return this.logcat?.collector.drain() ?? [];
|
|
416
|
+
}
|
|
417
|
+
beginStep(index) {
|
|
418
|
+
this.offsets.set(index, Math.round((Date.now() - this.startedAt) / 100) / 10);
|
|
419
|
+
this.appiumLogOffsets.set(index, this.appium.logOffset());
|
|
420
|
+
this.logcat?.collector.begin(index);
|
|
421
|
+
this.dialogCheckPending = true;
|
|
422
|
+
}
|
|
423
|
+
debug(index, result) {
|
|
424
|
+
const captured = this.logcat?.collector.for(index);
|
|
425
|
+
const appium = result === 'failed' || result === 'blocked' ? this.appiumLogFor(index) : null;
|
|
426
|
+
if (!captured && !appium)
|
|
427
|
+
return undefined;
|
|
428
|
+
const offset = this.offsets.get(index);
|
|
429
|
+
const debug = { ...captured, ...(offset !== undefined && this.recording ? { video_offset_secs: offset } : {}) };
|
|
430
|
+
if (appium && appium.lines.length > 0) {
|
|
431
|
+
debug.appium_log = appium.lines;
|
|
432
|
+
if (appium.dropped > 0)
|
|
433
|
+
debug.dropped = { ...debug.dropped, appium_log: appium.dropped };
|
|
434
|
+
}
|
|
435
|
+
return debug;
|
|
436
|
+
}
|
|
437
|
+
/** The Appium log lines written from step `index`'s start to the next step's start (or now). */
|
|
438
|
+
appiumLogFor(index) {
|
|
439
|
+
const start = this.appiumLogOffsets.get(index);
|
|
440
|
+
if (start === undefined)
|
|
441
|
+
return null;
|
|
442
|
+
const later = [...this.appiumLogOffsets.entries()].filter(([i]) => i > index).map(([, offset]) => offset);
|
|
443
|
+
const end = later.length > 0 ? Math.min(...later) : undefined;
|
|
444
|
+
return appiumLogLines(this.appium.logSince(start, APPIUM_LOG_MAX_BYTES, end), this.ctx.redact);
|
|
445
|
+
}
|
|
446
|
+
async stopRecording() {
|
|
447
|
+
if (!this.recording || this.videoStopped || !this.client)
|
|
448
|
+
return this.videoPath;
|
|
449
|
+
this.videoStopped = true;
|
|
450
|
+
try {
|
|
451
|
+
const base64 = await this.client.stopRecordingScreen();
|
|
452
|
+
if (!base64)
|
|
453
|
+
return undefined;
|
|
454
|
+
const dir = mkdtempSync(join(tmpdir(), 'qa-video-'));
|
|
455
|
+
this.videoPath = join(dir, 'video.mp4');
|
|
456
|
+
writeFileSync(this.videoPath, Buffer.from(base64, 'base64'));
|
|
457
|
+
return this.videoPath;
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
this.ctx.log.warn('screen recording could not be saved', { error: webdriverMessage(error) });
|
|
461
|
+
return undefined;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
async dispose() {
|
|
465
|
+
if (this.disposed)
|
|
466
|
+
return;
|
|
467
|
+
this.disposed = true;
|
|
468
|
+
this.logcat?.stop();
|
|
469
|
+
const client = this.client;
|
|
470
|
+
this.client = null;
|
|
471
|
+
const appId = this.ctx.job.app?.id;
|
|
472
|
+
if (client && appId) {
|
|
473
|
+
await mobile(client, 'terminateApp', { appId }).catch(() => undefined);
|
|
474
|
+
}
|
|
475
|
+
if (client)
|
|
476
|
+
await client.deleteSession().catch(() => undefined);
|
|
477
|
+
if (this.adb && this.lease && appId && this.installedFromFile) {
|
|
478
|
+
await this.adb.uninstall(this.lease.serial, appId);
|
|
479
|
+
}
|
|
480
|
+
if (this.systemPort !== null)
|
|
481
|
+
SYSTEM_PORTS.release(this.systemPort);
|
|
482
|
+
if (this.mjpegPort !== null)
|
|
483
|
+
MJPEG_PORTS.release(this.mjpegPort);
|
|
484
|
+
this.releaseBuild?.();
|
|
485
|
+
await this.lease?.release().catch(() => undefined);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
export const androidDriverFactory = {
|
|
489
|
+
platform: 'android',
|
|
490
|
+
async availability(_host) {
|
|
491
|
+
const paths = homePaths(process.env);
|
|
492
|
+
const reasons = [];
|
|
493
|
+
if (!bundledAppium())
|
|
494
|
+
reasons.push('Appium is not installed with this runner (optional dependencies were omitted)');
|
|
495
|
+
else if (bundledDrivers().every((d) => !d.platforms.includes('android'))) {
|
|
496
|
+
reasons.push('the Appium UiAutomator2 driver is not installed with this runner');
|
|
497
|
+
}
|
|
498
|
+
const sdk = androidSdkFor(process.env, configuredSdkPath(paths));
|
|
499
|
+
if (!sdk) {
|
|
500
|
+
reasons.push('the Android SDK was not found (set ANDROID_HOME, or run `leera-qa-runner setup android`)');
|
|
501
|
+
return { ok: false, labels: [], devices: [], reasons };
|
|
502
|
+
}
|
|
503
|
+
let devices = [];
|
|
504
|
+
try {
|
|
505
|
+
devices = await listAndroidDevices(sdk, process.env);
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
reasons.push(`adb could not list devices: ${webdriverMessage(error)}`);
|
|
509
|
+
}
|
|
510
|
+
if (reasons.length > 0)
|
|
511
|
+
return { ok: false, labels: [], devices: [], reasons };
|
|
512
|
+
const usable = devices.filter((device) => device.state === 'available');
|
|
513
|
+
if (usable.length === 0) {
|
|
514
|
+
return { ok: false, labels: [], devices, reasons: ['no Android device is connected and no emulator (AVD) exists'] };
|
|
515
|
+
}
|
|
516
|
+
return { ok: true, labels: androidDeviceLabels(usable), devices, reasons: [] };
|
|
517
|
+
},
|
|
518
|
+
async create(ctx, shared) {
|
|
519
|
+
const paths = ctx.paths ?? homePaths(process.env);
|
|
520
|
+
const sdk = androidSdkFor(process.env, ctx.androidSdkPath ?? configuredSdkPath(paths));
|
|
521
|
+
if (!sdk)
|
|
522
|
+
throw new Blocked('the Android SDK was not found on this runner (set ANDROID_HOME)');
|
|
523
|
+
return new AndroidAppiumDriver(ctx, sdk, shared.appium ?? sharedAppiumServer(paths, sdk));
|
|
524
|
+
},
|
|
525
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
import { renderTemplate } from '../template.js';
|
|
3
|
+
export const POLL_MS = 200;
|
|
4
|
+
/** Polls `check` until it holds or `timeout` passes, then fails with "expected …". */
|
|
5
|
+
export async function expectWithin(timeout, check, expectation) {
|
|
6
|
+
const deadline = Date.now() + timeout;
|
|
7
|
+
for (;;) {
|
|
8
|
+
if (await check().catch(() => false))
|
|
9
|
+
return;
|
|
10
|
+
if (Date.now() >= deadline) {
|
|
11
|
+
throw new Error(`expected ${typeof expectation === 'string' ? expectation : expectation()}`);
|
|
12
|
+
}
|
|
13
|
+
await sleep(POLL_MS);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function requireTarget(action) {
|
|
17
|
+
if (!action.target)
|
|
18
|
+
throw new Error(`'${action.type}' needs a target`);
|
|
19
|
+
return action.target;
|
|
20
|
+
}
|
|
21
|
+
export function render(text, ctx) {
|
|
22
|
+
return renderTemplate(text ?? '', ctx);
|
|
23
|
+
}
|
|
24
|
+
export function matches(actual, expected, mode) {
|
|
25
|
+
return mode === 'equals' ? actual === expected : actual.includes(expected);
|
|
26
|
+
}
|
|
27
|
+
export function verb(mode) {
|
|
28
|
+
return mode === 'equals' ? 'equal' : 'contain';
|
|
29
|
+
}
|
|
30
|
+
export function describe(action) {
|
|
31
|
+
if (action.description)
|
|
32
|
+
return action.description;
|
|
33
|
+
return action.target ? `${action.type} ${describeTarget(action.target)}` : action.type;
|
|
34
|
+
}
|
|
35
|
+
export function describeTarget(target) {
|
|
36
|
+
if (target.role)
|
|
37
|
+
return target.name ? `${target.role} "${target.name}"` : target.role;
|
|
38
|
+
if (target.label)
|
|
39
|
+
return `field labelled "${target.label}"`;
|
|
40
|
+
if (target.placeholder)
|
|
41
|
+
return `field with placeholder "${target.placeholder}"`;
|
|
42
|
+
if (target.text)
|
|
43
|
+
return `text "${target.text}"`;
|
|
44
|
+
if (target.test_id)
|
|
45
|
+
return `test id "${target.test_id}"`;
|
|
46
|
+
return `element "${target.css ?? ''}"`;
|
|
47
|
+
}
|
|
48
|
+
export function screenshotName(index, name) {
|
|
49
|
+
const slug = name
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
52
|
+
.replace(/^-+|-+$/g, '')
|
|
53
|
+
.slice(0, 40) || 'screenshot';
|
|
54
|
+
return `step-${index + 1}-${slug}.png`;
|
|
55
|
+
}
|
|
56
|
+
export function errorMessage(error) {
|
|
57
|
+
return error instanceof Error ? error.message : String(error);
|
|
58
|
+
}
|
|
59
|
+
export function firstLine(error) {
|
|
60
|
+
return (errorMessage(error).split('\n')[0] ?? '').slice(0, 500);
|
|
61
|
+
}
|
|
62
|
+
export function clip(text) {
|
|
63
|
+
return text.length > 120 ? `${text.slice(0, 117)}...` : text;
|
|
64
|
+
}
|