diff --git a/.nx/version-plans/version-plan-1792332597323.md b/.nx/version-plans/version-plan-1792332597323.md new file mode 100644 index 00000000..fd049ad1 --- /dev/null +++ b/.nx/version-plans/version-plan-1792332597323.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +Android app launches now restore port forwarding, allowing test runs to recover when ADB reverse mappings disappear between test files or startup retries. diff --git a/packages/platform-android/src/__tests__/instance.test.ts b/packages/platform-android/src/__tests__/instance.test.ts index 8fc17c70..67b5da04 100644 --- a/packages/platform-android/src/__tests__/instance.test.ts +++ b/packages/platform-android/src/__tests__/instance.test.ts @@ -9,6 +9,7 @@ import { getAndroidPhysicalDevicePlatformInstance, } from '../instance.js'; import * as adb from '../adb.js'; +import type { AndroidPlatformConfig } from '../config.js'; import * as avdConfig from '../avd-config.js'; import * as sharedPrefs from '../shared-prefs.js'; import { HarnessAppPathError, HarnessEmulatorConfigError } from '../errors.js'; @@ -58,6 +59,90 @@ describe('Android platform instance', () => { ).mockResolvedValue(undefined); }); + describe.each(['emulator', 'physical'] as const)('%s port forwarding', (type) => { + const adbId = type === 'emulator' ? 'emulator-5554' : '012345'; + const metroPort = 9191; + const config: AndroidPlatformConfig = { + name: 'android', + device: + type === 'emulator' + ? { type: 'emulator', name: 'Pixel_8_API_35' } + : { type: 'physical', manufacturer: 'motorola', model: 'moto g72' }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }; + const createInstance = () => + (type === 'emulator' + ? getAndroidEmulatorPlatformInstance + : getAndroidPhysicalDevicePlatformInstance)( + config, + { ...harnessConfig, metroPort }, + init + ); + + beforeEach(() => { + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([adbId]); + vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); + vi.spyOn(adb, 'getDeviceInfo').mockResolvedValue({ + manufacturer: 'motorola', + model: 'moto g72', + }); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue(undefined); + vi.spyOn(sharedPrefs, 'clearHarnessDebugHttpHost').mockResolvedValue(undefined); + vi.spyOn(adb, 'stopApp').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppPid').mockResolvedValue(4321); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue('01-01 00:00:00.000'); + vi.spyOn(adb, 'startLogcat').mockReturnValue(createLogcatProcess()); + }); + + it('restores lost mappings before launching each app session', async () => { + const forwardedPorts = new Set(); + vi.mocked(adb.reversePort).mockImplementation(async (id, port) => { + expect(id).toBe(adbId); + forwardedPorts.add(port); + }); + const startApp = vi.spyOn(adb, 'startApp').mockImplementation(async () => { + expect(forwardedPorts).toEqual(new Set([metroPort, 8080])); + }); + const instance = await createInstance(); + + try { + for (let launch = 0; launch < 3; launch++) { + // Forwarding can disappear after initialization or between files/retries. + forwardedPorts.clear(); + const session = await instance.createAppSession(); + await session.dispose(); + } + expect(startApp).toHaveBeenCalledTimes(3); + } finally { + await instance.dispose(); + } + }); + + it('does not launch the app when restoring forwarding fails', async () => { + const startApp = vi.spyOn(adb, 'startApp').mockResolvedValue(undefined); + const instance = await createInstance(); + const error = new Error('ADB reverse failed'); + vi.mocked(adb.reversePort).mockRejectedValue(error); + + try { + await expect( + instance.createAppSession().then(async (session) => { + await session.dispose(); + return session; + }) + ).rejects.toBe(error); + expect(startApp).not.toHaveBeenCalled(); + } finally { + await instance.dispose(); + } + }); + }); + it('reuses a running emulator and does not shut it down on dispose', async () => { const ensureAndroidEmulatorEnvironment = vi .spyOn( diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index 9e2fa362..bbb0034b 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -64,6 +64,17 @@ const getOptionalHarnessAppPath = (): string | undefined => { return appPath; }; +// ADB reconnects can remove device-side tunnels between app launches. +const restorePortForwarding = async ( + adbId: string, + metroPort: number +): Promise => { + await Promise.all([ + adbModule.reversePort(adbId, metroPort), + adbModule.reversePort(adbId, 8080), + ]); +}; + const configureAndroidRuntime = async ( adbId: string, config: AndroidPlatformConfig, @@ -72,8 +83,7 @@ const configureAndroidRuntime = async ( const metroPort = harnessConfig.metroPort; await Promise.all([ - adbModule.reversePort(adbId, metroPort), - adbModule.reversePort(adbId, 8080), + restorePortForwarding(adbId, metroPort), adbModule.setHideErrorDialogs(adbId, true), applyHarnessDebugHttpHost(adbId, config.bundleId, `localhost:${metroPort}`), ]); @@ -280,6 +290,7 @@ export const getAndroidEmulatorPlatformInstance = async ( return { createAppSession: async (options) => { await adb.stopApp(adbId, config.bundleId); + await restorePortForwarding(adbId, harnessConfig.metroPort); const launchOptions = (options as typeof config.appLaunchOptions | undefined) ?? config.appLaunchOptions; @@ -353,6 +364,7 @@ export const getAndroidPhysicalDevicePlatformInstance = async ( return { createAppSession: async (options) => { await adb.stopApp(adbId, config.bundleId); + await restorePortForwarding(adbId, harnessConfig.metroPort); const launchOptions = (options as typeof config.appLaunchOptions | undefined) ?? config.appLaunchOptions;