@leera.io/qa-runner 1.0.4 → 1.0.6

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.
@@ -245,6 +245,10 @@ export class Adb {
245
245
  async pidof(serial, pkg) {
246
246
  return parsePidof(await this.shell(serial, ['pidof', assertPackage(pkg)], 10_000).catch(() => ''));
247
247
  }
248
+ /** Drops adb's connection to a device so it reconnects (the fix for one stuck `offline`). */
249
+ async reconnect(serial) {
250
+ await this.run(['-s', assertSerial(serial), 'reconnect'], 15_000).catch(() => undefined);
251
+ }
248
252
  async killEmulator(serial) {
249
253
  await this.run(['-s', assertSerial(serial), 'emu', 'kill'], 15_000).catch(() => undefined);
250
254
  }
@@ -140,6 +140,28 @@ export async function waitForBoot(adb, serial, timeoutMs, failed = () => null) {
140
140
  await sleep(2_000);
141
141
  }
142
142
  }
143
+ /**
144
+ * Waits for a device adb lost for a moment (a busy emulator, a USB reconnect) to come back and
145
+ * finish booting. A device still `offline` after a few seconds gets its connection reset once.
146
+ */
147
+ export async function waitForDeviceBack(adb, serial, { timeoutMs = 120_000, nudgeAfterMs = 10_000, intervalMs = 2_000 } = {}) {
148
+ const deadline = Date.now() + timeoutMs;
149
+ const nudgeAt = Date.now() + nudgeAfterMs;
150
+ let nudged = false;
151
+ for (;;) {
152
+ const state = (await adb.devices().catch(() => [])).find((d) => d.serial === serial)?.state;
153
+ if (state === 'device' && (await adb.getprop(serial, 'sys.boot_completed').catch(() => '')) === '1')
154
+ return;
155
+ if (state === 'offline' && !nudged && Date.now() >= nudgeAt) {
156
+ nudged = true;
157
+ await adb.reconnect(serial);
158
+ }
159
+ if (Date.now() > deadline) {
160
+ throw new Error(`${serial} is ${state ?? 'not connected'} and did not come back within ${Math.round(timeoutMs / 1000)} s`);
161
+ }
162
+ await sleep(intervalMs);
163
+ }
164
+ }
143
165
  export async function shutdownEmulator(adb, serial, timeoutMs = 60_000) {
144
166
  bootedHere.delete(serial);
145
167
  await adb.killEmulator(serial);
@@ -7,6 +7,7 @@ import { bundledDrivers } from '../appium/drivers.js';
7
7
  import { AppNotAvailable, resolveAppFile } from '../builds/install.js';
8
8
  import { androidInstallable } from '../builds/unpack.js';
9
9
  import { Adb, resolveAndroidSdk } from '../devices/android/adb.js';
10
+ import { waitForDeviceBack } from '../devices/android/emulator.js';
10
11
  import { leaseAndroidDevice, listAndroidDevices } from '../devices/android/index.js';
11
12
  import { LogcatSink } from '../devices/android/logcat.js';
12
13
  import { dismissSystemErrorDialog } from '../devices/android/system-dialog.js';
@@ -33,6 +34,33 @@ export function appiumLogLines(text, redact) {
33
34
  });
34
35
  return { lines: kept, dropped: all.length - kept.length };
35
36
  }
37
+ /** adb's words for a device it lost for the moment: `offline`, or missing from its list. */
38
+ export function lostDevice(error) {
39
+ const message = error instanceof Error ? error.message : String(error);
40
+ return /\bdevice offline\b|\bdevice ('[^']*' )?not found\b/i.test(message);
41
+ }
42
+ /**
43
+ * Opens the Appium session, and once more when adb lost the device while Appium set it up. A busy
44
+ * emulator or a USB reconnect drops the device for a few seconds, and Appium (which retries
45
+ * "not found" but not "offline") then fails the whole session. `recover` waits for the device;
46
+ * when it does not come back, the first error stands.
47
+ */
48
+ export async function startSession(open, recover) {
49
+ try {
50
+ return await open();
51
+ }
52
+ catch (error) {
53
+ if (!lostDevice(error))
54
+ throw error;
55
+ try {
56
+ await recover();
57
+ }
58
+ catch {
59
+ throw error;
60
+ }
61
+ return open();
62
+ }
63
+ }
36
64
  /** `android.sdk_path` from config.json, when the runner config has one. */
37
65
  export function configuredSdkPath(paths) {
38
66
  try {
@@ -191,14 +219,19 @@ export class AndroidAppiumDriver {
191
219
  const url = await this.appium.ensure();
192
220
  this.systemPort = await SYSTEM_PORTS.take();
193
221
  this.mjpegPort = await MJPEG_PORTS.take();
194
- this.client = await openSession(url, uiautomator2Capabilities({
222
+ const capabilities = uiautomator2Capabilities({
195
223
  serial,
196
224
  appPackage: app.id,
197
225
  appActivity: app.launch_activity,
198
226
  systemPort: this.systemPort,
199
227
  mjpegServerPort: this.mjpegPort,
200
228
  newCommandTimeoutSecs: Math.max(300, Math.ceil((this.ctx.session?.idle_timeout_seconds ?? 0) + 60)),
201
- }));
229
+ });
230
+ const adb = this.adb;
231
+ this.client = await startSession(() => openSession(url, capabilities), async () => {
232
+ this.ctx.log.warn('adb lost the device while Appium started the session; waiting for it to retry', { serial });
233
+ await waitForDeviceBack(adb, serial);
234
+ });
202
235
  }
203
236
  catch (error) {
204
237
  const log = this.appium.logSince(logOffset, 2_000).trim().split('\n').slice(-5).join(' | ');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leera.io/qa-runner",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Runs recorded QA test scripts on your own machines and reports results back to your workspace.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",