@leera.io/qa-runner 1.2.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.
- package/dist/src/builds/download.js +8 -7
- package/dist/src/capabilities.js +3 -1
- package/dist/src/client.js +4 -0
- package/dist/src/devices/android/adb.js +22 -0
- package/dist/src/devices/ios/simctl.js +27 -0
- package/dist/src/devices/mp4.js +80 -0
- package/dist/src/devices/screen-record.js +87 -14
- package/dist/src/drivers/android-appium.js +29 -2
- package/dist/src/drivers/common.js +23 -0
- package/dist/src/drivers/desktop-appium.js +32 -15
- package/dist/src/drivers/electron-playwright.js +89 -21
- package/dist/src/drivers/ios-appium.js +41 -4
- package/dist/src/drivers/mac2-appium.js +28 -2
- package/dist/src/drivers/tauri-webdriver.js +83 -20
- package/dist/src/drivers/web-playwright.js +100 -1
- package/dist/src/drivers/webdriver/actions.js +1 -0
- package/dist/src/drivers/webdriver/desktop-keys.js +27 -0
- package/dist/src/drivers/webdriver/ios-actions.js +1 -0
- package/dist/src/drivers/webdriver/native-actions.js +1 -0
- package/dist/src/drivers/windows-appium.js +38 -3
- package/dist/src/executor.js +22 -1
- package/dist/src/files/store.js +181 -0
- package/dist/src/runner.js +12 -1
- package/dist/src/template.js +39 -0
- package/package.json +1 -1
|
@@ -13,10 +13,11 @@ import { ensureDisplay, hostDevice } from '../devices/desktop.js';
|
|
|
13
13
|
import { homePaths } from '../home.js';
|
|
14
14
|
import { playwrightVersion } from '../version.js';
|
|
15
15
|
import { LOCAL_PATHS_REFUSED } from './android-appium.js';
|
|
16
|
-
import { clip, errorMessage, expectWithin, firstLine, matches, render, renderLaunchArgs } from './common.js';
|
|
16
|
+
import { clip, errorMessage, expectWithin, firstLine, matches, render, renderLaunchArgs, stepStartOffset } from './common.js';
|
|
17
17
|
import { Blocked, LaunchRefused, Unsupported, } from './driver.js';
|
|
18
18
|
import { webSnapshot } from './snapshot/aria.js';
|
|
19
|
-
import {
|
|
19
|
+
import { resolveFileEntries } from '../template.js';
|
|
20
|
+
import { fileInputOf, findOnPage, locateAction, NO_FFMPEG_VIDEO, playwrightFfmpeg, runWebAction, uploadFiles, WEB_ACTIONS, } from './web-playwright.js';
|
|
20
21
|
/** Electron DSL (contracts §K): web actions minus `goto`, plus launch, terminate and focus_window. */
|
|
21
22
|
export const ELECTRON_ACTIONS = [...WEB_ACTIONS.filter((action) => action !== 'goto'), 'launch', 'terminate', 'focus_window'];
|
|
22
23
|
export const ELECTRON_TARGETS = ['role', 'label', 'placeholder', 'text', 'test_id', 'css'];
|
|
@@ -142,6 +143,47 @@ function recordingOf(page, video, log) {
|
|
|
142
143
|
});
|
|
143
144
|
return recording;
|
|
144
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Runs in the app's main process: the next Open dialog answers with `paths` instead of opening,
|
|
148
|
+
* once, then the real dialog is back. Self-contained, since Playwright sends its source.
|
|
149
|
+
*/
|
|
150
|
+
export function answerOpenDialog(electron, paths) {
|
|
151
|
+
const { dialog } = electron;
|
|
152
|
+
const holder = globalThis;
|
|
153
|
+
holder.__qaOpenDialog ??= {
|
|
154
|
+
showOpenDialog: dialog.showOpenDialog,
|
|
155
|
+
showOpenDialogSync: dialog.showOpenDialogSync,
|
|
156
|
+
answered: false,
|
|
157
|
+
};
|
|
158
|
+
const state = holder.__qaOpenDialog;
|
|
159
|
+
state.answered = false;
|
|
160
|
+
const restore = () => {
|
|
161
|
+
dialog.showOpenDialog = state.showOpenDialog;
|
|
162
|
+
dialog.showOpenDialogSync = state.showOpenDialogSync;
|
|
163
|
+
};
|
|
164
|
+
dialog.showOpenDialog = async () => {
|
|
165
|
+
state.answered = true;
|
|
166
|
+
restore();
|
|
167
|
+
return { canceled: false, filePaths: paths };
|
|
168
|
+
};
|
|
169
|
+
dialog.showOpenDialogSync = () => {
|
|
170
|
+
state.answered = true;
|
|
171
|
+
restore();
|
|
172
|
+
return paths;
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** Runs in the main process: whether the swapped dialog answered. */
|
|
176
|
+
export function openDialogAnswered() {
|
|
177
|
+
return globalThis.__qaOpenDialog?.answered === true;
|
|
178
|
+
}
|
|
179
|
+
/** Runs in the main process: puts the real Open dialog back. */
|
|
180
|
+
export function restoreOpenDialog(electron) {
|
|
181
|
+
const state = globalThis.__qaOpenDialog;
|
|
182
|
+
if (!state)
|
|
183
|
+
return;
|
|
184
|
+
electron.dialog.showOpenDialog = state.showOpenDialog;
|
|
185
|
+
electron.dialog.showOpenDialogSync = state.showOpenDialogSync;
|
|
186
|
+
}
|
|
145
187
|
/** The paint check, run in the window: settles once it is visible and has run two animation frames. */
|
|
146
188
|
function paintCheck() {
|
|
147
189
|
return new Promise((resolve) => {
|
|
@@ -154,21 +196,6 @@ function paintCheck() {
|
|
|
154
196
|
tick();
|
|
155
197
|
});
|
|
156
198
|
}
|
|
157
|
-
/**
|
|
158
|
-
* Where a step starts in a recording, in seconds, or undefined when the recording does not show
|
|
159
|
-
* it: the step ended before the recording's first frame, or began after the recording ended. A
|
|
160
|
-
* step already running when the recording started (the one that relaunched the app) starts at 0.
|
|
161
|
-
*/
|
|
162
|
-
export function stepVideoOffset(recording, begun, ended) {
|
|
163
|
-
// No frame yet: a window that never painted records only a closing frame, which shows no step.
|
|
164
|
-
if (recording.startedAt === null)
|
|
165
|
-
return undefined;
|
|
166
|
-
if (recording.endedAt !== null && begun >= recording.endedAt)
|
|
167
|
-
return undefined;
|
|
168
|
-
if (ended !== null && ended <= recording.startedAt)
|
|
169
|
-
return undefined;
|
|
170
|
-
return Math.max(0, Math.round((begun - recording.startedAt) / 100) / 10);
|
|
171
|
-
}
|
|
172
199
|
export class ElectronPlaywrightDriver {
|
|
173
200
|
ctx;
|
|
174
201
|
options;
|
|
@@ -529,10 +556,54 @@ export class ElectronPlaywrightDriver {
|
|
|
529
556
|
case 'focus_window':
|
|
530
557
|
await this.focusWindow(action);
|
|
531
558
|
return {};
|
|
559
|
+
case 'upload':
|
|
560
|
+
await this.upload(action);
|
|
561
|
+
return {};
|
|
532
562
|
default:
|
|
533
563
|
return runWebAction(this.requirePage(), action, this.ctx.template, this.ctx.timeouts.actionMs);
|
|
534
564
|
}
|
|
535
565
|
}
|
|
566
|
+
/**
|
|
567
|
+
* `upload` as on the web, and also through the app's own Open dialog: before the click, the main
|
|
568
|
+
* process's `dialog.showOpenDialog` and `showOpenDialogSync` are swapped for ones that answer with
|
|
569
|
+
* the files, then put back. Apps started over CDP have no main process to reach, so they only get
|
|
570
|
+
* the web way.
|
|
571
|
+
*/
|
|
572
|
+
async upload(action) {
|
|
573
|
+
const page = this.requirePage();
|
|
574
|
+
const timeout = action.timeout_ms ?? this.ctx.timeouts.actionMs;
|
|
575
|
+
const paths = resolveFileEntries(action.files, this.ctx.template).map((file) => file.path);
|
|
576
|
+
const locator = await locateAction(page, action, this.ctx.template, timeout);
|
|
577
|
+
const electron = this.instance?.electron ?? null;
|
|
578
|
+
if (!electron || (await fileInputOf(locator, timeout)).input) {
|
|
579
|
+
await uploadFiles(page, locator, paths, timeout);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
await electron.evaluate(answerOpenDialog, paths);
|
|
583
|
+
try {
|
|
584
|
+
const chooser = page.waitForEvent('filechooser', { timeout }).catch(() => null);
|
|
585
|
+
await locator.click({ timeout });
|
|
586
|
+
const deadline = Date.now() + timeout;
|
|
587
|
+
for (;;) {
|
|
588
|
+
if (await electron.evaluate(openDialogAnswered).catch(() => false))
|
|
589
|
+
return;
|
|
590
|
+
const opened = await Promise.race([chooser, sleep(200).then(() => undefined)]);
|
|
591
|
+
if (opened) {
|
|
592
|
+
if (paths.length > 1 && !opened.isMultiple()) {
|
|
593
|
+
throw new Error(`the file input takes one file, but the action hands over ${paths.length}`);
|
|
594
|
+
}
|
|
595
|
+
await opened.setFiles(paths, { timeout });
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (opened === null || Date.now() > deadline)
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
throw new Error("clicking the element opened neither a file chooser nor the app's Open dialog; target the <input type=file> or the button that opens the dialog");
|
|
602
|
+
}
|
|
603
|
+
finally {
|
|
604
|
+
await electron.evaluate(restoreOpenDialog).catch(() => undefined);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
536
607
|
async focusWindow(action) {
|
|
537
608
|
if (!this.instance)
|
|
538
609
|
throw new Error('the app is not running; start it with launch');
|
|
@@ -624,10 +695,7 @@ export class ElectronPlaywrightDriver {
|
|
|
624
695
|
}
|
|
625
696
|
videoOffset(index) {
|
|
626
697
|
const recording = this.recordings.at(-1);
|
|
627
|
-
|
|
628
|
-
if (!recording || i < 0)
|
|
629
|
-
return undefined;
|
|
630
|
-
return stepVideoOffset(recording, this.stepStarts[i].at, this.stepStarts[i + 1]?.at ?? null);
|
|
698
|
+
return recording ? stepStartOffset(recording, this.stepStarts, index) : undefined;
|
|
631
699
|
}
|
|
632
700
|
/**
|
|
633
701
|
* Closes the app (a video is only complete then) and returns the last recording. A relaunch
|
|
@@ -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,14 +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
14
|
import { ensureFfmpeg } from '../devices/ffmpeg.js';
|
|
15
15
|
import { homePaths } from '../home.js';
|
|
16
|
-
import { renderTemplate } from '../template.js';
|
|
16
|
+
import { renderTemplate, resolveFileEntries } from '../template.js';
|
|
17
17
|
import { AndroidAppiumDriver, androidSdkFor, APPIUM_LOG_MAX_BYTES, appiumLogLines, configuredSdkPath, LOCAL_PATHS_REFUSED, sharedAppiumServer } from './android-appium.js';
|
|
18
|
-
import { Blocked, LaunchRefused, } from './driver.js';
|
|
18
|
+
import { Blocked, LaunchRefused, Unsupported, } from './driver.js';
|
|
19
19
|
import { emptyScreen } from './snapshot/format.js';
|
|
20
20
|
import { IOS_ACTIONS, runIosAction } from './webdriver/ios-actions.js';
|
|
21
21
|
import { IOS_LOCATORS, iosFindAll } from './webdriver/ios-locate.js';
|
|
@@ -294,6 +294,10 @@ export class IosAppiumDriver {
|
|
|
294
294
|
};
|
|
295
295
|
}
|
|
296
296
|
async run(action) {
|
|
297
|
+
if (action.type === 'add_files') {
|
|
298
|
+
await this.addFiles(action);
|
|
299
|
+
return {};
|
|
300
|
+
}
|
|
297
301
|
const prepared = action.target ? { ...action, target: AndroidAppiumDriver.resolveRefTarget(action.target) } : action;
|
|
298
302
|
try {
|
|
299
303
|
return await runIosAction(this.actionContext(), prepared);
|
|
@@ -302,6 +306,39 @@ export class IosAppiumDriver {
|
|
|
302
306
|
throw this.translate(error);
|
|
303
307
|
}
|
|
304
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
|
+
}
|
|
305
342
|
/** A lost Appium/WebDriverAgent session is an environment problem, not a failed check. */
|
|
306
343
|
translate(error) {
|
|
307
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,12 +6,12 @@ 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 {
|
|
10
|
-
import { ScreenRecorder } from '../devices/screen-record.js';
|
|
9
|
+
import { ffmpegRecorder } from '../devices/screen-record.js';
|
|
11
10
|
import { loadTauriSettings, MACOS_PLUGIN_BLOCKED, tauriBackend, tauriCapabilities, tauriDriverArgv, } from '../devices/tauri.js';
|
|
12
11
|
import { homePaths } from '../home.js';
|
|
12
|
+
import { resolveFileEntries } from '../template.js';
|
|
13
13
|
import { LOCAL_PATHS_REFUSED } from './android-appium.js';
|
|
14
|
-
import { clip, errorMessage, expectWithin, matches, POLL_MS, render, renderLaunchArgs, requireTarget, verb } from './common.js';
|
|
14
|
+
import { clip, errorMessage, expectWithin, matches, POLL_MS, render, renderLaunchArgs, requireTarget, stepStartOffset, verb } from './common.js';
|
|
15
15
|
import { Blocked, LaunchRefused, NotFound, Unsupported, } from './driver.js';
|
|
16
16
|
import { ELECTRON_ACTIONS, electronEnv, killProcessTree, RESERVED_ARG, validateElectronAction } from './electron-playwright.js';
|
|
17
17
|
import { DOM_LOCATORS, describeDomTarget, domCandidates, domLocateOnce, domLocateRequest, domQuery, domScript, domToken, elementRef, keyActions, parseDomMatches, validateDomTarget, webdriverKeys, } from './webdriver/dom.js';
|
|
@@ -42,6 +42,30 @@ export function validateTauriAction(action) {
|
|
|
42
42
|
if (action.target)
|
|
43
43
|
validateDomTarget(action.target);
|
|
44
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;`;
|
|
45
69
|
/** Why a build file cannot run as a Tauri app, or null (installers are refused, contracts §L). */
|
|
46
70
|
export function tauriBuildRefusal(fileName) {
|
|
47
71
|
const lower = fileName.toLowerCase();
|
|
@@ -158,9 +182,8 @@ export class TauriWebDriver {
|
|
|
158
182
|
recorder = null;
|
|
159
183
|
videoStopped = false;
|
|
160
184
|
videoPath;
|
|
161
|
-
/** When the
|
|
162
|
-
|
|
163
|
-
offsets = new Map();
|
|
185
|
+
/** When each step began (ms), in the order they ran; offsets count from the video's first frame. */
|
|
186
|
+
stepStarts = [];
|
|
164
187
|
constructor(ctx, backend, deps = {}) {
|
|
165
188
|
this.ctx = ctx;
|
|
166
189
|
this.backend = backend;
|
|
@@ -253,7 +276,6 @@ export class TauriWebDriver {
|
|
|
253
276
|
* none downloaded) or when recording cannot start, the job runs without a video.
|
|
254
277
|
*/
|
|
255
278
|
async startRecording() {
|
|
256
|
-
this.startedAt = Date.now();
|
|
257
279
|
if (this.ctx.video === 'off')
|
|
258
280
|
return;
|
|
259
281
|
const os = this.screenOs();
|
|
@@ -262,10 +284,9 @@ export class TauriWebDriver {
|
|
|
262
284
|
this.ctx.log.warn('video is not recorded: the job has no X display');
|
|
263
285
|
return;
|
|
264
286
|
}
|
|
265
|
-
const recorder = await (this.deps.recorder ??
|
|
287
|
+
const recorder = await (this.deps.recorder ?? ffmpegRecorder(this.paths, this.ctx.log))(os, display);
|
|
266
288
|
if (!recorder)
|
|
267
289
|
return;
|
|
268
|
-
this.startedAt = Date.now();
|
|
269
290
|
try {
|
|
270
291
|
await recorder.start();
|
|
271
292
|
this.recorder = recorder;
|
|
@@ -591,10 +612,59 @@ export class TauriWebDriver {
|
|
|
591
612
|
}
|
|
592
613
|
case 'screenshot':
|
|
593
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 {};
|
|
594
621
|
default:
|
|
595
622
|
throw new Unsupported(`'${action.type}' is not a tauri action`);
|
|
596
623
|
}
|
|
597
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
|
+
}
|
|
598
668
|
async focusWindow(action) {
|
|
599
669
|
const client = this.requireClient();
|
|
600
670
|
const timeout = action.timeout_ms ?? this.ctx.timeouts.actionMs;
|
|
@@ -702,12 +772,12 @@ export class TauriWebDriver {
|
|
|
702
772
|
return this.requireClient().getPageSource();
|
|
703
773
|
}
|
|
704
774
|
beginStep(index) {
|
|
705
|
-
this.
|
|
775
|
+
this.stepStarts.push({ index, at: Date.now() });
|
|
706
776
|
}
|
|
707
|
-
/** While recording, a step
|
|
777
|
+
/** While recording, where a step starts in the video (Tauri steps capture no logs); none for a step it does not show. */
|
|
708
778
|
debug(index) {
|
|
709
|
-
const offset = this.
|
|
710
|
-
return offset !== undefined
|
|
779
|
+
const offset = this.recorder ? stepStartOffset(this.recorder, this.stepStarts, index) : undefined;
|
|
780
|
+
return offset !== undefined ? { video_offset_secs: offset } : undefined;
|
|
711
781
|
}
|
|
712
782
|
async stopRecording() {
|
|
713
783
|
if (!this.recorder || this.videoStopped)
|
|
@@ -749,13 +819,6 @@ export class TauriWebDriver {
|
|
|
749
819
|
killProcessTree(app.child.pid);
|
|
750
820
|
}
|
|
751
821
|
}
|
|
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
|
-
}
|
|
759
822
|
function nodePlatform(os) {
|
|
760
823
|
if (os === 'macos')
|
|
761
824
|
return 'darwin';
|
|
@@ -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);
|
|
@@ -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
|
+
}
|