@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.
- package/README.md +2 -1
- package/dist/src/builds/download.js +8 -7
- package/dist/src/capabilities.js +13 -8
- package/dist/src/cli/commands/setup.js +41 -2
- package/dist/src/client.js +4 -0
- package/dist/src/devices/android/adb.js +22 -0
- package/dist/src/devices/ffmpeg.js +135 -0
- package/dist/src/devices/ios/record.js +8 -7
- package/dist/src/devices/ios/simctl.js +27 -0
- package/dist/src/devices/macos.js +8 -9
- package/dist/src/devices/mp4.js +80 -0
- package/dist/src/devices/screen-record.js +116 -29
- package/dist/src/devices/tauri.js +11 -0
- package/dist/src/devices/windows.js +6 -7
- package/dist/src/drivers/android-appium.js +41 -5
- package/dist/src/drivers/common.js +23 -0
- package/dist/src/drivers/desktop-appium.js +33 -14
- package/dist/src/drivers/electron-playwright.js +89 -21
- package/dist/src/drivers/ios-appium.js +44 -4
- package/dist/src/drivers/mac2-appium.js +28 -2
- package/dist/src/drivers/tauri-webdriver.js +237 -67
- package/dist/src/drivers/web-playwright.js +100 -1
- package/dist/src/drivers/webdriver/actions.js +1 -0
- package/dist/src/drivers/webdriver/desktop-keys.js +27 -0
- package/dist/src/drivers/webdriver/dom-element.js +47 -0
- package/dist/src/drivers/webdriver/dom-locate.js +52 -7
- package/dist/src/drivers/webdriver/dom-snapshot.js +8 -5
- package/dist/src/drivers/webdriver/dom-tree.js +7 -15
- package/dist/src/drivers/webdriver/dom.js +40 -27
- package/dist/src/drivers/webdriver/ios-actions.js +1 -0
- package/dist/src/drivers/webdriver/native-actions.js +1 -0
- package/dist/src/drivers/windows-appium.js +38 -3
- package/dist/src/executor.js +23 -1
- package/dist/src/files/store.js +181 -0
- package/dist/src/home.js +1 -0
- package/dist/src/runner.js +15 -4
- package/dist/src/session/loop.js +2 -2
- package/dist/src/template.js +48 -0
- 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
package/dist/src/runner.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile, rm, stat } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
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(
|
|
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(
|
|
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);
|
package/dist/src/session/loop.js
CHANGED
|
@@ -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(
|
|
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;
|
package/dist/src/template.js
CHANGED
|
@@ -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.
|
|
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\"",
|