Files
software-mansion__argent/packages/tool-server/test/android-tv-discovery.test.ts
Hubert Gancarczyk f38f0e3876 feat(telemetry): distinguish tvOS and Android TV from iOS/Android (#477)
## Summary

Telemetry inferred the device platform purely from device-id **shape**
on the per-tool-call hot path, so a tvOS simulator (same 8-4-4-4-12 UUID
as an iPhone) and an Android TV emulator (same `emulator-NNNN` serial as
a phone) were both reported as `ios` / `android` — TV usage was
invisible in analytics.

This adds `tvos` and `android-tv` to the **telemetry** `PLATFORMS` enum
and refines the inference so a TV target is attributed correctly.

## Approach

- The runtime kind (`mobile` / `tv`) is already resolved and memoized in
two module-level caches (`runtimeKindCache` in `ios-devices.ts`,
`androidRuntimeKindCache` in `adb.ts`), warmed by the usual TV-path
tools (describe, screenshot, keyboard, launch/restart-app, the streaming
blueprint, tv-control).
- New synchronous, **cache-only** readers
(`getCachedSimulatorRuntimeKind`, `getCachedAndroidRuntimeKind`) let
`refineTvPlatform` in `http.ts` map `ios → tvos` / `android →
android-tv` with **no new `simctl`/`adb` round-trip** on the hot path.
- The registry **device** `Platform` (`@argent/registry`) is
intentionally left unchanged — a TV stays a `runtimeKind`, not its own
device platform, so capability gating and dispatch remain TV-agnostic.
The two new values are confined to the telemetry boundary (`tool:invoke`
/ `tool:complete` / `tool:fail` and child sub-tool attribution).

## Behavior notes

- The **first** tool call on a device may land before the cache is warm
and reports the coarse base platform (`ios`/`android`); subsequent
calls, once describe/screenshot/streaming have run, report the TV
variant. This is best-effort coarse telemetry by design.
- For an `emulator-NNNN` slot reclaimed by a different-form-factor AVD
within one tool-server lifetime, the synchronous reader can momentarily
report the prior occupant's kind until an async, avdName-checked path
re-probes. Documented inline; no crash and no dispatch impact.

## Verification

- **Live E2E** against real booted devices (real `simctl`/`adb` → real
cache → real http telemetry recording): Apple TV → `tvos` (cold-cache
`ios`, warm `tvos`); iPhone → `ios`; Android phone → `android`. The
`android-tv` path is symmetric and covered by unit/integration tests
with real leanback-feature probing.
- Unit + integration tests added: sanitize allowlist, http platform
inference (cold/warm/TV/child), and cache readers. Telemetry suite
192/192; tool-server affected suites green.
- `tsc` build, `typecheck:tests`, eslint (`--max-warnings 0`), and
prettier all clean.
2026-07-07 16:29:22 +02:00

295 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
const execFileMock = vi.fn();
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
execFile: (
cmd: string,
args: readonly string[],
opts: unknown,
cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void
) => {
const callback = typeof opts === "function" ? opts : cb!;
const options = typeof opts === "function" ? undefined : opts;
const result = execFileMock(cmd, args, options);
if (result instanceof Error) callback(result, { stdout: "", stderr: "" });
else callback(null, result ?? { stdout: "", stderr: "" });
},
};
});
vi.mock("../src/utils/android-binary", () => ({
resolveAndroidBinary: vi.fn(async (name: "adb" | "emulator") => name),
__resetAndroidBinaryCacheForTesting: () => {},
}));
import {
listAndroidDevices,
getAndroidRuntimeKind,
isAndroidTv,
__resetAndroidRuntimeKindCacheForTesting,
getCachedAndroidRuntimeKind,
} from "../src/utils/adb";
// Mock one device. `features` is the `pm list features` output (the primary TV
// signal — leanback / television); `characteristics` is the `ro.build.characteristics`
// value (the secondary fallback). Either can independently make a device read as TV.
function mockDevice(
serial: string,
opts: { features?: string; characteristics?: string } = {}
): void {
const features = opts.features ?? "";
const characteristics = opts.characteristics ?? "";
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "adb" && args[0] === "devices" && args.length === 1) {
return { stdout: `List of devices attached\n${serial}\tdevice\n`, stderr: "" };
}
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
const shell = args[3] ?? "";
if (shell === "getprop ro.product.model") return { stdout: "AndroidTV\n", stderr: "" };
if (shell === "getprop ro.build.version.sdk") return { stdout: "34\n", stderr: "" };
if (shell === "pm list features") return { stdout: `${features}\n`, stderr: "" };
if (shell === "getprop ro.build.characteristics")
return { stdout: `${characteristics}\n`, stderr: "" };
return { stdout: "\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
}
const LEANBACK_FEATURES =
"feature:android.hardware.type.television\nfeature:android.software.leanback\nfeature:android.software.leanback_only";
const PHONE_FEATURES = "feature:android.hardware.touchscreen\nfeature:android.software.app_widgets";
beforeEach(() => {
execFileMock.mockReset();
// The runtime-kind memo persists across calls within the process; clear it so
// each case starts from a cold probe (and reused serials don't leak verdicts).
__resetAndroidRuntimeKindCacheForTesting();
});
// `mockDevice` populates avd_name as empty, so the cache key avdName is null.
function withAvdName(serial: string, avdName: string): void {
const prev = execFileMock.getMockImplementation();
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (
cmd === "adb" &&
args[0] === "-s" &&
args[1] === serial &&
args[2] === "shell" &&
(args[3] === "getprop ro.boot.qemu.avd_name" || args[3] === "getprop ro.kernel.qemu.avd_name")
) {
return { stdout: `${avdName}\n`, stderr: "" };
}
return prev?.(cmd, args) ?? { stdout: "", stderr: "" };
});
}
describe("Android TV discovery — runtimeKind", () => {
it("tags a device exposing the leanback/television feature as runtimeKind 'tv'", async () => {
mockDevice("emulator-5556", { features: LEANBACK_FEATURES, characteristics: "emulator" });
const devices = await listAndroidDevices({ runtimeKind: true });
expect(devices).toHaveLength(1);
expect(devices[0]!.runtimeKind).toBe("tv");
});
it("regression: Google ATV emulator (leanback feature present, characteristics='emulator') is 'tv'", async () => {
// The Google ATV emulator images report ro.build.characteristics=emulator
// (NOT 'tv'), so the feature list is the only reliable signal. This is the
// exact shape observed on the Television_1080p AVD during live testing.
mockDevice("emulator-5554", { features: LEANBACK_FEATURES, characteristics: "emulator" });
const devices = await listAndroidDevices({ runtimeKind: true });
expect(devices[0]!.runtimeKind).toBe("tv");
});
it("falls back to the characteristics 'tv' token when the feature list is empty", async () => {
mockDevice("emulator-5556", { features: "", characteristics: "nosdcard,tv" });
const devices = await listAndroidDevices({ runtimeKind: true });
expect(devices[0]!.runtimeKind).toBe("tv");
});
it("tags a phone (touchscreen features, no leanback) as runtimeKind 'mobile'", async () => {
mockDevice("emulator-5554", { features: PHONE_FEATURES, characteristics: "nosdcard" });
const devices = await listAndroidDevices({ runtimeKind: true });
expect(devices[0]!.runtimeKind).toBe("mobile");
});
it("reports an indeterminate runtimeKind (undefined) when both signals are empty/unreadable", async () => {
// An empty feature list is indistinguishable from a genuine phone, so a
// device mid-boot / under load must NOT be pinned to "mobile" — it stays
// undefined (and uncached) so the next call re-probes.
mockDevice("emulator-5554", {});
const devices = await listAndroidDevices({ runtimeKind: true });
expect(devices[0]!.runtimeKind).toBeUndefined();
});
it("regression: empty feature list + characteristics='emulator' is indeterminate, NOT 'mobile'", async () => {
// ATV emulator boot window: feature list empty, characteristics='emulator'
// (non-empty, no `tv` token). Must stay indeterminate, not collapse to
// "mobile" — that would mislabel and cache the TV as a phone.
mockDevice("emulator-5554", { features: "", characteristics: "emulator" });
const devices = await listAndroidDevices({ runtimeKind: true });
expect(devices[0]!.runtimeKind).toBeUndefined();
});
it("characteristics fallback does not false-match a non-'tv' token like 'atv'", async () => {
mockDevice("emulator-5554", { features: PHONE_FEATURES, characteristics: "atv,nosdcard" });
const devices = await listAndroidDevices({ runtimeKind: true });
expect(devices[0]!.runtimeKind).toBe("mobile");
});
it("a populated non-leanback feature list wins over a stray 'tv' characteristics token", async () => {
// The feature list is the primary, authoritative signal. A phone that
// answered `pm list features` (no leanback) must stay "mobile" even if
// ro.build.characteristics happens to carry a `tv` token — and we must not
// pay the characteristics round-trip at all once features answered.
mockDevice("emulator-5554", { features: PHONE_FEATURES, characteristics: "tv,nosdcard" });
const devices = await listAndroidDevices({ runtimeKind: true });
expect(devices[0]!.runtimeKind).toBe("mobile");
const probedCharacteristics = execFileMock.mock.calls.some(
(c) => c[0] === "adb" && (c[1] as string[]).includes("ro.build.characteristics")
);
expect(probedCharacteristics).toBe(false);
});
it("skips the runtimeKind feature probe by default (boot-loop hot path)", async () => {
// The boot-loop poller (findSerialByAvdName) calls listAndroidDevices every
// 1.5s and never reads runtimeKind, so the default must NOT issue the
// `pm list features` probe — which mid-boot can block up to the enrichment
// timeout — nor populate the field.
mockDevice("emulator-5554", { features: LEANBACK_FEATURES });
const devices = await listAndroidDevices();
expect(devices[0]!.runtimeKind).toBeUndefined();
const probedFeatures = execFileMock.mock.calls.some(
(c) => c[0] === "adb" && (c[1] as string[]).includes("pm list features")
);
expect(probedFeatures).toBe(false);
});
});
describe("Android TV discovery — getAndroidRuntimeKind / isAndroidTv", () => {
it("resolves the runtime kind for a ready TV serial", async () => {
mockDevice("emulator-5556", { features: LEANBACK_FEATURES });
expect(await getAndroidRuntimeKind("emulator-5556")).toBe("tv");
expect(await isAndroidTv("emulator-5556")).toBe(true);
});
it("returns undefined for a serial not in the device list", async () => {
mockDevice("emulator-5556", { features: LEANBACK_FEATURES });
// NOTE: getAndroidRuntimeKind memoises per-serial; a serial never seen is
// not cached, so this re-lists and finds no match.
expect(await getAndroidRuntimeKind("emulator-9999")).toBeUndefined();
expect(await isAndroidTv("emulator-9999")).toBe(false);
});
it("does not cache an indeterminate probe — a slow TV re-probes and self-heals", async () => {
// First call: both signals empty (device still booting) → indeterminate.
mockDevice("emulator-5554", {});
expect(await getAndroidRuntimeKind("emulator-5554")).toBeUndefined();
// Now the feature list answers; since the miss wasn't cached as "mobile",
// the next call re-probes and correctly resolves "tv" — no restart needed.
mockDevice("emulator-5554", { features: LEANBACK_FEATURES });
expect(await getAndroidRuntimeKind("emulator-5554")).toBe("tv");
});
it("regression: ATV emulator mid-boot (features empty, characteristics='emulator') self-heals to 'tv'", async () => {
// Boot window must be indeterminate AND uncached — otherwise the "mobile"
// verdict is pinned for the process lifetime, even after PMS comes up.
mockDevice("emulator-5554", { features: "", characteristics: "emulator" });
expect(await getAndroidRuntimeKind("emulator-5554")).toBeUndefined();
// PMS up: the uncached miss re-probes and resolves "tv".
mockDevice("emulator-5554", { features: LEANBACK_FEATURES, characteristics: "emulator" });
expect(await getAndroidRuntimeKind("emulator-5554")).toBe("tv");
});
it("re-probes when a reused emulator slot boots a different AVD", async () => {
// A TV AVD occupies emulator-5554 and gets cached as 'tv'.
mockDevice("emulator-5554", { features: LEANBACK_FEATURES });
withAvdName("emulator-5554", "Television_1080p");
expect(await getAndroidRuntimeKind("emulator-5554")).toBe("tv");
// It shuts down and a phone AVD reclaims the same console slot. The serial
// matches a cached entry, but the avdName differs → re-probe, not a stale
// 'tv' verdict that would route the phone to the focus/leanback backends.
mockDevice("emulator-5554", { features: PHONE_FEATURES });
withAvdName("emulator-5554", "Pixel_7_API_34");
expect(await getAndroidRuntimeKind("emulator-5554")).toBe("mobile");
});
it("does not read avd_name getprops for a physical (non-emulator) serial", async () => {
// A physical serial is never reclaimed and has no qemu avd_name, so the hot
// path must not spend two getprops to always get null — it keys on null.
mockDevice("A1B2C3D4", { features: PHONE_FEATURES });
expect(await getAndroidRuntimeKind("A1B2C3D4")).toBe("mobile");
const probedAvdName = execFileMock.mock.calls.some(
(c) =>
c[0] === "adb" &&
Array.isArray(c[1]) &&
(c[1] as string[]).some((a) => typeof a === "string" && a.includes("avd_name"))
);
expect(probedAvdName).toBe(false);
});
it("re-uses the cached verdict on a second call without re-probing features", async () => {
// The per-interaction callers (describe / keyboard / launch / restart) hit
// this repeatedly; a cache hit must skip the `pm list features` round-trip.
mockDevice("emulator-5556", { features: LEANBACK_FEATURES });
expect(await getAndroidRuntimeKind("emulator-5556")).toBe("tv");
const featureProbesBefore = execFileMock.mock.calls.filter(
(c) =>
c[0] === "adb" && Array.isArray(c[1]) && (c[1] as string[]).includes("pm list features")
).length;
expect(await getAndroidRuntimeKind("emulator-5556")).toBe("tv");
const featureProbesAfter = execFileMock.mock.calls.filter(
(c) =>
c[0] === "adb" && Array.isArray(c[1]) && (c[1] as string[]).includes("pm list features")
).length;
expect(featureProbesAfter).toBe(featureProbesBefore);
});
it("evicts the cached verdict once the serial leaves the device list", async () => {
mockDevice("emulator-5554", { features: LEANBACK_FEATURES });
expect(await getAndroidRuntimeKind("emulator-5554")).toBe("tv");
// Serial no longer attached → undefined, and the cache entry is dropped so
// the slot's next occupant doesn't inherit 'tv'.
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "adb" && args[0] === "devices" && args.length === 1) {
return { stdout: "List of devices attached\n", stderr: "" };
}
return { stdout: "", stderr: "" };
});
expect(await getAndroidRuntimeKind("emulator-5554")).toBeUndefined();
});
});
describe("getCachedAndroidRuntimeKind — synchronous cache-only read", () => {
it("returns undefined for a serial that has never been probed", () => {
// No async probe has warmed the cache → the hot-path reader stays coarse.
expect(getCachedAndroidRuntimeKind("emulator-5554")).toBeUndefined();
});
it("returns the memoized 'tv' verdict after an async probe warms it, without any adb call", async () => {
mockDevice("emulator-5556", { features: LEANBACK_FEATURES });
expect(await getAndroidRuntimeKind("emulator-5556")).toBe("tv");
const callsBefore = execFileMock.mock.calls.length;
expect(getCachedAndroidRuntimeKind("emulator-5556")).toBe("tv");
// The cache read must not shell out to adb.
expect(execFileMock.mock.calls.length).toBe(callsBefore);
});
it("returns the memoized 'mobile' verdict for a warmed phone serial", async () => {
mockDevice("emulator-5554", { features: PHONE_FEATURES });
expect(await getAndroidRuntimeKind("emulator-5554")).toBe("mobile");
expect(getCachedAndroidRuntimeKind("emulator-5554")).toBe("mobile");
});
it("does not cache (stays undefined) when the probe was indeterminate", async () => {
mockDevice("emulator-5554", {});
expect(await getAndroidRuntimeKind("emulator-5554")).toBeUndefined();
expect(getCachedAndroidRuntimeKind("emulator-5554")).toBeUndefined();
});
});