mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
f38f0e3876
## 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.
126 lines
4.6 KiB
TypeScript
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();
|
|
});
|
|
});
|