@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,137 @@
|
|
|
1
|
+
/** Per step, so one noisy page cannot push a whole case past the server's size limit. */
|
|
2
|
+
const MAX_CONSOLE = 100;
|
|
3
|
+
const MAX_PAGE_ERRORS = 50;
|
|
4
|
+
const MAX_REQUESTS = 200;
|
|
5
|
+
const MAX_TEXT = 1_000;
|
|
6
|
+
/** Kept even when they succeed: these are the calls a failing step usually hinges on. */
|
|
7
|
+
const API_RESOURCE_TYPES = new Set(['fetch', 'xhr', 'document', 'eventsource', 'websocket']);
|
|
8
|
+
const SENSITIVE_PARAM = /token|secret|password|passwd|pwd|auth|session|signature|sig|key|code|credential/i;
|
|
9
|
+
/**
|
|
10
|
+
* Collects what the browser reports while a script runs and files each entry
|
|
11
|
+
* under the step that was running when it happened.
|
|
12
|
+
*
|
|
13
|
+
* Only what a person debugging a failure needs is kept: console errors and
|
|
14
|
+
* warnings, uncaught page errors, and requests. Headers and bodies are never
|
|
15
|
+
* recorded, since they carry cookies and the credential the script signs in with.
|
|
16
|
+
*/
|
|
17
|
+
export class DebugCollector {
|
|
18
|
+
steps = new Map();
|
|
19
|
+
current = null;
|
|
20
|
+
startedAt;
|
|
21
|
+
redact;
|
|
22
|
+
constructor(startedAt, redact) {
|
|
23
|
+
this.startedAt = startedAt;
|
|
24
|
+
this.redact = redact;
|
|
25
|
+
}
|
|
26
|
+
attach(page) {
|
|
27
|
+
page.on('console', (message) => this.onConsole(message));
|
|
28
|
+
page.on('pageerror', (error) => this.push('page_errors', MAX_PAGE_ERRORS, { message: this.text(error.stack || error.message), at_secs: this.now() }));
|
|
29
|
+
page.on('requestfinished', (request) => void this.onFinished(request));
|
|
30
|
+
page.on('requestfailed', (request) => this.push('requests', MAX_REQUESTS, {
|
|
31
|
+
...this.describe(request),
|
|
32
|
+
failure: this.text(request.failure()?.errorText ?? 'failed'),
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
/** Starts filing entries under `index`; returns the step's start, in seconds since the case began. */
|
|
36
|
+
begin(index) {
|
|
37
|
+
this.current = index;
|
|
38
|
+
const offset = this.now();
|
|
39
|
+
this.entry(index).video_offset_secs = offset;
|
|
40
|
+
return offset;
|
|
41
|
+
}
|
|
42
|
+
/** The step's capture, or undefined when nothing but its start time was recorded. */
|
|
43
|
+
for(index) {
|
|
44
|
+
const debug = this.steps.get(index);
|
|
45
|
+
if (!debug)
|
|
46
|
+
return undefined;
|
|
47
|
+
const { video_offset_secs: _offset, ...captured } = debug;
|
|
48
|
+
return Object.keys(captured).length > 0 ? debug : undefined;
|
|
49
|
+
}
|
|
50
|
+
onConsole(message) {
|
|
51
|
+
const type = message.type();
|
|
52
|
+
if (type !== 'error' && type !== 'warning')
|
|
53
|
+
return;
|
|
54
|
+
const { url, lineNumber } = message.location();
|
|
55
|
+
this.push('console', MAX_CONSOLE, {
|
|
56
|
+
type,
|
|
57
|
+
text: this.text(message.text()),
|
|
58
|
+
...(url ? { location: `${this.url(url)}:${lineNumber + 1}` } : {}),
|
|
59
|
+
at_secs: this.now(),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async onFinished(request) {
|
|
63
|
+
// Captured before the await: by the time the response resolves, the next step may have begun.
|
|
64
|
+
const index = this.current;
|
|
65
|
+
const base = this.describe(request);
|
|
66
|
+
const response = await request.response().catch(() => null);
|
|
67
|
+
const status = response?.status();
|
|
68
|
+
if (!API_RESOURCE_TYPES.has(request.resourceType()) && (status === undefined || status < 400))
|
|
69
|
+
return;
|
|
70
|
+
const { responseEnd } = request.timing();
|
|
71
|
+
this.push('requests', MAX_REQUESTS, {
|
|
72
|
+
...base,
|
|
73
|
+
...(status === undefined ? {} : { status }),
|
|
74
|
+
...(responseEnd >= 0 ? { duration_ms: Math.round(responseEnd) } : {}),
|
|
75
|
+
}, index);
|
|
76
|
+
}
|
|
77
|
+
describe(request) {
|
|
78
|
+
return {
|
|
79
|
+
method: request.method(),
|
|
80
|
+
url: this.url(request.url()),
|
|
81
|
+
resource_type: request.resourceType(),
|
|
82
|
+
at_secs: this.now(),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
push(kind, cap, entry, index = this.current) {
|
|
86
|
+
if (index === null)
|
|
87
|
+
return;
|
|
88
|
+
const debug = this.entry(index);
|
|
89
|
+
const list = (debug[kind] ??= []);
|
|
90
|
+
if (list.length < cap) {
|
|
91
|
+
list.push(entry);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
debug.dropped ??= {};
|
|
95
|
+
debug.dropped[kind] = (debug.dropped[kind] ?? 0) + 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
entry(index) {
|
|
99
|
+
let debug = this.steps.get(index);
|
|
100
|
+
if (!debug) {
|
|
101
|
+
debug = {};
|
|
102
|
+
this.steps.set(index, debug);
|
|
103
|
+
}
|
|
104
|
+
return debug;
|
|
105
|
+
}
|
|
106
|
+
now() {
|
|
107
|
+
return Math.round((Date.now() - this.startedAt) / 100) / 10;
|
|
108
|
+
}
|
|
109
|
+
text(value) {
|
|
110
|
+
const redacted = this.redact(value);
|
|
111
|
+
return redacted.length > MAX_TEXT ? `${redacted.slice(0, MAX_TEXT - 3)}...` : redacted;
|
|
112
|
+
}
|
|
113
|
+
url(value) {
|
|
114
|
+
return this.text(maskUrl(value));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Hides the values of query parameters that commonly carry secrets, and any user info. */
|
|
118
|
+
export function maskUrl(raw) {
|
|
119
|
+
let url;
|
|
120
|
+
try {
|
|
121
|
+
url = new URL(raw);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return raw;
|
|
125
|
+
}
|
|
126
|
+
if (url.protocol === 'data:' || url.protocol === 'blob:')
|
|
127
|
+
return `${url.protocol}…`;
|
|
128
|
+
if (url.username || url.password) {
|
|
129
|
+
url.username = '';
|
|
130
|
+
url.password = '';
|
|
131
|
+
}
|
|
132
|
+
for (const name of [...url.searchParams.keys()]) {
|
|
133
|
+
if (SENSITIVE_PARAM.test(name))
|
|
134
|
+
url.searchParams.set(name, '***');
|
|
135
|
+
}
|
|
136
|
+
return url.toString();
|
|
137
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
/** SDK directories to try, in order: config, `ANDROID_HOME`, `ANDROID_SDK_ROOT`, the per-OS default. */
|
|
6
|
+
export function sdkCandidates(lookup) {
|
|
7
|
+
const platform = lookup.platform ?? process.platform;
|
|
8
|
+
const home = lookup.home ?? homedir();
|
|
9
|
+
const out = [];
|
|
10
|
+
const add = (path, source) => {
|
|
11
|
+
const trimmed = path?.trim();
|
|
12
|
+
if (trimmed && !out.some((c) => c.path === trimmed))
|
|
13
|
+
out.push({ path: trimmed, source });
|
|
14
|
+
};
|
|
15
|
+
add(lookup.configured, 'config');
|
|
16
|
+
add(lookup.env.ANDROID_HOME, 'ANDROID_HOME');
|
|
17
|
+
add(lookup.env.ANDROID_SDK_ROOT, 'ANDROID_SDK_ROOT');
|
|
18
|
+
if (platform === 'darwin')
|
|
19
|
+
add(join(home, 'Library', 'Android', 'sdk'), 'default');
|
|
20
|
+
else if (platform === 'win32') {
|
|
21
|
+
const local = lookup.env.LOCALAPPDATA?.trim() || join(home, 'AppData', 'Local');
|
|
22
|
+
add(join(local, 'Android', 'Sdk'), 'default');
|
|
23
|
+
}
|
|
24
|
+
else
|
|
25
|
+
add(join(home, 'Android', 'Sdk'), 'default');
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function exe(name, platform) {
|
|
29
|
+
return platform === 'win32' ? `${name}.exe` : name;
|
|
30
|
+
}
|
|
31
|
+
function script(name, platform) {
|
|
32
|
+
return platform === 'win32' ? `${name}.bat` : name;
|
|
33
|
+
}
|
|
34
|
+
/** Compares dotted version strings numerically (`36.0.0` > `35.0.1`). */
|
|
35
|
+
export function compareDotted(a, b) {
|
|
36
|
+
const pa = a.split(/[.-]/).map((p) => Number.parseInt(p, 10) || 0);
|
|
37
|
+
const pb = b.split(/[.-]/).map((p) => Number.parseInt(p, 10) || 0);
|
|
38
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
|
39
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
40
|
+
if (d !== 0)
|
|
41
|
+
return d;
|
|
42
|
+
}
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
/** Finds the SDK: the first candidate directory that has `platform-tools/adb`. */
|
|
46
|
+
export function resolveAndroidSdk(lookup) {
|
|
47
|
+
const platform = lookup.platform ?? process.platform;
|
|
48
|
+
const exists = lookup.exists ?? existsSync;
|
|
49
|
+
for (const { path, source } of sdkCandidates(lookup)) {
|
|
50
|
+
const adb = join(path, 'platform-tools', exe('adb', platform));
|
|
51
|
+
if (!exists(adb))
|
|
52
|
+
continue;
|
|
53
|
+
const emulator = join(path, 'emulator', exe('emulator', platform));
|
|
54
|
+
const sdkmanager = join(path, 'cmdline-tools', 'latest', 'bin', script('sdkmanager', platform));
|
|
55
|
+
const avdmanager = join(path, 'cmdline-tools', 'latest', 'bin', script('avdmanager', platform));
|
|
56
|
+
let buildTools = null;
|
|
57
|
+
try {
|
|
58
|
+
const versions = (lookup.list ?? defaultList)(join(path, 'build-tools')).filter((v) => /^\d/.test(v));
|
|
59
|
+
versions.sort(compareDotted);
|
|
60
|
+
const newest = versions.at(-1);
|
|
61
|
+
if (newest)
|
|
62
|
+
buildTools = join(path, 'build-tools', newest);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
buildTools = null;
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
root: path,
|
|
69
|
+
source,
|
|
70
|
+
adb,
|
|
71
|
+
emulator: exists(emulator) ? emulator : null,
|
|
72
|
+
sdkmanager: exists(sdkmanager) ? sdkmanager : null,
|
|
73
|
+
avdmanager: exists(avdmanager) ? avdmanager : null,
|
|
74
|
+
buildTools,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Finds sdkmanager for `--install-sdk`. Unlike `resolveAndroidSdk` this accepts a directory that has
|
|
81
|
+
* only `cmdline-tools/latest` (no platform-tools yet): sdkmanager then installs platform-tools itself.
|
|
82
|
+
* The first candidate that has sdkmanager wins.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveSdkManager(lookup) {
|
|
85
|
+
const platform = lookup.platform ?? process.platform;
|
|
86
|
+
const exists = lookup.exists ?? existsSync;
|
|
87
|
+
for (const { path, source } of sdkCandidates(lookup)) {
|
|
88
|
+
const bin = join(path, 'cmdline-tools', 'latest', 'bin');
|
|
89
|
+
const sdkmanager = join(bin, script('sdkmanager', platform));
|
|
90
|
+
if (!exists(sdkmanager))
|
|
91
|
+
continue;
|
|
92
|
+
const avdmanager = join(bin, script('avdmanager', platform));
|
|
93
|
+
return { root: path, source, sdkmanager, avdmanager: exists(avdmanager) ? avdmanager : null };
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
function defaultList(path) {
|
|
98
|
+
try {
|
|
99
|
+
return readdirSync(path);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export class ToolError extends Error {
|
|
106
|
+
stderr;
|
|
107
|
+
code;
|
|
108
|
+
constructor(message, stderr, code) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.stderr = stderr;
|
|
111
|
+
this.code = code;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Runs a tool with a fixed argv (no shell) and collects its output. */
|
|
115
|
+
export function runTool(file, args, options = {}) {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
execFile(file, args, {
|
|
118
|
+
timeout: options.timeoutMs ?? 30_000,
|
|
119
|
+
maxBuffer: options.maxBuffer ?? 32 * 1024 * 1024,
|
|
120
|
+
windowsHide: true,
|
|
121
|
+
encoding: 'utf8',
|
|
122
|
+
...(options.env ? { env: options.env } : {}),
|
|
123
|
+
}, (error, stdout, stderr) => {
|
|
124
|
+
if (error) {
|
|
125
|
+
const detail = (stderr || stdout || error.message).trim().split('\n').slice(-3).join(' ');
|
|
126
|
+
const code = error.code ?? null;
|
|
127
|
+
reject(new ToolError(`${file.split(/[\\/]/).pop()} ${args.slice(0, 4).join(' ')} failed: ${detail}`, stderr, code));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
resolve({ stdout, stderr });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/** Parses `adb devices -l`. */
|
|
135
|
+
export function parseAdbDevices(output) {
|
|
136
|
+
const devices = [];
|
|
137
|
+
for (const raw of output.split(/\r?\n/)) {
|
|
138
|
+
const line = raw.trim();
|
|
139
|
+
if (!line || line.startsWith('List of devices') || line.startsWith('*') || line.startsWith('adb '))
|
|
140
|
+
continue;
|
|
141
|
+
// "<serial>\s+<state>[ key:value ...]"; the state "no permissions" contains a space.
|
|
142
|
+
const match = /^(\S+)\s+(no permissions(?: \([^)]*\))?|\S+)(.*)$/.exec(line);
|
|
143
|
+
if (!match)
|
|
144
|
+
continue;
|
|
145
|
+
const [, serial, rawState, rest] = match;
|
|
146
|
+
const fields = {};
|
|
147
|
+
for (const token of (rest ?? '').trim().split(/\s+/)) {
|
|
148
|
+
const at = token.indexOf(':');
|
|
149
|
+
if (at > 0)
|
|
150
|
+
fields[token.slice(0, at)] = token.slice(at + 1);
|
|
151
|
+
}
|
|
152
|
+
devices.push({ serial: serial, state: rawState.startsWith('no permissions') ? 'no permissions' : rawState, fields });
|
|
153
|
+
}
|
|
154
|
+
return devices;
|
|
155
|
+
}
|
|
156
|
+
/** Parses `adb shell getprop` (`[key]: [value]` lines; values may span lines). */
|
|
157
|
+
export function parseGetprop(output) {
|
|
158
|
+
const props = {};
|
|
159
|
+
const re = /\[([^\]]+)\]:\s*\[([\s\S]*?)\]\s*(?=\n\[|$)/g;
|
|
160
|
+
for (const match of output.replace(/\r\n/g, '\n').matchAll(re)) {
|
|
161
|
+
props[match[1]] = match[2];
|
|
162
|
+
}
|
|
163
|
+
return props;
|
|
164
|
+
}
|
|
165
|
+
export function isEmulatorSerial(serial) {
|
|
166
|
+
return /^emulator-\d+$/.test(serial);
|
|
167
|
+
}
|
|
168
|
+
/** Parses the console port out of `emulator-5554`. */
|
|
169
|
+
export function emulatorPort(serial) {
|
|
170
|
+
const match = /^emulator-(\d+)$/.exec(serial);
|
|
171
|
+
return match ? Number(match[1]) : null;
|
|
172
|
+
}
|
|
173
|
+
/** Parses `adb emu avd name` ("Pixel_8_API_36\nOK"). */
|
|
174
|
+
export function parseEmuAvdName(output) {
|
|
175
|
+
const lines = output
|
|
176
|
+
.split(/\r?\n/)
|
|
177
|
+
.map((l) => l.trim())
|
|
178
|
+
.filter((l) => l && l !== 'OK');
|
|
179
|
+
return lines[0] ?? null;
|
|
180
|
+
}
|
|
181
|
+
/** Parses `pidof <package>` (space-separated pids). */
|
|
182
|
+
export function parsePidof(output) {
|
|
183
|
+
return output
|
|
184
|
+
.trim()
|
|
185
|
+
.split(/\s+/)
|
|
186
|
+
.map((p) => Number.parseInt(p, 10))
|
|
187
|
+
.filter((p) => Number.isInteger(p) && p > 0);
|
|
188
|
+
}
|
|
189
|
+
/** A package name as Android defines it; checked before it is passed to any adb argv. */
|
|
190
|
+
export const PACKAGE_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$/;
|
|
191
|
+
export const SERIAL_RE = /^[A-Za-z0-9._:-]+$/;
|
|
192
|
+
export function assertPackage(pkg) {
|
|
193
|
+
if (!PACKAGE_RE.test(pkg) || pkg.length > 255)
|
|
194
|
+
throw new Error(`'${pkg}' is not a valid Android package name`);
|
|
195
|
+
return pkg;
|
|
196
|
+
}
|
|
197
|
+
function assertSerial(serial) {
|
|
198
|
+
if (!SERIAL_RE.test(serial))
|
|
199
|
+
throw new Error(`'${serial}' is not a valid adb serial`);
|
|
200
|
+
return serial;
|
|
201
|
+
}
|
|
202
|
+
/** Thin wrapper over adb with fixed argv. */
|
|
203
|
+
export class Adb {
|
|
204
|
+
path;
|
|
205
|
+
constructor(path) {
|
|
206
|
+
this.path = path;
|
|
207
|
+
}
|
|
208
|
+
run(args, timeoutMs = 30_000) {
|
|
209
|
+
return runTool(this.path, args, { timeoutMs }).then((r) => r.stdout);
|
|
210
|
+
}
|
|
211
|
+
async devices() {
|
|
212
|
+
return parseAdbDevices(await this.run(['devices', '-l'], 15_000));
|
|
213
|
+
}
|
|
214
|
+
shell(serial, args, timeoutMs = 30_000) {
|
|
215
|
+
return this.run(['-s', assertSerial(serial), 'shell', ...args], timeoutMs);
|
|
216
|
+
}
|
|
217
|
+
async getprops(serial) {
|
|
218
|
+
return parseGetprop(await this.shell(serial, ['getprop'], 15_000));
|
|
219
|
+
}
|
|
220
|
+
async getprop(serial, key) {
|
|
221
|
+
if (!/^[A-Za-z0-9._-]+$/.test(key))
|
|
222
|
+
throw new Error(`invalid property '${key}'`);
|
|
223
|
+
return (await this.shell(serial, ['getprop', key], 10_000)).trim();
|
|
224
|
+
}
|
|
225
|
+
async avdName(serial) {
|
|
226
|
+
try {
|
|
227
|
+
return parseEmuAvdName(await this.run(['-s', assertSerial(serial), 'emu', 'avd', 'name'], 10_000));
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async install(serial, apk, timeoutMs = 300_000) {
|
|
234
|
+
const out = await this.run(['-s', assertSerial(serial), 'install', '-r', '-t', '-g', apk], timeoutMs);
|
|
235
|
+
if (/Failure \[/.test(out))
|
|
236
|
+
throw new Error(`the app could not be installed: ${out.trim().split('\n').pop()}`);
|
|
237
|
+
}
|
|
238
|
+
async uninstall(serial, pkg) {
|
|
239
|
+
await this.run(['-s', assertSerial(serial), 'uninstall', assertPackage(pkg)], 60_000).catch(() => undefined);
|
|
240
|
+
}
|
|
241
|
+
async isInstalled(serial, pkg) {
|
|
242
|
+
const out = await this.shell(serial, ['pm', 'path', assertPackage(pkg)], 15_000).catch(() => '');
|
|
243
|
+
return out.includes('package:');
|
|
244
|
+
}
|
|
245
|
+
async pidof(serial, pkg) {
|
|
246
|
+
return parsePidof(await this.shell(serial, ['pidof', assertPackage(pkg)], 10_000).catch(() => ''));
|
|
247
|
+
}
|
|
248
|
+
async killEmulator(serial) {
|
|
249
|
+
await this.run(['-s', assertSerial(serial), 'emu', 'kill'], 15_000).catch(() => undefined);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { runTool } from './adb.js';
|
|
5
|
+
/** AVD names as avdmanager allows them; anything else in `-list-avds` output is a log line. */
|
|
6
|
+
export const AVD_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
7
|
+
/** Parses `emulator -list-avds`, skipping the INFO/WARNING lines newer emulators print. */
|
|
8
|
+
export function parseAvdList(output) {
|
|
9
|
+
const names = [];
|
|
10
|
+
for (const raw of output.split(/\r?\n/)) {
|
|
11
|
+
const line = raw.trim();
|
|
12
|
+
if (!line || !AVD_NAME_RE.test(line))
|
|
13
|
+
continue;
|
|
14
|
+
if (/^(INFO|WARNING|ERROR|ULOG)\b/.test(line))
|
|
15
|
+
continue;
|
|
16
|
+
if (!names.includes(line))
|
|
17
|
+
names.push(line);
|
|
18
|
+
}
|
|
19
|
+
return names;
|
|
20
|
+
}
|
|
21
|
+
export async function listAvds(emulator) {
|
|
22
|
+
const { stdout } = await runTool(emulator, ['-list-avds'], { timeoutMs: 20_000 });
|
|
23
|
+
return parseAvdList(stdout);
|
|
24
|
+
}
|
|
25
|
+
/** `ANDROID_AVD_HOME`, else `ANDROID_USER_HOME/avd`, else `~/.android/avd`. */
|
|
26
|
+
export function avdHome(env, home = homedir()) {
|
|
27
|
+
if (env.ANDROID_AVD_HOME?.trim())
|
|
28
|
+
return env.ANDROID_AVD_HOME.trim();
|
|
29
|
+
if (env.ANDROID_USER_HOME?.trim())
|
|
30
|
+
return join(env.ANDROID_USER_HOME.trim(), 'avd');
|
|
31
|
+
return join(home, '.android', 'avd');
|
|
32
|
+
}
|
|
33
|
+
/** Parses an AVD `config.ini` / `<name>.ini` (`key=value`). */
|
|
34
|
+
export function parseIni(text) {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
37
|
+
const line = raw.trim();
|
|
38
|
+
if (!line || line.startsWith('#') || line.startsWith(';'))
|
|
39
|
+
continue;
|
|
40
|
+
const at = line.indexOf('=');
|
|
41
|
+
if (at <= 0)
|
|
42
|
+
continue;
|
|
43
|
+
out[line.slice(0, at).trim()] = line.slice(at + 1).trim();
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/** Android release for an API level (what `os_version` reports for a device that is not booted). */
|
|
48
|
+
export function androidVersionForApi(api) {
|
|
49
|
+
if (api === null)
|
|
50
|
+
return null;
|
|
51
|
+
const table = {
|
|
52
|
+
21: '5.0', 22: '5.1', 23: '6.0', 24: '7.0', 25: '7.1', 26: '8.0', 27: '8.1', 28: '9', 29: '10', 30: '11',
|
|
53
|
+
31: '12', 32: '12L', 33: '13', 34: '14', 35: '15', 36: '16', 37: '17',
|
|
54
|
+
};
|
|
55
|
+
return table[api] ?? null;
|
|
56
|
+
}
|
|
57
|
+
export function avdInfoFromIni(name, config, pointer = {}) {
|
|
58
|
+
const target = config.target ?? pointer.target ?? '';
|
|
59
|
+
let api = null;
|
|
60
|
+
const fromTarget = /android-(\d+)/.exec(target);
|
|
61
|
+
if (fromTarget)
|
|
62
|
+
api = Number(fromTarget[1]);
|
|
63
|
+
else {
|
|
64
|
+
const fromImage = /android-(\d+)/.exec(config['image.sysdir.1'] ?? '');
|
|
65
|
+
if (fromImage)
|
|
66
|
+
api = Number(fromImage[1]);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
name,
|
|
70
|
+
displayName: config['avd.ini.displayname'] ?? null,
|
|
71
|
+
apiLevel: api,
|
|
72
|
+
abi: config['abi.type'] ?? null,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export function readAvdInfo(name, env) {
|
|
76
|
+
if (!AVD_NAME_RE.test(name))
|
|
77
|
+
return { name, displayName: null, apiLevel: null, abi: null };
|
|
78
|
+
const dir = avdHome(env);
|
|
79
|
+
let pointer = {};
|
|
80
|
+
let config = {};
|
|
81
|
+
try {
|
|
82
|
+
pointer = parseIni(readFileSync(join(dir, `${name}.ini`), 'utf8'));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
pointer = {};
|
|
86
|
+
}
|
|
87
|
+
const avdPath = pointer.path || join(dir, `${name}.avd`);
|
|
88
|
+
try {
|
|
89
|
+
config = parseIni(readFileSync(join(avdPath, 'config.ini'), 'utf8'));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
config = {};
|
|
93
|
+
}
|
|
94
|
+
return avdInfoFromIni(name, config, pointer);
|
|
95
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { closeSync, mkdirSync, openSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:net';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
6
|
+
import { emulatorPort } from './adb.js';
|
|
7
|
+
import { AVD_NAME_RE } from './avd.js';
|
|
8
|
+
/** Console ports adb scans for emulators (even ports; adb uses port + 1). */
|
|
9
|
+
export const EMULATOR_PORT_MIN = 5554;
|
|
10
|
+
export const EMULATOR_PORT_MAX = 5682;
|
|
11
|
+
/** The fixed argv that boots an AVD on a given console port. */
|
|
12
|
+
export function emulatorArgv(name, port, headless) {
|
|
13
|
+
if (!AVD_NAME_RE.test(name))
|
|
14
|
+
throw new Error(`'${name}' is not a valid AVD name`);
|
|
15
|
+
if (!Number.isInteger(port) || port < EMULATOR_PORT_MIN || port > EMULATOR_PORT_MAX || port % 2 !== 0) {
|
|
16
|
+
throw new Error(`emulator port ${port} is outside ${EMULATOR_PORT_MIN}-${EMULATOR_PORT_MAX}`);
|
|
17
|
+
}
|
|
18
|
+
return [
|
|
19
|
+
'-avd',
|
|
20
|
+
name,
|
|
21
|
+
'-port',
|
|
22
|
+
String(port),
|
|
23
|
+
'-no-audio',
|
|
24
|
+
'-no-boot-anim',
|
|
25
|
+
'-gpu',
|
|
26
|
+
'swiftshader_indirect',
|
|
27
|
+
...(headless ? ['-no-window'] : []),
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
function portFree(port) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const server = createServer();
|
|
33
|
+
server.once('error', () => resolve(false));
|
|
34
|
+
server.listen({ port, host: '127.0.0.1', exclusive: true }, () => server.close(() => resolve(true)));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/** The lowest even console port whose pair is free and not used by a running emulator. */
|
|
38
|
+
export async function pickEmulatorPort(inUse, isFree = portFree) {
|
|
39
|
+
const taken = new Set(inUse);
|
|
40
|
+
for (let port = EMULATOR_PORT_MIN; port <= EMULATOR_PORT_MAX; port += 2) {
|
|
41
|
+
if (taken.has(port))
|
|
42
|
+
continue;
|
|
43
|
+
if ((await isFree(port)) && (await isFree(port + 1)))
|
|
44
|
+
return port;
|
|
45
|
+
}
|
|
46
|
+
throw new Error('no free emulator port between 5554 and 5682');
|
|
47
|
+
}
|
|
48
|
+
/** Boots in progress, by AVD name, so two jobs never start the same AVD twice. */
|
|
49
|
+
const booting = new Map();
|
|
50
|
+
/** Emulators this process started (serial → adb), so an ephemeral runner can shut them down when it exits. */
|
|
51
|
+
const bootedHere = new Map();
|
|
52
|
+
/** Serials of the emulators this process booted and has not shut down. */
|
|
53
|
+
export function emulatorsBootedHere() {
|
|
54
|
+
return [...bootedHere.keys()];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Shuts down every emulator this process booted (`ci` owns them; a long-running `start`
|
|
58
|
+
* keeps emulators booted between jobs and does not call this). Returns the serials stopped.
|
|
59
|
+
*/
|
|
60
|
+
export async function shutdownEmulatorsBootedHere(timeoutMs = 60_000) {
|
|
61
|
+
const stopped = [];
|
|
62
|
+
for (const [serial, adb] of [...bootedHere]) {
|
|
63
|
+
await shutdownEmulator(adb, serial, timeoutMs).catch(() => undefined);
|
|
64
|
+
stopped.push(serial);
|
|
65
|
+
}
|
|
66
|
+
return stopped;
|
|
67
|
+
}
|
|
68
|
+
/** Finds a running emulator for the AVD, or null. */
|
|
69
|
+
export async function findRunningAvd(adb, name) {
|
|
70
|
+
for (const device of await adb.devices()) {
|
|
71
|
+
if (emulatorPort(device.serial) === null)
|
|
72
|
+
continue;
|
|
73
|
+
if ((await adb.avdName(device.serial)) === name)
|
|
74
|
+
return device.serial;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Boots an AVD (or returns the serial it already runs as) and waits until Android reports
|
|
80
|
+
* `sys.boot_completed=1`. The emulator is detached: it stays booted between jobs.
|
|
81
|
+
*/
|
|
82
|
+
export function bootAvd(emulator, adb, name, options) {
|
|
83
|
+
const pending = booting.get(name);
|
|
84
|
+
if (pending)
|
|
85
|
+
return pending;
|
|
86
|
+
const promise = (async () => {
|
|
87
|
+
const running = await findRunningAvd(adb, name);
|
|
88
|
+
if (running) {
|
|
89
|
+
await waitForBoot(adb, running, options.timeoutMs ?? 300_000);
|
|
90
|
+
return running;
|
|
91
|
+
}
|
|
92
|
+
const ports = (await adb.devices()).map((d) => emulatorPort(d.serial)).filter((p) => p !== null);
|
|
93
|
+
const port = await pickEmulatorPort(ports);
|
|
94
|
+
const argv = emulatorArgv(name, port, options.headless);
|
|
95
|
+
let out = 'ignore';
|
|
96
|
+
if (options.logFile) {
|
|
97
|
+
mkdirSync(dirname(options.logFile), { recursive: true });
|
|
98
|
+
out = openSync(options.logFile, 'a');
|
|
99
|
+
}
|
|
100
|
+
const child = spawn(emulator, argv, {
|
|
101
|
+
detached: true,
|
|
102
|
+
stdio: ['ignore', out, out],
|
|
103
|
+
windowsHide: true,
|
|
104
|
+
...(options.env ? { env: options.env } : {}),
|
|
105
|
+
});
|
|
106
|
+
if (typeof out === 'number')
|
|
107
|
+
closeSync(out);
|
|
108
|
+
let exited = null;
|
|
109
|
+
child.once('exit', (code, signal) => {
|
|
110
|
+
exited = `the emulator exited (${signal ?? `code ${code}`})${options.logFile ? `; see ${options.logFile}` : ''}`;
|
|
111
|
+
});
|
|
112
|
+
child.once('error', (error) => {
|
|
113
|
+
exited = `the emulator could not start: ${error.message}`;
|
|
114
|
+
});
|
|
115
|
+
child.unref();
|
|
116
|
+
const serial = `emulator-${port}`;
|
|
117
|
+
bootedHere.set(serial, adb);
|
|
118
|
+
await waitForBoot(adb, serial, options.timeoutMs ?? 300_000, () => exited);
|
|
119
|
+
return serial;
|
|
120
|
+
})().finally(() => booting.delete(name));
|
|
121
|
+
booting.set(name, promise);
|
|
122
|
+
return promise;
|
|
123
|
+
}
|
|
124
|
+
/** Waits for adb to see the device and for `sys.boot_completed` to become 1. */
|
|
125
|
+
export async function waitForBoot(adb, serial, timeoutMs, failed = () => null) {
|
|
126
|
+
const deadline = Date.now() + timeoutMs;
|
|
127
|
+
for (;;) {
|
|
128
|
+
const failure = failed();
|
|
129
|
+
if (failure)
|
|
130
|
+
throw new Error(failure);
|
|
131
|
+
const device = (await adb.devices().catch(() => [])).find((d) => d.serial === serial);
|
|
132
|
+
if (device?.state === 'device') {
|
|
133
|
+
const completed = await adb.getprop(serial, 'sys.boot_completed').catch(() => '');
|
|
134
|
+
if (completed === '1')
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (Date.now() > deadline) {
|
|
138
|
+
throw new Error(`${serial} did not finish booting within ${Math.round(timeoutMs / 1000)} s`);
|
|
139
|
+
}
|
|
140
|
+
await sleep(2_000);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export async function shutdownEmulator(adb, serial, timeoutMs = 60_000) {
|
|
144
|
+
bootedHere.delete(serial);
|
|
145
|
+
await adb.killEmulator(serial);
|
|
146
|
+
const deadline = Date.now() + timeoutMs;
|
|
147
|
+
while (Date.now() < deadline) {
|
|
148
|
+
const still = (await adb.devices().catch(() => [])).some((d) => d.serial === serial);
|
|
149
|
+
if (!still)
|
|
150
|
+
return;
|
|
151
|
+
await sleep(1_000);
|
|
152
|
+
}
|
|
153
|
+
}
|