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
2 changes: 1 addition & 1 deletion docs/agent-benchmark.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ must report adoption of the prepared simulator. On Android it requests the
pinned system image. The control must not inspect that home or use Stim; it
creates a new benchmark-named device with the same platform configuration.
Android control uses the same `avdmanager` default profile, 8 GiB data
partition, system image, and no-snapshot boot flags as Stim.
partition, system image, and default Quick Boot policy as Stim.

## Settings readiness proof

Expand Down
12 changes: 5 additions & 7 deletions packages/stim-cli/src/__tests__/engine-device.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,7 @@ describe('ensureBooted: android', () => {
timeoutMs: 5000,
});
expect(result).toEqual({ ok: true, serial: 'emulator-5556' });
expect(spawned).toEqual([
['emulator', '-avd', 'stim-app', '-port', '5556', '-no-snapshot-save', '-no-snapshot-load'],
]);
expect(spawned).toEqual([['emulator', '-avd', 'stim-app', '-port', '5556']]);
});

test('reuses the serial returned by a fresh owned AVD boot when adb listing briefly misses it', async () => {
Expand Down Expand Up @@ -1162,11 +1160,11 @@ describe('ensureOwnedDevice: android', () => {
const name = / -n "([^"]+)"/.exec(cmd)?.[1];
assert(name);
avds.push(name);
const root = process.env.ANDROID_AVD_HOME!;
const content = join(root, `${name}.avd`);
mkdirSync(content, { recursive: true });
writeFileSync(join(root, `${name}.ini`), `path=${content}\n`);
if (writeAvdFiles) {
const root = process.env.ANDROID_AVD_HOME!;
const content = join(root, `${name}.avd`);
mkdirSync(content, { recursive: true });
writeFileSync(join(root, `${name}.ini`), `path=${content}\n`);
writeFileSync(join(content, 'config.ini'), 'hw.cpu.ncore=4\ndisk.dataPartition.size=10G\n');
}
return '';
Expand Down
7 changes: 6 additions & 1 deletion packages/stim-cli/src/__tests__/guide.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,12 @@ test('the guide documents Android AVD disk-space diagnosis and cleanup', () => {
}
expect(errors).toMatch(/ENOSPC[^.]*disk space/i);
expect(cleanup).toMatch(/worktree remove[^.]*deletes[^.]*owned AVD/i);
expect(cleanup).toMatch(/neither loads nor saves[^.]*Quick\s+Boot snapshot/i);
expect(cleanup).toMatch(/default Quick Boot/i);
expect(cleanup).toMatch(/displayless Linux[^.]*snapshots are[^.]*disabled/i);
expect(cleanup).toMatch(/system image, or AVD\s+settings change are cold/i);
expect(cleanup).toMatch(/stop[^.]*waits[^.]*process[^.]*snapshot save/i);
expect(cleanup).toMatch(/default to an 8 GiB data partition[^.]*project settings[^.]*change it/i);
expect(cleanup).toMatch(/Quick Boot[^.]*one automatic snapshot/i);
expect(cleanup).toMatch(/gc[^.]*on-disk size[^.]*orphaned[^.]*stale owned Android AVD/i);
});

Expand Down
274 changes: 260 additions & 14 deletions packages/stim-cli/src/__tests__/sim-android.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import {
shutdownAndroidEmulator,
physicalDeviceModel,
resolvePhysicalDevice,
assertOwnedAvdStopped,
waitForAndroidEmulatorShutdown,
waitForBoot,
withAvdConfigOverrides,
withAvdDataPartitionSize,
Expand Down Expand Up @@ -139,18 +141,17 @@ test('nextConsolePort returns next even port above max claimed', () => {

test('headlessEmulatorArgs is headless on displayless linux only', () => {
expect(headlessEmulatorArgs({}, 'linux')).toEqual([
'-no-snapshot-save',
'-no-snapshot-load',
'-no-window',
'-noaudio',
'-no-boot-anim',
'-gpu',
'swiftshader_indirect',
'-no-snapshot-save',
'-no-snapshot-load',
]);
const snapshotArgs = ['-no-snapshot-save', '-no-snapshot-load'];
expect(headlessEmulatorArgs({ DISPLAY: ':0' }, 'linux')).toEqual(snapshotArgs);
expect(headlessEmulatorArgs({ WAYLAND_DISPLAY: 'wayland-0' }, 'linux')).toEqual(snapshotArgs);
expect(headlessEmulatorArgs({}, 'darwin')).toEqual(snapshotArgs);
expect(headlessEmulatorArgs({ DISPLAY: ':0' }, 'linux')).toEqual([]);
expect(headlessEmulatorArgs({ WAYLAND_DISPLAY: 'wayland-0' }, 'linux')).toEqual([]);
expect(headlessEmulatorArgs({}, 'darwin')).toEqual([]);
});

test('parseAvdRootIni keeps the content paths and ignores unrelated lines', () => {
Expand Down Expand Up @@ -450,20 +451,270 @@ test('resolveOwnedAvdSerial reports notRunning when the recorded port is held by
expect(resolveOwnedAvdSerial('stim-mine')).toEqual({ notRunning: true });
});

test('resolveOwnedAvdSerial resolves an offline emulator through its console identity', () => {
setExecutor({
run: (cmd) => {
if (cmd === 'emulator -list-avds') return 'stim-mine\n';
if (cmd === 'adb devices') return 'List of devices attached\nemulator-5554\toffline\n';
return '';
},
runQuiet: (cmd) => (/adb -s emulator-5554 emu avd name/.test(cmd) ? 'stim-mine\nOK' : null),
spawn: () => null,
});

expect(resolveOwnedAvdSerial('stim-mine')).toEqual({ serial: 'emulator-5554' });
});

test('assertOwnedAvdStopped rejects a live process and accepts a stale lock', () => {
expect(() =>
assertOwnedAvdStopped('stim-app', {
resolveDirectory: () => '/avds/stim-app.avd',
readProcessId: () => 123,
processAlive: () => true,
}),
).toThrow(/still has a live emulator process/);

expect(() =>
assertOwnedAvdStopped('stim-app', {
resolveDirectory: () => '/avds/stim-app.avd',
readProcessId: () => 123,
processAlive: () => false,
}),
).not.toThrow();
});

test.each([
{ timeoutMs: 12_345, flushMs: 5000, killTimeoutMs: 7345 },
{ timeoutMs: 250, flushMs: 250, killTimeoutMs: 1 },
])(
'shutdownAndroidEmulator shares a $timeoutMs ms deadline across sync and kill',
({ timeoutMs, flushMs, killTimeoutMs }) => {
const calls: Array<{ command: string; timeoutMs: number | undefined }> = [];
const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(flushMs);
setExecutor({
run: () => '',
runQuiet: (command, options) => {
calls.push({ command, timeoutMs: options?.timeoutMs });
return '';
},
spawn: () => null,
});

try {
shutdownAndroidEmulator('emulator-5554', timeoutMs);
} finally {
now.mockRestore();
}

expect(calls).toEqual([
{ command: 'adb -s emulator-5554 shell sync', timeoutMs: Math.min(5000, timeoutMs) },
{ command: 'adb -s emulator-5554 emu kill', timeoutMs: killTimeoutMs },
]);
},
);

test('waitForAndroidEmulatorShutdown waits for the owned AVD process lock to disappear', () => {
let locked = true;
const sleeps: number[] = [];
const calls: string[] = [];

waitForAndroidEmulatorShutdown('stim-app', (timeoutMs) => calls.push(`shutdown:${timeoutMs}`), {
resolveDirectory: () => '/avds/stim-app.avd',
readProcessId: () => 123,
processAlive: () => locked,
directoryExists: () => true,
sleep: (ms) => {
calls.push('wait');
sleeps.push(ms);
locked = false;
},
});

expect(sleeps).toEqual([100]);
expect(calls).toEqual(['shutdown:60000', 'wait']);
});

test('waitForAndroidEmulatorShutdown includes the shutdown command in its deadline', () => {
let elapsed = 0;
let commandTimeout = 0;

expect(() =>
waitForAndroidEmulatorShutdown(
'stim-app',
(timeoutMs) => {
commandTimeout = timeoutMs;
elapsed += 200;
},
{
timeoutMs: 250,
resolveDirectory: () => '/avds/stim-app.avd',
readProcessId: () => 123,
processAlive: () => true,
directoryExists: () => true,
now: () => elapsed,
sleep: (ms) => {
elapsed += ms;
},
},
),
).toThrow(/did not finish shutting down within 1s/);
expect(commandTimeout).toBe(250);
});

test('waitForAndroidEmulatorShutdown reads Android emulator lock PIDs on Unix and Windows', () => {
const avdDirectory = join(tmpHome, 'stim-app.avd');
for (const [platform, lockPath] of [
['darwin', join(avdDirectory, 'hardware-qemu.ini.lock')],
['win32', join(avdDirectory, 'hardware-qemu.ini.lock', 'pid')],
] as const) {
rmSync(avdDirectory, { recursive: true, force: true });
mkdirSync(join(lockPath, '..'), { recursive: true });
writeFileSync(lockPath, '412503\0');
let observedPid: number | null = null;

waitForAndroidEmulatorShutdown('stim-app', () => {}, {
platform,
resolveDirectory: () => avdDirectory,
processAlive: (pid) => {
observedPid = pid;
return false;
},
});

expect(observedPid).toBe(412503);
}
});

test.each(['darwin', 'win32'] as const)(
'assertOwnedAvdStopped refuses a present invalid %s process lock',
(platform) => {
const avdDirectory = join(tmpHome, 'stim-app.avd');
const lockPath = join(avdDirectory, 'hardware-qemu.ini.lock', ...(platform === 'win32' ? ['pid'] : []));
mkdirSync(join(lockPath, '..'), { recursive: true });
const options = { platform, resolveDirectory: () => avdDirectory };

for (const content of ['', 'invalid', '123garbage']) {
writeFileSync(lockPath, content);
expect(() => assertOwnedAvdStopped('stim-app', options)).toThrow(/Could not read the emulator PID/);
}

rmSync(lockPath);
expect(() => assertOwnedAvdStopped('stim-app', options)).not.toThrow();
},
);

test('waitForAndroidEmulatorShutdown prefers the active process lock over the legacy fallback', () => {
const paths: string[] = [];
let observedPid: number | null = null;

waitForAndroidEmulatorShutdown('stim-app', () => {}, {
resolveDirectory: () => '/avds/stim-app.avd',
readProcessId: (path) => {
paths.push(path);
return path.endsWith('hardware-qemu.ini.lock') ? 123 : 456;
},
processAlive: (pid) => {
observedPid = pid;
return false;
},
directoryExists: () => true,
});

expect(paths).toEqual(['/avds/stim-app.avd/hardware-qemu.ini.lock']);
expect(observedPid).toBe(123);
});

test('waitForAndroidEmulatorShutdown falls back to the legacy process lock', () => {
const paths: string[] = [];
let observedPid: number | null = null;

waitForAndroidEmulatorShutdown('stim-app', () => {}, {
resolveDirectory: () => '/avds/stim-app.avd',
readProcessId: (path) => {
paths.push(path);
return path.endsWith('userdata-qemu.img.lock') ? 456 : null;
},
processAlive: (pid) => {
observedPid = pid;
return false;
},
directoryExists: () => true,
});

expect(paths).toEqual(['/avds/stim-app.avd/hardware-qemu.ini.lock', '/avds/stim-app.avd/userdata-qemu.img.lock']);
expect(observedPid).toBe(456);
});

test('waitForAndroidEmulatorShutdown times out while the owned AVD process lock remains', () => {
let elapsed = 0;

expect(() =>
waitForAndroidEmulatorShutdown('stim-app', () => {}, {
timeoutMs: 250,
pollMs: 100,
resolveDirectory: () => '/avds/stim-app.avd',
readProcessId: () => 123,
processAlive: () => true,
directoryExists: () => true,
now: () => elapsed,
sleep: (ms) => {
elapsed += ms;
},
}),
).toThrow(/did not finish shutting down within 1s/);
});

test('waitForAndroidEmulatorShutdown refuses to signal a process without an AVD lock', () => {
const shutdown = vi.fn<() => void>();

expect(() =>
waitForAndroidEmulatorShutdown('stim-app', shutdown, {
resolveDirectory: () => '/avds/stim-app.avd',
readProcessId: () => null,
processAlive: () => false,
directoryExists: () => true,
}),
).toThrow(/Could not find the emulator process lock/);
expect(shutdown).not.toHaveBeenCalled();
});

test('waitForAndroidEmulatorShutdown verifies the AVD directory remains available', () => {
let locked = true;

expect(() =>
waitForAndroidEmulatorShutdown('stim-app', () => {}, {
resolveDirectory: () => '/avds/stim-app.avd',
readProcessId: () => 123,
processAlive: () => {
const result = locked;
locked = false;
return result;
},
directoryExists: () => false,
sleep: () => {},
}),
).toThrow(/Could not verify the content directory/);
});

test('shutdownAndroidEmulator bounds the guest write flush before killing the emulator', () => {
const calls: Array<{ command: string; timeoutMs: number | undefined }> = [];
const now = vi.spyOn(Date, 'now').mockReturnValue(0);
setExecutor({
runQuiet: (command: string, options) => {
calls.push({ command, timeoutMs: options?.timeoutMs });
return '';
},
});

shutdownAndroidEmulator('emulator-5554');
try {
shutdownAndroidEmulator('emulator-5554');
} finally {
now.mockRestore();
}

expect(calls).toEqual([
{ command: 'adb -s emulator-5554 shell sync', timeoutMs: 5000 },
{ command: 'adb -s emulator-5554 emu kill', timeoutMs: undefined },
{ command: 'adb -s emulator-5554 emu kill', timeoutMs: 60_000 },
]);
});

Expand Down Expand Up @@ -575,12 +826,7 @@ test('bootAndroidEmulator spawns the resolved emulator binary', () => {
if (savedDisplay === undefined) delete process.env.DISPLAY;
else process.env.DISPLAY = savedDisplay;
}
expect(spawned).toEqual([
[
join(sdk, 'emulator', 'emulator'),
['-avd', 'stim-app', '-port', '5556', '-no-snapshot-save', '-no-snapshot-load'],
],
]);
expect(spawned).toEqual([[join(sdk, 'emulator', 'emulator'), ['-avd', 'stim-app', '-port', '5556']]]);
});

test('listAvds keeps the bare command when resolution falls back to PATH', () => {
Expand Down
Loading
Loading