@leera.io/qa-runner 1.1.0 → 1.4.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 (39) hide show
  1. package/README.md +2 -1
  2. package/dist/src/builds/download.js +8 -7
  3. package/dist/src/capabilities.js +13 -8
  4. package/dist/src/cli/commands/setup.js +41 -2
  5. package/dist/src/client.js +4 -0
  6. package/dist/src/devices/android/adb.js +22 -0
  7. package/dist/src/devices/ffmpeg.js +135 -0
  8. package/dist/src/devices/ios/record.js +8 -7
  9. package/dist/src/devices/ios/simctl.js +27 -0
  10. package/dist/src/devices/macos.js +8 -9
  11. package/dist/src/devices/mp4.js +80 -0
  12. package/dist/src/devices/screen-record.js +116 -29
  13. package/dist/src/devices/tauri.js +11 -0
  14. package/dist/src/devices/windows.js +6 -7
  15. package/dist/src/drivers/android-appium.js +41 -5
  16. package/dist/src/drivers/common.js +23 -0
  17. package/dist/src/drivers/desktop-appium.js +33 -14
  18. package/dist/src/drivers/electron-playwright.js +89 -21
  19. package/dist/src/drivers/ios-appium.js +44 -4
  20. package/dist/src/drivers/mac2-appium.js +28 -2
  21. package/dist/src/drivers/tauri-webdriver.js +237 -67
  22. package/dist/src/drivers/web-playwright.js +100 -1
  23. package/dist/src/drivers/webdriver/actions.js +1 -0
  24. package/dist/src/drivers/webdriver/desktop-keys.js +27 -0
  25. package/dist/src/drivers/webdriver/dom-element.js +47 -0
  26. package/dist/src/drivers/webdriver/dom-locate.js +52 -7
  27. package/dist/src/drivers/webdriver/dom-snapshot.js +8 -5
  28. package/dist/src/drivers/webdriver/dom-tree.js +7 -15
  29. package/dist/src/drivers/webdriver/dom.js +40 -27
  30. package/dist/src/drivers/webdriver/ios-actions.js +1 -0
  31. package/dist/src/drivers/webdriver/native-actions.js +1 -0
  32. package/dist/src/drivers/windows-appium.js +38 -3
  33. package/dist/src/executor.js +23 -1
  34. package/dist/src/files/store.js +181 -0
  35. package/dist/src/home.js +1 -0
  36. package/dist/src/runner.js +15 -4
  37. package/dist/src/session/loop.js +2 -2
  38. package/dist/src/template.js +48 -0
  39. package/package.json +2 -2
