@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.
@@ -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, domFindAll, domLocate, domLocateOnce, domScript, elementRef, keyActions, validateDomTarget, webdriverKeys, } from './webdriver/dom.js';
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: 'none' };
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 → WebDriver element id from the last snapshot. */
147
- refElements = new Map();
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 ?? []).map(this.renderText);
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 ?? []).map(this.renderText));
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 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();
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 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 };
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
- 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;
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
- 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;
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 found = await this.element(target, timeoutMs);
461
+ let id = await this.element(target, timeoutMs);
391
462
  for (;;) {
392
463
  try {
393
- await command(found.elementId);
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
- found = await this.element(plain, Math.max(100, deadline - Date.now()));
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 found = await this.element(requireTarget(action), timeout);
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(found.elementId), x: 0, y: 0 }] },
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 found = await this.element(target, timeout);
427
- if ((await client.isElementSelected(found.elementId)) !== wanted) {
428
- await this.retrying(timeout, target, (id) => client.elementClick(id));
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(found.elementId)) === wanted, `the element to be ${wanted ? 'checked' : 'unchecked'}`);
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 found = await this.element(requireTarget(action), timeout);
445
- const options = await client.findElementsFromElement(found.elementId, 'css selector', 'option');
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 id = elementIdOf(option);
449
- const [optionValue, text] = await Promise.all([client.getElementProperty(id, 'value'), client.getElementText(id)]);
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(id);
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
- 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);
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.element(requireTarget(action), timeout, true);
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 found = await this.element(target, 0, false);
493
- actual = (await client.getElementText(found.elementId)).trim();
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.refElements.clear();
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 byElement = new Map([...this.refElements].map(([ref, id]) => [id, ref]));
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 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 };
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.refElements = built.elements;
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(_index) { }
620
- debug(_index) {
621
- return undefined;
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
- return undefined;
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.refElements.clear();
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
- const url = resolveUrl(render(action.url, ctx), ctx.baseUrl);
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
- /** Deep link schemes a script may open (plan §3); the server validates too. */
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 Error(`deep link '${clip(url)}' must start with a scheme such as https:// or myapp://`);
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 Error(`deep links with the ${match[1]} scheme are not allowed`);
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 takes one request object and returns plain JSON plus DOM
5
- * elements, which WebDriver serializes as element references.
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: { target: { css | text | test_id | xpath, exact? }, limit? }
8
- * returns: { count, elements: Element[] (at most `limit`), visible: boolean[] }
9
- * throws: Error('invalid css selector: …' | 'invalid xpath: …' | 'the target names no locator')
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 (typeof target.css === 'string') found = byCss(target.css);
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
- return { count: found.length, elements: elements, visible: visible };
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
  })