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