@leera.io/qa-runner 1.1.0 → 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 +11 -8
- package/dist/src/cli/commands/setup.js +41 -2
- 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 +12 -3
- package/dist/src/drivers/desktop-appium.js +5 -3
- package/dist/src/drivers/ios-appium.js +3 -0
- package/dist/src/drivers/tauri-webdriver.js +174 -67
- 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/executor.js +1 -0
- 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 +9 -0
- package/package.json +2 -2
|
@@ -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}"`;
|
package/dist/src/executor.js
CHANGED
|
@@ -15,6 +15,7 @@ export function templateContextFor(job) {
|
|
|
15
15
|
randomEmailDomain: job.environment.random_email_domain ?? null,
|
|
16
16
|
username: job.credential?.username ?? null,
|
|
17
17
|
password: job.credential?.password ?? null,
|
|
18
|
+
otp: job.credential?.otp ?? null,
|
|
18
19
|
runId: job.run.id,
|
|
19
20
|
appId: job.app?.id ?? null,
|
|
20
21
|
data: job.data?.refs ?? {},
|
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
|
@@ -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
|
|
@@ -47,6 +51,11 @@ function placeholderValue(name, ctx) {
|
|
|
47
51
|
return present(ctx.username, name);
|
|
48
52
|
case 'credential.password':
|
|
49
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;
|
|
50
59
|
case 'run.id':
|
|
51
60
|
return String(ctx.runId);
|
|
52
61
|
case 'item.id':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leera.io/qa-runner",
|
|
3
|
-
"version": "1.
|
|
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\"",
|