@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,394 @@
1
+ import { accessSync, chmodSync, constants, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { bundledDrivers } from './appium/drivers.js';
4
+ import { bundledAppium } from './appium/server.js';
5
+ import { Adb, resolveAndroidSdk, runTool } from './devices/android/adb.js';
6
+ import { iosChecks } from './devices/ios/setup.js';
7
+ import { electronChecks } from './devices/desktop.js';
8
+ import { electronSupported } from './drivers/electron-playwright.js';
9
+ import { loadTauriSettings, tauriChecks } from './devices/tauri.js';
10
+ import { macosChecks } from './devices/macos.js';
11
+ import { windowsChecks } from './devices/windows.js';
12
+ import { detect, hostInfo } from './capabilities.js';
13
+ import { RunnerClient } from './client.js';
14
+ import { loadFileConfig, TOKEN_PREFIX } from './config.js';
15
+ import { messageOf } from './errors.js';
16
+ import { ensureHome, isTooOpen, readJsonFile } from './home.js';
17
+ import { compareVersions, playwrightVersion, RUNNER_VERSION } from './version.js';
18
+ export const MIN_NODE = '22.12.0';
19
+ export async function runDoctor(options) {
20
+ const { env, paths, fix = false } = options;
21
+ const checks = [];
22
+ const host = hostInfo();
23
+ checks.push(compareVersions(host.node, MIN_NODE) >= 0
24
+ ? { id: 'node', title: 'Node.js', status: 'ok', detail: `Node.js ${host.node}` }
25
+ : {
26
+ id: 'node',
27
+ title: 'Node.js',
28
+ status: 'fail',
29
+ detail: `Node.js ${host.node} is too old; ${MIN_NODE} or newer is needed`,
30
+ fix: 'Install Node.js 22.12 or newer, or use the installer, which bundles Node.js',
31
+ });
32
+ // Home directory
33
+ const homeCheck = { id: 'home', title: 'Runner home', status: 'ok', detail: paths.root };
34
+ try {
35
+ if (!existsSync(paths.root)) {
36
+ if (fix) {
37
+ ensureHome(paths);
38
+ homeCheck.fixed = true;
39
+ homeCheck.detail = `${paths.root} (created)`;
40
+ }
41
+ else {
42
+ homeCheck.status = 'warn';
43
+ homeCheck.detail = `${paths.root} does not exist yet`;
44
+ homeCheck.fix = 'leera-qa-runner connect --url <server>';
45
+ }
46
+ }
47
+ else {
48
+ accessSync(paths.root, constants.W_OK);
49
+ if (isTooOpen(paths.root)) {
50
+ if (fix) {
51
+ chmodSync(paths.root, 0o700);
52
+ homeCheck.fixed = true;
53
+ }
54
+ else {
55
+ homeCheck.status = 'warn';
56
+ homeCheck.detail = `${paths.root} is readable by other users`;
57
+ homeCheck.fix = 'leera-qa-runner doctor --fix';
58
+ }
59
+ }
60
+ }
61
+ }
62
+ catch (error) {
63
+ homeCheck.status = 'fail';
64
+ homeCheck.detail = `${paths.root} is not writable: ${messageOf(error)}`;
65
+ }
66
+ checks.push(homeCheck);
67
+ // Configuration and token
68
+ let fileUrl;
69
+ let fileToken = null;
70
+ try {
71
+ const tokenWasOpen = existsSync(paths.token) && isTooOpen(paths.token);
72
+ if (tokenWasOpen && !fix) {
73
+ checks.push({
74
+ id: 'token_permissions',
75
+ title: 'Token file permissions',
76
+ status: 'warn',
77
+ detail: `${paths.token} is readable by other users; it is narrowed to 0600 on the next start`,
78
+ fix: 'leera-qa-runner doctor --fix',
79
+ });
80
+ }
81
+ const loaded = loadFileConfig(paths); // narrows the token file's permissions when they are too wide
82
+ if (tokenWasOpen && fix) {
83
+ checks.push({ id: 'token_permissions', title: 'Token file permissions', status: 'ok', detail: 'set to 0600', fixed: true });
84
+ }
85
+ fileUrl = loaded.file?.url;
86
+ fileToken = loaded.token;
87
+ }
88
+ catch (error) {
89
+ checks.push({ id: 'config', title: 'Configuration file', status: 'fail', detail: messageOf(error) });
90
+ }
91
+ const url = env.RUNNER_URL?.trim() || env.LEERA_URL?.trim() || fileUrl;
92
+ const token = env.RUNNER_TOKEN?.trim() || env.LEERA_RUNNER_TOKEN?.trim() || fileToken || undefined;
93
+ checks.push(url
94
+ ? { id: 'url', title: 'Server URL', status: 'ok', detail: url }
95
+ : { id: 'url', title: 'Server URL', status: 'fail', detail: 'no server URL configured', fix: 'leera-qa-runner connect --url <server>' });
96
+ if (!token) {
97
+ checks.push({ id: 'token', title: 'Runner token', status: 'fail', detail: 'no runner token configured', fix: 'leera-qa-runner config set-token' });
98
+ }
99
+ else if (!token.startsWith(TOKEN_PREFIX)) {
100
+ checks.push({
101
+ id: 'token',
102
+ title: 'Runner token',
103
+ status: 'fail',
104
+ detail: `the token is not a runner pool token (${TOKEN_PREFIX}…)`,
105
+ fix: 'leera-qa-runner config set-token',
106
+ });
107
+ }
108
+ else if (url && !options.offline) {
109
+ try {
110
+ const answer = await new RunnerClient(url, token).checkToken();
111
+ if (answer.result === 'accepted') {
112
+ checks.push({ id: 'server', title: 'Server and token', status: 'ok', detail: `${url} accepted the token` });
113
+ }
114
+ else if (answer.result === 'rejected') {
115
+ checks.push({
116
+ id: 'server',
117
+ title: 'Server and token',
118
+ status: 'fail',
119
+ detail: `${url} rejected the token (HTTP ${answer.status})`,
120
+ fix: 'Create a new runner token in the workspace, then run `leera-qa-runner config set-token`',
121
+ });
122
+ }
123
+ else {
124
+ checks.push({
125
+ id: 'server',
126
+ title: 'Server and token',
127
+ status: 'warn',
128
+ detail: `${url} answered HTTP ${answer.status}${answer.message ? ` (${answer.message})` : ''}; is this the right server URL?`,
129
+ });
130
+ }
131
+ }
132
+ catch (error) {
133
+ checks.push({
134
+ id: 'server',
135
+ title: 'Server and token',
136
+ status: 'fail',
137
+ detail: `could not reach ${url}: ${messageOf(error)}`,
138
+ fix: 'Check the URL, the network and any proxy settings',
139
+ });
140
+ }
141
+ }
142
+ else {
143
+ checks.push({ id: 'token', title: 'Runner token', status: 'ok', detail: 'configured' });
144
+ }
145
+ // Platforms
146
+ let detection = await detect(options.registry, host);
147
+ for (const factory of options.registry.values()) {
148
+ if (options.platform && factory.platform !== options.platform)
149
+ continue;
150
+ // Only a Mac can run iOS jobs; elsewhere iOS is not a problem to fix unless asked about.
151
+ if (factory.platform === 'ios' && !options.platform && host.os !== 'macos')
152
+ continue;
153
+ // Native macOS and Windows apps only run on their own OS.
154
+ if (factory.platform === 'macos' && !options.platform && host.os !== 'macos')
155
+ continue;
156
+ if (factory.platform === 'windows' && !options.platform && host.os !== 'windows')
157
+ continue;
158
+ // Tauri needs tools most runners do not have; it is only a problem when asked about.
159
+ if (factory.platform === 'tauri' && !options.platform && detection.unavailable.tauri)
160
+ continue;
161
+ const reasons = detection.unavailable[factory.platform];
162
+ if (factory.platform === 'web') {
163
+ const version = playwrightVersion();
164
+ if (reasons && fix && options.installBrowsers) {
165
+ const installed = await options.installBrowsers();
166
+ if (installed) {
167
+ detection = await detect(options.registry, host);
168
+ checks.push({ id: 'web.chromium', title: 'Web: Chromium', status: 'ok', detail: 'installed', fixed: true });
169
+ continue;
170
+ }
171
+ }
172
+ checks.push(reasons
173
+ ? { id: 'web.chromium', title: 'Web: Chromium', status: 'fail', detail: reasons.join('; '), fix: 'leera-qa-runner setup browsers' }
174
+ : { id: 'web.chromium', title: 'Web: Chromium', status: 'ok', detail: `Playwright ${version ?? 'unknown'}` });
175
+ }
176
+ else {
177
+ checks.push(reasons
178
+ ? { id: `${factory.platform}`, title: factory.platform, status: 'fail', detail: reasons.join('; ') }
179
+ : { id: `${factory.platform}`, title: factory.platform, status: 'ok', detail: 'ready' });
180
+ }
181
+ }
182
+ if (options.registry.has('android') && (!options.platform || options.platform === 'android')) {
183
+ checks.push(...(await androidChecks(env, paths)));
184
+ }
185
+ // iOS is only checked on a Mac (or when asked for): other hosts cannot run it at all.
186
+ if (options.registry.has('ios') && (options.platform === 'ios' || (!options.platform && process.platform === 'darwin'))) {
187
+ checks.push(...(await iosChecks(env, paths)));
188
+ }
189
+ if (options.registry.has('electron') && (!options.platform || options.platform === 'electron')) {
190
+ checks.push(...electronChecks({ playwright: playwrightVersion(), electronSupported: electronSupported(), env }));
191
+ }
192
+ if (options.registry.has('tauri') && (options.platform === 'tauri' || (!options.platform && !detection.unavailable.tauri))) {
193
+ checks.push(...tauriChecks(loadTauriSettings(env, paths), { env }));
194
+ }
195
+ if (options.registry.has('macos') && (options.platform === 'macos' || (!options.platform && process.platform === 'darwin'))) {
196
+ checks.push(...(await macosChecks(env, paths)));
197
+ }
198
+ if (options.registry.has('windows') && (options.platform === 'windows' || (!options.platform && process.platform === 'win32'))) {
199
+ checks.push(...(await windowsChecks(env, paths)));
200
+ }
201
+ const devices = detection.devices.filter((device) => !options.platform || device.platform === options.platform);
202
+ return {
203
+ ok: checks.every((check) => check.status !== 'fail'),
204
+ version: RUNNER_VERSION,
205
+ host,
206
+ home: paths.root,
207
+ platforms: [...new Set(devices.map((device) => device.platform))],
208
+ labels: detection.labels,
209
+ devices,
210
+ checks,
211
+ };
212
+ }
213
+ export function formatDoctor(report) {
214
+ const mark = { ok: '[ok] ', warn: '[warn]', fail: '[fail]' };
215
+ const lines = [`leera-qa-runner ${report.version} on ${report.host.os} ${report.host.arch} (${report.host.hostname})`, ''];
216
+ for (const check of report.checks) {
217
+ lines.push(`${mark[check.status]} ${check.title}: ${check.detail}${check.fixed ? ' (fixed)' : ''}`);
218
+ if (check.fix && check.status !== 'ok')
219
+ lines.push(` fix: ${check.fix}`);
220
+ }
221
+ lines.push('');
222
+ lines.push(`Devices: ${report.devices.length === 0 ? 'none' : report.devices.map((d) => `${d.name} (${d.platform})`).join(', ')}`);
223
+ lines.push(`Labels: ${report.labels.length === 0 ? 'none' : report.labels.join(', ')}`);
224
+ lines.push(report.ok ? 'Ready.' : 'Not ready: fix the items marked [fail].');
225
+ return lines.join('\n');
226
+ }
227
+ /** Finds `java`: `JAVA_HOME/bin/java`, else whatever is on PATH. */
228
+ export function javaCandidates(env, platform = process.platform) {
229
+ const exe = platform === 'win32' ? 'java.exe' : 'java';
230
+ const out = [];
231
+ if (env.JAVA_HOME?.trim())
232
+ out.push(join(env.JAVA_HOME.trim(), 'bin', exe));
233
+ out.push(exe);
234
+ return out;
235
+ }
236
+ /** Parses the version out of `java -version` output (`openjdk version "17.0.20"` → 17). */
237
+ export function javaMajor(output) {
238
+ const match = /version "(\d+)(?:\.(\d+))?/.exec(output);
239
+ if (!match)
240
+ return null;
241
+ const first = Number(match[1]);
242
+ return first === 1 && match[2] ? Number(match[2]) : first;
243
+ }
244
+ /**
245
+ * Android requirements (plan §7.4): SDK + adb, emulator, build-tools (`apksigner`), Java,
246
+ * the bundled Appium and UiAutomator2 driver, KVM on Linux, and devices that have not
247
+ * authorised this computer.
248
+ */
249
+ export async function androidChecks(env, paths, deps = {}) {
250
+ const run = deps.run ?? (async (file, args) => {
251
+ const { stdout, stderr } = await runTool(file, args, { timeoutMs: 20_000 });
252
+ return `${stdout}${stderr}`;
253
+ });
254
+ const exists = deps.exists ?? existsSync;
255
+ const platform = deps.platform ?? process.platform;
256
+ const checks = [];
257
+ const setup = 'leera-qa-runner setup android';
258
+ const appium = bundledAppium();
259
+ const drivers = bundledDrivers(platform).filter((d) => d.platforms.includes('android'));
260
+ if (!appium) {
261
+ checks.push({
262
+ id: 'android.appium',
263
+ title: 'Android: Appium',
264
+ status: 'fail',
265
+ detail: 'Appium is not installed with this runner (optional dependencies were omitted)',
266
+ fix: 'Reinstall the runner with the installer, or `npm install -g @leera.io/qa-runner` without --omit=optional',
267
+ });
268
+ }
269
+ else if (drivers.length === 0) {
270
+ checks.push({ id: 'android.appium', title: 'Android: Appium', status: 'fail', detail: 'the UiAutomator2 driver is not installed with this runner' });
271
+ }
272
+ else {
273
+ checks.push({
274
+ id: 'android.appium',
275
+ title: 'Android: Appium',
276
+ status: 'ok',
277
+ detail: `Appium ${appium.version}, ${drivers.map((d) => `${d.name} ${d.version}`).join(', ')}; home ${join(paths.root, 'appium')}`,
278
+ });
279
+ }
280
+ let configured = null;
281
+ try {
282
+ configured = readJsonFile(paths.config)?.android?.sdk_path?.trim() || null;
283
+ }
284
+ catch {
285
+ configured = null;
286
+ }
287
+ const sdk = resolveAndroidSdk({ env, platform, exists, ...(configured ? { configured } : {}) });
288
+ if (!sdk) {
289
+ checks.push({
290
+ id: 'android.sdk',
291
+ title: 'Android: SDK',
292
+ status: 'fail',
293
+ detail: 'no Android SDK with platform-tools was found (config android.sdk_path, ANDROID_HOME, ANDROID_SDK_ROOT, default location)',
294
+ fix: `Install Android Studio or the command-line tools, set ANDROID_HOME, then run \`${setup}\``,
295
+ });
296
+ return checks;
297
+ }
298
+ checks.push({ id: 'android.sdk', title: 'Android: SDK', status: 'ok', detail: `${sdk.root} (from ${sdk.source})` });
299
+ try {
300
+ const out = await run(sdk.adb, ['version']);
301
+ const version = /Android Debug Bridge version ([\d.]+)/.exec(out)?.[1] ?? 'unknown';
302
+ checks.push({ id: 'android.adb', title: 'Android: adb', status: 'ok', detail: `adb ${version}` });
303
+ }
304
+ catch (error) {
305
+ checks.push({ id: 'android.adb', title: 'Android: adb', status: 'fail', detail: `adb does not run: ${messageOf(error)}`, fix: 'Reinstall platform-tools with sdkmanager' });
306
+ }
307
+ checks.push(sdk.emulator
308
+ ? { id: 'android.emulator', title: 'Android: emulator', status: 'ok', detail: sdk.emulator }
309
+ : {
310
+ id: 'android.emulator',
311
+ title: 'Android: emulator',
312
+ status: 'warn',
313
+ detail: 'the emulator package is not installed; only USB devices can be used',
314
+ fix: 'sdkmanager --install emulator',
315
+ });
316
+ const apksigner = sdk.buildTools ? join(sdk.buildTools, platform === 'win32' ? 'apksigner.bat' : 'apksigner') : null;
317
+ checks.push(apksigner && exists(apksigner)
318
+ ? { id: 'android.build_tools', title: 'Android: build-tools', status: 'ok', detail: sdk.buildTools }
319
+ : {
320
+ id: 'android.build_tools',
321
+ title: 'Android: build-tools',
322
+ status: 'warn',
323
+ detail: 'no build-tools with apksigner were found; UiAutomator2 needs them to check app signatures',
324
+ fix: 'sdkmanager --install "build-tools;35.0.0"',
325
+ });
326
+ let javaDetail = null;
327
+ for (const java of javaCandidates(env, platform)) {
328
+ try {
329
+ const major = javaMajor(await run(java, ['-version']));
330
+ if (major !== null) {
331
+ javaDetail = `Java ${major} (${java})`;
332
+ break;
333
+ }
334
+ }
335
+ catch {
336
+ // try the next candidate
337
+ }
338
+ }
339
+ checks.push(javaDetail
340
+ ? { id: 'android.java', title: 'Android: Java', status: 'ok', detail: javaDetail }
341
+ : {
342
+ id: 'android.java',
343
+ title: 'Android: Java',
344
+ status: 'warn',
345
+ detail: 'Java was not found; UiAutomator2 uses it for app signature checks',
346
+ fix: 'Install a JDK (17 or newer) and set JAVA_HOME',
347
+ });
348
+ if (platform === 'linux') {
349
+ const kvm = (deps.canAccess ?? ((path) => {
350
+ try {
351
+ accessSync(path, constants.R_OK | constants.W_OK);
352
+ return true;
353
+ }
354
+ catch {
355
+ return false;
356
+ }
357
+ }))('/dev/kvm');
358
+ checks.push(kvm
359
+ ? { id: 'android.kvm', title: 'Android: KVM', status: 'ok', detail: '/dev/kvm is usable' }
360
+ : {
361
+ id: 'android.kvm',
362
+ title: 'Android: KVM',
363
+ status: 'warn',
364
+ detail: '/dev/kvm is missing or not accessible; emulators will be very slow or not start',
365
+ fix: 'Enable virtualization and add this user to the kvm group',
366
+ });
367
+ }
368
+ try {
369
+ const devices = await new Adb(sdk.adb).devices();
370
+ const unauthorized = devices.filter((d) => d.state === 'unauthorized').map((d) => d.serial);
371
+ const ready = devices.filter((d) => d.state === 'device').map((d) => d.serial);
372
+ if (unauthorized.length > 0) {
373
+ checks.push({
374
+ id: 'android.devices',
375
+ title: 'Android: devices',
376
+ status: 'warn',
377
+ detail: `not authorised: ${unauthorized.join(', ')}`,
378
+ fix: 'Unlock the phone and accept the "Allow USB debugging" prompt',
379
+ });
380
+ }
381
+ else {
382
+ checks.push({
383
+ id: 'android.devices',
384
+ title: 'Android: devices',
385
+ status: 'ok',
386
+ detail: ready.length > 0 ? `connected: ${ready.join(', ')}` : 'no device connected (AVDs are booted on demand)',
387
+ });
388
+ }
389
+ }
390
+ catch (error) {
391
+ checks.push({ id: 'android.devices', title: 'Android: devices', status: 'warn', detail: `adb could not list devices: ${messageOf(error)}` });
392
+ }
393
+ return checks;
394
+ }
@@ -0,0 +1,39 @@
1
+ import { closeSync, openSync, writeSync } from 'node:fs';
2
+ /**
3
+ * Writes an HTTP response body to `path` (mode 0600), calling `onChunk` before each chunk is written
4
+ * (throw from it to abort). Returns the number of bytes written.
5
+ *
6
+ * The body is read chunk by chunk and written synchronously instead of piped through
7
+ * `Readable.fromWeb` into a write stream: with the undici bundled in Node 24.21, a server that closes
8
+ * the connection after the body (HTTP/1.0 style, e.g. `python3 -m http.server` or some proxies) while
9
+ * the body stream is paused by write-stream backpressure crashes the process with an uncaught
10
+ * `assert(!this.paused)` in `Parser.finish` (fixed upstream in undici's client-h1). Draining the body
11
+ * promptly keeps the parser from being paused when the socket ends.
12
+ */
13
+ export async function writeResponseBody(body, path, onChunk) {
14
+ const reader = body.getReader();
15
+ const fd = openSync(path, 'w', 0o600);
16
+ let written = 0;
17
+ try {
18
+ for (;;) {
19
+ const { done, value } = await reader.read();
20
+ if (done)
21
+ break;
22
+ if (!value || value.length === 0)
23
+ continue;
24
+ onChunk?.(value);
25
+ let offset = 0;
26
+ while (offset < value.length)
27
+ offset += writeSync(fd, value, offset, value.length - offset);
28
+ written += value.length;
29
+ }
30
+ return written;
31
+ }
32
+ catch (error) {
33
+ await reader.cancel().catch(() => undefined);
34
+ throw error;
35
+ }
36
+ finally {
37
+ closeSync(fd);
38
+ }
39
+ }