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/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/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/FreeDeviceFinder.js b/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.js index bf0a74ee8d..ccbb84a78d 100644 --- a/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.js +++ b/detox/src/devices/allocation/drivers/android/FreeDeviceFinder.js @@ -1,19 +1,31 @@ +/** + * @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' }; class FreeDeviceFinder { - constructor(adb, deviceRegistry) { - this.adb = adb; + /** + * @param {import('../../DeviceRegistry')} deviceRegistry + */ + constructor(deviceRegistry) { 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..b7110b7e4f 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,34 @@ describe('FreeDeviceFinder', () => { mockDeviceRegistry = new DeviceRegistry(); mockDeviceRegistry.getTakenDevicesSync.mockImplementation(() => fakeDeviceList); - 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 () => { - 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 a9a7ec2142..7447e415f1 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js +++ b/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js @@ -1,10 +1,14 @@ /** * @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'); const { patchAvdSkinConfig } = require('./patchAvdSkinConfig'); @@ -48,29 +52,47 @@ class EmulatorAllocDriver extends AndroidAllocDriver { /** * @param deviceConfig - * @returns {Promise} + * @returns {Promise} */ 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); - const adbName = await this._deviceRegistry.registerDevice(async () => { - let adbName = await this._freeDeviceFinder.findFreeDevice(avdName); - if (!adbName) { + let adbServerPort; + let adbName; + + await this._deviceRegistry.registerDevice(async () => { + const candidates = await this._getAllDevices(useSeparateAdbServers); + const device = await this._freeDeviceFinder.findFreeDevice(candidates, avdName); + + if (device) { + adbName = device.adbName; + adbServerPort = device.adbServerPort; + adbPortRegistry.register(adbName, adbServerPort); + } else { const port = await this._freePortFinder.findFreePort(); - adbName = `emulator-${port}`; - await this._emulatorLauncher.launch({ - bootArgs: deviceConfig.bootArgs, - gpuMode: deviceConfig.gpuMode, - headless: deviceConfig.headless, - readonly: deviceConfig.readonly, - avdName, - adbName, - port, - }); + adbName = `emulator-${port}`; + adbServerPort = this._getFreeAdbServerPort(candidates, useSeparateAdbServers); + 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; @@ -80,6 +102,7 @@ class EmulatorAllocDriver extends AndroidAllocDriver { id: adbName, adbName, name: `${adbName} (${avdName})`, + adbServerPort, }; } @@ -111,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); } @@ -118,7 +143,7 @@ class EmulatorAllocDriver extends AndroidAllocDriver { async cleanup() { if (this._shouldShutdown) { - const { devices } = await this._adb.devices(); + 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); @@ -146,6 +171,41 @@ class EmulatorAllocDriver extends AndroidAllocDriver { const binaryVersion = _.get(rawBinaryVersion, 'major'); return await patchAvdSkinConfig(avdName, binaryVersion); } + + /** + * @param {boolean} useSeparateAdbServers + * @returns {Promise} + * @private + */ + async _getAllDevices(useSeparateAdbServers) { + const adbServers = await this._getRunningAdbServers(useSeparateAdbServers); + return (await this._adb.devices({}, adbServers)).devices; + } + + /** + * @param {boolean} useSeparateAdbServers + * @returns {Promise} + * @private + */ + 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; + } + + _getFreeAdbServerPort(currentDevices, useSeparateAdbServers) { + if (!useSeparateAdbServers) { + return this._adb.defaultServerPort; + } + + 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/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/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}`); }); }); 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 8b24467428..c369d028c8 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,19 @@ describe('FreeEmulatorFinder', () => { mockDeviceRegistry.getTakenDevicesSync.mockImplementation(() => fakeDeviceList); const FreeEmulatorFinder = require('./FreeEmulatorFinder'); - uut = new FreeEmulatorFinder(mockAdb, mockDeviceRegistry); + uut = new FreeEmulatorFinder(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 1398d10a7a..1f17d27205 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/launchEmulatorProcess.js +++ b/detox/src/devices/allocation/drivers/android/emulator/launchEmulatorProcess.js @@ -1,10 +1,23 @@ +/** + * @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' }); -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,7 +39,12 @@ 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(); log = log.child({ child_pid: childProcessPromise.childProcess.pid }); 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.js b/detox/src/devices/common/drivers/android/AdbPortRegistry.js new file mode 100644 index 0000000000..b5b3f818b5 --- /dev/null +++ b/detox/src/devices/common/drivers/android/AdbPortRegistry.js @@ -0,0 +1,30 @@ +class AdbPortRegistry { + constructor() { + this._registry = new Map(); + } + + /** + * @param { string } adbName + * @param { number } port + */ + register(adbName, port) { + this._registry.set(adbName, port); + } + + /** + * @param { string } adbName + */ + unregister(adbName) { + this._registry.delete(adbName); + } + + /** + * @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/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/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 3954219065..9bc9086079 100644 --- a/detox/src/devices/common/drivers/android/exec/ADB.js +++ b/detox/src/devices/common/drivers/android/exec/ADB.js @@ -6,6 +6,7 @@ const { execWithRetriesAndLogs, spawnWithRetriesAndLogs, spawnAndLog } = require const { getAdbPath } = require('../../../../../utils/environment'); const logger = require('../../../../../utils/logger'); const { escape } = require('../../../../../utils/pipeCommands'); +const adbPortRegistry = require('../AdbPortRegistry'); const DeviceHandle = require('../tools/DeviceHandle'); const EmulatorHandle = require('../tools/EmulatorHandle'); @@ -19,6 +20,8 @@ const DEFAULT_INSTALL_OPTIONS = { retries: 3, }; +const ADB_SERVER_PORT = 5037; + class ADB { constructor() { this._cachedApiLevels = new Map(); @@ -27,23 +30,44 @@ class ADB { this.adbBin = getAdbPath(); } + get defaultServerPort() { + return ADB_SERVER_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 }; + /** + * @returns {Promise<{devices: DeviceHandle[], stdout: string}>} + */ + async devices(options, ports = [ADB_SERVER_PORT]) { + const devicesByPort = {}; + const stdouts = []; + + for (let port of ports) { + const { stdout } = await this.adbCmdWithPort('', port, 'devices', { verbosity: 'high', ...options }); + 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, + stdout: stdouts.join('\n'), + }; } async getState(deviceId) { @@ -388,8 +412,13 @@ class ADB { } async adbCmd(deviceId, params, options = {}) { - const serial = `${deviceId ? `-s ${deviceId}` : ''}`; - const cmd = `"${this.adbBin}" ${serial} ${params}`; + 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(); const _options = { ...this.defaultExecOptions, ...options, @@ -400,7 +429,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, @@ -415,7 +446,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 aae94b5640..0fe220f853 100644 --- a/detox/src/devices/common/drivers/android/exec/ADB.test.js +++ b/detox/src/devices/common/drivers/android/exec/ADB.test.js @@ -4,6 +4,7 @@ jest.mock('../../../../../utils/logger'); describe('ADB', () => { const deviceId = 'mockEmulator'; const adbBinPath = `/Android/sdk-mock/platform-tools/adb`; + const baseAdbServerPort = 5037; let ADB; let adb; @@ -12,6 +13,7 @@ describe('ADB', () => { let execWithRetriesAndLogs; let spawnAndLog; let spawnWithRetriesAndLogs; + let adbPortRegistry; let logger; beforeEach(() => { @@ -39,31 +41,39 @@ describe('ADB', () => { spawnAndLog = require('../../../../../utils/childProcess').spawnAndLog; spawnWithRetriesAndLogs = require('../../../../../utils/childProcess').spawnWithRetriesAndLogs; + jest.mock('../AdbPortRegistry'); + adbPortRegistry = require('../AdbPortRegistry'); + 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], @@ -71,10 +81,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 () => { @@ -95,12 +105,68 @@ describe('ADB', () => { await adb.devices(options); expect(execWithRetriesAndLogs).toHaveBeenCalledWith(expect.any(String), options); }); + + 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({}, [baseAdbServerPort, baseAdbServerPort + 1]); + + expect(devices).toHaveLength(2); + expect(EmulatorHandle).toHaveBeenCalledWith(emulatorEntry, baseAdbServerPort); + expect(EmulatorHandle).toHaveBeenCalledWith(emulator5556Entry, baseAdbServerPort + 1); + }); }); 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/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/devices/runtime/RuntimeDevice.js b/detox/src/devices/runtime/RuntimeDevice.js index 9fc5bf3001..4c1bde665d 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', @@ -89,6 +90,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/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/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..09e3d128e8 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,27 @@ 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). + if (this._adbServerPort) { + 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..adaa1ce117 100644 --- a/detox/src/devices/runtime/factories/android.js +++ b/detox/src/devices/runtime/factories/android.js @@ -32,8 +32,10 @@ class AndroidEmulator extends RuntimeDriverFactoryAndroid { _createDriver(deviceCookie, deps, { deviceConfig }) { const props = { adbName: deviceCookie.adbName, + adbServerPort: deviceCookie.adbServerPort, avdName: deviceConfig.device.avdName, forceAdbInstall: deviceConfig.forceAdbInstall, + useSeparateAdbServers: deviceConfig.useSeparateAdbServers, }; const { AndroidEmulatorRuntimeDriver } = require('../drivers'); diff --git a/detox/src/utils/netUtils.js b/detox/src/utils/netUtils.js new file mode 100644 index 0000000000..02e3c02cda --- /dev/null +++ b/detox/src/utils/netUtils.js @@ -0,0 +1,51 @@ +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 + * @returns {Promise} + */ +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); + 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); + }); +}); 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*"