Skip to content

Commit 3d50d9b

Browse files
authored
fix: improve android sdk path fallback (#241)
* fix: improve android sdk path fallback * fix: honor pathext in android path fallback * chore: simplify android sdk fallback tests
1 parent 9def661 commit 3d50d9b

6 files changed

Lines changed: 324 additions & 100 deletions

File tree

src/platforms/android/__tests__/devices.test.ts

Lines changed: 153 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,96 @@ import {
1111
resolveAndroidAvdName,
1212
} from '../devices.ts';
1313

14+
const MOCK_ANDROID_ADB_SCRIPT = [
15+
'#!/bin/sh',
16+
'if [ "$1" = "devices" ] && [ "$2" = "-l" ]; then',
17+
' echo "List of devices attached"',
18+
' if [ -f "$AGENT_DEVICE_TEST_EMU_BOOTED_FILE" ]; then',
19+
' echo "emulator-5554 device product:sdk_gphone64 model:Pixel_9_Pro_XL device:emu64a transport_id:2"',
20+
' fi',
21+
' exit 0',
22+
'fi',
23+
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "emu" ] && [ "$4" = "avd" ] && [ "$5" = "name" ]; then',
24+
' echo "Pixel_9_Pro_XL"',
25+
' exit 0',
26+
'fi',
27+
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "ro.boot.qemu.avd_name" ]; then',
28+
' echo "Pixel_9_Pro_XL"',
29+
' exit 0',
30+
'fi',
31+
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "persist.sys.avd_name" ]; then',
32+
' echo "Pixel_9_Pro_XL"',
33+
' exit 0',
34+
'fi',
35+
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "sys.boot_completed" ]; then',
36+
' if [ -f "$AGENT_DEVICE_TEST_EMU_BOOTED_FILE" ]; then',
37+
' echo "1"',
38+
' else',
39+
' echo "0"',
40+
' fi',
41+
' exit 0',
42+
'fi',
43+
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "ro.build.characteristics" ]; then',
44+
' echo "phone"',
45+
' exit 0',
46+
'fi',
47+
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "cmd" ] && [ "$5" = "package" ] && [ "$6" = "has-feature" ]; then',
48+
' echo "false"',
49+
' exit 0',
50+
'fi',
51+
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "pm" ] && [ "$5" = "list" ] && [ "$6" = "features" ]; then',
52+
' echo ""',
53+
' exit 0',
54+
'fi',
55+
'echo "unexpected adb args: $@" >> "$AGENT_DEVICE_TEST_EMU_LOG_FILE"',
56+
'exit 1',
57+
'',
58+
];
59+
60+
const MOCK_ANDROID_EMULATOR_SCRIPT = [
61+
'#!/bin/sh',
62+
'if [ "$1" = "-list-avds" ]; then',
63+
' echo "Pixel_9_Pro_XL"',
64+
' exit 0',
65+
'fi',
66+
'if [ "$1" = "-avd" ]; then',
67+
' echo "$@" >> "$AGENT_DEVICE_TEST_EMU_LOG_FILE"',
68+
' touch "$AGENT_DEVICE_TEST_EMU_BOOTED_FILE"',
69+
' exit 0',
70+
'fi',
71+
'echo "unexpected emulator args: $@" >> "$AGENT_DEVICE_TEST_EMU_LOG_FILE"',
72+
'exit 1',
73+
'',
74+
];
75+
76+
async function writeExecutable(filePath: string, lines: readonly string[]): Promise<void> {
77+
await fs.writeFile(filePath, lines.join('\n'), 'utf8');
78+
await fs.chmod(filePath, 0o755);
79+
}
80+
81+
async function withEnv(
82+
overrides: Record<string, string | undefined>,
83+
run: () => Promise<void>,
84+
): Promise<void> {
85+
const saved = Object.fromEntries(
86+
Object.keys(overrides).map((key) => [key, process.env[key]]),
87+
) as Record<string, string | undefined>;
88+
89+
for (const [key, value] of Object.entries(overrides)) {
90+
if (value === undefined) delete process.env[key];
91+
else process.env[key] = value;
92+
}
93+
94+
try {
95+
await run();
96+
} finally {
97+
for (const [key, value] of Object.entries(saved)) {
98+
if (value === undefined) delete process.env[key];
99+
else process.env[key] = value;
100+
}
101+
}
102+
}
103+
14104
test('parseAndroidTargetFromCharacteristics detects tv markers', () => {
15105
assert.equal(parseAndroidTargetFromCharacteristics('tv,nosdcard'), 'tv');
16106
assert.equal(parseAndroidTargetFromCharacteristics('watch,leanback'), 'tv');
@@ -47,92 +137,56 @@ async function withMockedAndroidTools(
47137
const adbPath = path.join(tmpDir, 'adb');
48138
const emulatorPath = path.join(tmpDir, 'emulator');
49139

50-
await fs.writeFile(
51-
adbPath,
52-
[
53-
'#!/bin/sh',
54-
'if [ "$1" = "devices" ] && [ "$2" = "-l" ]; then',
55-
' echo "List of devices attached"',
56-
' if [ -f "$AGENT_DEVICE_TEST_EMU_BOOTED_FILE" ]; then',
57-
' echo "emulator-5554 device product:sdk_gphone64 model:Pixel_9_Pro_XL device:emu64a transport_id:2"',
58-
' fi',
59-
' exit 0',
60-
'fi',
61-
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "emu" ] && [ "$4" = "avd" ] && [ "$5" = "name" ]; then',
62-
' echo "Pixel_9_Pro_XL"',
63-
' exit 0',
64-
'fi',
65-
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "ro.boot.qemu.avd_name" ]; then',
66-
' echo "Pixel_9_Pro_XL"',
67-
' exit 0',
68-
'fi',
69-
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "persist.sys.avd_name" ]; then',
70-
' echo "Pixel_9_Pro_XL"',
71-
' exit 0',
72-
'fi',
73-
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "sys.boot_completed" ]; then',
74-
' if [ -f "$AGENT_DEVICE_TEST_EMU_BOOTED_FILE" ]; then',
75-
' echo "1"',
76-
' else',
77-
' echo "0"',
78-
' fi',
79-
' exit 0',
80-
'fi',
81-
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "ro.build.characteristics" ]; then',
82-
' echo "phone"',
83-
' exit 0',
84-
'fi',
85-
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "cmd" ] && [ "$5" = "package" ] && [ "$6" = "has-feature" ]; then',
86-
' echo "false"',
87-
' exit 0',
88-
'fi',
89-
'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "pm" ] && [ "$5" = "list" ] && [ "$6" = "features" ]; then',
90-
' echo ""',
91-
' exit 0',
92-
'fi',
93-
'echo "unexpected adb args: $@" >> "$AGENT_DEVICE_TEST_EMU_LOG_FILE"',
94-
'exit 1',
95-
'',
96-
].join('\n'),
97-
'utf8',
98-
);
99-
await fs.writeFile(
100-
emulatorPath,
101-
[
102-
'#!/bin/sh',
103-
'if [ "$1" = "-list-avds" ]; then',
104-
' echo "Pixel_9_Pro_XL"',
105-
' exit 0',
106-
'fi',
107-
'if [ "$1" = "-avd" ]; then',
108-
' echo "$@" >> "$AGENT_DEVICE_TEST_EMU_LOG_FILE"',
109-
' touch "$AGENT_DEVICE_TEST_EMU_BOOTED_FILE"',
110-
' exit 0',
111-
'fi',
112-
'echo "unexpected emulator args: $@" >> "$AGENT_DEVICE_TEST_EMU_LOG_FILE"',
113-
'exit 1',
114-
'',
115-
].join('\n'),
116-
'utf8',
117-
);
118-
await fs.chmod(adbPath, 0o755);
119-
await fs.chmod(emulatorPath, 0o755);
120-
121-
const previousPath = process.env.PATH;
122-
const previousBooted = process.env.AGENT_DEVICE_TEST_EMU_BOOTED_FILE;
123-
const previousLog = process.env.AGENT_DEVICE_TEST_EMU_LOG_FILE;
124-
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
125-
process.env.AGENT_DEVICE_TEST_EMU_BOOTED_FILE = emulatorBootedPath;
126-
process.env.AGENT_DEVICE_TEST_EMU_LOG_FILE = emulatorLogPath;
140+
await writeExecutable(adbPath, MOCK_ANDROID_ADB_SCRIPT);
141+
await writeExecutable(emulatorPath, MOCK_ANDROID_EMULATOR_SCRIPT);
142+
143+
try {
144+
await withEnv(
145+
{
146+
PATH: `${tmpDir}${path.delimiter}${process.env.PATH ?? ''}`,
147+
AGENT_DEVICE_TEST_EMU_BOOTED_FILE: emulatorBootedPath,
148+
AGENT_DEVICE_TEST_EMU_LOG_FILE: emulatorLogPath,
149+
HOME: tmpDir,
150+
ANDROID_SDK_ROOT: undefined,
151+
ANDROID_HOME: undefined,
152+
},
153+
async () => await run({ emulatorLogPath, emulatorBootedPath }),
154+
);
155+
} finally {
156+
await fs.rm(tmpDir, { recursive: true, force: true });
157+
}
158+
}
159+
160+
async function withMockedAndroidSdkRoot(
161+
run: (ctx: { emulatorLogPath: string; emulatorBootedPath: string; sdkRoot: string }) => Promise<void>,
162+
): Promise<void> {
163+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-sdk-root-'));
164+
const sdkRoot = path.join(tmpDir, 'Android', 'Sdk');
165+
const platformToolsDir = path.join(sdkRoot, 'platform-tools');
166+
const emulatorDir = path.join(sdkRoot, 'emulator');
167+
const emulatorLogPath = path.join(tmpDir, 'emulator.log');
168+
const emulatorBootedPath = path.join(tmpDir, 'emulator.booted');
169+
const adbPath = path.join(platformToolsDir, 'adb');
170+
const emulatorPath = path.join(emulatorDir, 'emulator');
171+
172+
await fs.mkdir(platformToolsDir, { recursive: true });
173+
await fs.mkdir(emulatorDir, { recursive: true });
174+
175+
await writeExecutable(adbPath, MOCK_ANDROID_ADB_SCRIPT);
176+
await writeExecutable(emulatorPath, MOCK_ANDROID_EMULATOR_SCRIPT);
127177

128178
try {
129-
await run({ emulatorLogPath, emulatorBootedPath });
179+
await withEnv(
180+
{
181+
PATH: process.env.PATH ?? '',
182+
AGENT_DEVICE_TEST_EMU_BOOTED_FILE: emulatorBootedPath,
183+
AGENT_DEVICE_TEST_EMU_LOG_FILE: emulatorLogPath,
184+
ANDROID_SDK_ROOT: sdkRoot,
185+
ANDROID_HOME: undefined,
186+
},
187+
async () => await run({ emulatorLogPath, emulatorBootedPath, sdkRoot }),
188+
);
130189
} finally {
131-
process.env.PATH = previousPath;
132-
if (previousBooted === undefined) delete process.env.AGENT_DEVICE_TEST_EMU_BOOTED_FILE;
133-
else process.env.AGENT_DEVICE_TEST_EMU_BOOTED_FILE = previousBooted;
134-
if (previousLog === undefined) delete process.env.AGENT_DEVICE_TEST_EMU_LOG_FILE;
135-
else process.env.AGENT_DEVICE_TEST_EMU_LOG_FILE = previousLog;
136190
await fs.rm(tmpDir, { recursive: true, force: true });
137191
}
138192
}
@@ -180,3 +234,19 @@ test('ensureAndroidEmulatorBooted launches emulator with GUI by default', async
180234
assert.doesNotMatch(log, /-no-window/);
181235
});
182236
});
237+
238+
test('ensureAndroidEmulatorBooted falls back to ANDROID_SDK_ROOT when PATH is incomplete', async () => {
239+
await withMockedAndroidSdkRoot(async ({ emulatorLogPath, sdkRoot }) => {
240+
const device = await ensureAndroidEmulatorBooted({
241+
avdName: 'Pixel 9 Pro XL',
242+
timeoutMs: 5_000,
243+
headless: true,
244+
});
245+
assert.equal(device.id, 'emulator-5554');
246+
const log = await fs.readFile(emulatorLogPath, 'utf8');
247+
assert.match(log, /-avd Pixel_9_Pro_XL -no-window -no-audio/);
248+
assert.ok((process.env.PATH ?? '').includes(path.join(sdkRoot, 'platform-tools')));
249+
assert.ok((process.env.PATH ?? '').includes(path.join(sdkRoot, 'emulator')));
250+
assert.equal(process.env.ANDROID_HOME, sdkRoot);
251+
});
252+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { promises as fs } from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import { ensureAndroidSdkPathConfigured, resolveAndroidSdkRoots } from '../sdk.ts';
7+
8+
async function withTempSdkLayout(
9+
run: (ctx: { env: NodeJS.ProcessEnv; sdkRoot: string }) => Promise<void>,
10+
): Promise<void> {
11+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-sdk-'));
12+
const sdkRoot = path.join(tmpDir, 'Android', 'Sdk');
13+
const cmdlineToolsLatestDir = path.join(sdkRoot, 'cmdline-tools', 'latest', 'bin');
14+
const cmdlineToolsToolsDir = path.join(sdkRoot, 'cmdline-tools', 'tools', 'bin');
15+
const emulatorDir = path.join(sdkRoot, 'emulator');
16+
const platformToolsDir = path.join(sdkRoot, 'platform-tools');
17+
18+
await fs.mkdir(cmdlineToolsLatestDir, { recursive: true });
19+
await fs.mkdir(cmdlineToolsToolsDir, { recursive: true });
20+
await fs.mkdir(emulatorDir, { recursive: true });
21+
await fs.mkdir(platformToolsDir, { recursive: true });
22+
23+
for (const filePath of [
24+
path.join(platformToolsDir, 'adb'),
25+
path.join(emulatorDir, 'emulator'),
26+
path.join(cmdlineToolsLatestDir, 'sdkmanager'),
27+
path.join(cmdlineToolsToolsDir, 'avdmanager'),
28+
]) {
29+
await fs.writeFile(filePath, '#!/bin/sh\nexit 0\n', 'utf8');
30+
await fs.chmod(filePath, 0o755);
31+
}
32+
33+
const env = {
34+
HOME: tmpDir,
35+
PATH: '',
36+
} satisfies NodeJS.ProcessEnv;
37+
38+
try {
39+
await run({ env, sdkRoot });
40+
} finally {
41+
await fs.rm(tmpDir, { recursive: true, force: true });
42+
}
43+
}
44+
45+
test('resolveAndroidSdkRoots prefers configured roots before HOME default', () => {
46+
const roots = resolveAndroidSdkRoots({
47+
HOME: '/tmp/home',
48+
ANDROID_HOME: '/tmp/android-home',
49+
ANDROID_SDK_ROOT: '/tmp/android-sdk-root',
50+
});
51+
assert.deepEqual(roots, [
52+
'/tmp/android-sdk-root',
53+
'/tmp/android-home',
54+
path.join('/tmp/home', 'Android', 'Sdk'),
55+
]);
56+
});
57+
58+
test('ensureAndroidSdkPathConfigured mirrors a single configured SDK root into PATH and ANDROID_HOME', async () => {
59+
await withTempSdkLayout(async ({ env, sdkRoot }) => {
60+
env.ANDROID_SDK_ROOT = sdkRoot;
61+
62+
await ensureAndroidSdkPathConfigured(env);
63+
64+
assert.equal(env.ANDROID_HOME, sdkRoot);
65+
assert.equal(env.ANDROID_SDK_ROOT, sdkRoot);
66+
const pathEntries = (env.PATH ?? '').split(path.delimiter).filter(Boolean);
67+
assert.deepEqual(pathEntries.slice(0, 4), [
68+
path.join(sdkRoot, 'emulator'),
69+
path.join(sdkRoot, 'platform-tools'),
70+
path.join(sdkRoot, 'cmdline-tools', 'latest', 'bin'),
71+
path.join(sdkRoot, 'cmdline-tools', 'tools', 'bin'),
72+
]);
73+
});
74+
});

src/platforms/android/adb.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { whichCmd } from '../../utils/exec.ts';
22
import { AppError } from '../../utils/errors.ts';
33
import type { DeviceInfo } from '../../utils/device.ts';
4+
import { ensureAndroidSdkPathConfigured } from './sdk.ts';
45

56
export function adbArgs(device: DeviceInfo, args: string[]): string[] {
67
return ['-s', device.id, ...args];
78
}
89

910
export async function ensureAdb(): Promise<void> {
11+
await ensureAndroidSdkPathConfigured();
1012
const adbAvailable = await whichCmd('adb');
1113
if (!adbAvailable) throw new AppError('TOOL_MISSING', 'adb not found in PATH');
1214
}

src/platforms/android/devices.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { DeviceInfo } from '../../utils/device.ts';
55
import { Deadline, retryWithPolicy, TIMEOUT_PROFILES } from '../../utils/retry.ts';
66
import { resolveAndroidSerialAllowlist } from '../../utils/device-isolation.ts';
77
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
8+
import { ensureAndroidSdkPathConfigured } from './sdk.ts';
89

910
const EMULATOR_SERIAL_PREFIX = 'emulator-';
1011
const ANDROID_BOOT_POLL_MS = 1000;
@@ -152,6 +153,7 @@ async function resolveAndroidTarget(serial: string): Promise<'mobile' | 'tv'> {
152153
export async function listAndroidDevices(
153154
options: AndroidDeviceDiscoveryOptions = {},
154155
): Promise<DeviceInfo[]> {
156+
await ensureAndroidSdkPathConfigured();
155157
const adbAvailable = await whichCmd('adb');
156158
if (!adbAvailable) {
157159
throw new AppError('TOOL_MISSING', 'adb not found in PATH');
@@ -324,6 +326,7 @@ export async function ensureAndroidEmulatorBooted(params: {
324326
timeoutMs?: number;
325327
headless?: boolean;
326328
}): Promise<DeviceInfo> {
329+
await ensureAndroidSdkPathConfigured();
327330
const requestedAvdName = params.avdName.trim();
328331
if (!requestedAvdName) {
329332
throw new AppError('INVALID_ARGS', 'Android emulator boot requires a non-empty AVD name.');

0 commit comments

Comments
 (0)