@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
|
@@ -6,13 +6,15 @@ import { disposeUnpacked } from '../builds/cleanup.js';
|
|
|
6
6
|
import { AppNotAvailable, resolveAppFile } from '../builds/install.js';
|
|
7
7
|
import { buildKind, DesktopBuildError, localDesktopApp, unpackDesktopBuild } from '../builds/unpack.js';
|
|
8
8
|
import { ensureDisplay, hostDevice } from '../devices/desktop.js';
|
|
9
|
+
import { ensureFfmpeg } from '../devices/ffmpeg.js';
|
|
10
|
+
import { ScreenRecorder } from '../devices/screen-record.js';
|
|
9
11
|
import { loadTauriSettings, MACOS_PLUGIN_BLOCKED, tauriBackend, tauriCapabilities, tauriDriverArgv, } from '../devices/tauri.js';
|
|
10
12
|
import { homePaths } from '../home.js';
|
|
11
13
|
import { LOCAL_PATHS_REFUSED } from './android-appium.js';
|
|
12
|
-
import { clip, errorMessage, expectWithin, matches, render, requireTarget, verb } from './common.js';
|
|
14
|
+
import { clip, errorMessage, expectWithin, matches, POLL_MS, render, renderLaunchArgs, requireTarget, verb } from './common.js';
|
|
13
15
|
import { Blocked, LaunchRefused, NotFound, Unsupported, } from './driver.js';
|
|
14
|
-
import { ELECTRON_ACTIONS, electronEnv, killProcessTree, validateElectronAction } from './electron-playwright.js';
|
|
15
|
-
import { DOM_LOCATORS, describeDomTarget, domCandidates,
|
|
16
|
+
import { ELECTRON_ACTIONS, electronEnv, killProcessTree, RESERVED_ARG, validateElectronAction } from './electron-playwright.js';
|
|
17
|
+
import { DOM_LOCATORS, describeDomTarget, domCandidates, domLocateOnce, domLocateRequest, domQuery, domScript, domToken, elementRef, keyActions, parseDomMatches, validateDomTarget, webdriverKeys, } from './webdriver/dom.js';
|
|
16
18
|
import { buildDomSnapshot, domRefOf, domScreen } from './webdriver/dom-tree.js';
|
|
17
19
|
import { elementIdOf } from './webdriver/locate.js';
|
|
18
20
|
import { openSession, webdriverMessage } from './webdriver/session.js';
|
|
@@ -24,9 +26,13 @@ export const MSI_REFUSED = 'an .msi installer cannot run in place — upload the
|
|
|
24
26
|
export const DEB_REFUSED = 'a .deb package cannot run in place — upload the .AppImage or a zip of the installed app folder';
|
|
25
27
|
/** How long a macOS plugin build has to answer `GET /status` (contracts §L). */
|
|
26
28
|
export const PLUGIN_STATUS_TIMEOUT_MS = 30_000;
|
|
29
|
+
/** The label Tauri gives an app's first window unless the app names it; the plugin's window handles are labels. */
|
|
30
|
+
export const MAIN_WINDOW_LABEL = 'main';
|
|
27
31
|
const DRIVER_START_TIMEOUT_MS = 20_000;
|
|
28
32
|
const SESSION_TIMEOUT_MS = 120_000;
|
|
29
33
|
const PAGE_READY_TIMEOUT_MS = 30_000;
|
|
34
|
+
/** Matches a session `find` names refs for. */
|
|
35
|
+
const FIND_LIMIT = 20;
|
|
30
36
|
/** Checks a Tauri action before it runs; throws what the step reports. */
|
|
31
37
|
export function validateTauriAction(action) {
|
|
32
38
|
if (action.type === 'goto')
|
|
@@ -134,7 +140,7 @@ export class TauriWebDriver {
|
|
|
134
140
|
backend;
|
|
135
141
|
deps;
|
|
136
142
|
platform = 'tauri';
|
|
137
|
-
capabilities = { actions: TAURI_ACTIONS, targets: TAURI_TARGETS, video: '
|
|
143
|
+
capabilities = { actions: TAURI_ACTIONS, targets: TAURI_TARGETS, video: 'mp4' };
|
|
138
144
|
client = null;
|
|
139
145
|
driverProcess = null;
|
|
140
146
|
driverPort = null;
|
|
@@ -143,11 +149,18 @@ export class TauriWebDriver {
|
|
|
143
149
|
unpacked = null;
|
|
144
150
|
releaseBuild = null;
|
|
145
151
|
display = null;
|
|
146
|
-
/** ref →
|
|
147
|
-
|
|
152
|
+
/** The snapshot the page keeps (refs resolve only against it) and ref → its element's index there. */
|
|
153
|
+
snapshotId = null;
|
|
154
|
+
refIndexes = new Map();
|
|
148
155
|
firstLaunchDone = false;
|
|
149
156
|
actionsRun = 0;
|
|
150
157
|
disposed = false;
|
|
158
|
+
recorder = null;
|
|
159
|
+
videoStopped = false;
|
|
160
|
+
videoPath;
|
|
161
|
+
/** When the recording started; each step's offset into the video counts from it. */
|
|
162
|
+
startedAt = Date.now();
|
|
163
|
+
offsets = new Map();
|
|
151
164
|
constructor(ctx, backend, deps = {}) {
|
|
152
165
|
this.ctx = ctx;
|
|
153
166
|
this.backend = backend;
|
|
@@ -224,11 +237,43 @@ export class TauriWebDriver {
|
|
|
224
237
|
let args = [];
|
|
225
238
|
if (first?.type === 'launch') {
|
|
226
239
|
validateTauriAction(first);
|
|
227
|
-
args = (first.args
|
|
240
|
+
args = renderLaunchArgs(first.args, this.renderText, RESERVED_ARG);
|
|
228
241
|
this.firstLaunchDone = true;
|
|
229
242
|
}
|
|
243
|
+
await this.startRecording();
|
|
230
244
|
await this.start(args);
|
|
231
245
|
}
|
|
246
|
+
/** The screen the app runs on, as the recorder names it. */
|
|
247
|
+
screenOs() {
|
|
248
|
+
return this.backend.kind === 'macos-plugin' ? 'macos' : this.backend.os;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Records the whole screen (on Linux, the job's X display) from before the app starts until
|
|
252
|
+
* the job ends, across relaunches, when the job wants video. Without ffmpeg (none installed and
|
|
253
|
+
* none downloaded) or when recording cannot start, the job runs without a video.
|
|
254
|
+
*/
|
|
255
|
+
async startRecording() {
|
|
256
|
+
this.startedAt = Date.now();
|
|
257
|
+
if (this.ctx.video === 'off')
|
|
258
|
+
return;
|
|
259
|
+
const os = this.screenOs();
|
|
260
|
+
const display = os === 'linux' ? this.display?.env.DISPLAY || process.env.DISPLAY?.trim() || undefined : undefined;
|
|
261
|
+
if (os === 'linux' && !display) {
|
|
262
|
+
this.ctx.log.warn('video is not recorded: the job has no X display');
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const recorder = await (this.deps.recorder ?? defaultRecorder(this.paths, this.ctx.log))(os, display);
|
|
266
|
+
if (!recorder)
|
|
267
|
+
return;
|
|
268
|
+
this.startedAt = Date.now();
|
|
269
|
+
try {
|
|
270
|
+
await recorder.start();
|
|
271
|
+
this.recorder = recorder;
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
this.ctx.log.warn('screen recording could not start', { error: errorMessage(error) });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
232
277
|
async start(args) {
|
|
233
278
|
const executable = this.executable;
|
|
234
279
|
if (!executable)
|
|
@@ -301,6 +346,19 @@ export class TauriWebDriver {
|
|
|
301
346
|
await this.closeApp();
|
|
302
347
|
throw error;
|
|
303
348
|
}
|
|
349
|
+
await this.startInMainWindow(this.client);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* The plugin starts its session in whichever window its window map yields first, so an app
|
|
353
|
+
* that opens several at launch could start in any of them. Start in the `main` window when
|
|
354
|
+
* there is one, as tauri-driver starts in the app's first window.
|
|
355
|
+
*/
|
|
356
|
+
async startInMainWindow(client) {
|
|
357
|
+
const handles = await client.getWindowHandles().catch(() => []);
|
|
358
|
+
if (handles.length < 2 || !handles.includes(MAIN_WINDOW_LABEL))
|
|
359
|
+
return;
|
|
360
|
+
if ((await client.getWindowHandle().catch(() => null)) !== MAIN_WINDOW_LABEL)
|
|
361
|
+
await client.switchToWindow(MAIN_WINDOW_LABEL);
|
|
304
362
|
}
|
|
305
363
|
/** Waits until the web view runs the runner's script (the page has loaded). */
|
|
306
364
|
async waitForPage() {
|
|
@@ -323,7 +381,7 @@ export class TauriWebDriver {
|
|
|
323
381
|
if (first && this.firstLaunchDone)
|
|
324
382
|
return {};
|
|
325
383
|
await this.closeApp();
|
|
326
|
-
await this.start((action.args
|
|
384
|
+
await this.start(renderLaunchArgs(action.args, this.renderText, RESERVED_ARG));
|
|
327
385
|
return {};
|
|
328
386
|
case 'terminate':
|
|
329
387
|
await this.closeApp();
|
|
@@ -354,43 +412,56 @@ export class TauriWebDriver {
|
|
|
354
412
|
throw new Error('the app is not running; start it with launch');
|
|
355
413
|
return this.client;
|
|
356
414
|
}
|
|
357
|
-
/** The
|
|
358
|
-
|
|
359
|
-
const client = this.requireClient();
|
|
415
|
+
/** The `dom-locate.js` request for the element a snapshot ref names, or null. */
|
|
416
|
+
refRequest(target) {
|
|
360
417
|
const ref = domRefOf(target.locate);
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
418
|
+
const index = ref ? this.refIndexes.get(ref) : undefined;
|
|
419
|
+
return this.snapshotId && index !== undefined ? { ref: { snapshot: this.snapshotId, index } } : null;
|
|
420
|
+
}
|
|
421
|
+
/** One look for the target: its snapshot ref while that element is still in the page, else the locator. */
|
|
422
|
+
async locateOnce(target, want = {}) {
|
|
423
|
+
const client = this.requireClient();
|
|
424
|
+
const byRef = this.refRequest(target);
|
|
425
|
+
if (byRef) {
|
|
426
|
+
const found = await domQuery(client, byRef, 0, want);
|
|
427
|
+
if (found.count > 0)
|
|
428
|
+
return { target, count: 1, visible: found.visible[0] ?? false, elementId: found.elementId };
|
|
366
429
|
}
|
|
367
430
|
const { locate: _locate, ...plain } = target;
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
431
|
+
return domLocateOnce(client, plain, this.renderText, want);
|
|
432
|
+
}
|
|
433
|
+
/** Looks for the target until it is there (visible and with an element id, as wanted) or the time is up. */
|
|
434
|
+
async locate(target, timeoutMs, want) {
|
|
435
|
+
const deadline = Date.now() + timeoutMs;
|
|
436
|
+
for (;;) {
|
|
437
|
+
const found = await this.locateOnce(target, want);
|
|
438
|
+
if (found && (!want.visible || found.visible) && (!want.element || found.elementId))
|
|
439
|
+
return found;
|
|
440
|
+
if (Date.now() >= deadline) {
|
|
441
|
+
const { locate: _locate, ...plain } = target;
|
|
442
|
+
throw new NotFound(`${describeDomTarget(plain)} was not found within ${Math.round(timeoutMs / 100) / 10} s`);
|
|
443
|
+
}
|
|
444
|
+
await sleep(POLL_MS);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/** The WebDriver element id of what a target names, waiting up to `timeoutMs` (for it to be visible, unless `visible` is false). */
|
|
448
|
+
async element(target, timeoutMs, visible = true) {
|
|
449
|
+
const found = await this.locate(target, timeoutMs, { element: true, visible });
|
|
450
|
+
if (!found.elementId)
|
|
451
|
+
throw new NotFound(`${describeDomTarget(target)} was not found`);
|
|
452
|
+
return found.elementId;
|
|
372
453
|
}
|
|
373
454
|
/** Whether the target is on screen now (no waiting). */
|
|
374
455
|
async visibleNow(target) {
|
|
375
|
-
|
|
376
|
-
const ref = domRefOf(target.locate);
|
|
377
|
-
const id = ref ? this.refElements.get(ref) : undefined;
|
|
378
|
-
if (id) {
|
|
379
|
-
const displayed = await client.isElementDisplayed(id).catch(() => null);
|
|
380
|
-
if (displayed !== null)
|
|
381
|
-
return displayed;
|
|
382
|
-
}
|
|
383
|
-
const { locate: _locate, ...plain } = target;
|
|
384
|
-
const found = await domLocateOnce(client, plain, this.renderText);
|
|
385
|
-
return found?.visible ?? false;
|
|
456
|
+
return (await this.locateOnce(target))?.visible ?? false;
|
|
386
457
|
}
|
|
387
458
|
/** Retries an element command while the element is not interactable yet. */
|
|
388
459
|
async retrying(timeoutMs, target, command) {
|
|
389
460
|
const deadline = Date.now() + timeoutMs;
|
|
390
|
-
let
|
|
461
|
+
let id = await this.element(target, timeoutMs);
|
|
391
462
|
for (;;) {
|
|
392
463
|
try {
|
|
393
|
-
await command(
|
|
464
|
+
await command(id);
|
|
394
465
|
return;
|
|
395
466
|
}
|
|
396
467
|
catch (error) {
|
|
@@ -400,7 +471,7 @@ export class TauriWebDriver {
|
|
|
400
471
|
await sleep(200);
|
|
401
472
|
if (/stale element/i.test(message)) {
|
|
402
473
|
const { locate: _locate, ...plain } = target;
|
|
403
|
-
|
|
474
|
+
id = await this.element(plain, Math.max(100, deadline - Date.now()));
|
|
404
475
|
}
|
|
405
476
|
}
|
|
406
477
|
}
|
|
@@ -413,9 +484,9 @@ export class TauriWebDriver {
|
|
|
413
484
|
await this.retrying(timeout, requireTarget(action), (id) => client.elementClick(id));
|
|
414
485
|
return {};
|
|
415
486
|
case 'hover': {
|
|
416
|
-
const
|
|
487
|
+
const id = await this.element(requireTarget(action), timeout);
|
|
417
488
|
await client.performActions([
|
|
418
|
-
{ type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions: [{ type: 'pointerMove', duration: 0, origin: elementRef(
|
|
489
|
+
{ type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions: [{ type: 'pointerMove', duration: 0, origin: elementRef(id), x: 0, y: 0 }] },
|
|
419
490
|
]);
|
|
420
491
|
return {};
|
|
421
492
|
}
|
|
@@ -423,11 +494,11 @@ export class TauriWebDriver {
|
|
|
423
494
|
case 'uncheck': {
|
|
424
495
|
const wanted = action.type === 'check';
|
|
425
496
|
const target = requireTarget(action);
|
|
426
|
-
const
|
|
427
|
-
if ((await client.isElementSelected(
|
|
428
|
-
await this.retrying(timeout, target, (
|
|
497
|
+
const id = await this.element(target, timeout);
|
|
498
|
+
if ((await client.isElementSelected(id)) !== wanted) {
|
|
499
|
+
await this.retrying(timeout, target, (elementId) => client.elementClick(elementId));
|
|
429
500
|
}
|
|
430
|
-
await expectWithin(Math.min(timeout, 2_000), async () => (await client.isElementSelected(
|
|
501
|
+
await expectWithin(Math.min(timeout, 2_000), async () => (await client.isElementSelected(id)) === wanted, `the element to be ${wanted ? 'checked' : 'unchecked'}`);
|
|
431
502
|
return {};
|
|
432
503
|
}
|
|
433
504
|
case 'fill': {
|
|
@@ -441,15 +512,22 @@ export class TauriWebDriver {
|
|
|
441
512
|
}
|
|
442
513
|
case 'select_option': {
|
|
443
514
|
const value = render(action.value, this.ctx.template);
|
|
444
|
-
const
|
|
445
|
-
const options = await client.findElementsFromElement(
|
|
515
|
+
const id = await this.element(requireTarget(action), timeout);
|
|
516
|
+
const options = await client.findElementsFromElement(id, 'css selector', 'option');
|
|
446
517
|
const seen = [];
|
|
447
518
|
for (const option of options) {
|
|
448
|
-
const
|
|
449
|
-
const [optionValue, text] = await Promise.all([client.getElementProperty(
|
|
519
|
+
const optionId = elementIdOf(option);
|
|
520
|
+
const [optionValue, text] = await Promise.all([client.getElementProperty(optionId, 'value'), client.getElementText(optionId)]);
|
|
450
521
|
seen.push(text.trim());
|
|
451
522
|
if (optionValue === value || text.trim() === value) {
|
|
452
|
-
await client.elementClick(
|
|
523
|
+
await client.elementClick(optionId);
|
|
524
|
+
// W3C servers select an option they click; tauri-plugin-wdio-webdriver 1.4 only fires
|
|
525
|
+
// a click event at it. Select it the way the click should have.
|
|
526
|
+
if (!(await client.isElementSelected(optionId))) {
|
|
527
|
+
await client.executeScript(domScript('dom-element'), [{ op: 'select' }, elementRef(optionId)]);
|
|
528
|
+
if (!(await client.isElementSelected(optionId)))
|
|
529
|
+
throw new Error(`the option "${clip(text.trim())}" could not be selected`);
|
|
530
|
+
}
|
|
453
531
|
return {};
|
|
454
532
|
}
|
|
455
533
|
}
|
|
@@ -458,18 +536,16 @@ export class TauriWebDriver {
|
|
|
458
536
|
case 'press': {
|
|
459
537
|
const keys = webdriverKeys(action.key ?? '');
|
|
460
538
|
if (action.target) {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
await
|
|
464
|
-
}
|
|
465
|
-
else {
|
|
466
|
-
await client.performActions(keyActions(keys));
|
|
467
|
-
await client.releaseActions().catch(() => undefined);
|
|
539
|
+
// Focus it, then press as without a target: Element Send Keys can type a named key into
|
|
540
|
+
// the field as text (tauri-plugin-wdio-webdriver 1.4 only appends to the value).
|
|
541
|
+
await this.retrying(timeout, action.target, (id) => client.executeScript(domScript('dom-element'), [{ op: 'focus' }, elementRef(id)]));
|
|
468
542
|
}
|
|
543
|
+
await client.performActions(keyActions(keys));
|
|
544
|
+
await client.releaseActions().catch(() => undefined);
|
|
469
545
|
return {};
|
|
470
546
|
}
|
|
471
547
|
case 'wait_for':
|
|
472
|
-
await this.
|
|
548
|
+
await this.locate(requireTarget(action), timeout, { visible: true });
|
|
473
549
|
return {};
|
|
474
550
|
case 'wait':
|
|
475
551
|
await sleep(Math.min(action.ms ?? 0, 30_000));
|
|
@@ -489,8 +565,8 @@ export class TauriWebDriver {
|
|
|
489
565
|
const expected = render(action.value, this.ctx.template);
|
|
490
566
|
let actual = '';
|
|
491
567
|
await expectWithin(timeout, async () => {
|
|
492
|
-
const
|
|
493
|
-
actual = (await client.getElementText(
|
|
568
|
+
const id = await this.element(target, 0, false);
|
|
569
|
+
actual = (await client.getElementText(id)).trim();
|
|
494
570
|
return matches(actual, expected, action.match);
|
|
495
571
|
}, () => `text to ${verb(action.match)} "${expected}", found "${clip(actual)}"`);
|
|
496
572
|
return {};
|
|
@@ -550,29 +626,37 @@ export class TauriWebDriver {
|
|
|
550
626
|
}, () => wanted === null
|
|
551
627
|
? `window ${action.index} to be open, found ${count} window(s)`
|
|
552
628
|
: `a window whose title ${action.match === 'equals' ? 'equals' : 'contains'} "${wanted}", found ${titles.map((t) => `"${clip(t)}"`).join(', ') || 'none'}`);
|
|
553
|
-
this.
|
|
629
|
+
this.forgetSnapshot();
|
|
630
|
+
}
|
|
631
|
+
/** The last snapshot's refs stop resolving (another window, or the app closed). */
|
|
632
|
+
forgetSnapshot() {
|
|
633
|
+
this.snapshotId = null;
|
|
634
|
+
this.refIndexes.clear();
|
|
554
635
|
}
|
|
555
636
|
async find(target) {
|
|
556
637
|
validateDomTarget(target);
|
|
557
638
|
const client = this.requireClient();
|
|
558
|
-
const
|
|
639
|
+
const refByIndex = new Map([...this.refIndexes].map(([ref, index]) => [index, ref]));
|
|
559
640
|
for (const candidate of domCandidates(target)) {
|
|
560
|
-
const found = await domFindAll(client, candidate, this.renderText, 20);
|
|
561
|
-
if (found.count === 0)
|
|
562
|
-
continue;
|
|
563
641
|
const nth = candidate.nth;
|
|
564
|
-
const
|
|
565
|
-
const
|
|
566
|
-
|
|
642
|
+
const request = domLocateRequest(candidate, this.renderText, Math.max(FIND_LIMIT, (nth ?? 0) + 1));
|
|
643
|
+
const found = parseDomMatches(await client.executeScript(domScript('dom-locate'), [this.snapshotId ? { ...request, snapshot: this.snapshotId } : request]));
|
|
644
|
+
if (found.count <= (nth ?? 0))
|
|
645
|
+
continue;
|
|
646
|
+
const indexes = nth === undefined ? found.snapshotIndexes.slice(0, FIND_LIMIT) : found.snapshotIndexes.slice(nth, nth + 1);
|
|
647
|
+
const refs = indexes.map((index) => refByIndex.get(index)).filter((ref) => ref !== undefined);
|
|
648
|
+
return { count: nth === undefined ? found.count : 1, refs };
|
|
567
649
|
}
|
|
568
650
|
return { count: 0, refs: [] };
|
|
569
651
|
}
|
|
570
652
|
async snapshot(options = {}) {
|
|
571
653
|
const client = this.requireClient();
|
|
654
|
+
const id = domToken();
|
|
572
655
|
const raw = (await client.executeScript(domScript('dom-snapshot'), [
|
|
573
656
|
{
|
|
574
657
|
...(options.depth !== undefined ? { depth: options.depth } : {}),
|
|
575
658
|
...(options.includeHidden !== undefined ? { includeHidden: options.includeHidden } : {}),
|
|
659
|
+
keep: id,
|
|
576
660
|
},
|
|
577
661
|
]));
|
|
578
662
|
const built = buildDomSnapshot(raw, {
|
|
@@ -580,7 +664,8 @@ export class TauriWebDriver {
|
|
|
580
664
|
...(options.maxChars !== undefined ? { maxChars: options.maxChars } : {}),
|
|
581
665
|
appId: this.ctx.job.app?.id || null,
|
|
582
666
|
});
|
|
583
|
-
this.
|
|
667
|
+
this.snapshotId = id;
|
|
668
|
+
this.refIndexes = built.indexes;
|
|
584
669
|
return built.snapshot;
|
|
585
670
|
}
|
|
586
671
|
async screen() {
|
|
@@ -616,18 +701,33 @@ export class TauriWebDriver {
|
|
|
616
701
|
source() {
|
|
617
702
|
return this.requireClient().getPageSource();
|
|
618
703
|
}
|
|
619
|
-
beginStep(
|
|
620
|
-
|
|
621
|
-
|
|
704
|
+
beginStep(index) {
|
|
705
|
+
this.offsets.set(index, Math.round((Date.now() - this.startedAt) / 100) / 10);
|
|
706
|
+
}
|
|
707
|
+
/** While recording, a step's start time in the video (Tauri steps capture no logs). */
|
|
708
|
+
debug(index) {
|
|
709
|
+
const offset = this.offsets.get(index);
|
|
710
|
+
return offset !== undefined && this.recorder ? { video_offset_secs: offset } : undefined;
|
|
622
711
|
}
|
|
623
|
-
/** Tauri jobs record no video (contracts §L). */
|
|
624
712
|
async stopRecording() {
|
|
625
|
-
|
|
713
|
+
if (!this.recorder || this.videoStopped)
|
|
714
|
+
return this.videoPath;
|
|
715
|
+
this.videoStopped = true;
|
|
716
|
+
this.videoPath = await this.recorder.stop();
|
|
717
|
+
if (!this.videoPath)
|
|
718
|
+
this.recorder.cleanup();
|
|
719
|
+
return this.videoPath;
|
|
626
720
|
}
|
|
627
721
|
async dispose() {
|
|
628
722
|
if (this.disposed)
|
|
629
723
|
return;
|
|
630
724
|
this.disposed = true;
|
|
725
|
+
if (this.recorder && !this.videoStopped) {
|
|
726
|
+
// Not fetched by the executor (the job ended early): discard it.
|
|
727
|
+
this.videoStopped = true;
|
|
728
|
+
await this.recorder.stop().catch(() => undefined);
|
|
729
|
+
this.recorder.cleanup();
|
|
730
|
+
}
|
|
631
731
|
await this.closeApp();
|
|
632
732
|
if (this.driverProcess)
|
|
633
733
|
killProcessTree(this.driverProcess.child.pid);
|
|
@@ -640,7 +740,7 @@ export class TauriWebDriver {
|
|
|
640
740
|
async closeApp() {
|
|
641
741
|
const client = this.client;
|
|
642
742
|
this.client = null;
|
|
643
|
-
this.
|
|
743
|
+
this.forgetSnapshot();
|
|
644
744
|
if (client)
|
|
645
745
|
await Promise.race([client.deleteSession().catch(() => undefined), sleep(10_000)]);
|
|
646
746
|
const app = this.app;
|
|
@@ -649,6 +749,13 @@ export class TauriWebDriver {
|
|
|
649
749
|
killProcessTree(app.child.pid);
|
|
650
750
|
}
|
|
651
751
|
}
|
|
752
|
+
/** ScreenRecorder with the ffmpeg on PATH or in the runner home (downloaded now when missing). */
|
|
753
|
+
function defaultRecorder(paths, log) {
|
|
754
|
+
return async (os, display) => {
|
|
755
|
+
const ffmpeg = await ensureFfmpeg({ paths, log });
|
|
756
|
+
return ffmpeg ? new ScreenRecorder(ffmpeg, os, display ? { display } : {}) : null;
|
|
757
|
+
};
|
|
758
|
+
}
|
|
652
759
|
function nodePlatform(os) {
|
|
653
760
|
if (os === 'macos')
|
|
654
761
|
return 'darwin';
|
|
@@ -138,9 +138,9 @@ export class WebPlaywrightDriver {
|
|
|
138
138
|
const page = await this.context.newPage();
|
|
139
139
|
this.page = page;
|
|
140
140
|
// The video starts with the page, so step offsets are measured from here.
|
|
141
|
-
this.collector = new DebugCollector(Date.now(), this.ctx.redact);
|
|
142
|
-
this.collector.attach(page);
|
|
143
141
|
this.video = page.video();
|
|
142
|
+
this.collector = new DebugCollector(Date.now(), this.ctx.redact, () => this.video !== null);
|
|
143
|
+
this.collector.attach(page);
|
|
144
144
|
page.setDefaultTimeout(this.ctx.timeouts.actionMs);
|
|
145
145
|
page.setDefaultNavigationTimeout(Math.max(this.ctx.timeouts.actionMs, NAVIGATION_TIMEOUT_MS));
|
|
146
146
|
}
|
|
@@ -260,12 +260,30 @@ export async function findOnPage(page, target, ctx, snapshotTaken) {
|
|
|
260
260
|
await page.ariaSnapshot({ mode: 'ai' }).catch(() => undefined);
|
|
261
261
|
return { count, refs };
|
|
262
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* The URL a `goto` opens, checked after rendering and resolving: only http(s). The server checks
|
|
265
|
+
* the script, but a URL that starts with a data value or the API base URL can render any scheme.
|
|
266
|
+
*/
|
|
267
|
+
export function checkGotoUrl(url) {
|
|
268
|
+
let protocol;
|
|
269
|
+
try {
|
|
270
|
+
protocol = new URL(url).protocol;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
throw new Blocked(`'${clip(url)}' is not a URL the browser can open; use http(s)://, or a path on an environment with a base URL`);
|
|
274
|
+
}
|
|
275
|
+
if (protocol !== 'http:' && protocol !== 'https:') {
|
|
276
|
+
throw new Blocked(`only http(s) URLs can be opened; '${clip(url)}' uses ${protocol}`);
|
|
277
|
+
}
|
|
278
|
+
return url;
|
|
279
|
+
}
|
|
263
280
|
/** Performs one web DSL action on a page (shared by the web and Electron drivers). */
|
|
264
281
|
export async function runWebAction(page, action, ctx, defaultTimeout) {
|
|
265
282
|
const timeout = action.timeout_ms ?? defaultTimeout;
|
|
266
283
|
switch (action.type) {
|
|
267
284
|
case 'goto': {
|
|
268
|
-
|
|
285
|
+
// Trimmed as the server reads it, so a path with a stray leading space still opens on the environment.
|
|
286
|
+
const url = checkGotoUrl(resolveUrl(render(action.url, ctx).trim(), ctx.baseUrl));
|
|
269
287
|
try {
|
|
270
288
|
await page.goto(url, { waitUntil: 'load', timeout: Math.max(timeout, NAVIGATION_TIMEOUT_MS) });
|
|
271
289
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
2
|
import { clip, expectWithin, matches, verb } from '../common.js';
|
|
3
|
-
import { NotFound, Unsupported } from '../driver.js';
|
|
3
|
+
import { Blocked, NotFound, Unsupported } from '../driver.js';
|
|
4
4
|
import { describeAndroidTarget, findAll, locate } from './locate.js';
|
|
5
5
|
import { mobile, webdriverMessage } from './session.js';
|
|
6
6
|
/** Named android keys (plan §3) → Android key codes. */
|
|
@@ -68,13 +68,17 @@ export const ANDROID_ACTIONS = [
|
|
|
68
68
|
'expect_hidden',
|
|
69
69
|
'expect_text',
|
|
70
70
|
];
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Deep link schemes a script may open (plan §3), checked on the rendered URL (android and ios):
|
|
73
|
+
* the server checks the script, but a link that starts with a data value can render any scheme.
|
|
74
|
+
* A refused link blocks the step.
|
|
75
|
+
*/
|
|
72
76
|
export function checkDeepLink(url) {
|
|
73
77
|
const match = /^([a-z][a-z0-9+.-]{1,63}):\/\//i.exec(url);
|
|
74
78
|
if (!match)
|
|
75
|
-
throw new
|
|
79
|
+
throw new Blocked(`deep link '${clip(url)}' must start with a scheme such as https:// or myapp://`);
|
|
76
80
|
if (/^(javascript|file|data|blob|content|intent)$/i.test(match[1]))
|
|
77
|
-
throw new
|
|
81
|
+
throw new Blocked(`deep links with the ${match[1]} scheme are not allowed`);
|
|
78
82
|
return url;
|
|
79
83
|
}
|
|
80
84
|
function requireTarget(action) {
|
|
@@ -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,20 +1,32 @@
|
|
|
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 };
|
|
15
25
|
|
|
26
|
+
/** Collapses whitespace and composes accents (NFC), so the target and the page compare alike. */
|
|
16
27
|
function normalize(value) {
|
|
17
28
|
return String(value == null ? '' : value)
|
|
29
|
+
.normalize('NFC')
|
|
18
30
|
.replace(/\s+/g, ' ')
|
|
19
31
|
.trim();
|
|
20
32
|
}
|
|
@@ -99,8 +111,24 @@
|
|
|
99
111
|
return out;
|
|
100
112
|
}
|
|
101
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
|
+
|
|
102
129
|
let found;
|
|
103
|
-
if (
|
|
130
|
+
if (request?.ref) found = byRef(request.ref);
|
|
131
|
+
else if (typeof target.css === 'string') found = byCss(target.css);
|
|
104
132
|
else if (typeof target.text === 'string') found = byText(target.text, target.exact === true);
|
|
105
133
|
else if (typeof target.test_id === 'string') found = byTestId(target.test_id);
|
|
106
134
|
else if (typeof target.xpath === 'string') found = byXpath(target.xpath);
|
|
@@ -109,5 +137,24 @@
|
|
|
109
137
|
const elements = found.slice(0, limit);
|
|
110
138
|
const visible = [];
|
|
111
139
|
for (let j = 0; j < elements.length; j += 1) visible.push(isVisible(elements[j]));
|
|
112
|
-
|
|
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;
|
|
113
160
|
})
|