Files
software-mansion__argent/packages/tool-server/test/android-binary.test.ts
Ignacy Łątka f2b0c0be1a feat(android-binary): probe OS-default SDK install locations when env vars are absent (#293)
## Summary

- Extends `androidRoots()` to fall back to OS-default Android SDK
install paths after `ANDROID_HOME` / `ANDROID_SDK_ROOT` come up empty
- Covers Android Studio defaults (`~/Library/Android/sdk` on macOS,
`~/Android/Sdk` on Linux) and common system-wide paths
(`/opt/android-sdk`, `/usr/lib/android-sdk`, etc.)
- Fixes the case where an MCP server spawned by a GUI process (Claude
Code desktop, VS Code) doesn't inherit the shell-exported
`ANDROID_HOME`, causing argent to silently fail to find `adb`/`emulator`
even on a correctly configured machine

## Stacking

Stacked on `feat/linux-host-support`. Base will be retargeted to `main`
after that PR merges.

## Test plan

- [ ] New tests in `android-binary.test.ts` cover the Studio-default
paths and the `ANDROID_HOME` priority invariant
- [ ] `npm run test -w packages/tool-server` passes
2026-06-03 17:14:35 +02:00

239 lines
9.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdir, mkdtemp, rm, writeFile, chmod } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
__resetAndroidBinaryCacheForTesting,
resolveAndroidBinary,
} from "../src/utils/android-binary";
import {
__resetDepCacheForTests,
ensureDep,
DependencyMissingError,
} from "../src/utils/check-deps";
// Snapshot the env vars we mutate so a failing assertion can't leak state into
// the next test (or the surrounding process: vitest reuses the worker for
// other suites and a stale ANDROID_HOME would silently flip their behavior).
// HOME is included because `androidRoots()` derives Android Studio's default
// install path from `os.homedir()`, which on Linux/macOS honors $HOME. Pinning
// HOME to a tmpdir keeps the resolver from accidentally finding the dev's real
// SDK during tests that assert "not resolvable".
const ENV_KEYS = ["PATH", "ANDROID_HOME", "ANDROID_SDK_ROOT", "HOME"] as const;
const originalEnv: Record<string, string | undefined> = {};
async function fakeSdk(root: string, name: "adb" | "emulator"): Promise<string> {
const subdir = name === "adb" ? "platform-tools" : "emulator";
const dir = join(root, subdir);
await mkdir(dir, { recursive: true });
const path = join(dir, name);
// Minimal executable shim — the resolver only checks X_OK + path; spawning
// is exercised separately in adb.ts integration tests.
await writeFile(path, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
await chmod(path, 0o755);
return path;
}
describe("resolveAndroidBinary", () => {
let tmpRoot: string;
beforeEach(async () => {
for (const k of ENV_KEYS) originalEnv[k] = process.env[k];
__resetAndroidBinaryCacheForTesting();
__resetDepCacheForTests();
tmpRoot = await mkdtemp(join(tmpdir(), "argent-android-binary-"));
});
afterEach(async () => {
for (const k of ENV_KEYS) {
if (originalEnv[k] === undefined) delete process.env[k];
else process.env[k] = originalEnv[k];
}
await rm(tmpRoot, { recursive: true, force: true });
});
it("finds emulator under $ANDROID_HOME when not on PATH", async () => {
const sdk = join(tmpRoot, "sdk");
const expected = await fakeSdk(sdk, "emulator");
// Strip PATH down to OS basics so the test doesn't accidentally find a
// real `emulator` binary on the host running the suite (CI shouldn't have
// one but a developer's macOS easily can).
process.env.PATH = tmpRoot; // empty: keep PATH-installed adb/emulator on dev boxes from short-circuiting the probe
process.env.ANDROID_HOME = sdk;
delete process.env.ANDROID_SDK_ROOT;
const path = await resolveAndroidBinary("emulator");
expect(path).toBe(expected);
});
it("finds adb under $ANDROID_HOME/platform-tools when not on PATH", async () => {
const sdk = join(tmpRoot, "sdk");
const expected = await fakeSdk(sdk, "adb");
process.env.PATH = tmpRoot; // empty: keep PATH-installed adb/emulator on dev boxes from short-circuiting the probe
process.env.ANDROID_HOME = sdk;
delete process.env.ANDROID_SDK_ROOT;
const path = await resolveAndroidBinary("adb");
expect(path).toBe(expected);
});
it("falls back to $ANDROID_SDK_ROOT when $ANDROID_HOME is unset", async () => {
const sdk = join(tmpRoot, "sdk-root");
const expected = await fakeSdk(sdk, "emulator");
process.env.PATH = tmpRoot; // empty: keep PATH-installed adb/emulator on dev boxes from short-circuiting the probe
delete process.env.ANDROID_HOME;
process.env.ANDROID_SDK_ROOT = sdk;
const path = await resolveAndroidBinary("emulator");
expect(path).toBe(expected);
});
it("prefers PATH over $ANDROID_HOME when both resolve", async () => {
// PATH-installed copy
const pathBinDir = join(tmpRoot, "pathbin");
await mkdir(pathBinDir, { recursive: true });
const pathCopy = join(pathBinDir, "emulator");
await writeFile(pathCopy, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
await chmod(pathCopy, 0o755);
// $ANDROID_HOME-installed copy
const sdk = join(tmpRoot, "sdk");
await fakeSdk(sdk, "emulator");
process.env.PATH = `${pathBinDir}:/usr/bin:/bin`;
process.env.ANDROID_HOME = sdk;
delete process.env.ANDROID_SDK_ROOT;
const path = await resolveAndroidBinary("emulator");
expect(path).toBe(pathCopy);
});
it("returns null when neither PATH nor SDK roots resolve", async () => {
process.env.PATH = tmpRoot; // empty: keep PATH-installed adb/emulator on dev boxes from short-circuiting the probe
delete process.env.ANDROID_HOME;
delete process.env.ANDROID_SDK_ROOT;
// Pin HOME to an empty tmpdir so the default-install probe can't
// accidentally pick up a real Android Studio install at
// `~/Android/Sdk` or `~/Library/Android/sdk` on the dev's box.
process.env.HOME = tmpRoot;
const path = await resolveAndroidBinary("emulator");
expect(path).toBeNull();
});
it("finds emulator under ~/Android/Sdk (Linux Android Studio default) without env vars", async () => {
const sdk = join(tmpRoot, "Android", "Sdk");
const expected = await fakeSdk(sdk, "emulator");
// PATH points at an empty dir so a dev with `emulator` installed via apt
// (in /usr/bin) can still run this suite without it short-circuiting on
// PATH before we exercise the default-install probe.
process.env.PATH = tmpRoot;
delete process.env.ANDROID_HOME;
delete process.env.ANDROID_SDK_ROOT;
process.env.HOME = tmpRoot;
const path = await resolveAndroidBinary("emulator");
expect(path).toBe(expected);
});
it("finds adb under ~/Library/Android/sdk (macOS Android Studio default) without env vars", async () => {
const sdk = join(tmpRoot, "Library", "Android", "sdk");
const expected = await fakeSdk(sdk, "adb");
process.env.PATH = tmpRoot;
delete process.env.ANDROID_HOME;
delete process.env.ANDROID_SDK_ROOT;
process.env.HOME = tmpRoot;
const path = await resolveAndroidBinary("adb");
expect(path).toBe(expected);
});
it("prefers $ANDROID_HOME over default install locations", async () => {
// SDK at $ANDROID_HOME
const envSdk = join(tmpRoot, "env-sdk");
const envBinary = await fakeSdk(envSdk, "emulator");
// Decoy SDK at the Linux Android Studio default — should be ignored when
// ANDROID_HOME is set, so a user with two installs gets the one they
// explicitly picked, not the one Studio happened to drop.
const studioSdk = join(tmpRoot, "Android", "Sdk");
await fakeSdk(studioSdk, "emulator");
process.env.PATH = tmpRoot;
process.env.ANDROID_HOME = envSdk;
delete process.env.ANDROID_SDK_ROOT;
process.env.HOME = tmpRoot;
const path = await resolveAndroidBinary("emulator");
expect(path).toBe(envBinary);
});
it("ignores a non-executable file at the canonical SDK path", async () => {
const sdk = join(tmpRoot, "sdk");
const dir = join(sdk, "emulator");
await mkdir(dir, { recursive: true });
// Mode 0o644 — present but not executable, simulating a corrupted install.
await writeFile(join(dir, "emulator"), "stub", { mode: 0o644 });
await chmod(join(dir, "emulator"), 0o644);
process.env.PATH = tmpRoot; // empty: keep PATH-installed adb/emulator on dev boxes from short-circuiting the probe
process.env.ANDROID_HOME = sdk;
delete process.env.ANDROID_SDK_ROOT;
// Pin HOME so the default-install probe can't fall back to a real SDK on
// the dev's box (~/android-sdk, ~/Android/Sdk, etc.) and turn this into
// a "found something else, test passes for the wrong reason" pass.
process.env.HOME = tmpRoot;
const path = await resolveAndroidBinary("emulator");
// Resolver should refuse the non-executable candidate. With no other
// root configured, that means null.
expect(path).toBeNull();
});
});
describe("ensureDep('emulator')", () => {
let tmpRoot: string;
beforeEach(async () => {
for (const k of ENV_KEYS) originalEnv[k] = process.env[k];
__resetAndroidBinaryCacheForTesting();
__resetDepCacheForTests();
tmpRoot = await mkdtemp(join(tmpdir(), "argent-ensure-dep-"));
});
afterEach(async () => {
for (const k of ENV_KEYS) {
if (originalEnv[k] === undefined) delete process.env[k];
else process.env[k] = originalEnv[k];
}
await rm(tmpRoot, { recursive: true, force: true });
});
it("passes when emulator is resolvable via $ANDROID_HOME alone", async () => {
const sdk = join(tmpRoot, "sdk");
await fakeSdk(sdk, "emulator");
process.env.PATH = tmpRoot; // empty: keep PATH-installed adb/emulator on dev boxes from short-circuiting the probe
process.env.ANDROID_HOME = sdk;
delete process.env.ANDROID_SDK_ROOT;
await expect(ensureDep("emulator")).resolves.toBeUndefined();
});
it("throws DependencyMissingError with install hint when neither resolves", async () => {
process.env.PATH = tmpRoot; // empty: keep PATH-installed adb/emulator on dev boxes from short-circuiting the probe
delete process.env.ANDROID_HOME;
delete process.env.ANDROID_SDK_ROOT;
// Same reason as the resolver test: keep the default-install probe from
// finding a real SDK on the dev box and turning this into a flaky pass.
process.env.HOME = tmpRoot;
await expect(ensureDep("emulator")).rejects.toBeInstanceOf(DependencyMissingError);
try {
await ensureDep("emulator");
} catch (err) {
// The hint must guide the user to fix the actual problem (set
// ANDROID_HOME) rather than just the prior PATH-only message.
expect((err as Error).message).toMatch(/ANDROID_HOME/);
expect((err as Error).message).toMatch(/emulator/);
}
});
});