@leera.io/qa-runner 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/src/capabilities.js +11 -8
- package/dist/src/cli/commands/setup.js +41 -2
- package/dist/src/devices/ffmpeg.js +135 -0
- package/dist/src/devices/ios/record.js +8 -7
- package/dist/src/devices/macos.js +8 -9
- package/dist/src/devices/screen-record.js +31 -17
- package/dist/src/devices/tauri.js +11 -0
- package/dist/src/devices/windows.js +6 -7
- package/dist/src/drivers/android-appium.js +12 -3
- package/dist/src/drivers/desktop-appium.js +5 -3
- package/dist/src/drivers/ios-appium.js +3 -0
- package/dist/src/drivers/tauri-webdriver.js +174 -67
- package/dist/src/drivers/webdriver/dom-element.js +47 -0
- package/dist/src/drivers/webdriver/dom-locate.js +52 -7
- package/dist/src/drivers/webdriver/dom-snapshot.js +8 -5
- package/dist/src/drivers/webdriver/dom-tree.js +7 -15
- package/dist/src/drivers/webdriver/dom.js +40 -27
- package/dist/src/executor.js +1 -0
- package/dist/src/home.js +1 -0
- package/dist/src/runner.js +3 -3
- package/dist/src/session/loop.js +2 -2
- package/dist/src/template.js +9 -0
- package/package.json +2 -2
|
@@ -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, renderLaunchArgs, 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
16
|
import { ELECTRON_ACTIONS, electronEnv, killProcessTree, RESERVED_ARG, validateElectronAction } from './electron-playwright.js';
|
|
15
|
-
import { DOM_LOCATORS, describeDomTarget, domCandidates,
|
|
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;
|
|
@@ -227,8 +240,40 @@ export class TauriWebDriver {
|
|
|
227
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() {
|
|
@@ -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';
|
|
@@ -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. */
|