@testingbot/mobilewright-driver 0.1.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 +202 -0
- package/README.md +211 -0
- package/dist/actions.d.ts +25 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +76 -0
- package/dist/actions.js.map +1 -0
- package/dist/app-storage.d.ts +20 -0
- package/dist/app-storage.d.ts.map +1 -0
- package/dist/app-storage.js +68 -0
- package/dist/app-storage.js.map +1 -0
- package/dist/capabilities.d.ts +27 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +120 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/ci.d.ts +8 -0
- package/dist/ci.d.ts.map +1 -0
- package/dist/ci.js +46 -0
- package/dist/ci.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +43 -0
- package/dist/cli.js.map +1 -0
- package/dist/device-catalog.d.ts +37 -0
- package/dist/device-catalog.d.ts.map +1 -0
- package/dist/device-catalog.js +159 -0
- package/dist/device-catalog.js.map +1 -0
- package/dist/driver.d.ts +80 -0
- package/dist/driver.d.ts.map +1 -0
- package/dist/driver.js +646 -0
- package/dist/driver.js.map +1 -0
- package/dist/errors.d.ts +41 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +104 -0
- package/dist/errors.js.map +1 -0
- package/dist/hierarchy.d.ts +10 -0
- package/dist/hierarchy.d.ts.map +1 -0
- package/dist/hierarchy.js +103 -0
- package/dist/hierarchy.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +19 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +108 -0
- package/dist/init.js.map +1 -0
- package/dist/keepalive.d.ts +16 -0
- package/dist/keepalive.d.ts.map +1 -0
- package/dist/keepalive.js +56 -0
- package/dist/keepalive.js.map +1 -0
- package/dist/keys.d.ts +32 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +117 -0
- package/dist/keys.js.map +1 -0
- package/dist/observer.d.ts +55 -0
- package/dist/observer.d.ts.map +1 -0
- package/dist/observer.js +148 -0
- package/dist/observer.js.map +1 -0
- package/dist/options.d.ts +154 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +49 -0
- package/dist/options.js.map +1 -0
- package/dist/rest-api.d.ts +67 -0
- package/dist/rest-api.d.ts.map +1 -0
- package/dist/rest-api.js +117 -0
- package/dist/rest-api.js.map +1 -0
- package/dist/run-log.d.ts +56 -0
- package/dist/run-log.d.ts.map +1 -0
- package/dist/run-log.js +72 -0
- package/dist/run-log.js.map +1 -0
- package/dist/tunnel.d.ts +13 -0
- package/dist/tunnel.d.ts.map +1 -0
- package/dist/tunnel.js +47 -0
- package/dist/tunnel.js.map +1 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/dist/webdriver-client.d.ts +43 -0
- package/dist/webdriver-client.d.ts.map +1 -0
- package/dist/webdriver-client.js +109 -0
- package/dist/webdriver-client.js.map +1 -0
- package/dist/webview.d.ts +16 -0
- package/dist/webview.d.ts.map +1 -0
- package/dist/webview.js +109 -0
- package/dist/webview.js.map +1 -0
- package/package.json +69 -0
package/dist/driver.js
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import createDebug from 'debug';
|
|
3
|
+
import { NoDeviceAvailableError, } from '@mobilewright/protocol';
|
|
4
|
+
import { doubleTapActions, gestureActions, longPressActions, swipeActions, tapActions, } from './actions.js';
|
|
5
|
+
import { AppStorage } from './app-storage.js';
|
|
6
|
+
import { appForCriteria, buildCapabilities } from './capabilities.js';
|
|
7
|
+
import { DeviceCatalog } from './device-catalog.js';
|
|
8
|
+
import { SessionNotActiveError, toAllocationError, withAllocationContext } from './errors.js';
|
|
9
|
+
import { Keepalive } from './keepalive.js';
|
|
10
|
+
import { parseChord, toAndroidKeyPress, toW3CKeyActions, typeTextActions } from './keys.js';
|
|
11
|
+
import { TestingBotTestObserver } from './observer.js';
|
|
12
|
+
import { requireCredentials, resolveOptions } from './options.js';
|
|
13
|
+
import { RestApiClient } from './rest-api.js';
|
|
14
|
+
import { RunLog } from './run-log.js';
|
|
15
|
+
import { TunnelManager } from './tunnel.js';
|
|
16
|
+
import { AppiumWebViewBridge } from './webview.js';
|
|
17
|
+
import { WebDriverClient } from './webdriver-client.js';
|
|
18
|
+
const debug = createDebug('testingbot:driver');
|
|
19
|
+
const ANDROID_BUTTON_KEYCODES = {
|
|
20
|
+
HOME: 3,
|
|
21
|
+
BACK: 4,
|
|
22
|
+
POWER: 26,
|
|
23
|
+
VOLUME_UP: 24,
|
|
24
|
+
VOLUME_DOWN: 25,
|
|
25
|
+
ENTER: 66,
|
|
26
|
+
DPAD_UP: 19,
|
|
27
|
+
DPAD_DOWN: 20,
|
|
28
|
+
DPAD_LEFT: 21,
|
|
29
|
+
DPAD_RIGHT: 22,
|
|
30
|
+
DPAD_CENTER: 23,
|
|
31
|
+
APP_SWITCH: 187,
|
|
32
|
+
LOCK: 26, // same as POWER — locks the screen
|
|
33
|
+
};
|
|
34
|
+
// XCUITest's mobile: pressButton supports exactly these names on iOS.
|
|
35
|
+
const IOS_BUTTON_NAMES = {
|
|
36
|
+
HOME: 'home',
|
|
37
|
+
VOLUME_UP: 'volumeup',
|
|
38
|
+
VOLUME_DOWN: 'volumedown',
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Mobilewright driver for TestingBot's device cloud.
|
|
42
|
+
*
|
|
43
|
+
* The WebDriver session is the allocation: `allocate()` creates an Appium
|
|
44
|
+
* session on hub.testingbot.com and hands its UUID to the device pool as the
|
|
45
|
+
* deviceId; worker processes `connect()` to that UUID over plain stateless
|
|
46
|
+
* HTTP. `disconnect()` only detaches (the pool re-grants slots without
|
|
47
|
+
* re-allocating) — the session actually ends in `release()`/`dispose()`.
|
|
48
|
+
*/
|
|
49
|
+
export class TestingBotDriver {
|
|
50
|
+
observer;
|
|
51
|
+
/** WebView support via Appium contexts (its presence signals capability). */
|
|
52
|
+
webViewBridge;
|
|
53
|
+
options;
|
|
54
|
+
hub;
|
|
55
|
+
api;
|
|
56
|
+
catalog;
|
|
57
|
+
storage;
|
|
58
|
+
keepalive;
|
|
59
|
+
runLog;
|
|
60
|
+
tunnel = new TunnelManager();
|
|
61
|
+
/** Sessions created by this instance's allocate() (coordinator process). */
|
|
62
|
+
allocated = new Map();
|
|
63
|
+
/** Every session ever allocated this run — release() does NOT remove
|
|
64
|
+
* entries, because the observer must report sessions the pool released
|
|
65
|
+
* before onRunEnd fires (which, with Playwright's teardown ordering, is
|
|
66
|
+
* all of them). */
|
|
67
|
+
everAllocated = new Map();
|
|
68
|
+
/** Refcount of real-device names pinned by in-flight/live allocations. */
|
|
69
|
+
pinCounts = new Map();
|
|
70
|
+
/** The session this instance is connected to (worker process). */
|
|
71
|
+
active;
|
|
72
|
+
/** sessionPerTest: pool slots whose original session this instance has already probed/used. */
|
|
73
|
+
rotatedDeviceIds = new Set();
|
|
74
|
+
constructor(options = {}) {
|
|
75
|
+
this.options = resolveOptions(options);
|
|
76
|
+
this.hub = new WebDriverClient(this.options.hubUrl, this.options.commandTimeout);
|
|
77
|
+
this.api = new RestApiClient(this.options.apiUrl, this.options.key, this.options.secret);
|
|
78
|
+
this.catalog = new DeviceCatalog(this.api);
|
|
79
|
+
this.storage = new AppStorage(this.api);
|
|
80
|
+
this.keepalive = new Keepalive(this.hub);
|
|
81
|
+
this.runLog = new RunLog();
|
|
82
|
+
this.webViewBridge = new AppiumWebViewBridge(this.hub, () => this.session().sessionId);
|
|
83
|
+
this.observer = this.options.testResults === 'off'
|
|
84
|
+
? undefined
|
|
85
|
+
: new TestingBotTestObserver(this.api, {
|
|
86
|
+
// Union of coordinator-allocated sessions and (with sessionPerTest)
|
|
87
|
+
// the per-test sessions workers logged to the shared run file;
|
|
88
|
+
// a timed worker record wins over the coordinator's untimed one.
|
|
89
|
+
sessions: () => {
|
|
90
|
+
const byId = new Map([...this.everAllocated.keys()].map((sessionId) => [sessionId, { sessionId, spans: 0 }]));
|
|
91
|
+
for (const record of this.runLog.sessions())
|
|
92
|
+
byId.set(record.sessionId, record);
|
|
93
|
+
return [...byId.values()];
|
|
94
|
+
},
|
|
95
|
+
build: this.options.build,
|
|
96
|
+
recordings: () => this.runLog.recordings(),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// ─── DeviceAllocator ────────────────────────────────────────────
|
|
100
|
+
async prepare() {
|
|
101
|
+
requireCredentials(this.options);
|
|
102
|
+
this.runLog.truncate(); // drop session/app records from any previous run
|
|
103
|
+
await this.api.getUser();
|
|
104
|
+
debug('credentials verified against %s', this.options.apiUrl);
|
|
105
|
+
await this.tunnel.start(this.options);
|
|
106
|
+
}
|
|
107
|
+
async allocate(criteria, _takenDeviceIds, signal) {
|
|
108
|
+
requireCredentials(this.options);
|
|
109
|
+
// takenDeviceIds is unused: every allocate() creates a fresh, exclusive
|
|
110
|
+
// WebDriver session; physical-device contention is handled server-side.
|
|
111
|
+
let pinned;
|
|
112
|
+
let reservedName;
|
|
113
|
+
if (criteria.deviceId) {
|
|
114
|
+
pinned = await this.catalog.resolveDeviceId(criteria.deviceId);
|
|
115
|
+
}
|
|
116
|
+
else if (criteria.deviceType === 'real') {
|
|
117
|
+
// pickRealDevice increments pinCounts synchronously before returning,
|
|
118
|
+
// so concurrent allocates (the pool fires them without awaiting) see
|
|
119
|
+
// each other's picks and spread across devices.
|
|
120
|
+
pinned = await this.catalog.pickRealDevice(criteria, this.pinCounts);
|
|
121
|
+
reservedName = pinned.deviceName;
|
|
122
|
+
}
|
|
123
|
+
else if (criteria.osVersion || criteria.deviceNamePattern) {
|
|
124
|
+
// Virtual devices: resolve osVersion ranges and deviceName regexes
|
|
125
|
+
// against the simulator/emulator catalog (GET /v1/browsers) — the hub's
|
|
126
|
+
// own name matching is not regex-based for virtual devices.
|
|
127
|
+
pinned = await this.catalog.pickVirtualDevice(criteria);
|
|
128
|
+
}
|
|
129
|
+
// TestingBot sessions must start with an app — resolve and upload the
|
|
130
|
+
// configured one now so it can ride along in the allocation caps.
|
|
131
|
+
let appUrl;
|
|
132
|
+
const configuredApp = appForCriteria(criteria, this.options.apps);
|
|
133
|
+
if (configuredApp) {
|
|
134
|
+
appUrl = isRemoteApp(configuredApp)
|
|
135
|
+
? configuredApp
|
|
136
|
+
: (await this.storage.ensureUploaded(configuredApp, criteria.deviceType)).app_url;
|
|
137
|
+
if (this.options.sessionPerTest && criteria.platform) {
|
|
138
|
+
// Workers rotating sessions per test reuse this upload instead of
|
|
139
|
+
// re-uploading the binary once per worker process.
|
|
140
|
+
this.runLog.append({ type: 'app', platform: criteria.platform, deviceType: criteria.deviceType, url: appUrl });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const capabilities = buildCapabilities(criteria, this.options, pinned, appUrl);
|
|
144
|
+
debug('allocating: %o', { ...capabilities.alwaysMatch, 'tb:options': '[redacted]' });
|
|
145
|
+
if (signal?.aborted) {
|
|
146
|
+
throw new NoDeviceAvailableError('allocation aborted before it started');
|
|
147
|
+
}
|
|
148
|
+
// POST /session queues inside the hub until a matching device frees up
|
|
149
|
+
// (up to its 390s queue TTL). Aborting the socket is the correct way to
|
|
150
|
+
// give up: the hub's 'aborted' handler removes the pending queue entry,
|
|
151
|
+
// so nothing leaks. The one race — the response arriving just as the
|
|
152
|
+
// pool aborts — is handled by deleting the session that slipped through.
|
|
153
|
+
let sessionId;
|
|
154
|
+
let caps;
|
|
155
|
+
try {
|
|
156
|
+
const controller = new AbortController();
|
|
157
|
+
const onAbort = () => controller.abort();
|
|
158
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
159
|
+
try {
|
|
160
|
+
({ sessionId, capabilities: caps } = await this.hub.newSession(capabilities, {
|
|
161
|
+
timeout: this.options.allocationTimeout,
|
|
162
|
+
signal: controller.signal,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
if (signal?.aborted) {
|
|
167
|
+
debug('allocation aborted while queued — hub dropped the pending request');
|
|
168
|
+
throw new NoDeviceAvailableError('allocation aborted');
|
|
169
|
+
}
|
|
170
|
+
throw withAllocationContext(toAllocationError(err), describeAllocation(criteria));
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
signal?.removeEventListener('abort', onAbort);
|
|
174
|
+
}
|
|
175
|
+
if (signal?.aborted) {
|
|
176
|
+
debug('allocation resolved as the pool aborted, deleting session %s', sessionId);
|
|
177
|
+
await this.hub.deleteSession(sessionId)
|
|
178
|
+
.catch(() => this.api.stopTest(sessionId).catch(() => { }));
|
|
179
|
+
throw new NoDeviceAvailableError('allocation aborted');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
if (reservedName)
|
|
184
|
+
this.unpin(reservedName);
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
const platform = criteria.platform ?? (String(caps['platformName']).toLowerCase() === 'ios' ? 'ios' : 'android');
|
|
188
|
+
const session = {
|
|
189
|
+
sessionId,
|
|
190
|
+
platform,
|
|
191
|
+
deviceName: pinned?.deviceName ?? str(caps['appium:deviceName'] ?? caps['deviceName']),
|
|
192
|
+
osVersion: pinned?.platformVersion ?? str(caps['appium:platformVersion'] ?? caps['platformVersion']),
|
|
193
|
+
pinnedName: reservedName,
|
|
194
|
+
};
|
|
195
|
+
this.allocated.set(sessionId, session);
|
|
196
|
+
this.everAllocated.set(sessionId, session);
|
|
197
|
+
this.keepalive.start(sessionId);
|
|
198
|
+
debug('allocated session %s (%s %s) — https://testingbot.com/members/tests/%s', sessionId, session.deviceName, session.osVersion, sessionId);
|
|
199
|
+
return {
|
|
200
|
+
deviceId: sessionId,
|
|
201
|
+
platform,
|
|
202
|
+
driver: 'testingbot',
|
|
203
|
+
model: session.deviceName,
|
|
204
|
+
osVersion: session.osVersion,
|
|
205
|
+
type: criteria.deviceType,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
async release(deviceId) {
|
|
209
|
+
this.keepalive.stop(deviceId);
|
|
210
|
+
const session = this.allocated.get(deviceId);
|
|
211
|
+
this.allocated.delete(deviceId);
|
|
212
|
+
if (session?.pinnedName)
|
|
213
|
+
this.unpin(session.pinnedName);
|
|
214
|
+
try {
|
|
215
|
+
await this.hub.deleteSession(deviceId);
|
|
216
|
+
debug('released session %s', deviceId);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
debug('DELETE /session/%s failed (%s), trying REST stop', deviceId, err);
|
|
220
|
+
await this.api.stopTest(deviceId).catch(() => { });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async dispose() {
|
|
224
|
+
this.keepalive.stopAll();
|
|
225
|
+
const leftover = [...this.allocated.keys()];
|
|
226
|
+
for (const session of this.allocated.values()) {
|
|
227
|
+
if (session.pinnedName)
|
|
228
|
+
this.unpin(session.pinnedName);
|
|
229
|
+
}
|
|
230
|
+
this.allocated.clear();
|
|
231
|
+
await Promise.all(leftover.map((id) => this.hub.deleteSession(id).catch(() => this.api.stopTest(id).catch(() => { }))));
|
|
232
|
+
if (leftover.length)
|
|
233
|
+
debug('disposed %d leftover session(s)', leftover.length);
|
|
234
|
+
await this.tunnel.stop();
|
|
235
|
+
}
|
|
236
|
+
// ─── MobilewrightSession: connection ───────────────────────────
|
|
237
|
+
async connect(config) {
|
|
238
|
+
const poolDeviceId = config.deviceId;
|
|
239
|
+
if (!poolDeviceId) {
|
|
240
|
+
throw new Error('TestingBotDriver.connect requires a deviceId (the WebDriver session UUID from allocate()). ' +
|
|
241
|
+
'Run through "mobilewright test" so the device pool allocates first.');
|
|
242
|
+
}
|
|
243
|
+
const timeout = config.timeout ?? this.options.commandTimeout;
|
|
244
|
+
if (!this.options.sessionPerTest) {
|
|
245
|
+
// Liveness probe — also validates the id belongs to a live TB session.
|
|
246
|
+
await this.hub.get(poolDeviceId, '/orientation', { timeout });
|
|
247
|
+
this.active = { sessionId: poolDeviceId, platform: config.platform, deviceType: config.deviceType, connectedAt: Date.now() };
|
|
248
|
+
debug('connected to session %s', poolDeviceId);
|
|
249
|
+
return { deviceId: poolDeviceId, platform: config.platform };
|
|
250
|
+
}
|
|
251
|
+
// sessionPerTest: the first test on this slot uses the session allocate()
|
|
252
|
+
// created; every later connect starts a fresh one (the previous test's
|
|
253
|
+
// session was deleted in disconnect()). The original may also already be
|
|
254
|
+
// gone when another worker process ran the slot's first test — then the
|
|
255
|
+
// probe fails and we rotate too.
|
|
256
|
+
let sessionId;
|
|
257
|
+
if (!this.rotatedDeviceIds.has(poolDeviceId)) {
|
|
258
|
+
try {
|
|
259
|
+
await this.hub.get(poolDeviceId, '/orientation', { timeout });
|
|
260
|
+
sessionId = poolDeviceId;
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
debug('slot %s has no live session, starting a fresh one', poolDeviceId);
|
|
264
|
+
}
|
|
265
|
+
this.rotatedDeviceIds.add(poolDeviceId);
|
|
266
|
+
}
|
|
267
|
+
sessionId ??= await this.startFreshSession(config);
|
|
268
|
+
this.active = { sessionId, platform: config.platform, deviceType: config.deviceType, connectedAt: Date.now() };
|
|
269
|
+
debug('connected to session %s (slot %s)', sessionId, poolDeviceId);
|
|
270
|
+
return { deviceId: poolDeviceId, platform: config.platform };
|
|
271
|
+
}
|
|
272
|
+
async disconnect() {
|
|
273
|
+
const session = this.active;
|
|
274
|
+
this.active = undefined;
|
|
275
|
+
if (!session)
|
|
276
|
+
return;
|
|
277
|
+
if (!this.options.sessionPerTest) {
|
|
278
|
+
// Soft detach only: the pool re-grants this slot to the next test and
|
|
279
|
+
// ends the session via release(). Still record this test's interval —
|
|
280
|
+
// a pooled session that hosted exactly one test can then be renamed to
|
|
281
|
+
// that test at run end.
|
|
282
|
+
this.runLog.append({
|
|
283
|
+
type: 'session',
|
|
284
|
+
sessionId: session.sessionId,
|
|
285
|
+
startedAt: session.connectedAt,
|
|
286
|
+
endedAt: Date.now(),
|
|
287
|
+
});
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
// sessionPerTest: this test's session ends here; the next connect starts
|
|
291
|
+
// a fresh one. The timed record lets the observer attribute this test's
|
|
292
|
+
// verdict to exactly this session. release()/dispose() failures on the
|
|
293
|
+
// long-gone original pool session are swallowed there.
|
|
294
|
+
this.runLog.append({
|
|
295
|
+
type: 'session',
|
|
296
|
+
sessionId: session.sessionId,
|
|
297
|
+
startedAt: session.connectedAt,
|
|
298
|
+
endedAt: Date.now(),
|
|
299
|
+
});
|
|
300
|
+
await this.hub.deleteSession(session.sessionId)
|
|
301
|
+
.catch(() => this.api.stopTest(session.sessionId).catch(() => { }));
|
|
302
|
+
debug('ended per-test session %s', session.sessionId);
|
|
303
|
+
}
|
|
304
|
+
/** Create a new hub session for this slot from the connect() config. */
|
|
305
|
+
async startFreshSession(config) {
|
|
306
|
+
const criteria = {
|
|
307
|
+
platform: config.platform,
|
|
308
|
+
deviceType: config.deviceType,
|
|
309
|
+
osVersion: config.osVersion,
|
|
310
|
+
deviceNamePattern: config.deviceName instanceof RegExp ? config.deviceName.source : config.deviceName,
|
|
311
|
+
};
|
|
312
|
+
let pinned;
|
|
313
|
+
if (criteria.deviceType === 'real') {
|
|
314
|
+
pinned = await this.catalog.pickRealDevice(criteria, this.pinCounts);
|
|
315
|
+
}
|
|
316
|
+
else if (criteria.osVersion || criteria.deviceNamePattern) {
|
|
317
|
+
pinned = await this.catalog.pickVirtualDevice(criteria);
|
|
318
|
+
}
|
|
319
|
+
// Prefer the app the coordinator already uploaded for this platform.
|
|
320
|
+
let appUrl = criteria.platform ? this.runLog.appUrlFor(criteria.platform, criteria.deviceType) : undefined;
|
|
321
|
+
if (!appUrl) {
|
|
322
|
+
const configuredApp = appForCriteria(criteria, this.options.apps);
|
|
323
|
+
if (configuredApp) {
|
|
324
|
+
appUrl = isRemoteApp(configuredApp)
|
|
325
|
+
? configuredApp
|
|
326
|
+
: (await this.storage.ensureUploaded(configuredApp, criteria.deviceType)).app_url;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const capabilities = buildCapabilities(criteria, this.options, pinned, appUrl);
|
|
330
|
+
const { sessionId } = await this.hub.newSession(capabilities, { timeout: this.options.allocationTimeout });
|
|
331
|
+
this.runLog.append({ type: 'session', sessionId });
|
|
332
|
+
debug('started per-test session %s — https://testingbot.com/members/tests/%s', sessionId, sessionId);
|
|
333
|
+
return sessionId;
|
|
334
|
+
}
|
|
335
|
+
// ─── MobilewrightSession: device settings ──────────────────────
|
|
336
|
+
/**
|
|
337
|
+
* Best-effort, fire-and-forget per the protocol: Android animation scales
|
|
338
|
+
* via `mobile: shell` (may be unavailable depending on TestingBot's Appium
|
|
339
|
+
* feature flags — failures are logged, never thrown); iOS has no equivalent.
|
|
340
|
+
*/
|
|
341
|
+
async applyDeviceSettings(settings) {
|
|
342
|
+
const { sessionId, platform } = this.session();
|
|
343
|
+
if (platform !== 'android' || settings.animations === undefined)
|
|
344
|
+
return;
|
|
345
|
+
const value = settings.animations === 'off' ? '0' : '1';
|
|
346
|
+
for (const key of ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale']) {
|
|
347
|
+
try {
|
|
348
|
+
await this.hub.execute(sessionId, 'mobile: shell', [{
|
|
349
|
+
command: 'settings',
|
|
350
|
+
args: ['put', 'global', key, value],
|
|
351
|
+
}]);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
debug('applyDeviceSettings: could not set %s (%s)', key, err);
|
|
355
|
+
return; // same failure for the remaining keys — stop early
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
debug('animations turned %s', settings.animations);
|
|
359
|
+
}
|
|
360
|
+
// ─── MobilewrightSession: UI hierarchy ─────────────────────────
|
|
361
|
+
async getViewHierarchy() {
|
|
362
|
+
const { sessionId, platform } = this.session();
|
|
363
|
+
const xml = await this.hub.source(sessionId);
|
|
364
|
+
const { parseHierarchy } = await import('./hierarchy.js');
|
|
365
|
+
return parseHierarchy(xml, platform);
|
|
366
|
+
}
|
|
367
|
+
// ─── MobilewrightSession: input ────────────────────────────────
|
|
368
|
+
async tap(x, y) {
|
|
369
|
+
await this.hub.performActions(this.session().sessionId, tapActions(x, y));
|
|
370
|
+
}
|
|
371
|
+
async doubleTap(x, y) {
|
|
372
|
+
await this.hub.performActions(this.session().sessionId, doubleTapActions(x, y));
|
|
373
|
+
}
|
|
374
|
+
async longPress(x, y, duration) {
|
|
375
|
+
await this.hub.performActions(this.session().sessionId, longPressActions(x, y, duration));
|
|
376
|
+
}
|
|
377
|
+
async typeText(text) {
|
|
378
|
+
const { sessionId, platform } = this.session();
|
|
379
|
+
if (platform === 'android') {
|
|
380
|
+
// UiAutomator2's typing extension handles unicode; W3C key actions don't.
|
|
381
|
+
await this.hub.execute(sessionId, 'mobile: type', [{ text }]);
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
await this.hub.performActions(sessionId, typeTextActions(text));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async pressKeys(keys) {
|
|
388
|
+
const { sessionId, platform } = this.session();
|
|
389
|
+
const chords = keys.map(parseChord);
|
|
390
|
+
if (platform === 'android') {
|
|
391
|
+
for (const chord of chords) {
|
|
392
|
+
await this.hub.execute(sessionId, 'mobile: pressKey', [toAndroidKeyPress(chord)]);
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// XCUITest/WDA dispatches complete key events only — held modifiers in
|
|
397
|
+
// W3C key actions do not combine into chords on iOS.
|
|
398
|
+
const chorded = chords.find((c) => c.modifiers.length > 0);
|
|
399
|
+
if (chorded) {
|
|
400
|
+
throw new Error(`TestingBotDriver: modifier chords ("${chorded.modifiers.join('+')}+${chorded.key}") are not ` +
|
|
401
|
+
'supported on iOS — XCUITest cannot hold modifier keys. Press single keys instead.');
|
|
402
|
+
}
|
|
403
|
+
await this.hub.performActions(sessionId, toW3CKeyActions(chords));
|
|
404
|
+
}
|
|
405
|
+
async clearText() {
|
|
406
|
+
const { sessionId, platform } = this.session();
|
|
407
|
+
if (platform === 'android') {
|
|
408
|
+
// Select-all + delete on the focused field.
|
|
409
|
+
await this.pressKeys(['ctrl+a', 'backspace']);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
// iOS has no working select-all chord; delete the focused field's value
|
|
413
|
+
// one backspace per character (hierarchy tells us how many).
|
|
414
|
+
const { parseHierarchy } = await import('./hierarchy.js');
|
|
415
|
+
const nodes = parseHierarchy(await this.hub.source(sessionId), 'ios');
|
|
416
|
+
const focused = findFocused(nodes);
|
|
417
|
+
const length = focused?.value?.length ?? 40;
|
|
418
|
+
if (length > 0) {
|
|
419
|
+
await this.hub.performActions(sessionId, toW3CKeyActions(Array.from({ length }, () => ({ modifiers: [], key: 'backspace' }))));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
async swipe(direction, opts) {
|
|
423
|
+
const { sessionId } = this.session();
|
|
424
|
+
const screen = await this.getScreenSize();
|
|
425
|
+
await this.hub.performActions(sessionId, swipeActions(direction, screen, opts));
|
|
426
|
+
}
|
|
427
|
+
async gesture(gestures) {
|
|
428
|
+
await this.hub.performActions(this.session().sessionId, gestureActions(gestures));
|
|
429
|
+
}
|
|
430
|
+
async pressButton(button) {
|
|
431
|
+
const { sessionId, platform } = this.session();
|
|
432
|
+
if (platform === 'android') {
|
|
433
|
+
await this.hub.execute(sessionId, 'mobile: pressKey', [{ keycode: ANDROID_BUTTON_KEYCODES[button] }]);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const name = IOS_BUTTON_NAMES[button];
|
|
437
|
+
if (!name) {
|
|
438
|
+
throw new Error(`TestingBotDriver: hardware button "${button}" is not supported on iOS`);
|
|
439
|
+
}
|
|
440
|
+
await this.hub.execute(sessionId, 'mobile: pressButton', [{ name }]);
|
|
441
|
+
}
|
|
442
|
+
// ─── MobilewrightSession: screen ───────────────────────────────
|
|
443
|
+
async screenshot(opts) {
|
|
444
|
+
const { sessionId } = this.session();
|
|
445
|
+
// TestingBot/Appium produce PNG; a jpeg format request falls back to PNG.
|
|
446
|
+
const base64 = await this.hub.screenshot(sessionId);
|
|
447
|
+
const buffer = Buffer.from(base64, 'base64');
|
|
448
|
+
if (opts?.path)
|
|
449
|
+
await writeFile(opts.path, buffer);
|
|
450
|
+
return buffer;
|
|
451
|
+
}
|
|
452
|
+
async getScreenSize() {
|
|
453
|
+
const session = this.session();
|
|
454
|
+
if (session.screenSize)
|
|
455
|
+
return session.screenSize;
|
|
456
|
+
const rect = await this.hub.get(session.sessionId, '/window/rect');
|
|
457
|
+
// scale = physical pixels / logical units: derived from one screenshot's
|
|
458
|
+
// PNG header so it is correct on both platforms without extra commands.
|
|
459
|
+
let scale = 1;
|
|
460
|
+
try {
|
|
461
|
+
const png = Buffer.from(await this.hub.screenshot(session.sessionId), 'base64');
|
|
462
|
+
const pngWidth = png.readUInt32BE(16); // IHDR width
|
|
463
|
+
if (pngWidth > 0 && rect.width > 0) {
|
|
464
|
+
scale = Math.round((pngWidth / rect.width) * 100) / 100;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
// scale stays 1 — viewport math still works, crops may be off on 2x/3x screens.
|
|
469
|
+
}
|
|
470
|
+
session.screenSize = { width: rect.width, height: rect.height, scale };
|
|
471
|
+
return session.screenSize;
|
|
472
|
+
}
|
|
473
|
+
async getOrientation() {
|
|
474
|
+
const value = await this.hub.get(this.session().sessionId, '/orientation');
|
|
475
|
+
return value.toLowerCase() === 'landscape' ? 'landscape' : 'portrait';
|
|
476
|
+
}
|
|
477
|
+
async setOrientation(orientation) {
|
|
478
|
+
const session = this.session();
|
|
479
|
+
await this.hub.post(session.sessionId, '/orientation', { orientation: orientation.toUpperCase() });
|
|
480
|
+
session.screenSize = undefined; // width/height swap
|
|
481
|
+
}
|
|
482
|
+
// ─── MobilewrightSession: apps ─────────────────────────────────
|
|
483
|
+
async launchApp(bundleId, opts) {
|
|
484
|
+
const { sessionId, platform } = this.session();
|
|
485
|
+
if (platform === 'android' && opts?.activity) {
|
|
486
|
+
await this.hub.execute(sessionId, 'mobile: startActivity', [{
|
|
487
|
+
intent: `${bundleId}/${opts.activity}`,
|
|
488
|
+
}]);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
await this.hub.execute(sessionId, 'mobile: activateApp', [{ appId: bundleId, bundleId }]);
|
|
492
|
+
}
|
|
493
|
+
async terminateApp(bundleId) {
|
|
494
|
+
const { sessionId } = this.session();
|
|
495
|
+
await this.hub.execute(sessionId, 'mobile: terminateApp', [{ appId: bundleId, bundleId }]);
|
|
496
|
+
}
|
|
497
|
+
async listApps() {
|
|
498
|
+
// TestingBot has no app-enumeration channel; the foreground app is the
|
|
499
|
+
// only one that can be reported reliably (documented limitation).
|
|
500
|
+
try {
|
|
501
|
+
return [await this.getForegroundApp()];
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
return [];
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
async getForegroundApp() {
|
|
508
|
+
const { sessionId, platform } = this.session();
|
|
509
|
+
if (platform === 'ios') {
|
|
510
|
+
const info = await this.hub.execute(sessionId, 'mobile: activeAppInfo', []);
|
|
511
|
+
return { bundleId: info.bundleId, name: info.name };
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
const pkg = await this.hub.execute(sessionId, 'mobile: getCurrentPackage', []);
|
|
515
|
+
return { bundleId: pkg };
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
// Older UiAutomator2 versions only expose the legacy endpoint.
|
|
519
|
+
const pkg = await this.hub.get(sessionId, '/appium/device/current_package');
|
|
520
|
+
return { bundleId: pkg };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
async installApp(path) {
|
|
524
|
+
// TestingBot has no mid-session install: the app was already installed
|
|
525
|
+
// at allocation time via appium:app (declared in the driver's `apps`
|
|
526
|
+
// option). This call only verifies the request matches that app.
|
|
527
|
+
const session = this.session();
|
|
528
|
+
const declared = appForCriteria({ platform: session.platform, deviceType: session.deviceType }, this.options.apps);
|
|
529
|
+
if (declared && (isRemoteApp(declared) || await sameFileContents(declared, path))) {
|
|
530
|
+
debug('installApp(%s): already installed at allocation via appium:app, skipping', path);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
throw new Error(`TestingBotDriver cannot install "${path}" mid-session: TestingBot sessions start with the ` +
|
|
534
|
+
"app declared in the driver's `apps` option and support no later installs. " +
|
|
535
|
+
(declared
|
|
536
|
+
? `The session was started with "${declared}" instead — point installApps and apps at the same build.`
|
|
537
|
+
: "Declare it when constructing the driver: new TestingBotDriver({ apps: { " +
|
|
538
|
+
`'${session.platform}${session.deviceType ? `-${session.deviceType}` : ''}': '${path}' } }).`));
|
|
539
|
+
}
|
|
540
|
+
async uninstallApp(bundleId) {
|
|
541
|
+
const { sessionId } = this.session();
|
|
542
|
+
await this.hub.execute(sessionId, 'mobile: removeApp', [{ appId: bundleId, bundleId }]);
|
|
543
|
+
}
|
|
544
|
+
// ─── MobilewrightSession: device ───────────────────────────────
|
|
545
|
+
async listDevices(opts) {
|
|
546
|
+
requireCredentials(this.options);
|
|
547
|
+
const devices = await this.catalog.listDevices(opts?.platform);
|
|
548
|
+
return opts?.state ? devices.filter((d) => d.state === opts.state) : devices;
|
|
549
|
+
}
|
|
550
|
+
async openUrl(url) {
|
|
551
|
+
const { sessionId, platform } = this.session();
|
|
552
|
+
if (platform === 'android') {
|
|
553
|
+
await this.hub.execute(sessionId, 'mobile: deepLink', [{ url }]);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
// XCUITest has no universal deep-link command; Safari via the WebDriver
|
|
557
|
+
// url endpoint is the broadest fallback.
|
|
558
|
+
try {
|
|
559
|
+
await this.hub.execute(sessionId, 'mobile: deepLink', [{ url }]);
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
await this.hub.post(sessionId, '/url', { url });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
// ─── MobilewrightSession: recording ────────────────────────────
|
|
566
|
+
async startRecording(opts) {
|
|
567
|
+
// TestingBot records every session (tb:options.screenrecorder is enabled
|
|
568
|
+
// by default in buildCapabilities); nothing to start. When the caller
|
|
569
|
+
// wants a local file, note the request — the video only finalizes after
|
|
570
|
+
// the session ends, so the observer downloads it at run end.
|
|
571
|
+
if (opts.output) {
|
|
572
|
+
this.runLog.append({ type: 'recording', sessionId: this.session().sessionId, output: opts.output });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
async stopRecording() {
|
|
576
|
+
const { sessionId } = this.session();
|
|
577
|
+
// The video asset finalizes after the session ends; return what we can.
|
|
578
|
+
try {
|
|
579
|
+
const test = await this.api.getTest(sessionId);
|
|
580
|
+
if (typeof test.video === 'string' && test.video) {
|
|
581
|
+
return { status: 'success', url: test.video, duration: test.duration };
|
|
582
|
+
}
|
|
583
|
+
if (test.video === false) {
|
|
584
|
+
return { status: 'disabled' };
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
// fall through to the dashboard link
|
|
589
|
+
}
|
|
590
|
+
return { status: 'pending', url: `https://testingbot.com/members/tests/${sessionId}` };
|
|
591
|
+
}
|
|
592
|
+
// ─── internals ─────────────────────────────────────────────────
|
|
593
|
+
session() {
|
|
594
|
+
if (!this.active)
|
|
595
|
+
throw new SessionNotActiveError();
|
|
596
|
+
return this.active;
|
|
597
|
+
}
|
|
598
|
+
unpin(name) {
|
|
599
|
+
const count = this.pinCounts.get(name) ?? 0;
|
|
600
|
+
if (count <= 1)
|
|
601
|
+
this.pinCounts.delete(name);
|
|
602
|
+
else
|
|
603
|
+
this.pinCounts.set(name, count - 1);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function str(value) {
|
|
607
|
+
return typeof value === 'string' && value ? value : undefined;
|
|
608
|
+
}
|
|
609
|
+
function describeAllocation(criteria) {
|
|
610
|
+
const parts = [
|
|
611
|
+
criteria.platform,
|
|
612
|
+
criteria.deviceType,
|
|
613
|
+
criteria.deviceNamePattern && `deviceName /${criteria.deviceNamePattern}/`,
|
|
614
|
+
criteria.osVersion && `osVersion "${criteria.osVersion}"`,
|
|
615
|
+
].filter(Boolean);
|
|
616
|
+
return parts.length ? parts.join(', ') : 'the requested device';
|
|
617
|
+
}
|
|
618
|
+
/** Values usable directly as appium:app without an upload. */
|
|
619
|
+
function isRemoteApp(value) {
|
|
620
|
+
return /^(tb|https?):\/\//i.test(value);
|
|
621
|
+
}
|
|
622
|
+
/** Whether two local files hold the same bytes (path shortcut first). */
|
|
623
|
+
async function sameFileContents(a, b) {
|
|
624
|
+
const { resolve } = await import('node:path');
|
|
625
|
+
if (resolve(a) === resolve(b))
|
|
626
|
+
return true;
|
|
627
|
+
try {
|
|
628
|
+
const { readFile } = await import('node:fs/promises');
|
|
629
|
+
const [dataA, dataB] = await Promise.all([readFile(a), readFile(b)]);
|
|
630
|
+
return dataA.equals(dataB);
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
function findFocused(nodes) {
|
|
637
|
+
for (const node of nodes) {
|
|
638
|
+
if (node.isFocused)
|
|
639
|
+
return node;
|
|
640
|
+
const inChildren = findFocused(node.children);
|
|
641
|
+
if (inChildren)
|
|
642
|
+
return inChildren;
|
|
643
|
+
}
|
|
644
|
+
return undefined;
|
|
645
|
+
}
|
|
646
|
+
//# sourceMappingURL=driver.js.map
|