Files
software-mansion__argent/packages/tool-server/test/external-attach.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

365 lines
14 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as fs from "node:fs";
import * as http from "node:http";
import * as os from "node:os";
import * as path from "node:path";
import type { AddressInfo } from "node:net";
import type { DeviceInfo } from "@argent/registry";
import { WebSocketServer } from "ws";
import { simulatorServerBlueprint } from "../src/blueprints/simulator-server";
import {
__resetExternalDeviceCacheForTesting,
__resetProviderWarningsForTesting,
makeExternalId,
} from "../src/utils/external-devices";
import {
httpScreenshot,
sendCommand,
setSimulatorClipboardText,
} from "../src/utils/simulator-client";
import { resolveDevice } from "../src/utils/device-info";
/**
* The simulator-server blueprint's attach branch, against a stand-in speaking
* the same wire protocol. It asserts the three things that are easy to get
* wrong and invisible until they bite:
*
* 1. `dispose()` must not touch the provider's process,
* 2. `pressKey` must travel over the WebSocket, because the local path uses
* stdin, which an attached server has none of, so `keyboard` would silently
* no-op,
* 3. only the endpoints argent's own build serves may be used, however much the
* attached binary exposes.
*/
vi.mock("../src/utils/ios-devices", () => ({
isTvOsSimulator: vi.fn(async () => false),
listIosSimulators: vi.fn(async () => []),
getSimulatorRuntimeKind: vi.fn(async () => undefined),
getCachedSimulatorRuntimeKind: vi.fn(() => undefined),
cacheSimulatorRuntimeKind: vi.fn(),
__resetSimulatorRuntimeKindCacheForTesting: vi.fn(),
}));
const IOS_UDID = "1A2B3C4D-5E6F-7081-92A3-B4C5D6E7F809";
const PROVIDER_ID = "acme-3f2a9c";
const DEVICE_ID = makeExternalId(PROVIDER_ID, IOS_UDID);
interface FakeSimulatorServer {
alive: () => boolean;
apiUrl: string;
/** Every text the server was asked to put on the clipboard, in order. */
clipboardTexts: string[];
close: () => Promise<void>;
/** Every HTTP path the server was asked for. */
httpPaths: string[];
streamUrl: string;
/** Every WebSocket command frame the server received, in order. */
wsCommands: unknown[];
}
/**
* A stand-in for a simulator-server another process is running. It answers
* only the endpoints argent's own build serves, so anything argent reaches for
* beyond that shows up as a 404 in `httpPaths`, which is what makes the parity
* rule checkable here as well as in the E2E.
*
* `clipboard: false` models a provider's build that has no clipboard route.
*/
async function createFakeSimulatorServer({
clipboard = true,
}: { clipboard?: boolean } = {}): Promise<FakeSimulatorServer> {
const clipboardTexts: string[] = [];
const httpPaths: string[] = [];
const wsCommands: unknown[] = [];
let closed = false;
const server = http.createServer((request, response) => {
httpPaths.push(request.url ?? "");
if (request.url === "/api/clipboard/text" && clipboard) {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
const body = JSON.parse(Buffer.concat(chunks).toString()) as { text: string };
clipboardTexts.push(body.text);
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ status: "ok" }));
});
return;
}
if (request.url === "/api/screenshot") {
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ path: "/tmp/shot.png", url: "file:///tmp/shot.png" }));
return;
}
if (request.url === "/api/pointer") {
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ status: "ok" }));
return;
}
response.statusCode = 404;
response.end();
});
const webSocketServer = new WebSocketServer({ path: "/ws", server });
webSocketServer.on("connection", (socket) => {
socket.on("message", (data) => {
const command = JSON.parse(String(data)) as { id?: string };
wsCommands.push(command);
/** The real server echoes the id back, `sendCommand` waits for it. */
socket.send(JSON.stringify({ id: command.id, status: "ok" }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as AddressInfo).port;
return {
alive: () => !closed,
apiUrl: `http://127.0.0.1:${port}`,
clipboardTexts,
close: () =>
new Promise<void>((resolve) => {
closed = true;
/**
* simulator-client keeps one long-lived WebSocket per `apiUrl`, so the
* http server would never finish closing without terminating it first.
*/
for (const client of webSocketServer.clients) client.terminate();
webSocketServer.close();
server.close(() => resolve());
}),
httpPaths,
streamUrl: `http://127.0.0.1:${port}/stream.mjpeg`,
wsCommands,
};
}
let temporaryDirectory: string;
const cleanups: Array<() => Promise<void> | void> = [];
/**
* Publish a descriptor offering `simulatorServer` as an iOS device, and return
* the `DeviceInfo` argent would resolve for it.
*/
function attachTo(
simulatorServer: FakeSimulatorServer,
capabilities: string[] = ["ax-service", "simctl", "simulator-server"]
): DeviceInfo {
const descriptorPath = path.join(temporaryDirectory, "acme.json");
fs.writeFileSync(
descriptorPath,
JSON.stringify({
devices: [
{
capabilities,
kind: "simulator",
name: "iPhone 16 Pro",
nativeId: IOS_UDID,
platform: "ios",
simulatorServer: {
apiUrl: simulatorServer.apiUrl,
streamUrl: simulatorServer.streamUrl,
version: "1.20.0",
},
state: "Booted",
},
],
id: PROVIDER_ID,
name: "Acme IDE",
schemaVersion: 1,
supportUrl: "https://example.invalid/issues",
})
);
process.env.ARGENT_DEVICE_PROVIDERS = descriptorPath;
return resolveDevice(DEVICE_ID);
}
/**
* Repoint the published descriptor at a different server, as a rebuild would.
*/
function republishAt(simulatorServer: FakeSimulatorServer): void {
const descriptorPath = path.join(temporaryDirectory, "acme.json");
const descriptor = JSON.parse(fs.readFileSync(descriptorPath, "utf8"));
descriptor.devices[0].simulatorServer = {
apiUrl: simulatorServer.apiUrl,
streamUrl: simulatorServer.streamUrl,
};
fs.writeFileSync(descriptorPath, JSON.stringify(descriptor));
}
beforeEach(() => {
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "argent-attach-"));
delete process.env.ARGENT_DEVICE_PROVIDERS;
delete process.env.ARGENT_DISABLE_DEVICE_PROVIDERS;
__resetExternalDeviceCacheForTesting();
__resetProviderWarningsForTesting();
});
afterEach(async () => {
for (const cleanup of cleanups.splice(0)) await cleanup();
delete process.env.ARGENT_DEVICE_PROVIDERS;
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
});
async function startSimulatorServer(options?: {
clipboard?: boolean;
}): Promise<FakeSimulatorServer> {
const simulatorServer = await createFakeSimulatorServer(options);
cleanups.push(() => simulatorServer.close());
return simulatorServer;
}
describe("attaching to a provider's simulator-server", () => {
it("uses the published urls instead of spawning anything", async () => {
const simulatorServer = await startSimulatorServer();
const device = attachTo(simulatorServer);
const instance = await simulatorServerBlueprint.factory({}, device, { device });
expect(instance.api.apiUrl).toBe(simulatorServer.apiUrl);
expect(instance.api.streamUrl).toBe(simulatorServer.streamUrl);
expect(instance.api.external).toBe(true);
});
it("leaves the provider's process running when disposed", async () => {
const simulatorServer = await startSimulatorServer();
const device = attachTo(simulatorServer);
const instance = await simulatorServerBlueprint.factory({}, device, { device });
await instance.dispose();
expect(simulatorServer.alive()).toBe(true);
});
/**
* The whole reconnection story: the provider restarts its server on a new
* ephemeral port and republishes, `recoverable()` disposes the old instance,
* the registry re-runs the factory, and this read picks up the new port. No
* reconnection code exists anywhere; it falls out of re-reading the file.
*/
it("picks up a rebound port on the next instantiation", async () => {
const originalServer = await startSimulatorServer();
const device = attachTo(originalServer);
const beforeRestart = await simulatorServerBlueprint.factory({}, device, { device });
expect(beforeRestart.api.apiUrl).toBe(originalServer.apiUrl);
const restartedServer = await startSimulatorServer();
republishAt(restartedServer);
const afterRestart = await simulatorServerBlueprint.factory({}, device, { device });
expect(afterRestart.api.apiUrl).toBe(restartedServer.apiUrl);
expect(afterRestart.api.apiUrl).not.toBe(originalServer.apiUrl);
});
it("sends key presses over the websocket, not stdin", async () => {
const simulatorServer = await startSimulatorServer();
const device = attachTo(simulatorServer);
const instance = await simulatorServerBlueprint.factory({}, device, { device });
await instance.api.pressKey("Down", 0x04);
await instance.api.pressKey("Up", 0x04);
await vi.waitFor(() => expect(simulatorServer.wsCommands).toHaveLength(2));
expect(simulatorServer.wsCommands).toEqual([
expect.objectContaining({ cmd: "key", direction: "Down", code: 0x04 }),
expect.objectContaining({ cmd: "key", direction: "Up", code: 0x04 }),
]);
});
it("sends gestures over the websocket", async () => {
const simulatorServer = await startSimulatorServer();
const device = attachTo(simulatorServer);
const instance = await simulatorServerBlueprint.factory({}, device, { device });
await sendCommand(instance.api, { cmd: "touch", type: "Down", x: 10, y: 20 });
await vi.waitFor(() => expect(simulatorServer.wsCommands).toHaveLength(1));
expect(simulatorServer.wsCommands[0]).toMatchObject({
cmd: "touch",
type: "Down",
x: 10,
y: 20,
});
});
it("takes screenshots over the http api", async () => {
const simulatorServer = await startSimulatorServer();
const device = attachTo(simulatorServer);
const instance = await simulatorServerBlueprint.factory({}, device, { device });
await expect(httpScreenshot(instance.api)).resolves.toEqual({
url: "file:///tmp/shot.png",
path: "/tmp/shot.png",
});
expect(simulatorServer.httpPaths).toEqual(["/api/screenshot"]);
});
it("fills the device clipboard over the http api", async () => {
const simulatorServer = await startSimulatorServer();
const device = attachTo(simulatorServer);
const instance = await simulatorServerBlueprint.factory({}, device, { device });
await expect(setSimulatorClipboardText(instance.api, "hello")).resolves.toBeUndefined();
expect(simulatorServer.clipboardTexts).toEqual(["hello"]);
});
/**
* A 404 here must read as "unsupported" and must point at the provider.
* "update argent" would be the wrong advice for someone else's binary.
*/
it("reports a missing clipboard endpoint as unsupported", async () => {
const simulatorServer = await startSimulatorServer({ clipboard: false });
const device = attachTo(simulatorServer);
const instance = await simulatorServerBlueprint.factory({}, device, { device });
await expect(setSimulatorClipboardText(instance.api, "hello")).rejects.toThrow(
/provider that supplied this device would have to ship a build with clipboard support/
);
});
/**
* The parity rule lives in `simulator-client-parity.test.ts`, against the
* real call sites. It was asserted here through a local helper that repeated
* the guard rather than reaching one, so it held whether or not any of them
* still called the allowlist. Deleting all three left it green.
*/
it("refuses to attach when the provider withheld the simulator-server capability", async () => {
const simulatorServer = await startSimulatorServer();
const device = attachTo(simulatorServer, ["simctl"]);
await expect(simulatorServerBlueprint.factory({}, device, { device })).rejects.toThrow(
/'simulator-server' capability/
);
});
/**
* `ios.additionalDeviceSets` lists a provider's simulators by their real udid
* and `adb devices` always reports its emulators by serial, so the raw
* spelling is reachable however carefully `list-devices` dedupes. Reaching
* the spawn path with one starts a second simulator-server against a device
* already being driven; the conflict this blueprint exists to prevent.
*/
describe("addressed by the raw udid rather than the ext: id", () => {
it("attaches to the provider's server instead of spawning a second one", async () => {
const simulatorServer = await startSimulatorServer();
attachTo(simulatorServer);
const device = resolveDevice(IOS_UDID);
const instance = await simulatorServerBlueprint.factory({}, device, { device });
expect(instance.api.apiUrl).toBe(simulatorServer.apiUrl);
expect(instance.api.external).toBe(true);
});
it("still refuses when the provider withheld the capability", async () => {
const simulatorServer = await startSimulatorServer();
attachTo(simulatorServer, ["simctl"]);
const device = resolveDevice(IOS_UDID);
await expect(simulatorServerBlueprint.factory({}, device, { device })).rejects.toThrow(
/'simulator-server' capability/
);
});
});
});