@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,319 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
2
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
3
|
+
import { AppiumUnavailable } from '../appium/server.js';
|
|
4
|
+
import { hostDevice } from '../devices/desktop.js';
|
|
5
|
+
import { installedWindowsApp, loadWindowsSettings, sharedWinAppDriver, WINAPPDRIVER_VERSION, WINDOWS_NEEDS_WINDOWS, windowsCapabilities, windowsReadiness, } from '../devices/windows.js';
|
|
6
|
+
import { homePaths } from '../home.js';
|
|
7
|
+
import { androidSdkFor, configuredSdkPath, sharedAppiumServer } from './android-appium.js';
|
|
8
|
+
import { clip, errorMessage, expectWithin, matches } from './common.js';
|
|
9
|
+
import { DesktopAppiumDriver, validateNativeAction } from './desktop-appium.js';
|
|
10
|
+
import { Blocked, LaunchRefused, } from './driver.js';
|
|
11
|
+
import { emptyScreen } from './snapshot/format.js';
|
|
12
|
+
import { windowsKeyActions } from './webdriver/desktop-keys.js';
|
|
13
|
+
import { elementIdOf } from './webdriver/locate.js';
|
|
14
|
+
import { NATIVE_DESKTOP_ACTIONS } from './webdriver/native-actions.js';
|
|
15
|
+
import { describeWindowsTarget, WINDOWS_LOCATORS, windowsSelectors, xpathLiteral } from './webdriver/native-locate.js';
|
|
16
|
+
import { openSession, webdriverMessage } from './webdriver/session.js';
|
|
17
|
+
import { windowsSnapshotFromXml } from './webdriver/xml-tree-desktop.js';
|
|
18
|
+
/** Runs an Appium Windows driver `windows:` extension command. */
|
|
19
|
+
export function windowsCommand(client, command, args = {}) {
|
|
20
|
+
if (!/^[a-zA-Z]+$/.test(command))
|
|
21
|
+
throw new Error(`invalid windows command '${command}'`);
|
|
22
|
+
return client.executeScript(`windows: ${command}`, [args]);
|
|
23
|
+
}
|
|
24
|
+
/** `NativeWindowHandle` (decimal, as UIA reports it) → the hex form `appTopLevelWindow` takes. */
|
|
25
|
+
export function windowHandleHex(value) {
|
|
26
|
+
const n = Number(value);
|
|
27
|
+
if (!value || !Number.isInteger(n) || n <= 0)
|
|
28
|
+
return null;
|
|
29
|
+
return `0x${n.toString(16)}`;
|
|
30
|
+
}
|
|
31
|
+
export function windowsOps(client) {
|
|
32
|
+
return {
|
|
33
|
+
platform: 'windows',
|
|
34
|
+
selectors: windowsSelectors,
|
|
35
|
+
describe: describeWindowsTarget,
|
|
36
|
+
click: async (elementId) => void (await client.elementClick(elementId)),
|
|
37
|
+
doubleClick: async (elementId) => void (await windowsCommand(client, 'click', { elementId, times: 2, interClickDelayMs: 100 })),
|
|
38
|
+
rightClick: async (elementId) => void (await windowsCommand(client, 'click', { elementId, button: 'right' })),
|
|
39
|
+
hover: async (elementId) => void (await windowsCommand(client, 'hover', { startElementId: elementId, endElementId: elementId, durationMs: 100 })),
|
|
40
|
+
press: async (chord, elementId) => {
|
|
41
|
+
// Focus the element first: `windows: keys` types into whatever has keyboard focus.
|
|
42
|
+
if (elementId)
|
|
43
|
+
await client.elementClick(elementId);
|
|
44
|
+
await windowsCommand(client, 'keys', { actions: windowsKeyActions(chord) });
|
|
45
|
+
},
|
|
46
|
+
isChecked: async (elementId) => {
|
|
47
|
+
const toggle = await client.getElementAttribute(elementId, 'Toggle.ToggleState').catch(() => null);
|
|
48
|
+
if (toggle === '1')
|
|
49
|
+
return true;
|
|
50
|
+
if (toggle === '0')
|
|
51
|
+
return false;
|
|
52
|
+
const selected = await client.getElementAttribute(elementId, 'SelectionItem.IsSelected').catch(() => null);
|
|
53
|
+
if (selected?.toLowerCase() === 'true')
|
|
54
|
+
return true;
|
|
55
|
+
if (selected?.toLowerCase() === 'false')
|
|
56
|
+
return false;
|
|
57
|
+
return null;
|
|
58
|
+
},
|
|
59
|
+
selectOption: async (elementId, value, timeoutMs) => {
|
|
60
|
+
// Expand a combo box (a list is already open), then click the list item with that name.
|
|
61
|
+
const type = await client.getElementAttribute(elementId, 'LocalizedControlType').catch(() => null);
|
|
62
|
+
if (type?.toLowerCase() !== 'list')
|
|
63
|
+
await client.elementClick(elementId);
|
|
64
|
+
const xpath = `//ListItem[@Name=${xpathLiteral(value)}]`;
|
|
65
|
+
const deadline = Date.now() + Math.max(timeoutMs, 500);
|
|
66
|
+
for (;;) {
|
|
67
|
+
for (const found of await client.findElements('xpath', xpath).catch(() => [])) {
|
|
68
|
+
const id = elementIdOf(found);
|
|
69
|
+
if (await client.isElementDisplayed(id).catch(() => false)) {
|
|
70
|
+
await client.elementClick(id);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (Date.now() >= deadline)
|
|
75
|
+
break;
|
|
76
|
+
await sleep(300);
|
|
77
|
+
}
|
|
78
|
+
const names = [];
|
|
79
|
+
for (const found of (await client.findElements('xpath', '//ListItem').catch(() => [])).slice(0, 30)) {
|
|
80
|
+
const name = await client.getElementAttribute(elementIdOf(found), 'Name').catch(() => null);
|
|
81
|
+
if (name)
|
|
82
|
+
names.push(`"${clip(name)}"`);
|
|
83
|
+
}
|
|
84
|
+
await windowsCommand(client, 'keys', { actions: windowsKeyActions({ modifiers: [], key: 'escape' }) }).catch(() => undefined);
|
|
85
|
+
throw new Error(`the list has no option "${value}" (options: ${names.join(', ') || 'none'})`);
|
|
86
|
+
},
|
|
87
|
+
text: async (elementId) => {
|
|
88
|
+
const value = await client.getElementAttribute(elementId, 'Value.Value').catch(() => null);
|
|
89
|
+
if (value?.trim())
|
|
90
|
+
return value.trim();
|
|
91
|
+
const text = (await client.getElementText(elementId).catch(() => '')) ?? '';
|
|
92
|
+
if (text.trim())
|
|
93
|
+
return text.trim();
|
|
94
|
+
return ((await client.getElementAttribute(elementId, 'Name').catch(() => null)) ?? '').trim();
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** Native Windows apps through Appium's Windows driver and WinAppDriver (contracts §M). */
|
|
99
|
+
export class WindowsAppiumDriver extends DesktopAppiumDriver {
|
|
100
|
+
deps;
|
|
101
|
+
platform = 'windows';
|
|
102
|
+
capabilities = { actions: NATIVE_DESKTOP_ACTIONS, targets: [...WINDOWS_LOCATORS], video: 'mp4' };
|
|
103
|
+
/** What `appium:app` names: an executable path or an AUMID. */
|
|
104
|
+
appTarget = null;
|
|
105
|
+
workingDir = null;
|
|
106
|
+
wadUrl = null;
|
|
107
|
+
disposed = false;
|
|
108
|
+
constructor(ctx, appium, deps = {}) {
|
|
109
|
+
super(ctx, appium);
|
|
110
|
+
this.deps = deps;
|
|
111
|
+
}
|
|
112
|
+
get ops() {
|
|
113
|
+
return windowsOps(this.requireClient());
|
|
114
|
+
}
|
|
115
|
+
async launch() {
|
|
116
|
+
if (process.platform !== 'win32' && !this.deps.open)
|
|
117
|
+
throw new LaunchRefused(WINDOWS_NEEDS_WINDOWS);
|
|
118
|
+
const app = this.ctx.job.app;
|
|
119
|
+
const resolved = await this.resolveApp('windows');
|
|
120
|
+
if (resolved.kind === 'installed') {
|
|
121
|
+
try {
|
|
122
|
+
this.appTarget = installedWindowsApp(app?.id ?? '');
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
throw new LaunchRefused(`the installed app cannot be started: ${errorMessage(error)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
this.appTarget = resolved.executable;
|
|
130
|
+
this.workingDir = resolved.executable ? dirname(resolved.executable) : null;
|
|
131
|
+
}
|
|
132
|
+
let wad = this.deps.winAppDriver;
|
|
133
|
+
if (!wad) {
|
|
134
|
+
const settings = loadWindowsSettings(process.env, this.paths);
|
|
135
|
+
const readiness = await windowsReadiness(settings);
|
|
136
|
+
if (!readiness.winAppDriver)
|
|
137
|
+
throw new LaunchRefused(`WinAppDriver ${WINAPPDRIVER_VERSION} is not installed on this runner (run \`leera-qa-runner setup windows\`)`);
|
|
138
|
+
wad = sharedWinAppDriver(readiness.winAppDriver);
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
this.wadUrl = await wad.ensure();
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
throw new Blocked(`WinAppDriver could not start: ${errorMessage(error)}`);
|
|
145
|
+
}
|
|
146
|
+
const args = this.firstLaunchArgs();
|
|
147
|
+
await this.startRecording();
|
|
148
|
+
await this.start(args);
|
|
149
|
+
}
|
|
150
|
+
/** Opens a session that launches the app (WinAppDriver starts it) with `args`. */
|
|
151
|
+
async start(args, topLevelWindow) {
|
|
152
|
+
const logOffset = this.appium.logOffset();
|
|
153
|
+
try {
|
|
154
|
+
const url = await this.appium.ensure();
|
|
155
|
+
const capabilities = windowsCapabilities({
|
|
156
|
+
...(topLevelWindow ? { topLevelWindow } : { app: this.appTarget ?? '' }),
|
|
157
|
+
args: topLevelWindow ? [] : args,
|
|
158
|
+
workingDir: topLevelWindow ? null : this.workingDir,
|
|
159
|
+
wadUrl: this.wadUrl ?? '',
|
|
160
|
+
newCommandTimeoutSecs: Math.max(300, Math.ceil((this.ctx.session?.idle_timeout_seconds ?? 0) + 60)),
|
|
161
|
+
});
|
|
162
|
+
this.client = await (this.deps.open ?? openSession)(url, capabilities, 180_000);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
const reason = error instanceof AppiumUnavailable ? error.message : webdriverMessage(error);
|
|
166
|
+
const tail = this.appium.logSince(logOffset, 2_000).trim().split('\n').slice(-5).join(' | ');
|
|
167
|
+
throw new Blocked(`the app could not be launched: ${reason}${tail ? ` (appium: ${clip(tail)})` : ''}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Closes the app window and ends the session. */
|
|
171
|
+
async closeApp() {
|
|
172
|
+
const client = this.client;
|
|
173
|
+
this.client = null;
|
|
174
|
+
this.lastRefs = {};
|
|
175
|
+
if (!client)
|
|
176
|
+
return;
|
|
177
|
+
await windowsCommand(client, 'closeApp').catch(() => undefined);
|
|
178
|
+
await Promise.race([client.deleteSession().catch(() => undefined), sleep(15_000)]);
|
|
179
|
+
}
|
|
180
|
+
async run(action) {
|
|
181
|
+
if (this.skipFirstLaunch(action))
|
|
182
|
+
return {};
|
|
183
|
+
validateNativeAction('windows', action);
|
|
184
|
+
try {
|
|
185
|
+
switch (action.type) {
|
|
186
|
+
case 'launch':
|
|
187
|
+
await this.closeApp();
|
|
188
|
+
await this.start((action.args ?? []).map(this.render));
|
|
189
|
+
return {};
|
|
190
|
+
case 'terminate':
|
|
191
|
+
await this.closeApp();
|
|
192
|
+
return {};
|
|
193
|
+
case 'focus_window':
|
|
194
|
+
await this.focusWindow(action);
|
|
195
|
+
return {};
|
|
196
|
+
default:
|
|
197
|
+
return await this.runShared(action);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
throw this.translate(error);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Switches to the app window with that title; a window the app's session does not list (a
|
|
206
|
+
* dialog of another process) is attached to through a desktop session and `appTopLevelWindow`.
|
|
207
|
+
*/
|
|
208
|
+
async focusWindow(action) {
|
|
209
|
+
const client = this.requireClient();
|
|
210
|
+
const timeout = action.timeout_ms ?? this.ctx.timeouts.actionMs;
|
|
211
|
+
const wanted = this.render(action.title ?? '');
|
|
212
|
+
let titles = [];
|
|
213
|
+
try {
|
|
214
|
+
await expectWithin(timeout, async () => {
|
|
215
|
+
if (!client.getWindowHandles || !client.switchToWindow || !client.getTitle)
|
|
216
|
+
return false;
|
|
217
|
+
titles = [];
|
|
218
|
+
for (const handle of await client.getWindowHandles()) {
|
|
219
|
+
await client.switchToWindow(handle);
|
|
220
|
+
const title = await client.getTitle().catch(() => '');
|
|
221
|
+
titles.push(title);
|
|
222
|
+
if (matches(title, wanted, action.match))
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
}, 'window');
|
|
227
|
+
this.lastRefs = {};
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// not one of the app session's windows: look on the desktop
|
|
232
|
+
}
|
|
233
|
+
const handle = await this.desktopWindowHandle(wanted, action.match === 'equals');
|
|
234
|
+
if (!handle) {
|
|
235
|
+
throw new Error(`expected a window whose title ${action.match === 'equals' ? 'equals' : 'contains'} "${wanted}", found ${titles.map((t) => `"${clip(t)}"`).join(', ') || 'none'}`);
|
|
236
|
+
}
|
|
237
|
+
const previous = this.client;
|
|
238
|
+
this.client = null;
|
|
239
|
+
if (previous)
|
|
240
|
+
await Promise.race([previous.deleteSession().catch(() => undefined), sleep(15_000)]);
|
|
241
|
+
await this.start([], handle);
|
|
242
|
+
this.lastRefs = {};
|
|
243
|
+
}
|
|
244
|
+
/** Finds a top-level window by title through a short-lived desktop (`Root`) session. */
|
|
245
|
+
async desktopWindowHandle(title, exact) {
|
|
246
|
+
const url = await this.appium.ensure();
|
|
247
|
+
const root = await (this.deps.open ?? openSession)(url, windowsCapabilities({ app: 'Root', wadUrl: this.wadUrl ?? '' }), 60_000);
|
|
248
|
+
try {
|
|
249
|
+
const xpath = exact ? `/Pane/Window[@Name=${xpathLiteral(title)}]` : `/Pane/Window[contains(@Name, ${xpathLiteral(title)})]`;
|
|
250
|
+
const found = (await root.findElements('xpath', xpath).catch(() => []))[0];
|
|
251
|
+
if (!found)
|
|
252
|
+
return null;
|
|
253
|
+
return windowHandleHex(await root.getElementAttribute(elementIdOf(found), 'NativeWindowHandle').catch(() => null));
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
await root.deleteSession().catch(() => undefined);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async snapshot(options = {}) {
|
|
260
|
+
const client = this.requireClient();
|
|
261
|
+
const tree = windowsSnapshotFromXml(await client.getPageSource(), this.snapshotOptions(options));
|
|
262
|
+
this.lastRefs = tree.refs;
|
|
263
|
+
const screen = await this.screen().catch(() => emptyScreen());
|
|
264
|
+
return {
|
|
265
|
+
platform: 'windows',
|
|
266
|
+
taken_at: new Date().toISOString(),
|
|
267
|
+
screen,
|
|
268
|
+
app: { id: this.ctx.job.app?.id || this.appTarget, activity: null },
|
|
269
|
+
root: tree.root,
|
|
270
|
+
text: tree.text,
|
|
271
|
+
refs: tree.refs,
|
|
272
|
+
element_count: tree.element_count,
|
|
273
|
+
truncated: tree.truncated,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
async screen() {
|
|
277
|
+
const client = this.requireClient();
|
|
278
|
+
const [rect, title] = await Promise.all([client.getWindowRect().catch(() => null), client.getTitle?.().catch(() => null) ?? null]);
|
|
279
|
+
return {
|
|
280
|
+
width: rect?.width ?? null,
|
|
281
|
+
height: rect?.height ?? null,
|
|
282
|
+
scale: null,
|
|
283
|
+
orientation: null,
|
|
284
|
+
activity: null,
|
|
285
|
+
url: null,
|
|
286
|
+
title: title || null,
|
|
287
|
+
package: this.ctx.job.app?.id || null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/** Clicks at screen coordinates (the snapshot's bounds). */
|
|
291
|
+
async tapAt(x, y) {
|
|
292
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
293
|
+
throw new Error('coordinates must be numbers');
|
|
294
|
+
await windowsCommand(this.requireClient(), 'click', { x: Math.round(x), y: Math.round(y) });
|
|
295
|
+
}
|
|
296
|
+
async dispose() {
|
|
297
|
+
if (this.disposed)
|
|
298
|
+
return;
|
|
299
|
+
this.disposed = true;
|
|
300
|
+
await this.disposeCommon();
|
|
301
|
+
await this.closeApp();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
export const windowsDriverFactory = {
|
|
305
|
+
platform: 'windows',
|
|
306
|
+
async availability(host) {
|
|
307
|
+
if (host.os !== 'windows')
|
|
308
|
+
return { ok: false, labels: [], devices: [], reasons: [WINDOWS_NEEDS_WINDOWS] };
|
|
309
|
+
const readiness = await windowsReadiness(loadWindowsSettings(process.env, homePaths(process.env)));
|
|
310
|
+
if (!readiness.ok)
|
|
311
|
+
return { ok: false, labels: [], devices: [], reasons: readiness.reasons };
|
|
312
|
+
return { ok: true, labels: ['windows'], devices: [hostDevice('windows', { name: host.hostname })], reasons: [] };
|
|
313
|
+
},
|
|
314
|
+
async create(ctx, shared) {
|
|
315
|
+
const paths = ctx.paths ?? homePaths(process.env);
|
|
316
|
+
const appium = shared.appium ?? sharedAppiumServer(paths, androidSdkFor(process.env, ctx.androidSdkPath ?? configuredSdkPath(paths)));
|
|
317
|
+
return new WindowsAppiumDriver(ctx, appium);
|
|
318
|
+
},
|
|
319
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Process exit codes (plan §7.1). */
|
|
2
|
+
export const ExitCode = {
|
|
3
|
+
ok: 0,
|
|
4
|
+
error: 1,
|
|
5
|
+
usage: 2,
|
|
6
|
+
tokenRejected: 3,
|
|
7
|
+
environmentNotReady: 4,
|
|
8
|
+
versionRefused: 5,
|
|
9
|
+
interrupted: 130,
|
|
10
|
+
};
|
|
11
|
+
/** An error that ends the process with a specific exit code and a message meant for a person. */
|
|
12
|
+
export class ExitError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function messageOf(error) {
|
|
20
|
+
return error instanceof Error ? error.message : String(error);
|
|
21
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
import { describe, errorMessage, firstLine, screenshotName } from './drivers/common.js';
|
|
3
|
+
import { isPlatform } from './devices/types.js';
|
|
4
|
+
import { Blocked, LaunchRefused } from './drivers/driver.js';
|
|
5
|
+
import { BrowserPool, webPlaywrightFactory } from './drivers/web-playwright.js';
|
|
6
|
+
import { silentLogger } from './log.js';
|
|
7
|
+
import { TemplateError } from './template.js';
|
|
8
|
+
import { scriptPlatform } from './types.js';
|
|
9
|
+
export { launchBrowser } from './drivers/web-playwright.js';
|
|
10
|
+
/** The values a script's `{{placeholders}}` resolve to for this job. */
|
|
11
|
+
export function templateContextFor(job) {
|
|
12
|
+
return {
|
|
13
|
+
baseUrl: job.environment.base_url ?? '',
|
|
14
|
+
username: job.credential?.username ?? null,
|
|
15
|
+
password: job.credential?.password ?? null,
|
|
16
|
+
runId: job.run.id,
|
|
17
|
+
appId: job.app?.id ?? null,
|
|
18
|
+
random: new Map(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** The platform a claimed job runs on: its `platform` (older servers: web), which its script must match. */
|
|
22
|
+
export function jobPlatform(job) {
|
|
23
|
+
const platform = job.platform ?? 'web';
|
|
24
|
+
if (!isPlatform(platform))
|
|
25
|
+
throw new Error(`this runner does not know the platform '${platform}'`);
|
|
26
|
+
const scripted = scriptPlatform(job.automation);
|
|
27
|
+
if (scripted !== platform)
|
|
28
|
+
throw new Error(`the job runs on ${platform} but its script is written for ${scripted}`);
|
|
29
|
+
return platform;
|
|
30
|
+
}
|
|
31
|
+
export function jobContext(job, options) {
|
|
32
|
+
return {
|
|
33
|
+
jobId: job.job.id,
|
|
34
|
+
platform: jobPlatform(job),
|
|
35
|
+
lease: options.lease,
|
|
36
|
+
job,
|
|
37
|
+
template: templateContextFor(job),
|
|
38
|
+
timeouts: { actionMs: options.actionTimeoutMs, jobMs: options.jobTimeoutMs },
|
|
39
|
+
headless: options.headless,
|
|
40
|
+
...(options.proxy ? { proxy: options.proxy } : {}),
|
|
41
|
+
video: job.capture?.video ?? 'off',
|
|
42
|
+
redact: options.redact,
|
|
43
|
+
log: options.log,
|
|
44
|
+
...(options.app ?? {}),
|
|
45
|
+
...(options.buildDownloadUrl ? { buildDownloadUrl: options.buildDownloadUrl } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A driver context for a device session. Drivers read the app, device and environment
|
|
50
|
+
* from `ctx.job`, so the session is also presented as a job with no steps (id 0, run 0).
|
|
51
|
+
*/
|
|
52
|
+
export function sessionContext(session, options) {
|
|
53
|
+
if (!isPlatform(session.platform))
|
|
54
|
+
throw new Error(`this runner does not know the platform '${session.platform}'`);
|
|
55
|
+
const platform = session.platform;
|
|
56
|
+
const job = {
|
|
57
|
+
kind: 'test',
|
|
58
|
+
platform,
|
|
59
|
+
app: session.app,
|
|
60
|
+
device: session.device,
|
|
61
|
+
job: { id: 0, attempt: 1, lease_seconds: session.lease_seconds, heartbeat_seconds: session.lease_seconds },
|
|
62
|
+
run: { id: 0, name: `session ${session.id}` },
|
|
63
|
+
item: { id: 0, title: `Device session ${session.id}`, steps: [] },
|
|
64
|
+
environment: session.environment,
|
|
65
|
+
credential: session.credential,
|
|
66
|
+
automation: platform === 'web' ? { version: 1, steps: [] } : { version: 2, platform, steps: [] },
|
|
67
|
+
capture: { video: 'off' },
|
|
68
|
+
};
|
|
69
|
+
return { ...jobContext(job, options), session };
|
|
70
|
+
}
|
|
71
|
+
/** Creates the driver for a context through the registry; a platform without a factory is blocked. */
|
|
72
|
+
export async function createDriver(registry, ctx, shared) {
|
|
73
|
+
const factory = registry.get(ctx.platform);
|
|
74
|
+
if (!factory)
|
|
75
|
+
throw new Blocked(`this runner cannot run ${ctx.platform} jobs`);
|
|
76
|
+
return factory.create(ctx, shared);
|
|
77
|
+
}
|
|
78
|
+
/** Runs a claimed job's script on the driver its platform maps to (web, android, …). */
|
|
79
|
+
export async function executeClaimedJob(registry, ctx, shared, shouldStop) {
|
|
80
|
+
const driver = await createDriver(registry, ctx, shared);
|
|
81
|
+
return executeSteps(driver, ctx.job.automation, { jobTimeoutMs: ctx.timeouts.jobMs }, shouldStop, ctx.redact);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Runs a script's steps in index order on a driver: stops at the first failed or
|
|
85
|
+
* blocked step, takes a "verified" screenshot after a step with expectations and a
|
|
86
|
+
* "failure" screenshot when a step does not pass, and blocks once the job's time is up.
|
|
87
|
+
*/
|
|
88
|
+
export async function executeSteps(driver, script, options, shouldStop, redact) {
|
|
89
|
+
const started = Date.now();
|
|
90
|
+
const deadline = started + options.jobTimeoutMs;
|
|
91
|
+
const steps = [];
|
|
92
|
+
let stopped = false;
|
|
93
|
+
let videoPath;
|
|
94
|
+
try {
|
|
95
|
+
const ordered = [...script.steps].sort((a, b) => a.index - b.index);
|
|
96
|
+
try {
|
|
97
|
+
await driver.launch();
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
// A launch this runner refuses as configured blocks the case; other launch errors fail the job (and may be retried).
|
|
101
|
+
const first = ordered[0];
|
|
102
|
+
if (!(error instanceof LaunchRefused) || !first)
|
|
103
|
+
throw error;
|
|
104
|
+
const outcome = { index: first.index, result: 'blocked', note: redact(errorMessage(error)), screenshots: [] };
|
|
105
|
+
return { steps: [outcome], elapsedSecs: Math.max(1, Math.round((Date.now() - started) / 1000)), stopped: false };
|
|
106
|
+
}
|
|
107
|
+
for (const step of ordered) {
|
|
108
|
+
const outcome = { index: step.index, result: 'passed', screenshots: [] };
|
|
109
|
+
driver.beginStep(step.index);
|
|
110
|
+
try {
|
|
111
|
+
for (const action of step.actions) {
|
|
112
|
+
if (shouldStop()) {
|
|
113
|
+
stopped = true;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
if (Date.now() > deadline) {
|
|
117
|
+
throw new Blocked(`the job ran past its ${Math.round(options.jobTimeoutMs / 60_000)} minute limit`);
|
|
118
|
+
}
|
|
119
|
+
const result = await runAction(driver, action);
|
|
120
|
+
if (result.screenshot) {
|
|
121
|
+
outcome.screenshots.push({ name: screenshotName(step.index, action.name ?? 'screenshot'), png: result.screenshot });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (stopped)
|
|
125
|
+
break;
|
|
126
|
+
if (step.actions.some((action) => action.type.startsWith('expect_'))) {
|
|
127
|
+
outcome.screenshots.push({ name: screenshotName(step.index, 'verified'), png: await driver.screenshot() });
|
|
128
|
+
}
|
|
129
|
+
steps.push(outcome);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
outcome.result = error instanceof Blocked ? 'blocked' : 'failed';
|
|
133
|
+
outcome.note = redact(errorMessage(error));
|
|
134
|
+
const failure = await driver.screenshot().catch(() => null);
|
|
135
|
+
if (failure)
|
|
136
|
+
outcome.screenshots.push({ name: screenshotName(step.index, 'failure'), png: failure });
|
|
137
|
+
steps.push(outcome);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Events still arriving belong to the step that started them.
|
|
142
|
+
await sleep(options.settleMs ?? 250);
|
|
143
|
+
for (const outcome of steps) {
|
|
144
|
+
const captured = driver.debug(outcome.index, outcome.result);
|
|
145
|
+
if (captured)
|
|
146
|
+
outcome.debug = captured;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
videoPath = await driver.stopRecording().catch(() => undefined);
|
|
151
|
+
await driver.dispose().catch(() => undefined);
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
steps,
|
|
155
|
+
elapsedSecs: Math.max(1, Math.round((Date.now() - started) / 1000)),
|
|
156
|
+
stopped,
|
|
157
|
+
...(videoPath ? { videoPath } : {}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** Prefixes the action's description; template problems block the step rather than fail it. */
|
|
161
|
+
async function runAction(driver, action) {
|
|
162
|
+
try {
|
|
163
|
+
return await driver.run(action);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
const message = `${describe(action)}: ${firstLine(error)}`;
|
|
167
|
+
throw error instanceof Blocked || error instanceof TemplateError ? new Blocked(message) : new Error(message);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Runs a web job on an already launched browser (kept for callers of the pre-driver API). */
|
|
171
|
+
export async function executeWebJob(browser, job, options, shouldStop, redact) {
|
|
172
|
+
const ctx = {
|
|
173
|
+
jobId: job.job.id,
|
|
174
|
+
platform: 'web',
|
|
175
|
+
lease: null,
|
|
176
|
+
job,
|
|
177
|
+
template: templateContextFor(job),
|
|
178
|
+
timeouts: { actionMs: options.actionTimeoutMs, jobMs: options.jobTimeoutMs },
|
|
179
|
+
headless: options.headless,
|
|
180
|
+
...(options.proxy ? { proxy: options.proxy } : {}),
|
|
181
|
+
video: job.capture?.video ?? 'off',
|
|
182
|
+
redact,
|
|
183
|
+
log: silentLogger,
|
|
184
|
+
};
|
|
185
|
+
const driver = await webPlaywrightFactory.create(ctx, { browsers: BrowserPool.of(browser) });
|
|
186
|
+
return executeSteps(driver, job.automation, { jobTimeoutMs: options.jobTimeoutMs }, shouldStop, redact);
|
|
187
|
+
}
|
|
188
|
+
/** @deprecated use executeWebJob */
|
|
189
|
+
export const executeJob = executeWebJob;
|
package/dist/src/home.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, renameSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
/** `LEERA_RUNNER_HOME`, else `~/.leera-qa-runner`. */
|
|
6
|
+
export function homePaths(env = process.env) {
|
|
7
|
+
const root = env.LEERA_RUNNER_HOME?.trim() || join(homedir(), '.leera-qa-runner');
|
|
8
|
+
return {
|
|
9
|
+
root,
|
|
10
|
+
config: join(root, 'config.json'),
|
|
11
|
+
token: join(root, 'token'),
|
|
12
|
+
state: join(root, 'state.json'),
|
|
13
|
+
install: join(root, 'install.json'),
|
|
14
|
+
logs: join(root, 'logs'),
|
|
15
|
+
log: join(root, 'logs', 'runner.log'),
|
|
16
|
+
browsers: join(root, 'browsers'),
|
|
17
|
+
jobs: join(root, 'jobs'),
|
|
18
|
+
versions: join(root, 'versions'),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const isWindows = process.platform === 'win32';
|
|
22
|
+
/** Creates the home directory (mode 0700) when missing. */
|
|
23
|
+
export function ensureHome(paths) {
|
|
24
|
+
mkdirSync(paths.root, { recursive: true, mode: 0o700 });
|
|
25
|
+
if (!isWindows)
|
|
26
|
+
chmodSync(paths.root, 0o700);
|
|
27
|
+
}
|
|
28
|
+
/** Writes a file atomically with owner-only permissions. */
|
|
29
|
+
export function writePrivateFile(path, contents) {
|
|
30
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
31
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
32
|
+
writeFileSync(temp, contents, { mode: 0o600 });
|
|
33
|
+
if (!isWindows)
|
|
34
|
+
chmodSync(temp, 0o600);
|
|
35
|
+
renameSync(temp, path);
|
|
36
|
+
}
|
|
37
|
+
/** True when group or others can read or write the file (always false on Windows, where ACLs apply). */
|
|
38
|
+
export function isTooOpen(path) {
|
|
39
|
+
if (isWindows)
|
|
40
|
+
return false;
|
|
41
|
+
return (statSync(path).mode & 0o077) !== 0;
|
|
42
|
+
}
|
|
43
|
+
/** The fixed argv that limits a file to the current user on Windows. */
|
|
44
|
+
export function windowsAclArgv(path, user) {
|
|
45
|
+
return ['icacls', path, '/inheritance:r', '/grant:r', `${user}:F`];
|
|
46
|
+
}
|
|
47
|
+
/** Reads the stored token, narrowing its permissions to 0600 when they were wider. */
|
|
48
|
+
export function readTokenFile(paths) {
|
|
49
|
+
if (!existsSync(paths.token))
|
|
50
|
+
return { token: null, fixedPermissions: false };
|
|
51
|
+
let fixedPermissions = false;
|
|
52
|
+
if (isTooOpen(paths.token)) {
|
|
53
|
+
chmodSync(paths.token, 0o600);
|
|
54
|
+
fixedPermissions = true;
|
|
55
|
+
}
|
|
56
|
+
const token = readFileSync(paths.token, 'utf8').trim();
|
|
57
|
+
return { token: token || null, fixedPermissions };
|
|
58
|
+
}
|
|
59
|
+
export function writeTokenFile(paths, token) {
|
|
60
|
+
ensureHome(paths);
|
|
61
|
+
writePrivateFile(paths.token, `${token.trim()}\n`);
|
|
62
|
+
if (isWindows && process.env.USERNAME) {
|
|
63
|
+
const user = process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` : process.env.USERNAME;
|
|
64
|
+
const [command, ...args] = windowsAclArgv(paths.token, user);
|
|
65
|
+
spawnSync(command, args, { stdio: 'ignore', windowsHide: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function readJsonFile(path) {
|
|
69
|
+
if (!existsSync(path))
|
|
70
|
+
return null;
|
|
71
|
+
// Installers on Windows may write a UTF-8 byte order mark.
|
|
72
|
+
const text = readFileSync(path, 'utf8').replace(/^\uFEFF/, '');
|
|
73
|
+
if (!text.trim())
|
|
74
|
+
return null;
|
|
75
|
+
return JSON.parse(text);
|
|
76
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Docker entry: starts the runner from the environment (RUNNER_URL, RUNNER_TOKEN, …).
|
|
2
|
+
import { stopSharedAppiumServer } from './drivers/android-appium.js';
|
|
3
|
+
import { messageOf } from './errors.js';
|
|
4
|
+
import { createLogger } from './log.js';
|
|
5
|
+
import { createRunner } from './runner.js';
|
|
6
|
+
const log = createLogger({ console: 'json' });
|
|
7
|
+
async function main() {
|
|
8
|
+
const { runner, shared } = await createRunner({ env: process.env, log });
|
|
9
|
+
process.once('SIGTERM', () => runner.stop());
|
|
10
|
+
process.once('SIGINT', () => runner.stop());
|
|
11
|
+
try {
|
|
12
|
+
await runner.run();
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
await shared.browsers.close();
|
|
16
|
+
await stopSharedAppiumServer();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
main().catch((error) => {
|
|
20
|
+
log.error('runner stopped', { error: messageOf(error) });
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
});
|