Files
software-mansion__argent/packages/tool-server/test/simulator-client-parity.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

135 lines
4.7 KiB
TypeScript

/**
* The parity rule, enforced at each site that speaks HTTP to a provider's
* simulator-server. On a server Argent did not spawn, use only the endpoints
* Argent's own build serves.
*
* `external-devices.test.ts` covers the allowlist itself, which endpoints it
* admits and refuses. That is the list, not the enforcement. This file covers
* the other half, that every caller actually consults it, because the two fail
* independently. Deleting `assertAllowedSimServerEndpoint` from all three call
* sites left the whole suite green.
*
* No caller takes its endpoint from the outside, so none can be steered at a
* refused one. What can be observed is whether the guard was asked. The
* allowlist is stubbed to refuse everything, and the assertion is that the
* provider's server never receives the request.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as http from "node:http";
import type { AddressInfo } from "node:net";
import type { SimulatorServerApi } from "../src/blueprints/simulator-server";
const REFUSAL = /Refusing to call/;
const assertAllowedMock = vi.fn();
vi.mock("../src/utils/external-devices", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/utils/external-devices")>();
return {
...actual,
assertAllowedSimServerEndpoint: (endpoint: string) => assertAllowedMock(endpoint),
};
});
import {
httpScreenshot,
setPointerVisible,
setSimulatorClipboardText,
} from "../src/utils/simulator-client";
/** A provider's server, standing in for one Argent did not spawn. */
let server: http.Server;
let requested: string[];
let apiUrl: string;
beforeEach(async () => {
requested = [];
server = http.createServer((request, response) => {
requested.push(request.url ?? "");
response.setHeader("Content-Type", "application/json");
response.end(JSON.stringify({ status: "ok", url: "file:///shot.png", path: "/shot.png" }));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
assertAllowedMock.mockReset();
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
function api(external: boolean): SimulatorServerApi {
return {
apiUrl,
external,
pressKey: async () => {},
streamUrl: `${apiUrl}/stream.mjpeg`,
};
}
/**
* Each case names the endpoint its site is expected to offer the allowlist.
* Pinning it means a site that starts posting somewhere else has to say so
* here, rather than silently widening what Argent asks of a provider.
*/
const SITES = [
{
endpoint: "/api/screenshot",
name: "httpScreenshot",
/** Propagates, so the caller learns the endpoint was refused. */
refused: (a: SimulatorServerApi) => expect(httpScreenshot(a)).rejects.toThrow(REFUSAL),
reaches: async (a: SimulatorServerApi) => void (await httpScreenshot(a)),
},
{
endpoint: "/api/pointer",
name: "setPointerVisible",
/**
* Swallows every failure and answers `false`, the pointer being cosmetic.
* The refusal is therefore only visible as a request that was not made,
* which is the assertion that matters here anyway.
*/
refused: async (a: SimulatorServerApi) => expect(await setPointerVisible(a, true)).toBe(false),
reaches: async (a: SimulatorServerApi) => void (await setPointerVisible(a, true)),
},
{
endpoint: "/api/clipboard/text",
name: "setSimulatorClipboardText",
refused: (a: SimulatorServerApi) =>
expect(setSimulatorClipboardText(a, "hello")).rejects.toThrow(REFUSAL),
reaches: async (a: SimulatorServerApi) => void (await setSimulatorClipboardText(a, "hello")),
},
] as const;
describe("every provider HTTP path consults the endpoint allowlist", () => {
it.each(SITES)("$name asks about $endpoint and sends nothing when refused", async (site) => {
assertAllowedMock.mockImplementation((endpoint: string) => {
throw new Error(`Refusing to call '${endpoint}' on an externally-provided simulator-server.`);
});
await site.refused(api(true));
expect(assertAllowedMock).toHaveBeenCalledWith(site.endpoint);
expect(requested).toEqual([]);
});
/**
* The gate belongs to provider-supplied servers only. A server Argent spawned
* is its own process and needs no restriction, so asking the allowlist about
* it would be the wrong kind of correct.
*/
it.each(SITES)("$name leaves a server argent spawned alone", async (site) => {
assertAllowedMock.mockImplementation(() => {
throw new Error("the allowlist must not be consulted for a local server");
});
await site.reaches(api(false));
expect(assertAllowedMock).not.toHaveBeenCalled();
expect(requested).toHaveLength(1);
});
});