@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.
Files changed (39) hide show
  1. package/README.md +2 -1
  2. package/dist/src/builds/download.js +8 -7
  3. package/dist/src/capabilities.js +13 -8
  4. package/dist/src/cli/commands/setup.js +41 -2
  5. package/dist/src/client.js +4 -0
  6. package/dist/src/devices/android/adb.js +22 -0
  7. package/dist/src/devices/ffmpeg.js +135 -0
  8. package/dist/src/devices/ios/record.js +8 -7
  9. package/dist/src/devices/ios/simctl.js +27 -0
  10. package/dist/src/devices/macos.js +8 -9
  11. package/dist/src/devices/mp4.js +80 -0
  12. package/dist/src/devices/screen-record.js +116 -29
  13. package/dist/src/devices/tauri.js +11 -0
  14. package/dist/src/devices/windows.js +6 -7
  15. package/dist/src/drivers/android-appium.js +41 -5
  16. package/dist/src/drivers/common.js +23 -0
  17. package/dist/src/drivers/desktop-appium.js +33 -14
  18. package/dist/src/drivers/electron-playwright.js +89 -21
  19. package/dist/src/drivers/ios-appium.js +44 -4
  20. package/dist/src/drivers/mac2-appium.js +28 -2
  21. package/dist/src/drivers/tauri-webdriver.js +237 -67
  22. package/dist/src/drivers/web-playwright.js +100 -1
  23. package/dist/src/drivers/webdriver/actions.js +1 -0
  24. package/dist/src/drivers/webdriver/desktop-keys.js +27 -0
  25. package/dist/src/drivers/webdriver/dom-element.js +47 -0
  26. package/dist/src/drivers/webdriver/dom-locate.js +52 -7
  27. package/dist/src/drivers/webdriver/dom-snapshot.js +8 -5
  28. package/dist/src/drivers/webdriver/dom-tree.js +7 -15
  29. package/dist/src/drivers/webdriver/dom.js +40 -27
  30. package/dist/src/drivers/webdriver/ios-actions.js +1 -0
  31. package/dist/src/drivers/webdriver/native-actions.js +1 -0
  32. package/dist/src/drivers/windows-appium.js +38 -3
  33. package/dist/src/executor.js +23 -1
  34. package/dist/src/files/store.js +181 -0
  35. package/dist/src/home.js +1 -0
  36. package/dist/src/runner.js +15 -4
  37. package/dist/src/session/loop.js +2 -2
  38. package/dist/src/template.js +48 -0
  39. package/package.json +2 -2
@@ -1,4 +1,4 @@
1
- import { mkdtempSync, writeFileSync } from 'node:fs';
1
+ import { copyFileSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { basename, join } from 'node:path';
4
4
  import { bundledDrivers } from '../appium/drivers.js';
@@ -8,13 +8,14 @@ import { AppNotAvailable, resolveAppFile } from '../builds/install.js';
8
8
  import { leaseIosDevice, listIosDevices, Simctl } from '../devices/ios/index.js';
9
9
  import { SimulatorRecorder } from '../devices/ios/record.js';
10
10
  import { loadIosSettings } from '../devices/ios/settings.js';
11
- import { assertBundleId } from '../devices/ios/simctl.js';
11
+ import { assertBundleId, isSimulatorMedia } from '../devices/ios/simctl.js';
12
12
  import { parseDeviceSyslogLine, SimulatorLogSink, SyslogCollector } from '../devices/ios/syslog.js';
13
13
  import { developerDir, isCommandLineToolsOnly, wdaRealDeviceDir, wdaStatus } from '../devices/ios/wda.js';
14
+ import { ensureFfmpeg } from '../devices/ffmpeg.js';
14
15
  import { homePaths } from '../home.js';
15
- import { renderTemplate } from '../template.js';
16
+ import { renderTemplate, resolveFileEntries } from '../template.js';
16
17
  import { AndroidAppiumDriver, androidSdkFor, APPIUM_LOG_MAX_BYTES, appiumLogLines, configuredSdkPath, LOCAL_PATHS_REFUSED, sharedAppiumServer } from './android-appium.js';
17
- import { Blocked, LaunchRefused, } from './driver.js';
18
+ import { Blocked, LaunchRefused, Unsupported, } from './driver.js';
18
19
  import { emptyScreen } from './snapshot/format.js';
19
20
  import { IOS_ACTIONS, runIosAction } from './webdriver/ios-actions.js';
20
21
  import { IOS_LOCATORS, iosFindAll } from './webdriver/ios-locate.js';
@@ -242,6 +243,8 @@ export class IosAppiumDriver {
242
243
  await recorder.start();
243
244
  }
244
245
  else {
246
+ // Appium records a real device through ffmpeg on this Mac: fetch it when missing.
247
+ await ensureFfmpeg({ paths: this.paths, log: this.ctx.log });
245
248
  await client.startRecordingScreen({ timeLimit: RECORDING_LIMIT_SECS, videoType: 'h264', forceRestart: true });
246
249
  }
247
250
  this.recording = true;
@@ -291,6 +294,10 @@ export class IosAppiumDriver {
291
294
  };
292
295
  }
