Skip to content
Merged
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
3 changes: 3 additions & 0 deletions src/core/interactors/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export function createAndroidInteractor(device: DeviceInfo): Interactor {
depth: options?.depth,
scope: options?.scope,
raw: options?.raw,
// appBundleId is present for app-backed daemon sessions; keep the helper warm there,
// but release it after standalone device snapshots so UiAutomation is not squatted.
helperSessionScope: options?.appBundleId ? 'daemon-session' : 'command',
}),
{ backend: 'android' },
);
Expand Down
61 changes: 61 additions & 0 deletions src/daemon/handlers/__tests__/session-close-shutdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ vi.mock('../../../platforms/android/perf.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/android/perf.ts')>();
return { ...actual, cleanupAndroidNativePerfSession: vi.fn(async () => {}) };
});
vi.mock('../../../platforms/android/snapshot-helper.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../platforms/android/snapshot-helper.ts')>();
return { ...actual, stopAndroidSnapshotHelperSessionForDevice: vi.fn(async () => {}) };
});
vi.mock('../../../platforms/ios/macos-helper.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/macos-helper.ts')>();
return { ...actual, runMacOsAlertAction: vi.fn(async () => {}) };
Expand All @@ -50,13 +55,17 @@ import { runCmd } from '../../../utils/exec.ts';
import { dispatchCommand } from '../../../core/dispatch.ts';
import { cleanupAppleXctracePerfCapture } from '../../../platforms/ios/perf-xctrace.ts';
import { cleanupAndroidNativePerfSession } from '../../../platforms/android/perf.ts';
import { stopAndroidSnapshotHelperSessionForDevice } from '../../../platforms/android/snapshot-helper.ts';
import { WEB_DESKTOP_DEVICE } from '../../../__tests__/test-utils/index.ts';

const mockShutdownSimulator = vi.mocked(shutdownSimulator);
const mockRunCmd = vi.mocked(runCmd);
const mockDispatchCommand = vi.mocked(dispatchCommand);
const mockCleanupAppleXctracePerfCapture = vi.mocked(cleanupAppleXctracePerfCapture);
const mockCleanupAndroidNativePerfSession = vi.mocked(cleanupAndroidNativePerfSession);
const mockStopAndroidSnapshotHelperSessionForDevice = vi.mocked(
stopAndroidSnapshotHelperSessionForDevice,
);

const noopInvoke = async (_req: DaemonRequest): Promise<DaemonResponse> => ({ ok: true, data: {} });

Expand Down Expand Up @@ -176,6 +185,40 @@ test('close --shutdown calls shutdownAndroidEmulator for Android emulator and in
}
});

test('close stops Android snapshot helper session before deleting session', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'android-snapshot-helper-session';
const device: SessionState['device'] = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel_9_API_35',
kind: 'emulator',
booted: true,
};
sessionStore.set(sessionName, {
...makeSession(sessionName, device),
appBundleId: 'com.example.app',
});

const response = await handleSessionCommands({
req: {
token: 't',
session: sessionName,
command: 'close',
positionals: [],
flags: {},
},
sessionName,
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
});

expect(response?.ok).toBe(true);
expect(mockStopAndroidSnapshotHelperSessionForDevice).toHaveBeenCalledWith(device);
expect(sessionStore.get(sessionName)).toBeUndefined();
});

