@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,313 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ import { AppiumUnavailable, PortRange } from '../appium/server.js';
3
+ import { hostDevice } from '../devices/desktop.js';
4
+ import { ACCESSIBILITY_BLOCKED, appBundleOf, BUNDLE_ID_RE, isAccessibilityError, MACOS_NEEDS_MAC, mac2Capabilities, macosReadiness, markAccessibilityBlocked, readBundleId, } from '../devices/macos.js';
5
+ import { homePaths } from '../home.js';
6
+ import { androidSdkFor, configuredSdkPath, sharedAppiumServer } from './android-appium.js';
7
+ import { clip, errorMessage, expectWithin } from './common.js';
8
+ import { DesktopAppiumDriver, validateNativeAction } from './desktop-appium.js';
9
+ import { Blocked, LaunchRefused, } from './driver.js';
10
+ import { emptyScreen } from './snapshot/format.js';
11
+ import { macKey } from './webdriver/desktop-keys.js';
12
+ import { predicateString } from './webdriver/ios-locate.js';
13
+ import { elementIdOf } from './webdriver/locate.js';
14
+ import { NATIVE_DESKTOP_ACTIONS } from './webdriver/native-actions.js';
15
+ import { describeMacTarget, MACOS_LOCATORS, macSelectors } from './webdriver/native-locate.js';
16
+ import { openSession, webdriverMessage } from './webdriver/session.js';
17
+ import { macSnapshotFromXml } from './webdriver/xml-tree-desktop.js';
18
+ /** WebDriverAgentMac ports on this Mac. */
19
+ export const MAC2_PORTS = new PortRange(10100, 10199);
20
+ /** XCUIElementType raw values the driver checks (`elementType` attribute). */
21
+ const ELEMENT_TYPE_COMBO_BOX = '15';
22
+ /** Runs a Mac2 `macos:` extension command. */
23
+ export function macos(client, command, args = {}) {
24
+ if (!/^[a-zA-Z]+$/.test(command))
25
+ throw new Error(`invalid macos command '${command}'`);
26
+ return client.executeScript(`macos: ${command}`, [args]);
27
+ }
28
+ /** The class chain that finds a window whose title contains (or equals) `title`. */
29
+ export function macWindowChain(title, exact) {
30
+ const literal = predicateString(title).replace(/`/g, '\\`');
31
+ return `**/XCUIElementTypeWindow[\`title ${exact ? '==' : 'CONTAINS'} ${literal}\`]`;
32
+ }
33
+ export function macOps(client) {
34
+ return {
35
+ platform: 'macos',
36
+ selectors: macSelectors,
37
+ describe: describeMacTarget,
38
+ click: async (elementId) => void (await macos(client, 'click', { elementId })),
39
+ doubleClick: async (elementId) => void (await macos(client, 'doubleClick', { elementId })),
40
+ rightClick: async (elementId) => void (await macos(client, 'rightClick', { elementId })),
41
+ hover: async (elementId) => void (await macos(client, 'hover', { elementId })),
42
+ press: async (chord, elementId) => {
43
+ await macos(client, 'keys', { keys: [macKey(chord)], ...(elementId ? { elementId } : {}) });
44
+ },
45
+ isChecked: async (elementId) => {
46
+ const value = (await client.getElementAttribute(elementId, 'value').catch(() => null))?.trim().toLowerCase();
47
+ if (value === '1' || value === 'true')
48
+ return true;
49
+ if (value === '0' || value === 'false')
50
+ return false;
51
+ return null;
52
+ },
53
+ selectOption: async (elementId, value, timeoutMs) => {
54
+ const type = await client.getElementAttribute(elementId, 'elementType').catch(() => null);
55
+ if (type === ELEMENT_TYPE_COMBO_BOX) {
56
+ await client.elementClear(elementId);
57
+ await client.elementSendKeys(elementId, value);
58
+ return;
59
+ }
60
+ // A pop-up button: open its menu and click the item.
61
+ await macos(client, 'click', { elementId });
62
+ const chain = `**/XCUIElementTypeMenuItem[\`title == ${predicateString(value).replace(/`/g, '\\`')}\`]`;
63
+ const deadline = Date.now() + Math.max(timeoutMs, 500);
64
+ for (;;) {
65
+ for (const found of await client.findElements('-ios class chain', chain).catch(() => [])) {
66
+ const id = elementIdOf(found);
67
+ if (await client.isElementDisplayed(id).catch(() => false)) {
68
+ await macos(client, 'click', { elementId: id });
69
+ return;
70
+ }
71
+ }
72
+ if (Date.now() >= deadline)
73
+ break;
74
+ await sleep(300);
75
+ }
76
+ const titles = [];
77
+ for (const found of (await client.findElements('-ios class chain', '**/XCUIElementTypeMenuItem').catch(() => [])).slice(0, 30)) {
78
+ const id = elementIdOf(found);
79
+ if (!(await client.isElementDisplayed(id).catch(() => false)))
80
+ continue;
81
+ const title = await client.getElementAttribute(id, 'title').catch(() => null);
82
+ if (title)
83
+ titles.push(`"${clip(title)}"`);
84
+ }
85
+ await macos(client, 'keys', { keys: [macKey({ modifiers: [], key: 'escape' })] }).catch(() => undefined);
86
+ throw new Error(`the list has no option "${value}" (options: ${titles.join(', ') || 'none'})`);
87
+ },
88
+ text: async (elementId) => {
89
+ const text = (await client.getElementText(elementId).catch(() => '')) ?? '';
90
+ if (text.trim())
91
+ return text.trim();
92
+ for (const name of ['value', 'title', 'label']) {
93
+ const value = await client.getElementAttribute(elementId, name).catch(() => null);
94
+ if (value?.trim())
95
+ return value.trim();
96
+ }
97
+ return '';
98
+ },
99
+ };
100
+ }
101
+ /** Native macOS apps through Appium Mac2 (contracts §M). */
102
+ export class Mac2AppiumDriver extends DesktopAppiumDriver {
103
+ deps;
104
+ platform = 'macos';
105
+ capabilities = { actions: NATIVE_DESKTOP_ACTIONS, targets: [...MACOS_LOCATORS], video: 'mp4' };
106
+ bundleId = null;
107
+ /** The `.app` to launch by path (builds and local paths), so an installed copy is never used instead. */
108
+ appPath = null;
109
+ port = null;
110
+ launched = false;
111
+ disposed = false;
112
+ constructor(ctx, appium, deps = {}) {
113
+ super(ctx, appium);
114
+ this.deps = deps;
115
+ }
116
+ get ops() {
117
+ return macOps(this.requireClient());
118
+ }
119
+ async launch() {
120
+ if (process.platform !== 'darwin' && !this.deps.open)
121
+ throw new LaunchRefused(MACOS_NEEDS_MAC);
122
+ const app = this.ctx.job.app;
123
+ const resolved = await this.resolveApp('macos');
124
+ if (resolved.kind === 'installed') {
125
+ const id = app?.id?.trim() ?? '';
126
+ if (!id)
127
+ throw new LaunchRefused('an installed macOS app needs its bundle id (set the app id in the environment)');
128
+ if (!BUNDLE_ID_RE.test(id))
129
+ throw new LaunchRefused(`'${id}' is not a bundle id`);
130
+ this.bundleId = id;
131
+ }
132
+ else {
133
+ const bundle = appBundleOf(resolved.executable ?? '');
134
+ if (!bundle)
135
+ throw new LaunchRefused('macOS apps must be .app bundles (the build or path holds no .app)');
136
+ this.appPath = bundle;
137
+ try {
138
+ this.bundleId = await (this.deps.readBundleId ?? readBundleId)(bundle);
139
+ }
140
+ catch (error) {
141
+ throw new LaunchRefused(`the app's bundle id could not be read: ${errorMessage(error)}`);
142
+ }
143
+ if (app?.id && app.id !== this.bundleId) {
144
+ this.ctx.log.warn('the build has a different bundle id than the environment names', { environment: app.id, build: this.bundleId });
145
+ }
146
+ }
147
+ const args = this.firstLaunchArgs();
148
+ await this.openSession();
149
+ await this.startRecording();
150
+ await this.start(args);
151
+ }
152
+ async openSession() {
153
+ // Appium may (re)start and truncate its log in ensure(): measure the offset afterwards.
154
+ let logOffset = 0;
155
+ try {
156
+ const url = await this.appium.ensure();
157
+ logOffset = this.appium.logOffset();
158
+ this.port = await MAC2_PORTS.take();
159
+ const capabilities = mac2Capabilities({
160
+ systemPort: this.port,
161
+ newCommandTimeoutSecs: Math.max(300, Math.ceil((this.ctx.session?.idle_timeout_seconds ?? 0) + 60)),
162
+ });
163
+ this.client = await (this.deps.open ?? openSession)(url, capabilities, 600_000);
164
+ }
165
+ catch (error) {
166
+ const log = this.appium.logSince(logOffset, 256_000);
167
+ const reason = error instanceof AppiumUnavailable ? error.message : webdriverMessage(error);
168
+ if (isAccessibilityError(`${reason}\n${log}`)) {
169
+ markAccessibilityBlocked(this.paths, reason);
170
+ throw new LaunchRefused(ACCESSIBILITY_BLOCKED);
171
+ }
172
+ const tail = log.trim().split('\n').slice(-5).join(' | ');
173
+ throw new Blocked(`Appium could not start a Mac2 session: ${reason}${tail ? ` (appium: ${clip(tail)})` : ''}`);
174
+ }
175
+ }
176
+ appArgs() {
177
+ return this.appPath ? { path: this.appPath } : { bundleId: this.bundleId };
178
+ }
179
+ /** Quits a running copy, then launches the app fresh with `args`. */
180
+ async start(args) {
181
+ const client = this.requireClient();
182
+ await macos(client, 'terminateApp', { bundleId: this.bundleId }).catch(() => undefined);
183
+ try {
184
+ await macos(client, 'launchApp', { ...this.appArgs(), arguments: args });
185
+ this.launched = true;
186
+ }
187
+ catch (error) {
188
+ const message = webdriverMessage(error);
189
+ if (isAccessibilityError(message)) {
190
+ markAccessibilityBlocked(this.paths, message);
191
+ throw new LaunchRefused(ACCESSIBILITY_BLOCKED);
192
+ }
193
+ throw new Blocked(`the app could not be launched: ${message}`);
194
+ }
195
+ }
196
+ async run(action) {
197
+ if (this.skipFirstLaunch(action))
198
+ return {};
199
+ validateNativeAction('macos', action);
200
+ try {
201
+ switch (action.type) {
202
+ case 'launch':
203
+ await this.start((action.args ?? []).map(this.render));
204
+ return {};
205
+ case 'terminate':
206
+ await macos(this.requireClient(), 'terminateApp', { bundleId: this.bundleId });
207
+ this.launched = false;
208
+ return {};
209
+ case 'focus_window':
210
+ await this.focusWindow(action);
211
+ return {};
212
+ default:
213
+ return await this.runShared(action);
214
+ }
215
+ }
216
+ catch (error) {
217
+ throw this.translate(error);
218
+ }
219
+ }
220
+ /** Activates the app and raises the window with that title (a click on its title bar). */
221
+ async focusWindow(action) {
222
+ const client = this.requireClient();
223
+ const timeout = action.timeout_ms ?? this.ctx.timeouts.actionMs;
224
+ const wanted = this.render(action.title ?? '');
225
+ await macos(client, 'activateApp', { bundleId: this.bundleId }).catch(() => undefined);
226
+ const chain = macWindowChain(wanted, action.match === 'equals');
227
+ let titles = [];
228
+ await expectWithin(timeout, async () => {
229
+ const found = await client.findElements('-ios class chain', chain);
230
+ const first = found[0];
231
+ if (!first) {
232
+ titles = [];
233
+ for (const window of (await client.findElements('-ios class chain', '**/XCUIElementTypeWindow').catch(() => [])).slice(0, 10)) {
234
+ const title = await client.getElementAttribute(elementIdOf(window), 'title').catch(() => null);
235
+ titles.push(`"${clip(title ?? '')}"`);
236
+ }
237
+ return false;
238
+ }
239
+ const rect = await client.getElementRect(elementIdOf(first));
240
+ await macos(client, 'click', { x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + 8) });
241
+ return true;
242
+ }, () => `a window whose title ${action.match === 'equals' ? 'equals' : 'contains'} "${wanted}", found ${titles.join(', ') || 'none'}`);
243
+ }
244
+ async snapshot(options = {}) {
245
+ const client = this.requireClient();
246
+ const tree = macSnapshotFromXml(await client.getPageSource(), this.snapshotOptions(options));
247
+ this.lastRefs = tree.refs;
248
+ const screen = await this.screen().catch(() => emptyScreen());
249
+ return {
250
+ platform: 'macos',
251
+ taken_at: new Date().toISOString(),
252
+ screen,
253
+ app: { id: this.bundleId, activity: null },
254
+ root: tree.root,
255
+ text: tree.text,
256
+ refs: tree.refs,
257
+ element_count: tree.element_count,
258
+ truncated: tree.truncated,
259
+ };
260
+ }
261
+ async screen() {
262
+ const client = this.requireClient();
263
+ const rect = await client.getWindowRect().catch(() => null);
264
+ const window = (await client.findElements('-ios class chain', '**/XCUIElementTypeWindow').catch(() => []))[0];
265
+ const title = window ? await client.getElementAttribute(elementIdOf(window), 'title').catch(() => null) : null;
266
+ return {
267
+ width: rect?.width ?? null,
268
+ height: rect?.height ?? null,
269
+ scale: null,
270
+ orientation: null,
271
+ activity: null,
272
+ url: null,
273
+ title: title || null,
274
+ package: this.bundleId,
275
+ };
276
+ }
277
+ /** Clicks at screen coordinates (the snapshot's bounds, in points). */
278
+ async tapAt(x, y) {
279
+ if (!Number.isFinite(x) || !Number.isFinite(y))
280
+ throw new Error('coordinates must be numbers');
281
+ await macos(this.requireClient(), 'click', { x: Math.round(x), y: Math.round(y) });
282
+ }
283
+ async dispose() {
284
+ if (this.disposed)
285
+ return;
286
+ this.disposed = true;
287
+ await this.disposeCommon();
288
+ const client = this.client;
289
+ this.client = null;
290
+ if (client && this.launched && this.bundleId)
291
+ await macos(client, 'terminateApp', { bundleId: this.bundleId }).catch(() => undefined);
292
+ if (client)
293
+ await Promise.race([client.deleteSession().catch(() => undefined), sleep(30_000)]);
294
+ if (this.port !== null)
295
+ MAC2_PORTS.release(this.port);
296
+ }
297
+ }
298
+ export const mac2DriverFactory = {
299
+ platform: 'macos',
300
+ async availability(host) {
301
+ if (host.os !== 'macos')
302
+ return { ok: false, labels: [], devices: [], reasons: [MACOS_NEEDS_MAC] };
303
+ const readiness = await macosReadiness(homePaths(process.env));
304
+ if (!readiness.ok)
305
+ return { ok: false, labels: [], devices: [], reasons: readiness.reasons };
306
+ return { ok: true, labels: ['macos'], devices: [hostDevice('macos', { name: host.hostname })], reasons: [] };
307
+ },
308
+ async create(ctx, shared) {
309
+ const paths = ctx.paths ?? homePaths(process.env);
310
+ const appium = shared.appium ?? sharedAppiumServer(paths, androidSdkFor(process.env, ctx.androidSdkPath ?? configuredSdkPath(paths)));
311
+ return new Mac2AppiumDriver(ctx, appium);
312
+ },
313
+ };
@@ -0,0 +1,47 @@
1
+ import { androidDriverFactory } from './android-appium.js';
2
+ import { electronDriverFactory } from './electron-playwright.js';
3
+ import { iosDriverFactory } from './ios-appium.js';
4
+ import { mac2DriverFactory } from './mac2-appium.js';
5
+ import { tauriDriverFactory } from './tauri-webdriver.js';
6
+ import { webPlaywrightFactory } from './web-playwright.js';
7
+ import { windowsDriverFactory } from './windows-appium.js';
8
+ /** True in the web-only Docker image (`ENV RUNNER_DOCKER=1` in the Dockerfile). */
9
+ export function isDockerRunner(env = process.env) {
10
+ return ['1', 'true', 'yes', 'on'].includes((env.RUNNER_DOCKER ?? '').trim().toLowerCase());
11
+ }
12
+ /**
13
+ * The factories this runner version ships: web (Playwright), android (Appium UiAutomator2),
14
+ * ios (Appium XCUITest), electron (Playwright), tauri (WebDriver), macos (Appium Mac2) and
15
+ * windows (Appium Windows + WinAppDriver). The Docker image is web-only (contracts §L): with
16
+ * `RUNNER_DOCKER=1` only web is registered, so it never advertises desktop host devices.
17
+ */
18
+ export function defaultFactories(env = process.env) {
19
+ if (isDockerRunner(env))
20
+ return [webPlaywrightFactory];
21
+ return [webPlaywrightFactory, androidDriverFactory, iosDriverFactory, electronDriverFactory, tauriDriverFactory, mac2DriverFactory, windowsDriverFactory];
22
+ }
23
+ /** The platforms this runner version can drive. */
24
+ export function createRegistry(factories = defaultFactories()) {
25
+ return new Map(factories.map((factory) => [factory.platform, factory]));
26
+ }
27
+ /**
28
+ * Every platform's availability on this host. A factory that throws is reported as
29
+ * unavailable with the error instead of failing the whole check.
30
+ */
31
+ export async function registryAvailability(registry, host) {
32
+ const out = new Map();
33
+ for (const [platform, factory] of registry) {
34
+ try {
35
+ out.set(platform, await factory.availability(host));
36
+ }
37
+ catch (error) {
38
+ out.set(platform, {
39
+ ok: false,
40
+ labels: [],
41
+ devices: [],
42
+ reasons: [`could not check: ${error instanceof Error ? error.message : String(error)}`],
43
+ });
44
+ }
45
+ }
46
+ return out;
47
+ }