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
115 changes: 114 additions & 1 deletion packages/platform-android/src/__tests__/app-lifecycle-open.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test } from 'vitest';
import { test, vi } from 'vitest';
import assert from 'node:assert/strict';
import { closeAndroidApp, openAndroidApp } from '../app-lifecycle.ts';
import { withAndroidAdbProvider } from '../adb-executor.ts';
Expand Down Expand Up @@ -162,6 +162,119 @@ test('closeAndroidApp waits until package process exits after force-stop', async
]);
});

test.each(['foreground', 'process'] as const)(
'closeAndroidApp refuses an exhausted %s fence',
async (phase) => {
vi.useFakeTimers();
const calls: (readonly string[])[] = [];
try {
await withAndroidAdbProvider(
{
exec: async (args) => {
calls.push(args);
const stdout =
args[1] === 'pidof'
? '12345\n'
: args[1] === 'dumpsys'
? `mCurrentFocus=Window{42 u0 ${phase === 'foreground' ? 'com.example.app/.MainActivity' : 'com.android.launcher/.Launcher'}}\n`
: '';
return { stdout, stderr: '', exitCode: 0 };
},
},
{ serial: ANDROID_EMULATOR.id },
async () => {
const rejection = assert.rejects(
closeAndroidApp(ANDROID_EMULATOR, 'com.example.app'),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'COMMAND_FAILED');
assert.deepEqual(error.details, {
reason: 'android-app-close-timeout',
phase,
packageName: 'com.example.app',
timeoutMs: 2_000,
});
return true;
},
);
await Promise.all([rejection, vi.runAllTimersAsync()]);
},
);
assert.deepEqual(calls[0], ['shell', 'am', 'force-stop', 'com.example.app']);
assert.equal(
calls.filter((args) => args[1] === 'pidof').length,
phase === 'process' ? 40 : 0,
);
} finally {
vi.useRealTimers();
}
},
);

test.each([
['foreground', 1, 1_999, true, 2],
['foreground', 1, 2_000, false, 0],
['foreground', 1, 2_001, false, 0],
['process', 1, 1_849, true, 2],
['process', 1, 1_850, false, 1],
['process', 1, 2_000, false, 1],
['process', 1, 2_001, false, 1],
['process', 2, 1_999, true, 2],
['process', 2, 2_000, false, 2],
['process', 2, 2_001, false, 2],
] as const)(
'closeAndroidApp admits %s probe %i completing at %ims only within its fence (success=%s)',
async (phase, probe, completionMs, succeeds, expectedProcessPolls) => {
vi.useFakeTimers();
vi.setSystemTime(0);
let processPolls = 0;
try {
await withAndroidAdbProvider(
{
exec: async (args) => {
if (args[1] === 'pidof') processPolls += 1;
if (
(phase === 'foreground' && args[1] === 'dumpsys') ||
(phase === 'process' && args[1] === 'pidof' && processPolls === probe)
) {
await new Promise((resolve) => setTimeout(resolve, completionMs - Date.now()));
}
return {
stdout:
args[1] === 'dumpsys'
? 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n'
: '',
stderr: '',
exitCode: args[1] === 'pidof' ? 1 : 0,
};
},
},
{ serial: ANDROID_EMULATOR.id },
async () => {
const close = closeAndroidApp(ANDROID_EMULATOR, 'com.example.app');
const result = succeeds
? close
: assert.rejects(close, (error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'COMMAND_FAILED');
assert.deepEqual(error.details, {
reason: 'android-app-close-timeout',
phase,
packageName: 'com.example.app',
timeoutMs: 2_000,
});
return true;
});
await Promise.all([result, vi.runAllTimersAsync()]);
},
);
assert.equal(processPolls, expectedProcessPolls);
} finally {
vi.useRealTimers();
}
},
);

