@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,566 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdtemp, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import { createServer } from 'node:net';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
7
|
+
import { _electron, chromium } from 'playwright-core';
|
|
8
|
+
import { AppNotAvailable, resolveAppFile } from '../builds/install.js';
|
|
9
|
+
import { disposeUnpacked, endWindowsProcessesUnder, taskkillArgv } from '../builds/cleanup.js';
|
|
10
|
+
import { DesktopBuildError, localDesktopApp, unpackDesktopBuild } from '../builds/unpack.js';
|
|
11
|
+
import { DebugCollector } from '../debug.js';
|
|
12
|
+
import { ensureDisplay, hostDevice } from '../devices/desktop.js';
|
|
13
|
+
import { homePaths } from '../home.js';
|
|
14
|
+
import { playwrightVersion } from '../version.js';
|
|
15
|
+
import { LOCAL_PATHS_REFUSED } from './android-appium.js';
|
|
16
|
+
import { clip, errorMessage, expectWithin, firstLine, matches, render } from './common.js';
|
|
17
|
+
import { Blocked, LaunchRefused, Unsupported, } from './driver.js';
|
|
18
|
+
import { webSnapshot } from './snapshot/aria.js';
|
|
19
|
+
import { findOnPage, NO_FFMPEG_VIDEO, playwrightFfmpeg, runWebAction, WEB_ACTIONS } from './web-playwright.js';
|
|
20
|
+
/** Electron DSL (contracts §K): web actions minus `goto`, plus launch, terminate and focus_window. */
|
|
21
|
+
export const ELECTRON_ACTIONS = [...WEB_ACTIONS.filter((action) => action !== 'goto'), 'launch', 'terminate', 'focus_window'];
|
|
22
|
+
export const ELECTRON_TARGETS = ['role', 'label', 'placeholder', 'text', 'test_id', 'css'];
|
|
23
|
+
export const MAX_LAUNCH_ARGS = 20;
|
|
24
|
+
export const MAX_LAUNCH_ARG_CHARS = 500;
|
|
25
|
+
export const MAX_WINDOW_INDEX = 100;
|
|
26
|
+
export const INSTALLED_REFUSED = 'electron apps need an uploaded build or a path on the runner';
|
|
27
|
+
const LAUNCH_TIMEOUT_MS = 60_000;
|
|
28
|
+
const PAINT_TIMEOUT_MS = 15_000;
|
|
29
|
+
const VIDEO_SIZE = { width: 1024, height: 576 };
|
|
30
|
+
/** Switches the runner controls itself (debugging ports); a script cannot pass them. */
|
|
31
|
+
const RESERVED_ARG = /^--(inspect|inspect-brk|inspect-port|remote-debugging-port|remote-debugging-pipe|remote-allow-origins)(=|$)/i;
|
|
32
|
+
/** Runner secrets never reach the app's environment. */
|
|
33
|
+
const SECRET_ENV = new Set(['RUNNER_TOKEN', 'LEERA_RUNNER_TOKEN', 'ELECTRON_RUN_AS_NODE', 'NODE_OPTIONS']);
|
|
34
|
+
/** Checks an Electron-only action before it runs; throws what the step reports. */
|
|
35
|
+
export function validateElectronAction(action) {
|
|
36
|
+
if (action.type === 'goto')
|
|
37
|
+
throw new Unsupported("'goto' is not an electron action; start the app with launch");
|
|
38
|
+
if (action.type === 'launch') {
|
|
39
|
+
const args = action.args;
|
|
40
|
+
if (args === undefined)
|
|
41
|
+
return;
|
|
42
|
+
if (!Array.isArray(args) || args.length > MAX_LAUNCH_ARGS)
|
|
43
|
+
throw new Error(`launch args must be a list of at most ${MAX_LAUNCH_ARGS} strings`);
|
|
44
|
+
args.forEach((arg, i) => {
|
|
45
|
+
if (typeof arg !== 'string')
|
|
46
|
+
throw new Error(`launch args[${i}] must be a string`);
|
|
47
|
+
if (arg.length > MAX_LAUNCH_ARG_CHARS)
|
|
48
|
+
throw new Error(`launch args[${i}] is longer than ${MAX_LAUNCH_ARG_CHARS} characters`);
|
|
49
|
+
if (RESERVED_ARG.test(arg))
|
|
50
|
+
throw new Error(`launch args[${i}] '${arg.split('=')[0]}' is reserved for the runner`);
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (action.type === 'focus_window') {
|
|
55
|
+
const hasTitle = typeof action.title === 'string' && action.title.length > 0;
|
|
56
|
+
const hasIndex = action.index !== undefined;
|
|
57
|
+
if (hasTitle === hasIndex)
|
|
58
|
+
throw new Error('focus_window needs exactly one of title or index');
|
|
59
|
+
if (hasIndex && (!Number.isInteger(action.index) || action.index < 0 || action.index > MAX_WINDOW_INDEX)) {
|
|
60
|
+
throw new Error(`focus_window index must be a window number from 0 to ${MAX_WINDOW_INDEX}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** The app's environment: the runner's, minus its secrets, plus the display. */
|
|
65
|
+
export function electronEnv(base, extra = {}) {
|
|
66
|
+
const env = {};
|
|
67
|
+
for (const [key, value] of Object.entries(base)) {
|
|
68
|
+
if (value !== undefined && !SECRET_ENV.has(key.toUpperCase()))
|
|
69
|
+
env[key] = value;
|
|
70
|
+
}
|
|
71
|
+
return { ...env, ...extra };
|
|
72
|
+
}
|
|
73
|
+
/** The argv of the fallback launch: the app's own arguments plus a DevTools port on loopback. */
|
|
74
|
+
export function cdpLaunchArgv(args, port) {
|
|
75
|
+
return [...args, `--remote-debugging-port=${port}`, '--remote-allow-origins=http://127.0.0.1'];
|
|
76
|
+
}
|
|
77
|
+
export { endWindowsProcessesUnder, parseWindowsProcesses, taskkillArgv, windowsProcessesUnder, windowsProcessesUnderArgv, } from '../builds/cleanup.js';
|
|
78
|
+
/** Ends a process and everything it started (Electron helpers): the process group on POSIX, taskkill /T on Windows. */
|
|
79
|
+
export function killProcessTree(pid, platform = process.platform) {
|
|
80
|
+
if (!pid)
|
|
81
|
+
return;
|
|
82
|
+
if (platform === 'win32') {
|
|
83
|
+
spawnSync('taskkill', taskkillArgv(pid), { stdio: 'ignore', windowsHide: true, timeout: 15_000 });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
process.kill(-pid, 'SIGKILL');
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 'SIGKILL');
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Already gone.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function freePort() {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const server = createServer();
|
|
101
|
+
server.once('error', reject);
|
|
102
|
+
server.listen(0, '127.0.0.1', () => {
|
|
103
|
+
const address = server.address();
|
|
104
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
105
|
+
server.close(() => resolve(port));
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
export class ElectronPlaywrightDriver {
|
|
110
|
+
ctx;
|
|
111
|
+
options;
|
|
112
|
+
platform = 'electron';
|
|
113
|
+
capabilities = { actions: ELECTRON_ACTIONS, targets: ELECTRON_TARGETS, video: 'webm' };
|
|
114
|
+
executable = null;
|
|
115
|
+
unpacked = null;
|
|
116
|
+
releaseBuild = null;
|
|
117
|
+
display = null;
|
|
118
|
+
instance = null;
|
|
119
|
+
/** Windows of the running app in opening order (closed ones removed). */
|
|
120
|
+
windows = [];
|
|
121
|
+
page = null;
|
|
122
|
+
collector = null;
|
|
123
|
+
videoDir = null;
|
|
124
|
+
videos = [];
|
|
125
|
+
videoPath;
|
|
126
|
+
videoStopped = false;
|
|
127
|
+
/** The script's first action is `launch`: `launch()` already started the app with its args. */
|
|
128
|
+
firstLaunchDone = false;
|
|
129
|
+
actionsRun = 0;
|
|
130
|
+
snapshotTaken = false;
|
|
131
|
+
disposed = false;
|
|
132
|
+
constructor(ctx, options = {}) {
|
|
133
|
+
this.ctx = ctx;
|
|
134
|
+
this.options = options;
|
|
135
|
+
}
|
|
136
|
+
/** How the running app is connected: Playwright's Electron launch or the DevTools-port fallback. */
|
|
137
|
+
get connection() {
|
|
138
|
+
return this.instance?.via ?? null;
|
|
139
|
+
}
|
|
140
|
+
/** The running app's process id. */
|
|
141
|
+
get pid() {
|
|
142
|
+
return this.instance?.process?.pid ?? null;
|
|
143
|
+
}
|
|
144
|
+
get paths() {
|
|
145
|
+
return this.ctx.paths ?? homePaths(process.env);
|
|
146
|
+
}
|
|
147
|
+
/** Where the app comes from: an unpacked build, or an app at a path on this runner. */
|
|
148
|
+
async resolveExecutable() {
|
|
149
|
+
const app = this.ctx.job.app;
|
|
150
|
+
if (!app)
|
|
151
|
+
throw new LaunchRefused('the job names no Electron app (set the app in the environment)');
|
|
152
|
+
const override = app.launch_activity?.trim() || null;
|
|
153
|
+
if (app.install === 'installed')
|
|
154
|
+
throw new LaunchRefused(INSTALLED_REFUSED);
|
|
155
|
+
if (app.install === 'path') {
|
|
156
|
+
if (!this.ctx.allowLocalPaths)
|
|
157
|
+
throw new LaunchRefused(LOCAL_PATHS_REFUSED);
|
|
158
|
+
try {
|
|
159
|
+
return localDesktopApp(app.path ?? '', override);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
throw new LaunchRefused(errorMessage(error));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
let file;
|
|
166
|
+
try {
|
|
167
|
+
const resolved = await resolveAppFile({
|
|
168
|
+
app,
|
|
169
|
+
paths: this.paths,
|
|
170
|
+
allowLocalPaths: this.ctx.allowLocalPaths ?? false,
|
|
171
|
+
getUrl: (buildId) => {
|
|
172
|
+
if (!this.ctx.buildDownloadUrl)
|
|
173
|
+
throw new Blocked('this runner cannot download builds for this job');
|
|
174
|
+
return this.ctx.buildDownloadUrl(buildId);
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
this.releaseBuild = resolved.release;
|
|
178
|
+
file = resolved.path;
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
const message = `the app build is not available: ${errorMessage(error)}`;
|
|
182
|
+
throw error instanceof AppNotAvailable ? new LaunchRefused(message) : new Blocked(message);
|
|
183
|
+
}
|
|
184
|
+
if (!file)
|
|
185
|
+
throw new LaunchRefused(INSTALLED_REFUSED);
|
|
186
|
+
try {
|
|
187
|
+
this.unpacked = unpackDesktopBuild(file, override);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
const message = `the app build could not be unpacked: ${errorMessage(error)}`;
|
|
191
|
+
throw error instanceof DesktopBuildError ? new LaunchRefused(message) : new Blocked(message);
|
|
192
|
+
}
|
|
193
|
+
return this.unpacked.executable;
|
|
194
|
+
}
|
|
195
|
+
async launch() {
|
|
196
|
+
this.executable = await this.resolveExecutable();
|
|
197
|
+
try {
|
|
198
|
+
this.display = await ensureDisplay();
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
throw new Blocked(errorMessage(error));
|
|
202
|
+
}
|
|
203
|
+
if (this.ctx.video !== 'off') {
|
|
204
|
+
if (playwrightFfmpeg() === null)
|
|
205
|
+
this.ctx.log.warn(NO_FFMPEG_VIDEO);
|
|
206
|
+
else
|
|
207
|
+
this.videoDir = await mkdtemp(join(tmpdir(), 'qa-video-'));
|
|
208
|
+
}
|
|
209
|
+
this.collector = new DebugCollector(Date.now(), this.ctx.redact);
|
|
210
|
+
// A script that starts with `launch {args}` gets its arguments on the first start.
|
|
211
|
+
const first = [...this.ctx.job.automation.steps].sort((a, b) => a.index - b.index)[0]?.actions[0];
|
|
212
|
+
let args = [];
|
|
213
|
+
if (first?.type === 'launch') {
|
|
214
|
+
validateElectronAction(first);
|
|
215
|
+
args = (first.args ?? []).map((arg) => render(arg, this.ctx.template));
|
|
216
|
+
this.firstLaunchDone = true;
|
|
217
|
+
}
|
|
218
|
+
await this.start(args);
|
|
219
|
+
}
|
|
220
|
+
async start(args) {
|
|
221
|
+
const executable = this.executable;
|
|
222
|
+
if (!executable)
|
|
223
|
+
throw new Blocked('the app was not prepared');
|
|
224
|
+
const env = electronEnv(process.env, this.display?.env ?? {});
|
|
225
|
+
const cwd = dirname(executable);
|
|
226
|
+
try {
|
|
227
|
+
if (this.options.launchVia === 'cdp')
|
|
228
|
+
throw new Error('the DevTools-port launch was requested');
|
|
229
|
+
const electron = await _electron.launch({
|
|
230
|
+
executablePath: executable,
|
|
231
|
+
args,
|
|
232
|
+
env,
|
|
233
|
+
cwd,
|
|
234
|
+
timeout: LAUNCH_TIMEOUT_MS,
|
|
235
|
+
acceptDownloads: false,
|
|
236
|
+
...(this.videoDir ? { recordVideo: { dir: this.videoDir, size: VIDEO_SIZE } } : {}),
|
|
237
|
+
});
|
|
238
|
+
this.adopt({ via: 'playwright', electron, browser: null, context: electron.context(), process: electron.process(), video: null, closed: false });
|
|
239
|
+
electron.on('window', (page) => this.addWindow(page));
|
|
240
|
+
for (const page of electron.windows())
|
|
241
|
+
this.addWindow(page);
|
|
242
|
+
if (this.windows.length === 0)
|
|
243
|
+
await electron.firstWindow({ timeout: LAUNCH_TIMEOUT_MS });
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (this.instance)
|
|
247
|
+
await this.closeInstance();
|
|
248
|
+
this.ctx.log.warn('Playwright could not launch the Electron app; retrying with a DevTools port', { error: firstLine(error) });
|
|
249
|
+
try {
|
|
250
|
+
await this.startOverCdp(executable, args, env, cwd);
|
|
251
|
+
}
|
|
252
|
+
catch (fallback) {
|
|
253
|
+
throw new Blocked(`the app could not be launched: ${firstLine(error)}; with a DevTools port: ${firstLine(fallback)}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const page = this.windows[0];
|
|
257
|
+
if (!page)
|
|
258
|
+
throw new Blocked('the app opened no window');
|
|
259
|
+
this.page = page;
|
|
260
|
+
await page.waitForLoadState('domcontentloaded', { timeout: LAUNCH_TIMEOUT_MS }).catch(() => undefined);
|
|
261
|
+
await this.waitForPaint(page, 'launch');
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Waits until the window is visible and has painted (two animation frames). A loaded DOM is not
|
|
265
|
+
* enough: right after a cold start the window may produce no frames yet (seen on a fresh Linux
|
|
266
|
+
* VM under Xvfb), and Chromium's screenshot waits for a frame, so the first screenshot timed out
|
|
267
|
+
* while actions on the DOM passed. Animation frames only run while frames are produced. Bounded;
|
|
268
|
+
* a window that never paints is reported by the step that needs it, not here.
|
|
269
|
+
*/
|
|
270
|
+
async waitForPaint(page, when) {
|
|
271
|
+
const started = Date.now();
|
|
272
|
+
const frames = page
|
|
273
|
+
.evaluate(() => new Promise((resolve) => {
|
|
274
|
+
const tick = () => {
|
|
275
|
+
if (document.visibilityState === 'visible')
|
|
276
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve(true)));
|
|
277
|
+
else
|
|
278
|
+
setTimeout(tick, 250);
|
|
279
|
+
};
|
|
280
|
+
tick();
|
|
281
|
+
}))
|
|
282
|
+
.catch(() => false);
|
|
283
|
+
const timer = new AbortController();
|
|
284
|
+
const painted = await Promise.race([frames, sleep(PAINT_TIMEOUT_MS, false, { signal: timer.signal }).catch(() => false)]);
|
|
285
|
+
timer.abort();
|
|
286
|
+
const ms = Date.now() - started;
|
|
287
|
+
if (!painted)
|
|
288
|
+
this.ctx.log.warn(`the app window did not paint within ${Math.round(PAINT_TIMEOUT_MS / 1000)} s after ${when}`);
|
|
289
|
+
else if (ms > 2_000)
|
|
290
|
+
this.ctx.log.warn(`the app window took ${ms} ms to paint after ${when}`);
|
|
291
|
+
}
|
|
292
|
+
/** Fallback for apps whose fuses block Playwright's launch: spawn with a DevTools port and connect over CDP. */
|
|
293
|
+
async startOverCdp(executable, args, env, cwd) {
|
|
294
|
+
const port = await freePort();
|
|
295
|
+
const child = spawn(executable, cdpLaunchArgv(args, port), {
|
|
296
|
+
cwd,
|
|
297
|
+
env,
|
|
298
|
+
stdio: 'ignore',
|
|
299
|
+
detached: process.platform !== 'win32',
|
|
300
|
+
windowsHide: false,
|
|
301
|
+
});
|
|
302
|
+
let exited = null;
|
|
303
|
+
child.once('exit', (code, signal) => {
|
|
304
|
+
exited = `the app exited (${signal ?? `code ${code}`})`;
|
|
305
|
+
});
|
|
306
|
+
child.once('error', (error) => {
|
|
307
|
+
exited = `the app could not start: ${error.message}`;
|
|
308
|
+
});
|
|
309
|
+
const instance = { via: 'cdp', electron: null, browser: null, context: null, process: child, video: null, closed: false };
|
|
310
|
+
try {
|
|
311
|
+
const endpoint = `http://127.0.0.1:${port}`;
|
|
312
|
+
const deadline = Date.now() + LAUNCH_TIMEOUT_MS;
|
|
313
|
+
for (;;) {
|
|
314
|
+
if (exited)
|
|
315
|
+
throw new Error(exited);
|
|
316
|
+
const ready = await fetch(`${endpoint}/json/version`, { signal: AbortSignal.timeout(2_000) }).then((response) => response.ok, () => false);
|
|
317
|
+
if (ready)
|
|
318
|
+
break;
|
|
319
|
+
if (Date.now() > deadline)
|
|
320
|
+
throw new Error(`no DevTools endpoint on port ${port} within ${LAUNCH_TIMEOUT_MS / 1000} s`);
|
|
321
|
+
await sleep(250);
|
|
322
|
+
}
|
|
323
|
+
const browser = await chromium.connectOverCDP(endpoint, { timeout: LAUNCH_TIMEOUT_MS });
|
|
324
|
+
const context = browser.contexts()[0];
|
|
325
|
+
if (!context)
|
|
326
|
+
throw new Error('the app exposes no browser context');
|
|
327
|
+
instance.browser = browser;
|
|
328
|
+
instance.context = context;
|
|
329
|
+
this.adopt(instance);
|
|
330
|
+
context.on('page', (page) => this.addWindow(page));
|
|
331
|
+
for (const page of context.pages())
|
|
332
|
+
this.addWindow(page);
|
|
333
|
+
if (this.windows.length === 0)
|
|
334
|
+
await context.waitForEvent('page', { timeout: LAUNCH_TIMEOUT_MS });
|
|
335
|
+
if (this.videoDir)
|
|
336
|
+
this.ctx.log.warn('video is not recorded for an Electron app launched with a DevTools port');
|
|
337
|
+
return instance;
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
await this.closeInstance();
|
|
341
|
+
killProcessTree(child.pid);
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
adopt(instance) {
|
|
346
|
+
this.instance = instance;
|
|
347
|
+
this.windows = [];
|
|
348
|
+
this.page = null;
|
|
349
|
+
}
|
|
350
|
+
addWindow(page) {
|
|
351
|
+
if (this.windows.includes(page) || page.url().startsWith('devtools://'))
|
|
352
|
+
return;
|
|
353
|
+
this.windows.push(page);
|
|
354
|
+
page.setDefaultTimeout(this.ctx.timeouts.actionMs);
|
|
355
|
+
page.setDefaultNavigationTimeout(Math.max(this.ctx.timeouts.actionMs, 30_000));
|
|
356
|
+
this.collector?.attach(page);
|
|
357
|
+
const instance = this.instance;
|
|
358
|
+
if (instance && !instance.video && instance.via === 'playwright') {
|
|
359
|
+
const video = page.video();
|
|
360
|
+
if (video) {
|
|
361
|
+
instance.video = video;
|
|
362
|
+
this.videos.push(video);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
page.once('close', () => {
|
|
366
|
+
this.windows = this.windows.filter((p) => p !== page);
|
|
367
|
+
if (this.page === page)
|
|
368
|
+
this.page = this.windows[0] ?? null;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
async run(action) {
|
|
372
|
+
const first = this.actionsRun === 0;
|
|
373
|
+
this.actionsRun += 1;
|
|
374
|
+
validateElectronAction(action);
|
|
375
|
+
switch (action.type) {
|
|
376
|
+
case 'launch': {
|
|
377
|
+
if (first && this.firstLaunchDone)
|
|
378
|
+
return {};
|
|
379
|
+
await this.closeInstance();
|
|
380
|
+
await this.start((action.args ?? []).map((arg) => render(arg, this.ctx.template)));
|
|
381
|
+
return {};
|
|
382
|
+
}
|
|
383
|
+
case 'terminate':
|
|
384
|
+
await this.closeInstance();
|
|
385
|
+
return {};
|
|
386
|
+
case 'focus_window':
|
|
387
|
+
await this.focusWindow(action);
|
|
388
|
+
return {};
|
|
389
|
+
default:
|
|
390
|
+
return runWebAction(this.requirePage(), action, this.ctx.template, this.ctx.timeouts.actionMs);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async focusWindow(action) {
|
|
394
|
+
if (!this.instance)
|
|
395
|
+
throw new Error('the app is not running; start it with launch');
|
|
396
|
+
const timeout = action.timeout_ms ?? this.ctx.timeouts.actionMs;
|
|
397
|
+
const wanted = action.title !== undefined ? render(action.title, this.ctx.template) : null;
|
|
398
|
+
let titles = [];
|
|
399
|
+
let found = null;
|
|
400
|
+
await expectWithin(timeout, async () => {
|
|
401
|
+
const open = this.windows.filter((page) => !page.isClosed());
|
|
402
|
+
if (wanted === null) {
|
|
403
|
+
found = open[action.index ?? 0] ?? null;
|
|
404
|
+
titles = [];
|
|
405
|
+
return found !== null;
|
|
406
|
+
}
|
|
407
|
+
titles = await Promise.all(open.map((page) => page.title().catch(() => '')));
|
|
408
|
+
const i = titles.findIndex((title) => matches(title, wanted, action.match));
|
|
409
|
+
found = i >= 0 ? open[i] : null;
|
|
410
|
+
return found !== null;
|
|
411
|
+
}, () => wanted === null
|
|
412
|
+
? `window ${action.index} to be open, found ${this.windows.length} window(s)`
|
|
413
|
+
: `a window whose title ${action.match === 'equals' ? 'equals' : 'contains'} "${wanted}", found ${titles.map((t) => `"${clip(t)}"`).join(', ') || 'none'}`);
|
|
414
|
+
const page = found;
|
|
415
|
+
if (!page)
|
|
416
|
+
return;
|
|
417
|
+
this.page = page;
|
|
418
|
+
await page.bringToFront().catch(() => undefined);
|
|
419
|
+
await this.waitForPaint(page, 'focus_window');
|
|
420
|
+
}
|
|
421
|
+
find(target) {
|
|
422
|
+
return findOnPage(this.requirePage(), target, this.ctx.template, this.snapshotTaken);
|
|
423
|
+
}
|
|
424
|
+
async snapshot(options = {}) {
|
|
425
|
+
const page = this.requirePage();
|
|
426
|
+
const snapshot = await webSnapshot(page, options);
|
|
427
|
+
snapshot.screen = await this.screen();
|
|
428
|
+
this.snapshotTaken = true;
|
|
429
|
+
return snapshot;
|
|
430
|
+
}
|
|
431
|
+
/** The focused window: its inner size, URL and title (Electron windows have no emulated viewport). */
|
|
432
|
+
async screen() {
|
|
433
|
+
const page = this.requirePage();
|
|
434
|
+
const size = await page
|
|
435
|
+
.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, scale: window.devicePixelRatio }))
|
|
436
|
+
.catch(() => null);
|
|
437
|
+
return {
|
|
438
|
+
width: size?.width ?? null,
|
|
439
|
+
height: size?.height ?? null,
|
|
440
|
+
scale: size?.scale ?? null,
|
|
441
|
+
orientation: size ? (size.width >= size.height ? 'landscape' : 'portrait') : null,
|
|
442
|
+
activity: null,
|
|
443
|
+
url: page.url(),
|
|
444
|
+
title: await page.title().catch(() => null),
|
|
445
|
+
package: this.ctx.job.app?.id || null,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
async tapAt(x, y) {
|
|
449
|
+
await this.requirePage().mouse.click(x, y);
|
|
450
|
+
}
|
|
451
|
+
async back() {
|
|
452
|
+
throw new Unsupported("'back' is not available on electron");
|
|
453
|
+
}
|
|
454
|
+
async home() {
|
|
455
|
+
throw new Unsupported("'home' is not available on electron");
|
|
456
|
+
}
|
|
457
|
+
screenshot() {
|
|
458
|
+
return this.requirePage().screenshot({ type: 'png' });
|
|
459
|
+
}
|
|
460
|
+
source() {
|
|
461
|
+
return this.requirePage().content();
|
|
462
|
+
}
|
|
463
|
+
beginStep(index) {
|
|
464
|
+
this.collector?.begin(index);
|
|
465
|
+
}
|
|
466
|
+
debug(index) {
|
|
467
|
+
return this.collector?.for(index);
|
|
468
|
+
}
|
|
469
|
+
/** Closes the app (a video is only complete then) and returns the longest recording. */
|
|
470
|
+
async stopRecording() {
|
|
471
|
+
if (this.videoStopped)
|
|
472
|
+
return this.videoPath;
|
|
473
|
+
this.videoStopped = true;
|
|
474
|
+
await this.closeInstance();
|
|
475
|
+
let best = null;
|
|
476
|
+
for (const video of this.videos) {
|
|
477
|
+
const path = await video.path().catch(() => undefined);
|
|
478
|
+
if (!path)
|
|
479
|
+
continue;
|
|
480
|
+
const size = await stat(path).then((s) => s.size, () => 0);
|
|
481
|
+
if (size > 0 && (!best || size > best.size))
|
|
482
|
+
best = { path, size };
|
|
483
|
+
}
|
|
484
|
+
this.videoPath = best?.path;
|
|
485
|
+
if (!this.videoPath && this.videoDir) {
|
|
486
|
+
await rm(this.videoDir, { recursive: true, force: true }).catch(() => undefined);
|
|
487
|
+
this.videoDir = null;
|
|
488
|
+
}
|
|
489
|
+
return this.videoPath;
|
|
490
|
+
}
|
|
491
|
+
async dispose() {
|
|
492
|
+
if (this.disposed)
|
|
493
|
+
return;
|
|
494
|
+
this.disposed = true;
|
|
495
|
+
await this.closeInstance();
|
|
496
|
+
// Without a returned video (stopRecording not called, or nothing recorded) the directory is ours to delete.
|
|
497
|
+
if (!this.videoPath && this.videoDir)
|
|
498
|
+
await rm(this.videoDir, { recursive: true, force: true }).catch(() => undefined);
|
|
499
|
+
await this.display?.stop().catch(() => undefined);
|
|
500
|
+
await disposeUnpacked(this.unpacked, this.ctx.log);
|
|
501
|
+
this.releaseBuild?.();
|
|
502
|
+
}
|
|
503
|
+
/** Closes the running app gracefully, then kills whatever of its process tree is left. */
|
|
504
|
+
async closeInstance() {
|
|
505
|
+
const instance = this.instance;
|
|
506
|
+
this.instance = null;
|
|
507
|
+
this.windows = [];
|
|
508
|
+
this.page = null;
|
|
509
|
+
this.snapshotTaken = false;
|
|
510
|
+
if (!instance || instance.closed)
|
|
511
|
+
return;
|
|
512
|
+
instance.closed = true;
|
|
513
|
+
const pid = instance.process?.pid;
|
|
514
|
+
const graceful = instance.electron
|
|
515
|
+
? instance.electron.close()
|
|
516
|
+
: (async () => {
|
|
517
|
+
for (const page of instance.context?.pages() ?? [])
|
|
518
|
+
await page.close({ runBeforeUnload: false }).catch(() => undefined);
|
|
519
|
+
await instance.browser?.close();
|
|
520
|
+
})();
|
|
521
|
+
await Promise.race([graceful.catch(() => undefined), sleep(10_000)]);
|
|
522
|
+
killProcessTree(pid);
|
|
523
|
+
await this.endLeftoverProcesses();
|
|
524
|
+
}
|
|
525
|
+
/** Windows: no process from the app's folder may outlive the instance (see endWindowsProcessesUnder). */
|
|
526
|
+
async endLeftoverProcesses() {
|
|
527
|
+
if (process.platform !== 'win32')
|
|
528
|
+
return;
|
|
529
|
+
const dir = this.unpacked?.root ?? (this.executable ? dirname(this.executable) : null);
|
|
530
|
+
if (!dir)
|
|
531
|
+
return;
|
|
532
|
+
// A folder the runner unpacked is ours alone: anything naming it on its command line goes too.
|
|
533
|
+
const { killed, remaining } = await endWindowsProcessesUnder(dir, { commandLine: this.unpacked?.tempDir === dir });
|
|
534
|
+
if (killed.length > 0)
|
|
535
|
+
this.ctx.log.warn('ended app processes left running after the app closed', { processes: killed.map((p) => `${p.pid} ${p.name}`) });
|
|
536
|
+
if (remaining.length > 0)
|
|
537
|
+
this.ctx.log.warn('app processes are still running after 15 s', { processes: remaining.map((p) => `${p.pid} ${p.name}`) });
|
|
538
|
+
}
|
|
539
|
+
requirePage() {
|
|
540
|
+
if (!this.page || this.page.isClosed()) {
|
|
541
|
+
if (!this.instance)
|
|
542
|
+
throw new Error('the app is not running; start it with launch');
|
|
543
|
+
const next = this.windows.find((page) => !page.isClosed());
|
|
544
|
+
if (!next)
|
|
545
|
+
throw new Error('the app has no open window');
|
|
546
|
+
this.page = next;
|
|
547
|
+
}
|
|
548
|
+
return this.page;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/** True when this playwright-core can drive Electron. */
|
|
552
|
+
export function electronSupported() {
|
|
553
|
+
return typeof _electron?.launch === 'function';
|
|
554
|
+
}
|
|
555
|
+
export const electronDriverFactory = {
|
|
556
|
+
platform: 'electron',
|
|
557
|
+
async availability(host) {
|
|
558
|
+
if (!electronSupported() || !playwrightVersion()) {
|
|
559
|
+
return { ok: false, labels: [], devices: [], reasons: ['playwright-core with Electron support is not installed with this runner'] };
|
|
560
|
+
}
|
|
561
|
+
return { ok: true, labels: ['electron'], devices: [hostDevice('electron', { name: host.hostname })], reasons: [] };
|
|
562
|
+
},
|
|
563
|
+
async create(ctx, _shared) {
|
|
564
|
+
return new ElectronPlaywrightDriver(ctx);
|
|
565
|
+
},
|
|
566
|
+
};
|