Files
software-mansion__argent/packages/tool-server/test/ios-devices-runtime-kind.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

126 lines
4.6 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: "" });
},
};
});
import {
getSimulatorRuntimeKind,
getCachedSimulatorRuntimeKind,
cacheSimulatorRuntimeKind,
__resetSimulatorRuntimeKindCacheForTesting,
} from "../src/utils/ios-devices";
const TV_UDID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA";
const PHONE_UDID = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB";
// Shape a `simctl list devices --json` payload: one tvOS device and one iOS
// device, so both a "tv" and a "mobile" verdict can be resolved from one probe.
function mockSimctl(): void {
execFileMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "xcrun" && args[0] === "simctl" && args[1] === "list") {
return {
stdout: JSON.stringify({
devices: {
"com.apple.CoreSimulator.SimRuntime.tvOS-18-0": [
{
udid: TV_UDID,
name: "Apple TV",
state: "Booted",
deviceTypeIdentifier:
"com.apple.CoreSimulator.SimDeviceType.Apple-TV-4K-3rd-generation",
isAvailable: true,
},
],
"com.apple.CoreSimulator.SimRuntime.iOS-18-0": [
{
udid: PHONE_UDID,
name: "iPhone 16",
state: "Booted",
deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-16",
isAvailable: true,
},
],
},
}),
stderr: "",
};
}
return { stdout: "", stderr: "" };
});
}
beforeEach(() => {
execFileMock.mockReset();
__resetSimulatorRuntimeKindCacheForTesting();
});
describe("getCachedSimulatorRuntimeKind — synchronous cache-only read", () => {
it("returns undefined for a UDID that has never been probed", () => {
// No async resolution has warmed the cache → the hot-path reader stays coarse
// and never triggers a simctl call.
mockSimctl();
expect(getCachedSimulatorRuntimeKind(TV_UDID)).toBeUndefined();
expect(execFileMock).not.toHaveBeenCalled();
});
it("returns 'tv' after an async probe warms the cache, without any simctl call", async () => {
mockSimctl();
expect(await getSimulatorRuntimeKind(TV_UDID)).toBe("tv");
const callsBefore = execFileMock.mock.calls.length;
expect(getCachedSimulatorRuntimeKind(TV_UDID)).toBe("tv");
// The synchronous read must not shell out.
expect(execFileMock.mock.calls.length).toBe(callsBefore);
});
it("returns 'mobile' for a warmed iPhone simulator UDID", async () => {
mockSimctl();
expect(await getSimulatorRuntimeKind(PHONE_UDID)).toBe("mobile");
expect(getCachedSimulatorRuntimeKind(PHONE_UDID)).toBe("mobile");
});
it("stays undefined for an unknown UDID even after another device warms the cache", async () => {
mockSimctl();
await getSimulatorRuntimeKind(TV_UDID);
expect(getCachedSimulatorRuntimeKind("CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")).toBeUndefined();
});
});
describe("cacheSimulatorRuntimeKind — warm from an out-of-band verdict", () => {
it("seeds the cache so the synchronous reader refines without any simctl call", () => {
// The tv-control factory already holds the runtime kind from its own
// listIosSimulators() call; warming here lets the telemetry reader see `tv`
// with no further probe (the whole point of the synchronous hot path).
cacheSimulatorRuntimeKind(TV_UDID, "tv");
expect(getCachedSimulatorRuntimeKind(TV_UDID)).toBe("tv");
expect(execFileMock).not.toHaveBeenCalled();
});
it("caches a mobile verdict too (an iPhone that reached a tv path)", () => {
cacheSimulatorRuntimeKind(PHONE_UDID, "mobile");
expect(getCachedSimulatorRuntimeKind(PHONE_UDID)).toBe("mobile");
});
it("is a no-op for an undefined kind, leaving the entry unwarmed", () => {
cacheSimulatorRuntimeKind(TV_UDID, undefined);
expect(getCachedSimulatorRuntimeKind(TV_UDID)).toBeUndefined();
});
});