@leera.io/qa-runner 1.2.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/dist/src/builds/download.js +8 -7
- package/dist/src/capabilities.js +3 -1
- package/dist/src/client.js +4 -0
- package/dist/src/devices/android/adb.js +22 -0
- package/dist/src/devices/ios/simctl.js +27 -0
- package/dist/src/devices/mp4.js +80 -0
- package/dist/src/devices/screen-record.js +87 -14
- package/dist/src/drivers/android-appium.js +29 -2
- package/dist/src/drivers/common.js +23 -0
- package/dist/src/drivers/desktop-appium.js +32 -15
- package/dist/src/drivers/electron-playwright.js +89 -21
- package/dist/src/drivers/ios-appium.js +41 -4
- package/dist/src/drivers/mac2-appium.js +28 -2
- package/dist/src/drivers/tauri-webdriver.js +83 -20
- 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/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 +22 -1
- package/dist/src/files/store.js +181 -0
- package/dist/src/runner.js +12 -1
- package/dist/src/template.js +39 -0
- package/package.json +1 -1
|
@@ -9,12 +9,41 @@ import { clip, errorMessage, expectWithin, matches, renderLaunchArgs } from './c
|
|
|
9
9
|
import { DesktopAppiumDriver, validateNativeAction } from './desktop-appium.js';
|
|
10
10
|
import { Blocked, LaunchRefused, } from './driver.js';
|
|
11
11
|
import { emptyScreen } from './snapshot/format.js';
|
|
12
|
-
import { windowsKeyActions } from './webdriver/desktop-keys.js';
|
|
12
|
+
import { windowsKeyActions, windowsOpenDialogEntries } from './webdriver/desktop-keys.js';
|
|
13
13
|
import { elementIdOf } from './webdriver/locate.js';
|
|
14
14
|
import { NATIVE_DESKTOP_ACTIONS } from './webdriver/native-actions.js';
|
|
15
|
-
import { describeWindowsTarget, WINDOWS_LOCATORS, windowsSelectors, xpathLiteral } from './webdriver/native-locate.js';
|
|
15
|
+
import { describeWindowsTarget, nativeLocate, WINDOWS_LOCATORS, windowsSelectors, xpathLiteral } from './webdriver/native-locate.js';
|
|
16
16
|
import { openSession, webdriverMessage } from './webdriver/session.js';
|
|
17
17
|
import { windowsSnapshotFromXml } from './webdriver/xml-tree-desktop.js';
|
|
18
|
+
/** The AutomationId of the File name box of the common Open dialog. */
|
|
19
|
+
export const OPEN_DIALOG_FILE_NAME_BOX = '1148';
|
|
20
|
+
/**
|
|
21
|
+
* Picks files in the common Open dialog: each entry goes into its File name box (replacing what
|
|
22
|
+
* is there) followed by Enter; the dialog must be gone afterwards, or it refused the files.
|
|
23
|
+
*/
|
|
24
|
+
export async function fillWindowsOpenDialog(client, folder, names, timeoutMs, pauseMs = 500) {
|
|
25
|
+
const box = await nativeLocate(client, windowsSelectors, { automation_id: OPEN_DIALOG_FILE_NAME_BOX }, timeoutMs);
|
|
26
|
+
if (!box) {
|
|
27
|
+
throw new Error('no Open dialog appeared after the click (its File name box was not found); target the control that opens the dialog');
|
|
28
|
+
}
|
|
29
|
+
const selectAll = windowsKeyActions({ modifiers: ['ctrl'], key: 'a' });
|
|
30
|
+
const enter = windowsKeyActions({ modifiers: [], key: 'enter' });
|
|
31
|
+
for (const entry of windowsOpenDialogEntries(folder, names)) {
|
|
32
|
+
await client.elementClick(box.elementId).catch(() => undefined);
|
|
33
|
+
await windowsCommand(client, 'keys', { actions: [...selectAll, { text: entry }, ...enter] });
|
|
34
|
+
await sleep(pauseMs);
|
|
35
|
+
}
|
|
36
|
+
const deadline = Date.now() + Math.max(timeoutMs, 1_000);
|
|
37
|
+
for (;;) {
|
|
38
|
+
const open = await client.isElementDisplayed(box.elementId).catch(() => false);
|
|
39
|
+
if (!open)
|
|
40
|
+
return;
|
|
41
|
+
if (Date.now() >= deadline) {
|
|
42
|
+
throw new Error(`the Open dialog did not accept the files (${names.join(', ')}); it is still open`);
|
|
43
|
+
}
|
|
44
|
+
await sleep(300);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
18
47
|
/** Runs an Appium Windows driver `windows:` extension command. */
|
|
19
48
|
export function windowsCommand(client, command, args = {}) {
|
|
20
49
|
if (!/^[a-zA-Z]+$/.test(command))
|
|
@@ -106,7 +135,7 @@ export class WindowsAppiumDriver extends DesktopAppiumDriver {
|
|
|
106
135
|
wadUrl = null;
|
|
107
136
|
disposed = false;
|
|
108
137
|
constructor(ctx, appium, deps = {}) {
|
|
109
|
-
super(ctx, appium);
|
|
138
|
+
super(ctx, appium, deps.recorder);
|
|
110
139
|
this.deps = deps;
|
|
111
140
|
}
|
|
112
141
|
get ops() {
|
|
@@ -193,6 +222,9 @@ export class WindowsAppiumDriver extends DesktopAppiumDriver {
|
|
|
193
222
|
case 'focus_window':
|
|
194
223
|
await this.focusWindow(action);
|
|
195
224
|
return {};
|
|
225
|
+
case 'upload':
|
|
226
|
+
await this.upload(action);
|
|
227
|
+
return {};
|
|
196
228
|
default:
|
|
197
229
|
return await this.runShared(action);
|
|
198
230
|
}
|
|
@@ -201,6 +233,9 @@ export class WindowsAppiumDriver extends DesktopAppiumDriver {
|
|
|
201
233
|
throw this.translate(error);
|
|
202
234
|
}
|
|
203
235
|
}
|
|
236
|
+
pickInOpenDialog(folder, names, timeoutMs) {
|
|
237
|
+
return fillWindowsOpenDialog(this.requireClient(), folder, names, timeoutMs);
|
|
238
|
+
}
|
|
204
239
|
/**
|
|
205
240
|
* Switches to the app window with that title; a window the app's session does not list (a
|
|
206
241
|
* dialog of another process) is attached to through a desktop session and `appTopLevelWindow`.
|
package/dist/src/executor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
2
|
import { describe, errorMessage, firstLine, screenshotName } from './drivers/common.js';
|
|
3
|
+
import { mp4Seconds } from './devices/mp4.js';
|
|
3
4
|
import { isPlatform } from './devices/types.js';
|
|
4
5
|
import { Blocked, LaunchRefused } from './drivers/driver.js';
|
|
5
6
|
import { BrowserPool, webPlaywrightFactory } from './drivers/web-playwright.js';
|
|
@@ -37,12 +38,15 @@ export function jobPlatform(job) {
|
|
|
37
38
|
return platform;
|
|
38
39
|
}
|
|
39
40
|
export function jobContext(job, options) {
|
|
41
|
+
const template = templateContextFor(job);
|
|
42
|
+
if (options.files)
|
|
43
|
+
template.files = options.files.files;
|
|
40
44
|
return {
|
|
41
45
|
jobId: job.job.id,
|
|
42
46
|
platform: jobPlatform(job),
|
|
43
47
|
lease: options.lease,
|
|
44
48
|
job,
|
|
45
|
-
template
|
|
49
|
+
template,
|
|
46
50
|
timeouts: { actionMs: options.actionTimeoutMs, jobMs: options.jobTimeoutMs },
|
|
47
51
|
headless: options.headless,
|
|
48
52
|
...(options.proxy ? { proxy: options.proxy } : {}),
|
|
@@ -51,6 +55,7 @@ export function jobContext(job, options) {
|
|
|
51
55
|
log: options.log,
|
|
52
56
|
...(options.app ?? {}),
|
|
53
57
|
...(options.buildDownloadUrl ? { buildDownloadUrl: options.buildDownloadUrl } : {}),
|
|
58
|
+
...(options.files ? { files: options.files } : {}),
|
|
54
59
|
};
|
|
55
60
|
}
|
|
56
61
|
/**
|
|
@@ -179,6 +184,8 @@ export async function executeSteps(driver, script, options, shouldStop, redact)
|
|
|
179
184
|
videoPath = await driver.stopRecording().catch(() => undefined);
|
|
180
185
|
await driver.dispose().catch(() => undefined);
|
|
181
186
|
}
|
|
187
|
+
if (videoPath)
|
|
188
|
+
keepOffsetsInVideo(steps, mp4Seconds(videoPath));
|
|
182
189
|
return {
|
|
183
190
|
steps,
|
|
184
191
|
elapsedSecs: Math.max(1, Math.round((Date.now() - started) / 1000)),
|
|
@@ -186,6 +193,20 @@ export async function executeSteps(driver, script, options, shouldStop, redact)
|
|
|
186
193
|
...(videoPath ? { videoPath } : {}),
|
|
187
194
|
};
|
|
188
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Places no step past the end of the video (`seconds` long, null when unknown): a video can end a
|
|
198
|
+
* moment before a step that began just before it stopped, and that step is then shown at its end.
|
|
199
|
+
*/
|
|
200
|
+
export function keepOffsetsInVideo(steps, seconds) {
|
|
201
|
+
if (seconds === null)
|
|
202
|
+
return;
|
|
203
|
+
const end = Math.floor(seconds * 10) / 10;
|
|
204
|
+
for (const step of steps) {
|
|
205
|
+
const offset = step.debug?.video_offset_secs;
|
|
206
|
+
if (offset !== undefined && offset > end)
|
|
207
|
+
step.debug = { ...step.debug, video_offset_secs: end };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
189
210
|
/** Prefixes the action's description; template problems block the step rather than fail it. */
|
|
190
211
|
async function runAction(driver, action) {
|
|
191
212
|
try {
|
|
@@ -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/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,6 +10,7 @@ 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';
|
|
@@ -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/template.js
CHANGED
|
@@ -38,6 +38,8 @@ export function redactDeep(value, redact) {
|
|
|
38
38
|
function placeholderValue(name, ctx) {
|
|
39
39
|
if (name.startsWith('data.'))
|
|
40
40
|
return dataValue(name, ctx);
|
|
41
|
+
if (name.startsWith('file.'))
|
|
42
|
+
return testFile(name.slice('file.'.length), name, ctx).file_name;
|
|
41
43
|
switch (name) {
|
|
42
44
|
case 'env.base_url':
|
|
43
45
|
return trimSlash(ctx.baseUrl);
|
|
@@ -107,6 +109,43 @@ function dataValue(name, ctx) {
|
|
|
107
109
|
}
|
|
108
110
|
return value;
|
|
109
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
|
+
}
|
|
110
149
|
/** A value only a queued job has: device sessions refuse it, and older servers may not send it. */
|
|
111
150
|
function jobValue(ctx, name, value) {
|
|
112
151
|
if (ctx.session)
|
package/package.json
CHANGED