@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.
@@ -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 `elements`)
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
- return { viewport: viewport, url: String(location.href), title: document.title, truncated: truncated, count: count, nodes: nodes, elements: elements };
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 rawElements = Array.isArray(raw.elements) ? raw.elements : [];
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
- const reference = rawElements[node.i];
60
- if (reference !== undefined) {
61
- try {
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) || !elements.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
- elements,
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`; 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}"`;
@@ -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 ?? []).map(this.render));
188
+ await this.start(renderLaunchArgs(action.args, this.render));
189
189
  return {};
190
190
  case 'terminate':
191
191
  await this.closeApp();
@@ -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
- return { ...jobContext(job, options), session };
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 blocks the case; other launch errors fail the job (and may be retried).
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
@@ -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;
@@ -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-z_.]+)\s*\}\}/g;
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)}@example.com`);
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.6",
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\"",