Files
software-mansion__argent/packages/tool-server/test/simulator-server-blueprint.test.ts
Mateusz Aliyev 6e9e45c9f0 feat: attach to devices offered by external providers (#735)
This pull request lets Argent drive a simulator or emulator another
process is already running, attaching to its simulator-server rather
than spawning a second one.

A provider writes a JSON descriptor to `~/.argent/providers/` listing
the devices it offers and the mechanisms Argent may use on each. They
appear in `list-devices` with an `ext:` id and work with the existing
tools. The file is re-read on every call, so a withdrawal or a narrowed
grant applies immediately.

`ios.additionalDeviceSets` (#600) already makes such a simulator
reachable by UDID. What changes is ownership. `boot-device` and
`stop-simulator-server` refuse these devices, anything the provider did
not grant is refused with a message naming it and Argent uses only the
endpoints its own simulator-server build serves. A grant binds to the
device rather than to one of its names, so the real udid or serial is
gated exactly like the `ext:` id. Android emulators are covered too and
a device visible to both a provider and `adb`/`simctl` is listed once.

The contract ships as `schemas/device-provider-v1.json`, validated by
`argent providers check`, and is documented in
`docs/reference/device-providers.mdx`. Includes unit tests and an e2e
phase where the harness acts as its own provider.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for discovering and connecting to externally managed
devices.
* Added `argent providers` commands for listing, validating, publishing,
withdrawing, and pruning providers.
* Added provider-aware simulator, debugger, profiler, native tools, and
device listings.
* Added capability controls, endpoint validation, revocation handling,
and provider-specific diagnostics.
  * Added automatic CLI discovery for provider integrations.
* **Bug Fixes**
  * Improved service recovery and paused-runtime error reporting.
  * Improved simulator keyboard and paste command reliability.
* **Documentation**
* Documented provider descriptors, capabilities, lifecycle rules, and
CLI usage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-04 22:02:57 +02:00

330 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",
simulatorServerRunDir: () => "/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]);
// The binary resolves resources/android relative to cwd — must be the run dir.
expect(spawnMock.mock.calls[0]![2]).toMatchObject({ cwd: "/fake/bin" });
});
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;
await instance.api.pressKey("Down", 0x29);
await 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);
});
});