@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,189 @@
1
+ import { accessSync, constants } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { readJsonFile } from '../home.js';
5
+ import { displayNeed, findOnPath, NO_DISPLAY, XVFB_DISPLAY } from './desktop.js';
6
+ /**
7
+ * Where Tauri jobs run (contracts §L): `tauri-driver` 2.0.6 with the platform's native
8
+ * WebDriver (WebKitWebDriver on Linux, msedgedriver on Windows), or on macOS an app build that
9
+ * embeds the WebDriver plugin (`tauri.macos_plugin`). There is no tauri-driver backend on macOS.
10
+ */
11
+ export const TAURI_DRIVER_VERSION = '2.0.6';
12
+ export const TAURI_DRIVER_INSTALL = `cargo install tauri-driver --version ${TAURI_DRIVER_VERSION} --locked`;
13
+ export const WEBKIT_DRIVER_INSTALL = 'sudo apt install webkit2gtk-driver';
14
+ export const MSEDGEDRIVER_URL = 'https://developer.microsoft.com/microsoft-edge/tools/webdriver/';
15
+ /** The exact message a macOS job or session is blocked with when the app exposes no plugin port. */
16
+ export const MACOS_PLUGIN_BLOCKED = 'Tauri on macOS needs a build with the embedded WebDriver plugin';
17
+ function pick(...values) {
18
+ for (const value of values) {
19
+ const trimmed = value?.trim();
20
+ if (trimmed)
21
+ return trimmed;
22
+ }
23
+ return null;
24
+ }
25
+ function truthy(value) {
26
+ if (value === undefined || value.trim() === '')
27
+ return undefined;
28
+ return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
29
+ }
30
+ /** Environment wins over the config file. */
31
+ export function tauriSettings(env, file) {
32
+ return {
33
+ driverPath: pick(env.RUNNER_TAURI_DRIVER, file?.tauri?.driver_path),
34
+ nativeDriverPath: pick(env.RUNNER_TAURI_NATIVE_DRIVER, file?.tauri?.native_driver_path),
35
+ macosPlugin: truthy(env.RUNNER_TAURI_MACOS_PLUGIN) ?? file?.tauri?.macos_plugin === true,
36
+ };
37
+ }
38
+ /** Reads the settings from the runner home's config file (unreadable config = defaults). */
39
+ export function loadTauriSettings(env, paths) {
40
+ let file = null;
41
+ try {
42
+ file = readJsonFile(paths.config);
43
+ }
44
+ catch {
45
+ file = null;
46
+ }
47
+ return tauriSettings(env, file);
48
+ }
49
+ function lookup(options) {
50
+ const platform = options.platform ?? process.platform;
51
+ const env = options.env ?? process.env;
52
+ const exists = options.exists;
53
+ const find = (name) => findOnPath(name, env, exists);
54
+ return { platform, env, exists, find, home: options.home ?? homedir() };
55
+ }
56
+ /** `tauri-driver`: configured path, then `~/.cargo/bin`, then PATH. */
57
+ export function findTauriDriver(settings, options = {}) {
58
+ const { platform, find, exists, home } = lookup(options);
59
+ const name = platform === 'win32' ? 'tauri-driver.exe' : 'tauri-driver';
60
+ if (settings.driverPath)
61
+ return settings.driverPath;
62
+ const cargo = join(home, '.cargo', 'bin', name);
63
+ if ((exists ?? defaultExists)(cargo))
64
+ return cargo;
65
+ return find(name);
66
+ }
67
+ /** The native WebDriver tauri-driver talks to: configured path, then PATH. Null on macOS. */
68
+ export function findNativeDriver(settings, options = {}) {
69
+ const { platform, find } = lookup(options);
70
+ if (settings.nativeDriverPath)
71
+ return settings.nativeDriverPath;
72
+ if (platform === 'win32')
73
+ return find('msedgedriver.exe');
74
+ if (platform === 'linux')
75
+ return find('WebKitWebDriver');
76
+ return null;
77
+ }
78
+ function defaultExists(path) {
79
+ try {
80
+ accessSync(path, constants.X_OK);
81
+ return true;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ /** Which backend this host runs Tauri jobs with, or why it cannot. */
88
+ export function tauriBackend(settings, options = {}) {
89
+ const platform = options.platform ?? process.platform;
90
+ if (platform === 'darwin') {
91
+ if (settings.macosPlugin)
92
+ return { kind: 'macos-plugin' };
93
+ return {
94
+ kind: 'unavailable',
95
+ reasons: [
96
+ 'macOS has no tauri-driver; Tauri jobs run here only with builds that embed the WebDriver plugin (set tauri.macos_plugin true)',
97
+ ],
98
+ };
99
+ }
100
+ if (platform !== 'linux' && platform !== 'win32')
101
+ return { kind: 'unavailable', reasons: [`Tauri jobs do not run on ${platform}`] };
102
+ const reasons = [];
103
+ const driver = findTauriDriver(settings, options);
104
+ if (!driver)
105
+ reasons.push(`tauri-driver was not found (install it with \`${TAURI_DRIVER_INSTALL}\` or set tauri.driver_path)`);
106
+ const nativeDriver = findNativeDriver(settings, options);
107
+ if (!nativeDriver) {
108
+ reasons.push(platform === 'linux'
109
+ ? `WebKitWebDriver was not found (install it with \`${WEBKIT_DRIVER_INSTALL}\`)`
110
+ : 'msedgedriver.exe was not found (download the version matching WebView2 and set tauri.native_driver_path)');
111
+ }
112
+ if (!driver || !nativeDriver)
113
+ return { kind: 'unavailable', reasons };
114
+ return { kind: 'driver', driver, nativeDriver, os: platform === 'linux' ? 'linux' : 'windows' };
115
+ }
116
+ /**
117
+ * tauri-driver's argv for one job: its own port, the native driver's port and, on Windows (or
118
+ * when configured), the native driver's path.
119
+ */
120
+ export function tauriDriverArgv(options) {
121
+ for (const port of [options.port, options.nativePort]) {
122
+ if (!Number.isInteger(port) || port <= 0 || port > 65_535)
123
+ throw new Error(`invalid port ${port}`);
124
+ }
125
+ const argv = ['--port', String(options.port), '--native-port', String(options.nativePort)];
126
+ if (options.nativeDriver && (options.os === 'windows' || options.configuredNative))
127
+ argv.push('--native-driver', options.nativeDriver);
128
+ return argv;
129
+ }
130
+ /** W3C capabilities for a tauri-driver session: tauri-driver maps `tauri:options` to the native driver's. */
131
+ export function tauriCapabilities(application, args) {
132
+ return { 'tauri:options': { application, args } };
133
+ }
134
+ /** `doctor` checks for Tauri on this host. */
135
+ export function tauriChecks(settings, options = {}) {
136
+ const platform = options.platform ?? process.platform;
137
+ const env = options.env ?? process.env;
138
+ const checks = [];
139
+ if (platform === 'darwin') {
140
+ checks.push(settings.macosPlugin
141
+ ? {
142
+ id: 'tauri.macos_plugin',
143
+ title: 'Tauri: macOS WebDriver plugin',
144
+ status: 'ok',
145
+ detail: 'enabled: builds must embed the WebDriver plugin; plain builds are blocked',
146
+ }
147
+ : {
148
+ id: 'tauri.macos_plugin',
149
+ title: 'Tauri: macOS WebDriver plugin',
150
+ status: 'fail',
151
+ detail: 'macOS has no tauri-driver; this Mac runs Tauri jobs only for builds that embed the WebDriver plugin',
152
+ fix: 'leera-qa-runner config set tauri.macos_plugin true (only when your builds embed the plugin)',
153
+ });
154
+ return checks;
155
+ }
156
+ if (platform !== 'linux' && platform !== 'win32') {
157
+ checks.push({ id: 'tauri.host', title: 'Tauri', status: 'fail', detail: `Tauri jobs do not run on ${platform}` });
158
+ return checks;
159
+ }
160
+ const driver = findTauriDriver(settings, options);
161
+ checks.push(driver
162
+ ? { id: 'tauri.driver', title: 'Tauri: tauri-driver', status: 'ok', detail: driver }
163
+ : { id: 'tauri.driver', title: 'Tauri: tauri-driver', status: 'fail', detail: 'tauri-driver was not found', fix: TAURI_DRIVER_INSTALL });
164
+ const native = findNativeDriver(settings, options);
165
+ if (platform === 'linux') {
166
+ checks.push(native
167
+ ? { id: 'tauri.native_driver', title: 'Tauri: WebKitWebDriver', status: 'ok', detail: native }
168
+ : { id: 'tauri.native_driver', title: 'Tauri: WebKitWebDriver', status: 'fail', detail: 'WebKitWebDriver was not found', fix: WEBKIT_DRIVER_INSTALL });
169
+ const need = displayNeed(platform, env, (name) => findOnPath(name, env, options.exists));
170
+ if (need.kind === 'existing')
171
+ checks.push({ id: 'tauri.display', title: 'Tauri: display', status: 'ok', detail: `DISPLAY ${need.display}` });
172
+ else if (need.kind === 'xvfb')
173
+ checks.push({ id: 'tauri.display', title: 'Tauri: display', status: 'ok', detail: `no DISPLAY; jobs start ${need.xvfb} ${XVFB_DISPLAY}` });
174
+ else if (need.kind === 'missing')
175
+ checks.push({ id: 'tauri.display', title: 'Tauri: display', status: 'warn', detail: NO_DISPLAY, fix: 'sudo apt-get install xvfb' });
176
+ }
177
+ else {
178
+ checks.push(native
179
+ ? { id: 'tauri.native_driver', title: 'Tauri: msedgedriver', status: 'ok', detail: native }
180
+ : {
181
+ id: 'tauri.native_driver',
182
+ title: 'Tauri: msedgedriver',
183
+ status: 'fail',
184
+ detail: 'msedgedriver.exe was not found',
185
+ fix: `download the msedgedriver matching your WebView2 Runtime from ${MSEDGEDRIVER_URL}, then leera-qa-runner config set tauri.native_driver_path C:\\path\\to\\msedgedriver.exe`,
186
+ });
187
+ }
188
+ return checks;
189
+ }
@@ -0,0 +1,6 @@
1
+ /** Every platform the protocol knows; this runner version drives `web` only. */
2
+ export const PLATFORMS = ['web', 'android', 'ios', 'electron', 'tauri', 'windows', 'macos', 'linux'];
3
+ export const BROWSER_DEVICE_ID = 'browser';
4
+ export function isPlatform(value) {
5
+ return typeof value === 'string' && PLATFORMS.includes(value);
6
+ }
@@ -0,0 +1,362 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { createReadStream, existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
4
+ import { join, win32 } from 'node:path';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { setTimeout as sleep } from 'node:timers/promises';
7
+ import { bundledDrivers } from '../appium/drivers.js';
8
+ import { appiumHomeDir, seedAppiumHome } from '../appium/home.js';
9
+ import { bundledAppium, freePort } from '../appium/server.js';
10
+ import { writeResponseBody } from '../download.js';
11
+ import { ExitCode, messageOf } from '../errors.js';
12
+ import { ensureHome, readJsonFile } from '../home.js';
13
+ import { runTool } from './android/adb.js';
14
+ import { findOnPath } from './desktop.js';
15
+ import { findFfmpeg } from './screen-record.js';
16
+ /**
17
+ * Native Windows apps (contracts §M): Appium's Windows driver in front of WinAppDriver 1.2.1,
18
+ * which the runner starts itself on a free loopback port. WinAppDriver needs Developer Mode and
19
+ * an interactive desktop session (not a service session without a desktop).
20
+ */
21
+ export const WINDOWS_NEEDS_WINDOWS = 'Windows app jobs need a Windows runner';
22
+ export const WINAPPDRIVER_VERSION = '1.2.1';
23
+ /** The official release asset (github.com/microsoft/WinAppDriver, tag v1.2.1). */
24
+ export const WINAPPDRIVER_MSI_URL = 'https://github.com/microsoft/WinAppDriver/releases/download/v1.2.1/WindowsApplicationDriver_1.2.1.msi';
25
+ export const WINAPPDRIVER_MSI_NAME = 'WindowsApplicationDriver_1.2.1.msi';
26
+ export const WINAPPDRIVER_MSI_SHA256 = 'a76a8f4e44b29bad331acf6b6c248fcc65324f502f28826ad2acd5f3c80857fe';
27
+ export const WINAPPDRIVER_MSI_SIZE = 3_932_160;
28
+ export const DEVELOPER_MODE_STEPS = 'Settings → System → For developers (Windows 10: Update & Security → For developers) → turn on Developer Mode';
29
+ export function windowsSettings(env, file) {
30
+ return { winAppDriverPath: env.RUNNER_WINAPPDRIVER?.trim() || file?.windows?.winappdriver_path?.trim() || null };
31
+ }
32
+ export function loadWindowsSettings(env, paths) {
33
+ try {
34
+ return windowsSettings(env, readJsonFile(paths.config));
35
+ }
36
+ catch {
37
+ return windowsSettings(env, null);
38
+ }
39
+ }
40
+ /** Where WinAppDriver.exe may be: the configured path, then the msi's install folders. */
41
+ export function winAppDriverCandidates(settings, env = process.env) {
42
+ const out = [];
43
+ if (settings.winAppDriverPath)
44
+ out.push(settings.winAppDriverPath);
45
+ if (env.APPIUM_WAD_PATH?.trim())
46
+ out.push(env.APPIUM_WAD_PATH.trim());
47
+ for (const root of [env['ProgramFiles(x86)'], env.ProgramFiles, `${env.SystemDrive || 'C:'}\\Program Files (x86)`]) {
48
+ if (root?.trim())
49
+ out.push(win32.join(root.trim(), 'Windows Application Driver', 'WinAppDriver.exe'));
50
+ }
51
+ return [...new Set(out)];
52
+ }
53
+ export function findWinAppDriver(settings, env = process.env, exists = existsSync) {
54
+ return winAppDriverCandidates(settings, env).find((path) => exists(path)) ?? null;
55
+ }
56
+ /** WinAppDriver's fixed argv: listen on loopback only, on `port`. */
57
+ export function winAppDriverArgv(port) {
58
+ if (!Number.isInteger(port) || port <= 0 || port > 65_535)
59
+ throw new Error(`invalid port ${port}`);
60
+ return ['127.0.0.1', String(port)];
61
+ }
62
+ /** `reg query` for the Developer Mode switch (read only). */
63
+ export function developerModeArgv() {
64
+ return ['query', 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\AppModelUnlock', '/v', 'AllowDevelopmentWithoutDevLicense'];
65
+ }
66
+ export function parseDeveloperMode(output) {
67
+ return /AllowDevelopmentWithoutDevLicense\s+REG_DWORD\s+0x0*1\b/i.test(output);
68
+ }
69
+ export async function developerModeOn(run = async (file, args) => (await runTool(file, args, { timeoutMs: 10_000 })).stdout) {
70
+ try {
71
+ return parseDeveloperMode(await run('reg', developerModeArgv()));
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ /** `msiexec /i <msi> /qn /norestart` (only for `setup windows --install`, from an elevated shell). */
78
+ export function msiexecArgv(msi) {
79
+ if (!msi.toLowerCase().endsWith('.msi'))
80
+ throw new Error(`'${msi}' is not an .msi file`);
81
+ return ['/i', msi, '/qn', '/norestart'];
82
+ }
83
+ /** Quotes one argument for a Windows command line (CommandLineToArgvW rules). */
84
+ export function quoteWindowsArg(arg) {
85
+ if (arg !== '' && !/[\s"]/.test(arg))
86
+ return arg;
87
+ let out = '"';
88
+ let slashes = 0;
89
+ for (const char of arg) {
90
+ if (char === '\\') {
91
+ slashes += 1;
92
+ continue;
93
+ }
94
+ if (char === '"') {
95
+ out += '\\'.repeat(slashes * 2 + 1);
96
+ }
97
+ else {
98
+ out += '\\'.repeat(slashes);
99
+ }
100
+ slashes = 0;
101
+ out += char;
102
+ }
103
+ return `${out}${'\\'.repeat(slashes * 2)}"`;
104
+ }
105
+ /** W3C capabilities for an Appium Windows driver session against the runner's WinAppDriver. */
106
+ export function windowsCapabilities(options) {
107
+ if (!options.app && !options.topLevelWindow)
108
+ throw new Error('a Windows session needs an app or a window');
109
+ if (options.topLevelWindow && !/^0x[0-9a-f]+$/i.test(options.topLevelWindow))
110
+ throw new Error(`invalid window handle '${options.topLevelWindow}'`);
111
+ return {
112
+ platformName: 'Windows',
113
+ 'appium:automationName': 'Windows',
114
+ ...(options.topLevelWindow ? { 'appium:appTopLevelWindow': options.topLevelWindow } : { 'appium:app': options.app }),
115
+ ...(options.args && options.args.length > 0 ? { 'appium:appArguments': options.args.map(quoteWindowsArg).join(' ') } : {}),
116
+ ...(options.workingDir ? { 'appium:appWorkingDir': options.workingDir } : {}),
117
+ 'appium:wadUrl': options.wadUrl,
118
+ 'appium:newCommandTimeout': options.newCommandTimeoutSecs ?? 300,
119
+ 'appium:createSessionTimeout': 60_000,
120
+ 'ms:waitForAppLaunch': 25,
121
+ };
122
+ }
123
+ /** `Contoso.App_8wekyb3d8bbwe!App` */
124
+ export const AUMID_RE = /^[A-Za-z0-9._-]+_[a-z0-9]{13}![A-Za-z0-9._-]+$/;
125
+ /** What `install: installed` launches: an AUMID as is, an absolute exe, or an exe name found on PATH. */
126
+ export function installedWindowsApp(appId, env = process.env, exists) {
127
+ const id = appId.trim();
128
+ if (AUMID_RE.test(id))
129
+ return id;
130
+ if (win32.isAbsolute(id) && id.toLowerCase().endsWith('.exe'))
131
+ return id;
132
+ if (/^[^\\/:*?"<>|]+\.exe$/i.test(id)) {
133
+ const found = findOnPath(id, { ...env, PATH: env.PATH ?? env.Path ?? '' }, exists);
134
+ if (found)
135
+ return found;
136
+ throw new Error(`${id} was not found on PATH on this runner`);
137
+ }
138
+ throw new Error(`'${id}' is neither an AppUserModelID (Publisher.App_xxxxxxxxxxxxx!App) nor an .exe name`);
139
+ }
140
+ /**
141
+ * The runner's own WinAppDriver: started on a free loopback port the first time a job needs it
142
+ * and reused while it answers `/status`.
143
+ */
144
+ /**
145
+ * WinAppDriver prints "Press ENTER to exit." and exits (code 0) as soon as a line or end of input
146
+ * arrives on stdin, so an ignored stdin (NUL) stops it at once: stdin stays an open pipe.
147
+ */
148
+ export const WINAPPDRIVER_STDIO = ['pipe', 'ignore', 'ignore'];
149
+ export class WinAppDriverProcess {
150
+ executable;
151
+ spawnImpl;
152
+ fetchImpl;
153
+ child = null;
154
+ port = 0;
155
+ exited = null;
156
+ constructor(executable, spawnImpl = spawn, fetchImpl = fetch) {
157
+ this.executable = executable;
158
+ this.spawnImpl = spawnImpl;
159
+ this.fetchImpl = fetchImpl;
160
+ }
161
+ get url() {
162
+ return `http://127.0.0.1:${this.port}`;
163
+ }
164
+ async healthy() {
165
+ if (!this.port)
166
+ return false;
167
+ try {
168
+ const response = await this.fetchImpl(`${this.url}/status`, { signal: AbortSignal.timeout(2_000) });
169
+ return response.ok;
170
+ }
171
+ catch {
172
+ return false;
173
+ }
174
+ }
175
+ async ensure(timeoutMs = 20_000) {
176
+ if (this.child && !this.exited && (await this.healthy()))
177
+ return this.url;
178
+ this.stop();
179
+ this.port = await freePort();
180
+ this.exited = null;
181
+ const child = this.spawnImpl(this.executable, winAppDriverArgv(this.port), { stdio: WINAPPDRIVER_STDIO, windowsHide: true });
182
+ this.child = child;
183
+ // The open stdin pipe is what keeps WinAppDriver running; nothing is ever written to it.
184
+ child.stdin?.on('error', () => undefined);
185
+ // A helper must not keep the runner alive: with the child and its pipe referenced, a process
186
+ // that finished its work (a CI run, a test) never exited. The exit hook below still ends it.
187
+ child.unref();
188
+ child.stdin?.unref?.();
189
+ // Never leave WinAppDriver behind when the runner exits.
190
+ const killOnExit = () => child.kill();
191
+ process.once('exit', killOnExit);
192
+ child.once('exit', (code) => {
193
+ this.exited = `WinAppDriver exited (code ${code})`;
194
+ process.removeListener('exit', killOnExit);
195
+ });
196
+ child.once('error', (error) => {
197
+ this.exited = `WinAppDriver could not start: ${error.message}`;
198
+ });
199
+ const deadline = Date.now() + timeoutMs;
200
+ for (;;) {
201
+ if (this.exited)
202
+ throw new Error(this.exited);
203
+ if (await this.healthy())
204
+ return this.url;
205
+ if (Date.now() > deadline) {
206
+ this.stop();
207
+ throw new Error(`WinAppDriver did not answer on port ${this.port} within ${Math.round(timeoutMs / 1000)} s (is Developer Mode on?)`);
208
+ }
209
+ await sleep(250);
210
+ }
211
+ }
212
+ stop() {
213
+ const child = this.child;
214
+ this.child = null;
215
+ if (child && !this.exited)
216
+ child.kill();
217
+ }
218
+ }
219
+ let processWad = null;
220
+ /** One WinAppDriver per runner process. */
221
+ export function sharedWinAppDriver(executable) {
222
+ processWad ??= new WinAppDriverProcess(executable);
223
+ return processWad;
224
+ }
225
+ export async function windowsReadiness(settings, platform = process.platform, env = process.env) {
226
+ if (platform !== 'win32')
227
+ return { ok: false, reasons: [WINDOWS_NEEDS_WINDOWS], winAppDriver: null };
228
+ const reasons = [];
229
+ if (!bundledAppium())
230
+ reasons.push('Appium is not installed with this runner (optional dependencies were omitted)');
231
+ else if (!bundledDrivers('win32').some((driver) => driver.platforms.includes('windows')))
232
+ reasons.push('the Appium Windows driver is not installed with this runner');
233
+ const winAppDriver = findWinAppDriver(settings, env);
234
+ if (!winAppDriver)
235
+ reasons.push(`WinAppDriver ${WINAPPDRIVER_VERSION} is not installed (run \`leera-qa-runner setup windows\`)`);
236
+ if (!(await developerModeOn()))
237
+ reasons.push(`Developer Mode is off (${DEVELOPER_MODE_STEPS})`);
238
+ return { ok: reasons.length === 0, reasons, winAppDriver };
239
+ }
240
+ /** `doctor` checks for Windows apps. */
241
+ export async function windowsChecks(env, paths, platform = process.platform) {
242
+ if (platform !== 'win32')
243
+ return [{ id: 'windows.host', title: 'Windows apps: host', status: 'warn', detail: WINDOWS_NEEDS_WINDOWS }];
244
+ const settings = loadWindowsSettings(env, paths);
245
+ const checks = [];
246
+ const appium = bundledAppium();
247
+ const driver = bundledDrivers('win32').find((d) => d.platforms.includes('windows'));
248
+ checks.push(appium && driver
249
+ ? { id: 'windows.appium', title: 'Windows apps: Appium', status: 'ok', detail: `Appium ${appium.version}, windows ${driver.version}` }
250
+ : { id: 'windows.appium', title: 'Windows apps: Appium', status: 'fail', detail: 'Appium or its Windows driver is not installed with this runner', fix: 'Reinstall the runner' });
251
+ const wad = findWinAppDriver(settings, env);
252
+ checks.push(wad
253
+ ? { id: 'windows.winappdriver', title: 'Windows apps: WinAppDriver', status: 'ok', detail: wad }
254
+ : { id: 'windows.winappdriver', title: 'Windows apps: WinAppDriver', status: 'fail', detail: `WinAppDriver ${WINAPPDRIVER_VERSION} is not installed`, fix: 'leera-qa-runner setup windows' });
255
+ checks.push((await developerModeOn())
256
+ ? { id: 'windows.developer_mode', title: 'Windows apps: Developer Mode', status: 'ok', detail: 'on' }
257
+ : { id: 'windows.developer_mode', title: 'Windows apps: Developer Mode', status: 'fail', detail: 'off (WinAppDriver needs it)', fix: DEVELOPER_MODE_STEPS });
258
+ const ffmpeg = findFfmpeg(env, platform);
259
+ checks.push(ffmpeg
260
+ ? { id: 'windows.video', title: 'Windows apps: video', status: 'ok', detail: `ffmpeg ${ffmpeg}` }
261
+ : { id: 'windows.video', title: 'Windows apps: video', status: 'warn', detail: 'ffmpeg is not installed; jobs record no video', fix: 'winget install ffmpeg' });
262
+ return checks;
263
+ }
264
+ export async function sha256File(path) {
265
+ const hash = createHash('sha256');
266
+ await pipeline(createReadStream(path), hash);
267
+ return hash.digest('hex');
268
+ }
269
+ /** Downloads the pinned msi into `dir` (kept when already there with the right digest). */
270
+ export async function downloadWinAppDriverMsi(dir, fetchImpl = fetch) {
271
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
272
+ const target = join(dir, WINAPPDRIVER_MSI_NAME);
273
+ if (existsSync(target) && (await sha256File(target)) === WINAPPDRIVER_MSI_SHA256)
274
+ return target;
275
+ const temp = `${target}.${process.pid}.part`;
276
+ try {
277
+ const response = await fetchImpl(WINAPPDRIVER_MSI_URL, { redirect: 'follow', signal: AbortSignal.timeout(10 * 60_000) });
278
+ if (!response.ok || !response.body)
279
+ throw new Error(`the download failed with HTTP ${response.status}`);
280
+ await writeResponseBody(response.body, temp);
281
+ const digest = await sha256File(temp);
282
+ if (digest !== WINAPPDRIVER_MSI_SHA256)
283
+ throw new Error(`the downloaded msi's sha256 ${digest} is not the pinned ${WINAPPDRIVER_MSI_SHA256}`);
284
+ renameSync(temp, target);
285
+ return target;
286
+ }
287
+ finally {
288
+ rmSync(temp, { force: true });
289
+ }
290
+ }
291
+ /**
292
+ * `setup windows`: seeds Appium's home with the bundled Windows driver, downloads the pinned
293
+ * WinAppDriver msi into the runner home and prints the install and Developer Mode steps.
294
+ * `--install` runs `msiexec /i … /qn` (needs an elevated shell); nothing else is changed.
295
+ */
296
+ export async function setupWindows(io, paths, options = {}, deps = {}) {
297
+ if (io.platform !== 'win32') {
298
+ io.stderr(`${WINDOWS_NEEDS_WINDOWS}; this computer cannot run them.`);
299
+ return ExitCode.environmentNotReady;
300
+ }
301
+ ensureHome(paths);
302
+ let ok = true;
303
+ const appium = bundledAppium();
304
+ const drivers = bundledDrivers('win32');
305
+ if (!appium || !drivers.some((d) => d.platforms.includes('windows'))) {
306
+ io.stderr('Appium and its Windows driver are not installed with this runner. Reinstall the runner with its installer, or with npm without --omit=optional.');
307
+ return ExitCode.environmentNotReady;
308
+ }
309
+ const home = appiumHomeDir(paths);
310
+ const seeded = seedAppiumHome(home, appium, drivers);
311
+ io.stdout(`Appium ${appium.version} home: ${home}${seeded.changed ? ' (updated)' : ''}`);
312
+ const settings = loadWindowsSettings(io.env, paths);
313
+ let wad = findWinAppDriver(settings, io.env);
314
+ if (wad) {
315
+ io.stdout(`WinAppDriver: ${wad}`);
316
+ }
317
+ else {
318
+ let msi = null;
319
+ try {
320
+ msi = await downloadWinAppDriverMsi(join(paths.root, 'winappdriver'), deps.fetchImpl);
321
+ io.stdout(`Downloaded WinAppDriver ${WINAPPDRIVER_VERSION} (sha256 ${WINAPPDRIVER_MSI_SHA256}) to ${msi}`);
322
+ }
323
+ catch (error) {
324
+ ok = false;
325
+ io.stderr(`WinAppDriver could not be downloaded: ${messageOf(error)}. Download it from ${WINAPPDRIVER_MSI_URL} yourself.`);
326
+ }
327
+ if (msi && options.install) {
328
+ const run = deps.runMsiexec ??
329
+ ((argv) => new Promise((resolve) => {
330
+ const child = spawn('msiexec', argv, { stdio: 'inherit', windowsHide: true });
331
+ child.on('error', () => resolve(1));
332
+ child.on('close', (code) => resolve(code ?? 1));
333
+ }));
334
+ io.stdout('Installing WinAppDriver (msiexec, needs an elevated shell) ...');
335
+ const code = await run(msiexecArgv(msi));
336
+ wad = findWinAppDriver(settings, io.env);
337
+ if (code !== 0 || !wad) {
338
+ ok = false;
339
+ io.stderr(`msiexec exited with ${code}; run this shell as Administrator, or install ${msi} by double-clicking it.`);
340
+ }
341
+ else {
342
+ io.stdout(`WinAppDriver installed: ${wad}`);
343
+ }
344
+ }
345
+ else if (msi) {
346
+ ok = false;
347
+ io.stdout(`Install it: double-click ${msi}, or from an elevated shell run\n msiexec ${msiexecArgv(msi).join(' ')}\n(or run \`leera-qa-runner setup windows --install\` as Administrator)`);
348
+ }
349
+ }
350
+ if (!(await developerModeOn())) {
351
+ ok = false;
352
+ io.stdout(`Developer Mode is off. WinAppDriver needs it: ${DEVELOPER_MODE_STEPS}.`);
353
+ }
354
+ else {
355
+ io.stdout('Developer Mode: on');
356
+ }
357
+ io.stdout('Windows app jobs need an interactive desktop: run the runner in a logged-in session (not as a Windows service without a desktop) and keep the screen unlocked.');
358
+ if (!findFfmpeg(io.env, io.platform))
359
+ io.stdout('ffmpeg is not installed: Windows app jobs record no video.');
360
+ io.stdout(ok ? 'Windows apps are set up. Check with `leera-qa-runner doctor --platform windows`.' : 'Windows setup is incomplete; see the messages above.');
361
+ return ok ? ExitCode.ok : ExitCode.environmentNotReady;
362
+ }