@@ -0,0 +1,181 @@
1
+ import { randomFillSync } from 'node:crypto';
2
+ import { closeSync, constants, copyFileSync, linkSync, mkdirSync, mkdtempSync, openSync, rmSync, statSync, truncateSync, writeSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, join } from 'node:path';
5
+ import { BuildCache } from '../builds/cache.js';
6
+ import { obtainBuild } from '../builds/download.js';
7
+ /** The largest generated file the runner writes: the server's own limit. */
8
+ export const MAX_GENERATED_BYTES = 2 * 1024 * 1024 * 1024;
9
+ /** Test files stay small; 2 GB of them are kept between jobs by default. */
10
+ export const DEFAULT_FILE_CACHE_BYTES = 2 * 1024 * 1024 * 1024;
11
+ const WRITE_CHUNK = 1024 * 1024;
12
+ export class TestFileError extends Error {
13
+ }
14
+ /** `~/.leera-qa-runner/cache/test-files`. */
15
+ export function testFileCacheDir(paths) {
16
+ return join(paths.root, 'cache', 'test-files');
17
+ }
18
+ const caches = new Map();
19
+ /** One cache object per directory per process, so pins are shared between jobs. */
20
+ export function testFileCacheFor(paths, maxBytes = DEFAULT_FILE_CACHE_BYTES) {
21
+ const dir = testFileCacheDir(paths);
22
+ let cache = caches.get(dir);
23
+ if (!cache) {
24
+ cache = new BuildCache(dir, maxBytes);
25
+ caches.set(dir, cache);
26
+ }
27
+ return cache;
28
+ }
29
+ /**
30
+ * The name a file is written under: the server checked it is a plain file name every OS accepts;
31
+ * anything that still names a folder or a device is refused here too, so a job can never write
32
+ * outside its own folder.
33
+ */
34
+ export function checkedFileName(fileName) {
35
+ const name = typeof fileName === 'string' ? fileName : '';
36
+ const plain = name.length > 0 && name.length <= 255 && basename(name) === name && !name.includes('\\') && !name.includes('/');
37
+ const control = [...name].some((ch) => ch.charCodeAt(0) < 0x20);
38
+ if (!plain || control || name === '.' || name === '..' || /[<>:"|?*]/.test(name)) {
39
+ throw new TestFileError(`the test file name '${name}' cannot be written on this runner`);
40
+ }
41
+ return name;
42
+ }
43
+ /**
44
+ * A job's test files on this machine: `<job folder>/<name>/<file name>`, so each keeps the name
45
+ * the app should see even when two share it. Stored files come through the digest-checked cache;
46
+ * generated files are written here. Disposing removes the folder.
47
+ */
48
+ export class JobFiles {
49
+ dir;
50
+ files;
51
+ releases;
52
+ picks = 0;
53
+ constructor(dir, files, releases) {
54
+ this.dir = dir;
55
+ this.files = files;
56
+ this.releases = releases;
57
+ }
58
+ /** No files: a job whose script hands none over. */
59
+ static empty() {
60
+ return new JobFiles('', {}, []);
61
+ }
62
+ static async prepare(options) {
63
+ const entries = Object.entries(options.files ?? {});
64
+ if (entries.length === 0)
65
+ return JobFiles.empty();
66
+ const dir = mkdtempSync(join(options.root ?? tmpdir(), 'qa-files-'));
67
+ const files = {};
68
+ const releases = [];
69
+ const store = new JobFiles(dir, files, releases);
70
+ try {
71
+ for (const [name, file] of entries) {
72
+ if (!/^[a-z][a-z0-9_]{0,39}$/.test(name))
73
+ throw new TestFileError(`'${name}' is not a test file name`);
74
+ const fileName = checkedFileName(file.file_name);
75
+ const folder = join(dir, name);
76
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
77
+ const target = join(folder, fileName);
78
+ if (file.kind === 'generated') {
79
+ writeGenerated(target, file);
80
+ }
81
+ else if (file.kind === 'stored') {
82
+ const obtained = await obtainBuild({
83
+ cache: options.cache,
84
+ build: {
85
+ id: file.id,
86
+ version_name: null,
87
+ build_number: null,
88
+ file_name: fileName,
89
+ sha256: file.sha256 ?? '',
90
+ size_bytes: file.size_bytes,
91
+ },
92
+ getUrl: () => options.downloadUrl(file.id),
93
+ what: `test file ${name}`,
94
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
95
+ });
96
+ releases.push(obtained.release);
97
+ copyFileSync(obtained.path, target, constants.COPYFILE_FICLONE);
98
+ }
99
+ else {
100
+ throw new TestFileError(`test file ${name} is of a kind this runner does not know (${file.kind}); update the runner`);
101
+ }
102
+ files[name] = { file_name: fileName, path: target };
103
+ }
104
+ options.cache.prune();
105
+ return store;
106
+ }
107
+ catch (error) {
108
+ await store.dispose();
109
+ throw error;
110
+ }
111
+ }
112
+ /**
113
+ * A new folder holding exactly these files under their own names, for an Open dialog that picks
114
+ * everything in one folder. Links where the filesystem allows, copies otherwise.
115
+ */
116
+ folderWith(paths) {
117
+ if (!this.dir)
118
+ throw new TestFileError('the job has no test files');
119
+ this.picks += 1;
120
+ const folder = join(this.dir, `.pick-${this.picks}`);
121
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
122
+ const seen = new Set();
123
+ for (const path of paths) {
124
+ const name = basename(path);
125
+ if (seen.has(name.toLowerCase())) {
126
+ throw new TestFileError(`two of the files are named ${name}; an Open dialog cannot pick both`);
127
+ }
128
+ seen.add(name.toLowerCase());
129
+ const target = join(folder, name);
130
+ try {
131
+ linkSync(path, target);
132
+ }
133
+ catch {
134
+ copyFileSync(path, target, constants.COPYFILE_FICLONE);
135
+ }
136
+ }
137
+ return folder;
138
+ }
139
+ async dispose() {
140
+ for (const release of this.releases.splice(0))
141
+ release();
142
+ if (this.dir)
143
+ rmSync(this.dir, { recursive: true, force: true });
144
+ }
145
+ }
146
+ /** Writes a generated file: zeros (sparse where the filesystem allows) or random bytes. */
147
+ export function writeGenerated(path, file) {
148
+ const size = file.size_bytes;
149
+ if (!Number.isSafeInteger(size) || size < 0 || size > MAX_GENERATED_BYTES) {
150
+ throw new TestFileError(`test file ${file.name} has a size this runner will not generate (${size} bytes)`);
151
+ }
152
+ const fill = file.fill ?? 'zeros';
153
+ if (fill === 'zeros') {
154
+ const fd = openSync(path, 'w', 0o600);
155
+ closeSync(fd);
156
+ truncateSync(path, size);
157
+ }
158
+ else if (fill === 'random') {
159
+ const fd = openSync(path, 'w', 0o600);
160
+ try {
161
+ const chunk = Buffer.allocUnsafe(Math.min(WRITE_CHUNK, Math.max(size, 1)));
162
+ let left = size;
163
+ while (left > 0) {
164
+ const length = Math.min(left, chunk.length);
165
+ randomFillSync(chunk, 0, length);
166
+ let offset = 0;
167
+ while (offset < length)
168
+ offset += writeSync(fd, chunk, offset, length - offset);
169
+ left -= length;
170
+ }
171
+ }
172
+ finally {
173
+ closeSync(fd);
174
+ }
175
+ }
176
+ else {
177
+ throw new TestFileError(`test file ${file.name} has a fill this runner does not know (${fill}); update the runner`);
178
+ }
179
+ if (statSync(path).size !== size)
180
+ throw new TestFileError(`test file ${file.name} was not written in full`);
181
+ }
package/dist/src/home.js CHANGED
@@ -16,6 +16,7 @@ export function homePaths(env = process.env) {
16
16
  browsers: join(root, 'browsers'),
17
17
  jobs: join(root, 'jobs'),
18
18
  versions: join(root, 'versions'),
19
+ ffmpeg: join(root, 'ffmpeg'),
19
20
  };
20
21
  }
