@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,282 @@
1
+ import { basename } from 'node:path';
2
+ import { disposeUnpacked } from '../builds/cleanup.js';
3
+ import { AppNotAvailable, resolveAppFile } from '../builds/install.js';
4
+ import { buildKind, DesktopBuildError, localDesktopApp, unpackDesktopBuild } from '../builds/unpack.js';
5
+ import { findFfmpeg, ScreenRecorder } from '../devices/screen-record.js';
6
+ import { homePaths } from '../home.js';
7
+ import { renderTemplate } from '../template.js';
8
+ import { APPIUM_LOG_MAX_BYTES, appiumLogLines, LOCAL_PATHS_REFUSED } from './android-appium.js';
9
+ import { errorMessage } from './common.js';
10
+ import { Blocked, LaunchRefused, NotFound, Unsupported } from './driver.js';
11
+ import { runNativeAction } from './webdriver/native-actions.js';
12
+ import { nativeFindAll, resolveNativeRef } from './webdriver/native-locate.js';
13
+ import { candidates } from './webdriver/locate.js';
14
+ import { webdriverMessage } from './webdriver/session.js';
15
+ /**
16
+ * What native macOS and Windows drivers share (contracts §M): where the app comes from, the
17
+ * first `launch {args}`, the native action runner, refs, the Appium log per step and the ffmpeg
18
+ * screen recording.
19
+ */
20
+ export const MAX_LAUNCH_ARGS = 20;
21
+ export const MAX_LAUNCH_ARG_CHARS = 500;
22
+ export const MSI_REFUSED = 'an .msi installer cannot run in place — upload a zip of the app';
23
+ export const PKG_REFUSED = 'a .pkg installer cannot run in place — upload a zip of the app';
24
+ /** Why a build file cannot run as a native desktop app of `platform`, or null (server wording). */
25
+ export function nativeBuildRefusal(platform, fileName) {
26
+ const lower = fileName.toLowerCase();
27
+ if (lower.endsWith('.msi'))
28
+ return MSI_REFUSED;
29
+ if (lower.endsWith('.pkg'))
30
+ return PKG_REFUSED;
31
+ const kind = buildKind(lower);
32
+ if (platform === 'macos' && !['zip', 'app_zip', 'dmg'].includes(kind))
33
+ return 'a macos build must be a .zip or .dmg file';
34
+ if (platform === 'windows' && !['zip', 'app_zip', 'exe'].includes(kind))
35
+ return 'a windows build must be a .zip or .exe file';
36
+ return null;
37
+ }
38
+ /** Checks the native lifecycle actions' fields. */
39
+ export function validateNativeAction(platform, action) {
40
+ if (action.type === 'launch') {
41
+ const args = action.args;
42
+ if (args === undefined)
43
+ return;
44
+ if (!Array.isArray(args) || args.length > MAX_LAUNCH_ARGS)
45
+ throw new Error(`launch args must be a list of at most ${MAX_LAUNCH_ARGS} strings`);
46
+ args.forEach((arg, i) => {
47
+ if (typeof arg !== 'string')
48
+ throw new Error(`launch args[${i}] must be a string`);
49
+ if (arg.length > MAX_LAUNCH_ARG_CHARS)
50
+ throw new Error(`launch args[${i}] is longer than ${MAX_LAUNCH_ARG_CHARS} characters`);
51
+ });
52
+ return;
53
+ }
54
+ if (action.type === 'focus_window') {
55
+ if (typeof action.title !== 'string' || action.title.length === 0)
56
+ throw new Error('focus_window needs a title');
57
+ if (action.index !== undefined)
58
+ throw new Unsupported(`focus_window on ${platform} takes a title, not an index`);
59
+ }
60
+ }
61
+ export class DesktopAppiumDriver {
62
+ ctx;
63
+ appium;
64
+ client = null;
65
+ unpacked = null;
66
+ releaseBuild = null;
67
+ recorder = null;
68
+ videoPath;
69
+ videoStopped = false;
70
+ firstLaunchDone = false;
71
+ actionsRun = 0;
72
+ lastRefs = {};
73
+ startedAt = Date.now();
74
+ offsets = new Map();
75
+ appiumLogOffsets = new Map();
76
+ constructor(ctx, appium) {
77
+ this.ctx = ctx;
78
+ this.appium = appium;
79
+ }
80
+ get paths() {
81
+ return this.ctx.paths ?? homePaths(process.env);
82
+ }
83
+ render = (text) => renderTemplate(text, this.ctx.template);
84
+ requireClient() {
85
+ if (!this.client)
86
+ throw new Error('the app is not running; start it with launch');
87
+ return this.client;
88
+ }
89
+ /** Resolves the app of the job: an installed app, or the executable of a build or local path. */
90
+ async resolveApp(os) {
91
+ const app = this.ctx.job.app;
92
+ if (!app)
93
+ throw new LaunchRefused(`the job names no ${this.platform} app (set the app in the environment)`);
94
+ const override = app.launch_activity?.trim() || null;
95
+ if (app.install === 'installed')
96
+ return { kind: 'installed', executable: null };
97
+ if (app.install === 'path') {
98
+ if (!this.ctx.allowLocalPaths)
99
+ throw new LaunchRefused(LOCAL_PATHS_REFUSED);
100
+ try {
101
+ return { kind: 'file', executable: localDesktopApp(app.path ?? '', override, os) };
102
+ }
103
+ catch (error) {
104
+ throw new LaunchRefused(errorMessage(error));
105
+ }
106
+ }
107
+ const named = app.build?.file_name;
108
+ const early = named ? nativeBuildRefusal(this.platform, named) : null;
109
+ if (early)
110
+ throw new LaunchRefused(early);
111
+ let file;
112
+ try {
113
+ const resolved = await resolveAppFile({
114
+ app,
115
+ paths: this.paths,
116
+ allowLocalPaths: this.ctx.allowLocalPaths ?? false,
117
+ getUrl: (buildId) => {
118
+ if (!this.ctx.buildDownloadUrl)
119
+ throw new Blocked('this runner cannot download builds for this job');
120
+ return this.ctx.buildDownloadUrl(buildId);
121
+ },
122
+ });
123
+ this.releaseBuild = resolved.release;
124
+ file = resolved.path;
125
+ }
126
+ catch (error) {
127
+ const message = `the app build is not available: ${errorMessage(error)}`;
128
+ throw error instanceof AppNotAvailable ? new LaunchRefused(message) : new Blocked(message);
129
+ }
130
+ if (!file)
131
+ throw new LaunchRefused(`the job names no ${this.platform} build`);
132
+ const refusal = nativeBuildRefusal(this.platform, named ?? basename(file));
133
+ if (refusal)
134
+ throw new LaunchRefused(refusal);
135
+ try {
136
+ this.unpacked = unpackDesktopBuild(file, override, { os });
137
+ }
138
+ catch (error) {
139
+ const message = `the app build could not be unpacked: ${errorMessage(error)}`;
140
+ throw error instanceof DesktopBuildError ? new LaunchRefused(message) : new Blocked(message);
141
+ }
142
+ return { kind: 'file', executable: this.unpacked.executable };
143
+ }
144
+ /** The args of a leading `launch {args}` (that action is then not run again). */
145
+ firstLaunchArgs() {
146
+ const first = [...this.ctx.job.automation.steps].sort((a, b) => a.index - b.index)[0]?.actions[0];
147
+ if (first?.type !== 'launch')
148
+ return [];
149
+ validateNativeAction(this.platform, first);
150
+ this.firstLaunchDone = true;
151
+ return (first.args ?? []).map(this.render);
152
+ }
153
+ /** Starts a whole-screen recording when the job wants video and ffmpeg is installed. */
154
+ async startRecording() {
155
+ this.startedAt = Date.now();
156
+ if (this.ctx.video === 'off')
157
+ return;
158
+ const ffmpeg = findFfmpeg();
159
+ if (!ffmpeg)
160
+ return;
161
+ const recorder = new ScreenRecorder(ffmpeg, this.platform);
162
+ try {
163
+ await recorder.start();
164
+ this.recorder = recorder;
165
+ }
166
+ catch (error) {
167
+ this.ctx.log.warn('screen recording could not start', { error: errorMessage(error) });
168
+ }
169
+ }
170
+ actionContext() {
171
+ return {
172
+ client: this.requireClient(),
173
+ ops: this.ops,
174
+ render: this.render,
175
+ defaultTimeoutMs: this.ctx.timeouts.actionMs,
176
+ };
177
+ }
178
+ /** Runs a shared native action (not launch/terminate/focus_window). */
179
+ async runShared(action) {
180
+ const prepared = action.target ? { ...action, target: resolveNativeRef(action.target) } : action;
181
+ return runNativeAction(this.actionContext(), prepared);
182
+ }
183
+ /** Whether this action is the leading `launch` whose args the first start already used. */
184
+ skipFirstLaunch(action) {
185
+ const first = this.actionsRun === 0;
186
+ this.actionsRun += 1;
187
+ return action.type === 'launch' && first && this.firstLaunchDone;
188
+ }
189
+ /** A lost Appium/WebDriverAgent/WinAppDriver session is an environment problem, not a failed check. */
190
+ translate(error) {
191
+ if (error instanceof Blocked || error instanceof NotFound)
192
+ return error;
193
+ const message = webdriverMessage(error);
194
+ if (/invalid session id|session is either terminated|ECONNREFUSED|ECONNRESET|socket hang up|WebDriverAgent|WinAppDriver server|not listening/i.test(message)) {
195
+ return new Blocked(`the app connection was lost: ${message}`);
196
+ }
197
+ return error;
198
+ }
199
+ async find(target) {
200
+ const client = this.requireClient();
201
+ for (const candidate of candidates(resolveNativeRef(target))) {
202
+ const ids = await nativeFindAll(client, this.ops.selectors, candidate, this.render);
203
+ if (ids.length === 0)
204
+ continue;
205
+ const refs = [];
206
+ for (const id of ids.slice(0, 20)) {
207
+ const rect = await client.getElementRect(id).catch(() => null);
208
+ if (!rect)
209
+ continue;
210
+ const ref = Object.entries(this.lastRefs).find(([, value]) => value.bounds &&
211
+ value.bounds.x === Math.round(rect.x) &&
212
+ value.bounds.y === Math.round(rect.y) &&
213
+ value.bounds.width === Math.round(rect.width) &&
214
+ value.bounds.height === Math.round(rect.height));
215
+ if (ref && !refs.includes(ref[0]))
216
+ refs.push(ref[0]);
217
+ }
218
+ return { count: ids.length, refs };
219
+ }
220
+ return { count: 0, refs: [] };
221
+ }
222
+ snapshotOptions(options) {
223
+ return {
224
+ ...(options.depth !== undefined ? { depth: options.depth } : {}),
225
+ ...(options.includeHidden !== undefined ? { includeHidden: options.includeHidden } : {}),
226
+ ...(options.maxChars !== undefined ? { maxChars: options.maxChars } : {}),
227
+ };
228
+ }
229
+ async screenshot() {
230
+ return Buffer.from(await this.requireClient().takeScreenshot(), 'base64');
231
+ }
232
+ source() {
233
+ return this.requireClient().getPageSource();
234
+ }
235
+ async back() {
236
+ throw new Unsupported(`'back' is not available on ${this.platform}`);
237
+ }
238
+ async home() {
239
+ throw new Unsupported(`'home' is not available on ${this.platform}`);
240
+ }
241
+ beginStep(index) {
242
+ this.offsets.set(index, Math.round((Date.now() - this.startedAt) / 100) / 10);
243
+ this.appiumLogOffsets.set(index, this.appium.logOffset());
244
+ }
245
+ debug(index, result) {
246
+ if (result !== 'failed' && result !== 'blocked')
247
+ return undefined;
248
+ const start = this.appiumLogOffsets.get(index);
249
+ if (start === undefined)
250
+ return undefined;
251
+ const later = [...this.appiumLogOffsets.entries()].filter(([i]) => i > index).map(([, offset]) => offset);
252
+ const end = later.length > 0 ? Math.min(...later) : undefined;
253
+ const appium = appiumLogLines(this.appium.logSince(start, APPIUM_LOG_MAX_BYTES, end), this.ctx.redact);
254
+ if (appium.lines.length === 0)
255
+ return undefined;
256
+ const offset = this.offsets.get(index);
257
+ return {
258
+ appium_log: appium.lines,
259
+ ...(appium.dropped > 0 ? { dropped: { appium_log: appium.dropped } } : {}),
260
+ ...(offset !== undefined && this.recorder ? { video_offset_secs: offset } : {}),
261
+ };
262
+ }
263
+ async stopRecording() {
264
+ if (!this.recorder || this.videoStopped)
265
+ return this.videoPath;
266
+ this.videoStopped = true;
267
+ this.videoPath = await this.recorder.stop();
268
+ if (!this.videoPath)
269
+ this.recorder.cleanup();
270
+ return this.videoPath;
271
+ }
272
+ /** Stops recording (discarding an unfetched video) and removes the unpacked build. */
273
+ async disposeCommon() {
274
+ if (this.recorder && !this.videoStopped) {
275
+ this.videoStopped = true;
276
+ await this.recorder.stop();
277
+ this.recorder.cleanup();
278
+ }
279
+ await disposeUnpacked(this.unpacked, this.ctx.log);
280
+ this.releaseBuild?.();
281
+ }
282
+ }
@@ -0,0 +1,15 @@
1
+ /** The environment, not the application, stopped the step: unreachable site, missing credential, time limit. */
2
+ export class Blocked extends Error {
3
+ }
4
+ /** The driver cannot perform this action or locator on its platform; reported as blocked. */
5
+ export class Unsupported extends Blocked {
6
+ }
7
+ /**
8
+ * The job cannot start on this runner as it is configured (for example an app installed from a
9
+ * local path when `builds.allow_local_paths` is off): the case is reported blocked, not retried.
10
+ */
11
+ export class LaunchRefused extends Blocked {
12
+ }
13
+ /** No element matched the target (session results report it as `not_found`). */
14
+ export class NotFound extends Error {
15
+ }