@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
@@ -7,7 +7,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
7
7
  import { chromium } from 'playwright-core';
8
8
  import { DebugCollector } from '../debug.js';
9
9
  import { BROWSER_DEVICE_ID } from '../devices/types.js';
10
- import { resolveUrl } from '../template.js';
10
+ import { resolveFileEntries, resolveUrl } from '../template.js';
11
11
  import { clip, errorMessage, expectWithin, firstLine, matches, POLL_MS, render, requireTarget, verb, } from './common.js';
12
12
  import { Blocked, Unsupported, } from './driver.js';
13
13
  import { ariaRefSelector, refFromElementSnapshot, webScreen, webSnapshot } from './snapshot/aria.js';
@@ -31,6 +31,8 @@ export const WEB_ACTIONS = [
31
31
  'expect_url',
32
32
  'expect_title',
33
33
  'screenshot',
34
+ 'upload',
35
+ 'drop_files',
34
36
  ];
35
37
  export function launchBrowser(options) {
36
38
  return chromium.launch({
@@ -363,10 +365,107 @@ export async function runWebAction(page, action, ctx, defaultTimeout) {
363
365
  }
364
366
  case 'screenshot':
365
367
  return { screenshot: await page.screenshot({ type: 'png' }) };
368
+ case 'upload': {
369
+ const paths = resolveFileEntries(action.files, ctx).map((file) => file.path);
370
+ await uploadFiles(page, await find(page, action, ctx, timeout), paths, timeout);
371
+ return {};
372
+ }
373
+ case 'drop_files': {
374
+ const paths = resolveFileEntries(action.files, ctx).map((file) => file.path);
375
+ await dropFiles(page, await find(page, action, ctx, timeout), paths, timeout);
376
+ return {};
377
+ }
366
378
  default:
367
379
  throw new Error(`unsupported action '${action.type}'`);
368
380
  }
369
381
  }
382
+ /** Whether a target is a file input (or a label for one), and whether it takes several files. */
383
+ export async function fileInputOf(locator, timeout) {
384
+ return locator.evaluate((element) => {
385
+ const control = element instanceof HTMLLabelElement ? element.control : element;
386
+ const input = control instanceof HTMLInputElement && control.type === 'file';
387
+ return { input, multiple: input && control.multiple };
388
+ }, undefined, { timeout });
389
+ }
390
+ function tooMany(count) {
391
+ return new Error(`the file input takes one file, but the action hands over ${count}`);
392
+ }
393
+ /**
394
+ * Hands files to the app the way a person picking them would, without a dialog: straight into a
395
+ * file input (or the one a label names), else by clicking the target and answering the file
396
+ * chooser it opens.
397
+ */
398
+ export async function uploadFiles(page, locator, paths, timeout) {
399
+ const target = await fileInputOf(locator, timeout);
400
+ if (target.input) {
401
+ if (paths.length > 1 && !target.multiple)
402
+ throw tooMany(paths.length);
403
+ await locator.setInputFiles(paths, { timeout });
404
+ return;
405
+ }
406
+ const chooser = page.waitForEvent('filechooser', { timeout }).catch(() => null);
407
+ await locator.click({ timeout });
408
+ const opened = await chooser;
409
+ if (!opened) {
410
+ throw new Error('clicking the element opened no file chooser; target the <input type=file>, its label, or the button that opens the chooser');
411
+ }
412
+ if (paths.length > 1 && !opened.isMultiple())
413
+ throw tooMany(paths.length);
414
+ await opened.setFiles(paths, { timeout });
415
+ }
416
+ /**
417
+ * Drops files onto a drop zone as if dragged from the desktop: the files go through a hidden file
418
+ * input (so Playwright reads them from disk, however large), then `dragenter`, `dragover` and
419
+ * `drop` carry them to the target. Fails when nothing on the page accepted the drop.
420
+ */
421
+ export async function dropFiles(page, locator, paths, timeout) {
422
+ await locator.waitFor({ state: 'attached', timeout });
423
+ const source = await page.evaluateHandle(() => {
424
+ const input = document.createElement('input');
425
+ input.type = 'file';
426
+ input.multiple = true;
427
+ input.style.display = 'none';
428
+ input.setAttribute('data-qa-drop-source', '');
429
+ (document.body ?? document.documentElement).appendChild(input);
430
+ return input;
431
+ });
432
+ try {
433
+ const input = source.asElement();
434
+ if (!input)
435
+ throw new Error('the page refused a file input for the drop');
436
+ await input.setInputFiles(paths, { timeout });
437
+ const accepted = await locator.evaluate((target, from) => {
438
+ const transfer = new DataTransfer();
439
+ for (const file of Array.from(from.files ?? []))
440
+ transfer.items.add(file);
441
+ const box = target.getBoundingClientRect();
442
+ const init = {
443
+ bubbles: true,
444
+ cancelable: true,
445
+ composed: true,
446
+ dataTransfer: transfer,
447
+ clientX: box.left + box.width / 2,
448
+ clientY: box.top + box.height / 2,
449
+ };
450
+ target.dispatchEvent(new DragEvent('dragenter', init));
451
+ // A browser only drops where a handler cancelled dragover (or the drop itself).
452
+ const over = !target.dispatchEvent(new DragEvent('dragover', init));
453
+ const dropped = !target.dispatchEvent(new DragEvent('drop', init));
454
+ return over || dropped;
455
+ }, source, { timeout });
456
+ if (!accepted) {
457
+ throw new Error('nothing on the page accepted the dropped files (no drag handler cancelled dragover or drop); target the drop zone itself');
458
+ }
459
+ }
460
+ finally {
461
+ await source.evaluate((input) => input.remove()).catch(() => undefined);
462
+ await source.dispose().catch(() => undefined);
463
+ }
464
+ }
465
+ /** The element an action targets: its target, or the first fallback that appears in time. */
466
+ export function locateAction(page, action, ctx, timeout) {
467
+ return find(page, action, ctx, timeout);
468
+ }
370
469
  /** The primary target, or the first fallback that appears on the page before the timeout. */
371
470
  async function find(page, action, ctx, timeout) {
372
471
  const target = requireTarget(action);
@@ -67,6 +67,7 @@ export const ANDROID_ACTIONS = [
67
67
  'expect_visible',
68
68
  'expect_hidden',
69
69
  'expect_text',
70
+ 'add_files',
70
71
  ];
71
72
  /**
72
73
  * Deep link schemes a script may open (plan §3), checked on the rendered URL (android and ios):
@@ -139,3 +139,30 @@ export function windowsKeyActions(chord) {
139
139
  ...[...mods].reverse().map((virtualKeyCode) => ({ virtualKeyCode, down: false })),
140
140
  ];
141
141
  }
142
+ // ---------------------------------------------------------------------------------------------
143
+ // The system Open dialog (`upload` on native apps)
144
+ // ---------------------------------------------------------------------------------------------
145
+ /**
146
+ * What to type into a Windows Open dialog's File name box, entry by entry (each followed by
147
+ * Enter): the file's full path for one file; for several, the folder (Enter shows it), then the
148
+ * names quoted, which is how the dialog takes a multiple selection.
149
+ */
150
+ export function windowsOpenDialogEntries(folder, names, separator = '\\') {
151
+ if (names.length === 1)
152
+ return [`${folder.replace(/[\\/]+$/, '')}${separator}${names[0]}`];
153
+ return [folder, names.map((name) => `"${name}"`).join(' ')];
154
+ }
155
+ /**
156
+ * The chords that pick files in a macOS Open panel once the folder's path is on the pasteboard:
157
+ * Go to Folder (cmd+shift+g), paste, show the folder; then select its only file (down) or all of
158
+ * them (cmd+a), and open.
159
+ */
160
+ export function macOpenDialogChords(count) {
161
+ return [
162
+ [{ modifiers: ['cmd', 'shift'], key: 'g' }],
163
+ [{ modifiers: ['cmd'], key: 'v' }],
164
+ [{ modifiers: [], key: 'return' }],
165
+ [count > 1 ? { modifiers: ['cmd'], key: 'a' } : { modifiers: [], key: 'down' }],
166
+ [{ modifiers: [], key: 'return' }],
167
+ ];
168
+ }
@@ -0,0 +1,47 @@
1
+ /*
2
+ * Runner-owned script for element operations WebDriver servers disagree on (Tauri, contracts §L).
3
+ * Executed with W3C `execute/sync` as `return (<this file>).apply(null, arguments);`, never
4
+ * with user-provided script. The element comes in as a W3C element reference (the second
5
+ * argument), which every server resolves; nothing but plain JSON goes back.
6
+ *
7
+ * request: { op: 'focus' } focuses the element, so key actions reach it; a text field that
8
+ * gains focus gets the caret at its end, as Element Send Keys does
9
+ * { op: 'select' } selects an <option> and fires input and change at its list, as an
10
+ * option click does (tauri-plugin-wdio-webdriver 1.4's click does not)
11
+ * returns: { focused: boolean } | { selected: boolean }
12
+ * throws: Error('element not interactable: …') when the element cannot take it
13
+ */
14
+ (function domElement(request, element) {
15
+ if (element?.nodeType !== 1 || !element.isConnected) throw new Error('stale element reference: the element is not in the page');
16
+ const op = request?.op;
17
+
18
+ if (op === 'focus') {
19
+ const page = element.ownerDocument;
20
+ if (page.activeElement !== element) {
21
+ if (typeof element.focus === 'function') element.focus();
22
+ if ((element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') && typeof element.value === 'string') {
23
+ try {
24
+ element.setSelectionRange(element.value.length, element.value.length);
25
+ } catch {
26
+ // Inputs such as email and number have no caret to place.
27
+ }
28
+ }
29
+ }
30
+ if (page.activeElement !== element) throw new Error('element not interactable: it cannot take keyboard focus');
31
+ return { focused: true };
32
+ }
33
+
34
+ if (op === 'select') {
35
+ if (element.tagName !== 'OPTION') throw new Error('the element is not an option');
36
+ const list = element.closest('select');
37
+ if (!list) throw new Error('the option is not in a select list');
38
+ if (element.matches(':disabled') || list.matches(':disabled')) throw new Error('element not interactable: the option is disabled');
39
+ const before = element.selected;
40
+ element.selected = true;
41
+ list.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
42
+ if (!before) list.dispatchEvent(new Event('change', { bubbles: true }));
43
+ return { selected: element.selected };
44
+ }
45
+
46
+ throw new Error(`unknown element operation '${op}'`);
47
+ })
@@ -1,14 +1,24 @@
1
1
  /*
2
2
  * Runner-owned locator script for WebDriver-backed web views (Tauri, contracts §L).
3
3
  * Executed with W3C `execute/sync` as `return (<this file>).apply(null, arguments);`, never
4
- * with user-provided script. It 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 };
@@ -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 (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);
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
- 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;
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 `elements`)
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
- return { viewport: viewport, url: String(location.href), title: document.title, truncated: truncated, count: count, nodes: nodes, elements: elements };
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 rawElements = Array.isArray(raw.elements) ? raw.elements : [];
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
- const reference = rawElements[node.i];
60
- if (reference !== undefined) {
61
- try {
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) || !elements.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
- elements,
103
+ indexes,
112
104
  };
113
105
  }
114
106
  /** The ref a `locate` from a DOM snapshot names, or null. */
@@ -1,15 +1,23 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  import { readFileSync } from 'node:fs';
2
- import { setTimeout as sleep } from 'node:timers/promises';
3
3
  import { Unsupported } from '../driver.js';
4
4
  import { elementIdOf } from './locate.js';
5
5
  /**
6
6
  * Locators for WebDriver-backed web views (Tauri, contracts §L). Elements are found by the
7
- * runner-owned `dom-locate.js`, executed with W3C `execute/sync`; no other script is ever run.
7
+ * runner-owned `dom-locate.js`, executed with W3C `execute/sync`; the only other scripts ever
8
+ * run are the runner-owned `dom-snapshot.js` and `dom-element.js`.
9
+ *
10
+ * The scripts return plain JSON, never DOM elements. W3C serializes a script's elements as
11
+ * element references, but tauri-plugin-wdio-webdriver (the macOS path) returns each as null.
12
+ * So the element the runner acts on is tagged by `dom-locate.js` and fetched with W3C Find
13
+ * Elements, which every server implements, and the tag is removed straight after.
8
14
  */
9
15
  export const DOM_LOCATORS = ['css', 'text', 'test_id', 'xpath'];
10
16
  export const MAX_XPATH_CHARS = 500;
11
- /** Elements one locate call returns (a `nth` beyond it is fetched on demand). */
17
+ /** Matches one locate call reports visibility for (raised for a larger `nth`). */
12
18
  export const DOM_LOCATE_LIMIT = 50;
19
+ /** The attribute `dom-locate.js` tags an element with, for Find Elements to return it. */
20
+ export const MARK_ATTRIBUTE = 'data-qa-runner-mark';
13
21
  const scripts = new Map();
14
22
  /** The file as shipped next to this module (copied into dist by the build). */
15
23
  export function domScriptSource(name) {
@@ -84,12 +92,13 @@ export function parseDomMatches(raw) {
84
92
  if (!raw || typeof raw !== 'object')
85
93
  throw new Error('the locator script returned nothing');
86
94
  const value = raw;
87
- const elements = Array.isArray(value.elements) ? value.elements : [];
88
95
  const visible = Array.isArray(value.visible) ? value.visible.map((v) => v === true) : [];
96
+ const indexes = Array.isArray(value.snapshot_indexes) ? value.snapshot_indexes : [];
89
97
  return {
90
- count: typeof value.count === 'number' ? value.count : elements.length,
91
- elementIds: elements.map(elementIdOf),
98
+ count: typeof value.count === 'number' ? value.count : visible.length,
92
99
  visible,
100
+ marked: value.marked === true,
101
+ snapshotIndexes: indexes.map((index) => (Number.isInteger(index) ? index : -1)),
93
102
  };
94
103
  }
95
104
  /** Candidates in order: the target itself, then its fallbacks. */
@@ -97,34 +106,38 @@ export function domCandidates(target) {
97
106
  const { fallbacks, locate: _locate, ...primary } = target;
98
107
  return [primary, ...(fallbacks ?? []).map(({ fallbacks: _nested, locate: _l, ...rest }) => rest)];
99
108
  }
100
- /** All matches of one target (no fallbacks). */
101
- export async function domFindAll(client, target, render, limit) {
102
- const raw = await client.executeScript(domScript('dom-locate'), [domLocateRequest(target, render, limit ?? Math.max(DOM_LOCATE_LIMIT, (target.nth ?? 0) + 1))]);
103
- return parseDomMatches(raw);
109
+ /** A random token for a tag or a kept snapshot. */
110
+ export function domToken() {
111
+ return randomBytes(8).toString('hex');
112
+ }
113
+ /**
114
+ * Runs one `dom-locate.js` request. For an element, match `nth` is tagged, fetched with W3C Find
115
+ * Elements and untagged again; it stays null when the match went away in between.
116
+ */
117
+ export async function domQuery(client, request, nth, want = {}) {
118
+ const token = want.element ? domToken() : null;
119
+ const mark = token ? { mark: { token, nth, visible: want.visible === true } } : {};
120
+ const matches = parseDomMatches(await client.executeScript(domScript('dom-locate'), [{ ...request, ...mark }]));
121
+ if (!token || !matches.marked)
122
+ return { ...matches, elementId: null };
123
+ try {
124
+ const found = await client.findElements('css selector', `[${MARK_ATTRIBUTE}="${token}"]`);
125
+ return { ...matches, elementId: found.length > 0 ? elementIdOf(found[0]) : null };
126
+ }
127
+ finally {
128
+ await client.executeScript(domScript('dom-locate'), [{ unmark: true }]).catch(() => undefined);
129
+ }
104
130
  }
105
131
  /** One pass over the target and its fallbacks; null when nothing matches at `nth`. */
106
- export async function domLocateOnce(client, target, render) {
132
+ export async function domLocateOnce(client, target, render, want = {}) {
107
133
  for (const candidate of domCandidates(target)) {
108
- const found = await domFindAll(client, candidate, render);
109
134
  const nth = candidate.nth ?? 0;
110
- const elementId = found.elementIds[nth];
111
- if (elementId)
112
- return { elementId, target: candidate, count: found.count, visible: found.visible[nth] ?? false };
135
+ const found = await domQuery(client, domLocateRequest(candidate, render, Math.max(DOM_LOCATE_LIMIT, nth + 1)), nth, want);
136
+ if (found.count > nth)
137
+ return { target: candidate, count: found.count, visible: found.visible[nth] ?? false, elementId: found.elementId };
113
138
  }
114
139
  return null;
115
140
  }
116
- /** Polls until the target (or a fallback) matches (and is visible, when asked) or the time is up. */
117
- export async function domLocate(client, target, timeoutMs, options = {}) {
118
- const deadline = Date.now() + timeoutMs;
119
- for (;;) {
120
- const found = await domLocateOnce(client, target, options.render);
121
- if (found && (!options.visible || found.visible))
122
- return found;
123
- if (Date.now() >= deadline)
124
- return options.visible ? null : found;
125
- await sleep(options.pollMs ?? 200);
126
- }
127
- }
128
141
  export function describeDomTarget(target) {
129
142
  if (target.test_id !== undefined)
130
143
  return `test id "${target.test_id}"`;
@@ -49,6 +49,7 @@ export const IOS_ACTIONS = [
49
49
  'expect_visible',
50
50
  'expect_hidden',
51
51
  'expect_text',
52
+ 'add_files',
52
53
  ];
53
54
  function requireTarget(action) {
54
55
  if (!action.target)
@@ -28,6 +28,7 @@ export const NATIVE_DESKTOP_ACTIONS = [
28
28
  'expect_visible',
29
29
  'expect_hidden',
30
30
  'expect_text',
31
+ 'upload',
31
32
  ];
32
33
  function requireTarget(action) {
33
34
  if (!action.target)
@@ -9,12 +9,41 @@ import { clip, errorMessage, expectWithin, matches, renderLaunchArgs } from './c
9
9
  import { DesktopAppiumDriver, validateNativeAction } from './desktop-appium.js';
10
10
  import { Blocked, LaunchRefused, } from './driver.js';
11
11
  import { emptyScreen } from './snapshot/format.js';
12
- import { windowsKeyActions } from './webdriver/desktop-keys.js';
12
+ import { windowsKeyActions, windowsOpenDialogEntries } from './webdriver/desktop-keys.js';
13
13
  import { elementIdOf } from './webdriver/locate.js';
14
14
  import { NATIVE_DESKTOP_ACTIONS } from './webdriver/native-actions.js';
15
- import { describeWindowsTarget, WINDOWS_LOCATORS, windowsSelectors, xpathLiteral } from './webdriver/native-locate.js';
15
+ import { describeWindowsTarget, nativeLocate, WINDOWS_LOCATORS, windowsSelectors, xpathLiteral } from './webdriver/native-locate.js';
16
16
  import { openSession, webdriverMessage } from './webdriver/session.js';
17
17
  import { windowsSnapshotFromXml } from './webdriver/xml-tree-desktop.js';
18
+ /** The AutomationId of the File name box of the common Open dialog. */
19
+ export const OPEN_DIALOG_FILE_NAME_BOX = '1148';
20
+ /**
21
+ * Picks files in the common Open dialog: each entry goes into its File name box (replacing what
22
+ * is there) followed by Enter; the dialog must be gone afterwards, or it refused the files.
23
+ */
24
+ export async function fillWindowsOpenDialog(client, folder, names, timeoutMs, pauseMs = 500) {
25
+ const box = await nativeLocate(client, windowsSelectors, { automation_id: OPEN_DIALOG_FILE_NAME_BOX }, timeoutMs);
26
+ if (!box) {
27
+ throw new Error('no Open dialog appeared after the click (its File name box was not found); target the control that opens the dialog');
28
+ }
29
+ const selectAll = windowsKeyActions({ modifiers: ['ctrl'], key: 'a' });
30
+ const enter = windowsKeyActions({ modifiers: [], key: 'enter' });
31
+ for (const entry of windowsOpenDialogEntries(folder, names)) {
32
+ await client.elementClick(box.elementId).catch(() => undefined);
33
+ await windowsCommand(client, 'keys', { actions: [...selectAll, { text: entry }, ...enter] });
34
+ await sleep(pauseMs);
35
+ }
36
+ const deadline = Date.now() + Math.max(timeoutMs, 1_000);
37
+ for (;;) {
38
+ const open = await client.isElementDisplayed(box.elementId).catch(() => false);
39
+ if (!open)
40
+ return;
41
+ if (Date.now() >= deadline) {
42
+ throw new Error(`the Open dialog did not accept the files (${names.join(', ')}); it is still open`);
43
+ }
44
+ await sleep(300);
45
+ }
46
+ }
18
47
  /** Runs an Appium Windows driver `windows:` extension command. */
19
48
  export function windowsCommand(client, command, args = {}) {
20
49
  if (!/^[a-zA-Z]+$/.test(command))
@@ -106,7 +135,7 @@ export class WindowsAppiumDriver extends DesktopAppiumDriver {
106
135
  wadUrl = null;
107
136
  disposed = false;
108
137
  constructor(ctx, appium, deps = {}) {
109
- super(ctx, appium);
138
+ super(ctx, appium, deps.recorder);
110
139
  this.deps = deps;
111
140
  }
112
141
  get ops() {
@@ -193,6 +222,9 @@ export class WindowsAppiumDriver extends DesktopAppiumDriver {
193
222
  case 'focus_window':
194
223
  await this.focusWindow(action);
195
224
  return {};
225
+ case 'upload':
226
+ await this.upload(action);
227
+ return {};
196
228
  default:
197
229
  return await this.runShared(action);
198
230
  }
@@ -201,6 +233,9 @@ export class WindowsAppiumDriver extends DesktopAppiumDriver {
201
233
  throw this.translate(error);
202
234
  }
203
235
  }
236
+ pickInOpenDialog(folder, names, timeoutMs) {
237
+ return fillWindowsOpenDialog(this.requireClient(), folder, names, timeoutMs);
238
+ }
204
239
  /**
205
240
  * Switches to the app window with that title; a window the app's session does not list (a
206
241
  * dialog of another process) is attached to through a desktop session and `appTopLevelWindow`.
@@ -1,5 +1,6 @@
1
1
  import { setTimeout as sleep } from 'node:timers/promises';
2
2
  import { describe, errorMessage, firstLine, screenshotName } from './drivers/common.js';
3
+ import { mp4Seconds } from './devices/mp4.js';
3
4
  import { isPlatform } from './devices/types.js';
4
5
  import { Blocked, LaunchRefused } from './drivers/driver.js';
5
6
  import { BrowserPool, webPlaywrightFactory } from './drivers/web-playwright.js';
@@ -15,6 +16,7 @@ export function templateContextFor(job) {
15
16
  randomEmailDomain: job.environment.random_email_domain ?? null,
16
17
  username: job.credential?.username ?? null,
17
18
  password: job.credential?.password ?? null,
19
+ otp: job.credential?.otp ?? null,
18
20
  runId: job.run.id,
19
21
  appId: job.app?.id ?? null,
20
22
  data: job.data?.refs ?? {},
@@ -36,12 +38,15 @@ export function jobPlatform(job) {
36
38
  return platform;
37
39
  }
38
40
  export function jobContext(job, options) {
41
+ const template = templateContextFor(job);
42
+ if (options.files)
43
+ template.files = options.files.files;
39
44
  return {
40
45
  jobId: job.job.id,
41
46
  platform: jobPlatform(job),
42
47
  lease: options.lease,
43
48
  job,
44
- template: templateContextFor(job),
49
+ template,
45
50
  timeouts: { actionMs: options.actionTimeoutMs, jobMs: options.jobTimeoutMs },
46
51
  headless: options.headless,
47
52
  ...(options.proxy ? { proxy: options.proxy } : {}),
@@ -50,6 +55,7 @@ export function jobContext(job, options) {
50
55
  log: options.log,
51
56
  ...(options.app ?? {}),
52
57
  ...(options.buildDownloadUrl ? { buildDownloadUrl: options.buildDownloadUrl } : {}),
58
+ ...(options.files ? { files: options.files } : {}),
53
59
  };
54
60
  }
55
61
  /**
@@ -178,6 +184,8 @@ export async function executeSteps(driver, script, options, shouldStop, redact)
178
184
  videoPath = await driver.stopRecording().catch(() => undefined);
179
185
  await driver.dispose().catch(() => undefined);
180
186
  }
187
+ if (videoPath)
188
+ keepOffsetsInVideo(steps, mp4Seconds(videoPath));
181
189
  return {
182
190
  steps,
183
191
  elapsedSecs: Math.max(1, Math.round((Date.now() - started) / 1000)),
@@ -185,6 +193,20 @@ export async function executeSteps(driver, script, options, shouldStop, redact)
185
193
  ...(videoPath ? { videoPath } : {}),
186
194
  };
187
195
  }
196
+ /**
197
+ * Places no step past the end of the video (`seconds` long, null when unknown): a video can end a
198
+ * moment before a step that began just before it stopped, and that step is then shown at its end.
199
+ */
200
+ export function keepOffsetsInVideo(steps, seconds) {
201
+ if (seconds === null)
202
+ return;
203
+ const end = Math.floor(seconds * 10) / 10;
204
+ for (const step of steps) {
205
+ const offset = step.debug?.video_offset_secs;
206
+ if (offset !== undefined && offset > end)
207
+ step.debug = { ...step.debug, video_offset_secs: end };
208
+ }
209
+ }
188
210
  /** Prefixes the action's description; template problems block the step rather than fail it. */
189
211
  async function runAction(driver, action) {
190
212
  try {