test('close --shutdown is ignored for non-simulator iOS devices', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-device-shutdown-session';
Expand Down Expand Up @@ -411,6 +454,24 @@ test('daemon session teardown stops active Android native perf capture', async (
expect(session.nativePerf?.android).toBeUndefined();
});

test('daemon session teardown stops Android snapshot helper session', async () => {
const sessionName = 'android-snapshot-helper-teardown-session';
const session = {
...makeSession(sessionName, {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
}),
appBundleId: 'com.example.app',
} as SessionState;

await teardownSessionResources(session, sessionName);

expect(mockStopAndroidSnapshotHelperSessionForDevice).toHaveBeenCalledWith(session.device);
});

test('close --shutdown is ignored for Android devices', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'android-device-shutdown-session';
Expand Down
8 changes: 8 additions & 0 deletions src/daemon/handlers/session-close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { stopAppLog } from '../app-log.ts';
import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
import { cleanupAppleXctracePerfCapture } from '../../platforms/ios/perf-xctrace.ts';
import { cleanupAndroidNativePerfSession } from '../../platforms/android/perf.ts';
import { stopAndroidSnapshotHelperSessionForDevice } from '../../platforms/android/snapshot-helper.ts';
import { clearRuntimeHintsFromApp, hasRuntimeTransportHints } from '../runtime-hints.ts';
import { cleanupRetainedMaterializedPathsForSession } from '../materialized-path-registry.ts';
import {
Expand Down Expand Up @@ -80,6 +81,11 @@ async function stopSessionAndroidNativePerfCapture(session: SessionState): Promi
session.nativePerf = { ...(session.nativePerf ?? {}), android: undefined };
}

async function stopSessionAndroidSnapshotHelper(session: SessionState): Promise<void> {
if (session.device.platform !== 'android') return;
await stopAndroidSnapshotHelperSessionForDevice(session.device);
}

export async function teardownSessionResources(
session: SessionState,
sessionName: string,
Expand All @@ -89,6 +95,7 @@ export async function teardownSessionResources(
}
await stopSessionApplePerfCapture(session);
await stopSessionAndroidNativePerfCapture(session);
await stopSessionAndroidSnapshotHelper(session);
if (isApplePlatform(session.device.platform)) {
await stopAppleRunnerForClose(session);
}
Expand All @@ -111,6 +118,7 @@ export async function handleCloseCommand(params: {
}
await stopSessionApplePerfCapture(session);
await stopSessionAndroidNativePerfCapture(session);
await stopSessionAndroidSnapshotHelper(session);
if (shouldDispatchPlatformClose(req, session)) {
if (shouldStopAppleRunnerBeforeTargetedClose(session)) {
await stopAppleRunnerForClose(session);
Expand Down
200 changes: 197 additions & 3 deletions src/platforms/android/__tests__/snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { beforeEach, test, vi } from 'vitest';
import { afterEach, beforeEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { promises as fs } from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { PassThrough } from 'node:stream';

vi.mock('../../../utils/exec.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../utils/exec.ts')>();
Expand All @@ -22,11 +25,16 @@ import { AppError } from '../../../utils/errors.ts';
import { runCmd } from '../../../utils/exec.ts';
import { sleep } from '../adb.ts';
import {
resetAndroidSnapshotHelperSessions,
resetAndroidSnapshotHelperInstallCache,
type AndroidAdbExecutor,
type AndroidSnapshotHelperManifest,
} from '../snapshot-helper.ts';
import { withAndroidAdbProvider, type AndroidAdbProvider } from '../adb-executor.ts';
import {
withAndroidAdbProvider,
type AndroidAdbProcess,
type AndroidAdbProvider,
} from '../adb-executor.ts';

const VALID_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+b9xkAAAAASUVORK5CYII=',
Expand Down Expand Up @@ -94,7 +102,123 @@ function createHelperAdb(
};
}

beforeEach(() => {
function createPersistentSnapshotHelperProvider(options: {
calls: string[][];
spawnArgs: string[][];
killedProcesses: FakeAndroidProcess[];
}): AndroidAdbProvider {
return {
exec: async (args) => {
options.calls.push(args);
if (args.includes('--show-versioncode')) return installedHelperProbe;
if (args[0] === 'forward') return { exitCode: 0, stdout: '', stderr: '' };
if (args[0] === 'shell' && args[1] === 'am' && args[2] === 'force-stop') {
return { exitCode: 0, stdout: '', stderr: '' };
}
throw new Error(`unexpected persistent helper adb args: ${args.join(' ')}`);
},
spawn: (args) => {
options.spawnArgs.push(args);
const process = new FakeAndroidProcess();
const port = readSessionPort(args);
let snapshotCount = 0;
const server = net.createServer((socket) => {
socket.once('data', (chunk) => {
const command = chunk.toString('utf8').trim();
const [, requestId = ''] = command.split(/\s+/, 2);
if (command.startsWith('quit')) {
socket.end(sessionResponse({ requestId, body: '' }));
return;
}
snapshotCount += 1;
const body = `<hierarchy><node text="persistent helper snapshot ${snapshotCount}" bounds="[0,0][10,10]" /></hierarchy>`;
socket.end(
sessionResponse({
requestId,
body,
metadata: {
waitForIdleTimeoutMs: '500',
waitForIdleQuietMs: '100',
timeoutMs: '5000',
maxDepth: '128',
maxNodes: '5000',
rootPresent: 'true',
captureMode: 'interactive-windows',
windowCount: '1',
nodeCount: '1',
truncated: 'false',
elapsedMs: '8',
},
}),
);
});
});
server.listen(port, '127.0.0.1', () => {
process.stdout.write(
[
'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1',
'INSTRUMENTATION_STATUS: sessionReady=true',
'INSTRUMENTATION_STATUS_CODE: 2',
'',
].join('\n'),
);
});
process.onKill = () => {
options.killedProcesses.push(process);
server.close(() => process.emitExit(0, null));
};
return process;
},
};
}

function sessionResponse(params: {
requestId: string;
body: string;
metadata?: Record<string, string>;
}): string {
const headers = {
agentDeviceProtocol: 'android-snapshot-helper-v1',
helperApiVersion: '1',
outputFormat: 'uiautomator-xml',
requestId: params.requestId,
ok: 'true',
byteLength: String(Buffer.byteLength(params.body, 'utf8')),
...params.metadata,
};
return `${Object.entries(headers)
.map(([key, value]) => `${key}=${value}`)
.join('\n')}\n\n${params.body}`;
}

function readSessionPort(args: string[]): number {
const index = args.indexOf('sessionPort');
assert.notEqual(index, -1);
return Number(args[index + 1]);
}

class FakeAndroidProcess extends EventEmitter implements AndroidAdbProcess {
stdin = new PassThrough();
stdout = new PassThrough();
stderr = new PassThrough();
killed = false;
onKill: (() => void) | undefined;

kill(): boolean {
if (this.killed) return true;
this.killed = true;
this.onKill?.();
return true;
}

emitExit(code: number | null, signal: NodeJS.Signals | null): void {
this.emit('exit', code, signal);
this.emit('close', code, signal);
}
}

beforeEach(async () => {
await resetAndroidSnapshotHelperSessions();
resetAndroidSnapshotHelperInstallCache();
mockRunCmd.mockReset();
mockSleep.mockReset();
Expand All @@ -107,6 +231,10 @@ beforeEach(() => {
});
});

afterEach(async () => {
await resetAndroidSnapshotHelperSessions();
});

test('screenshotAndroid waits for transient UI to settle before capture', async () => {
const events: string[] = [];
const outPath = path.join(os.tmpdir(), `agent-device-android-screenshot-${Date.now()}.png`);
Expand Down Expand Up @@ -496,6 +624,72 @@ test('snapshotAndroid resolves helper adb through scoped provider', async () =>
assert.equal(mockRunCmd.mock.calls.length, 0);
});

test('snapshotAndroid stops command-scoped persistent helper session after capture', async () => {
const adbCalls: string[][] = [];
const spawnArgs: string[][] = [];
const killedProcesses: FakeAndroidProcess[] = [];
const provider = createPersistentSnapshotHelperProvider({
calls: adbCalls,
spawnArgs,
killedProcesses,
});

const result = await snapshotAndroid(device, {
helperAdb: provider,
helperArtifact,
});

assert.equal(result.nodes[0]?.label, 'persistent helper snapshot 1');
assert.equal(result.androidSnapshot.helperTransport, 'persistent-session');
assert.equal(result.androidSnapshot.helperSessionReused, false);
assert.equal(spawnArgs.length, 1);
assert.equal(killedProcesses.length, 1);
assert.equal(
adbCalls.some((args) => args[0] === 'forward' && args[1] === '--remove'),
true,
);
});

test('snapshotAndroid keeps daemon-session helper alive for reuse until session cleanup', async () => {
const adbCalls: string[][] = [];
const spawnArgs: string[][] = [];
const killedProcesses: FakeAndroidProcess[] = [];
const provider = createPersistentSnapshotHelperProvider({
calls: adbCalls,
spawnArgs,
killedProcesses,
});

const first = await snapshotAndroid(device, {
helperAdb: provider,
helperArtifact,
helperSessionScope: 'daemon-session',
});
const second = await snapshotAndroid(device, {
helperAdb: provider,
helperArtifact,
helperSessionScope: 'daemon-session',
});

assert.equal(first.androidSnapshot.helperSessionReused, false);
assert.equal(second.androidSnapshot.helperSessionReused, true);
assert.equal(second.nodes[0]?.label, 'persistent helper snapshot 2');
assert.equal(spawnArgs.length, 1);
assert.equal(killedProcesses.length, 0);
assert.equal(
adbCalls.some((args) => args[0] === 'forward' && args[1] === '--remove'),
false,
);

await resetAndroidSnapshotHelperSessions();

assert.equal(killedProcesses.length, 1);
assert.equal(
adbCalls.some((args) => args[0] === 'forward' && args[1] === '--remove'),
true,
);
});

test('snapshotAndroid falls back to stock uiautomator when helper fails', async () => {
const adbCalls: string[][] = [];
const stockXml =
Expand Down
Loading
Loading