@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.
Files changed (110) hide show
  1. package/LICENSE +64 -0
  2. package/README.md +333 -0
  3. package/dist/src/appium/drivers.js +60 -0
  4. package/dist/src/appium/home.js +60 -0
  5. package/dist/src/appium/server.js +237 -0
  6. package/dist/src/builds/cache.js +133 -0
  7. package/dist/src/builds/cleanup.js +208 -0
  8. package/dist/src/builds/download.js +64 -0
  9. package/dist/src/builds/install.js +75 -0
  10. package/dist/src/builds/ios-app.js +99 -0
  11. package/dist/src/builds/unpack.js +347 -0
  12. package/dist/src/capabilities.js +93 -0
  13. package/dist/src/ci/builds.js +68 -0
  14. package/dist/src/ci/client.js +90 -0
  15. package/dist/src/ci/env.js +118 -0
  16. package/dist/src/ci/junit.js +79 -0
  17. package/dist/src/ci/run.js +320 -0
  18. package/dist/src/ci/summary.js +126 -0
  19. package/dist/src/cli/commands/builds.js +104 -0
  20. package/dist/src/cli/commands/ci.js +167 -0
  21. package/dist/src/cli/commands/config.js +83 -0
  22. package/dist/src/cli/commands/connect.js +70 -0
  23. package/dist/src/cli/commands/doctor.js +57 -0
  24. package/dist/src/cli/commands/service.js +165 -0
  25. package/dist/src/cli/commands/setup.js +193 -0
  26. package/dist/src/cli/commands/start.js +94 -0
  27. package/dist/src/cli/commands/update.js +189 -0
  28. package/dist/src/cli/commands/version.js +28 -0
  29. package/dist/src/cli/main.js +59 -0
  30. package/dist/src/cli/output.js +67 -0
  31. package/dist/src/cli.js +40 -0
  32. package/dist/src/client.js +146 -0
  33. package/dist/src/config.js +260 -0
  34. package/dist/src/debug.js +137 -0
  35. package/dist/src/devices/android/adb.js +251 -0
  36. package/dist/src/devices/android/avd.js +95 -0
  37. package/dist/src/devices/android/emulator.js +153 -0
  38. package/dist/src/devices/android/index.js +133 -0
  39. package/dist/src/devices/android/logcat.js +205 -0
  40. package/dist/src/devices/android/prepare.js +40 -0
  41. package/dist/src/devices/android/setup.js +162 -0
  42. package/dist/src/devices/android/system-dialog.js +78 -0
  43. package/dist/src/devices/desktop.js +145 -0
  44. package/dist/src/devices/ios/devicectl.js +87 -0
  45. package/dist/src/devices/ios/index.js +130 -0
  46. package/dist/src/devices/ios/prepare.js +39 -0
  47. package/dist/src/devices/ios/record.js +122 -0
  48. package/dist/src/devices/ios/settings.js +55 -0
  49. package/dist/src/devices/ios/setup.js +186 -0
  50. package/dist/src/devices/ios/simctl.js +201 -0
  51. package/dist/src/devices/ios/syslog.js +185 -0
  52. package/dist/src/devices/ios/wda.js +108 -0
  53. package/dist/src/devices/macos.js +248 -0
  54. package/dist/src/devices/manager.js +206 -0
  55. package/dist/src/devices/screen-record.js +95 -0
  56. package/dist/src/devices/tauri.js +189 -0
  57. package/dist/src/devices/types.js +6 -0
  58. package/dist/src/devices/windows.js +362 -0
  59. package/dist/src/doctor.js +394 -0
  60. package/dist/src/download.js +39 -0
  61. package/dist/src/drivers/android-appium.js +525 -0
  62. package/dist/src/drivers/common.js +64 -0
  63. package/dist/src/drivers/desktop-appium.js +282 -0
  64. package/dist/src/drivers/driver.js +15 -0
  65. package/dist/src/drivers/electron-playwright.js +566 -0
  66. package/dist/src/drivers/ios-appium.js +535 -0
  67. package/dist/src/drivers/mac2-appium.js +313 -0
  68. package/dist/src/drivers/registry.js +47 -0
  69. package/dist/src/drivers/snapshot/aria.js +351 -0
  70. package/dist/src/drivers/snapshot/format.js +106 -0
  71. package/dist/src/drivers/tauri-webdriver.js +678 -0
  72. package/dist/src/drivers/web-playwright.js +403 -0
  73. package/dist/src/drivers/webdriver/actions.js +328 -0
  74. package/dist/src/drivers/webdriver/desktop-keys.js +141 -0
  75. package/dist/src/drivers/webdriver/dom-locate.js +113 -0
  76. package/dist/src/drivers/webdriver/dom-snapshot.js +376 -0
  77. package/dist/src/drivers/webdriver/dom-tree.js +118 -0
  78. package/dist/src/drivers/webdriver/dom.js +213 -0
  79. package/dist/src/drivers/webdriver/ios-actions.js +299 -0
  80. package/dist/src/drivers/webdriver/ios-locate.js +121 -0
  81. package/dist/src/drivers/webdriver/locate.js +136 -0
  82. package/dist/src/drivers/webdriver/native-actions.js +175 -0
  83. package/dist/src/drivers/webdriver/native-locate.js +273 -0
  84. package/dist/src/drivers/webdriver/session.js +105 -0
  85. package/dist/src/drivers/webdriver/xml-tree-desktop.js +425 -0
  86. package/dist/src/drivers/webdriver/xml-tree-ios.js +314 -0
  87. package/dist/src/drivers/webdriver/xml-tree.js +393 -0
  88. package/dist/src/drivers/windows-appium.js +319 -0
  89. package/dist/src/errors.js +21 -0
  90. package/dist/src/executor.js +189 -0
  91. package/dist/src/home.js +76 -0
  92. package/dist/src/index.js +22 -0
  93. package/dist/src/log.js +69 -0
  94. package/dist/src/runner.js +498 -0
  95. package/dist/src/service/index.js +64 -0
  96. package/dist/src/service/launchd.js +85 -0
  97. package/dist/src/service/names.js +2 -0
  98. package/dist/src/service/schtasks.js +128 -0
  99. package/dist/src/service/systemd.js +65 -0
  100. package/dist/src/session/commands.js +165 -0
  101. package/dist/src/session/execute.js +232 -0
  102. package/dist/src/session/loop.js +148 -0
  103. package/dist/src/template.js +62 -0
  104. package/dist/src/types.js +7 -0
  105. package/dist/src/update/apply.js +223 -0
  106. package/dist/src/update/check.js +59 -0
  107. package/dist/src/update/manifest.js +93 -0
  108. package/dist/src/update/verify.js +65 -0
  109. package/dist/src/version.js +42 -0
  110. package/package.json +44 -0
