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