Files
software-mansion__argent/packages/tool-server/test/simulator-server-blueprint.test.ts
filip131311 05f53502ab feat(tool-server): drive iOS simulators from additional CoreSimulator device sets (#600)
> Recreates #543 — GitHub auto-closed it when the base branch of the
stacked PR (#542) was deleted on merge, and a closed PR cannot be
reopened after its head is force-pushed. Same commit, rebased onto main.

## Summary

**Was stacked on #542** (now merged). Consumes the
`ios.additionalDeviceSets` configuration across the iOS tool surface, so
simulators living in extra CoreSimulator device sets — e.g. **Radon
IDE's** `~/Library/Caches/com.swmansion.radon-ide/Devices/iOS` — are
discovered by `list-devices` and drivable by every tool, by UDID,
exactly like default-set devices.

### The core piece: a UDID → device-set map (`utils/ios-device-sets.ts`)

simctl scopes every operation to one device set, so each per-device call
must know the target's owning set. The new module owns that mapping and
the single argv choke point:

- **Learned from discovery**: `list-devices` and the simulator watcher
enumerate the default set + every configured set and `rememberDeviceSet`
each sighting — any tool used after a list hits a warm cache.
- **Lazy fallback**: an unknown UDID triggers one `simctl list` probe
per configured set, cached on hit. An unknown-everywhere UDID resolves
to the default set **without** caching (a device that appears later is
found on the next call) — preserving pre-feature behavior and error
messages byte-for-byte.
- **`simctlArgsForUdid(udid, args)`** builds the full argv (`--set`
injected only for additional-set devices); `simctlPrefix` covers
multi-call sites that resolve once; sync variants
(`simctlArgsForUdidSync`, `cachedDeviceSetForUdid`) cover the
`ChildProcess`-returning and `execFileSync` call sites, each of which
runs after an async step that warmed the cache.
- Config is **read off disk per call** (the #534 design): editing
`ios.additionalDeviceSets` applies without a tool-server restart.
- **Guard**: a configured set whose directory doesn't exist is never
queried — simctl would materialize the dir, turning a config typo into a
stray directory.

### Wired consumers

- **`list-devices`** — enumerates every set, tags additional-set devices
with `deviceSet`, documents the field.
- **`simulator-watcher`** — polls every set (a set failing to list
throws → the tick is skipped, so a transient error still can't
mass-dispose tracked services); warms the map for the injection path.
- **simulator-server blueprint** — passes `--device-set` (the same flag
Radon IDE passes to this shared binary); default-set spawns unchanged.
- **`ax-prefs`** — the pre-boot AX plist now lands under the owning
set's device dir (previously hardcoded to the default
`CoreSimulator/Devices` root, so the write would land nowhere for these
devices and describe would degrade).
- **`boot-device`** — set-aware shutdown/boot/bootstatus; skips the
Simulator.app attach + `CurrentDeviceUDID` write for additional-set
devices (one Simulator.app instance displays one device set — these run
headless and are streamed, exactly how Radon IDE presents them).
- **Every remaining per-device simctl call site**: launch-app /
restart-app / reinstall-app (both the direct impls and the
`SimctlBackend` shared handlers), open-url, settings-permissions
(privacy + install probe), the tvOS screenshot fallback, status-bar
pin/restore, device-shutdown, the ios-host `simctl spawn` family behind
describe/native-devtools, tv-control's AX daemon, and the native
profiler's sync helpers (cache warmed at `startNativeProfilerIos`).

**Out of scope**: `sim-remote` — the remote orchestrator host has its
own device-set story.

## Testing

- **Unit**: new `ios-device-sets.test.ts` (mapping, lazy probe + caching
semantics, no-probe fast path with no sets configured, discovery
enumeration/tagging/dedup, missing-dir guard); blueprint test asserts
the `--device-set` argv; the profiler shell-injection mock extended for
the new import. Full tool-server suite green: **276 files / 2752
tests**; `tsc --build`, eslint, prettier clean.
- **Live, end-to-end on a real Radon IDE device** (iPhone Air, iOS 26.4,
in `com.swmansion.radon-ide/Devices/iOS`), driving this branch's
tool-server over HTTP with the config set at project scope:
- `list-devices` → the Radon device appears, tagged with its `deviceSet`
(invisible to argent 0.16.1 on the same machine);
  - `boot-device` → boots headless in its set, no Simulator.app window;
- `screenshot` → real home-screen PNG via simulator-server spawned with
`--device-set`;
  - `launch-app` (Settings) → launched via `simctl --set launch`;
- `describe` → full accessibility tree from the Settings screen, i.e.
the AX bootstrap (set-aware prefs + in-sim ax-service daemon via `simctl
--set spawn`) works;
- clean shutdown; default-set devices meanwhile listed and behaved
exactly as before.
2026-07-31 15:14:03 +02:00

328 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import type { DeviceInfo } from "@argent/registry";
import { toSimulatorNetworkError } from "../src/utils/format-error";
// ─── Mocks ───────────────────────────────────────────────────────────
//
// We mock at the module-boundary layer so the real blueprint factory runs —
// this is a repro of the dispatch and stdio behaviour, not a shape check.
// If any of these are quietly regressed, hands-on Android sessions will start
// failing before this test does, so the assertions below are deliberately
// specific (argv, stdio).
const spawnMock = vi.fn();
const ensureAutomationEnabledMock = vi.fn();
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawn: spawnMock,
};
});
vi.mock("../src/blueprints/ax-service", () => ({
ensureAutomationEnabled: (...args: unknown[]) => ensureAutomationEnabledMock(...args),
}));
vi.mock("@argent/native-devtools-ios", () => ({
simulatorServerBinaryPath: () => "/fake/bin/simulator-server",
simulatorServerBinaryDir: () => "/fake/bin",
}));
// The factory now probes the runtime kind to reject tvOS sims. Mock it to the
// iOS path (false) so these spawn/stdio tests stay hermetic — no real `simctl`,
// which would otherwise hang the fake-timer test waiting on a child process.
vi.mock("../src/utils/ios-devices", () => ({
isTvOsSimulator: vi.fn(async () => false),
}));
// Device-set resolution reads the user's config + probes simctl — mock it to
// the default set (null) so spawns stay hermetic; the additional-set spawn
// test flips it per-case.
const deviceSetForUdidMock = vi.fn(async (_udid: string): Promise<string | null> => null);
vi.mock("../src/utils/ios-device-sets", () => ({
deviceSetForUdid: (udid: string) => deviceSetForUdidMock(udid),
}));
function makeFakeProc() {
const proc = new EventEmitter() as EventEmitter & {
stdout: Readable;
stderr: Readable;
stdin: { write: ReturnType<typeof vi.fn> };
kill: ReturnType<typeof vi.fn>;
};
proc.stdout = new Readable({ read() {} });
proc.stderr = new Readable({ read() {} });
proc.stdin = { write: vi.fn() };
proc.kill = vi.fn();
return proc;
}
/**
* Push the readiness lines into stdout so readline's line events fire and the
* blueprint resolves. We push on nextTick so the blueprint has time to attach
* its listener after calling `spawn`.
*
* A real streaming simulator-server prints `stream_ready` BEFORE `api_ready`
* (see the comment in spawnSimulatorServerProcess). Emitting both — in that
* order — lets the blueprint resolve immediately. Emitting only `api_ready`
* (the old behavior) forced every test to wait out the full STREAM_GRACE_MS
* non-streaming fallback window, adding ~500ms of dead time per test. The
* grace-window fallback itself is covered explicitly, with fake timers, by
* the dedicated non-streaming test below.
*/
function signalReady(proc: ReturnType<typeof makeFakeProc>, port: number) {
setImmediate(() => {
proc.stdout.push(`stream_ready http://127.0.0.1:${port + 1}\n`);
proc.stdout.push(`api_ready http://127.0.0.1:${port}\n`);
});
}
function iosDevice(udid: string): DeviceInfo {
return { id: udid, platform: "ios", kind: "simulator" };
}
function androidDevice(serial: string): DeviceInfo {
return { id: serial, platform: "android", kind: "emulator" };
}
describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInfo", () => {
beforeEach(async () => {
spawnMock.mockReset();
ensureAutomationEnabledMock.mockReset().mockResolvedValue(undefined);
// Pre-warm the dep cache so the Android branch's `ensureDep('adb')` doesn't
// shell out to `command -v adb` — CI Linux runners don't have adb on PATH
// and the real probe would surface as a DependencyMissingError unrelated
// to the dispatch behaviour under test. Lazy-imported so check-deps.ts
// loads after the hoisted vi.mock factories have spawnMock initialised.
const { __resetDepCacheForTests, __primeDepCacheForTests } =
await import("../src/utils/check-deps");
__resetDepCacheForTests();
__primeDepCacheForTests(["xcrun", "adb"]);
});
afterEach(() => {
vi.clearAllMocks();
});
it("spawns the `ios` subcommand for an iOS device", async () => {
const fakeProc = makeFakeProc();
spawnMock.mockReturnValue(fakeProc);
// Late import — the mocks are active at module-load time.
const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server");
const udid = "11111111-2222-3333-4444-555555555555";
const device = iosDevice(udid);
const factoryPromise = simulatorServerBlueprint.factory({}, device, { device });
signalReady(fakeProc, 55555);
const instance = await factoryPromise;
// Contract under test:
expect(spawnMock).toHaveBeenCalledTimes(1);
const [binary, args, opts] = spawnMock.mock.calls[0]!;
expect(binary).toBe("/fake/bin/simulator-server");
expect(args).toEqual(["ios", "--id", udid]);
// stdin must stay open — the server treats EOF on stdin as a shutdown signal.
// We verified this hands-on; if this regresses the server silently exits
// as soon as the tool-server pipes /dev/null.
expect(opts?.stdio).toEqual(["pipe", "pipe", "pipe"]);
expect(instance.api.apiUrl).toBe("http://127.0.0.1:55555");
expect(typeof instance.api.pressKey).toBe("function");
await instance.dispose();
expect(fakeProc.kill).toHaveBeenCalledTimes(1);
});
it("passes --device-set for an iOS device from an additional CoreSimulator set", async () => {
const fakeProc = makeFakeProc();
spawnMock.mockReturnValue(fakeProc);
const radonSet = "/Users/dev/Library/Caches/com.swmansion.radon-ide/Devices/iOS";
deviceSetForUdidMock.mockResolvedValueOnce(radonSet);
const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server");
const udid = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE";
const device = iosDevice(udid);
const factoryPromise = simulatorServerBlueprint.factory({}, device, { device });
signalReady(fakeProc, 55556);
const instance = await factoryPromise;
const [, args] = spawnMock.mock.calls[0]!;
// Same flag Radon IDE passes to this binary for its own set's devices.
expect(args).toEqual(["ios", "--id", udid, "--device-set", radonSet]);
await instance.dispose();
});
it("spawns the `android` subcommand for an Android device", async () => {
const fakeProc = makeFakeProc();
spawnMock.mockReturnValue(fakeProc);
const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server");
const serial = "emulator-5554";
const device = androidDevice(serial);
const factoryPromise = simulatorServerBlueprint.factory({}, device, { device });
signalReady(fakeProc, 55556);
await factoryPromise;
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0]![1]).toEqual(["android", "--id", serial]);
});
it("spawns the `android_device` subcommand for a physical Android device", async () => {
// A physical phone (kind 'device') is driven by a different simulator-server
// controller than an emulator — the screen-sharing-agent path. The blueprint
// selects it by kind, so the rest of the tool surface stays identical.
const fakeProc = makeFakeProc();
spawnMock.mockReturnValue(fakeProc);
const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server");
const serial = "HT82A0203045";
const device: DeviceInfo = { id: serial, platform: "android", kind: "device" };
const factoryPromise = simulatorServerBlueprint.factory({}, device, { device });
signalReady(fakeProc, 55559);
await factoryPromise;
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0]![1]).toEqual(["android_device", "--id", serial]);
});
it("trusts the supplied DeviceInfo and does not reclassify the id", async () => {
// Single-source-of-truth: the blueprint must not run resolveDevice itself.
// If a caller passes an Android device whose id happens to look like an
// iOS UDID, the factory honors the platform on the DeviceInfo and routes
// to the `android` subcommand — not the `ios` one a shape heuristic would
// have picked.
const fakeProc = makeFakeProc();
spawnMock.mockReturnValue(fakeProc);
const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server");
const idShapedLikeIos = "11111111-2222-3333-4444-555555555555";
const device: DeviceInfo = { id: idShapedLikeIos, platform: "android", kind: "emulator" };
const factoryPromise = simulatorServerBlueprint.factory({}, device, { device });
signalReady(fakeProc, 55557);
await factoryPromise;
expect(spawnMock.mock.calls[0]![1]![0]).toBe("android");
});
it("pressKey writes the shared stdin command protocol regardless of platform", async () => {
const fakeProc = makeFakeProc();
spawnMock.mockReturnValue(fakeProc);
const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server");
const device = androidDevice("emulator-5554");
const factoryPromise = simulatorServerBlueprint.factory({}, device, { device });
signalReady(fakeProc, 55558);
const instance = await factoryPromise;
instance.api.pressKey("Down", 0x29);
instance.api.pressKey("Up", 0x29);
expect(fakeProc.stdin.write).toHaveBeenNthCalledWith(1, "key Down 41\n");
expect(fakeProc.stdin.write).toHaveBeenNthCalledWith(2, "key Up 41\n");
});
it("rejects when the caller forgets to pass DeviceInfo via options", async () => {
// Defensive: without a device, the factory has no way to decide ios vs
// android (and that's intentional — the SOT now lives upstream). Surface a
// clear actionable error instead of silently using a default.
const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server");
const stub: DeviceInfo = { id: "ignored", platform: "ios", kind: "simulator" };
await expect(simulatorServerBlueprint.factory({}, stub)).rejects.toThrow(
/requires a resolved DeviceInfo via options\.device/
);
});
it("falls back to the STREAM_GRACE_MS resolve when only api_ready arrives (non-streaming build)", async () => {
// Non-streaming / older simulator-server builds never print `stream_ready`.
// The blueprint must still resolve, after a bounded grace window, with an
// empty streamUrl. Fake timers prove the timing deterministically instead
// of burning ~500ms of real wall time (this is exactly the cost the other
// tests used to pay implicitly before signalReady emitted stream_ready).
const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server");
vi.useFakeTimers();
try {
const fakeProc = makeFakeProc();
spawnMock.mockReturnValue(fakeProc);
const device = iosDevice("99999999-8888-7777-6666-555555555555");
const factoryPromise = simulatorServerBlueprint.factory({}, device, { device });
let resolved = false;
void factoryPromise.then(() => {
resolved = true;
});
// Only api_ready — no stream_ready ever arrives.
fakeProc.stdout.push("api_ready http://127.0.0.1:60000\n");
// Settle the readline pipeline so the blueprint arms its grace timer,
// then confirm it is still waiting well inside the grace window.
await vi.advanceTimersByTimeAsync(1);
expect(resolved).toBe(false);
// Crossing STREAM_GRACE_MS resolves it — with no stream URL.
await vi.advanceTimersByTimeAsync(600);
const instance = await factoryPromise;
expect(resolved).toBe(true);
expect(instance.api.apiUrl).toBe("http://127.0.0.1:60000");
expect(instance.api.streamUrl).toBe("");
} finally {
vi.useRealTimers();
}
});
});
describe("simulatorServerBlueprint.recoverable — self-heal a wedged sim-server", () => {
const apiUrl = "http://127.0.0.1:58710";
// Mirror what `fetch()` throws so the classifier walks the real cause chain:
// a `TypeError: fetch failed` wrapping the low-level connect error.
function fetchError(causeMessage: string, name = "TypeError"): Error {
const cause = new Error(causeMessage);
const err = new Error("fetch failed", { cause });
err.name = name;
return err;
}
it("recovers on ECONNREFUSED — the un-booted-simulator symptom", async () => {
const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server");
const err = toSimulatorNetworkError(
"Screenshot",
fetchError("connect ECONNREFUSED 127.0.0.1:58710"),
apiUrl
);
expect(simulatorServerBlueprint.recoverable!(err)).toBe(true);
});
it("does NOT recover on a reset — the request may have taken effect", async () => {
const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server");
const err = toSimulatorNetworkError("Screenshot", fetchError("read ECONNRESET"), apiUrl);
expect(simulatorServerBlueprint.recoverable!(err)).toBe(false);
});
it("does NOT recover on a timeout — a hung-but-listening server won't be fixed by respawning", async () => {
const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server");
const err = toSimulatorNetworkError(
"Screenshot",
fetchError("The operation was aborted", "AbortError"),
apiUrl
);
expect(simulatorServerBlueprint.recoverable!(err)).toBe(false);
});
it("does NOT recover on an unrelated error carrying no failure signal", async () => {
const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server");
expect(simulatorServerBlueprint.recoverable!(new Error("boom"))).toBe(false);
});
});