21
22
  const isWindows = process.platform === 'win32';
@@ -1,5 +1,6 @@
1
1
  import { readFile, rm, stat } from 'node:fs/promises';
2
- import { dirname } from 'node:path';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
3
4
  import { buildCapabilities, detect, hostInfo } from './capabilities.js';
4
5
  import { ApiError, RunnerClient } from './client.js';
5
6
  import { loadFileConfig, resolveConfig } from './config.js';
@@ -9,10 +10,11 @@ import { createRegistry, defaultFactories } from './drivers/registry.js';
9
10
  import { BrowserPool } from './drivers/web-playwright.js';
10
11
  import { ExitCode, ExitError, messageOf } from './errors.js';
11
12
  import { createDriver, executeClaimedJob, jobContext, sessionContext } from './executor.js';
13
+ import { JobFiles, testFileCacheFor } from './files/store.js';
12
14
  import { HealthMonitor, healthReport } from './health.js';
13
15
  import { homePaths, readJsonFile, writePrivateFile } from './home.js';
14
16
  import { progressReporter } from './progress.js';
15
- import { redactor } from './template.js';
17
+ import { credentialSecrets, redactor } from './template.js';
16
18
  import { runSession } from './session/loop.js';
17
19
  import { isSessionClaim } from './types.js';
18
20
  import { localDay } from './update/check.js';
@@ -221,7 +223,7 @@ export class Runner {
221
223
  jobTimeoutMs: config.jobTimeoutMs,
222
224
  headless: config.headless,
223
225
  ...(config.proxy ? { proxy: config.proxy } : {}),
224
- redact: redactor([session.credential?.password]),
226
+ redact: redactor(credentialSecrets(session.credential)),
225
227
  log,
226
228
  });
