@leera.io/qa-runner 1.0.6 → 1.2.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/capabilities.js +13 -7
- package/dist/src/cli/commands/setup.js +41 -2
- package/dist/src/debug.js +14 -4
- package/dist/src/devices/ffmpeg.js +135 -0
- package/dist/src/devices/ios/record.js +8 -7
- package/dist/src/devices/macos.js +8 -9
- package/dist/src/devices/screen-record.js +31 -17
- package/dist/src/devices/tauri.js +11 -0
- package/dist/src/devices/windows.js +6 -7
- package/dist/src/drivers/android-appium.js +14 -6
- package/dist/src/drivers/common.js +40 -2
- package/dist/src/drivers/desktop-appium.js +21 -20
- package/dist/src/drivers/electron-playwright.js +216 -47
- package/dist/src/drivers/ios-appium.js +5 -3
- package/dist/src/drivers/mac2-appium.js +2 -2
- package/dist/src/drivers/tauri-webdriver.js +177 -70
- package/dist/src/drivers/web-playwright.js +21 -3
- package/dist/src/drivers/webdriver/actions.js +8 -4
- package/dist/src/drivers/webdriver/dom-element.js +47 -0
- package/dist/src/drivers/webdriver/dom-locate.js +54 -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/windows-appium.js +2 -2
- package/dist/src/executor.js +14 -3
- package/dist/src/home.js +1 -0
- package/dist/src/runner.js +3 -3
- package/dist/src/session/loop.js +2 -2
- package/dist/src/template.js +72 -2
- package/package.json +2 -2
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
* into a session snapshot (plan §7.3): role from ARIA or the tag, accessible name, text
|
|
6
6
|
* (≤80 characters), data-testid, id, bounds and state. Invisible elements are dropped unless
|
|
7
7
|
* `includeHidden`; layout-only elements are hoisted; depth and node count are capped.
|
|
8
|
+
* It returns plain JSON only (see dom-locate.js): with `keep`, the nodes' elements stay in the
|
|
9
|
+
* page under that id, for dom-locate.js to resolve a ref (`{ ref: { snapshot, index: i } }`).
|
|
8
10
|
*
|
|
9
|
-
* request: { depth?, includeHidden?, maxNodes?, viewportOnly? }
|
|
10
|
-
* returns: { viewport: {width, height, scale}, url, title, truncated, count,
|
|
11
|
-
* nodes: RawNode[], elements: Element[] }
|
|
11
|
+
* request: { depth?, includeHidden?, maxNodes?, viewportOnly?, keep? }
|
|
12
|
+
* returns: { viewport: {width, height, scale}, url, title, truncated, count, nodes: RawNode[] }
|
|
12
13
|
* RawNode: { i, role, name?, text?, test_id?, id?, bounds?, state?, xpath,
|
|
13
14
|
* test_id_count?, test_id_index?, id_selector?, id_count?, text_key?, text_count?, text_index?,
|
|
14
|
-
* children? } (`i` indexes
|
|
15
|
+
* children? } (`i` indexes the kept elements)
|
|
15
16
|
*/
|
|
16
17
|
(function domSnapshot(request) {
|
|
17
18
|
const options = request || {};
|
|
@@ -372,5 +373,7 @@
|
|
|
372
373
|
if (nodes.length === 1 && elements[nodes[0].i] === document.body && nodes[0].role === 'generic' && !nodes[0].name) {
|
|
373
374
|
nodes = nodes[0].children || [];
|
|
374
375
|
}
|
|
375
|
-
|
|
376
|
+
// One snapshot is kept at a time: a new one (or a page load) makes the previous refs stale.
|
|
377
|
+
if (typeof options.keep === 'string') window[Symbol.for('qa-runner.snapshot')] = { id: options.keep, elements: elements };
|
|
378
|
+
return { viewport: viewport, url: String(location.href), title: document.title, truncated: truncated, count: count, nodes: nodes };
|
|
376
379
|
})
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { clampMaxChars, clipText, renderSnapshotText, } from '../snapshot/format.js';
|
|
2
2
|
import { MAX_XPATH_CHARS } from './dom.js';
|
|
3
|
-
import { elementIdOf } from './locate.js';
|
|
4
3
|
/** The screen a DOM snapshot (or its viewport-only form) describes. */
|
|
5
4
|
export function domScreen(raw, appId = null) {
|
|
6
5
|
const width = numberOrNull(raw.viewport?.width);
|
|
@@ -48,23 +47,16 @@ export function suggestFor(node) {
|
|
|
48
47
|
suggest.fallbacks = [next.count > 1 ? { ...next.target, nth: next.index } : next.target];
|
|
49
48
|
return { suggest, alternatives, fragile: true };
|
|
50
49
|
}
|
|
51
|
-
/** Builds the snapshot and the ref → element map from the script's result. */
|
|
50
|
+
/** Builds the snapshot and the ref → element index map from the script's result. */
|
|
52
51
|
export function buildDomSnapshot(raw, options = {}) {
|
|
53
|
-
const
|
|
54
|
-
const elements = new Map();
|
|
52
|
+
const indexes = new Map();
|
|
55
53
|
const sources = new Map();
|
|
56
54
|
let next = 1;
|
|
57
55
|
const convert = (node) => {
|
|
58
56
|
const ref = `e${next++}`;
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
elements.set(ref, elementIdOf(reference));
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
// Not an element reference: the node stays in the tree without a ref target.
|
|
66
|
-
}
|
|
67
|
-
}
|
|
57
|
+
// A node without an element index stays in the tree without a ref target.
|
|
58
|
+
if (Number.isInteger(node.i) && node.i >= 0)
|
|
59
|
+
indexes.set(ref, node.i);
|
|
68
60
|
sources.set(ref, node);
|
|
69
61
|
const out = { ref, role: String(node.role || 'generic') };
|
|
70
62
|
if (node.name)
|
|
@@ -92,7 +84,7 @@ export function buildDomSnapshot(raw, options = {}) {
|
|
|
92
84
|
const rendered = renderSnapshotText(root, clampMaxChars(options.maxChars));
|
|
93
85
|
const refs = {};
|
|
94
86
|
for (const [ref, node] of sources) {
|
|
95
|
-
if (!rendered.refs.has(ref) || !
|
|
87
|
+
if (!rendered.refs.has(ref) || !indexes.has(ref))
|
|
96
88
|
continue;
|
|
97
89
|
refs[ref] = { locate: { dom_ref: ref }, ...suggestFor(node), bounds: node.bounds ?? null };
|
|
98
90
|
}
|
|
@@ -108,7 +100,7 @@ export function buildDomSnapshot(raw, options = {}) {
|
|
|
108
100
|
element_count: typeof raw.count === 'number' ? raw.count : sources.size,
|
|
109
101
|
truncated: raw.truncated === true || rendered.truncated,
|
|
110
102
|
},
|
|
111
|
-
|
|
103
|
+
indexes,
|
|
112
104
|
};
|
|
113
105
|
}
|
|
114
106
|
/** The ref a `locate` from a DOM snapshot names, or null. */
|
|
@@ -1,15 +1,23 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
1
2
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { setTimeout as sleep } from 'node:timers/promises';
|
|
3
3
|
import { Unsupported } from '../driver.js';
|
|
4
4
|
import { elementIdOf } from './locate.js';
|
|
5
5
|
/**
|
|
6
6
|
* Locators for WebDriver-backed web views (Tauri, contracts §L). Elements are found by the
|
|
7
|
-
* runner-owned `dom-locate.js`, executed with W3C `execute/sync`;
|
|
7
|
+
* runner-owned `dom-locate.js`, executed with W3C `execute/sync`; the only other scripts ever
|
|
8
|
+
* run are the runner-owned `dom-snapshot.js` and `dom-element.js`.
|
|
9
|
+
*
|
|
10
|
+
* The scripts return plain JSON, never DOM elements. W3C serializes a script's elements as
|
|
11
|
+
* element references, but tauri-plugin-wdio-webdriver (the macOS path) returns each as null.
|
|
12
|
+
* So the element the runner acts on is tagged by `dom-locate.js` and fetched with W3C Find
|
|
13
|
+
* Elements, which every server implements, and the tag is removed straight after.
|
|
8
14
|
*/
|
|
9
15
|
export const DOM_LOCATORS = ['css', 'text', 'test_id', 'xpath'];
|
|
10
16
|
export const MAX_XPATH_CHARS = 500;
|
|
11
|
-
/**
|
|
17
|
+
/** Matches one locate call reports visibility for (raised for a larger `nth`). */
|
|
12
18
|
export const DOM_LOCATE_LIMIT = 50;
|
|
19
|
+
/** The attribute `dom-locate.js` tags an element with, for Find Elements to return it. */
|
|
20
|
+
export const MARK_ATTRIBUTE = 'data-qa-runner-mark';
|
|
13
21
|
const scripts = new Map();
|
|
14
22
|
/** The file as shipped next to this module (copied into dist by the build). */
|
|
15
23
|
export function domScriptSource(name) {
|
|
@@ -84,12 +92,13 @@ export function parseDomMatches(raw) {
|
|
|
84
92
|
if (!raw || typeof raw !== 'object')
|
|
85
93
|
throw new Error('the locator script returned nothing');
|
|
86
94
|
const value = raw;
|
|
87
|
-
const elements = Array.isArray(value.elements) ? value.elements : [];
|
|
88
95
|
const visible = Array.isArray(value.visible) ? value.visible.map((v) => v === true) : [];
|
|
96
|
+
const indexes = Array.isArray(value.snapshot_indexes) ? value.snapshot_indexes : [];
|
|
89
97
|
return {
|
|
90
|
-
count: typeof value.count === 'number' ? value.count :
|
|
91
|
-
elementIds: elements.map(elementIdOf),
|
|
98
|
+
count: typeof value.count === 'number' ? value.count : visible.length,
|
|
92
99
|
visible,
|
|
100
|
+
marked: value.marked === true,
|
|
101
|
+
snapshotIndexes: indexes.map((index) => (Number.isInteger(index) ? index : -1)),
|
|
93
102
|
};
|
|
94
103
|
}
|
|
95
104
|
/** Candidates in order: the target itself, then its fallbacks. */
|
|
@@ -97,34 +106,38 @@ export function domCandidates(target) {
|
|
|
97
106
|
const { fallbacks, locate: _locate, ...primary } = target;
|
|
98
107
|
return [primary, ...(fallbacks ?? []).map(({ fallbacks: _nested, locate: _l, ...rest }) => rest)];
|
|
99
108
|
}
|
|
100
|
-
/**
|
|
101
|
-
export
|
|
102
|
-
|
|
103
|
-
|
|
109
|
+
/** A random token for a tag or a kept snapshot. */
|
|
110
|
+
export function domToken() {
|
|
111
|
+
return randomBytes(8).toString('hex');
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Runs one `dom-locate.js` request. For an element, match `nth` is tagged, fetched with W3C Find
|
|
115
|
+
* Elements and untagged again; it stays null when the match went away in between.
|
|
116
|
+
*/
|
|
117
|
+
export async function domQuery(client, request, nth, want = {}) {
|
|
118
|
+
const token = want.element ? domToken() : null;
|
|
119
|
+
const mark = token ? { mark: { token, nth, visible: want.visible === true } } : {};
|
|
120
|
+
const matches = parseDomMatches(await client.executeScript(domScript('dom-locate'), [{ ...request, ...mark }]));
|
|
121
|
+
if (!token || !matches.marked)
|
|
122
|
+
return { ...matches, elementId: null };
|
|
123
|
+
try {
|
|
124
|
+
const found = await client.findElements('css selector', `[${MARK_ATTRIBUTE}="${token}"]`);
|
|
125
|
+
return { ...matches, elementId: found.length > 0 ? elementIdOf(found[0]) : null };
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
await client.executeScript(domScript('dom-locate'), [{ unmark: true }]).catch(() => undefined);
|
|
129
|
+
}
|
|
104
130
|
}
|
|
105
131
|
/** One pass over the target and its fallbacks; null when nothing matches at `nth`. */
|
|
106
|
-
export async function domLocateOnce(client, target, render) {
|
|
132
|
+
export async function domLocateOnce(client, target, render, want = {}) {
|
|
107
133
|
for (const candidate of domCandidates(target)) {
|
|
108
|
-
const found = await domFindAll(client, candidate, render);
|
|
109
134
|
const nth = candidate.nth ?? 0;
|
|
110
|
-
const
|
|
111
|
-
if (
|
|
112
|
-
return {
|
|
135
|
+
const found = await domQuery(client, domLocateRequest(candidate, render, Math.max(DOM_LOCATE_LIMIT, nth + 1)), nth, want);
|
|
136
|
+
if (found.count > nth)
|
|
137
|
+
return { target: candidate, count: found.count, visible: found.visible[nth] ?? false, elementId: found.elementId };
|
|
113
138
|
}
|
|
114
139
|
return null;
|
|
115
140
|
}
|
|
116
|
-
/** Polls until the target (or a fallback) matches (and is visible, when asked) or the time is up. */
|
|
117
|
-
export async function domLocate(client, target, timeoutMs, options = {}) {
|
|
118
|
-
const deadline = Date.now() + timeoutMs;
|
|
119
|
-
for (;;) {
|
|
120
|
-
const found = await domLocateOnce(client, target, options.render);
|
|
121
|
-
if (found && (!options.visible || found.visible))
|
|
122
|
-
return found;
|
|
123
|
-
if (Date.now() >= deadline)
|
|
124
|
-
return options.visible ? null : found;
|
|
125
|
-
await sleep(options.pollMs ?? 200);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
141
|
export function describeDomTarget(target) {
|
|
129
142
|
if (target.test_id !== undefined)
|
|
130
143
|
return `test id "${target.test_id}"`;
|
|
@@ -5,7 +5,7 @@ import { hostDevice } from '../devices/desktop.js';
|
|
|
5
5
|
import { installedWindowsApp, loadWindowsSettings, sharedWinAppDriver, WINAPPDRIVER_VERSION, WINDOWS_NEEDS_WINDOWS, windowsCapabilities, windowsReadiness, } from '../devices/windows.js';
|
|
6
6
|
import { homePaths } from '../home.js';
|
|
7
7
|
import { androidSdkFor, configuredSdkPath, sharedAppiumServer } from './android-appium.js';
|
|
8
|
-
import { clip, errorMessage, expectWithin, matches } from './common.js';
|
|
8
|
+
import { clip, errorMessage, expectWithin, matches, renderLaunchArgs } from './common.js';
|
|
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';
|
|
@@ -185,7 +185,7 @@ export class WindowsAppiumDriver extends DesktopAppiumDriver {
|
|
|
185
185
|
switch (action.type) {
|
|
186
186
|
case 'launch':
|
|
187
187
|
await this.closeApp();
|
|
188
|
-
await this.start((action.args
|
|
188
|
+
await this.start(renderLaunchArgs(action.args, this.render));
|
|
189
189
|
return {};
|
|
190
190
|
case 'terminate':
|
|
191
191
|
await this.closeApp();
|
package/dist/src/executor.js
CHANGED
|
@@ -11,10 +11,18 @@ export { launchBrowser } from './drivers/web-playwright.js';
|
|
|
11
11
|
export function templateContextFor(job) {
|
|
12
12
|
return {
|
|
13
13
|
baseUrl: job.environment.base_url ?? '',
|
|
14
|
+
apiBaseUrl: job.environment.api_base_url ?? null,
|
|
15
|
+
randomEmailDomain: job.environment.random_email_domain ?? null,
|
|
14
16
|
username: job.credential?.username ?? null,
|
|
15
17
|
password: job.credential?.password ?? null,
|
|
18
|
+
otp: job.credential?.otp ?? null,
|
|
16
19
|
runId: job.run.id,
|
|
17
20
|
appId: job.app?.id ?? null,
|
|
21
|
+
data: job.data?.refs ?? {},
|
|
22
|
+
tag: job.data?.tag ?? null,
|
|
23
|
+
itemId: job.item.id,
|
|
24
|
+
jobId: job.job.id,
|
|
25
|
+
attempt: job.job.attempt,
|
|
18
26
|
random: new Map(),
|
|
19
27
|
};
|
|
20
28
|
}
|
|
@@ -48,6 +56,7 @@ export function jobContext(job, options) {
|
|
|
48
56
|
/**
|
|
49
57
|
* A driver context for a device session. Drivers read the app, device and environment
|
|
50
58
|
* from `ctx.job`, so the session is also presented as a job with no steps (id 0, run 0).
|
|
59
|
+
* Its placeholders know they belong to a session: job values and test data are refused.
|
|
51
60
|
*/
|
|
52
61
|
export function sessionContext(session, options) {
|
|
53
62
|
if (!isPlatform(session.platform))
|
|
@@ -66,7 +75,8 @@ export function sessionContext(session, options) {
|
|
|
66
75
|
automation: platform === 'web' ? { version: 1, steps: [] } : { version: 2, platform, steps: [] },
|
|
67
76
|
capture: { video: 'off' },
|
|
68
77
|
};
|
|
69
|
-
|
|
78
|
+
const ctx = jobContext(job, options);
|
|
79
|
+
return { ...ctx, template: { ...ctx.template, session: true }, session };
|
|
70
80
|
}
|
|
71
81
|
/** Creates the driver for a context through the registry; a platform without a factory is blocked. */
|
|
72
82
|
export async function createDriver(registry, ctx, shared) {
|
|
@@ -114,9 +124,10 @@ export async function executeSteps(driver, script, options, shouldStop, redact)
|
|
|
114
124
|
await driver.launch();
|
|
115
125
|
}
|
|
116
126
|
catch (error) {
|
|
117
|
-
// A launch this runner refuses as configured
|
|
127
|
+
// A launch this runner refuses as configured, or launch args it cannot fill, block the case;
|
|
128
|
+
// other launch errors fail the job (and may be retried).
|
|
118
129
|
const first = ordered[0];
|
|
119
|
-
if (!(error instanceof LaunchRefused) || !first)
|
|
130
|
+
if (!(error instanceof LaunchRefused || error instanceof TemplateError) || !first)
|
|
120
131
|
throw error;
|
|
121
132
|
const outcome = { index: first.index, result: 'blocked', note: redact(errorMessage(error)), screenshots: [] };
|
|
122
133
|
return { steps: [outcome], elapsedSecs: Math.max(1, Math.round((Date.now() - started) / 1000)), stopped: false };
|
package/dist/src/home.js
CHANGED
package/dist/src/runner.js
CHANGED
|
@@ -12,7 +12,7 @@ import { createDriver, executeClaimedJob, jobContext, sessionContext } from './e
|
|
|
12
12
|
import { HealthMonitor, healthReport } from './health.js';
|
|
13
13
|
import { homePaths, readJsonFile, writePrivateFile } from './home.js';
|
|
14
14
|
import { progressReporter } from './progress.js';
|
|
15
|
-
import { redactor } from './template.js';
|
|
15
|
+
import { credentialSecrets, redactor } from './template.js';
|
|
16
16
|
import { runSession } from './session/loop.js';
|
|
17
17
|
import { isSessionClaim } from './types.js';
|
|
18
18
|
import { localDay } from './update/check.js';
|
|
@@ -221,7 +221,7 @@ export class Runner {
|
|
|
221
221
|
jobTimeoutMs: config.jobTimeoutMs,
|
|
222
222
|
headless: config.headless,
|
|
223
223
|
...(config.proxy ? { proxy: config.proxy } : {}),
|
|
224
|
-
redact: redactor(
|
|
224
|
+
redact: redactor(credentialSecrets(session.credential)),
|
|
225
225
|
log,
|
|
226
226
|
});
|
|
227
227
|
return createDriver(registry, ctx, shared);
|
|
@@ -314,7 +314,7 @@ export class Runner {
|
|
|
314
314
|
const jobId = job.job.id;
|
|
315
315
|
const attempt = job.job.attempt;
|
|
316
316
|
const platform = lease.device.platform;
|
|
317
|
-
const redact = redactor(
|
|
317
|
+
const redact = redactor(credentialSecrets(job.credential));
|
|
318
318
|
let cancelled = false;
|
|
319
319
|
this.running.set(jobId, { id: jobId, kind: 'test', platform, device_id: lease.device.id, started_at: new Date().toISOString() });
|
|
320
320
|
this.writeState();
|
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
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { randomBytes, randomInt } from 'node:crypto';
|
|
2
2
|
export class TemplateError extends Error {
|
|
3
3
|
}
|
|
4
|
-
const PLACEHOLDER = /\{\{\s*([a-
|
|
4
|
+
const PLACEHOLDER = /\{\{\s*([a-z][a-z0-9_.]*)\s*\}\}/g;
|
|
5
|
+
/** The field a bare `{{data.<ref>}}` reads. */
|
|
6
|
+
export const DEFAULT_DATA_FIELD = 'value';
|
|
7
|
+
/** Fills every placeholder in one pass: a value that itself contains `{{…}}` is inserted as it is. */
|
|
5
8
|
export function renderTemplate(text, ctx) {
|
|
6
9
|
return text.replace(PLACEHOLDER, (_match, name) => placeholderValue(name, ctx));
|
|
7
10
|
}
|
|
@@ -10,6 +13,10 @@ export function resolveUrl(url, baseUrl) {
|
|
|
10
13
|
return trimSlash(baseUrl) + url;
|
|
11
14
|
return url;
|
|
12
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
|
+
}
|
|
13
20
|
/** Replaces every secret in `text`; secrets under 4 characters are skipped because they would mangle ordinary words. */
|
|
14
21
|
export function redactor(secrets) {
|
|
15
22
|
const values = secrets
|
|
@@ -29,29 +36,86 @@ export function redactDeep(value, redact) {
|
|
|
29
36
|
return value;
|
|
30
37
|
}
|
|
31
38
|
function placeholderValue(name, ctx) {
|
|
39
|
+
if (name.startsWith('data.'))
|
|
40
|
+
return dataValue(name, ctx);
|
|
32
41
|
switch (name) {
|
|
33
42
|
case 'env.base_url':
|
|
34
43
|
return trimSlash(ctx.baseUrl);
|
|
44
|
+
case 'env.api_base_url': {
|
|
45
|
+
const apiBaseUrl = ctx.apiBaseUrl?.trim();
|
|
46
|
+
if (!apiBaseUrl)
|
|
47
|
+
throw new TemplateError(`the script uses {{${name}}} but the environment has no API base URL`);
|
|
48
|
+
return trimSlash(apiBaseUrl);
|
|
49
|
+
}
|
|
35
50
|
case 'credential.username':
|
|
36
51
|
return present(ctx.username, name);
|
|
37
52
|
case 'credential.password':
|
|
38
53
|
return present(ctx.password, name);
|
|
54
|
+
case 'credential.otp':
|
|
55
|
+
present(ctx.password, name);
|
|
56
|
+
if (!ctx.otp)
|
|
57
|
+
throw new TemplateError(`the script uses {{${name}}} but the account has no one-time code`);
|
|
58
|
+
return ctx.otp;
|
|
39
59
|
case 'run.id':
|
|
40
60
|
return String(ctx.runId);
|
|
61
|
+
case 'item.id':
|
|
62
|
+
return jobValue(ctx, name, ctx.itemId);
|
|
63
|
+
case 'job.id':
|
|
64
|
+
return jobValue(ctx, name, ctx.jobId);
|
|
65
|
+
case 'job.attempt':
|
|
66
|
+
return jobValue(ctx, name, ctx.attempt);
|
|
67
|
+
case 'job.tag':
|
|
68
|
+
return jobValue(ctx, name, ctx.tag);
|
|
41
69
|
case 'app.id':
|
|
42
70
|
if (!ctx.appId)
|
|
43
71
|
throw new TemplateError('the script uses {{app.id}} but the job has no app');
|
|
44
72
|
return ctx.appId;
|
|
45
73
|
case 'random.email':
|
|
46
|
-
return remembered(ctx, name, () => `qa.${token(10)}
|
|
74
|
+
return remembered(ctx, name, () => `qa.${token(10)}@${emailDomain(ctx)}`);
|
|
47
75
|
case 'random.string':
|
|
48
76
|
return remembered(ctx, name, () => token(12));
|
|
49
77
|
case 'random.number':
|
|
50
78
|
return remembered(ctx, name, () => String(randomInt(100_000, 1_000_000)));
|
|
79
|
+
case 'now.iso':
|
|
80
|
+
return now(ctx);
|
|
81
|
+
case 'date.today':
|
|
82
|
+
// Taken from the same remembered instant as {{now.iso}}, so the two always agree (UTC).
|
|
83
|
+
return now(ctx).slice(0, 10);
|
|
51
84
|
default:
|
|
52
85
|
throw new TemplateError(`unknown placeholder {{${name}}}`);
|
|
53
86
|
}
|
|
54
87
|
}
|
|
88
|
+
/** `{{data.<ref>}}` (field `value`) or `{{data.<ref>.<field>}}`; a missing value is an error, never empty text. */
|
|
89
|
+
function dataValue(name, ctx) {
|
|
90
|
+
const rest = name.slice('data.'.length);
|
|
91
|
+
const dot = rest.indexOf('.');
|
|
92
|
+
const reference = dot === -1 ? rest : rest.slice(0, dot);
|
|
93
|
+
const field = dot === -1 ? DEFAULT_DATA_FIELD : rest.slice(dot + 1);
|
|
94
|
+
if (!reference || !field)
|
|
95
|
+
throw new TemplateError(`unknown placeholder {{${name}}}`);
|
|
96
|
+
if (ctx.session)
|
|
97
|
+
throw new TemplateError(`only a queued job has test data; a device session cannot fill {{${name}}}`);
|
|
98
|
+
const refs = ctx.data ?? {};
|
|
99
|
+
const fields = Object.hasOwn(refs, reference) ? refs[reference] : undefined;
|
|
100
|
+
// The server sends a reference it found no value for as an empty map.
|
|
101
|
+
if (!fields || typeof fields !== 'object' || Object.keys(fields).length === 0) {
|
|
102
|
+
throw new TemplateError(`the script uses {{${name}}} but the job has no value for the test data reference ${reference}`);
|
|
103
|
+
}
|
|
104
|
+
const value = Object.hasOwn(fields, field) ? fields[field] : undefined;
|
|
105
|
+
if (typeof value !== 'string') {
|
|
106
|
+
throw new TemplateError(`the script uses {{${name}}} but the value of the test data reference ${reference} has no field ${field}`);
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
/** A value only a queued job has: device sessions refuse it, and older servers may not send it. */
|
|
111
|
+
function jobValue(ctx, name, value) {
|
|
112
|
+
if (ctx.session)
|
|
113
|
+
throw new TemplateError(`only a queued job has {{${name}}}`);
|
|
114
|
+
if (value === null || value === undefined || value === '') {
|
|
115
|
+
throw new TemplateError(`the script uses {{${name}}} but the server did not send it with this job`);
|
|
116
|
+
}
|
|
117
|
+
return String(value);
|
|
118
|
+
}
|
|
55
119
|
function present(value, name) {
|
|
56
120
|
if (value === null)
|
|
57
121
|
throw new TemplateError(`the script uses {{${name}}} but the job has no credential`);
|
|
@@ -65,6 +129,12 @@ function remembered(ctx, name, make) {
|
|
|
65
129
|
}
|
|
66
130
|
return value;
|
|
67
131
|
}
|
|
132
|
+
function now(ctx) {
|
|
133
|
+
return remembered(ctx, 'now.iso', () => new Date().toISOString());
|
|
134
|
+
}
|
|
135
|
+
function emailDomain(ctx) {
|
|
136
|
+
return ctx.randomEmailDomain?.trim() || 'example.com';
|
|
137
|
+
}
|
|
68
138
|
function token(length) {
|
|
69
139
|
return randomBytes(length).toString('hex').slice(0, length);
|
|
70
140
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leera.io/qa-runner",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.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\"",
|