@leera.io/qa-runner 1.0.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/LICENSE +64 -0
- package/README.md +333 -0
- package/dist/src/appium/drivers.js +60 -0
- package/dist/src/appium/home.js +60 -0
- package/dist/src/appium/server.js +237 -0
- package/dist/src/builds/cache.js +133 -0
- package/dist/src/builds/cleanup.js +208 -0
- package/dist/src/builds/download.js +64 -0
- package/dist/src/builds/install.js +75 -0
- package/dist/src/builds/ios-app.js +99 -0
- package/dist/src/builds/unpack.js +347 -0
- package/dist/src/capabilities.js +93 -0
- package/dist/src/ci/builds.js +68 -0
- package/dist/src/ci/client.js +90 -0
- package/dist/src/ci/env.js +118 -0
- package/dist/src/ci/junit.js +79 -0
- package/dist/src/ci/run.js +320 -0
- package/dist/src/ci/summary.js +126 -0
- package/dist/src/cli/commands/builds.js +104 -0
- package/dist/src/cli/commands/ci.js +167 -0
- package/dist/src/cli/commands/config.js +83 -0
- package/dist/src/cli/commands/connect.js +70 -0
- package/dist/src/cli/commands/doctor.js +57 -0
- package/dist/src/cli/commands/service.js +165 -0
- package/dist/src/cli/commands/setup.js +193 -0
- package/dist/src/cli/commands/start.js +94 -0
- package/dist/src/cli/commands/update.js +189 -0
- package/dist/src/cli/commands/version.js +28 -0
- package/dist/src/cli/main.js +59 -0
- package/dist/src/cli/output.js +67 -0
- package/dist/src/cli.js +40 -0
- package/dist/src/client.js +146 -0
- package/dist/src/config.js +260 -0
- package/dist/src/debug.js +137 -0
- package/dist/src/devices/android/adb.js +251 -0
- package/dist/src/devices/android/avd.js +95 -0
- package/dist/src/devices/android/emulator.js +153 -0
- package/dist/src/devices/android/index.js +133 -0
- package/dist/src/devices/android/logcat.js +205 -0
- package/dist/src/devices/android/prepare.js +40 -0
- package/dist/src/devices/android/setup.js +162 -0
- package/dist/src/devices/android/system-dialog.js +78 -0
- package/dist/src/devices/desktop.js +145 -0
- package/dist/src/devices/ios/devicectl.js +87 -0
- package/dist/src/devices/ios/index.js +130 -0
- package/dist/src/devices/ios/prepare.js +39 -0
- package/dist/src/devices/ios/record.js +122 -0
- package/dist/src/devices/ios/settings.js +55 -0
- package/dist/src/devices/ios/setup.js +186 -0
- package/dist/src/devices/ios/simctl.js +201 -0
- package/dist/src/devices/ios/syslog.js +185 -0
- package/dist/src/devices/ios/wda.js +108 -0
- package/dist/src/devices/macos.js +248 -0
- package/dist/src/devices/manager.js +206 -0
- package/dist/src/devices/screen-record.js +95 -0
- package/dist/src/devices/tauri.js +189 -0
- package/dist/src/devices/types.js +6 -0
- package/dist/src/devices/windows.js +362 -0
- package/dist/src/doctor.js +394 -0
- package/dist/src/download.js +39 -0
- package/dist/src/drivers/android-appium.js +525 -0
- package/dist/src/drivers/common.js +64 -0
- package/dist/src/drivers/desktop-appium.js +282 -0
- package/dist/src/drivers/driver.js +15 -0
- package/dist/src/drivers/electron-playwright.js +566 -0
- package/dist/src/drivers/ios-appium.js +535 -0
- package/dist/src/drivers/mac2-appium.js +313 -0
- package/dist/src/drivers/registry.js +47 -0
- package/dist/src/drivers/snapshot/aria.js +351 -0
- package/dist/src/drivers/snapshot/format.js +106 -0
- package/dist/src/drivers/tauri-webdriver.js +678 -0
- package/dist/src/drivers/web-playwright.js +403 -0
- package/dist/src/drivers/webdriver/actions.js +328 -0
- package/dist/src/drivers/webdriver/desktop-keys.js +141 -0
- package/dist/src/drivers/webdriver/dom-locate.js +113 -0
- package/dist/src/drivers/webdriver/dom-snapshot.js +376 -0
- package/dist/src/drivers/webdriver/dom-tree.js +118 -0
- package/dist/src/drivers/webdriver/dom.js +213 -0
- package/dist/src/drivers/webdriver/ios-actions.js +299 -0
- package/dist/src/drivers/webdriver/ios-locate.js +121 -0
- package/dist/src/drivers/webdriver/locate.js +136 -0
- package/dist/src/drivers/webdriver/native-actions.js +175 -0
- package/dist/src/drivers/webdriver/native-locate.js +273 -0
- package/dist/src/drivers/webdriver/session.js +105 -0
- package/dist/src/drivers/webdriver/xml-tree-desktop.js +425 -0
- package/dist/src/drivers/webdriver/xml-tree-ios.js +314 -0
- package/dist/src/drivers/webdriver/xml-tree.js +393 -0
- package/dist/src/drivers/windows-appium.js +319 -0
- package/dist/src/errors.js +21 -0
- package/dist/src/executor.js +189 -0
- package/dist/src/home.js +76 -0
- package/dist/src/index.js +22 -0
- package/dist/src/log.js +69 -0
- package/dist/src/runner.js +498 -0
- package/dist/src/service/index.js +64 -0
- package/dist/src/service/launchd.js +85 -0
- package/dist/src/service/names.js +2 -0
- package/dist/src/service/schtasks.js +128 -0
- package/dist/src/service/systemd.js +65 -0
- package/dist/src/session/commands.js +165 -0
- package/dist/src/session/execute.js +232 -0
- package/dist/src/session/loop.js +148 -0
- package/dist/src/template.js +62 -0
- package/dist/src/types.js +7 -0
- package/dist/src/update/apply.js +223 -0
- package/dist/src/update/check.js +59 -0
- package/dist/src/update/manifest.js +93 -0
- package/dist/src/update/verify.js +65 -0
- package/dist/src/version.js +42 -0
- package/package.json +44 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { connect, createServer } from 'node:net';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
5
|
+
import { disposeUnpacked } from '../builds/cleanup.js';
|
|
6
|
+
import { AppNotAvailable, resolveAppFile } from '../builds/install.js';
|
|
7
|
+
import { buildKind, DesktopBuildError, localDesktopApp, unpackDesktopBuild } from '../builds/unpack.js';
|
|
8
|
+
import { ensureDisplay, hostDevice } from '../devices/desktop.js';
|
|
9
|
+
import { loadTauriSettings, MACOS_PLUGIN_BLOCKED, tauriBackend, tauriCapabilities, tauriDriverArgv, } from '../devices/tauri.js';
|
|
10
|
+
import { homePaths } from '../home.js';
|
|
11
|
+
import { LOCAL_PATHS_REFUSED } from './android-appium.js';
|
|
12
|
+
import { clip, errorMessage, expectWithin, matches, render, requireTarget, verb } from './common.js';
|
|
13
|
+
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 { buildDomSnapshot, domRefOf, domScreen } from './webdriver/dom-tree.js';
|
|
17
|
+
import { elementIdOf } from './webdriver/locate.js';
|
|
18
|
+
import { openSession, webdriverMessage } from './webdriver/session.js';
|
|
19
|
+
/** Tauri DSL (contracts §L): the electron actions; locators css, text, test_id, xpath. */
|
|
20
|
+
export const TAURI_ACTIONS = [...ELECTRON_ACTIONS];
|
|
21
|
+
export const TAURI_TARGETS = [...DOM_LOCATORS];
|
|
22
|
+
export const TAURI_INSTALLED_REFUSED = 'tauri apps need an uploaded build or a path on the runner';
|
|
23
|
+
export const MSI_REFUSED = 'an .msi installer cannot run in place — upload the portable .exe or a zip of the installed app folder';
|
|
24
|
+
export const DEB_REFUSED = 'a .deb package cannot run in place — upload the .AppImage or a zip of the installed app folder';
|
|
25
|
+
/** How long a macOS plugin build has to answer `GET /status` (contracts §L). */
|
|
26
|
+
export const PLUGIN_STATUS_TIMEOUT_MS = 30_000;
|
|
27
|
+
const DRIVER_START_TIMEOUT_MS = 20_000;
|
|
28
|
+
const SESSION_TIMEOUT_MS = 120_000;
|
|
29
|
+
const PAGE_READY_TIMEOUT_MS = 30_000;
|
|
30
|
+
/** Checks a Tauri action before it runs; throws what the step reports. */
|
|
31
|
+
export function validateTauriAction(action) {
|
|
32
|
+
if (action.type === 'goto')
|
|
33
|
+
throw new Unsupported("'goto' is not a tauri action; start the app with launch");
|
|
34
|
+
if (action.type === 'launch' || action.type === 'focus_window')
|
|
35
|
+
validateElectronAction(action);
|
|
36
|
+
if (action.target)
|
|
37
|
+
validateDomTarget(action.target);
|
|
38
|
+
}
|
|
39
|
+
/** Why a build file cannot run as a Tauri app, or null (installers are refused, contracts §L). */
|
|
40
|
+
export function tauriBuildRefusal(fileName) {
|
|
41
|
+
const lower = fileName.toLowerCase();
|
|
42
|
+
if (lower.endsWith('.msi'))
|
|
43
|
+
return MSI_REFUSED;
|
|
44
|
+
if (lower.endsWith('.deb'))
|
|
45
|
+
return DEB_REFUSED;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/** The environment a macOS plugin build starts with: the runner's minus secrets, plus the plugin port. */
|
|
49
|
+
export function pluginAppEnv(base, port, extra = {}) {
|
|
50
|
+
return electronEnv(base, { ...extra, TAURI_WEBDRIVER_PORT: String(port) });
|
|
51
|
+
}
|
|
52
|
+
export function freePort() {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const server = createServer();
|
|
55
|
+
server.once('error', reject);
|
|
56
|
+
server.listen(0, '127.0.0.1', () => {
|
|
57
|
+
const address = server.address();
|
|
58
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
59
|
+
server.close(() => resolve(port));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Polls `GET http://127.0.0.1:<port>/status` until it answers 2xx (true), the app exits or the
|
|
65
|
+
* time is up (false).
|
|
66
|
+
*/
|
|
67
|
+
export async function waitForPluginStatus(port, options = {}) {
|
|
68
|
+
const deadline = Date.now() + (options.timeoutMs ?? PLUGIN_STATUS_TIMEOUT_MS);
|
|
69
|
+
for (;;) {
|
|
70
|
+
if (options.exited?.())
|
|
71
|
+
return false;
|
|
72
|
+
const ok = await fetch(`http://127.0.0.1:${port}/status`, { signal: AbortSignal.timeout(2_000) }).then((response) => response.ok, () => false);
|
|
73
|
+
if (ok)
|
|
74
|
+
return true;
|
|
75
|
+
if (Date.now() >= deadline)
|
|
76
|
+
return false;
|
|
77
|
+
await sleep(options.pollMs ?? 250);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const defaultOpener = async (url, capabilities, timeoutMs) => (await openSession(url, capabilities, timeoutMs));
|
|
81
|
+
/**
|
|
82
|
+
* macOS: waits for the embedded plugin's `/status` and attaches a WebDriver session to its
|
|
83
|
+
* port. An app that never answers is refused with exactly MACOS_PLUGIN_BLOCKED.
|
|
84
|
+
*/
|
|
85
|
+
export async function attachMacosPlugin(port, options = {}) {
|
|
86
|
+
const reachable = await waitForPluginStatus(port, options);
|
|
87
|
+
if (!reachable)
|
|
88
|
+
throw new LaunchRefused(MACOS_PLUGIN_BLOCKED);
|
|
89
|
+
try {
|
|
90
|
+
return await (options.open ?? defaultOpener)(`http://127.0.0.1:${port}`, {}, SESSION_TIMEOUT_MS);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
throw new Blocked(`the app's WebDriver plugin did not start a session: ${webdriverMessage(error)}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Resolves when something accepts connections on the port, or rejects when the process exits or the time is up. */
|
|
97
|
+
async function waitForPort(port, timeoutMs, exited) {
|
|
98
|
+
const deadline = Date.now() + timeoutMs;
|
|
99
|
+
for (;;) {
|
|
100
|
+
const failure = exited();
|
|
101
|
+
if (failure)
|
|
102
|
+
throw new Error(failure);
|
|
103
|
+
const open = await new Promise((resolve) => {
|
|
104
|
+
const socket = connect({ host: '127.0.0.1', port });
|
|
105
|
+
socket.once('connect', () => {
|
|
106
|
+
socket.destroy();
|
|
107
|
+
resolve(true);
|
|
108
|
+
});
|
|
109
|
+
socket.once('error', () => resolve(false));
|
|
110
|
+
});
|
|
111
|
+
if (open)
|
|
112
|
+
return;
|
|
113
|
+
if (Date.now() >= deadline)
|
|
114
|
+
throw new Error(`nothing listens on port ${port} after ${Math.round(timeoutMs / 1000)} s`);
|
|
115
|
+
await sleep(150);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function track(child, what) {
|
|
119
|
+
let exit = null;
|
|
120
|
+
let tail = '';
|
|
121
|
+
child.stderr?.on('data', (chunk) => {
|
|
122
|
+
tail = `${tail}${chunk.toString('utf8')}`.slice(-2_000);
|
|
123
|
+
});
|
|
124
|
+
child.once('exit', (code, signal) => {
|
|
125
|
+
exit = `${what} exited (${signal ?? `code ${code}`})`;
|
|
126
|
+
});
|
|
127
|
+
child.once('error', (error) => {
|
|
128
|
+
exit = `${what} could not start: ${error.message}`;
|
|
129
|
+
});
|
|
130
|
+
return { child, exited: () => exit, stderr: () => tail.trim().split('\n').pop() ?? '' };
|
|
131
|
+
}
|
|
132
|
+
export class TauriWebDriver {
|
|
133
|
+
ctx;
|
|
134
|
+
backend;
|
|
135
|
+
deps;
|
|
136
|
+
platform = 'tauri';
|
|
137
|
+
capabilities = { actions: TAURI_ACTIONS, targets: TAURI_TARGETS, video: 'none' };
|
|
138
|
+
client = null;
|
|
139
|
+
driverProcess = null;
|
|
140
|
+
driverPort = null;
|
|
141
|
+
app = null;
|
|
142
|
+
executable = null;
|
|
143
|
+
unpacked = null;
|
|
144
|
+
releaseBuild = null;
|
|
145
|
+
display = null;
|
|
146
|
+
/** ref → WebDriver element id from the last snapshot. */
|
|
147
|
+
refElements = new Map();
|
|
148
|
+
firstLaunchDone = false;
|
|
149
|
+
actionsRun = 0;
|
|
150
|
+
disposed = false;
|
|
151
|
+
constructor(ctx, backend, deps = {}) {
|
|
152
|
+
this.ctx = ctx;
|
|
153
|
+
this.backend = backend;
|
|
154
|
+
this.deps = deps;
|
|
155
|
+
}
|
|
156
|
+
get paths() {
|
|
157
|
+
return this.ctx.paths ?? homePaths(process.env);
|
|
158
|
+
}
|
|
159
|
+
get renderText() {
|
|
160
|
+
return (text) => render(text, this.ctx.template);
|
|
161
|
+
}
|
|
162
|
+
/** Where the app comes from: an unpacked build, or an app at a path on this runner. */
|
|
163
|
+
async resolveExecutable() {
|
|
164
|
+
const app = this.ctx.job.app;
|
|
165
|
+
if (!app)
|
|
166
|
+
throw new LaunchRefused('the job names no Tauri app (set the app in the environment)');
|
|
167
|
+
const override = app.launch_activity?.trim() || null;
|
|
168
|
+
if (app.install === 'installed')
|
|
169
|
+
throw new LaunchRefused(TAURI_INSTALLED_REFUSED);
|
|
170
|
+
if (app.install === 'path') {
|
|
171
|
+
if (!this.ctx.allowLocalPaths)
|
|
172
|
+
throw new LaunchRefused(LOCAL_PATHS_REFUSED);
|
|
173
|
+
try {
|
|
174
|
+
return localDesktopApp(app.path ?? '', override);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
throw new LaunchRefused(errorMessage(error));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const refusal = app.build ? tauriBuildRefusal(app.build.file_name) : null;
|
|
181
|
+
if (refusal)
|
|
182
|
+
throw new LaunchRefused(refusal);
|
|
183
|
+
let file;
|
|
184
|
+
try {
|
|
185
|
+
const resolved = await resolveAppFile({
|
|
186
|
+
app,
|
|
187
|
+
paths: this.paths,
|
|
188
|
+
allowLocalPaths: this.ctx.allowLocalPaths ?? false,
|
|
189
|
+
getUrl: (buildId) => {
|
|
190
|
+
if (!this.ctx.buildDownloadUrl)
|
|
191
|
+
throw new Blocked('this runner cannot download builds for this job');
|
|
192
|
+
return this.ctx.buildDownloadUrl(buildId);
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
this.releaseBuild = resolved.release;
|
|
196
|
+
file = resolved.path;
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
const message = `the app build is not available: ${errorMessage(error)}`;
|
|
200
|
+
throw error instanceof AppNotAvailable ? new LaunchRefused(message) : new Blocked(message);
|
|
201
|
+
}
|
|
202
|
+
if (!file)
|
|
203
|
+
throw new LaunchRefused(TAURI_INSTALLED_REFUSED);
|
|
204
|
+
if (buildKind(file) === 'dmg' && this.backend.kind !== 'macos-plugin')
|
|
205
|
+
throw new LaunchRefused('.dmg builds run on macOS runners only');
|
|
206
|
+
try {
|
|
207
|
+
this.unpacked = unpackDesktopBuild(file, override);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
const message = `the app build could not be unpacked: ${errorMessage(error)}`;
|
|
211
|
+
throw error instanceof DesktopBuildError ? new LaunchRefused(message) : new Blocked(message);
|
|
212
|
+
}
|
|
213
|
+
return this.unpacked.executable;
|
|
214
|
+
}
|
|
215
|
+
async launch() {
|
|
216
|
+
this.executable = await this.resolveExecutable();
|
|
217
|
+
try {
|
|
218
|
+
this.display = await (this.deps.display ?? ensureDisplay)();
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
throw new Blocked(errorMessage(error));
|
|
222
|
+
}
|
|
223
|
+
const first = [...this.ctx.job.automation.steps].sort((a, b) => a.index - b.index)[0]?.actions[0];
|
|
224
|
+
let args = [];
|
|
225
|
+
if (first?.type === 'launch') {
|
|
226
|
+
validateTauriAction(first);
|
|
227
|
+
args = (first.args ?? []).map(this.renderText);
|
|
228
|
+
this.firstLaunchDone = true;
|
|
229
|
+
}
|
|
230
|
+
await this.start(args);
|
|
231
|
+
}
|
|
232
|
+
async start(args) {
|
|
233
|
+
const executable = this.executable;
|
|
234
|
+
if (!executable)
|
|
235
|
+
throw new Blocked('the app was not prepared');
|
|
236
|
+
if (this.backend.kind === 'macos-plugin')
|
|
237
|
+
await this.startWithPlugin(executable, args);
|
|
238
|
+
else
|
|
239
|
+
await this.startWithTauriDriver(executable, args);
|
|
240
|
+
await this.waitForPage();
|
|
241
|
+
}
|
|
242
|
+
/** Linux/Windows: one tauri-driver per job; each session launches the app. */
|
|
243
|
+
async startWithTauriDriver(executable, args) {
|
|
244
|
+
if (this.backend.kind !== 'driver')
|
|
245
|
+
throw new Blocked('tauri-driver is not available');
|
|
246
|
+
if (!this.driverProcess || this.driverProcess.exited()) {
|
|
247
|
+
const port = await freePort();
|
|
248
|
+
let nativePort = await freePort();
|
|
249
|
+
while (nativePort === port)
|
|
250
|
+
nativePort = await freePort();
|
|
251
|
+
const argv = tauriDriverArgv({
|
|
252
|
+
port,
|
|
253
|
+
nativePort,
|
|
254
|
+
nativeDriver: this.backend.nativeDriver,
|
|
255
|
+
os: this.backend.os,
|
|
256
|
+
configuredNative: this.deps.configuredNative ?? false,
|
|
257
|
+
});
|
|
258
|
+
const child = (this.deps.spawn ?? spawn)(this.backend.driver, argv, {
|
|
259
|
+
env: electronEnv(process.env, this.display?.env ?? {}),
|
|
260
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
261
|
+
detached: process.platform !== 'win32',
|
|
262
|
+
windowsHide: true,
|
|
263
|
+
});
|
|
264
|
+
const tracked = track(child, 'tauri-driver');
|
|
265
|
+
this.driverProcess = tracked;
|
|
266
|
+
this.driverPort = port;
|
|
267
|
+
try {
|
|
268
|
+
await waitForPort(port, DRIVER_START_TIMEOUT_MS, tracked.exited);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
const detail = tracked.stderr();
|
|
272
|
+
throw new Blocked(`tauri-driver did not start: ${errorMessage(error)}${detail ? ` (${clip(detail)})` : ''}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
this.client = await (this.deps.open ?? defaultOpener)(`http://127.0.0.1:${this.driverPort}`, tauriCapabilities(executable, args), SESSION_TIMEOUT_MS);
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
const detail = this.driverProcess.stderr();
|
|
280
|
+
throw new Blocked(`the app could not be launched: ${webdriverMessage(error)}${detail ? ` (${clip(detail)})` : ''}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** macOS: start the app with the plugin port, wait for `/status`, attach. */
|
|
284
|
+
async startWithPlugin(executable, args) {
|
|
285
|
+
const port = await freePort();
|
|
286
|
+
const child = (this.deps.spawn ?? spawn)(executable, args, {
|
|
287
|
+
cwd: dirname(executable),
|
|
288
|
+
env: pluginAppEnv(process.env, port, this.display?.env ?? {}),
|
|
289
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
290
|
+
detached: process.platform !== 'win32',
|
|
291
|
+
});
|
|
292
|
+
this.app = track(child, 'the app');
|
|
293
|
+
try {
|
|
294
|
+
this.client = await attachMacosPlugin(port, {
|
|
295
|
+
...(this.deps.statusTimeoutMs !== undefined ? { timeoutMs: this.deps.statusTimeoutMs } : {}),
|
|
296
|
+
exited: this.app.exited,
|
|
297
|
+
...(this.deps.open ? { open: this.deps.open } : {}),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
await this.closeApp();
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** Waits until the web view runs the runner's script (the page has loaded). */
|
|
306
|
+
async waitForPage() {
|
|
307
|
+
const client = this.requireClient();
|
|
308
|
+
const deadline = Date.now() + PAGE_READY_TIMEOUT_MS;
|
|
309
|
+
for (;;) {
|
|
310
|
+
const ready = await client.executeScript(domScript('dom-snapshot'), [{ viewportOnly: true }]).then(() => true, () => false);
|
|
311
|
+
if (ready || Date.now() >= deadline)
|
|
312
|
+
return;
|
|
313
|
+
await sleep(250);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
async run(action) {
|
|
317
|
+
const first = this.actionsRun === 0;
|
|
318
|
+
this.actionsRun += 1;
|
|
319
|
+
validateTauriAction(action);
|
|
320
|
+
try {
|
|
321
|
+
switch (action.type) {
|
|
322
|
+
case 'launch':
|
|
323
|
+
if (first && this.firstLaunchDone)
|
|
324
|
+
return {};
|
|
325
|
+
await this.closeApp();
|
|
326
|
+
await this.start((action.args ?? []).map(this.renderText));
|
|
327
|
+
return {};
|
|
328
|
+
case 'terminate':
|
|
329
|
+
await this.closeApp();
|
|
330
|
+
return {};
|
|
331
|
+
case 'focus_window':
|
|
332
|
+
await this.focusWindow(action);
|
|
333
|
+
return {};
|
|
334
|
+
default:
|
|
335
|
+
return await this.runDomAction(action);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
throw this.translate(error);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/** A lost WebDriver session is an environment problem, not a failed check. */
|
|
343
|
+
translate(error) {
|
|
344
|
+
if (error instanceof Blocked || error instanceof NotFound)
|
|
345
|
+
return error;
|
|
346
|
+
const message = webdriverMessage(error);
|
|
347
|
+
if (/invalid session id|no such window|session not created|ECONNREFUSED|ECONNRESET|socket hang up|disconnected/i.test(message)) {
|
|
348
|
+
return new Blocked(`the app connection was lost: ${message}`);
|
|
349
|
+
}
|
|
350
|
+
return error;
|
|
351
|
+
}
|
|
352
|
+
requireClient() {
|
|
353
|
+
if (!this.client)
|
|
354
|
+
throw new Error('the app is not running; start it with launch');
|
|
355
|
+
return this.client;
|
|
356
|
+
}
|
|
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();
|
|
360
|
+
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 };
|
|
366
|
+
}
|
|
367
|
+
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;
|
|
372
|
+
}
|
|
373
|
+
/** Whether the target is on screen now (no waiting). */
|
|
374
|
+
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;
|
|
386
|
+
}
|
|
387
|
+
/** Retries an element command while the element is not interactable yet. */
|
|
388
|
+
async retrying(timeoutMs, target, command) {
|
|
389
|
+
const deadline = Date.now() + timeoutMs;
|
|
390
|
+
let found = await this.element(target, timeoutMs);
|
|
391
|
+
for (;;) {
|
|
392
|
+
try {
|
|
393
|
+
await command(found.elementId);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
const message = webdriverMessage(error);
|
|
398
|
+
if (Date.now() >= deadline || !/not interactable|intercepted|stale element|not visible|element not/i.test(message))
|
|
399
|
+
throw error;
|
|
400
|
+
await sleep(200);
|
|
401
|
+
if (/stale element/i.test(message)) {
|
|
402
|
+
const { locate: _locate, ...plain } = target;
|
|
403
|
+
found = await this.element(plain, Math.max(100, deadline - Date.now()));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
async runDomAction(action) {
|
|
409
|
+
const client = this.requireClient();
|
|
410
|
+
const timeout = action.timeout_ms ?? this.ctx.timeouts.actionMs;
|
|
411
|
+
switch (action.type) {
|
|
412
|
+
case 'click':
|
|
413
|
+
await this.retrying(timeout, requireTarget(action), (id) => client.elementClick(id));
|
|
414
|
+
return {};
|
|
415
|
+
case 'hover': {
|
|
416
|
+
const found = await this.element(requireTarget(action), timeout);
|
|
417
|
+
await client.performActions([
|
|
418
|
+
{ type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions: [{ type: 'pointerMove', duration: 0, origin: elementRef(found.elementId), x: 0, y: 0 }] },
|
|
419
|
+
]);
|
|
420
|
+
return {};
|
|
421
|
+
}
|
|
422
|
+
case 'check':
|
|
423
|
+
case 'uncheck': {
|
|
424
|
+
const wanted = action.type === 'check';
|
|
425
|
+
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));
|
|
429
|
+
}
|
|
430
|
+
await expectWithin(Math.min(timeout, 2_000), async () => (await client.isElementSelected(found.elementId)) === wanted, `the element to be ${wanted ? 'checked' : 'unchecked'}`);
|
|
431
|
+
return {};
|
|
432
|
+
}
|
|
433
|
+
case 'fill': {
|
|
434
|
+
const value = render(action.value, this.ctx.template);
|
|
435
|
+
await this.retrying(timeout, requireTarget(action), async (id) => {
|
|
436
|
+
await client.elementClear(id);
|
|
437
|
+
if (value)
|
|
438
|
+
await client.elementSendKeys(id, value);
|
|
439
|
+
});
|
|
440
|
+
return {};
|
|
441
|
+
}
|
|
442
|
+
case 'select_option': {
|
|
443
|
+
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');
|
|
446
|
+
const seen = [];
|
|
447
|
+
for (const option of options) {
|
|
448
|
+
const id = elementIdOf(option);
|
|
449
|
+
const [optionValue, text] = await Promise.all([client.getElementProperty(id, 'value'), client.getElementText(id)]);
|
|
450
|
+
seen.push(text.trim());
|
|
451
|
+
if (optionValue === value || text.trim() === value) {
|
|
452
|
+
await client.elementClick(id);
|
|
453
|
+
return {};
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
throw new Error(`the list has no option "${value}" (options: ${seen.map((s) => `"${clip(s)}"`).join(', ') || 'none'})`);
|
|
457
|
+
}
|
|
458
|
+
case 'press': {
|
|
459
|
+
const keys = webdriverKeys(action.key ?? '');
|
|
460
|
+
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);
|
|
468
|
+
}
|
|
469
|
+
return {};
|
|
470
|
+
}
|
|
471
|
+
case 'wait_for':
|
|
472
|
+
await this.element(requireTarget(action), timeout, true);
|
|
473
|
+
return {};
|
|
474
|
+
case 'wait':
|
|
475
|
+
await sleep(Math.min(action.ms ?? 0, 30_000));
|
|
476
|
+
return {};
|
|
477
|
+
case 'expect_visible': {
|
|
478
|
+
const target = requireTarget(action);
|
|
479
|
+
await expectWithin(timeout, () => this.visibleNow(target), `${describeDomTarget(target)} to be visible`);
|
|
480
|
+
return {};
|
|
481
|
+
}
|
|
482
|
+
case 'expect_hidden': {
|
|
483
|
+
const target = requireTarget(action);
|
|
484
|
+
await expectWithin(timeout, async () => !(await this.visibleNow(target)), `${describeDomTarget(target)} to be hidden`);
|
|
485
|
+
return {};
|
|
486
|
+
}
|
|
487
|
+
case 'expect_text': {
|
|
488
|
+
const target = requireTarget(action);
|
|
489
|
+
const expected = render(action.value, this.ctx.template);
|
|
490
|
+
let actual = '';
|
|
491
|
+
await expectWithin(timeout, async () => {
|
|
492
|
+
const found = await this.element(target, 0, false);
|
|
493
|
+
actual = (await client.getElementText(found.elementId)).trim();
|
|
494
|
+
return matches(actual, expected, action.match);
|
|
495
|
+
}, () => `text to ${verb(action.match)} "${expected}", found "${clip(actual)}"`);
|
|
496
|
+
return {};
|
|
497
|
+
}
|
|
498
|
+
case 'expect_url': {
|
|
499
|
+
const expected = render(action.value, this.ctx.template);
|
|
500
|
+
let actual = '';
|
|
501
|
+
await expectWithin(timeout, async () => {
|
|
502
|
+
actual = await client.getUrl();
|
|
503
|
+
return matches(actual, expected, action.match);
|
|
504
|
+
}, () => `the URL to ${verb(action.match)} "${expected}", found "${clip(actual)}"`);
|
|
505
|
+
return {};
|
|
506
|
+
}
|
|
507
|
+
case 'expect_title': {
|
|
508
|
+
const expected = render(action.value, this.ctx.template);
|
|
509
|
+
let actual = '';
|
|
510
|
+
await expectWithin(timeout, async () => {
|
|
511
|
+
actual = await client.getTitle();
|
|
512
|
+
return matches(actual, expected, action.match);
|
|
513
|
+
}, () => `the title to ${verb(action.match)} "${expected}", found "${clip(actual)}"`);
|
|
514
|
+
return {};
|
|
515
|
+
}
|
|
516
|
+
case 'screenshot':
|
|
517
|
+
return { screenshot: await this.screenshot() };
|
|
518
|
+
default:
|
|
519
|
+
throw new Unsupported(`'${action.type}' is not a tauri action`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async focusWindow(action) {
|
|
523
|
+
const client = this.requireClient();
|
|
524
|
+
const timeout = action.timeout_ms ?? this.ctx.timeouts.actionMs;
|
|
525
|
+
const wanted = action.title !== undefined ? render(action.title, this.ctx.template) : null;
|
|
526
|
+
let titles = [];
|
|
527
|
+
let count = 0;
|
|
528
|
+
const original = await client.getWindowHandle().catch(() => null);
|
|
529
|
+
await expectWithin(timeout, async () => {
|
|
530
|
+
const handles = await client.getWindowHandles();
|
|
531
|
+
count = handles.length;
|
|
532
|
+
if (wanted === null) {
|
|
533
|
+
const handle = handles[action.index ?? 0];
|
|
534
|
+
if (!handle)
|
|
535
|
+
return false;
|
|
536
|
+
await client.switchToWindow(handle);
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
titles = [];
|
|
540
|
+
for (const handle of handles) {
|
|
541
|
+
await client.switchToWindow(handle);
|
|
542
|
+
const title = await client.getTitle().catch(() => '');
|
|
543
|
+
titles.push(title);
|
|
544
|
+
if (matches(title, wanted, action.match))
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
if (original)
|
|
548
|
+
await client.switchToWindow(original).catch(() => undefined);
|
|
549
|
+
return false;
|
|
550
|
+
}, () => wanted === null
|
|
551
|
+
? `window ${action.index} to be open, found ${count} window(s)`
|
|
552
|
+
: `a window whose title ${action.match === 'equals' ? 'equals' : 'contains'} "${wanted}", found ${titles.map((t) => `"${clip(t)}"`).join(', ') || 'none'}`);
|
|
553
|
+
this.refElements.clear();
|
|
554
|
+
}
|
|
555
|
+
async find(target) {
|
|
556
|
+
validateDomTarget(target);
|
|
557
|
+
const client = this.requireClient();
|
|
558
|
+
const byElement = new Map([...this.refElements].map(([ref, id]) => [id, ref]));
|
|
559
|
+
for (const candidate of domCandidates(target)) {
|
|
560
|
+
const found = await domFindAll(client, candidate, this.renderText, 20);
|
|
561
|
+
if (found.count === 0)
|
|
562
|
+
continue;
|
|
563
|
+
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 };
|
|
567
|
+
}
|
|
568
|
+
return { count: 0, refs: [] };
|
|
569
|
+
}
|
|
570
|
+
async snapshot(options = {}) {
|
|
571
|
+
const client = this.requireClient();
|
|
572
|
+
const raw = (await client.executeScript(domScript('dom-snapshot'), [
|
|
573
|
+
{
|
|
574
|
+
...(options.depth !== undefined ? { depth: options.depth } : {}),
|
|
575
|
+
...(options.includeHidden !== undefined ? { includeHidden: options.includeHidden } : {}),
|
|
576
|
+
},
|
|
577
|
+
]));
|
|
578
|
+
const built = buildDomSnapshot(raw, {
|
|
579
|
+
platform: 'tauri',
|
|
580
|
+
...(options.maxChars !== undefined ? { maxChars: options.maxChars } : {}),
|
|
581
|
+
appId: this.ctx.job.app?.id || null,
|
|
582
|
+
});
|
|
583
|
+
this.refElements = built.elements;
|
|
584
|
+
return built.snapshot;
|
|
585
|
+
}
|
|
586
|
+
async screen() {
|
|
587
|
+
const client = this.requireClient();
|
|
588
|
+
const raw = (await client.executeScript(domScript('dom-snapshot'), [{ viewportOnly: true }]));
|
|
589
|
+
return domScreen(raw, this.ctx.job.app?.id || null);
|
|
590
|
+
}
|
|
591
|
+
async tapAt(x, y) {
|
|
592
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
593
|
+
throw new Error('coordinates must be numbers');
|
|
594
|
+
await this.requireClient().performActions([
|
|
595
|
+
{
|
|
596
|
+
type: 'pointer',
|
|
597
|
+
id: 'mouse',
|
|
598
|
+
parameters: { pointerType: 'mouse' },
|
|
599
|
+
actions: [
|
|
600
|
+
{ type: 'pointerMove', duration: 0, origin: 'viewport', x: Math.round(x), y: Math.round(y) },
|
|
601
|
+
{ type: 'pointerDown', button: 0 },
|
|
602
|
+
{ type: 'pointerUp', button: 0 },
|
|
603
|
+
],
|
|
604
|
+
},
|
|
605
|
+
]);
|
|
606
|
+
}
|
|
607
|
+
async back() {
|
|
608
|
+
throw new Unsupported("'back' is not available on tauri");
|
|
609
|
+
}
|
|
610
|
+
async home() {
|
|
611
|
+
throw new Unsupported("'home' is not available on tauri");
|
|
612
|
+
}
|
|
613
|
+
async screenshot() {
|
|
614
|
+
return Buffer.from(await this.requireClient().takeScreenshot(), 'base64');
|
|
615
|
+
}
|
|
616
|
+
source() {
|
|
617
|
+
return this.requireClient().getPageSource();
|
|
618
|
+
}
|
|
619
|
+
beginStep(_index) { }
|
|
620
|
+
debug(_index) {
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
/** Tauri jobs record no video (contracts §L). */
|
|
624
|
+
async stopRecording() {
|
|
625
|
+
return undefined;
|
|
626
|
+
}
|
|
627
|
+
async dispose() {
|
|
628
|
+
if (this.disposed)
|
|
629
|
+
return;
|
|
630
|
+
this.disposed = true;
|
|
631
|
+
await this.closeApp();
|
|
632
|
+
if (this.driverProcess)
|
|
633
|
+
killProcessTree(this.driverProcess.child.pid);
|
|
634
|
+
this.driverProcess = null;
|
|
635
|
+
await this.display?.stop().catch(() => undefined);
|
|
636
|
+
await disposeUnpacked(this.unpacked, this.ctx.log);
|
|
637
|
+
this.releaseBuild?.();
|
|
638
|
+
}
|
|
639
|
+
/** Ends the WebDriver session (tauri-driver closes the app) and kills a plugin app's process tree. */
|
|
640
|
+
async closeApp() {
|
|
641
|
+
const client = this.client;
|
|
642
|
+
this.client = null;
|
|
643
|
+
this.refElements.clear();
|
|
644
|
+
if (client)
|
|
645
|
+
await Promise.race([client.deleteSession().catch(() => undefined), sleep(10_000)]);
|
|
646
|
+
const app = this.app;
|
|
647
|
+
this.app = null;
|
|
648
|
+
if (app && !app.exited())
|
|
649
|
+
killProcessTree(app.child.pid);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
function nodePlatform(os) {
|
|
653
|
+
if (os === 'macos')
|
|
654
|
+
return 'darwin';
|
|
655
|
+
if (os === 'windows')
|
|
656
|
+
return 'win32';
|
|
657
|
+
return os;
|
|
658
|
+
}
|
|
659
|
+
/** Availability for a host with the given settings (a device only when a backend is ready). */
|
|
660
|
+
export function tauriAvailability(host, settings, lookup = {}) {
|
|
661
|
+
const backend = tauriBackend(settings, { ...lookup, platform: nodePlatform(host.os) });
|
|
662
|
+
if (backend.kind === 'unavailable')
|
|
663
|
+
return { ok: false, labels: [], devices: [], reasons: backend.reasons };
|
|
664
|
+
return { ok: true, labels: ['tauri'], devices: [hostDevice('tauri', { name: host.hostname })], reasons: [] };
|
|
665
|
+
}
|
|
666
|
+
export const tauriDriverFactory = {
|
|
667
|
+
platform: 'tauri',
|
|
668
|
+
async availability(host) {
|
|
669
|
+
return tauriAvailability(host, loadTauriSettings(process.env, homePaths(process.env)));
|
|
670
|
+
},
|
|
671
|
+
async create(ctx, _shared) {
|
|
672
|
+
const settings = loadTauriSettings(process.env, ctx.paths ?? homePaths(process.env));
|
|
673
|
+
const backend = tauriBackend(settings);
|
|
674
|
+
if (backend.kind === 'unavailable')
|
|
675
|
+
throw new Blocked(backend.reasons.join('; '));
|
|
676
|
+
return new TauriWebDriver(ctx, backend, { configuredNative: settings.nativeDriverPath !== null });
|
|
677
|
+
},
|
|
678
|
+
};
|