@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,328 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
import { clip, expectWithin, matches, verb } from '../common.js';
|
|
3
|
+
import { NotFound, Unsupported } from '../driver.js';
|
|
4
|
+
import { describeAndroidTarget, findAll, locate } from './locate.js';
|
|
5
|
+
import { mobile, webdriverMessage } from './session.js';
|
|
6
|
+
/** Named android keys (plan §3) → Android key codes. */
|
|
7
|
+
export const ANDROID_KEYS = {
|
|
8
|
+
back: 4,
|
|
9
|
+
home: 3,
|
|
10
|
+
enter: 66,
|
|
11
|
+
tab: 61,
|
|
12
|
+
delete: 67,
|
|
13
|
+
volume_up: 24,
|
|
14
|
+
volume_down: 25,
|
|
15
|
+
app_switch: 187,
|
|
16
|
+
};
|
|
17
|
+
/** `KEYCODE_*` names from android.view.KeyEvent that scripts are most likely to use. */
|
|
18
|
+
export const ANDROID_KEYCODES = {
|
|
19
|
+
KEYCODE_SOFT_LEFT: 1, KEYCODE_SOFT_RIGHT: 2, KEYCODE_HOME: 3, KEYCODE_BACK: 4, KEYCODE_CALL: 5, KEYCODE_ENDCALL: 6,
|
|
20
|
+
KEYCODE_0: 7, KEYCODE_1: 8, KEYCODE_2: 9, KEYCODE_3: 10, KEYCODE_4: 11, KEYCODE_5: 12, KEYCODE_6: 13, KEYCODE_7: 14,
|
|
21
|
+
KEYCODE_8: 15, KEYCODE_9: 16, KEYCODE_STAR: 17, KEYCODE_POUND: 18, KEYCODE_DPAD_UP: 19, KEYCODE_DPAD_DOWN: 20,
|
|
22
|
+
KEYCODE_DPAD_LEFT: 21, KEYCODE_DPAD_RIGHT: 22, KEYCODE_DPAD_CENTER: 23, KEYCODE_VOLUME_UP: 24, KEYCODE_VOLUME_DOWN: 25,
|
|
23
|
+
KEYCODE_POWER: 26, KEYCODE_CAMERA: 27, KEYCODE_CLEAR: 28,
|
|
24
|
+
KEYCODE_A: 29, KEYCODE_B: 30, KEYCODE_C: 31, KEYCODE_D: 32, KEYCODE_E: 33, KEYCODE_F: 34, KEYCODE_G: 35, KEYCODE_H: 36,
|
|
25
|
+
KEYCODE_I: 37, KEYCODE_J: 38, KEYCODE_K: 39, KEYCODE_L: 40, KEYCODE_M: 41, KEYCODE_N: 42, KEYCODE_O: 43, KEYCODE_P: 44,
|
|
26
|
+
KEYCODE_Q: 45, KEYCODE_R: 46, KEYCODE_S: 47, KEYCODE_T: 48, KEYCODE_U: 49, KEYCODE_V: 50, KEYCODE_W: 51, KEYCODE_X: 52,
|
|
27
|
+
KEYCODE_Y: 53, KEYCODE_Z: 54, KEYCODE_COMMA: 55, KEYCODE_PERIOD: 56, KEYCODE_TAB: 61, KEYCODE_SPACE: 62,
|
|
28
|
+
KEYCODE_ENTER: 66, KEYCODE_DEL: 67, KEYCODE_MINUS: 69, KEYCODE_EQUALS: 70, KEYCODE_AT: 77, KEYCODE_SEARCH: 84,
|
|
29
|
+
KEYCODE_MEDIA_PLAY_PAUSE: 85, KEYCODE_PAGE_UP: 92, KEYCODE_PAGE_DOWN: 93, KEYCODE_ESCAPE: 111, KEYCODE_FORWARD_DEL: 112,
|
|
30
|
+
KEYCODE_MOVE_HOME: 122, KEYCODE_MOVE_END: 123, KEYCODE_MENU: 82, KEYCODE_NOTIFICATION: 83, KEYCODE_VOLUME_MUTE: 164,
|
|
31
|
+
KEYCODE_APP_SWITCH: 187, KEYCODE_WAKEUP: 224, KEYCODE_SLEEP: 223,
|
|
32
|
+
};
|
|
33
|
+
/** Resolves an android key name to its key code; throws `Unsupported` for anything else. */
|
|
34
|
+
export function androidKeyCode(key) {
|
|
35
|
+
const named = ANDROID_KEYS[key.toLowerCase()];
|
|
36
|
+
if (named !== undefined)
|
|
37
|
+
return named;
|
|
38
|
+
const upper = key.toUpperCase();
|
|
39
|
+
if (/^KEYCODE_[A-Z0-9_]+$/.test(upper)) {
|
|
40
|
+
const code = ANDROID_KEYCODES[upper];
|
|
41
|
+
if (code !== undefined)
|
|
42
|
+
return code;
|
|
43
|
+
throw new Unsupported(`key '${key}' is not known to this runner version`);
|
|
44
|
+
}
|
|
45
|
+
throw new Unsupported(`'${key}' is not an android key; use ${Object.keys(ANDROID_KEYS).join(', ')} or KEYCODE_…`);
|
|
46
|
+
}
|
|
47
|
+
export const ANDROID_ACTIONS = [
|
|
48
|
+
'launch',
|
|
49
|
+
'terminate',
|
|
50
|
+
'reset',
|
|
51
|
+
'deep_link',
|
|
52
|
+
'tap',
|
|
53
|
+
'double_tap',
|
|
54
|
+
'long_press',
|
|
55
|
+
'type',
|
|
56
|
+
'clear',
|
|
57
|
+
'press',
|
|
58
|
+
'hide_keyboard',
|
|
59
|
+
'swipe',
|
|
60
|
+
'scroll_to',
|
|
61
|
+
'set_orientation',
|
|
62
|
+
'accept_alert',
|
|
63
|
+
'dismiss_alert',
|
|
64
|
+
'wait',
|
|
65
|
+
'wait_for',
|
|
66
|
+
'screenshot',
|
|
67
|
+
'expect_visible',
|
|
68
|
+
'expect_hidden',
|
|
69
|
+
'expect_text',
|
|
70
|
+
];
|
|
71
|
+
/** Deep link schemes a script may open (plan §3); the server validates too. */
|
|
72
|
+
export function checkDeepLink(url) {
|
|
73
|
+
const match = /^([a-z][a-z0-9+.-]{1,63}):\/\//i.exec(url);
|
|
74
|
+
if (!match)
|
|
75
|
+
throw new Error(`deep link '${clip(url)}' must start with a scheme such as https:// or myapp://`);
|
|
76
|
+
if (/^(javascript|file|data|blob|content|intent)$/i.test(match[1]))
|
|
77
|
+
throw new Error(`deep links with the ${match[1]} scheme are not allowed`);
|
|
78
|
+
return url;
|
|
79
|
+
}
|
|
80
|
+
function requireTarget(action) {
|
|
81
|
+
if (!action.target)
|
|
82
|
+
throw new Error(`'${action.type}' needs a target`);
|
|
83
|
+
return action.target;
|
|
84
|
+
}
|
|
85
|
+
async function element(ctx, target, timeoutMs) {
|
|
86
|
+
const found = await locate(ctx.client, target, timeoutMs, ctx.render);
|
|
87
|
+
if (!found)
|
|
88
|
+
throw new NotFound(`${describeAndroidTarget(target)} was not found within ${Math.round(timeoutMs / 100) / 10} s`);
|
|
89
|
+
ctx.onLocated?.(found);
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
async function visibleCount(ctx, target) {
|
|
93
|
+
const nth = target.nth;
|
|
94
|
+
const ids = await findAll(ctx.client, { ...target, fallbacks: undefined }, ctx.render);
|
|
95
|
+
const pool = nth === undefined ? ids : ids.slice(nth, nth + 1);
|
|
96
|
+
let count = 0;
|
|
97
|
+
for (const id of pool) {
|
|
98
|
+
if (await ctx.client.isElementDisplayed(id).catch(() => false))
|
|
99
|
+
count += 1;
|
|
100
|
+
}
|
|
101
|
+
return count;
|
|
102
|
+
}
|
|
103
|
+
/** The text an element shows: its text, else its content-description. */
|
|
104
|
+
export async function elementText(client, elementId) {
|
|
105
|
+
const text = (await client.getElementText(elementId).catch(() => '')) ?? '';
|
|
106
|
+
if (text.trim())
|
|
107
|
+
return text.trim();
|
|
108
|
+
const desc = await client.getElementAttribute(elementId, 'content-desc').catch(() => null);
|
|
109
|
+
return (desc ?? '').trim();
|
|
110
|
+
}
|
|
111
|
+
export function orientationValue(value) {
|
|
112
|
+
const lower = (value ?? '').toLowerCase();
|
|
113
|
+
if (lower === 'portrait')
|
|
114
|
+
return 'PORTRAIT';
|
|
115
|
+
if (lower === 'landscape')
|
|
116
|
+
return 'LANDSCAPE';
|
|
117
|
+
throw new Error(`orientation must be portrait or landscape, not '${value ?? ''}'`);
|
|
118
|
+
}
|
|
119
|
+
export function swipeDistance(distance) {
|
|
120
|
+
if (distance === undefined)
|
|
121
|
+
return 0.7;
|
|
122
|
+
if (!Number.isFinite(distance) || distance < 0.1 || distance > 1)
|
|
123
|
+
throw new Error('distance must be between 0.1 and 1');
|
|
124
|
+
return distance;
|
|
125
|
+
}
|
|
126
|
+
/** The screen area gestures use when no element is given: the middle, clear of the system bars. */
|
|
127
|
+
export function gestureArea(rect) {
|
|
128
|
+
const left = Math.round(rect.width * 0.1);
|
|
129
|
+
const top = Math.round(rect.height * 0.2);
|
|
130
|
+
return { left, top, width: Math.round(rect.width * 0.8), height: Math.round(rect.height * 0.6) };
|
|
131
|
+
}
|
|
132
|
+
function checkDirection(direction, fallback) {
|
|
133
|
+
const value = (direction ?? fallback);
|
|
134
|
+
if (!['up', 'down', 'left', 'right'].includes(value))
|
|
135
|
+
throw new Error(`direction must be up, down, left or right, not '${direction}'`);
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
async function pressKey(ctx, key) {
|
|
139
|
+
await mobile(ctx.client, 'pressKey', { keycode: androidKeyCode(key) });
|
|
140
|
+
}
|
|
141
|
+
/** Performs one android DSL action (or a session-only `back`/`home`). */
|
|
142
|
+
export async function runAndroidAction(ctx, action) {
|
|
143
|
+
const timeout = action.timeout_ms ?? ctx.defaultTimeoutMs;
|
|
144
|
+
const { client, appId } = ctx;
|
|
145
|
+
switch (action.type) {
|
|
146
|
+
case 'launch': {
|
|
147
|
+
if (action.activity) {
|
|
148
|
+
const activity = ctx.render(action.activity);
|
|
149
|
+
if (!/^[A-Za-z0-9_.$/]+$/.test(activity))
|
|
150
|
+
throw new Error(`'${activity}' is not an activity name`);
|
|
151
|
+
const component = activity.includes('/') ? activity : `${appId}/${activity.includes('.') ? activity : `.${activity}`}`;
|
|
152
|
+
await mobile(client, 'startActivity', { component, wait: true });
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
await mobile(client, 'activateApp', { appId });
|
|
156
|
+
}
|
|
157
|
+
return {};
|
|
158
|
+
}
|
|
159
|
+
case 'terminate':
|
|
160
|
+
await mobile(client, 'terminateApp', { appId });
|
|
161
|
+
return {};
|
|
162
|
+
case 'reset':
|
|
163
|
+
await mobile(client, 'clearApp', { appId });
|
|
164
|
+
await mobile(client, 'activateApp', { appId });
|
|
165
|
+
return {};
|
|
166
|
+
case 'deep_link': {
|
|
167
|
+
const url = checkDeepLink(ctx.render(action.url ?? ''));
|
|
168
|
+
try {
|
|
169
|
+
await mobile(client, 'deepLink', { url, package: appId, waitForLaunch: true });
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
throw new Error(`the deep link could not be opened: ${webdriverMessage(error)}`);
|
|
173
|
+
}
|
|
174
|
+
return {};
|
|
175
|
+
}
|
|
176
|
+
case 'tap': {
|
|
177
|
+
const found = await element(ctx, requireTarget(action), timeout);
|
|
178
|
+
await client.elementClick(found.elementId);
|
|
179
|
+
return {};
|
|
180
|
+
}
|
|
181
|
+
case 'double_tap': {
|
|
182
|
+
const found = await element(ctx, requireTarget(action), timeout);
|
|
183
|
+
await mobile(client, 'doubleClickGesture', { elementId: found.elementId });
|
|
184
|
+
return {};
|
|
185
|
+
}
|
|
186
|
+
case 'long_press': {
|
|
187
|
+
const ms = action.ms ?? 1_000;
|
|
188
|
+
if (ms < 300 || ms > 10_000)
|
|
189
|
+
throw new Error('long_press ms must be between 300 and 10000');
|
|
190
|
+
const found = await element(ctx, requireTarget(action), timeout);
|
|
191
|
+
await mobile(client, 'longClickGesture', { elementId: found.elementId, duration: ms });
|
|
192
|
+
return {};
|
|
193
|
+
}
|
|
194
|
+
case 'type': {
|
|
195
|
+
const found = await element(ctx, requireTarget(action), timeout);
|
|
196
|
+
if (action.clear)
|
|
197
|
+
await client.elementClear(found.elementId);
|
|
198
|
+
await client.elementSendKeys(found.elementId, ctx.render(action.value ?? ''));
|
|
199
|
+
return {};
|
|
200
|
+
}
|
|
201
|
+
case 'clear': {
|
|
202
|
+
const found = await element(ctx, requireTarget(action), timeout);
|
|
203
|
+
await client.elementClear(found.elementId);
|
|
204
|
+
return {};
|
|
205
|
+
}
|
|
206
|
+
case 'press':
|
|
207
|
+
await pressKey(ctx, action.key ?? '');
|
|
208
|
+
return {};
|
|
209
|
+
case 'back':
|
|
210
|
+
await pressKey(ctx, 'back');
|
|
211
|
+
return {};
|
|
212
|
+
case 'home':
|
|
213
|
+
await pressKey(ctx, 'home');
|
|
214
|
+
return {};
|
|
215
|
+
case 'hide_keyboard':
|
|
216
|
+
await mobile(client, 'hideKeyboard').catch((error) => {
|
|
217
|
+
// Nothing to hide is not a failure.
|
|
218
|
+
if (!/keyboard/i.test(webdriverMessage(error)))
|
|
219
|
+
throw error;
|
|
220
|
+
});
|
|
221
|
+
return {};
|
|
222
|
+
case 'swipe': {
|
|
223
|
+
const direction = checkDirection(action.direction, 'up');
|
|
224
|
+
const percent = swipeDistance(action.distance);
|
|
225
|
+
if (action.target) {
|
|
226
|
+
const found = await element(ctx, action.target, timeout);
|
|
227
|
+
await mobile(client, 'swipeGesture', { elementId: found.elementId, direction, percent });
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
const area = gestureArea(await client.getWindowRect());
|
|
231
|
+
await mobile(client, 'swipeGesture', { ...area, direction, percent });
|
|
232
|
+
}
|
|
233
|
+
return {};
|
|
234
|
+
}
|
|
235
|
+
case 'scroll_to': {
|
|
236
|
+
const target = requireTarget(action);
|
|
237
|
+
const direction = checkDirection(action.direction, 'down');
|
|
238
|
+
const maxSwipes = action.max_swipes ?? 10;
|
|
239
|
+
if (!Number.isInteger(maxSwipes) || maxSwipes < 1 || maxSwipes > 20)
|
|
240
|
+
throw new Error('max_swipes must be 1-20');
|
|
241
|
+
const area = gestureArea(await client.getWindowRect());
|
|
242
|
+
for (let swipe = 0;; swipe += 1) {
|
|
243
|
+
const found = await locate(client, target, 500, ctx.render);
|
|
244
|
+
if (found && (await client.isElementDisplayed(found.elementId).catch(() => false))) {
|
|
245
|
+
ctx.onLocated?.(found);
|
|
246
|
+
return {};
|
|
247
|
+
}
|
|
248
|
+
if (swipe >= maxSwipes)
|
|
249
|
+
break;
|
|
250
|
+
const more = await mobile(client, 'scrollGesture', { ...area, direction, percent: 0.7 });
|
|
251
|
+
if (more === false) {
|
|
252
|
+
// One last look after reaching the end.
|
|
253
|
+
const last = await locate(client, target, 500, ctx.render);
|
|
254
|
+
if (last) {
|
|
255
|
+
ctx.onLocated?.(last);
|
|
256
|
+
return {};
|
|
257
|
+
}
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
throw new Error(`${describeAndroidTarget(target)} did not appear after scrolling ${direction} ${maxSwipes} times`);
|
|
262
|
+
}
|
|
263
|
+
case 'set_orientation':
|
|
264
|
+
await client.setOrientation(orientationValue(action.value));
|
|
265
|
+
return {};
|
|
266
|
+
case 'accept_alert':
|
|
267
|
+
case 'dismiss_alert': {
|
|
268
|
+
const deadline = Date.now() + timeout;
|
|
269
|
+
for (;;) {
|
|
270
|
+
try {
|
|
271
|
+
if (action.type === 'accept_alert')
|
|
272
|
+
await client.acceptAlert();
|
|
273
|
+
else
|
|
274
|
+
await client.dismissAlert();
|
|
275
|
+
return {};
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (Date.now() >= deadline)
|
|
279
|
+
throw new Error(`no alert was showing: ${webdriverMessage(error)}`);
|
|
280
|
+
await sleep(300);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
case 'wait':
|
|
285
|
+
await sleep(Math.min(Math.max(action.ms ?? 0, 0), 30_000));
|
|
286
|
+
return {};
|
|
287
|
+
case 'screenshot':
|
|
288
|
+
return { screenshot: Buffer.from(await client.takeScreenshot(), 'base64') };
|
|
289
|
+
case 'wait_for':
|
|
290
|
+
case 'expect_visible': {
|
|
291
|
+
const target = requireTarget(action);
|
|
292
|
+
await expectWithin(timeout, async () => {
|
|
293
|
+
const found = await locate(client, target, 0, ctx.render);
|
|
294
|
+
if (!found)
|
|
295
|
+
return false;
|
|
296
|
+
const shown = await client.isElementDisplayed(found.elementId).catch(() => false);
|
|
297
|
+
if (shown)
|
|
298
|
+
ctx.onLocated?.(found);
|
|
299
|
+
return shown;
|
|
300
|
+
}, `${describeAndroidTarget(target)} to be visible`);
|
|
301
|
+
return {};
|
|
302
|
+
}
|
|
303
|
+
case 'expect_hidden': {
|
|
304
|
+
const target = requireTarget(action);
|
|
305
|
+
await expectWithin(timeout, async () => (await visibleCount(ctx, target)) === 0, `${describeAndroidTarget(target)} to be hidden`);
|
|
306
|
+
return {};
|
|
307
|
+
}
|
|
308
|
+
case 'expect_text': {
|
|
309
|
+
const target = requireTarget(action);
|
|
310
|
+
const expected = ctx.render(action.value ?? '');
|
|
311
|
+
let actual = '';
|
|
312
|
+
await expectWithin(timeout, async () => {
|
|
313
|
+
const found = await locate(client, target, 0, ctx.render);
|
|
314
|
+
if (!found) {
|
|
315
|
+
actual = '(not found)';
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
actual = await elementText(client, found.elementId);
|
|
319
|
+
return matches(actual, expected, action.match);
|
|
320
|
+
}, () => `text of ${describeAndroidTarget(target)} to ${verb(action.match)} "${expected}", found "${clip(actual)}"`);
|
|
321
|
+
return {};
|
|
322
|
+
}
|
|
323
|
+
case 'goto':
|
|
324
|
+
throw new Unsupported(`'goto' is not an android action; use deep_link`);
|
|
325
|
+
default:
|
|
326
|
+
throw new Unsupported(`'${action.type}' is not an android action`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Unsupported } from '../driver.js';
|
|
2
|
+
/**
|
|
3
|
+
* The desktop key rule for native macOS and Windows scripts (contracts §M, backend
|
|
4
|
+
* `KeyRule::Desktop`): a letter, digit, f1-f12 or a named key, optionally after `ctrl+`,
|
|
5
|
+
* `alt+`, `shift+` or `cmd+` (each at most once).
|
|
6
|
+
*/
|
|
7
|
+
export const DESKTOP_KEYS = [
|
|
8
|
+
'enter',
|
|
9
|
+
'return',
|
|
10
|
+
'tab',
|
|
11
|
+
'escape',
|
|
12
|
+
'backspace',
|
|
13
|
+
'delete',
|
|
14
|
+
'space',
|
|
15
|
+
'up',
|
|
16
|
+
'down',
|
|
17
|
+
'left',
|
|
18
|
+
'right',
|
|
19
|
+
'home',
|
|
20
|
+
'end',
|
|
21
|
+
'page_up',
|
|
22
|
+
'page_down',
|
|
23
|
+
'insert',
|
|
24
|
+
];
|
|
25
|
+
export const DESKTOP_MODIFIERS = ['ctrl', 'alt', 'shift', 'cmd'];
|
|
26
|
+
/** Parses `ctrl+shift+s` into modifiers and the key; throws `Unsupported` for anything else. */
|
|
27
|
+
export function parseDesktopKey(value) {
|
|
28
|
+
const raw = value.trim();
|
|
29
|
+
const parts = raw.split('+');
|
|
30
|
+
const main = (parts.pop() ?? '').trim();
|
|
31
|
+
const modifiers = [];
|
|
32
|
+
for (const part of parts) {
|
|
33
|
+
const name = part.trim().toLowerCase();
|
|
34
|
+
if (!DESKTOP_MODIFIERS.includes(name) || modifiers.includes(name))
|
|
35
|
+
throw new Unsupported(invalidKey(value));
|
|
36
|
+
modifiers.push(name);
|
|
37
|
+
}
|
|
38
|
+
const key = main.toLowerCase();
|
|
39
|
+
const single = /^[a-z0-9]$/.test(key);
|
|
40
|
+
const fn = /^f([1-9]|1[0-2])$/.test(key);
|
|
41
|
+
if (!single && !fn && !DESKTOP_KEYS.includes(key))
|
|
42
|
+
throw new Unsupported(invalidKey(value));
|
|
43
|
+
return { modifiers, key };
|
|
44
|
+
}
|
|
45
|
+
function invalidKey(value) {
|
|
46
|
+
return `'${value}' is not a desktop key; use a letter, digit, f1-f12 or ${DESKTOP_KEYS.join(', ')}, optionally after ctrl+, alt+, shift+ or cmd+`;
|
|
47
|
+
}
|
|
48
|
+
// ---------------------------------------------------------------------------------------------
|
|
49
|
+
// macOS (Mac2): `macos: keys {keys: [{key, modifierFlags}]}`
|
|
50
|
+
// ---------------------------------------------------------------------------------------------
|
|
51
|
+
/** XCUIKeyModifierFlags. `cmd` is Command, `alt` is Option. */
|
|
52
|
+
export const MAC_MODIFIER_FLAGS = {
|
|
53
|
+
shift: 1 << 1,
|
|
54
|
+
ctrl: 1 << 2,
|
|
55
|
+
alt: 1 << 3,
|
|
56
|
+
cmd: 1 << 4,
|
|
57
|
+
};
|
|
58
|
+
/** Named keys → the XCUIKeyboardKey constant names WebDriverAgentMac resolves. */
|
|
59
|
+
const MAC_KEY_NAMES = {
|
|
60
|
+
enter: 'XCUIKeyboardKeyReturn',
|
|
61
|
+
return: 'XCUIKeyboardKeyReturn',
|
|
62
|
+
tab: 'XCUIKeyboardKeyTab',
|
|
63
|
+
escape: 'XCUIKeyboardKeyEscape',
|
|
64
|
+
backspace: 'XCUIKeyboardKeyDelete',
|
|
65
|
+
delete: 'XCUIKeyboardKeyForwardDelete',
|
|
66
|
+
space: 'XCUIKeyboardKeySpace',
|
|
67
|
+
up: 'XCUIKeyboardKeyUpArrow',
|
|
68
|
+
down: 'XCUIKeyboardKeyDownArrow',
|
|
69
|
+
left: 'XCUIKeyboardKeyLeftArrow',
|
|
70
|
+
right: 'XCUIKeyboardKeyRightArrow',
|
|
71
|
+
home: 'XCUIKeyboardKeyHome',
|
|
72
|
+
end: 'XCUIKeyboardKeyEnd',
|
|
73
|
+
page_up: 'XCUIKeyboardKeyPageUp',
|
|
74
|
+
page_down: 'XCUIKeyboardKeyPageDown',
|
|
75
|
+
insert: 'XCUIKeyboardKeyHelp',
|
|
76
|
+
};
|
|
77
|
+
/** A chord as the Mac2 driver's `macos: keys` item. */
|
|
78
|
+
export function macKey(chord) {
|
|
79
|
+
const fn = /^f(\d+)$/.exec(chord.key);
|
|
80
|
+
const key = fn ? `XCUIKeyboardKeyF${fn[1]}` : (MAC_KEY_NAMES[chord.key] ?? chord.key);
|
|
81
|
+
const modifierFlags = chord.modifiers.reduce((flags, modifier) => flags | MAC_MODIFIER_FLAGS[modifier], 0);
|
|
82
|
+
return { key, modifierFlags };
|
|
83
|
+
}
|
|
84
|
+
// ---------------------------------------------------------------------------------------------
|
|
85
|
+
// Windows: `windows: keys {actions: [{virtualKeyCode, down} | {text}]}`
|
|
86
|
+
// ---------------------------------------------------------------------------------------------
|
|
87
|
+
/** Win32 virtual-key codes. `cmd` is the Windows key. */
|
|
88
|
+
export const WINDOWS_MODIFIER_VK = {
|
|
89
|
+
shift: 0x10,
|
|
90
|
+
ctrl: 0x11,
|
|
91
|
+
alt: 0x12,
|
|
92
|
+
cmd: 0x5b,
|
|
93
|
+
};
|
|
94
|
+
const WINDOWS_KEY_VK = {
|
|
95
|
+
enter: 0x0d,
|
|
96
|
+
return: 0x0d,
|
|
97
|
+
tab: 0x09,
|
|
98
|
+
escape: 0x1b,
|
|
99
|
+
backspace: 0x08,
|
|
100
|
+
delete: 0x2e,
|
|
101
|
+
space: 0x20,
|
|
102
|
+
up: 0x26,
|
|
103
|
+
down: 0x28,
|
|
104
|
+
left: 0x25,
|
|
105
|
+
right: 0x27,
|
|
106
|
+
home: 0x24,
|
|
107
|
+
end: 0x23,
|
|
108
|
+
page_up: 0x21,
|
|
109
|
+
page_down: 0x22,
|
|
110
|
+
insert: 0x2d,
|
|
111
|
+
};
|
|
112
|
+
/** The virtual-key code of a key: letters `A`-`Z` 0x41-0x5A, digits 0x30-0x39, F1-F12 0x70-0x7B. */
|
|
113
|
+
export function windowsVirtualKey(key) {
|
|
114
|
+
if (/^[a-z]$/.test(key))
|
|
115
|
+
return key.toUpperCase().charCodeAt(0);
|
|
116
|
+
if (/^[0-9]$/.test(key))
|
|
117
|
+
return key.charCodeAt(0);
|
|
118
|
+
const fn = /^f(\d+)$/.exec(key);
|
|
119
|
+
if (fn)
|
|
120
|
+
return 0x70 + Number(fn[1]) - 1;
|
|
121
|
+
const code = WINDOWS_KEY_VK[key];
|
|
122
|
+
if (code === undefined)
|
|
123
|
+
throw new Unsupported(invalidKey(key));
|
|
124
|
+
return code;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* A chord as `windows: keys` actions: modifiers down, the key down and up, modifiers up in
|
|
128
|
+
* reverse. A plain letter or digit is typed as text (keyboard layout independent).
|
|
129
|
+
*/
|
|
130
|
+
export function windowsKeyActions(chord) {
|
|
131
|
+
if (chord.modifiers.length === 0 && /^[a-z0-9]$/.test(chord.key))
|
|
132
|
+
return [{ text: chord.key }];
|
|
133
|
+
const vk = windowsVirtualKey(chord.key);
|
|
134
|
+
const mods = chord.modifiers.map((modifier) => WINDOWS_MODIFIER_VK[modifier]);
|
|
135
|
+
return [
|
|
136
|
+
...mods.map((virtualKeyCode) => ({ virtualKeyCode, down: true })),
|
|
137
|
+
{ virtualKeyCode: vk, down: true },
|
|
138
|
+
{ virtualKeyCode: vk, down: false },
|
|
139
|
+
...[...mods].reverse().map((virtualKeyCode) => ({ virtualKeyCode, down: false })),
|
|
140
|
+
];
|
|
141
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Runner-owned locator script for WebDriver-backed web views (Tauri, contracts §L).
|
|
3
|
+
* Executed with W3C `execute/sync` as `return (<this file>).apply(null, arguments);`, never
|
|
4
|
+
* with user-provided script. It takes one request object and returns plain JSON plus DOM
|
|
5
|
+
* elements, which WebDriver serializes as element references.
|
|
6
|
+
*
|
|
7
|
+
* request: { target: { css | text | test_id | xpath, exact? }, limit? }
|
|
8
|
+
* returns: { count, elements: Element[] (at most `limit`), visible: boolean[] }
|
|
9
|
+
* throws: Error('invalid css selector: …' | 'invalid xpath: …' | 'the target names no locator')
|
|
10
|
+
*/
|
|
11
|
+
(function domLocate(request) {
|
|
12
|
+
const target = (request?.target) || {};
|
|
13
|
+
const limit = Math.max(1, Math.min(1000, Number((request?.limit) || 50)));
|
|
14
|
+
const SKIP = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEMPLATE: 1, HEAD: 1, META: 1, LINK: 1, TITLE: 1 };
|
|
15
|
+
|
|
16
|
+
function normalize(value) {
|
|
17
|
+
return String(value == null ? '' : value)
|
|
18
|
+
.replace(/\s+/g, ' ')
|
|
19
|
+
.trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The text a text locator compares: button-like inputs use their value. */
|
|
23
|
+
function textOf(element) {
|
|
24
|
+
if (element.tagName === 'INPUT') {
|
|
25
|
+
const type = (element.getAttribute('type') || '').toLowerCase();
|
|
26
|
+
return type === 'button' || type === 'submit' || type === 'reset' ? normalize(element.value) : '';
|
|
27
|
+
}
|
|
28
|
+
return normalize(element.textContent);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function textMatches(actual, wanted, exact) {
|
|
32
|
+
if (!actual) return false;
|
|
33
|
+
if (exact) return actual === wanted;
|
|
34
|
+
return actual.toLowerCase().indexOf(wanted.toLowerCase()) >= 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isVisible(element) {
|
|
38
|
+
if (typeof element.checkVisibility === 'function') {
|
|
39
|
+
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false;
|
|
40
|
+
} else {
|
|
41
|
+
const style = window.getComputedStyle(element);
|
|
42
|
+
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return false;
|
|
43
|
+
}
|
|
44
|
+
const rect = element.getBoundingClientRect();
|
|
45
|
+
return rect.width > 0 && rect.height > 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function byText(wanted, exact) {
|
|
49
|
+
const value = normalize(wanted);
|
|
50
|
+
const out = [];
|
|
51
|
+
if (!value || !document.body) return out;
|
|
52
|
+
const all = document.body.querySelectorAll('*');
|
|
53
|
+
for (let i = 0; i < all.length; i += 1) {
|
|
54
|
+
const element = all[i];
|
|
55
|
+
if (SKIP[element.tagName]) continue;
|
|
56
|
+
if (!textMatches(textOf(element), value, exact)) continue;
|
|
57
|
+
// Innermost match only: skip an element when one of its element children matches too.
|
|
58
|
+
let inner = false;
|
|
59
|
+
for (let child = element.firstElementChild; child; child = child.nextElementSibling) {
|
|
60
|
+
if (!SKIP[child.tagName] && textMatches(textOf(child), value, exact)) {
|
|
61
|
+
inner = true;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!inner) out.push(element);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function byCss(selector) {
|
|
71
|
+
try {
|
|
72
|
+
return Array.prototype.slice.call(document.querySelectorAll(selector));
|
|
73
|
+
} catch {
|
|
74
|
+
throw new Error(`invalid css selector: ${selector}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function byXpath(expression) {
|
|
79
|
+
let result;
|
|
80
|
+
try {
|
|
81
|
+
result = document.evaluate(expression, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
82
|
+
} catch {
|
|
83
|
+
throw new Error(`invalid xpath: ${expression}`);
|
|
84
|
+
}
|
|
85
|
+
const out = [];
|
|
86
|
+
for (let i = 0; i < result.snapshotLength; i += 1) {
|
|
87
|
+
const node = result.snapshotItem(i);
|
|
88
|
+
if (node && node.nodeType === 1) out.push(node);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function byTestId(value) {
|
|
94
|
+
const out = [];
|
|
95
|
+
const all = document.querySelectorAll('[data-testid]');
|
|
96
|
+
for (let i = 0; i < all.length; i += 1) {
|
|
97
|
+
if (all[i].getAttribute('data-testid') === value) out.push(all[i]);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let found;
|
|
103
|
+
if (typeof target.css === 'string') found = byCss(target.css);
|
|
104
|
+
else if (typeof target.text === 'string') found = byText(target.text, target.exact === true);
|
|
105
|
+
else if (typeof target.test_id === 'string') found = byTestId(target.test_id);
|
|
106
|
+
else if (typeof target.xpath === 'string') found = byXpath(target.xpath);
|
|
107
|
+
else throw new Error('the target names no locator');
|
|
108
|
+
|
|
109
|
+
const elements = found.slice(0, limit);
|
|
110
|
+
const visible = [];
|
|
111
|
+
for (let j = 0; j < elements.length; j += 1) visible.push(isVisible(elements[j]));
|
|
112
|
+
return { count: found.length, elements: elements, visible: visible };
|
|
113
|
+
})
|