293
296
  async run(action) {
297
+ if (action.type === 'add_files') {
298
+ await this.addFiles(action);
299
+ return {};
300
+ }
294
301
  const prepared = action.target ? { ...action, target: AndroidAppiumDriver.resolveRefTarget(action.target) } : action;
295
302
  try {
296
303
  return await runIosAction(this.actionContext(), prepared);
@@ -299,6 +306,39 @@ export class IosAppiumDriver {
299
306
  throw this.translate(error);
300
307
  }
301
308
  }
309
+ /**
310
+ * `add_files` on a simulator: photos and videos go to its Photos library (`simctl addmedia`),
311
+ * other files into the app's Documents folder, where the app and the Files app (for apps that
312
+ * share their documents) find them. Real devices cannot take files yet.
313
+ */
314
+ async addFiles(action) {
315
+ const files = resolveFileEntries(action.files, this.ctx.template);
316
+ const lease = this.lease;
317
+ if (!lease)
318
+ throw new Blocked('the device is not connected');
319
+ if (lease.kind !== 'simulator') {
320
+ throw new Unsupported('add_files works on simulators; adding files to a real iPhone or iPad is not available yet');
321
+ }
322
+ const media = files.filter((file) => isSimulatorMedia(file.file.file_name));
323
+ const documents = files.filter((file) => !isSimulatorMedia(file.file.file_name));
324
+ if (media.length > 0) {
325
+ try {
326
+ await this.simctl.addMedia(lease.udid, media.map((file) => file.path));
327
+ }
328
+ catch (error) {
329
+ throw new Blocked(`the photos could not be added to the simulator: ${webdriverMessage(error)}`);
330
+ }
331
+ }
332
+ if (documents.length === 0)
333
+ return;
334
+ const container = await this.simctl.dataContainer(lease.udid, this.bundleId);
335
+ if (!container)
336
+ throw new Blocked(`${this.bundleId} has no data folder on the simulator; launch the app first`);
337
+ const folder = join(container, 'Documents');
338
+ mkdirSync(folder, { recursive: true });
339
+ for (const file of documents)
340
+ copyFileSync(file.path, join(folder, file.file.file_name));
341
+ }
302
342
  /** A lost Appium/WebDriverAgent session is an environment problem, not a failed check. */
303
343
  translate(error) {
304
344
  const message = webdriverMessage(error);
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from 'node:child_process';
1
2
  import { setTimeout as sleep } from 'node:timers/promises';
2
3
  import { AppiumUnavailable, PortRange } from '../appium/server.js';
3
4
  import { hostDevice } from '../devices/desktop.js';
@@ -8,7 +9,7 @@ import { clip, errorMessage, expectWithin, renderLaunchArgs } from './common.js'
8
9
  import { DesktopAppiumDriver, validateNativeAction } from './desktop-appium.js';
9
10
  import { Blocked, LaunchRefused, } from './driver.js';
10
11
  import { emptyScreen } from './snapshot/format.js';
11
- import { macKey } from './webdriver/desktop-keys.js';
12
+ import { macKey, macOpenDialogChords } from './webdriver/desktop-keys.js';
12
13
  import { predicateString } from './webdriver/ios-locate.js';
13
14
  import { elementIdOf } from './webdriver/locate.js';
14
15
  import { NATIVE_DESKTOP_ACTIONS } from './webdriver/native-actions.js';
@@ -25,6 +26,25 @@ export function macos(client, command, args = {}) {
25
26
  throw new Error(`invalid macos command '${command}'`);
26
27
  return client.executeScript(`macos: ${command}`, [args]);
27
28
  }
29
+ /** Puts text on the Mac's pasteboard (the runner runs on the Mac it drives). */
30
+ export function copyToPasteboard(text) {
31
+ const result = spawnSync('pbcopy', { input: text, timeout: 10_000 });
32
+ if (result.status !== 0)
33
+ throw new Error(`the folder of the files could not be put on the pasteboard (pbcopy exited ${String(result.status)})`);
34
+ }
35
+ /**
36
+ * Picks files in a macOS Open panel: Go to Folder with the folder's path pasted (typing it would
37
+ * depend on the keyboard layout), then the folder's only file (down) or all of them (cmd+a), and
38
+ * Open. The panel animates in and out, so each chord waits a little.
39
+ */
40
+ export async function fillMacOpenDialog(client, folder, names, copy = copyToPasteboard, pauseMs = 600) {
41
+ await sleep(pauseMs);
42
+ copy(folder);
43
+ for (const chords of macOpenDialogChords(names.length)) {
44
+ await macos(client, 'keys', { keys: chords.map(macKey) });
45
+ await sleep(pauseMs);
46
+ }
47
+ }
28
48
  /** The class chain that finds a window whose title contains (or equals) `title`. */
29
49
  export function macWindowChain(title, exact) {
30
50
  const literal = predicateString(title).replace(/`/g, '\\`');
@@ -110,7 +130,7 @@ export class Mac2AppiumDriver extends DesktopAppiumDriver {
110
130
  launched = false;
111
131
  disposed = false;
112
132
  constructor(ctx, appium, deps = {}) {
113
- super(ctx, appium);
133
+ super(ctx, appium, deps.recorder);
114
134
  this.deps = deps;
115
135
  }
116
136
  get ops() {
@@ -209,6 +229,9 @@ export class Mac2AppiumDriver extends DesktopAppiumDriver {
209
229
  case 'focus_window':
210
230
  await this.focusWindow(action);
211
231
  return {};
232
+ case 'upload':
233
+ await this.upload(action);
234
+ return {};
212
235
  default:
213
236
  return await this.runShared(action);
214
237
  }
@@ -217,6 +240,9 @@ export class Mac2AppiumDriver extends DesktopAppiumDriver {
217
240
  throw this.translate(error);
218
241
  }
219
242
  }
243
+ pickInOpenDialog(folder, names) {
244
+ return fillMacOpenDialog(this.requireClient(), folder, names);
245
+ }
220
246
  /** Activates the app and raises the window with that title (a click on its title bar). */
221
247
  async focusWindow(action) {
222
248
  const client = this.requireClient();
@@ -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 { ffmpegRecorder } from '../devices/screen-record.js';
9
10
  import { loadTauriSettings, MACOS_PLUGIN_BLOCKED, tauriBackend, tauriCapabilities, tauriDriverArgv, } from '../devices/tauri.js';
10
11
  import { homePaths } from '../home.js';
12
+ import { resolveFileEntries } from '../template.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, stepStartOffset, 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, domFindAll, domLocate, domLocateOnce, domScript, elementRef, keyActions, validateDomTarget, webdriverKeys, } from './webdriver/dom.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')
@@ -36,6 +42,30 @@ export function validateTauriAction(action) {
36
42
  if (action.target)
37
43
  validateDomTarget(action.target);
38
44
  }
45
+ /** Whether an element is a file input or a label for one, with that input (a WebDriver script). */
46
+ const FILE_INPUT_SCRIPT = `const el = arguments[0];
47
+ const control = el instanceof HTMLLabelElement ? el.control : el;
48
+ if (!(control instanceof HTMLInputElement) || control.type !== 'file') return { input: null, multiple: false };
49
+ return { input: control, multiple: control.multiple };`;
50
+ /** A hidden multiple file input for a drop's files (a WebDriver script). */
51
+ const DROP_SOURCE_SCRIPT = `const input = document.createElement('input');
52
+ input.type = 'file';
53
+ input.multiple = true;
54
+ input.style.display = 'none';
55
+ input.setAttribute('data-qa-drop-source', '');
56
+ (document.body || document.documentElement).appendChild(input);
57
+ return input;`;
58
+ /** Drops the files of arguments[1] onto arguments[0]; true when a handler took them (a WebDriver script). */
59
+ const DROP_SCRIPT = `const target = arguments[0];
60
+ const transfer = new DataTransfer();
61
+ for (const file of Array.from(arguments[1].files || [])) transfer.items.add(file);
62
+ const box = target.getBoundingClientRect();
63
+ const init = { bubbles: true, cancelable: true, composed: true, dataTransfer: transfer,
64
+ clientX: box.left + box.width / 2, clientY: box.top + box.height / 2 };
65
+ target.dispatchEvent(new DragEvent('dragenter', init));
66
+ const over = !target.dispatchEvent(new DragEvent('dragover', init));
67
+ const dropped = !target.dispatchEvent(new DragEvent('drop', init));
68
+ return over || dropped;`;
39
69
  /** Why a build file cannot run as a Tauri app, or null (installers are refused, contracts §L). */
40
70
  export function tauriBuildRefusal(fileName) {
41
71
  const lower = fileName.toLowerCase();
@@ -134,7 +164,7 @@ export class TauriWebDriver {
134
164
  backend;
135
165
  deps;
136
166
  platform = 'tauri';
137
- capabilities = { actions: TAURI_ACTIONS, targets: TAURI_TARGETS, video: 'none' };
167
+ capabilities = { actions: TAURI_ACTIONS, targets: TAURI_TARGETS, video: 'mp4' };
138
168
  client = null;
139
169
  driverProcess = null;
140
170
  driverPort = null;
@@ -143,11 +173,17 @@ export class TauriWebDriver {
143
173
  unpacked = null;
144
174
  releaseBuild = null;
145
175
  display = null;
146
- /** ref → WebDriver element id from the last snapshot. */
147
- refElements = new Map();
176
+ /** The snapshot the page keeps (refs resolve only against it) and ref → its element's index there. */
177
+ snapshotId = null;
178
+ refIndexes = new Map();
148
179
  firstLaunchDone = false;
149
180
  actionsRun = 0;
150
181
  disposed = false;
182
+ recorder = null;
183
+ videoStopped = false;
184
+ videoPath;
185
+ /** When each step began (ms), in the order they ran; offsets count from the video's first frame. */
186
+ stepStarts = [];
151
187
  constructor(ctx, backend, deps = {}) {
152
188
  this.ctx = ctx;
153
189
  this.backend = backend;
@@ -227,8 +263,38 @@ export class TauriWebDriver {
227
263
  args = renderLaunchArgs(first.args, this.renderText, RESERVED_ARG);
228
264
  this.firstLaunchDone = true;
229
265
  }
266
+ await this.startRecording();
230
267
  await this.start(args);
231
268
  }
269
+ /** The screen the app runs on, as the recorder names it. */
270
+ screenOs() {
271
+ return this.backend.kind === 'macos-plugin' ? 'macos' : this.backend.os;
272
+ }
273
+ /**
274
+ * Records the whole screen (on Linux, the job's X display) from before the app starts until
275
+ * the job ends, across relaunches, when the job wants video. Without ffmpeg (none installed and
276
+ * none downloaded) or when recording cannot start, the job runs without a video.
277
+ */
278
+ async startRecording() {
279
+ if (this.ctx.video === 'off')
280
+ return;
281
+ const os = this.screenOs();
282
+ const display = os === 'linux' ? this.display?.env.DISPLAY || process.env.DISPLAY?.trim() || undefined : undefined;
283
+ if (os === 'linux' && !display) {
284
+ this.ctx.log.warn('video is not recorded: the job has no X display');
285
+ return;
286
+ }
287
+ const recorder = await (this.deps.recorder ?? ffmpegRecorder(this.paths, this.ctx.log))(os, display);
288
+ if (!recorder)
289
+ return;
290
+ try {
291
+ await recorder.start();
292
+ this.recorder = recorder;
293
+ }
294
+ catch (error) {
295
+ this.ctx.log.warn('screen recording could not start', { error: errorMessage(error) });
296
+ }
297
+ }
232
298
  async start(args) {
233
299
  const executable = this.executable;
234
300
  if (!executable)
@@ -301,6 +367,19 @@ export class TauriWebDriver {
301
367
  await this.closeApp();
302
368
  throw error;
303
369
  }
370
+ await this.startInMainWindow(this.client);
371
+ }
372
+ /**
373
+ * The plugin starts its session in whichever window its window map yields first, so an app
374
+ * that opens several at launch could start in any of them. Start in the `main` window when
375
+ * there is one, as tauri-driver starts in the app's first window.
376
+ */
377
+ async startInMainWindow(client) {
378
+ const handles = await client.getWindowHandles().catch(() => []);
379
+ if (handles.length < 2 || !handles.includes(MAIN_WINDOW_LABEL))
380
+ return;
381
+ if ((await client.getWindowHandle().catch(() => null)) !== MAIN_WINDOW_LABEL)
382
+ await client.switchToWindow(MAIN_WINDOW_LABEL);
304
383
  }
305
384
  /** Waits until the web view runs the runner's script (the page has loaded). */
306
385
  async waitForPage() {
@@ -354,43 +433,56 @@ export class TauriWebDriver {
354
433
  throw new Error('the app is not running; start it with launch');
355
434
  return this.client;
356
435
  }
357
- /** The element a target names: a snapshot ref when it still exists, else the locator (waiting up to `timeoutMs`). */
358
- async element(target, timeoutMs, visible = true) {
359
- const client = this.requireClient();
436
+ /** The `dom-locate.js` request for the element a snapshot ref names, or null. */
437
+ refRequest(target) {
360
438
  const ref = domRefOf(target.locate);
361
- const id = ref ? this.refElements.get(ref) : undefined;
362
- if (id) {
363
- const displayed = await client.isElementDisplayed(id).then((value) => ({ ok: true, value }), () => ({ ok: false, value: false }));
364
- if (displayed.ok)
365
- return { elementId: id, target, count: 1, visible: displayed.value };
439
+ const index = ref ? this.refIndexes.get(ref) : undefined;
440
+ return this.snapshotId && index !== undefined ? { ref: { snapshot: this.snapshotId, index } } : null;
441
+ }
442
+ /** One look for the target: its snapshot ref while that element is still in the page, else the locator. */
443
+ async locateOnce(target, want = {}) {
444
+ const client = this.requireClient();
445
+ const byRef = this.refRequest(target);
446
+ if (byRef) {
447
+ const found = await domQuery(client, byRef, 0, want);
448
+ if (found.count > 0)
449
+ return { target, count: 1, visible: found.visible[0] ?? false, elementId: found.elementId };
366
450
  }
367
451
  const { locate: _locate, ...plain } = target;
368
- const found = await domLocate(client, plain, timeoutMs, { render: this.renderText, visible });
369
- if (!found)
370
- throw new NotFound(`${describeDomTarget(plain)} was not found within ${Math.round(timeoutMs / 100) / 10} s`);
371
- return found;
452
+ return domLocateOnce(client, plain, this.renderText, want);
453
+ }
454
+ /** Looks for the target until it is there (visible and with an element id, as wanted) or the time is up. */
455
+ async locate(target, timeoutMs, want) {
456
+ const deadline = Date.now() + timeoutMs;
457
+ for (;;) {
458
+ const found = await this.locateOnce(target, want);
459
+ if (found && (!want.visible || found.visible) && (!want.element || found.elementId))
460
+ return found;
461
+ if (Date.now() >= deadline) {
462
+ const { locate: _locate, ...plain } = target;
463
+ throw new NotFound(`${describeDomTarget(plain)} was not found within ${Math.round(timeoutMs / 100) / 10} s`);
464
+ }
465
+ await sleep(POLL_MS);
466
+ }
467
+ }
468
+ /** The WebDriver element id of what a target names, waiting up to `timeoutMs` (for it to be visible, unless `visible` is false). */
469
+ async element(target, timeoutMs, visible = true) {
470
+ const found = await this.locate(target, timeoutMs, { element: true, visible });
471
+ if (!found.elementId)
472
+ throw new NotFound(`${describeDomTarget(target)} was not found`);
473
+ return found.elementId;
372
474
  }
373
475
  /** Whether the target is on screen now (no waiting). */
374
476
  async visibleNow(target) {
375
- const client = this.requireClient();
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;
477
+ return (await this.locateOnce(target))?.visible ?? false;
386
478
  }
387
479
  /** Retries an element command while the element is not interactable yet. */
388
480
  async retrying(timeoutMs, target, command) {
389
481
  const deadline = Date.now() + timeoutMs;
390
- let found = await this.element(target, timeoutMs);
482
+ let id = await this.element(target, timeoutMs);
391
483
  for (;;) {
392
484
  try {
393
- await command(found.elementId);
485
+ await command(id);
394
486
  return;
395
487
  }
396
488
  catch (error) {
@@ -400,7 +492,7 @@ export class TauriWebDriver {
400
492
  await sleep(200);
401
493
  if (/stale element/i.test(message)) {
402
494
  const { locate: _locate, ...plain } = target;
403
- found = await this.element(plain, Math.max(100, deadline - Date.now()));
495
+ id = await this.element(plain, Math.max(100, deadline - Date.now()));
404
496
  }
405
497
  }
406
498
  }
@@ -413,9 +505,9 @@ export class TauriWebDriver {
413
505
  await this.retrying(timeout, requireTarget(action), (id) => client.elementClick(id));
414
506
  return {};
415
507
  case 'hover': {
416
- const found = await this.element(requireTarget(action), timeout);
508
+ const id = await this.element(requireTarget(action), timeout);
417
509
  await client.performActions([
418
- { type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions: [{ type: 'pointerMove', duration: 0, origin: elementRef(found.elementId), x: 0, y: 0 }] },
510
+ { type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions: [{ type: 'pointerMove', duration: 0, origin: elementRef(id), x: 0, y: 0 }] },
419
511
  ]);
420
512
  return {};
421
513
  }
@@ -423,11 +515,11 @@ export class TauriWebDriver {
423
515
  case 'uncheck': {
424
516
  const wanted = action.type === 'check';
425
517
  const target = requireTarget(action);
426
- const found = await this.element(target, timeout);
427
- if ((await client.isElementSelected(found.elementId)) !== wanted) {
428
- await this.retrying(timeout, target, (id) => client.elementClick(id));
518
+ const id = await this.element(target, timeout);
519
+ if ((await client.isElementSelected(id)) !== wanted) {
520
+ await this.retrying(timeout, target, (elementId) => client.elementClick(elementId));
429
521
  }
430
- await expectWithin(Math.min(timeout, 2_000), async () => (await client.isElementSelected(found.elementId)) === wanted, `the element to be ${wanted ? 'checked' : 'unchecked'}`);
522
+ await expectWithin(Math.min(timeout, 2_000), async () => (await client.isElementSelected(id)) === wanted, `the element to be ${wanted ? 'checked' : 'unchecked'}`);
431
523
  return {};
432
524
  }
433
525
  case 'fill': {
@@ -441,15 +533,22 @@ export class TauriWebDriver {
441
533
  }
442
534
  case 'select_option': {
443
535
  const value = render(action.value, this.ctx.template);
444
- const found = await this.element(requireTarget(action), timeout);
445
- const options = await client.findElementsFromElement(found.elementId, 'css selector', 'option');
536
+ const id = await this.element(requireTarget(action), timeout);
537
+ const options = await client.findElementsFromElement(id, 'css selector', 'option');
446
538
  const seen = [];
447
539
  for (const option of options) {
448
- const id = elementIdOf(option);
449
- const [optionValue, text] = await Promise.all([client.getElementProperty(id, 'value'), client.getElementText(id)]);
540
+ const optionId = elementIdOf(option);
541
+ const [optionValue, text] = await Promise.all([client.getElementProperty(optionId, 'value'), client.getElementText(optionId)]);
450
542
  seen.push(text.trim());
451
543
  if (optionValue === value || text.trim() === value) {
452
- await client.elementClick(id);
544
+ await client.elementClick(optionId);
545
+ // W3C servers select an option they click; tauri-plugin-wdio-webdriver 1.4 only fires
546
+ // a click event at it. Select it the way the click should have.
547
+ if (!(await client.isElementSelected(optionId))) {
548
+ await client.executeScript(domScript('dom-element'), [{ op: 'select' }, elementRef(optionId)]);
549
+ if (!(await client.isElementSelected(optionId)))
550
+ throw new Error(`the option "${clip(text.trim())}" could not be selected`);
551
+ }
453
552
  return {};
454
553
  }
455
554
  }
@@ -458,18 +557,16 @@ export class TauriWebDriver {
458
557
  case 'press': {
459
558
  const keys = webdriverKeys(action.key ?? '');
460
559
  if (action.target) {
461
- const found = await this.element(action.target, timeout);
462
- const chord = `${keys.modifiers.join('')}${keys.key}${keys.modifiers.length > 0 ? '' : ''}`;
463
- await client.elementSendKeys(found.elementId, chord);
464
- }
465
- else {
466
- await client.performActions(keyActions(keys));
467
- await client.releaseActions().catch(() => undefined);
560
+ // Focus it, then press as without a target: Element Send Keys can type a named key into
561
+ // the field as text (tauri-plugin-wdio-webdriver 1.4 only appends to the value).
562
+ await this.retrying(timeout, action.target, (id) => client.executeScript(domScript('dom-element'), [{ op: 'focus' }, elementRef(id)]));
468
563
  }
564
+ await client.performActions(keyActions(keys));
565
+ await client.releaseActions().catch(() => undefined);
469
566
  return {};
470
567
  }
471
568
  case 'wait_for':
472
- await this.element(requireTarget(action), timeout, true);
569
+ await this.locate(requireTarget(action), timeout, { visible: true });
473
570
  return {};
474
571
  case 'wait':
475
572
  await sleep(Math.min(action.ms ?? 0, 30_000));
@@ -489,8 +586,8 @@ export class TauriWebDriver {
489
586
  const expected = render(action.value, this.ctx.template);
490
587
  let actual = '';
491
588
  await expectWithin(timeout, async () => {
492
- const found = await this.element(target, 0, false);
493
- actual = (await client.getElementText(found.elementId)).trim();
589
+ const id = await this.element(target, 0, false);
590
+ actual = (await client.getElementText(id)).trim();
494
591
  return matches(actual, expected, action.match);
495
592
  }, () => `text to ${verb(action.match)} "${expected}", found "${clip(actual)}"`);
496
593
  return {};
@@ -515,10 +612,59 @@ export class TauriWebDriver {
515
612
  }
516
613
  case 'screenshot':
517
614
  return { screenshot: await this.screenshot() };
615
+ case 'upload':
616
+ await this.upload(action, timeout);
617
+ return {};
618
+ case 'drop_files':
619
+ await this.dropFiles(action, timeout);
620
+ return {};
518
621
  default:
519
622
  throw new Unsupported(`'${action.type}' is not a tauri action`);
520
623
  }
521
624
  }
625
+ /**
626
+ * Sets the files of an `<input type=file>` (or the one a label names) through Element Send Keys,
627
+ * which W3C drivers accept for file inputs without a dialog, hidden ones included. A native Open
628
+ * dialog the app opens itself is out of WebDriver's reach, so any other target is refused.
629
+ */
630
+ async upload(action, timeout) {
631
+ const client = this.requireClient();
632
+ const paths = resolveFileEntries(action.files, this.ctx.template).map((file) => file.path);
633
+ const id = await this.element(requireTarget(action), timeout, false);
634
+ const found = (await client.executeScript(FILE_INPUT_SCRIPT, [elementRef(id)]));
635
+ if (!found?.input) {
636
+ throw new Unsupported('the target is not a file input; tauri apps are driven over WebDriver, which cannot fill a native Open dialog. Target the <input type=file> or its label');
637
+ }
638
+ if (paths.length > 1 && !found.multiple)
639
+ throw new Error(`the file input takes one file, but the action hands over ${paths.length}`);
640
+ await this.setInputFiles(elementIdOf(found.input), paths);
641
+ }
642
+ /** Element Send Keys with the paths, one per line, then a check that the input holds them all. */
643
+ async setInputFiles(inputId, paths) {
644
+ const client = this.requireClient();
645
+ await client.elementSendKeys(inputId, paths.join('\n'));
646
+ const count = await client.executeScript('return arguments[0].files ? arguments[0].files.length : -1;', [elementRef(inputId)]);
647
+ if (count !== paths.length) {
648
+ throw new Unsupported(`this app's WebDriver did not set the files of the input (it holds ${String(count)} of ${paths.length}); it may not support file uploads`);
649
+ }
650
+ }
651
+ /** `drop_files` as on the web: the files go through a hidden input, then drag events carry them to the target. */
652
+ async dropFiles(action, timeout) {
653
+ const client = this.requireClient();
654
+ const paths = resolveFileEntries(action.files, this.ctx.template).map((file) => file.path);
655
+ const target = await this.element(requireTarget(action), timeout, false);
656
+ const source = elementIdOf(await client.executeScript(DROP_SOURCE_SCRIPT, []));
657
+ try {
658
+ await this.setInputFiles(source, paths);
659
+ const accepted = await client.executeScript(DROP_SCRIPT, [elementRef(target), elementRef(source)]);
660
+ if (accepted !== true) {
661
+ throw new Error('nothing in the app accepted the dropped files (no drag handler cancelled dragover or drop); target the drop zone itself');
662
+ }
663
+ }
664
+ finally {
665
+ await client.executeScript('arguments[0].remove();', [elementRef(source)]).catch(() => undefined);
666
+ }
667
+ }
522
668
  async focusWindow(action) {
523
669
  const client = this.requireClient();
524
670
  const timeout = action.timeout_ms ?? this.ctx.timeouts.actionMs;
@@ -550,29 +696,37 @@ export class TauriWebDriver {
550
696
  }, () => wanted === null
551
697
  ? `window ${action.index} to be open, found ${count} window(s)`
552
698
  : `a window whose title ${action.match === 'equals' ? 'equals' : 'contains'} "${wanted}", found ${titles.map((t) => `"${clip(t)}"`).join(', ') || 'none'}`);
553
- this.refElements.clear();
699
+ this.forgetSnapshot();
700
+ }
701
+ /** The last snapshot's refs stop resolving (another window, or the app closed). */
702
+ forgetSnapshot() {
703
+ this.snapshotId = null;
704
+ this.refIndexes.clear();
554
705
  }
555
706
  async find(target) {
556
707
  validateDomTarget(target);
557
708
  const client = this.requireClient();
558
- const byElement = new Map([...this.refElements].map(([ref, id]) => [id, ref]));
709
+ const refByIndex = new Map([...this.refIndexes].map(([ref, index]) => [index, ref]));
559
710
  for (const candidate of domCandidates(target)) {
560
- const found = await domFindAll(client, candidate, this.renderText, 20);
561
- if (found.count === 0)
562
- continue;
563
711
  const nth = candidate.nth;
564
- const ids = nth === undefined ? found.elementIds : found.elementIds.slice(nth, nth + 1);
565
- const refs = ids.map((id) => byElement.get(id)).filter((ref) => ref !== undefined);
566
- return { count: nth === undefined ? found.count : ids.length, refs };
712
+ const request = domLocateRequest(candidate, this.renderText, Math.max(FIND_LIMIT, (nth ?? 0) + 1));
713
+ const found = parseDomMatches(await client.executeScript(domScript('dom-locate'), [this.snapshotId ? { ...request, snapshot: this.snapshotId } : request]));
714
+ if (found.count <= (nth ?? 0))
715
+ continue;
716
+ const indexes = nth === undefined ? found.snapshotIndexes.slice(0, FIND_LIMIT) : found.snapshotIndexes.slice(nth, nth + 1);
717
+ const refs = indexes.map((index) => refByIndex.get(index)).filter((ref) => ref !== undefined);
718
+ return { count: nth === undefined ? found.count : 1, refs };
567
719
  }
568
720
  return { count: 0, refs: [] };
569
721
  }
570
722
  async snapshot(options = {}) {
571
723
  const client = this.requireClient();
724
+ const id = domToken();
572
725
  const raw = (await client.executeScript(domScript('dom-snapshot'), [
573
726
  {
574
727
  ...(options.depth !== undefined ? { depth: options.depth } : {}),
575
728
  ...(options.includeHidden !== undefined ? { includeHidden: options.includeHidden } : {}),
729
+ keep: id,
576
730
  },
577
731
  ]));
578
732
  const built = buildDomSnapshot(raw, {
@@ -580,7 +734,8 @@ export class TauriWebDriver {
580
734
  ...(options.maxChars !== undefined ? { maxChars: options.maxChars } : {}),
581
735
  appId: this.ctx.job.app?.id || null,
582
736
  });
583
- this.refElements = built.elements;
737
+ this.snapshotId = id;
738
+ this.refIndexes = built.indexes;
584
739
  return built.snapshot;
585
740
  }
586
741
  async screen() {
@@ -616,18 +771,33 @@ export class TauriWebDriver {
616
771
  source() {
617
772
  return this.requireClient().getPageSource();
618
773
  }
619
- beginStep(_index) { }
620
- debug(_index) {
621
- return undefined;
774
+ beginStep(index) {
775
+ this.stepStarts.push({ index, at: Date.now() });
776
+ }
777
+ /** While recording, where a step starts in the video (Tauri steps capture no logs); none for a step it does not show. */
778
+ debug(index) {
779
+ const offset = this.recorder ? stepStartOffset(this.recorder, this.stepStarts, index) : undefined;
780
+ return offset !== undefined ? { video_offset_secs: offset } : undefined;
622
781
  }
623
- /** Tauri jobs record no video (contracts §L). */
624
782
  async stopRecording() {
625
- return undefined;
783
+ if (!this.recorder || this.videoStopped)
784
+ return this.videoPath;
785
+ this.videoStopped = true;
786
+ this.videoPath = await this.recorder.stop();
787
+ if (!this.videoPath)
788
+ this.recorder.cleanup();
789
+ return this.videoPath;
626
790
  }
627
791
  async dispose() {
628
792
  if (this.disposed)
629
793
  return;
630
794
  this.disposed = true;
795
+ if (this.recorder && !this.videoStopped) {
796
+ // Not fetched by the executor (the job ended early): discard it.
797
+ this.videoStopped = true;
798
+ await this.recorder.stop().catch(() => undefined);
799
+ this.recorder.cleanup();
800
+ }
631
801
  await this.closeApp();
632
802
  if (this.driverProcess)
633
803
  killProcessTree(this.driverProcess.child.pid);
@@ -640,7 +810,7 @@ export class TauriWebDriver {
640
810
  async closeApp() {
641
811
  const client = this.client;
642
812
  this.client = null;
643
- this.refElements.clear();
813
+ this.forgetSnapshot();
644
814
  if (client)
645
815
  await Promise.race([client.deleteSession().catch(() => undefined), sleep(10_000)]);
646
816
  const app = this.app;