@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.
@@ -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`; no other script is ever run.
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
- /** Elements one locate call returns (a `nth` beyond it is fetched on demand). */
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 : elements.length,
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
- /** All matches of one target (no fallbacks). */
101
- export async function domFindAll(client, target, render, limit) {
102
- const raw = await client.executeScript(domScript('dom-locate'), [domLocateRequest(target, render, limit ?? Math.max(DOM_LOCATE_LIMIT, (target.nth ?? 0) + 1))]);
103
- return parseDomMatches(raw);
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 elementId = found.elementIds[nth];
111
- if (elementId)
112
- return { elementId, target: candidate, count: found.count, visible: found.visible[nth] ?? false };
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}"`;
@@ -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
@@ -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';
@@ -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([session.credential?.password]),
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([job.credential?.password]);
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();
@@ -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
@@ -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.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\"",