@leera.io/qa-runner 1.1.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/README.md +2 -1
- package/dist/src/builds/download.js +8 -7
- package/dist/src/capabilities.js +13 -8
- package/dist/src/cli/commands/setup.js +41 -2
- package/dist/src/client.js +4 -0
- package/dist/src/devices/android/adb.js +22 -0
- package/dist/src/devices/ffmpeg.js +135 -0
- package/dist/src/devices/ios/record.js +8 -7
- package/dist/src/devices/ios/simctl.js +27 -0
- package/dist/src/devices/macos.js +8 -9
- package/dist/src/devices/mp4.js +80 -0
- package/dist/src/devices/screen-record.js +116 -29
- package/dist/src/devices/tauri.js +11 -0
- package/dist/src/devices/windows.js +6 -7
- package/dist/src/drivers/android-appium.js +41 -5
- package/dist/src/drivers/common.js +23 -0
- package/dist/src/drivers/desktop-appium.js +33 -14
- package/dist/src/drivers/electron-playwright.js +89 -21
- package/dist/src/drivers/ios-appium.js +44 -4
- package/dist/src/drivers/mac2-appium.js +28 -2
- package/dist/src/drivers/tauri-webdriver.js +237 -67
- 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/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/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 +23 -1
- package/dist/src/files/store.js +181 -0
- package/dist/src/home.js +1 -0
- package/dist/src/runner.js +15 -4
- package/dist/src/session/loop.js +2 -2
- package/dist/src/template.js +48 -0
- package/package.json +2 -2
|
@@ -7,7 +7,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
|
|
7
7
|
import { chromium } from 'playwright-core';
|
|
8
8
|
import { DebugCollector } from '../debug.js';
|
|
9
9
|
import { BROWSER_DEVICE_ID } from '../devices/types.js';
|
|
10
|
-
import { resolveUrl } from '../template.js';
|
|
10
|
+
import { resolveFileEntries, resolveUrl } from '../template.js';
|
|
11
11
|
import { clip, errorMessage, expectWithin, firstLine, matches, POLL_MS, render, requireTarget, verb, } from './common.js';
|
|
12
12
|
import { Blocked, Unsupported, } from './driver.js';
|
|
13
13
|
import { ariaRefSelector, refFromElementSnapshot, webScreen, webSnapshot } from './snapshot/aria.js';
|
|
@@ -31,6 +31,8 @@ export const WEB_ACTIONS = [
|
|
|
31
31
|
'expect_url',
|
|
32
32
|
'expect_title',
|
|
33
33
|
'screenshot',
|
|
34
|
+
'upload',
|
|
35
|
+
'drop_files',
|
|
34
36
|
];
|
|
35
37
|
export function launchBrowser(options) {
|
|
36
38
|
return chromium.launch({
|
|
@@ -363,10 +365,107 @@ export async function runWebAction(page, action, ctx, defaultTimeout) {
|
|
|
363
365
|
}
|
|
364
366
|
case 'screenshot':
|
|
365
367
|
return { screenshot: await page.screenshot({ type: 'png' }) };
|
|
368
|
+
case 'upload': {
|
|
369
|
+
const paths = resolveFileEntries(action.files, ctx).map((file) => file.path);
|
|
370
|
+
await uploadFiles(page, await find(page, action, ctx, timeout), paths, timeout);
|
|
371
|
+
return {};
|
|
372
|
+
}
|
|
373
|
+
case 'drop_files': {
|
|
374
|
+
const paths = resolveFileEntries(action.files, ctx).map((file) => file.path);
|
|
375
|
+
await dropFiles(page, await find(page, action, ctx, timeout), paths, timeout);
|
|
376
|
+
return {};
|
|
377
|
+
}
|
|
366
378
|
default:
|
|
367
379
|
throw new Error(`unsupported action '${action.type}'`);
|
|
368
380
|
}
|
|
369
381
|
}
|
|
382
|
+
/** Whether a target is a file input (or a label for one), and whether it takes several files. */
|
|
383
|
+
export async function fileInputOf(locator, timeout) {
|
|
384
|
+
return locator.evaluate((element) => {
|
|
385
|
+
const control = element instanceof HTMLLabelElement ? element.control : element;
|
|
386
|
+
const input = control instanceof HTMLInputElement && control.type === 'file';
|
|
387
|
+
return { input, multiple: input && control.multiple };
|
|
388
|
+
}, undefined, { timeout });
|
|
389
|
+
}
|
|
390
|
+
function tooMany(count) {
|
|
391
|
+
return new Error(`the file input takes one file, but the action hands over ${count}`);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Hands files to the app the way a person picking them would, without a dialog: straight into a
|
|
395
|
+
* file input (or the one a label names), else by clicking the target and answering the file
|
|
396
|
+
* chooser it opens.
|
|
397
|
+
*/
|
|
398
|
+
export async function uploadFiles(page, locator, paths, timeout) {
|
|
399
|
+
const target = await fileInputOf(locator, timeout);
|
|
400
|
+
if (target.input) {
|
|
401
|
+
if (paths.length > 1 && !target.multiple)
|
|
402
|
+
throw tooMany(paths.length);
|
|
403
|
+
await locator.setInputFiles(paths, { timeout });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const chooser = page.waitForEvent('filechooser', { timeout }).catch(() => null);
|
|
407
|
+
await locator.click({ timeout });
|
|
408
|
+
const opened = await chooser;
|
|
409
|
+
if (!opened) {
|
|
410
|
+
throw new Error('clicking the element opened no file chooser; target the <input type=file>, its label, or the button that opens the chooser');
|
|
411
|
+
}
|
|
412
|
+
if (paths.length > 1 && !opened.isMultiple())
|
|
413
|
+
throw tooMany(paths.length);
|
|
414
|
+
await opened.setFiles(paths, { timeout });
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Drops files onto a drop zone as if dragged from the desktop: the files go through a hidden file
|
|
418
|
+
* input (so Playwright reads them from disk, however large), then `dragenter`, `dragover` and
|
|
419
|
+
* `drop` carry them to the target. Fails when nothing on the page accepted the drop.
|
|
420
|
+
*/
|
|
421
|
+
export async function dropFiles(page, locator, paths, timeout) {
|
|
422
|
+
await locator.waitFor({ state: 'attached', timeout });
|
|
423
|
+
const source = await page.evaluateHandle(() => {
|
|
424
|
+
const input = document.createElement('input');
|
|
425
|
+
input.type = 'file';
|
|
426
|
+
input.multiple = true;
|
|
427
|
+
input.style.display = 'none';
|
|
428
|
+
input.setAttribute('data-qa-drop-source', '');
|
|
429
|
+
(document.body ?? document.documentElement).appendChild(input);
|
|
430
|
+
return input;
|
|
431
|
+
});
|
|
432
|
+
try {
|
|
433
|
+
const input = source.asElement();
|
|
434
|
+
if (!input)
|
|
435
|
+
throw new Error('the page refused a file input for the drop');
|
|
436
|
+
await input.setInputFiles(paths, { timeout });
|
|
437
|
+
const accepted = await locator.evaluate((target, from) => {
|
|
438
|
+
const transfer = new DataTransfer();
|
|
439
|
+
for (const file of Array.from(from.files ?? []))
|
|
440
|
+
transfer.items.add(file);
|
|
441
|
+
const box = target.getBoundingClientRect();
|
|
442
|
+
const init = {
|
|
443
|
+
bubbles: true,
|
|
444
|
+
cancelable: true,
|
|
445
|
+
composed: true,
|
|
446
|
+
dataTransfer: transfer,
|
|
447
|
+
clientX: box.left + box.width / 2,
|
|
448
|
+
clientY: box.top + box.height / 2,
|
|
449
|
+
};
|
|
450
|
+
target.dispatchEvent(new DragEvent('dragenter', init));
|
|
451
|
+
// A browser only drops where a handler cancelled dragover (or the drop itself).
|
|
452
|
+
const over = !target.dispatchEvent(new DragEvent('dragover', init));
|
|
453
|
+
const dropped = !target.dispatchEvent(new DragEvent('drop', init));
|
|
454
|
+
return over || dropped;
|
|
455
|
+
}, source, { timeout });
|
|
456
|
+
if (!accepted) {
|
|
457
|
+
throw new Error('nothing on the page accepted the dropped files (no drag handler cancelled dragover or drop); target the drop zone itself');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
finally {
|
|
461
|
+
await source.evaluate((input) => input.remove()).catch(() => undefined);
|
|
462
|
+
await source.dispose().catch(() => undefined);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/** The element an action targets: its target, or the first fallback that appears in time. */
|
|
466
|
+
export function locateAction(page, action, ctx, timeout) {
|
|
467
|
+
return find(page, action, ctx, timeout);
|
|
468
|
+
}
|
|
370
469
|
/** The primary target, or the first fallback that appears on the page before the timeout. */
|
|
371
470
|
async function find(page, action, ctx, timeout) {
|
|
372
471
|
const target = requireTarget(action);
|
|
@@ -139,3 +139,30 @@ export function windowsKeyActions(chord) {
|
|
|
139
139
|
...[...mods].reverse().map((virtualKeyCode) => ({ virtualKeyCode, down: false })),
|
|
140
140
|
];
|
|
141
141
|
}
|
|
142
|
+
// ---------------------------------------------------------------------------------------------
|
|
143
|
+
// The system Open dialog (`upload` on native apps)
|
|
144
|
+
// ---------------------------------------------------------------------------------------------
|
|
145
|
+
/**
|
|
146
|
+
* What to type into a Windows Open dialog's File name box, entry by entry (each followed by
|
|
147
|
+
* Enter): the file's full path for one file; for several, the folder (Enter shows it), then the
|
|
148
|
+
* names quoted, which is how the dialog takes a multiple selection.
|
|
149
|
+
*/
|
|
150
|
+
export function windowsOpenDialogEntries(folder, names, separator = '\\') {
|
|
151
|
+
if (names.length === 1)
|
|
152
|
+
return [`${folder.replace(/[\\/]+$/, '')}${separator}${names[0]}`];
|
|
153
|
+
return [folder, names.map((name) => `"${name}"`).join(' ')];
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The chords that pick files in a macOS Open panel once the folder's path is on the pasteboard:
|
|
157
|
+
* Go to Folder (cmd+shift+g), paste, show the folder; then select its only file (down) or all of
|
|
158
|
+
* them (cmd+a), and open.
|
|
159
|
+
*/
|
|
160
|
+
export function macOpenDialogChords(count) {
|
|
161
|
+
return [
|
|
162
|
+
[{ modifiers: ['cmd', 'shift'], key: 'g' }],
|
|
163
|
+
[{ modifiers: ['cmd'], key: 'v' }],
|
|
164
|
+
[{ modifiers: [], key: 'return' }],
|
|
165
|
+
[count > 1 ? { modifiers: ['cmd'], key: 'a' } : { modifiers: [], key: 'down' }],
|
|
166
|
+
[{ modifiers: [], key: 'return' }],
|
|
167
|
+
];
|
|
168
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Runner-owned script for element operations WebDriver servers disagree on (Tauri, contracts §L).
|
|
3
|
+
* Executed with W3C `execute/sync` as `return (<this file>).apply(null, arguments);`, never
|
|
4
|
+
* with user-provided script. The element comes in as a W3C element reference (the second
|
|
5
|
+
* argument), which every server resolves; nothing but plain JSON goes back.
|
|
6
|
+
*
|
|
7
|
+
* request: { op: 'focus' } focuses the element, so key actions reach it; a text field that
|
|
8
|
+
* gains focus gets the caret at its end, as Element Send Keys does
|
|
9
|
+
* { op: 'select' } selects an <option> and fires input and change at its list, as an
|
|
10
|
+
* option click does (tauri-plugin-wdio-webdriver 1.4's click does not)
|
|
11
|
+
* returns: { focused: boolean } | { selected: boolean }
|
|
12
|
+
* throws: Error('element not interactable: …') when the element cannot take it
|
|
13
|
+
*/
|
|
14
|
+
(function domElement(request, element) {
|
|
15
|
+
if (element?.nodeType !== 1 || !element.isConnected) throw new Error('stale element reference: the element is not in the page');
|
|
16
|
+
const op = request?.op;
|
|
17
|
+
|
|
18
|
+
if (op === 'focus') {
|
|
19
|
+
const page = element.ownerDocument;
|
|
20
|
+
if (page.activeElement !== element) {
|
|
21
|
+
if (typeof element.focus === 'function') element.focus();
|
|
22
|
+
if ((element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') && typeof element.value === 'string') {
|
|
23
|
+
try {
|
|
24
|
+
element.setSelectionRange(element.value.length, element.value.length);
|
|
25
|
+
} catch {
|
|
26
|
+
// Inputs such as email and number have no caret to place.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (page.activeElement !== element) throw new Error('element not interactable: it cannot take keyboard focus');
|
|
31
|
+
return { focused: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (op === 'select') {
|
|
35
|
+
if (element.tagName !== 'OPTION') throw new Error('the element is not an option');
|
|
36
|
+
const list = element.closest('select');
|
|
37
|
+
if (!list) throw new Error('the option is not in a select list');
|
|
38
|
+
if (element.matches(':disabled') || list.matches(':disabled')) throw new Error('element not interactable: the option is disabled');
|
|
39
|
+
const before = element.selected;
|
|
40
|
+
element.selected = true;
|
|
41
|
+
list.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
|
42
|
+
if (!before) list.dispatchEvent(new Event('change', { bubbles: true }));
|
|
43
|
+
return { selected: element.selected };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
throw new Error(`unknown element operation '${op}'`);
|
|
47
|
+
})
|
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Runner-owned locator script for WebDriver-backed web views (Tauri, contracts §L).
|
|
3
3
|
* Executed with W3C `execute/sync` as `return (<this file>).apply(null, arguments);`, never
|
|
4
|
-
* with user-provided script. It
|
|
5
|
-
*
|
|
4
|
+
* with user-provided script. It returns plain JSON only, never DOM elements: WebDriver servers
|
|
5
|
+
* disagree on those (tauri-plugin-wdio-webdriver 1.4 returns each as null). The element the
|
|
6
|
+
* runner needs a reference for is tagged `data-qa-runner-mark="<token>"` instead, for W3C Find
|
|
7
|
+
* Elements to return; the runner then removes the tag with `{ unmark: true }`.
|
|
6
8
|
*
|
|
7
|
-
* request
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* request, one of:
|
|
10
|
+
* { target: { css | text | test_id | xpath, exact? }, limit?, mark?, snapshot? }
|
|
11
|
+
* { ref: { snapshot, index }, mark? } the element a snapshot kept (dom-snapshot.js `keep`)
|
|
12
|
+
* { unmark: true } removes every tag
|
|
13
|
+
* mark: { token, nth, visible? } tags match `nth` (with `visible: true`, only when visible)
|
|
14
|
+
* snapshot: the id of the snapshot kept in the page; each returned match gets its index there
|
|
15
|
+
* returns: { count, visible: boolean[] (at most `limit`), marked, snapshot_indexes? } |
|
|
16
|
+
* { unmarked }
|
|
17
|
+
* throws: Error('invalid css selector: …' | 'invalid xpath: …' | 'the target names no locator')
|
|
10
18
|
*/
|
|
11
19
|
(function domLocate(request) {
|
|
20
|
+
const MARK = 'data-qa-runner-mark';
|
|
21
|
+
const KEPT = Symbol.for('qa-runner.snapshot');
|
|
12
22
|
const target = (request?.target) || {};
|
|
13
23
|
const limit = Math.max(1, Math.min(1000, Number((request?.limit) || 50)));
|
|
14
24
|
const SKIP = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEMPLATE: 1, HEAD: 1, META: 1, LINK: 1, TITLE: 1 };
|
|
@@ -101,8 +111,24 @@
|
|
|
101
111
|
return out;
|
|
102
112
|
}
|
|
103
113
|
|
|
114
|
+
/** The element a snapshot kept at `index`, while it is still in the page. */
|
|
115
|
+
function byRef(ref) {
|
|
116
|
+
const kept = window[KEPT];
|
|
117
|
+
const element = kept && kept.id === ref.snapshot ? kept.elements[Number(ref.index)] : null;
|
|
118
|
+
return element?.isConnected ? [element] : [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function clearMarks() {
|
|
122
|
+
const marked = document.querySelectorAll(`[${MARK}]`);
|
|
123
|
+
for (let i = 0; i < marked.length; i += 1) marked[i].removeAttribute(MARK);
|
|
124
|
+
return marked.length;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (request?.unmark) return { unmarked: clearMarks() };
|
|
128
|
+
|
|
104
129
|
let found;
|
|
105
|
-
if (
|
|
130
|
+
if (request?.ref) found = byRef(request.ref);
|
|
131
|
+
else if (typeof target.css === 'string') found = byCss(target.css);
|
|
106
132
|
else if (typeof target.text === 'string') found = byText(target.text, target.exact === true);
|
|
107
133
|
else if (typeof target.test_id === 'string') found = byTestId(target.test_id);
|
|
108
134
|
else if (typeof target.xpath === 'string') found = byXpath(target.xpath);
|
|
@@ -111,5 +137,24 @@
|
|
|
111
137
|
const elements = found.slice(0, limit);
|
|
112
138
|
const visible = [];
|
|
113
139
|
for (let j = 0; j < elements.length; j += 1) visible.push(isVisible(elements[j]));
|
|
114
|
-
|
|
140
|
+
const result = { count: found.length, visible: visible, marked: false };
|
|
141
|
+
|
|
142
|
+
const mark = request?.mark;
|
|
143
|
+
if (mark && typeof mark.token === 'string') {
|
|
144
|
+
// A tag left behind by an interrupted call must not be found instead.
|
|
145
|
+
clearMarks();
|
|
146
|
+
const nth = Math.max(0, Number(mark.nth) || 0);
|
|
147
|
+
const element = found[nth];
|
|
148
|
+
if (element && (mark.visible !== true || (nth < visible.length ? visible[nth] : isVisible(element)))) {
|
|
149
|
+
element.setAttribute(MARK, mark.token);
|
|
150
|
+
result.marked = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (typeof request?.snapshot === 'string') {
|
|
155
|
+
const kept = window[KEPT];
|
|
156
|
+
const list = kept && kept.id === request.snapshot ? kept.elements : [];
|
|
157
|
+
result.snapshot_indexes = elements.map((element) => list.indexOf(element));
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
115
160
|
})
|
|
@@ -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}"`;
|
|
@@ -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';
|
|
@@ -15,6 +16,7 @@ export function templateContextFor(job) {
|
|
|
15
16
|
randomEmailDomain: job.environment.random_email_domain ?? null,
|
|
16
17
|
username: job.credential?.username ?? null,
|
|
17
18
|
password: job.credential?.password ?? null,
|
|
19
|
+
otp: job.credential?.otp ?? null,
|
|
18
20
|
runId: job.run.id,
|
|
19
21
|
appId: job.app?.id ?? null,
|
|
20
22
|
data: job.data?.refs ?? {},
|
|
@@ -36,12 +38,15 @@ export function jobPlatform(job) {
|
|
|
36
38
|
return platform;
|
|
37
39
|
}
|
|
38
40
|
export function jobContext(job, options) {
|
|
41
|
+
const template = templateContextFor(job);
|
|
42
|
+
if (options.files)
|
|
43
|
+
template.files = options.files.files;
|
|
39
44
|
return {
|
|
40
45
|
jobId: job.job.id,
|
|
41
46
|
platform: jobPlatform(job),
|
|
42
47
|
lease: options.lease,
|
|
43
48
|
job,
|
|
44
|
-
template
|
|
49
|
+
template,
|
|
45
50
|
timeouts: { actionMs: options.actionTimeoutMs, jobMs: options.jobTimeoutMs },
|
|
46
51
|
headless: options.headless,
|
|
47
52
|
...(options.proxy ? { proxy: options.proxy } : {}),
|
|
@@ -50,6 +55,7 @@ export function jobContext(job, options) {
|
|
|
50
55
|
log: options.log,
|
|
51
56
|
...(options.app ?? {}),
|
|
52
57
|
...(options.buildDownloadUrl ? { buildDownloadUrl: options.buildDownloadUrl } : {}),
|
|
58
|
+
...(options.files ? { files: options.files } : {}),
|
|
53
59
|
};
|
|
54
60
|
}
|
|
55
61
|
/**
|
|
@@ -178,6 +184,8 @@ export async function executeSteps(driver, script, options, shouldStop, redact)
|
|
|
178
184
|
videoPath = await driver.stopRecording().catch(() => undefined);
|
|
179
185
|
await driver.dispose().catch(() => undefined);
|
|
180
186
|
}
|
|
187
|
+
if (videoPath)
|
|
188
|
+
keepOffsetsInVideo(steps, mp4Seconds(videoPath));
|
|
181
189
|
return {
|
|
182
190
|
steps,
|
|
183
191
|
elapsedSecs: Math.max(1, Math.round((Date.now() - started) / 1000)),
|
|
@@ -185,6 +193,20 @@ export async function executeSteps(driver, script, options, shouldStop, redact)
|
|
|
185
193
|
...(videoPath ? { videoPath } : {}),
|
|
186
194
|
};
|
|
187
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
|
+
}
|
|
188
210
|
/** Prefixes the action's description; template problems block the step rather than fail it. */
|
|
189
211
|
async function runAction(driver, action) {
|
|
190
212
|
try {
|