Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1792332597323.md
Original file line number Diff line number Diff line change
@@ -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.
85 changes: 85 additions & 0 deletions packages/platform-android/src/__tests__/instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<number>();
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(
Expand Down
16 changes: 14 additions & 2 deletions packages/platform-android/src/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
await Promise.all([
adbModule.reversePort(adbId, metroPort),
adbModule.reversePort(adbId, 8080),
]);
};

const configureAndroidRuntime = async (
adbId: string,
config: AndroidPlatformConfig,
Expand All @@ -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}`),
]);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down