227
229
  return createDriver(registry, ctx, shared);
@@ -314,7 +316,7 @@ export class Runner {
314
316
  const jobId = job.job.id;
315
317
  const attempt = job.job.attempt;
316
318
  const platform = lease.device.platform;
317
- const redact = redactor([job.credential?.password]);
319
+ const redact = redactor(credentialSecrets(job.credential));
318
320
  let cancelled = false;
319
321
  this.running.set(jobId, { id: jobId, kind: 'test', platform, device_id: lease.device.id, started_at: new Date().toISOString() });
320
322
  this.writeState();
@@ -339,10 +341,18 @@ export class Runner {
339
341
  }
340
342
  });
341
343
  let videoPath;
344
+ let files = JobFiles.empty();
342
345
  try {
346
+ // Downloaded or generated before the app starts, so a step never waits on the network.
347
+ files = await JobFiles.prepare({
348
+ files: job.files,
349
+ cache: testFileCacheFor(this.deps.paths ?? { root: join(tmpdir(), 'leera-qa-runner') }),
350
+ downloadUrl: (testFileId) => client.testFileDownloadUrl(testFileId, jobId),
351
+ });
343
352
  const ctx = jobContext(job, {
344
353
  lease,
345
354
  app: this.appSettings(),
355
+ files,
346
356
  buildDownloadUrl: (buildId) => client.buildDownloadUrl(buildId, { jobId }),
347
357
  actionTimeoutMs: config.actionTimeoutMs,
348
358
  jobTimeoutMs: config.jobTimeoutMs,
@@ -443,6 +453,7 @@ export class Runner {
443
453
  clearInterval(heartbeat);
444
454
  progress.stop();
445
455
  this.running.delete(jobId);
456
+ await files.dispose().catch(() => undefined);
446
457
  // Each job records into its own temporary directory.
447
458
  if (videoPath)
448
459
  await rm(dirname(videoPath), { recursive: true, force: true }).catch(() => undefined);
@@ -1,6 +1,6 @@
1
1
  import { setTimeout as sleep } from 'node:timers/promises';
2
2
  import { ApiError } from '../client.js';
3
- import { redactor } from '../template.js';
3
+ import { credentialSecrets, redactor } from '../template.js';
4
4
  import { DEFAULT_COMMAND_TIMEOUT_MS } from './commands.js';
5
5
  import { SessionExecutor } from './execute.js';
6
6
  /** The long-poll never asks for more than the server allows (plan §4). */
@@ -16,7 +16,7 @@ export async function runSession(session, deps) {
16
16
  const { client, log } = deps;
17
17
  const now = deps.now ?? Date.now;
18
18
  const retryDelayMs = deps.retryDelayMs ?? 2_000;
19
- const redact = redactor([session.credential?.password]);
19
+ const redact = redactor(credentialSecrets(session.credential));
20
20
  const started = now();
21
21
  const idleMs = Math.max(1, session.idle_timeout_seconds) * 1_000;
22
22
  const maxMs = Math.max(1, session.max_seconds) * 1_000;
@@ -13,6 +13,10 @@ export function resolveUrl(url, baseUrl) {
13
13
  return trimSlash(baseUrl) + url;
14
14
  return url;
15
15
  }
16
+ /** The values of a credential that must never appear in logs, reports or debug output. */
17
+ export function credentialSecrets(credential) {
18
+ return [credential?.password, credential?.otp];
19
+ }
16
20
  /** Replaces every secret in `text`; secrets under 4 characters are skipped because they would mangle ordinary words. */
17
21
  export function redactor(secrets) {
18
22
  const values = secrets
@@ -34,6 +38,8 @@ export function redactDeep(value, redact) {
34
38
  function placeholderValue(name, ctx) {
35
39
  if (name.startsWith('data.'))
36
40
  return dataValue(name, ctx);
41
+ if (name.startsWith('file.'))
42
+ return testFile(name.slice('file.'.length), name, ctx).file_name;
37
43
  switch (name) {
38
44
  case 'env.base_url':
39
45
  return trimSlash(ctx.baseUrl);
@@ -47,6 +53,11 @@ function placeholderValue(name, ctx) {
47
53
  return present(ctx.username, name);
48
54
  case 'credential.password':
49
55
  return present(ctx.password, name);
56
+ case 'credential.otp':
57
+ present(ctx.password, name);
58
+ if (!ctx.otp)
59
+ throw new TemplateError(`the script uses {{${name}}} but the account has no one-time code`);
60
+ return ctx.otp;
50
61
  case 'run.id':
51
62
  return String(ctx.runId);
52
63
  case 'item.id':
@@ -98,6 +109,43 @@ function dataValue(name, ctx) {
98
109
  }
99
110
  return value;
100
111
  }
112
+ /** A test file of the job by name; `placeholder` is how the script wrote it, for messages. */
113
+ export function testFile(fileName, placeholder, ctx) {
114
+ if (ctx.session)
115
+ throw new TemplateError(`a device session has no test files; {{${placeholder}}} only works in a queued run`);
116
+ const files = ctx.files ?? {};
117
+ const file = /^[a-z][a-z0-9_]{0,39}$/.test(fileName) && Object.hasOwn(files, fileName) ? files[fileName] : undefined;
118
+ if (!file)
119
+ throw new TemplateError(`the script uses {{${placeholder}}} but the job has no test file named ${fileName}`);
120
+ return file;
121
+ }
122
+ /**
123
+ * The files an `upload`, `drop_files` or `add_files` action hands over, as paths on this machine.
124
+ * Each entry is `{{file.<name>}}`, or a data placeholder whose value names a test file of the job;
125
+ * anything else is refused, so a script can never point the runner at its own files.
126
+ */
127
+ export function resolveFileEntries(entries, ctx) {
128
+ if (!Array.isArray(entries) || entries.length === 0)
129
+ throw new TemplateError('the action names no files');
130
+ return entries.map((entry, i) => {
131
+ const whole = /^\s*\{\{\s*([a-z][a-z0-9_.]*)\s*\}\}\s*$/.exec(typeof entry === 'string' ? entry : '');
132
+ const placeholder = whole?.[1];
133
+ let name;
134
+ if (placeholder?.startsWith('file.')) {
135
+ name = placeholder.slice('file.'.length);
136
+ }
137
+ else if (placeholder?.startsWith('data.')) {
138
+ name = dataValue(placeholder, ctx).trim();
139
+ }
140
+ else {
141
+ throw new TemplateError(`files[${i}] must be {{file.<name>}} or a test data placeholder naming a test file`);
142
+ }
143
+ const file = testFile(name, placeholder.startsWith('file.') ? placeholder : `file.${name}`, ctx);
144
+ if (!file.path)
145
+ throw new TemplateError(`test file ${name} is not ready on this runner`);
146
+ return { name, file, path: file.path };
147
+ });
148
+ }
101
149
  /** A value only a queued job has: device sessions refuse it, and older servers may not send it. */
102
150
  function jobValue(ctx, name, value) {
103
151
  if (ctx.session)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leera.io/qa-runner",
3
- "version": "1.1.0",
3
+ "version": "1.4.0",
4
4
  "description": "Runs recorded QA test scripts on your own machines and reports results back to your workspace.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -16,7 +16,7 @@
16
16
  "node": ">=22.12"
17
17
  },
18
18
  "scripts": {
19
- "build": "tsc -p tsconfig.json && node -e \"for (const f of ['dom-locate.js', 'dom-snapshot.js']) require('node:fs').copyFileSync('src/drivers/webdriver/' + f, 'dist/src/drivers/webdriver/' + f)\"",
19
+ "build": "tsc -p tsconfig.json && node -e \"for (const f of ['dom-locate.js', 'dom-snapshot.js', 'dom-element.js']) require('node:fs').copyFileSync('src/drivers/webdriver/' + f, 'dist/src/drivers/webdriver/' + f)\"",
20
20
  "typecheck": "tsc -p tsconfig.json --noEmit",
21
21
  "lint": "biome lint --error-on-warnings src test packaging",
22
22
  "test": "npm run build && node --test \"dist/test/unit/**/*.test.js\"",