From 389c4790f52edc12bb3a425218e40d2ae62524e9 Mon Sep 17 00:00:00 2001 From: d4vidi Date: Mon, 5 Jan 2026 17:55:00 +0200 Subject: [PATCH 1/7] feat(Android): Split emulators across different adb servers --- detox/src/DetoxWorker.js | 2 + .../android/emulator/EmulatorAllocDriver.js | 10 +++- .../android/emulator/EmulatorLauncher.js | 3 +- .../android/emulator/EmulatorLauncher.test.js | 9 ++- .../android/emulator/launchEmulatorProcess.js | 27 ++++++++- .../common/drivers/android/AdbPortRegistry.js | 23 ++++++++ .../common/drivers/android/cookies.d.ts | 4 ++ .../common/drivers/android/exec/ADB.js | 15 +++-- .../common/drivers/android/exec/ADB.test.js | 56 ++++++++++++++++++- .../common/drivers/android/exec/BinaryExec.js | 5 +- .../drivers/android/exec/BinaryExec.test.js | 23 +++++++- .../__snapshots__/BinaryExec.test.js.snap | 40 +++++++++++++ detox/src/devices/runtime/RuntimeDevice.js | 5 ++ .../runtime/drivers/DeviceDriverBase.js | 3 + .../android/emulator/EmulatorDriver.js | 15 ++++- .../src/devices/runtime/factories/android.js | 1 + 16 files changed, 225 insertions(+), 16 deletions(-) create mode 100644 detox/src/devices/common/drivers/android/AdbPortRegistry.js create mode 100644 detox/src/devices/common/drivers/android/exec/__snapshots__/BinaryExec.test.js.snap diff --git a/detox/src/DetoxWorker.js b/detox/src/DetoxWorker.js index 5a91e57a09..ef10c437d1 100644 --- a/detox/src/DetoxWorker.js +++ b/detox/src/DetoxWorker.js @@ -186,6 +186,8 @@ class DetoxWorker { }); } + // @ts-ignore + yield this.device.init(); // @ts-ignore yield this.device.installUtilBinaries(); diff --git a/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js b/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js index a9a7ec2142..a880a37fd1 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js +++ b/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js @@ -1,5 +1,6 @@ /** * @typedef {import('../../../../common/drivers/android/cookies').AndroidDeviceCookie} AndroidDeviceCookie + * @typedef {import('../../../../common/drivers/android/cookies').EmulatorDeviceCookie} EmulatorDeviceCookie */ const _ = require('lodash'); @@ -9,6 +10,8 @@ const AndroidAllocDriver = require('../AndroidAllocDriver'); const { patchAvdSkinConfig } = require('./patchAvdSkinConfig'); +const ADB_SERVER_BASE_PORT = 5037; + class EmulatorAllocDriver extends AndroidAllocDriver { /** * @param {object} options @@ -40,6 +43,7 @@ class EmulatorAllocDriver extends AndroidAllocDriver { this._freePortFinder = freePortFinder; this._shouldShutdown = detoxConfig.behavior.cleanup.shutdownDevice; this._fixAvdConfigIniSkinNameIfNeeded = _.memoize(this._fixAvdConfigIniSkinNameIfNeeded.bind(this)); + this._adbServerPortCounter = ADB_SERVER_BASE_PORT; } async init() { @@ -48,7 +52,7 @@ class EmulatorAllocDriver extends AndroidAllocDriver { /** * @param deviceConfig - * @returns {Promise} + * @returns {Promise} */ async allocate(deviceConfig) { const avdName = deviceConfig.device.avdName; @@ -56,6 +60,8 @@ class EmulatorAllocDriver extends AndroidAllocDriver { await this._avdValidator.validate(avdName, deviceConfig.headless); await this._fixAvdConfigIniSkinNameIfNeeded(avdName, deviceConfig.headless); + const adbServerPort = ++this._adbServerPortCounter; + const adbName = await this._deviceRegistry.registerDevice(async () => { let adbName = await this._freeDeviceFinder.findFreeDevice(avdName); if (!adbName) { @@ -70,6 +76,7 @@ class EmulatorAllocDriver extends AndroidAllocDriver { avdName, adbName, port, + adbServerPort, }); } @@ -80,6 +87,7 @@ class EmulatorAllocDriver extends AndroidAllocDriver { id: adbName, adbName, name: `${adbName} (${avdName})`, + adbServerPort, }; } diff --git a/detox/src/devices/allocation/drivers/android/emulator/EmulatorLauncher.js b/detox/src/devices/allocation/drivers/android/emulator/EmulatorLauncher.js index d345c0a8f9..1cf91f63b2 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/EmulatorLauncher.js +++ b/detox/src/devices/allocation/drivers/android/emulator/EmulatorLauncher.js @@ -17,6 +17,7 @@ class EmulatorLauncher { * @param {object} options * @param {string} options.avdName * @param {string} options.adbName + * @param {number} options.adbServerPort * @param {number} options.port * @param {string | undefined} options.bootArgs * @param {string | undefined} options.gpuMode @@ -29,7 +30,7 @@ class EmulatorLauncher { retries: 2, interval: 100, conditionFn: isUnknownEmulatorError, - }, () => launchEmulatorProcess(this._emulatorExec, this._adb, launchCommand)); + }, () => launchEmulatorProcess(this._emulatorExec, this._adb, launchCommand, options.adbServerPort)); } /** diff --git a/detox/src/devices/allocation/drivers/android/emulator/EmulatorLauncher.test.js b/detox/src/devices/allocation/drivers/android/emulator/EmulatorLauncher.test.js index 5651dfb72a..caffa45e4f 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/EmulatorLauncher.test.js +++ b/detox/src/devices/allocation/drivers/android/emulator/EmulatorLauncher.test.js @@ -38,7 +38,7 @@ describe('Emulator launcher', () => { expect(launchEmulatorProcess).toHaveBeenCalledWith(emulatorExec, adb, expect.objectContaining({ avdName, adbName, - })); + }), undefined); }); it('should launch using a specific emulator port, if provided', async () => { @@ -49,6 +49,13 @@ describe('Emulator launcher', () => { expect(command.port).toEqual(port); }); + it('should pass adbServerPort to launchEmulatorProcess', async () => { + const adbServerPort = 5038; + await uut.launch({ avdName, adbName, adbServerPort }); + + expect(launchEmulatorProcess).toHaveBeenCalledWith(emulatorExec, adb, expect.anything(), adbServerPort); + }); + it('should retry emulator process launching with custom args', async () => { const expectedRetryOptions = { retries: 2, diff --git a/detox/src/devices/allocation/drivers/android/emulator/launchEmulatorProcess.js b/detox/src/devices/allocation/drivers/android/emulator/launchEmulatorProcess.js index 1398d10a7a..bc4aac2bc9 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/launchEmulatorProcess.js +++ b/detox/src/devices/allocation/drivers/android/emulator/launchEmulatorProcess.js @@ -1,10 +1,24 @@ +/** + * @typedef {import('../../../../common/drivers/android/emulator/exec/EmulatorExec').EmulatorExec} EmulatorExec + * @typedef {import('../../../../common/drivers/android/exec/ADB')} ADB + * @typedef {import('../../../../common/drivers/android/emulator/exec/EmulatorExec').LaunchCommand} LaunchCommand + */ + const fs = require('fs'); const _ = require('lodash'); const unitLogger = require('../../../../../utils/logger').child({ cat: 'device' }); +const adbPortRegistry = require('../../../../common/drivers/android/AdbPortRegistry'); -function launchEmulatorProcess(emulatorExec, adb, emulatorLaunchCommand) { +/** + * @param { EmulatorExec } emulatorExec - Instance for executing emulator commands + * @param { ADB } adb - Instance of the Android Debug Bridge handler + * @param { LaunchCommand } emulatorLaunchCommand - The command describing how to launch the emulator + * @param { number|undefined } adbServerPort - Port number for ADB server, if any + * @returns { Promise } A Promise that resolves when the emulator process is launched and ready + */ +function launchEmulatorProcess(emulatorExec, adb, emulatorLaunchCommand, adbServerPort) { let childProcessOutput; const portName = emulatorLaunchCommand.port ? `-${emulatorLaunchCommand.port}` : ''; const tempLog = `./${emulatorLaunchCommand.avdName}${portName}.log`; @@ -26,9 +40,18 @@ function launchEmulatorProcess(emulatorExec, adb, emulatorLaunchCommand) { let log = unitLogger.child({ fn: 'boot' }); log.debug({ event: 'SPAWN_CMD' }, emulatorExec.toString(), emulatorLaunchCommand.toString()); - const childProcessPromise = emulatorExec.spawn(emulatorLaunchCommand, stdout, stderr); + const childProcessPromise = emulatorExec.spawn(emulatorLaunchCommand, stdout, stderr, { + env: { + ...process.env, + ANDROID_ADB_SERVER_PORT: adbServerPort ? String(adbServerPort) : undefined, + }, + }); childProcessPromise.childProcess.unref(); + if (adbServerPort) { + adbPortRegistry.register(emulatorLaunchCommand.adbName, adbServerPort); + } + log = log.child({ child_pid: childProcessPromise.childProcess.pid }); // Create a deferred promise that resolves when the device is ready diff --git a/detox/src/devices/common/drivers/android/AdbPortRegistry.js b/detox/src/devices/common/drivers/android/AdbPortRegistry.js new file mode 100644 index 0000000000..e7e91d5e0f --- /dev/null +++ b/detox/src/devices/common/drivers/android/AdbPortRegistry.js @@ -0,0 +1,23 @@ +class AdbPortRegistry { + constructor() { + this._registry = new Map(); + } + + /** + * @param { string } adbName + * @param { number } port + */ + register(adbName, port) { + this._registry.set(adbName, port); + } + + /** + * @param { string } adbName + * @returns { number | undefined } + */ + getPort(adbName) { + return this._registry.get(adbName); + } +} + +module.exports = new AdbPortRegistry(); diff --git a/detox/src/devices/common/drivers/android/cookies.d.ts b/detox/src/devices/common/drivers/android/cookies.d.ts index d33d19811e..f70a62b681 100644 --- a/detox/src/devices/common/drivers/android/cookies.d.ts +++ b/detox/src/devices/common/drivers/android/cookies.d.ts @@ -6,6 +6,10 @@ interface AndroidDeviceCookie extends DeviceCookie { adbName: string; } +interface EmulatorDeviceCookie extends AndroidDeviceCookie { + adbServerPort: number; +} + interface GenycloudEmulatorCookie extends AndroidDeviceCookie { instance: GenyInstance; } diff --git a/detox/src/devices/common/drivers/android/exec/ADB.js b/detox/src/devices/common/drivers/android/exec/ADB.js index 19dd6210b7..07d3b56a31 100644 --- a/detox/src/devices/common/drivers/android/exec/ADB.js +++ b/detox/src/devices/common/drivers/android/exec/ADB.js @@ -5,6 +5,7 @@ const DetoxRuntimeError = require('../../../../../errors/DetoxRuntimeError'); const { execWithRetriesAndLogs, spawnWithRetriesAndLogs, spawnAndLog } = require('../../../../../utils/childProcess'); const { getAdbPath } = require('../../../../../utils/environment'); const { escape } = require('../../../../../utils/pipeCommands'); +const adbPortRegistry = require('../AdbPortRegistry'); const DeviceHandle = require('../tools/DeviceHandle'); const EmulatorHandle = require('../tools/EmulatorHandle'); @@ -370,8 +371,10 @@ class ADB { } async adbCmd(deviceId, params, options = {}) { - const serial = `${deviceId ? `-s ${deviceId}` : ''}`; - const cmd = `"${this.adbBin}" ${serial} ${params}`; + const port = adbPortRegistry.getPort(deviceId); + const portFlag = port ? `-P ${port} ` : ''; + const serial = deviceId ? `-s ${deviceId} ` : ''; + const cmd = `"${this.adbBin}" ${portFlag}${serial}${params}`.replace(/\s+/g, ' ').trim(); const _options = { ...this.defaultExecOptions, ...options, @@ -382,7 +385,9 @@ class ADB { async adbCmdSpawned(deviceId, command, spawnOptions = {}) { const flags = command.split(/\s+/); const serial = deviceId ? ['-s', deviceId] : []; - const _flags = [...serial, ...flags]; + const port = deviceId ? adbPortRegistry.getPort(deviceId) : undefined; + const portFlag = port ? ['-P', String(port)] : []; + const _flags = [...portFlag, ...serial, ...flags]; const _spawnOptions = { ...this.defaultExecOptions, ...spawnOptions, @@ -397,7 +402,9 @@ class ADB { */ spawn(deviceId, params, spawnOptions) { const serial = deviceId ? ['-s', deviceId] : []; - return spawnAndLog(this.adbBin, [...serial, ...params], spawnOptions); + const port = deviceId ? adbPortRegistry.getPort(deviceId) : undefined; + const portFlag = port ? ['-P', String(port)] : []; + return spawnAndLog(this.adbBin, [...portFlag, ...serial, ...params], spawnOptions); } } diff --git a/detox/src/devices/common/drivers/android/exec/ADB.test.js b/detox/src/devices/common/drivers/android/exec/ADB.test.js index d6a3310d77..a2fd7ab25f 100644 --- a/detox/src/devices/common/drivers/android/exec/ADB.test.js +++ b/detox/src/devices/common/drivers/android/exec/ADB.test.js @@ -10,6 +10,7 @@ describe('ADB', () => { let execWithRetriesAndLogs; let spawnAndLog; let spawnWithRetriesAndLogs; + let adbPortRegistry; beforeEach(() => { jest.mock('../../../../../utils/logger'); @@ -35,6 +36,13 @@ describe('ADB', () => { spawnAndLog = require('../../../../../utils/childProcess').spawnAndLog; spawnWithRetriesAndLogs = require('../../../../../utils/childProcess').spawnWithRetriesAndLogs; + jest.mock('../AdbPortRegistry', () => ({ + register: jest.fn(), + getPort: jest.fn().mockReturnValue(undefined), + unregister: jest.fn(), + })); + adbPortRegistry = require('../AdbPortRegistry'); + ADB = require('./ADB'); adb = new ADB(); }); @@ -50,7 +58,7 @@ describe('ADB', () => { it(`should invoke ADB`, async () => { await adb.devices(); - expect(execWithRetriesAndLogs).toHaveBeenCalledWith(`"${adbBinPath}" devices`, { verbosity: 'high', retries: 1 }); + expect(execWithRetriesAndLogs).toHaveBeenCalledWith(`"${adbBinPath}" devices`, { verbosity: 'high', retries: 1 }); expect(execWithRetriesAndLogs).toHaveBeenCalledTimes(1); }); @@ -96,7 +104,51 @@ describe('ADB', () => { describe('ADB Daemon (server)', () => { it('should start the daemon', async () => { await adb.startDaemon(); - expect(execWithRetriesAndLogs).toHaveBeenCalledWith(`"${adbBinPath}" start-server`, { retries: 0, verbosity: 'high' }); + expect(execWithRetriesAndLogs).toHaveBeenCalledWith(`"${adbBinPath}" start-server`, { retries: 0, verbosity: 'high' }); + }); + }); + + describe('ADB Port Registry integration', () => { + beforeEach(() => { + adbPortRegistry.getPort.mockReturnValue(undefined); + }); + + it('should include -P flag when port is found in registry for adbCmd', async () => { + const port = 5038; + adbPortRegistry.getPort.mockReturnValue(port); + + await adb.adbCmd(deviceId, 'devices'); + + expect(execWithRetriesAndLogs).toHaveBeenCalledWith( + expect.stringContaining(`-P ${port} -s ${deviceId} devices`), + expect.any(Object) + ); + }); + + it('should include -P flag when port is found in registry for adbCmdSpawned', async () => { + const port = 5038; + adbPortRegistry.getPort.mockReturnValue(port); + + await adb.adbCmdSpawned(deviceId, 'install test.apk'); + + expect(spawnWithRetriesAndLogs).toHaveBeenCalledWith( + adbBinPath, + expect.arrayContaining(['-P', String(port), '-s', deviceId]), + expect.any(Object) + ); + }); + + it('should include -P flag when port is found in registry for spawn', async () => { + const port = 5038; + adbPortRegistry.getPort.mockReturnValue(port); + + adb.spawn(deviceId, ['shell', 'command']); + + expect(spawnAndLog).toHaveBeenCalledWith( + adbBinPath, + expect.arrayContaining(['-P', String(port), '-s', deviceId]), + undefined + ); }); }); diff --git a/detox/src/devices/common/drivers/android/exec/BinaryExec.js b/detox/src/devices/common/drivers/android/exec/BinaryExec.js index f55143396c..5009a51e75 100644 --- a/detox/src/devices/common/drivers/android/exec/BinaryExec.js +++ b/detox/src/devices/common/drivers/android/exec/BinaryExec.js @@ -27,11 +27,12 @@ class BinaryExec { return await execAsync(`"${this.binary}" ${command._getArgsString()}`); } - spawn(command, stdout, stderr) { + spawn(command, stdout, stderr, options = {}) { return spawnAndLog(this.binary, command._getArgs(), { detached: true, encoding: 'utf8', - stdio: ['ignore', stdout, stderr] + stdio: ['ignore', stdout, stderr], + ...options }); } } diff --git a/detox/src/devices/common/drivers/android/exec/BinaryExec.test.js b/detox/src/devices/common/drivers/android/exec/BinaryExec.test.js index 83319c464a..cc4ced0150 100644 --- a/detox/src/devices/common/drivers/android/exec/BinaryExec.test.js +++ b/detox/src/devices/common/drivers/android/exec/BinaryExec.test.js @@ -81,13 +81,32 @@ describe('BinaryExec', () => { expect(spawn).toHaveBeenCalledWith(binaryPath, commandArgs, expect.anything()); }); + it('should specify spawn options', async () => { + const command = anEmptyCommand(); + const stdout = null; + const stderr = null; + + await binaryExec.spawn(command, stdout, stderr); + + expect(spawn.mock.calls).toMatchSnapshot(); + }); + + it('should merge default options with user options', async () => { + const command = anEmptyCommand(); + const customOptions = { env: { TEST_VAR: 'test' } }; + + await binaryExec.spawn(command, null, null, customOptions); + + expect(spawn.mock.calls).toMatchSnapshot(); + }); + it('should chain-return spawn result', async () => { + const command = anEmptyCommand(); const spawnResult = Promise.resolve('mock result'); spawn.mockReturnValue(spawnResult); - const command = anEmptyCommand(); - const result = binaryExec.spawn(command); + expect(result).toEqual(spawnResult); }); }); diff --git a/detox/src/devices/common/drivers/android/exec/__snapshots__/BinaryExec.test.js.snap b/detox/src/devices/common/drivers/android/exec/__snapshots__/BinaryExec.test.js.snap new file mode 100644 index 0000000000..2cfa6e9315 --- /dev/null +++ b/detox/src/devices/common/drivers/android/exec/__snapshots__/BinaryExec.test.js.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`BinaryExec spawn should merge default options with user options 1`] = ` +[ + [ + "/binary/mock", + [], + { + "detached": true, + "encoding": "utf8", + "env": { + "TEST_VAR": "test", + }, + "stdio": [ + "ignore", + null, + null, + ], + }, + ], +] +`; + +exports[`BinaryExec spawn should specify spawn options 1`] = ` +[ + [ + "/binary/mock", + [], + { + "detached": true, + "encoding": "utf8", + "stdio": [ + "ignore", + null, + null, + ], + }, + ], +] +`; diff --git a/detox/src/devices/runtime/RuntimeDevice.js b/detox/src/devices/runtime/RuntimeDevice.js index 753bd70c08..c0a61e0009 100644 --- a/detox/src/devices/runtime/RuntimeDevice.js +++ b/detox/src/devices/runtime/RuntimeDevice.js @@ -17,6 +17,7 @@ class RuntimeDevice { runtimeErrorComposer, }, deviceDriver) { const methodNames = [ + 'init', 'captureViewHierarchy', 'clearKeychain', 'disableSynchronization', @@ -88,6 +89,10 @@ class RuntimeDevice { return this._currentAppLaunchArgs; } + async init() { + await this.deviceDriver.init(); + } + async selectApp(name) { if (name === undefined) { throw this._errorComposer.cantSelectEmptyApp(); diff --git a/detox/src/devices/runtime/drivers/DeviceDriverBase.js b/detox/src/devices/runtime/drivers/DeviceDriverBase.js index 7f572f6953..82edc222f2 100644 --- a/detox/src/devices/runtime/drivers/DeviceDriverBase.js +++ b/detox/src/devices/runtime/drivers/DeviceDriverBase.js @@ -39,6 +39,9 @@ class DeviceDriverBase { return {}; } + async init() { + } + async launchApp() { return NaN; } diff --git a/detox/src/devices/runtime/drivers/android/emulator/EmulatorDriver.js b/detox/src/devices/runtime/drivers/android/emulator/EmulatorDriver.js index 1b1333dbfb..6fce3c7a38 100644 --- a/detox/src/devices/runtime/drivers/android/emulator/EmulatorDriver.js +++ b/detox/src/devices/runtime/drivers/android/emulator/EmulatorDriver.js @@ -7,6 +7,7 @@ const AndroidDriver = require('../AndroidDriver'); /** * @typedef { AndroidDriverProps } EmulatorDriverProps + * @property adbServerPort { Number } * @property avdName { String } * @property forceAdbInstall { Boolean } */ @@ -16,13 +17,25 @@ class EmulatorDriver extends AndroidDriver { * @param deps { EmulatorDriverDeps } * @param props { EmulatorDriverProps } */ - constructor(deps, { adbName, avdName, forceAdbInstall }) { + constructor(deps, { adbName, adbServerPort, avdName, forceAdbInstall }) { super(deps, { adbName }); this._deviceName = `${adbName} (${avdName})`; + this._adbServerPort = adbServerPort; this._forceAdbInstall = forceAdbInstall; } + async init() { + // This here comes instead of the utilization of the adb-server ports registry, which fundamentally aims to serve + // the same purpose but in a more self-contained, pure way. It's required nonetheless as the right port needs + // not only to be set in the current process, but also to be propagated onto (external?) child processes which + // sometime need ADB access too (e.g. test reporters). + // IMPORTANT: This approach relies on a premise where this runtime driver is unique within it's running + // process. It will not work in a multi-device-in-one-process environment (case in which the registry should + // be reconsidered). + process.env.ANDROID_ADB_SERVER_PORT = this._adbServerPort; + } + getDeviceName() { return this._deviceName; } diff --git a/detox/src/devices/runtime/factories/android.js b/detox/src/devices/runtime/factories/android.js index cee58963f3..0cc3d6afd5 100644 --- a/detox/src/devices/runtime/factories/android.js +++ b/detox/src/devices/runtime/factories/android.js @@ -32,6 +32,7 @@ class AndroidEmulator extends RuntimeDriverFactoryAndroid { _createDriver(deviceCookie, deps, { deviceConfig }) { const props = { adbName: deviceCookie.adbName, + adbServerPort: deviceCookie.adbServerPort, avdName: deviceConfig.device.avdName, forceAdbInstall: deviceConfig.forceAdbInstall, }; From 08acca0877e6e57b752aa6e8414ee59f666697ad Mon Sep 17 00:00:00 2001 From: d4vidi Date: Wed, 7 Jan 2026 18:28:03 +0200 Subject: [PATCH 2/7] fix: Allocation for worker respawn --- .../drivers/android/FreeDeviceFinder.js | 4 ++ .../android/emulator/EmulatorAllocDriver.js | 19 +++++--- .../common/drivers/android/AdbPortRegistry.js | 7 +++ .../common/drivers/android/exec/ADB.js | 47 ++++++++++++++----- .../common/drivers/android/exec/ADB.test.js | 43 ++++++++++++----- 5 files changed, 87 insertions(+), 33 deletions(-) diff --git a/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.js b/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.js index bf0a74ee8d..b919bddf55 100644 --- a/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.js +++ b/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.js @@ -3,6 +3,10 @@ const log = require('../../../../utils/logger').child({ cat: 'device' }); const DEVICE_LOOKUP = { event: 'DEVICE_LOOKUP' }; class FreeDeviceFinder { + /** + * @param {import('../../../common/drivers/android/exec/ADB')} adb + * @param {import('../../DeviceRegistry')} deviceRegistry + */ constructor(adb, deviceRegistry) { this.adb = adb; this.deviceRegistry = deviceRegistry; diff --git a/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js b/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js index a880a37fd1..96280f66c6 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js +++ b/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js @@ -6,12 +6,11 @@ const _ = require('lodash'); const log = require('../../../../../utils/logger').child({ cat: 'device,device-allocation' }); +const adbPortRegistry = require('../../../../common/drivers/android/AdbPortRegistry'); const AndroidAllocDriver = require('../AndroidAllocDriver'); const { patchAvdSkinConfig } = require('./patchAvdSkinConfig'); -const ADB_SERVER_BASE_PORT = 5037; - class EmulatorAllocDriver extends AndroidAllocDriver { /** * @param {object} options @@ -43,7 +42,7 @@ class EmulatorAllocDriver extends AndroidAllocDriver { this._freePortFinder = freePortFinder; this._shouldShutdown = detoxConfig.behavior.cleanup.shutdownDevice; this._fixAvdConfigIniSkinNameIfNeeded = _.memoize(this._fixAvdConfigIniSkinNameIfNeeded.bind(this)); - this._adbServerPortCounter = ADB_SERVER_BASE_PORT; + this._nextAdbServerPort = adb.baseServerPort + 1; } async init() { @@ -60,13 +59,19 @@ class EmulatorAllocDriver extends AndroidAllocDriver { await this._avdValidator.validate(avdName, deviceConfig.headless); await this._fixAvdConfigIniSkinNameIfNeeded(avdName, deviceConfig.headless); - const adbServerPort = ++this._adbServerPortCounter; + let adbServerPort; + let adbName; + + await this._deviceRegistry.registerDevice(async () => { + adbName = await this._freeDeviceFinder.findFreeDevice(avdName); - const adbName = await this._deviceRegistry.registerDevice(async () => { - let adbName = await this._freeDeviceFinder.findFreeDevice(avdName); - if (!adbName) { + if (adbName) { + adbServerPort = adbPortRegistry.getPort(adbName); + } else { const port = await this._freePortFinder.findFreePort(); + adbName = `emulator-${port}`; + adbServerPort = this._nextAdbServerPort++; await this._emulatorLauncher.launch({ bootArgs: deviceConfig.bootArgs, diff --git a/detox/src/devices/common/drivers/android/AdbPortRegistry.js b/detox/src/devices/common/drivers/android/AdbPortRegistry.js index e7e91d5e0f..806ce2fa67 100644 --- a/detox/src/devices/common/drivers/android/AdbPortRegistry.js +++ b/detox/src/devices/common/drivers/android/AdbPortRegistry.js @@ -18,6 +18,13 @@ class AdbPortRegistry { getPort(adbName) { return this._registry.get(adbName); } + + /** + * @returns { number[] } + */ + getAllPorts() { + return this._registry.values().toArray(); + } } module.exports = new AdbPortRegistry(); diff --git a/detox/src/devices/common/drivers/android/exec/ADB.js b/detox/src/devices/common/drivers/android/exec/ADB.js index 07d3b56a31..390dd95cad 100644 --- a/detox/src/devices/common/drivers/android/exec/ADB.js +++ b/detox/src/devices/common/drivers/android/exec/ADB.js @@ -17,6 +17,8 @@ const DEFAULT_INSTALL_OPTIONS = { retries: 3, }; +const ADB_SERVER_BASE_PORT = 5037; + class ADB { constructor() { this._cachedApiLevels = new Map(); @@ -25,23 +27,39 @@ class ADB { this.adbBin = getAdbPath(); } + get baseServerPort() { + return ADB_SERVER_BASE_PORT; + } + async startDaemon() { await this.adbCmd('', 'start-server', { retries: 0, verbosity: 'high' }); } async devices(options) { - const { stdout } = await this.adbCmd('', 'devices', { verbosity: 'high', ...options }); - /** @type {DeviceHandle[]} */ - const devices = _.chain(stdout) - .trim() - .split('\n') - .slice(1) - .map(s => _.trim(s)) - .map(s => s.startsWith('emulator-') - ? new EmulatorHandle(s) - : new DeviceHandle(s)) - .value(); - return { devices, stdout }; + let devices = []; + const stdouts = []; + const ports = [ADB_SERVER_BASE_PORT, ...adbPortRegistry.getAllPorts()]; + + for (const port of ports) { + const { stdout } = await this.adbCmdWithPort('', port, 'devices', { verbosity: 'high', ...options }); + devices.push( + _.chain(stdout) + .trim() + .split('\n') + .slice(1) + .map(s => _.trim(s)) + .value() + ); + stdouts.push(stdout); + } + + return { + devices: _.flatten(devices) + .map(s => s.startsWith('emulator-') + ? new EmulatorHandle(s) + : new DeviceHandle(s)), + stdout: stdouts.join('\n'), + }; } async getState(deviceId) { @@ -371,7 +389,10 @@ class ADB { } async adbCmd(deviceId, params, options = {}) { - const port = adbPortRegistry.getPort(deviceId); + return this.adbCmdWithPort(deviceId, adbPortRegistry.getPort(deviceId), params, options); + } + + async adbCmdWithPort(deviceId, port, params, options = {}) { const portFlag = port ? `-P ${port} ` : ''; const serial = deviceId ? `-s ${deviceId} ` : ''; const cmd = `"${this.adbBin}" ${portFlag}${serial}${params}`.replace(/\s+/g, ' ').trim(); diff --git a/detox/src/devices/common/drivers/android/exec/ADB.test.js b/detox/src/devices/common/drivers/android/exec/ADB.test.js index a2fd7ab25f..f19e95739e 100644 --- a/detox/src/devices/common/drivers/android/exec/ADB.test.js +++ b/detox/src/devices/common/drivers/android/exec/ADB.test.js @@ -36,38 +36,41 @@ describe('ADB', () => { spawnAndLog = require('../../../../../utils/childProcess').spawnAndLog; spawnWithRetriesAndLogs = require('../../../../../utils/childProcess').spawnWithRetriesAndLogs; - jest.mock('../AdbPortRegistry', () => ({ - register: jest.fn(), - getPort: jest.fn().mockReturnValue(undefined), - unregister: jest.fn(), - })); + jest.mock('../AdbPortRegistry'); adbPortRegistry = require('../AdbPortRegistry'); + adbPortRegistry.getAllPorts.mockReturnValue([]); ADB = require('./ADB'); adb = new ADB(); }); describe('devices', () => { + + const mockDeviceEntry = 'MOCK_SERIAL\tdevice'; + const wifiDeviceEntry = '192.168.60.101:6666\tdevice'; + const emulatorEntry = 'emulator-5554\tdevice'; + const emulator5556Entry = 'emulator-5556\toffline'; const mockDevices = [ - 'MOCK_SERIAL\tdevice', - '192.168.60.101:6666\tdevice', - 'emulator-5554\tdevice', - 'emulator-5556\toffline', + mockDeviceEntry, + wifiDeviceEntry, + emulatorEntry, + emulator5556Entry, ]; - const adbDevices = ['List of devices attached', ...mockDevices, ''].join('\n'); + const adbDevicesStdout = (devicePairs) => (['List of devices attached', ...devicePairs, '']).join('\n'); + const allAdbDevices = adbDevicesStdout(mockDevices); it(`should invoke ADB`, async () => { await adb.devices(); - expect(execWithRetriesAndLogs).toHaveBeenCalledWith(`"${adbBinPath}" devices`, { verbosity: 'high', retries: 1 }); + expect(execWithRetriesAndLogs).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`"${adbBinPath}" .*devices`)), { verbosity: 'high', retries: 1 }); expect(execWithRetriesAndLogs).toHaveBeenCalledTimes(1); }); it('should return proper, type-based device handles', async () => { - execWithRetriesAndLogs.mockReturnValue({ stdout: adbDevices }); + execWithRetriesAndLogs.mockReturnValue({ stdout: allAdbDevices }); const { devices, stdout } = await adb.devices(); - expect(stdout).toBe(adbDevices); + expect(stdout).toBe(allAdbDevices); expect(devices).toHaveLength(4); expect(devices).toEqual([ DeviceHandle.mock.instances[0], @@ -99,6 +102,20 @@ describe('ADB', () => { await adb.devices(options); expect(execWithRetriesAndLogs).toHaveBeenCalledWith(expect.any(String), options); }); + + it('should process devices correctly when adbPortRegistry.getAllPorts() returns a port array only on the 2nd call', async () => { + const regPort = 5038; + adbPortRegistry.getAllPorts.mockReturnValue([regPort]); + execWithRetriesAndLogs + .mockReturnValueOnce({ stdout: adbDevicesStdout([emulatorEntry]) }) // Result for the standard port (5037) + .mockReturnValueOnce({ stdout: adbDevicesStdout([emulator5556Entry]) }); // Result for the port from the adb-ports registry + + const { devices } = await adb.devices(); + + expect(devices).toHaveLength(2); + expect(EmulatorHandle).toHaveBeenCalledWith(emulatorEntry); + expect(EmulatorHandle).toHaveBeenCalledWith(emulator5556Entry); + }); }); describe('ADB Daemon (server)', () => { From 626a50116ef4fa4462dff4db8a83fb9ec8e12c78 Mon Sep 17 00:00:00 2001 From: d4vidi Date: Wed, 7 Jan 2026 20:18:05 +0200 Subject: [PATCH 3/7] fix: Allocation for emulator reuse --- .../drivers/android/FreeDeviceFinder.js | 18 +++-- .../drivers/android/FreeDeviceFinder.test.js | 23 ++----- .../attached/AttachedAndroidAllocDriver.js | 3 +- .../android/emulator/EmulatorAllocDriver.js | 69 ++++++++++++++----- .../emulator/FreeEmulatorFinder.test.js | 16 ++--- .../android/emulator/FreePortFinder.js | 18 +---- .../android/emulator/FreePortFinder.test.js | 32 ++------- .../android/emulator/launchEmulatorProcess.js | 5 -- .../common/drivers/android/AdbPortRegistry.js | 12 ++-- .../common/drivers/android/exec/ADB.js | 44 +++++++----- .../common/drivers/android/exec/ADB.test.js | 26 +++---- .../drivers/android/tools/DeviceHandle.js | 3 +- .../drivers/android/tools/EmulatorHandle.js | 4 +- detox/src/utils/netUtils.js | 56 +++++++++++++++ detox/src/utils/netUtils.test.js | 41 +++++++++++ 15 files changed, 235 insertions(+), 135 deletions(-) create mode 100644 detox/src/utils/netUtils.js create mode 100644 detox/src/utils/netUtils.test.js diff --git a/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.js b/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.js index b919bddf55..85b905aca6 100644 --- a/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.js +++ b/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.js @@ -1,3 +1,8 @@ +/** + * @typedef {import('../../../common/drivers/android/tools/DeviceHandle')} DeviceHandle + * @typedef {import('../../../common/drivers/android/tools/EmulatorHandle')} EmulatorHandle + */ + const log = require('../../../../utils/logger').child({ cat: 'device' }); const DEVICE_LOOKUP = { event: 'DEVICE_LOOKUP' }; @@ -12,12 +17,17 @@ class FreeDeviceFinder { this.deviceRegistry = deviceRegistry; } - async findFreeDevice(deviceQuery) { - const { devices } = await this.adb.devices(); + /** + * @param {DeviceHandle[]} candidates + * @param {string} deviceQuery + * @returns {Promise} + */ + async findFreeDevice(candidates, deviceQuery) { const takenDevices = this.deviceRegistry.getTakenDevicesSync(); - for (const candidate of devices) { + for (const candidate of candidates) { if (await this._isDeviceFreeAndMatching(takenDevices, candidate, deviceQuery)) { - return candidate.adbName; + // @ts-ignore + return candidate; } } return null; diff --git a/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.test.js b/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.test.js index 570d74386c..e86f9b8f71 100644 --- a/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.test.js +++ b/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.test.js @@ -4,8 +4,6 @@ const FreeDeviceFinder = require('./FreeDeviceFinder'); const { deviceOffline, emulator5556, ip5557, localhost5555 } = require('./__mocks__/handles'); describe('FreeDeviceFinder', () => { - const mockAdb = { devices: jest.fn() }; - /** @type {DeviceList} */ let fakeDeviceList; let mockDeviceRegistry; @@ -17,42 +15,35 @@ describe('FreeDeviceFinder', () => { mockDeviceRegistry = new DeviceRegistry(); mockDeviceRegistry.getTakenDevicesSync.mockImplementation(() => fakeDeviceList); + const mockAdb = /** @type {any} */ ({}); uut = new FreeDeviceFinder(mockAdb, mockDeviceRegistry); }); it('should return the only device when it matches, is online and not already taken by other workers', async () => { - mockAdbDevices([emulator5556]); - - const result = await uut.findFreeDevice(emulator5556.adbName); - expect(result).toEqual(emulator5556.adbName); + const result = await uut.findFreeDevice([emulator5556], emulator5556.adbName); + expect(result).toEqual(emulator5556); }); it('should return null when there are no devices', async () => { - mockAdbDevices([]); - - const result = await uut.findFreeDevice(emulator5556.adbName); + const result = await uut.findFreeDevice([], emulator5556.adbName); expect(result).toEqual(null); }); it('should return null when device is already taken by other workers', async () => { - mockAdbDevices([emulator5556]); mockAllDevicesTaken(); - expect(await uut.findFreeDevice(emulator5556.adbName)).toEqual(null); + expect(await uut.findFreeDevice([emulator5556], emulator5556.adbName)).toEqual(null); }); it('should return null when device is offline', async () => { - mockAdbDevices([deviceOffline]); - expect(await uut.findFreeDevice(deviceOffline.adbName)).toEqual(null); + expect(await uut.findFreeDevice([deviceOffline], deviceOffline.adbName)).toEqual(null); }); it('should return first device that matches a regular expression', async () => { - mockAdbDevices([emulator5556, localhost5555, ip5557]); const localhost = '^localhost:\\d+$'; - expect(await uut.findFreeDevice(localhost)).toBe(localhost5555.adbName); + expect(await uut.findFreeDevice([emulator5556, localhost5555, ip5557], localhost)).toBe(localhost5555); }); - const mockAdbDevices = (devices) => mockAdb.devices.mockResolvedValue({ devices }); const mockAllDevicesTaken = () => { fakeDeviceList.add(emulator5556.adbName, { busy: true }); fakeDeviceList.add(localhost5555.adbName, { busy: true }); diff --git a/detox/src/devices/allocation/drivers/android/attached/AttachedAndroidAllocDriver.js b/detox/src/devices/allocation/drivers/android/attached/AttachedAndroidAllocDriver.js index 136c6662ce..c78f40175c 100644 --- a/detox/src/devices/allocation/drivers/android/attached/AttachedAndroidAllocDriver.js +++ b/detox/src/devices/allocation/drivers/android/attached/AttachedAndroidAllocDriver.js @@ -27,7 +27,8 @@ class AttachedAndroidAllocDriver extends AndroidAllocDriver { */ async allocate(deviceConfig) { const adbNamePattern = deviceConfig.device.adbName; - const adbName = await this._deviceRegistry.registerDevice(() => this._freeDeviceFinder.findFreeDevice(adbNamePattern)); + const adbName = await this._deviceRegistry.registerDevice(async () => + (await this._freeDeviceFinder.findFreeDevice(adbNamePattern)).adbName); return { id: adbName, adbName }; } diff --git a/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js b/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js index 96280f66c6..6d6a7363c1 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js +++ b/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js @@ -1,11 +1,13 @@ /** * @typedef {import('../../../../common/drivers/android/cookies').AndroidDeviceCookie} AndroidDeviceCookie * @typedef {import('../../../../common/drivers/android/cookies').EmulatorDeviceCookie} EmulatorDeviceCookie + * @typedef {import('../../../../common/drivers/android/tools/DeviceHandle')} DeviceHandle */ const _ = require('lodash'); const log = require('../../../../../utils/logger').child({ cat: 'device,device-allocation' }); +const { isPortTaken } = require('../../../../../utils/netUtils'); const adbPortRegistry = require('../../../../common/drivers/android/AdbPortRegistry'); const AndroidAllocDriver = require('../AndroidAllocDriver'); @@ -42,7 +44,6 @@ class EmulatorAllocDriver extends AndroidAllocDriver { this._freePortFinder = freePortFinder; this._shouldShutdown = detoxConfig.behavior.cleanup.shutdownDevice; this._fixAvdConfigIniSkinNameIfNeeded = _.memoize(this._fixAvdConfigIniSkinNameIfNeeded.bind(this)); - this._nextAdbServerPort = adb.baseServerPort + 1; } async init() { @@ -63,26 +64,34 @@ class EmulatorAllocDriver extends AndroidAllocDriver { let adbName; await this._deviceRegistry.registerDevice(async () => { - adbName = await this._freeDeviceFinder.findFreeDevice(avdName); + const candidates = await this._getAllDevices(); + const device = await this._freeDeviceFinder.findFreeDevice(candidates, avdName); - if (adbName) { - adbServerPort = adbPortRegistry.getPort(adbName); + if (device) { + adbName = device.adbName; + adbServerPort = device.adbServerPort; + adbPortRegistry.register(adbName, adbServerPort); } else { const port = await this._freePortFinder.findFreePort(); adbName = `emulator-${port}`; - adbServerPort = this._nextAdbServerPort++; - - await this._emulatorLauncher.launch({ - bootArgs: deviceConfig.bootArgs, - gpuMode: deviceConfig.gpuMode, - headless: deviceConfig.headless, - readonly: deviceConfig.readonly, - avdName, - adbName, - port, - adbServerPort, - }); + adbServerPort = this._getFreeAdbServerPort(candidates); + adbPortRegistry.register(adbName, adbServerPort); + + try { + await this._emulatorLauncher.launch({ + bootArgs: deviceConfig.bootArgs, + gpuMode: deviceConfig.gpuMode, + headless: deviceConfig.headless, + readonly: deviceConfig.readonly, + avdName, + adbName, + port, + adbServerPort, + }); + } catch (e) { + adbPortRegistry.unregister(adbName); + } } return adbName; @@ -131,7 +140,7 @@ class EmulatorAllocDriver extends AndroidAllocDriver { async cleanup() { if (this._shouldShutdown) { - const { devices } = await this._adb.devices(); + const devices = await this._getAllDevices(); const actualEmulators = devices.map((device) => device.adbName); const sessionDevices = await this._deviceRegistry.readSessionDevices(); const emulatorsToShutdown = _.intersection(sessionDevices.getIds(), actualEmulators); @@ -159,6 +168,32 @@ class EmulatorAllocDriver extends AndroidAllocDriver { const binaryVersion = _.get(rawBinaryVersion, 'major'); return await patchAvdSkinConfig(avdName, binaryVersion); } + + /** + * @returns {Promise} + * @private + */ + async _getAllDevices() { + const adbServers = await this._getRunningAdbServers(); + return (await this._adb.devices({}, adbServers)).devices; + } + + /** + * @returns {Promise} + * @private + */ + async _getRunningAdbServers() { + const ports = []; + for (let port = this._adb.defaultServerPort + 1; await isPortTaken(port); port++) { + ports.push(port); + } + return ports; + } + + _getFreeAdbServerPort(currentDevices) { + const maxPortDevice = _.maxBy(currentDevices, 'adbServerPort'); + return _.get(maxPortDevice, 'adbServerPort', this._adb.defaultServerPort) + 1; + } } module.exports = EmulatorAllocDriver; diff --git a/detox/src/devices/allocation/drivers/android/emulator/FreeEmulatorFinder.test.js b/detox/src/devices/allocation/drivers/android/emulator/FreeEmulatorFinder.test.js index 8b24467428..2654a6daf4 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/FreeEmulatorFinder.test.js +++ b/detox/src/devices/allocation/drivers/android/emulator/FreeEmulatorFinder.test.js @@ -2,8 +2,6 @@ const DeviceList = require('../../../DeviceList'); const { emulator5556, localhost5555, mockAvdName } = require('../__mocks__/handles'); describe('FreeEmulatorFinder', () => { - const mockAdb = { devices: jest.fn() }; - /** @type {DeviceList} */ let fakeDeviceList; /** @type {jest.Mocked} */ @@ -18,24 +16,20 @@ describe('FreeEmulatorFinder', () => { mockDeviceRegistry.getTakenDevicesSync.mockImplementation(() => fakeDeviceList); const FreeEmulatorFinder = require('./FreeEmulatorFinder'); + const mockAdb = /** @type {any} */ ({}); uut = new FreeEmulatorFinder(mockAdb, mockDeviceRegistry); }); it('should return device when it is an emulator and avdName matches', async () => { - mockAdbDevices([emulator5556]); - const result = await uut.findFreeDevice(mockAvdName); - expect(result).toBe(emulator5556.adbName); + const result = await uut.findFreeDevice([emulator5556], mockAvdName); + expect(result).toBe(emulator5556); }); it('should return null when avdName does not match', async () => { - mockAdbDevices([emulator5556]); - expect(await uut.findFreeDevice('wrongAvdName')).toBe(null); + expect(await uut.findFreeDevice([emulator5556], 'wrongAvdName')).toBe(null); }); it('should return null when not an emulator', async () => { - mockAdbDevices([localhost5555]); - expect(await uut.findFreeDevice(mockAvdName)).toBe(null); + expect(await uut.findFreeDevice([localhost5555], mockAvdName)).toBe(null); }); - - const mockAdbDevices = (devices) => mockAdb.devices.mockResolvedValue({ devices }); }); diff --git a/detox/src/devices/allocation/drivers/android/emulator/FreePortFinder.js b/detox/src/devices/allocation/drivers/android/emulator/FreePortFinder.js index 37263e87f1..e13feb9d74 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/FreePortFinder.js +++ b/detox/src/devices/allocation/drivers/android/emulator/FreePortFinder.js @@ -1,4 +1,4 @@ -const net = require('net'); +const { isPortTaken } = require('../../../../../utils/netUtils'); class FreePortFinder { constructor({ min = 10000, max = 20000 } = {}) { @@ -14,24 +14,10 @@ class FreePortFinder { const max = this._max; port = Math.random() * (max - min) + min; port = port & 0xFFFFFFFE; // Should always be even - } while (await this.isPortTaken(port)); + } while (await isPortTaken(port)); return port; } - - async isPortTaken(port) { - return new Promise((resolve, reject) => { - const tester = net.createServer() - .once('error', /** @param {*} err */ (err) => { - /* istanbul ignore next */ - return err && err.code === 'EADDRINUSE' ? resolve(true) : reject(err); - }) - .once('listening', () => { - tester.once('close', () => resolve(false)).close(); - }) - .listen(port); - }); - } } module.exports = FreePortFinder; diff --git a/detox/src/devices/allocation/drivers/android/emulator/FreePortFinder.test.js b/detox/src/devices/allocation/drivers/android/emulator/FreePortFinder.test.js index cdacc725fd..b2eb9885fb 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/FreePortFinder.test.js +++ b/detox/src/devices/allocation/drivers/android/emulator/FreePortFinder.test.js @@ -1,21 +1,14 @@ -const net = require('net'); - -const FreePortFinder = require('./FreePortFinder'); - describe('FreePortFinder', () => { let finder; - let server; + let isPortTaken; beforeEach(() => { - finder = new FreePortFinder(); - }); + jest.mock('../../../../../utils/netUtils'); + isPortTaken = require('../../../../../utils/netUtils').isPortTaken; + isPortTaken.mockResolvedValue(false); - afterEach(done => { - if (server) { - server.close(done); - } else { - done(); - } + const FreePortFinder = require('./FreePortFinder'); + finder = new FreePortFinder(); }); test('should find a free port', async () => { @@ -23,17 +16,6 @@ describe('FreePortFinder', () => { expect(port).toBeGreaterThanOrEqual(10000); expect(port).toBeLessThanOrEqual(20000); expect(port % 2).toBe(0); - await expect(finder.isPortTaken(port)).resolves.toBe(false); - }); - - test('should identify a taken port', async () => { - server = net.createServer(); - const portTaken = await new Promise(resolve => { - server.listen(0, () => { // 0 means random available port - resolve(server.address().port); - }); - }); - - await expect(finder.isPortTaken(portTaken)).resolves.toBe(true); + expect(isPortTaken).toHaveBeenCalled(); }); }); diff --git a/detox/src/devices/allocation/drivers/android/emulator/launchEmulatorProcess.js b/detox/src/devices/allocation/drivers/android/emulator/launchEmulatorProcess.js index bc4aac2bc9..1f17d27205 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/launchEmulatorProcess.js +++ b/detox/src/devices/allocation/drivers/android/emulator/launchEmulatorProcess.js @@ -9,7 +9,6 @@ const fs = require('fs'); const _ = require('lodash'); const unitLogger = require('../../../../../utils/logger').child({ cat: 'device' }); -const adbPortRegistry = require('../../../../common/drivers/android/AdbPortRegistry'); /** * @param { EmulatorExec } emulatorExec - Instance for executing emulator commands @@ -48,10 +47,6 @@ function launchEmulatorProcess(emulatorExec, adb, emulatorLaunchCommand, adbServ }); childProcessPromise.childProcess.unref(); - if (adbServerPort) { - adbPortRegistry.register(emulatorLaunchCommand.adbName, adbServerPort); - } - log = log.child({ child_pid: childProcessPromise.childProcess.pid }); // Create a deferred promise that resolves when the device is ready diff --git a/detox/src/devices/common/drivers/android/AdbPortRegistry.js b/detox/src/devices/common/drivers/android/AdbPortRegistry.js index 806ce2fa67..b5b3f818b5 100644 --- a/detox/src/devices/common/drivers/android/AdbPortRegistry.js +++ b/detox/src/devices/common/drivers/android/AdbPortRegistry.js @@ -13,17 +13,17 @@ class AdbPortRegistry { /** * @param { string } adbName - * @returns { number | undefined } */ - getPort(adbName) { - return this._registry.get(adbName); + unregister(adbName) { + this._registry.delete(adbName); } /** - * @returns { number[] } + * @param { string } adbName + * @returns { number | undefined } */ - getAllPorts() { - return this._registry.values().toArray(); + getPort(adbName) { + return this._registry.get(adbName); } } diff --git a/detox/src/devices/common/drivers/android/exec/ADB.js b/detox/src/devices/common/drivers/android/exec/ADB.js index 390dd95cad..fc665cc360 100644 --- a/detox/src/devices/common/drivers/android/exec/ADB.js +++ b/detox/src/devices/common/drivers/android/exec/ADB.js @@ -4,6 +4,7 @@ const _ = require('lodash'); const DetoxRuntimeError = require('../../../../../errors/DetoxRuntimeError'); const { execWithRetriesAndLogs, spawnWithRetriesAndLogs, spawnAndLog } = require('../../../../../utils/childProcess'); const { getAdbPath } = require('../../../../../utils/environment'); +const { isPortTaken } = require('../../../../../utils/netUtils'); const { escape } = require('../../../../../utils/pipeCommands'); const adbPortRegistry = require('../AdbPortRegistry'); const DeviceHandle = require('../tools/DeviceHandle'); @@ -17,7 +18,7 @@ const DEFAULT_INSTALL_OPTIONS = { retries: 3, }; -const ADB_SERVER_BASE_PORT = 5037; +const ADB_SERVER_PORT = 5037; class ADB { constructor() { @@ -27,37 +28,42 @@ class ADB { this.adbBin = getAdbPath(); } - get baseServerPort() { - return ADB_SERVER_BASE_PORT; + get defaultServerPort() { + return ADB_SERVER_PORT; } async startDaemon() { await this.adbCmd('', 'start-server', { retries: 0, verbosity: 'high' }); } - async devices(options) { - let devices = []; + /** + * @returns {Promise<{devices: DeviceHandle[], stdout: string}>} + */ + async devices(options, ports = [ADB_SERVER_PORT]) { + const devicesByPort = {}; const stdouts = []; - const ports = [ADB_SERVER_BASE_PORT, ...adbPortRegistry.getAllPorts()]; - for (const port of ports) { + for (let port of ports) { const { stdout } = await this.adbCmdWithPort('', port, 'devices', { verbosity: 'high', ...options }); - devices.push( - _.chain(stdout) - .trim() - .split('\n') - .slice(1) - .map(s => _.trim(s)) - .value() - ); + devicesByPort[port] = _.chain(stdout) + .trim() + .split('\n') + .slice(1) + .map(s => _.trim(s)) + .value(); stdouts.push(stdout); } + const devices = + _.flatMap(Object.keys(devicesByPort), port => + _.map(devicesByPort[port], + s => s.startsWith('emulator-') + ? new EmulatorHandle(s, Number(port)) + : new DeviceHandle(s, Number(port)) + )); + return { - devices: _.flatten(devices) - .map(s => s.startsWith('emulator-') - ? new EmulatorHandle(s) - : new DeviceHandle(s)), + devices, stdout: stdouts.join('\n'), }; } diff --git a/detox/src/devices/common/drivers/android/exec/ADB.test.js b/detox/src/devices/common/drivers/android/exec/ADB.test.js index f19e95739e..d61eec69a3 100644 --- a/detox/src/devices/common/drivers/android/exec/ADB.test.js +++ b/detox/src/devices/common/drivers/android/exec/ADB.test.js @@ -2,6 +2,7 @@ describe('ADB', () => { const deviceId = 'mockEmulator'; const adbBinPath = `/Android/sdk-mock/platform-tools/adb`; + const baseAdbServerPort = 5037; let ADB; let adb; @@ -11,6 +12,7 @@ describe('ADB', () => { let spawnAndLog; let spawnWithRetriesAndLogs; let adbPortRegistry; + let isPortTaken; beforeEach(() => { jest.mock('../../../../../utils/logger'); @@ -38,14 +40,16 @@ describe('ADB', () => { jest.mock('../AdbPortRegistry'); adbPortRegistry = require('../AdbPortRegistry'); - adbPortRegistry.getAllPorts.mockReturnValue([]); + + jest.mock('../../../../../utils/netUtils'); + isPortTaken = require('../../../../../utils/netUtils').isPortTaken; + isPortTaken.mockResolvedValueOnce(true); ADB = require('./ADB'); adb = new ADB(); }); describe('devices', () => { - const mockDeviceEntry = 'MOCK_SERIAL\tdevice'; const wifiDeviceEntry = '192.168.60.101:6666\tdevice'; const emulatorEntry = 'emulator-5554\tdevice'; @@ -78,10 +82,10 @@ describe('ADB', () => { EmulatorHandle.mock.instances[0], EmulatorHandle.mock.instances[1], ]); - expect(DeviceHandle).toHaveBeenCalledWith(mockDevices[0]); - expect(DeviceHandle).toHaveBeenCalledWith(mockDevices[1]); - expect(EmulatorHandle).toHaveBeenCalledWith(mockDevices[2]); - expect(EmulatorHandle).toHaveBeenCalledWith(mockDevices[3]); + expect(DeviceHandle).toHaveBeenCalledWith(mockDevices[0], baseAdbServerPort); + expect(DeviceHandle).toHaveBeenCalledWith(mockDevices[1], baseAdbServerPort); + expect(EmulatorHandle).toHaveBeenCalledWith(mockDevices[2], baseAdbServerPort); + expect(EmulatorHandle).toHaveBeenCalledWith(mockDevices[3], baseAdbServerPort); }); it(`should return an empty list if no devices are available`, async () => { @@ -103,18 +107,16 @@ describe('ADB', () => { expect(execWithRetriesAndLogs).toHaveBeenCalledWith(expect.any(String), options); }); - it('should process devices correctly when adbPortRegistry.getAllPorts() returns a port array only on the 2nd call', async () => { - const regPort = 5038; - adbPortRegistry.getAllPorts.mockReturnValue([regPort]); + it('should process devices correctly when emulators are on multiple adb-server', async () => { execWithRetriesAndLogs .mockReturnValueOnce({ stdout: adbDevicesStdout([emulatorEntry]) }) // Result for the standard port (5037) .mockReturnValueOnce({ stdout: adbDevicesStdout([emulator5556Entry]) }); // Result for the port from the adb-ports registry - const { devices } = await adb.devices(); + const { devices } = await adb.devices({}, [baseAdbServerPort, baseAdbServerPort + 1]); expect(devices).toHaveLength(2); - expect(EmulatorHandle).toHaveBeenCalledWith(emulatorEntry); - expect(EmulatorHandle).toHaveBeenCalledWith(emulator5556Entry); + expect(EmulatorHandle).toHaveBeenCalledWith(emulatorEntry, baseAdbServerPort); + expect(EmulatorHandle).toHaveBeenCalledWith(emulator5556Entry, baseAdbServerPort + 1); }); }); diff --git a/detox/src/devices/common/drivers/android/tools/DeviceHandle.js b/detox/src/devices/common/drivers/android/tools/DeviceHandle.js index 3ee667fba5..ea1f21491e 100644 --- a/detox/src/devices/common/drivers/android/tools/DeviceHandle.js +++ b/detox/src/devices/common/drivers/android/tools/DeviceHandle.js @@ -1,9 +1,10 @@ class DeviceHandle { - constructor(deviceString) { + constructor(deviceString, adbServerPort) { const [adbName, status] = deviceString.split('\t'); this.type = this._inferDeviceType(adbName); this.adbName = adbName; this.status = status; + this.adbServerPort = adbServerPort; } _inferDeviceType(adbName) { diff --git a/detox/src/devices/common/drivers/android/tools/EmulatorHandle.js b/detox/src/devices/common/drivers/android/tools/EmulatorHandle.js index 7b9f401c18..09c3333819 100644 --- a/detox/src/devices/common/drivers/android/tools/EmulatorHandle.js +++ b/detox/src/devices/common/drivers/android/tools/EmulatorHandle.js @@ -2,8 +2,8 @@ const DeviceHandle = require('./DeviceHandle'); const EmulatorTelnet = require('./EmulatorTelnet'); class EmulatorHandle extends DeviceHandle { - constructor(deviceString) { - super(deviceString); + constructor(deviceString, adbServerPort) { + super(deviceString, adbServerPort); this._telnet = new EmulatorTelnet(); this.port = this.adbName.split('-')[1]; diff --git a/detox/src/utils/netUtils.js b/detox/src/utils/netUtils.js new file mode 100644 index 0000000000..e92797f952 --- /dev/null +++ b/detox/src/utils/netUtils.js @@ -0,0 +1,56 @@ +const net = require('net'); + +/** + * Checks if a given port is currently taken (in use) + * Uses a two-step approach: + * 1. First tries to connect to the port (detects services like ADB that are listening) + * 2. If connection fails, tries to bind to the port (detects if port is in use) + * @param {number} port - The port number to check + * @returns {Promise} - Resolves to true if the port is taken, false if it's available + */ +async function isPortTaken(port) { + return new Promise((resolve, reject) => { + function tryBind() { + const tester = net.createServer() + .once('error', /** @param {*} err */ (err) => { + /* istanbul ignore next */ + return err && err.code === 'EADDRINUSE' ? resolve(true) : reject(err); + }) + .once('listening', () => { + tester.once('close', () => resolve(false)).close(); + }) + .listen(port); + } + + // Try to connect to the port to detect if something is listening (e.g., ADB server) + const socket = new net.Socket(); + const timeout = setTimeout(() => { + socket.destroy(); + // Connection timeout means port might be free, try binding instead + tryBind(); + }, 100); + + socket.once('connect', () => { + clearTimeout(timeout); + socket.destroy(); + resolve(true); + }); + + socket.once('error', /** @param {NodeJS.ErrnoException} err */ (err) => { + clearTimeout(timeout); + if (err.code === 'ECONNREFUSED') { + // Connection refused means nothing is listening, try binding to confirm + tryBind(); + } else { + // Other errors might indicate port is in use, try binding to confirm + tryBind(); + } + }); + + socket.connect(port, 'localhost'); + }); +} + +module.exports = { + isPortTaken, +}; diff --git a/detox/src/utils/netUtils.test.js b/detox/src/utils/netUtils.test.js new file mode 100644 index 0000000000..f8d359f84a --- /dev/null +++ b/detox/src/utils/netUtils.test.js @@ -0,0 +1,41 @@ +const net = require('net'); + +const { isPortTaken } = require('./netUtils'); + +describe('Network utils', () => { + let server; + + afterEach(done => { + if (server) { + server.close(done); + } else { + done(); + } + }); + + test('should identify a taken port', async () => { + server = net.createServer(); + const portTaken = await new Promise(resolve => { + server.listen(0, () => { + resolve(server.address().port); + }); + }); + + await expect(isPortTaken(portTaken)).resolves.toEqual(true); + }); + + test('should identify an available port', async () => { + server = net.createServer(); + const availablePort = await new Promise(resolve => { + server.listen(0, () => { + const port = server.address().port; + server.close(() => { + resolve(port); + server = null; + }); + }); + }); + + await expect(isPortTaken(availablePort)).resolves.toEqual(false); + }); +}); From 3090565997396abdc7add91b19805b703660e9b0 Mon Sep 17 00:00:00 2001 From: d4vidi Date: Thu, 8 Jan 2026 16:18:06 +0200 Subject: [PATCH 4/7] clean up, fix unit tests, coverage --- detox/jest.config.js | 1 + .../drivers/android/FreeDeviceFinder.js | 4 +- .../drivers/android/FreeDeviceFinder.test.js | 3 +- .../android/emulator/FreeEmulatorFinder.js | 7 +++ .../emulator/FreeEmulatorFinder.test.js | 3 +- .../devices/allocation/factories/android.js | 4 +- .../drivers/android/AdbPortRegistry.test.js | 46 +++++++++++++++++++ .../common/drivers/android/exec/ADB.js | 1 - .../common/drivers/android/exec/ADB.test.js | 5 -- .../src/devices/runtime/RuntimeDevice.test.js | 1 + detox/src/utils/netUtils.js | 15 ++---- 11 files changed, 65 insertions(+), 25 deletions(-) create mode 100644 detox/src/devices/common/drivers/android/AdbPortRegistry.test.js diff --git a/detox/jest.config.js b/detox/jest.config.js index dabfd23274..509ed59ac0 100644 --- a/detox/jest.config.js +++ b/detox/jest.config.js @@ -71,6 +71,7 @@ module.exports = { 'src/utils/pressAnyKey.js', 'src/utils/repl.js', 'src/utils/shellUtils.js', + 'src/utils/netUtils.js', 'runners/jest/reporters', 'runners/jest/testEnvironment', 'src/DetoxWorker.js', diff --git a/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.js b/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.js index 85b905aca6..ccbb84a78d 100644 --- a/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.js +++ b/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.js @@ -9,11 +9,9 @@ const DEVICE_LOOKUP = { event: 'DEVICE_LOOKUP' }; class FreeDeviceFinder { /** - * @param {import('../../../common/drivers/android/exec/ADB')} adb * @param {import('../../DeviceRegistry')} deviceRegistry */ - constructor(adb, deviceRegistry) { - this.adb = adb; + constructor(deviceRegistry) { this.deviceRegistry = deviceRegistry; } diff --git a/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.test.js b/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.test.js index e86f9b8f71..b7110b7e4f 100644 --- a/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.test.js +++ b/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.test.js @@ -15,8 +15,7 @@ describe('FreeDeviceFinder', () => { mockDeviceRegistry = new DeviceRegistry(); mockDeviceRegistry.getTakenDevicesSync.mockImplementation(() => fakeDeviceList); - const mockAdb = /** @type {any} */ ({}); - uut = new FreeDeviceFinder(mockAdb, mockDeviceRegistry); + uut = new FreeDeviceFinder(mockDeviceRegistry); }); it('should return the only device when it matches, is online and not already taken by other workers', async () => { diff --git a/detox/src/devices/allocation/drivers/android/emulator/FreeEmulatorFinder.js b/detox/src/devices/allocation/drivers/android/emulator/FreeEmulatorFinder.js index 30c4771ff4..46c2e597e3 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/FreeEmulatorFinder.js +++ b/detox/src/devices/allocation/drivers/android/emulator/FreeEmulatorFinder.js @@ -1,6 +1,13 @@ const FreeDeviceFinder = require('../FreeDeviceFinder'); class FreeEmulatorFinder extends FreeDeviceFinder { + /** + * @param {import('../../../DeviceRegistry')} deviceRegistry + */ + constructor(deviceRegistry) { + super(deviceRegistry); + } + /** * @override */ diff --git a/detox/src/devices/allocation/drivers/android/emulator/FreeEmulatorFinder.test.js b/detox/src/devices/allocation/drivers/android/emulator/FreeEmulatorFinder.test.js index 2654a6daf4..c369d028c8 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/FreeEmulatorFinder.test.js +++ b/detox/src/devices/allocation/drivers/android/emulator/FreeEmulatorFinder.test.js @@ -16,8 +16,7 @@ describe('FreeEmulatorFinder', () => { mockDeviceRegistry.getTakenDevicesSync.mockImplementation(() => fakeDeviceList); const FreeEmulatorFinder = require('./FreeEmulatorFinder'); - const mockAdb = /** @type {any} */ ({}); - uut = new FreeEmulatorFinder(mockAdb, mockDeviceRegistry); + uut = new FreeEmulatorFinder(mockDeviceRegistry); }); it('should return device when it is an emulator and avdName matches', async () => { diff --git a/detox/src/devices/allocation/factories/android.js b/detox/src/devices/allocation/factories/android.js index 900d0c94e8..a4041e4f96 100644 --- a/detox/src/devices/allocation/factories/android.js +++ b/detox/src/devices/allocation/factories/android.js @@ -19,7 +19,7 @@ class AndroidEmulator extends DeviceAllocatorFactory { const avdValidator = new AVDValidator(avdsResolver, emulatorVersionResolver); const FreeEmulatorFinder = require('../drivers/android/emulator/FreeEmulatorFinder'); - const freeEmulatorFinder = new FreeEmulatorFinder(adb, deviceRegistry); + const freeEmulatorFinder = new FreeEmulatorFinder(deviceRegistry); const FreePortFinder = require('../drivers/android/emulator/FreePortFinder'); const freePortFinder = new FreePortFinder(); @@ -49,7 +49,7 @@ class AndroidAttached extends DeviceAllocatorFactory { const deviceRegistry = new DeviceRegistry({ sessionId: detoxSession.id }); const FreeDeviceFinder = require('../drivers/android/FreeDeviceFinder'); - const freeDeviceFinder = new FreeDeviceFinder(adb, deviceRegistry); + const freeDeviceFinder = new FreeDeviceFinder(deviceRegistry); const AttachedAndroidAllocDriver = require('../drivers/android/attached/AttachedAndroidAllocDriver'); return new AttachedAndroidAllocDriver({ adb, deviceRegistry, freeDeviceFinder }); diff --git a/detox/src/devices/common/drivers/android/AdbPortRegistry.test.js b/detox/src/devices/common/drivers/android/AdbPortRegistry.test.js new file mode 100644 index 0000000000..21a3d7f9c3 --- /dev/null +++ b/detox/src/devices/common/drivers/android/AdbPortRegistry.test.js @@ -0,0 +1,46 @@ +describe('AdbPortRegistry', () => { + let registry; + + beforeEach(() => { + registry = require('./AdbPortRegistry'); + registry._registry.clear(); + }); + + it('should register a device with a port', () => { + registry.register('emulator-5554', 5038); + expect(registry.getPort('emulator-5554')).toEqual(5038); + }); + + it('should allow registering multiple devices', () => { + registry.register('emulator-5554', 5038); + registry.register('emulator-5556', 5039); + registry.register('localhost:5555', 5040); + + expect(registry.getPort('emulator-5554')).toEqual(5038); + expect(registry.getPort('emulator-5556')).toEqual(5039); + expect(registry.getPort('localhost:5555')).toEqual(5040); + }); + + it('should overwrite existing registration when registering the same device again', () => { + registry.register('emulator-5554', 5038); + registry.register('emulator-5554', 5040); + expect(registry.getPort('emulator-5554')).toEqual(5040); + }); + + describe('unregister', () => { + it('should remove a registered device', () => { + registry.register('emulator-5554', 5038); + registry.register('emulator-5556', 5039); + + registry.unregister('emulator-5554'); + expect(registry.getPort('emulator-5554')).toBeUndefined(); + expect(registry.getPort('emulator-5556')).toEqual(5039); + }); + + it('should not throw when unregistering a non-existent device', () => { + expect(() => { + registry.unregister('non-existent-device'); + }).not.toThrow(); + }); + }); +}); diff --git a/detox/src/devices/common/drivers/android/exec/ADB.js b/detox/src/devices/common/drivers/android/exec/ADB.js index fc665cc360..6746f4359d 100644 --- a/detox/src/devices/common/drivers/android/exec/ADB.js +++ b/detox/src/devices/common/drivers/android/exec/ADB.js @@ -4,7 +4,6 @@ const _ = require('lodash'); const DetoxRuntimeError = require('../../../../../errors/DetoxRuntimeError'); const { execWithRetriesAndLogs, spawnWithRetriesAndLogs, spawnAndLog } = require('../../../../../utils/childProcess'); const { getAdbPath } = require('../../../../../utils/environment'); -const { isPortTaken } = require('../../../../../utils/netUtils'); const { escape } = require('../../../../../utils/pipeCommands'); const adbPortRegistry = require('../AdbPortRegistry'); const DeviceHandle = require('../tools/DeviceHandle'); diff --git a/detox/src/devices/common/drivers/android/exec/ADB.test.js b/detox/src/devices/common/drivers/android/exec/ADB.test.js index d61eec69a3..ac86226b61 100644 --- a/detox/src/devices/common/drivers/android/exec/ADB.test.js +++ b/detox/src/devices/common/drivers/android/exec/ADB.test.js @@ -12,7 +12,6 @@ describe('ADB', () => { let spawnAndLog; let spawnWithRetriesAndLogs; let adbPortRegistry; - let isPortTaken; beforeEach(() => { jest.mock('../../../../../utils/logger'); @@ -41,10 +40,6 @@ describe('ADB', () => { jest.mock('../AdbPortRegistry'); adbPortRegistry = require('../AdbPortRegistry'); - jest.mock('../../../../../utils/netUtils'); - isPortTaken = require('../../../../../utils/netUtils').isPortTaken; - isPortTaken.mockResolvedValueOnce(true); - ADB = require('./ADB'); adb = new ADB(); }); diff --git a/detox/src/devices/runtime/RuntimeDevice.test.js b/detox/src/devices/runtime/RuntimeDevice.test.js index 73e6b7313e..f94385f12e 100644 --- a/detox/src/devices/runtime/RuntimeDevice.test.js +++ b/detox/src/devices/runtime/RuntimeDevice.test.js @@ -130,6 +130,7 @@ describe('Device', () => { async function aValidDevice(overrides) { const device = aValidUnpreparedDevice(overrides); + await device.init(); await device.selectApp('default'); return device; } diff --git a/detox/src/utils/netUtils.js b/detox/src/utils/netUtils.js index e92797f952..02e3c02cda 100644 --- a/detox/src/utils/netUtils.js +++ b/detox/src/utils/netUtils.js @@ -5,8 +5,9 @@ const net = require('net'); * Uses a two-step approach: * 1. First tries to connect to the port (detects services like ADB that are listening) * 2. If connection fails, tries to bind to the port (detects if port is in use) - * @param {number} port - The port number to check - * @returns {Promise} - Resolves to true if the port is taken, false if it's available + * + * @param {number} port + * @returns {Promise} */ async function isPortTaken(port) { return new Promise((resolve, reject) => { @@ -36,15 +37,9 @@ async function isPortTaken(port) { resolve(true); }); - socket.once('error', /** @param {NodeJS.ErrnoException} err */ (err) => { + socket.once('error', /** @param {NodeJS.ErrnoException} _err */ (_err) => { clearTimeout(timeout); - if (err.code === 'ECONNREFUSED') { - // Connection refused means nothing is listening, try binding to confirm - tryBind(); - } else { - // Other errors might indicate port is in use, try binding to confirm - tryBind(); - } + tryBind(); }); socket.connect(port, 'localhost'); From 932a5f19c485df47f9846233ce9ee265f0a11d1e Mon Sep 17 00:00:00 2001 From: d4vidi Date: Sun, 11 Jan 2026 15:25:43 +0200 Subject: [PATCH 5/7] Add protecting opt-in toggle --- .buildkite/jobs/pipeline.android_rn_82.yml | 1 - .buildkite/jobs/pipeline.android_rn_83.yml | 2 + detox/detox.d.ts | 7 +++ .../android/emulator/EmulatorAllocDriver.js | 26 +++++++---- .../android/emulator/EmulatorDriver.js | 4 +- .../src/devices/runtime/factories/android.js | 1 + detox/test/e2e/detox.config.js | 44 ++++++++++++------- detox/test/package.json | 1 + scripts/ci.android.sh | 12 +++++ 9 files changed, 72 insertions(+), 26 deletions(-) diff --git a/.buildkite/jobs/pipeline.android_rn_82.yml b/.buildkite/jobs/pipeline.android_rn_82.yml index 20d0171cba..60d1039054 100644 --- a/.buildkite/jobs/pipeline.android_rn_82.yml +++ b/.buildkite/jobs/pipeline.android_rn_82.yml @@ -5,7 +5,6 @@ env: REACT_NATIVE_VERSION: 0.83.0 RCT_NEW_ARCH_ENABLED: 1 - TEST_GENYCLOUD_SANITY: true DETOX_DISABLE_POD_INSTALL: true DETOX_DISABLE_POSTINSTALL: true artifact_paths: diff --git a/.buildkite/jobs/pipeline.android_rn_83.yml b/.buildkite/jobs/pipeline.android_rn_83.yml index 60d1039054..9e1b56405b 100644 --- a/.buildkite/jobs/pipeline.android_rn_83.yml +++ b/.buildkite/jobs/pipeline.android_rn_83.yml @@ -5,6 +5,8 @@ env: REACT_NATIVE_VERSION: 0.83.0 RCT_NEW_ARCH_ENABLED: 1 + TEST_GENYCLOUD_SANITY: true # Only set 'true' in jobs with the latest supported RN + TEST_SINGLE_ADB_SERVER_SANITY: true # Only set 'true' in jobs with the latest supported RN DETOX_DISABLE_POD_INSTALL: true DETOX_DISABLE_POSTINSTALL: true artifact_paths: diff --git a/detox/detox.d.ts b/detox/detox.d.ts index fe3455dc68..011756f7a0 100644 --- a/detox/detox.d.ts +++ b/detox/detox.d.ts @@ -434,6 +434,13 @@ declare global { * @default true */ readonly?: boolean; + /** + * When `true`, each emulator uses its own separate ADB server on a separate port. This is the + * opposite of Android's default, which is to share a single ADB server on the default port (5037). The default + * has been found to be unstable and is therefore not recommended. + * @default true + */ + useSeparateAdbServers?: boolean; } interface DetoxGenymotionCloudDriverConfig extends DetoxSharedAndroidDriverConfig { diff --git a/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js b/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js index 6d6a7363c1..6be7afb58c 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js +++ b/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js @@ -56,6 +56,7 @@ class EmulatorAllocDriver extends AndroidAllocDriver { */ async allocate(deviceConfig) { const avdName = deviceConfig.device.avdName; + const useSeparateAdbServers = deviceConfig.useSeparateAdbServers === true; await this._avdValidator.validate(avdName, deviceConfig.headless); await this._fixAvdConfigIniSkinNameIfNeeded(avdName, deviceConfig.headless); @@ -64,7 +65,7 @@ class EmulatorAllocDriver extends AndroidAllocDriver { let adbName; await this._deviceRegistry.registerDevice(async () => { - const candidates = await this._getAllDevices(); + const candidates = await this._getAllDevices(useSeparateAdbServers); const device = await this._freeDeviceFinder.findFreeDevice(candidates, avdName); if (device) { @@ -133,6 +134,8 @@ class EmulatorAllocDriver extends AndroidAllocDriver { if (options.shutdown) { await this._doShutdown(adbName); await this._deviceRegistry.unregisterDevice(adbName); + + adbPortRegistry.unregister(adbName); } else { await this._deviceRegistry.releaseDevice(adbName); } @@ -140,7 +143,7 @@ class EmulatorAllocDriver extends AndroidAllocDriver { async cleanup() { if (this._shouldShutdown) { - const devices = await this._getAllDevices(); + const devices = await this._getAllDevices(true); const actualEmulators = devices.map((device) => device.adbName); const sessionDevices = await this._deviceRegistry.readSessionDevices(); const emulatorsToShutdown = _.intersection(sessionDevices.getIds(), actualEmulators); @@ -170,23 +173,28 @@ class EmulatorAllocDriver extends AndroidAllocDriver { } /** + * @param {boolean} useSeparateAdbServers * @returns {Promise} * @private */ - async _getAllDevices() { - const adbServers = await this._getRunningAdbServers(); + async _getAllDevices(useSeparateAdbServers) { + const adbServers = await this._getRunningAdbServers(useSeparateAdbServers); return (await this._adb.devices({}, adbServers)).devices; } /** + * @param {boolean} useSeparateAdbServers * @returns {Promise} * @private */ - async _getRunningAdbServers() { - const ports = []; - for (let port = this._adb.defaultServerPort + 1; await isPortTaken(port); port++) { - ports.push(port); - } + async _getRunningAdbServers(useSeparateAdbServers = true) { + const ports = [this._adb.defaultServerPort]; + + if (useSeparateAdbServers) { + for (let port = this._adb.defaultServerPort + 1; await isPortTaken(port); port++) { + ports.push(port); + } + } return ports; } diff --git a/detox/src/devices/runtime/drivers/android/emulator/EmulatorDriver.js b/detox/src/devices/runtime/drivers/android/emulator/EmulatorDriver.js index 6fce3c7a38..09e3d128e8 100644 --- a/detox/src/devices/runtime/drivers/android/emulator/EmulatorDriver.js +++ b/detox/src/devices/runtime/drivers/android/emulator/EmulatorDriver.js @@ -33,7 +33,9 @@ class EmulatorDriver extends AndroidDriver { // IMPORTANT: This approach relies on a premise where this runtime driver is unique within it's running // process. It will not work in a multi-device-in-one-process environment (case in which the registry should // be reconsidered). - process.env.ANDROID_ADB_SERVER_PORT = this._adbServerPort; + if (this._adbServerPort) { + process.env.ANDROID_ADB_SERVER_PORT = this._adbServerPort; + } } getDeviceName() { diff --git a/detox/src/devices/runtime/factories/android.js b/detox/src/devices/runtime/factories/android.js index 0cc3d6afd5..adaa1ce117 100644 --- a/detox/src/devices/runtime/factories/android.js +++ b/detox/src/devices/runtime/factories/android.js @@ -35,6 +35,7 @@ class AndroidEmulator extends RuntimeDriverFactoryAndroid { adbServerPort: deviceCookie.adbServerPort, avdName: deviceConfig.device.avdName, forceAdbInstall: deviceConfig.forceAdbInstall, + useSeparateAdbServers: deviceConfig.useSeparateAdbServers, }; const { AndroidEmulatorRuntimeDriver } = require('../drivers'); diff --git a/detox/test/e2e/detox.config.js b/detox/test/e2e/detox.config.js index 8864e58000..21e409859e 100644 --- a/detox/test/e2e/detox.config.js +++ b/detox/test/e2e/detox.config.js @@ -6,6 +6,25 @@ const launchArgs = { micro: 'soft', }; +const emulatorConfig = { + type: 'android.emulator', + headless: Boolean(process.env.CI), + device: { + avdName: 'Pixel_3a_API_36' + }, + utilBinaryPaths: ["e2e/util-binary/detoxbutler-1.1.0-aosp-release.apk"], + useSeparateAdbServers: true, + systemUI: { + extends: 'genymotion', + pointerLocationBar: 'show', + touches: 'show', + navigationMode: '3-button', + statusBar: { + clock: '1948', + }, + }, +}; + /** @type {Detox.DetoxConfig} */ const config = { extends: 'detox-allure2-adapter/preset-detox', @@ -104,21 +123,12 @@ const config = { }, 'android.emulator': { - type: 'android.emulator', - headless: Boolean(process.env.CI), - device: { - avdName: 'Pixel_3a_API_36' - }, - utilBinaryPaths: ["e2e/util-binary/detoxbutler-1.1.0-aosp-release.apk"], - systemUI: { - extends: 'genymotion', - pointerLocationBar: 'show', - touches: 'show', - navigationMode: '3-button', - statusBar: { - clock: '1948', - }, - }, + ...emulatorConfig, + }, + + 'android.emulator.single-adb-server': { + ...emulatorConfig, + useSeparateAdbServers: false, }, 'android.attached': { @@ -199,6 +209,10 @@ const config = { device: 'android.emulator', apps: ['android.release', 'android.release.withArgs'], }, + 'android.emu.release.single-adb-server': { + device: 'android.emulator.single-adb-server', + apps: ['android.release', 'android.release.withArgs'], + }, 'android.genycloud.debug': { device: 'android.genycloud.uuid', apps: ['android.debug'], diff --git a/detox/test/package.json b/detox/test/package.json index 12f66d91b7..06a8fecca1 100644 --- a/detox/test/package.json +++ b/detox/test/package.json @@ -15,6 +15,7 @@ "detox-server": "detox run-server", "e2e:ios": "detox test -c ios.sim.release", "e2e:android": "detox test -c android.emu.release", + "e2e:android.single-adb-server": "detox test -c android.emu.release.single-adb-server", "e2e:android:genycloud": "detox test -c android.genycloud.release", "e2e:android:genycloud-arm64": "detox test -c android.genycloud.release-arm64", "e2e:android-debug": "detox test -c android.emu.debug", diff --git a/scripts/ci.android.sh b/scripts/ci.android.sh index 6170588fdc..b068f741af 100755 --- a/scripts/ci.android.sh +++ b/scripts/ci.android.sh @@ -29,6 +29,18 @@ run_f "yarn build:android" run_f "yarn e2e:android" cp coverage/lcov.info ../../coverage/e2e-emulator-ci.lcov +if [ "$TEST_SINGLE_ADB_SERVER_SANITY" = "true" ]; then + ### Custom backwards-compatible test for single ADB server + killall adb + run_f "yarn e2e:android.single-adb-server e2e/01* e2e/02*" + cp coverage/lcov.info ../../coverage/e2e-emulator-single-adb-server-ci.lcov + ADB_SERVER_COUNT=$(ps -ef | grep adb | grep -v "grep" | grep '' -c) + if [ "$ADB_SERVER_COUNT" -gt 1 ]; then + echo "[FAIL] ADB server count is greater than 1: $ADB_SERVER_COUNT" + exit 1 + fi +fi + if [ "$TEST_GENYCLOUD_SANITY" = "true" ]; then # Sanity-test support for genycloud (though not ARM) run_f "yarn e2e:android:genycloud e2e/01* e2e/02* e2e/03.actions*" From d9af687198bc33350a035a00712758bbdf52793f Mon Sep 17 00:00:00 2001 From: d4vidi Date: Sun, 11 Jan 2026 16:14:27 +0200 Subject: [PATCH 6/7] Fix: opt-in toggle bug --- .../drivers/android/emulator/EmulatorAllocDriver.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js b/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js index 6be7afb58c..7447e415f1 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js +++ b/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js @@ -76,7 +76,7 @@ class EmulatorAllocDriver extends AndroidAllocDriver { const port = await this._freePortFinder.findFreePort(); adbName = `emulator-${port}`; - adbServerPort = this._getFreeAdbServerPort(candidates); + adbServerPort = this._getFreeAdbServerPort(candidates, useSeparateAdbServers); adbPortRegistry.register(adbName, adbServerPort); try { @@ -198,7 +198,11 @@ class EmulatorAllocDriver extends AndroidAllocDriver { return ports; } - _getFreeAdbServerPort(currentDevices) { + _getFreeAdbServerPort(currentDevices, useSeparateAdbServers) { + if (!useSeparateAdbServers) { + return this._adb.defaultServerPort; + } + const maxPortDevice = _.maxBy(currentDevices, 'adbServerPort'); return _.get(maxPortDevice, 'adbServerPort', this._adb.defaultServerPort) + 1; } From f24489dfeea20b9082dfd99c91dd4ff5e3cb2660 Mon Sep 17 00:00:00 2001 From: d4vidi Date: Mon, 12 Jan 2026 17:52:19 +0200 Subject: [PATCH 7/7] Fix: emulator log (long overdue tech debt) --- .../drivers/android/emulator/EmulatorVersionResolver.js | 2 +- .../drivers/android/emulator/EmulatorVersionResolver.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/detox/src/devices/allocation/drivers/android/emulator/EmulatorVersionResolver.js b/detox/src/devices/allocation/drivers/android/emulator/EmulatorVersionResolver.js index 8d06cc2cc3..203b0d5444 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/EmulatorVersionResolver.js +++ b/detox/src/devices/allocation/drivers/android/emulator/EmulatorVersionResolver.js @@ -30,7 +30,7 @@ class EmulatorVersionResolver { } const version = this._parseVersionString(matches[1]); - log.debug({ success: true }, 'Detected emulator binary version', version); + log.debug({ success: true }, `Detected emulator binary version ${version}`); return version; } diff --git a/detox/src/devices/allocation/drivers/android/emulator/EmulatorVersionResolver.test.js b/detox/src/devices/allocation/drivers/android/emulator/EmulatorVersionResolver.test.js index 486eaa81b0..50b8cb8b47 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/EmulatorVersionResolver.test.js +++ b/detox/src/devices/allocation/drivers/android/emulator/EmulatorVersionResolver.test.js @@ -97,6 +97,6 @@ describe('Emulator binary version', () => { it('should log the version', async () => { await uut.resolve(); - expect(log.debug).toHaveBeenCalledWith({ success: true }, expect.any(String), expect.objectContaining(expectedVersion)); + expect(log.debug).toHaveBeenCalledWith({ success: true }, `Detected emulator binary version ${expectedVersionRaw}`); }); });