@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.
Files changed (87) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +211 -0
  3. package/dist/actions.d.ts +25 -0
  4. package/dist/actions.d.ts.map +1 -0
  5. package/dist/actions.js +76 -0
  6. package/dist/actions.js.map +1 -0
  7. package/dist/app-storage.d.ts +20 -0
  8. package/dist/app-storage.d.ts.map +1 -0
  9. package/dist/app-storage.js +68 -0
  10. package/dist/app-storage.js.map +1 -0
  11. package/dist/capabilities.d.ts +27 -0
  12. package/dist/capabilities.d.ts.map +1 -0
  13. package/dist/capabilities.js +120 -0
  14. package/dist/capabilities.js.map +1 -0
  15. package/dist/ci.d.ts +8 -0
  16. package/dist/ci.d.ts.map +1 -0
  17. package/dist/ci.js +46 -0
  18. package/dist/ci.js.map +1 -0
  19. package/dist/cli.d.ts +3 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +43 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/device-catalog.d.ts +37 -0
  24. package/dist/device-catalog.d.ts.map +1 -0
  25. package/dist/device-catalog.js +159 -0
  26. package/dist/device-catalog.js.map +1 -0
  27. package/dist/driver.d.ts +80 -0
  28. package/dist/driver.d.ts.map +1 -0
  29. package/dist/driver.js +646 -0
  30. package/dist/driver.js.map +1 -0
  31. package/dist/errors.d.ts +41 -0
  32. package/dist/errors.d.ts.map +1 -0
  33. package/dist/errors.js +104 -0
  34. package/dist/errors.js.map +1 -0
  35. package/dist/hierarchy.d.ts +10 -0
  36. package/dist/hierarchy.d.ts.map +1 -0
  37. package/dist/hierarchy.js +103 -0
  38. package/dist/hierarchy.js.map +1 -0
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +3 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/init.d.ts +19 -0
  44. package/dist/init.d.ts.map +1 -0
  45. package/dist/init.js +108 -0
  46. package/dist/init.js.map +1 -0
  47. package/dist/keepalive.d.ts +16 -0
  48. package/dist/keepalive.d.ts.map +1 -0
  49. package/dist/keepalive.js +56 -0
  50. package/dist/keepalive.js.map +1 -0
  51. package/dist/keys.d.ts +32 -0
  52. package/dist/keys.d.ts.map +1 -0
  53. package/dist/keys.js +117 -0
  54. package/dist/keys.js.map +1 -0
  55. package/dist/observer.d.ts +55 -0
  56. package/dist/observer.d.ts.map +1 -0
  57. package/dist/observer.js +148 -0
  58. package/dist/observer.js.map +1 -0
  59. package/dist/options.d.ts +154 -0
  60. package/dist/options.d.ts.map +1 -0
  61. package/dist/options.js +49 -0
  62. package/dist/options.js.map +1 -0
  63. package/dist/rest-api.d.ts +67 -0
  64. package/dist/rest-api.d.ts.map +1 -0
  65. package/dist/rest-api.js +117 -0
  66. package/dist/rest-api.js.map +1 -0
  67. package/dist/run-log.d.ts +56 -0
  68. package/dist/run-log.d.ts.map +1 -0
  69. package/dist/run-log.js +72 -0
  70. package/dist/run-log.js.map +1 -0
  71. package/dist/tunnel.d.ts +13 -0
  72. package/dist/tunnel.d.ts.map +1 -0
  73. package/dist/tunnel.js +47 -0
  74. package/dist/tunnel.js.map +1 -0
  75. package/dist/version.d.ts +4 -0
  76. package/dist/version.d.ts.map +1 -0
  77. package/dist/version.js +7 -0
  78. package/dist/version.js.map +1 -0
  79. package/dist/webdriver-client.d.ts +43 -0
  80. package/dist/webdriver-client.d.ts.map +1 -0
  81. package/dist/webdriver-client.js +109 -0
  82. package/dist/webdriver-client.js.map +1 -0
  83. package/dist/webview.d.ts +16 -0
  84. package/dist/webview.d.ts.map +1 -0
  85. package/dist/webview.js +109 -0
  86. package/dist/webview.js.map +1 -0
  87. 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