test('openAndroidApp ensures Android reverse before localhost deep link launch', async () => {
const device: DeviceInfo = {
platform: 'android',
Expand Down
30 changes: 28 additions & 2 deletions packages/platform-android/src/app-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,9 +543,20 @@ async function waitForAndroidPackageNotForeground(
const deadline = Date.now() + ANDROID_CLOSE_FOCUS_TIMEOUT_MS;
while (Date.now() < deadline) {
const foreground = await readAndroidForegroundApp(device);
if (Date.now() >= deadline) break;
if (foreground?.package !== packageName) return;
await sleep(ANDROID_CLOSE_FOCUS_POLL_MS);
}
throw new AppError(
Comment thread
thymikee marked this conversation as resolved.
'COMMAND_FAILED',
`Android app ${packageName} remained foreground after close`,
{
reason: 'android-app-close-timeout',
phase: 'foreground',
packageName,
timeoutMs: ANDROID_CLOSE_FOCUS_TIMEOUT_MS,
},
);
}

async function readAndroidForegroundApp(device: DeviceInfo): Promise<AppStateRuntimeResult | null> {
Expand All @@ -559,12 +570,27 @@ async function waitForAndroidPackageProcessGone(
): Promise<void> {
const deadline = Date.now() + ANDROID_CLOSE_PROCESS_TIMEOUT_MS;
while (Date.now() < deadline) {
if (!(await isAndroidPackageProcessRunning(device, packageName))) {
const processRunning = await isAndroidPackageProcessRunning(device, packageName);
if (Date.now() >= deadline) break;
if (!processRunning) {
await sleep(ANDROID_CLOSE_PROCESS_GONE_STABLE_MS);
if (!(await isAndroidPackageProcessRunning(device, packageName))) return;
if (Date.now() >= deadline) break;
const processStillRunning = await isAndroidPackageProcessRunning(device, packageName);
if (Date.now() >= deadline) break;
if (!processStillRunning) return;
}
await sleep(ANDROID_CLOSE_PROCESS_POLL_MS);
}
throw new AppError(
'COMMAND_FAILED',
`Android app ${packageName} process remained running after close`,
{
reason: 'android-app-close-timeout',
phase: 'process',
packageName,
timeoutMs: ANDROID_CLOSE_PROCESS_TIMEOUT_MS,
},
);
}

async function isAndroidPackageProcessRunning(
Expand Down
43 changes: 43 additions & 0 deletions packages/platform-android/src/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import type { Interactor, SnapshotOptions } from '@agent-device/contracts/intera
import type { DeviceInfo } from '@agent-device/kernel/device';
import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors';
import { bindAndroidApplicationLifecycle } from './lifecycle.ts';
import { closeAndroidApp } from './app-lifecycle.ts';
import { withAndroidAdbProvider } from './adb-executor.ts';
import { bindAndroidAdbHostStub } from './adb-host.fixtures.ts';

const device: DeviceInfo = {
platform: 'android',
Expand All @@ -20,6 +23,7 @@ const device: DeviceInfo = {
};

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

Expand All @@ -39,6 +43,7 @@ function createLifecycle(
input: Readonly<{ stateDir: string; shutdownTarget: boolean; signal: AbortSignal }>,
) => Promise<void>;
shutdownTarget?: () => Promise<unknown>;
close?: Interactor['close'];
}> = {},
): LifecycleFixture {
const calls: string[] = [];
Expand All @@ -53,6 +58,7 @@ function createLifecycle(
openDevice: async () => {},
close: async (app: string) => {
calls.push(`close:${app}`);
await params.close?.(app);
},
setSetting: async () => {},
snapshot: async (options: SnapshotOptions) => {
Expand Down Expand Up @@ -164,6 +170,43 @@ test.each([
expect(outcome.appBundleId).toBe(appBundleId);
});

test('relaunch refuses to open or observe after the Android process close fence expires', async () => {
vi.useFakeTimers();
bindAndroidAdbHostStub({});
const { calls, lifecycle } = createLifecycle({
close: async (app) => await closeAndroidApp(device, app),
});
await withAndroidAdbProvider(
{
exec: async (args) => ({
stdout:
args[1] === 'pidof'
? '12345\n'
: args[1] === 'dumpsys'
? 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n'
: '',
stderr: '',
exitCode: 0,
}),
},
{ serial: device.id },
async () => {
const rejection = expect(
lifecycle.openApplication(openInput({ relaunch: true })),
).rejects.toMatchObject({
code: 'COMMAND_FAILED',
details: {
reason: 'android-app-close-timeout',
phase: 'process',
packageName: 'com.example.app',
},
});
await Promise.all([rejection, vi.runAllTimersAsync()]);
},
);
expect(calls).toEqual(['close:com.example.app']);
});

test('preserves a runtime launch URL duration after the admitted Android follow-up open', async () => {
const { calls, lifecycle } = createLifecycle();
vi.spyOn(Date, 'now')
Expand Down
Loading