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.
206 lines
7.6 KiB
TypeScript
206 lines
7.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
const execFileMock = vi.fn();
|
|
const additionalSetsMock = vi.fn<() => string[]>(() => []);
|
|
const existsSyncMock = vi.fn<(p: string) => boolean>(() => true);
|
|
|
|
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 result = execFileMock(cmd, args);
|
|
if (result instanceof Error) callback(result, { stdout: "", stderr: "" });
|
|
else callback(null, result ?? { stdout: "", stderr: "" });
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("@argent/configuration-core", async () => {
|
|
const actual = await vi.importActual<typeof import("@argent/configuration-core")>(
|
|
"@argent/configuration-core"
|
|
);
|
|
return { ...actual, getAdditionalIosDeviceSets: () => additionalSetsMock() };
|
|
});
|
|
|
|
vi.mock("node:fs", async () => {
|
|
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
|
return { ...actual, existsSync: (p: string) => existsSyncMock(p) };
|
|
});
|
|
|
|
import {
|
|
deviceSetForUdid,
|
|
cachedDeviceSetForUdid,
|
|
rememberDeviceSet,
|
|
simctlArgsForUdid,
|
|
simctlArgsForUdidSync,
|
|
simctlPrefix,
|
|
__resetDeviceSetCacheForTesting,
|
|
} from "../src/utils/ios-device-sets";
|
|
import { listIosSimulators } from "../src/utils/ios-devices";
|
|
|
|
const DEFAULT_UDID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA";
|
|
const RADON_UDID = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB";
|
|
const RADON_SET = "/Users/dev/Library/Caches/com.swmansion.radon-ide/Devices/iOS";
|
|
|
|
function listPayload(udid: string, name: string): { stdout: string; stderr: string } {
|
|
return {
|
|
stdout: JSON.stringify({
|
|
devices: {
|
|
"com.apple.CoreSimulator.SimRuntime.iOS-26-4": [
|
|
{ udid, name, state: "Shutdown", deviceTypeIdentifier: "x", isAvailable: true },
|
|
],
|
|
},
|
|
}),
|
|
stderr: "",
|
|
};
|
|
}
|
|
|
|
/** simctl list responses: default set has DEFAULT_UDID, RADON_SET has RADON_UDID. */
|
|
function mockTwoSets(): void {
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd !== "xcrun" || args[0] !== "simctl") return { stdout: "", stderr: "" };
|
|
if (args[1] === "--set") {
|
|
expect(args[2]).toBe(RADON_SET);
|
|
return listPayload(RADON_UDID, "iPhone Air");
|
|
}
|
|
return listPayload(DEFAULT_UDID, "iPhone 17");
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
execFileMock.mockReset();
|
|
additionalSetsMock.mockReset().mockReturnValue([]);
|
|
existsSyncMock.mockReset().mockReturnValue(true);
|
|
__resetDeviceSetCacheForTesting();
|
|
});
|
|
|
|
describe("simctlPrefix", () => {
|
|
it("injects --set only for a non-default set", () => {
|
|
expect(simctlPrefix(null)).toEqual(["simctl"]);
|
|
expect(simctlPrefix(RADON_SET)).toEqual(["simctl", "--set", RADON_SET]);
|
|
});
|
|
});
|
|
|
|
describe("deviceSetForUdid — no additional sets configured", () => {
|
|
it("resolves to the default set without any simctl probe", async () => {
|
|
expect(await deviceSetForUdid(DEFAULT_UDID)).toBeNull();
|
|
expect(execFileMock).not.toHaveBeenCalled();
|
|
expect(await simctlArgsForUdid(DEFAULT_UDID, ["boot", DEFAULT_UDID])).toEqual([
|
|
"simctl",
|
|
"boot",
|
|
DEFAULT_UDID,
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("deviceSetForUdid — lazy probe with additional sets", () => {
|
|
it("finds a device in an additional set and caches the verdict", async () => {
|
|
additionalSetsMock.mockReturnValue([RADON_SET]);
|
|
mockTwoSets();
|
|
expect(await deviceSetForUdid(RADON_UDID)).toBe(RADON_SET);
|
|
const probes = execFileMock.mock.calls.length;
|
|
expect(probes).toBe(2); // default set (miss) + RADON_SET (hit)
|
|
// Cached: no further probes, sync view agrees.
|
|
expect(await simctlArgsForUdid(RADON_UDID, ["boot", RADON_UDID])).toEqual([
|
|
"simctl",
|
|
"--set",
|
|
RADON_SET,
|
|
"boot",
|
|
RADON_UDID,
|
|
]);
|
|
expect(execFileMock.mock.calls.length).toBe(probes);
|
|
expect(cachedDeviceSetForUdid(RADON_UDID)).toBe(RADON_SET);
|
|
expect(simctlArgsForUdidSync(RADON_UDID, ["shutdown", RADON_UDID])).toEqual([
|
|
"simctl",
|
|
"--set",
|
|
RADON_SET,
|
|
"shutdown",
|
|
RADON_UDID,
|
|
]);
|
|
});
|
|
|
|
it("keeps default-set behavior for a default-set device (probe caches null)", async () => {
|
|
additionalSetsMock.mockReturnValue([RADON_SET]);
|
|
mockTwoSets();
|
|
expect(await deviceSetForUdid(DEFAULT_UDID)).toBeNull();
|
|
const probes = execFileMock.mock.calls.length;
|
|
expect(probes).toBe(1); // found in the default set, RADON_SET never queried
|
|
await deviceSetForUdid(DEFAULT_UDID);
|
|
expect(execFileMock.mock.calls.length).toBe(probes);
|
|
});
|
|
|
|
it("resolves an unknown-everywhere UDID to the default set WITHOUT caching", async () => {
|
|
additionalSetsMock.mockReturnValue([RADON_SET]);
|
|
mockTwoSets();
|
|
const ghost = "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC";
|
|
expect(await deviceSetForUdid(ghost)).toBeNull();
|
|
expect(cachedDeviceSetForUdid(ghost)).toBeNull(); // sync default fallback
|
|
const probes = execFileMock.mock.calls.length;
|
|
// Not cached → a later call re-probes (the device may have appeared).
|
|
await deviceSetForUdid(ghost);
|
|
expect(execFileMock.mock.calls.length).toBeGreaterThan(probes);
|
|
});
|
|
|
|
it("never queries a configured set whose directory does not exist", async () => {
|
|
additionalSetsMock.mockReturnValue([RADON_SET]);
|
|
existsSyncMock.mockImplementation((p) => p !== RADON_SET);
|
|
mockTwoSets();
|
|
await deviceSetForUdid(RADON_UDID);
|
|
expect(execFileMock.mock.calls.every((c) => !(c[1] as string[]).includes("--set"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("listIosSimulators — additional-set enumeration", () => {
|
|
it("merges sets, tags additional-set devices, and warms the mapping", async () => {
|
|
additionalSetsMock.mockReturnValue([RADON_SET]);
|
|
mockTwoSets();
|
|
const sims = await listIosSimulators();
|
|
expect(sims.map((s) => s.udid).sort()).toEqual([DEFAULT_UDID, RADON_UDID]);
|
|
expect(sims.find((s) => s.udid === DEFAULT_UDID)?.deviceSet).toBeUndefined();
|
|
expect(sims.find((s) => s.udid === RADON_UDID)?.deviceSet).toBe(RADON_SET);
|
|
// Discovery warmed the map: no extra probe for a follow-up argv build.
|
|
const probes = execFileMock.mock.calls.length;
|
|
expect(await simctlArgsForUdid(RADON_UDID, ["launch", RADON_UDID, "com.app"])).toEqual([
|
|
"simctl",
|
|
"--set",
|
|
RADON_SET,
|
|
"launch",
|
|
RADON_UDID,
|
|
"com.app",
|
|
]);
|
|
expect(execFileMock.mock.calls.length).toBe(probes);
|
|
});
|
|
|
|
it("skips a configured set whose directory does not exist (no dir materialization)", async () => {
|
|
additionalSetsMock.mockReturnValue([RADON_SET]);
|
|
existsSyncMock.mockImplementation((p) => p !== RADON_SET);
|
|
mockTwoSets();
|
|
const sims = await listIosSimulators();
|
|
expect(sims.map((s) => s.udid)).toEqual([DEFAULT_UDID]);
|
|
expect(execFileMock.mock.calls.every((c) => !(c[1] as string[]).includes("--set"))).toBe(true);
|
|
});
|
|
|
|
it("dedups a UDID seen in two sets, keeping the first (default-first) sighting", async () => {
|
|
additionalSetsMock.mockReturnValue([RADON_SET]);
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) =>
|
|
cmd === "xcrun" && args[0] === "simctl" ? listPayload(DEFAULT_UDID, "iPhone 17") : undefined
|
|
);
|
|
const sims = await listIosSimulators();
|
|
expect(sims).toHaveLength(1);
|
|
expect(sims[0].deviceSet).toBeUndefined();
|
|
expect(await deviceSetForUdid(DEFAULT_UDID)).toBeNull();
|
|
});
|
|
|
|
it("rememberDeviceSet overrides are visible to the sync view", () => {
|
|
rememberDeviceSet(RADON_UDID, RADON_SET);
|
|
expect(cachedDeviceSetForUdid(RADON_UDID)).toBe(RADON_SET);
|
|
});
|
|
});
|