@@ -0,0 +1,133 @@
1
+ import { Adb, isEmulatorSerial, resolveAndroidSdk } from './adb.js';
2
+ import { androidVersionForApi, listAvds, readAvdInfo } from './avd.js';
3
+ import { bootAvd, shutdownEmulator } from './emulator.js';
4
+ import { prepareDevice } from './prepare.js';
5
+ export { Adb, resolveAndroidSdk } from './adb.js';
6
+ export const AVD_PREFIX = 'avd:';
7
+ export function androidLabels(kind) {
8
+ return kind === 'real' ? ['android', 'android-real'] : ['android', 'android-emulator'];
9
+ }
10
+ function stateOf(line) {
11
+ if (line.state === 'device')
12
+ return 'available';
13
+ if (line.state === 'unauthorized')
14
+ return 'unauthorized';
15
+ return 'offline';
16
+ }
17
+ /** Maps what adb reports to the runner's device shape. */
18
+ export function deviceFromAdb(line, props, avdName) {
19
+ const emulator = isEmulatorSerial(line.serial) || props['ro.kernel.qemu'] === '1' || props['ro.boot.qemu'] === '1';
20
+ const kind = emulator ? 'emulator' : 'real';
21
+ const model = props['ro.product.model'] || line.fields.model?.replace(/_/g, ' ') || null;
22
+ return {
23
+ id: line.serial,
24
+ platform: 'android',
25
+ kind,
26
+ name: avdName ?? model ?? line.serial,
27
+ os_version: props['ro.build.version.release'] || null,
28
+ state: stateOf(line),
29
+ labels: androidLabels(kind),
30
+ can_boot: false,
31
+ };
32
+ }
33
+ export function deviceFromAvd(name, osVersion) {
34
+ return {
35
+ id: `${AVD_PREFIX}${name}`,
36
+ platform: 'android',
37
+ kind: 'emulator',
38
+ name,
39
+ os_version: osVersion,
40
+ state: 'available',
41
+ labels: androidLabels('emulator'),
42
+ can_boot: true,
43
+ };
44
+ }
45
+ export function sdkFor(env, configured) {
46
+ const lookup = { env, ...(configured ? { configured } : {}) };
47
+ return resolveAndroidSdk(lookup);
48
+ }
49
+ /**
50
+ * Connected devices (`adb devices -l` + `getprop`) plus every AVD that is not running,
51
+ * advertised as `avd:<name>` with `can_boot: true`.
52
+ */
53
+ export async function listAndroidDevices(sdk, env = process.env) {
54
+ const adb = new Adb(sdk.adb);
55
+ const devices = [];
56
+ const runningAvds = new Set();
57
+ for (const line of await adb.devices()) {
58
+ let props = {};
59
+ let avd = null;
60
+ if (line.state === 'device') {
61
+ props = await adb.getprops(line.serial).catch(() => ({}));
62
+ if (isEmulatorSerial(line.serial))
63
+ avd = await adb.avdName(line.serial);
64
+ }
65
+ if (avd)
66
+ runningAvds.add(avd);
67
+ devices.push(deviceFromAdb(line, props, avd));
68
+ }
69
+ if (sdk.emulator) {
70
+ const names = await listAvds(sdk.emulator).catch(() => []);
71
+ for (const name of names) {
72
+ if (runningAvds.has(name))
73
+ continue;
74
+ devices.push(deviceFromAvd(name, androidVersionForApi(readAvdInfo(name, env).apiLevel)));
75
+ }
76
+ }
77
+ return devices;
78
+ }
79
+ /** Boots `avd:<name>` on demand (or checks a connected device is usable), then prepares it once per boot. */
80
+ export async function leaseAndroidDevice(device, options) {
81
+ const adb = new Adb(options.sdk.adb);
82
+ let serial;
83
+ let booted = false;
84
+ if (device.id.startsWith(AVD_PREFIX)) {
85
+ if (!options.sdk.emulator)
86
+ throw new Error('the Android emulator is not installed: run `leera-qa-runner setup android`');
87
+ const name = device.id.slice(AVD_PREFIX.length);
88
+ const before = (await adb.devices().catch(() => [])).map((d) => d.serial);
89
+ serial = await bootAvd(options.sdk.emulator, adb, name, {
90
+ headless: options.headless ?? true,
91
+ ...(options.logFile ? { logFile: options.logFile } : {}),
92
+ ...(options.bootTimeoutMs ? { timeoutMs: options.bootTimeoutMs } : {}),
93
+ ...(options.env ? { env: options.env } : {}),
94
+ });
95
+ booted = !before.includes(serial);
96
+ }
97
+ else {
98
+ serial = device.id;
99
+ const line = (await adb.devices()).find((d) => d.serial === serial);
100
+ if (!line)
101
+ throw new Error(`the Android device ${serial} is not connected`);
102
+ if (line.state === 'unauthorized') {
103
+ throw new Error(`the Android device ${serial} has not authorised this computer: accept the USB debugging prompt on the phone`);
104
+ }
105
+ if (line.state !== 'device')
106
+ throw new Error(`the Android device ${serial} is ${line.state}`);
107
+ }
108
+ const { errors } = await prepareDevice(adb, serial);
109
+ const full = {
110
+ platform: 'android',
111
+ kind: 'emulator',
112
+ name: device.name ?? serial,
113
+ os_version: device.os_version ?? null,
114
+ state: 'available',
115
+ labels: device.labels ?? androidLabels(device.kind ?? 'emulator'),
116
+ can_boot: device.can_boot ?? false,
117
+ ...device,
118
+ };
119
+ let released = false;
120
+ return {
121
+ serial,
122
+ device: full,
123
+ booted,
124
+ warnings: errors,
125
+ async release() {
126
+ if (released)
127
+ return;
128
+ released = true;
129
+ if (booted && options.shutdownOnRelease)
130
+ await shutdownEmulator(adb, serial);
131
+ },
132
+ };
133
+ }
@@ -0,0 +1,205 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+ import { assertPackage, SERIAL_RE } from './adb.js';
4
+ /** Parses one `logcat -v threadtime` line: `09-17 00:12:01.123 1234 1250 E Tag : message`. */
5
+ export function parseLogcatLine(line) {
6
+ const match = /^(\d\d-\d\d \d\d:\d\d:\d\d\.\d{3})\s+(\d+)\s+(\d+)\s+([VDIWEFA])\s+(.*?)\s*:\s(.*)$/.exec(line);
7
+ if (!match)
8
+ return null;
9
+ return {
10
+ time: match[1],
11
+ pid: Number(match[2]),
12
+ tid: Number(match[3]),
13
+ level: match[4],
14
+ tag: match[5],
15
+ message: match[6],
16
+ };
17
+ }
18
+ /** `Start proc 4321:com.acme.app/u0a190 for …` → 4321 when it is the app's process. */
19
+ export function startedPid(line, pkg) {
20
+ const match = /Start proc (\d+):([^/\s]+)/.exec(line.message);
21
+ if (!match)
22
+ return null;
23
+ const name = match[2];
24
+ return name === pkg || name.startsWith(`${pkg}:`) ? Number(match[1]) : null;
25
+ }
26
+ export function formatLogcat(line) {
27
+ return `${line.level}/${line.tag}: ${line.message}`;
28
+ }
29
+ export const LOGCAT_LIMITS = { linesPerStep: 200, crashesPerStep: 10, lineChars: 500, crashChars: 8_000, drain: 50 };
30
+ /**
31
+ * Keeps the app's warning and error lines (by pid) and crash reports (`FATAL EXCEPTION`,
32
+ * `ANR in`) from a logcat stream, filed under the step that was running.
33
+ */
34
+ export class LogcatCollector {
35
+ pkg;
36
+ redact;
37
+ steps = new Map();
38
+ current = null;
39
+ pids = new Set();
40
+ crash = null;
41
+ pending = [];
42
+ constructor(pkg, redact = (t) => t) {
43
+ this.pkg = pkg;
44
+ this.redact = redact;
45
+ }
46
+ setPids(pids) {
47
+ for (const pid of pids)
48
+ this.pids.add(pid);
49
+ }
50
+ begin(index) {
51
+ this.flushCrash();
52
+ this.current = index;
53
+ }
54
+ /** A line from the runner itself (e.g. a system dialog it answered) for the current step. */
55
+ note(text) {
56
+ const line = this.redact(text).slice(0, LOGCAT_LIMITS.lineChars);
57
+ this.pending.push(line);
58
+ if (this.current === null)
59
+ return;
60
+ const debug = this.entry(this.current);
61
+ const list = (debug.device_log ??= []);
62
+ if (list.length < LOGCAT_LIMITS.linesPerStep)
63
+ list.push(line);
64
+ else {
65
+ debug.dropped ??= {};
66
+ debug.dropped.device_log = (debug.dropped.device_log ?? 0) + 1;
67
+ }
68
+ }
69
+ /** Lines seen since the last call, for session results. */
70
+ drain() {
71
+ const out = this.pending;
72
+ this.pending = [];
73
+ return out.slice(-LOGCAT_LIMITS.drain);
74
+ }
75
+ for(index) {
76
+ this.flushCrash();
77
+ const debug = this.steps.get(index);
78
+ if (!debug || (!debug.device_log?.length && !debug.crashes?.length))
79
+ return undefined;
80
+ return debug;
81
+ }
82
+ accept(raw) {
83
+ const line = parseLogcatLine(raw);
84
+ if (!line)
85
+ return;
86
+ const started = startedPid(line, this.pkg);
87
+ if (started !== null)
88
+ this.pids.add(started);
89
+ // Crash blocks: AndroidRuntime lines of one pid, starting at FATAL EXCEPTION.
90
+ if (line.tag === 'AndroidRuntime' && line.level === 'E') {
91
+ if (line.message.startsWith('FATAL EXCEPTION')) {
92
+ this.flushCrash();
93
+ this.crash = { pid: line.pid, lines: [formatLogcat(line)], step: this.current };
94
+ return;
95
+ }
96
+ if (this.crash && line.pid === this.crash.pid) {
97
+ this.crash.lines.push(line.message);
98
+ const processLine = /^Process: ([^,]+), PID: (\d+)/.exec(line.message);
99
+ if (processLine && processLine[1] !== this.pkg && !processLine[1].startsWith(`${this.pkg}:`))
100
+ this.crash.step = null; // another app
101
+ return;
102
+ }
103
+ }
104
+ else if (this.crash) {
105
+ this.flushCrash();
106
+ }
107
+ if (line.tag === 'ActivityManager' && line.level === 'E' && line.message.startsWith(`ANR in ${this.pkg}`)) {
108
+ this.addCrash(this.current, formatLogcat(line));
109
+ return;
110
+ }
111
+ if (!this.pids.has(line.pid))
112
+ return;
113
+ if (line.level !== 'W' && line.level !== 'E' && line.level !== 'F' && line.level !== 'A')
114
+ return;
115
+ const text = this.redact(formatLogcat(line)).slice(0, LOGCAT_LIMITS.lineChars);
116
+ this.pending.push(text);
117
+ if (this.pending.length > LOGCAT_LIMITS.drain * 2)
118
+ this.pending.splice(0, this.pending.length - LOGCAT_LIMITS.drain);
119
+ if (this.current === null)
120
+ return;
121
+ const debug = this.entry(this.current);
122
+ const list = (debug.device_log ??= []);
123
+ if (list.length < LOGCAT_LIMITS.linesPerStep)
124
+ list.push(text);
125
+ else {
126
+ debug.dropped ??= {};
127
+ debug.dropped.device_log = (debug.dropped.device_log ?? 0) + 1;
128
+ }
129
+ }
130
+ flushCrash() {
131
+ const crash = this.crash;
132
+ this.crash = null;
133
+ if (!crash || crash.step === null)
134
+ return;
135
+ this.addCrash(crash.step, crash.lines.join('\n'));
136
+ }
137
+ addCrash(step, text) {
138
+ const redacted = this.redact(text).slice(0, LOGCAT_LIMITS.crashChars);
139
+ this.pending.push(redacted.split('\n').slice(0, 3).join(' | ').slice(0, LOGCAT_LIMITS.lineChars));
140
+ if (step === null)
141
+ return;
142
+ const debug = this.entry(step);
143
+ const list = (debug.crashes ??= []);
144
+ if (list.length < LOGCAT_LIMITS.crashesPerStep)
145
+ list.push(redacted);
146
+ else {
147
+ debug.dropped ??= {};
148
+ debug.dropped.crashes = (debug.dropped.crashes ?? 0) + 1;
149
+ }
150
+ }
151
+ entry(index) {
152
+ let debug = this.steps.get(index);
153
+ if (!debug) {
154
+ debug = {};
155
+ this.steps.set(index, debug);
156
+ }
157
+ return debug;
158
+ }
159
+ }
160
+ /** The fixed argv that follows the device log from now on. */
161
+ export function logcatArgv(serial) {
162
+ if (!SERIAL_RE.test(serial))
163
+ throw new Error(`'${serial}' is not a valid adb serial`);
164
+ return ['-s', serial, 'logcat', '-v', 'threadtime', '-T', '1'];
165
+ }
166
+ /** Runs `adb logcat` for one job and feeds a collector; refreshes the app's pids every 2 s. */
167
+ export class LogcatSink {
168
+ adb;
169
+ serial;
170
+ child = null;
171
+ timer = null;
172
+ collector;
173
+ constructor(adb, serial, pkg, redact) {
174
+ this.adb = adb;
175
+ this.serial = serial;
176
+ assertPackage(pkg);
177
+ this.collector = new LogcatCollector(pkg, redact);
178
+ this.pkg = pkg;
179
+ }
180
+ pkg;
181
+ start() {
182
+ if (this.child)
183
+ return;
184
+ const child = spawn(this.adb.path, logcatArgv(this.serial), { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
185
+ child.on('error', () => undefined);
186
+ this.child = child;
187
+ if (child.stdout) {
188
+ const lines = createInterface({ input: child.stdout });
189
+ lines.on('line', (line) => this.collector.accept(line));
190
+ }
191
+ const refresh = () => {
192
+ void this.adb.pidof(this.serial, this.pkg).then((pids) => this.collector.setPids(pids));
193
+ };
194
+ refresh();
195
+ this.timer = setInterval(refresh, 2_000);
196
+ this.timer.unref();
197
+ }
198
+ stop() {
199
+ if (this.timer)
200
+ clearInterval(this.timer);
201
+ this.timer = null;
202
+ this.child?.kill();
203
+ this.child = null;
204
+ }
205
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shell argv run once per device boot: animations off (steadier timing, smaller videos),
3
+ * screen kept on while charging/USB, keyguard dismissed. Fixed strings only.
4
+ */
5
+ export const PREPARE_COMMANDS = [
6
+ ['settings', 'put', 'global', 'window_animation_scale', '0'],
7
+ ['settings', 'put', 'global', 'transition_animation_scale', '0'],
8
+ ['settings', 'put', 'global', 'animator_duration_scale', '0'],
9
+ ['svc', 'power', 'stayon', 'true'],
10
+ ['input', 'keyevent', 'KEYCODE_WAKEUP'],
11
+ ['wm', 'dismiss-keyguard'],
12
+ ];
13
+ /** `serial:boot_id` of devices already prepared by this process. */
14
+ const prepared = new Set();
15
+ async function bootId(adb, serial) {
16
+ const id = (await adb.shell(serial, ['cat', '/proc/sys/kernel/random/boot_id'], 10_000).catch(() => '')).trim();
17
+ return id || 'unknown';
18
+ }
19
+ /** Prepares a device unless this process already did so since its last boot. Failures are reported, not thrown. */
20
+ export async function prepareDevice(adb, serial) {
21
+ const key = `${serial}:${await bootId(adb, serial)}`;
22
+ if (prepared.has(key))
23
+ return { prepared: false, errors: [] };
24
+ const errors = [];
25
+ for (const argv of PREPARE_COMMANDS) {
26
+ try {
27
+ await adb.shell(serial, [...argv], 15_000);
28
+ }
29
+ catch (error) {
30
+ // Real devices may refuse some settings (e.g. without WRITE_SECURE_SETTINGS); keep going.
31
+ errors.push(`${argv.join(' ')}: ${error instanceof Error ? error.message : String(error)}`);
32
+ }
33
+ }
34
+ prepared.add(key);
35
+ return { prepared: true, errors };
36
+ }
37
+ /** Forgets which devices were prepared (tests). */
38
+ export function resetPrepared() {
39
+ prepared.clear();
40
+ }
@@ -0,0 +1,162 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { join } from 'node:path';
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, readJsonFile } from '../../home.js';
8
+ import { resolveAndroidSdk, resolveSdkManager, runTool } from './adb.js';
9
+ import { AVD_NAME_RE, listAvds } from './avd.js';
10
+ import { listAndroidDevices } from './index.js';
11
+ export const IMAGE_RE = /^system-images;android-\d{2,3};[a-z_]+;(arm64-v8a|x86_64|x86)$/;
12
+ export const SDK_PLATFORM = 35;
13
+ export const BUILD_TOOLS = '35.0.0';
14
+ /** The system image `--install-sdk` installs by default for this CPU. */
15
+ export function defaultSystemImage(arch = process.arch) {
16
+ return `system-images;android-${SDK_PLATFORM};google_apis;${arch === 'arm64' ? 'arm64-v8a' : 'x86_64'}`;
17
+ }
18
+ /** The fixed argv for sdkmanager's package install. */
19
+ export function sdkmanagerInstallArgv(sdkRoot, image) {
20
+ if (!IMAGE_RE.test(image))
21
+ throw new Error(`'${image}' is not a system image id such as ${defaultSystemImage()}`);
22
+ return [`--sdk_root=${sdkRoot}`, 'platform-tools', 'emulator', `build-tools;${BUILD_TOOLS}`, `platforms;android-${SDK_PLATFORM}`, image];
23
+ }
24
+ /** The fixed argv that creates an AVD from a system image. */
25
+ export function avdCreateArgv(name, image) {
26
+ if (!AVD_NAME_RE.test(name))
27
+ throw new Error(`'${name}' is not a valid emulator name (letters, digits, . _ -)`);
28
+ if (!IMAGE_RE.test(image))
29
+ throw new Error(`'${image}' is not a system image id`);
30
+ return ['create', 'avd', '--name', name, '--package', image, '--device', 'pixel_8'];
31
+ }
32
+ /** The fixed argv that lists Appium's installed drivers as JSON. */
33
+ export function appiumDriverListArgv(node, appiumDir) {
34
+ return [node, join(appiumDir, 'index.js'), 'driver', 'list', '--installed', '--json'];
35
+ }
36
+ function spawnInherit(file, args, env, input) {
37
+ return new Promise((resolve) => {
38
+ const child = spawn(file, args, { stdio: [input === undefined ? 'inherit' : 'pipe', 'inherit', 'inherit'], env, windowsHide: true });
39
+ if (input !== undefined)
40
+ child.stdin?.end(input);
41
+ child.on('error', () => resolve(1));
42
+ child.on('close', (code) => resolve(code ?? 1));
43
+ });
44
+ }
45
+ /**
46
+ * `setup android`: seeds Appium's home with the bundled UiAutomator2 driver and checks that
47
+ * Appium sees it, finds the SDK (optionally installing packages with the SDK's own sdkmanager
48
+ * and creating an emulator), then lists devices.
49
+ */
50
+ export async function setupAndroid(io, paths, options) {
51
+ ensureHome(paths);
52
+ let ok = true;
53
+ // 1. Appium and its driver.
54
+ const appium = bundledAppium();
55
+ const drivers = bundledDrivers(io.platform).filter((d) => d.platforms.includes('android'));
56
+ if (!appium || drivers.length === 0) {
57
+ io.stderr('Appium and its UiAutomator2 driver are not installed with this runner. Install the runner with its installer, or with npm without --omit=optional.');
58
+ return ExitCode.environmentNotReady;
59
+ }
60
+ const home = appiumHomeDir(paths);
61
+ const seeded = seedAppiumHome(home, appium, drivers);
62
+ io.stdout(`Appium ${appium.version} home: ${home}${seeded.changed ? ' (updated)' : ''}`);
63
+ try {
64
+ const argv = appiumDriverListArgv(process.execPath, appium.dir);
65
+ const { stdout } = await runTool(argv[0], argv.slice(1), { timeoutMs: 120_000, env: { ...io.env, APPIUM_HOME: home } });
66
+ const listed = JSON.parse(stdout.slice(stdout.indexOf('{')));
67
+ for (const driver of drivers) {
68
+ const found = listed[driver.name];
69
+ if (found)
70
+ io.stdout(` driver ${driver.name} ${found.version ?? driver.version}: installed`);
71
+ else {
72
+ ok = false;
73
+ io.stderr(` driver ${driver.name}: Appium does not see it in ${home}`);
74
+ }
75
+ }
76
+ }
77
+ catch (error) {
78
+ ok = false;
79
+ io.stderr(`Appium could not list its drivers: ${messageOf(error)}`);
80
+ }
81
+ // 2. The Android SDK.
82
+ let configured = null;
83
+ try {
84
+ configured = readJsonFile(paths.config)?.android?.sdk_path?.trim() || null;
85
+ }
86
+ catch {
87
+ configured = null;
88
+ }
89
+ let sdk = resolveAndroidSdk({ env: io.env, platform: io.platform, ...(configured ? { configured } : {}) });
90
+ if (options.installSdk) {
91
+ const image = options.image ?? defaultSystemImage();
92
+ // A folder with only cmdline-tools/latest is enough: sdkmanager installs platform-tools and the rest.
93
+ const target = sdk?.root && sdk.sdkmanager
94
+ ? { root: sdk.root, sdkmanager: sdk.sdkmanager }
95
+ : resolveSdkManager({ env: io.env, platform: io.platform, ...(configured ? { configured } : {}) });
96
+ if (!target) {
97
+ io.stderr([
98
+ 'Installing SDK packages needs the Android command-line tools first:',
99
+ ' 1. Download "Command line tools only" from https://developer.android.com/studio#command-line-tools-only',
100
+ ' 2. Unpack it to <sdk>/cmdline-tools/latest (for example ~/Library/Android/sdk on macOS, ~/Android/Sdk on Linux)',
101
+ ' 3. Set ANDROID_HOME to <sdk> and run this command again',
102
+ ].join('\n'));
103
+ return ExitCode.environmentNotReady;
104
+ }
105
+ let argv;
106
+ try {
107
+ argv = sdkmanagerInstallArgv(target.root, image);
108
+ }
109
+ catch (error) {
110
+ io.stderr(messageOf(error));
111
+ return ExitCode.usage;
112
+ }
113
+ io.stdout(`Installing ${argv.slice(1).join(', ')} with sdkmanager (it asks you to review and accept the SDK licences) ...`);
114
+ if ((await spawnInherit(target.sdkmanager, argv, io.env)) !== 0) {
115
+ io.stderr('sdkmanager did not finish; see its output above.');
116
+ return ExitCode.error;
117
+ }
118
+ sdk = resolveAndroidSdk({ env: { ...io.env, ANDROID_HOME: target.root }, platform: io.platform });
119
+ const avds = sdk?.emulator ? await listAvds(sdk.emulator).catch(() => []) : [];
120
+ if (sdk?.avdmanager && avds.length === 0) {
121
+ const name = options.avd ?? 'leera_pixel';
122
+ try {
123
+ const create = avdCreateArgv(name, image);
124
+ io.stdout(`Creating emulator ${name} ...`);
125
+ // avdmanager asks whether to create a custom hardware profile; the answer is always no.
126
+ if ((await spawnInherit(sdk.avdmanager, create, { ...io.env, ANDROID_HOME: sdk.root ?? '' }, 'no\n')) !== 0) {
127
+ ok = false;
128
+ io.stderr('avdmanager could not create the emulator; see its output above.');
129
+ }
130
+ }
131
+ catch (error) {
132
+ io.stderr(messageOf(error));
133
+ return ExitCode.usage;
134
+ }
135
+ }
136
+ }
137
+ if (!sdk) {
138
+ io.stderr('The Android SDK was not found. Install Android Studio (or the command-line tools), set ANDROID_HOME, or set "android": {"sdk_path": "<dir>"} in the runner config.json.');
139
+ return ExitCode.environmentNotReady;
140
+ }
141
+ io.stdout(`Android SDK: ${sdk.root} (from ${sdk.source})`);
142
+ if (!sdk.emulator)
143
+ io.stdout(' The emulator is not installed; only USB devices can run jobs. Use --install-sdk to add it.');
144
+ // 3. Devices.
145
+ try {
146
+ const devices = await listAndroidDevices(sdk, io.env);
147
+ if (devices.length === 0)
148
+ io.stdout('Devices: none (connect a phone with USB debugging, or create an emulator with --install-sdk)');
149
+ else {
150
+ io.stdout('Devices:');
151
+ for (const device of devices) {
152
+ io.stdout(` ${device.id} ${device.name} ${device.kind}${device.os_version ? ` Android ${device.os_version}` : ''} ${device.state}${device.can_boot ? ' (booted when a job needs it)' : ''}`);
153
+ }
154
+ }
155
+ }
156
+ catch (error) {
157
+ ok = false;
158
+ io.stderr(`adb could not list devices: ${messageOf(error)}`);
159
+ }
160
+ io.stdout(ok ? 'Android is set up. Check everything with `leera-qa-runner doctor --platform android`.' : 'Android setup is incomplete; see the messages above.');
161
+ return ok ? ExitCode.ok : ExitCode.environmentNotReady;
162
+ }
@@ -0,0 +1,78 @@
1
+ import { parsePageSource } from '../../drivers/webdriver/xml-tree.js';
2
+ /**
3
+ * System "isn't responding" / "keeps stopping" dialogs on Android.
4
+ *
5
+ * On slow devices (software-rendered emulators right after boot above all) the system shows an
6
+ * ANR or crash dialog for System UI or the launcher over the app under test, and every locate
7
+ * then fails. Such a dialog is not the app's fault, so the runner answers it with Wait (or
8
+ * Close app) and carries on. A dialog about the app under test is a real failure and is never
9
+ * touched.
10
+ */
11
+ const TITLE_ID = 'android:id/alertTitle';
12
+ const WAIT_ID = 'android:id/aerr_wait';
13
+ const CLOSE_ID = 'android:id/aerr_close';
14
+ /** Packages that draw these dialogs; a title with this text from any other package is the app's own UI. */
15
+ const SYSTEM_PACKAGES = new Set(['android', 'com.android.systemui']);
16
+ const TITLE_RE = /(?:isn['’]t|is not) responding|keeps stopping/i;
17
+ /** The system error dialog in a UiAutomator2 page source, or null. */
18
+ export function errorDialogIn(xml) {
19
+ let all;
20
+ try {
21
+ all = parsePageSource(xml).all;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ const title = all.find((e) => e.attrs['resource-id'] === TITLE_ID && TITLE_RE.test(e.attrs.text ?? ''));
27
+ if (!title)
28
+ return null;
29
+ return {
30
+ title: title.attrs.text ?? '',
31
+ drawnBy: title.attrs.package ?? '',
32
+ hasWait: all.some((e) => e.attrs['resource-id'] === WAIT_ID),
33
+ hasClose: all.some((e) => e.attrs['resource-id'] === CLOSE_ID),
34
+ };
35
+ }
36
+ /**
37
+ * The packages an ANR or crash dialog is about, from `dumpsys window windows`
38
+ * (`Window{… u0 Application Not Responding: com.android.systemui}`, `… Application Error: pkg}`).
39
+ */
40
+ export function errorDialogOwners(dumpsys) {
41
+ const owners = [];
42
+ for (const match of dumpsys.matchAll(/Application (?:Not Responding|Error): ([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)/g)) {
43
+ if (!owners.includes(match[1]))
44
+ owners.push(match[1]);
45
+ }
46
+ return owners;
47
+ }
48
+ /** Whether and how to answer a dialog. Anything uncertain is left alone. */
49
+ export function decideErrorDialog(dialog, owners, appPackage) {
50
+ if (!SYSTEM_PACKAGES.has(dialog.drawnBy))
51
+ return { action: 'none', reason: 'not a system dialog' };
52
+ const ours = (pkg) => appPackage !== null && (pkg === appPackage || pkg.startsWith(`${appPackage}:`));
53
+ if (owners.some(ours))
54
+ return { action: 'none', reason: 'belongs to the app under test' };
55
+ const owner = owners[0];
56
+ if (!owner)
57
+ return { action: 'none', reason: 'owner unknown' };
58
+ if (dialog.hasWait)
59
+ return { action: 'wait', owner };
60
+ if (dialog.hasClose)
61
+ return { action: 'close', owner };
62
+ return { action: 'none', reason: 'no button' };
63
+ }
64
+ /** Answers a system error dialog not about the app under test; returns the note to log, or null. */
65
+ export async function dismissSystemErrorDialog(appPackage, deps) {
66
+ const xml = await deps.pageSource().catch(() => '');
67
+ const dialog = xml ? errorDialogIn(xml) : null;
68
+ if (!dialog)
69
+ return null;
70
+ const owners = errorDialogOwners(await deps.dumpsysWindows().catch(() => ''));
71
+ const decision = decideErrorDialog(dialog, owners, appPackage);
72
+ if (decision.action === 'none')
73
+ return null;
74
+ const tapped = await deps.tapId(decision.action === 'wait' ? WAIT_ID : CLOSE_ID).catch(() => false);
75
+ if (!tapped)
76
+ return null;
77
+ return `runner: answered the system dialog "${dialog.title}" (${decision.owner}) with ${decision.action === 'wait' ? 'Wait' : 'Close app'}`;
78
+ }