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

1479 lines
52 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 { argentHomeDir } from "@argent/configuration-core";
import {
ALLOWED_SIM_SERVER_ENDPOINTS,
EXTERNAL_CAPABILITIES,
EXTERNAL_PREFIX,
PROVIDER_ID_SHAPE,
PROVIDER_SCHEMA_VERSION,
__resetExternalDeviceCacheForTesting,
__resetProviderWarningsForTesting,
assertAllowedSimServerEndpoint,
assertExternalCapability,
discoverProviders,
disposeExternalDeviceServices,
enforceExternalDeviceGrant,
externalNativeId,
externalProviderId,
externalProviderLabel,
externalClaimForNativeId,
externalSupportHint,
isExternalDeviceUrn,
isExternalId,
listExternalDevices,
findExternalDevice,
lookupExternalDevice,
makeExternalId,
type ProviderDevice,
providerDeviceSchema,
type ProviderRecord,
providerRecordSchema,
providersDirectory,
} from "../src/utils/external-devices";
import { classifyDevice, resolveDevice } from "../src/utils/device-info";
import { adbArgv } from "../src/utils/adb";
import {
deviceSetForUdid,
simctlArgsForUdid,
simctlArgsForUdidSync,
simctlTargetForUdid,
simctlTargetForUdidSync,
simctlTargetForWithdrawal,
} from "../src/utils/ios-device-sets";
const ANDROID_SERIAL = "emulator-5554";
/** A pid nothing can be running under, so `kill(0)` fails with ESRCH. */
const DEAD_PID = 0x7fffffff;
const IOS_UDID = "1A2B3C4D-5E6F-7081-92A3-B4C5D6E7F809";
function androidDevice(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
capabilities: ["adb", "simulator-server"],
kind: "emulator",
name: "Pixel 8",
nativeId: ANDROID_SERIAL,
platform: "android",
simulatorServer: {
apiUrl: "http://127.0.0.1:52002",
streamUrl: "http://127.0.0.1:52002/stream.mjpeg",
},
state: "device",
...overrides,
};
}
function iosDevice(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
capabilities: ["ax-service", "simctl", "simulator-server"],
deviceSet: "/tmp/acme/Devices/iOS",
kind: "simulator",
name: "iPhone 16 Pro",
nativeId: IOS_UDID,
platform: "ios",
simulatorServer: {
apiUrl: "http://127.0.0.1:52001",
streamUrl: "http://127.0.0.1:52001/stream.mjpeg",
version: "1.20.0",
},
state: "Booted",
...overrides,
};
}
/**
* A stand-in for the simulator-server a provider would be running. Discovery
* probes `apiUrl` for liveness, so a device that wants to survive it needs
* something actually listening.
*/
type FakeSimulatorServer = {
apiUrl: string;
close: () => Promise<void>;
streamUrl: string;
};
async function createFakeSimulatorServer(): Promise<FakeSimulatorServer> {
const server = http.createServer((_request, response) => {
response.statusCode = 404;
response.end();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as AddressInfo).port;
return {
apiUrl: `http://127.0.0.1:${port}`,
close: () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
streamUrl: `http://127.0.0.1:${port}/stream.mjpeg`,
};
}
/**
* A port with nothing on it, so devices pointing here fail the liveness probe:
* how a descriptor left behind by a crashed provider behaves.
*/
const DEAD_API_URL = "http://127.0.0.1:1";
let temporaryDirectory: string;
const cleanups: Array<() => Promise<void> | void> = [];
function writeDescriptor(name: string, body: unknown): string {
const descriptorPath = path.join(temporaryDirectory, name);
fs.writeFileSync(descriptorPath, typeof body === "string" ? body : JSON.stringify(body));
return descriptorPath;
}
function useDescriptors(...files: string[]): void {
process.env.ARGENT_DEVICE_PROVIDERS = files.join(",");
}
beforeEach(() => {
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "argent-providers-"));
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;
delete process.env.ARGENT_DISABLE_DEVICE_PROVIDERS;
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
vi.restoreAllMocks();
});
async function startSimulatorServer(): Promise<FakeSimulatorServer> {
const simulatorServer = await createFakeSimulatorServer();
cleanups.push(() => simulatorServer.close());
return simulatorServer;
}
/**
* Write a conformant descriptor whose single iOS device points at a live
* simulator-server, so it survives the discovery probe. `overrides` patches
* the record, `deviceOverrides` the device.
*/
async function liveDescriptor(
overrides: Record<string, unknown> = {},
deviceOverrides: Record<string, unknown> = {},
name = "acme.json"
): Promise<string> {
const simulatorServer = await startSimulatorServer();
return writeDescriptor(name, {
devices: [
iosDevice({
simulatorServer: {
apiUrl: simulatorServer.apiUrl,
streamUrl: simulatorServer.streamUrl,
version: "1.20.0",
},
...deviceOverrides,
}),
],
id: "acme-3f2a9c",
name: "Acme IDE",
schemaVersion: 1,
supportUrl: "https://example.invalid/issues",
workspace: { name: "my-app", path: "/Users/me/src/my-app" },
...overrides,
});
}
/**
* The contract itself is tested in `packages/device-providers/test/`; id
* round-trips, schema acceptance and rejection, the discovery policies. What is
* left here is everything that only exists once the tool-server is in the
* picture: `classifyDevice`, `FailureError`, the liveness probe, the revocation
* cache and the argv builders.
*/
describe("the contract's tool-server facade", () => {
/**
* The one genuine hazard of `@argent/device-providers` resolving the home
* directory itself (its dependency list is zod and nothing else) is that the
* writer and the reader could end up pointed at different directories. There
* is no way to catch that inside the package; here, where both are on the
* dependency list, there is.
*/
it("resolves the same providers directory as the shared config paths do", () => {
expect(providersDirectory()).toBe(path.join(argentHomeDir(), "providers"));
});
/**
* Third parties resolve the contract through this module and its header
* promises the export set never shrinks. A re-export dropped during a refactor
* is silent until someone's build breaks, so name them.
*/
it("re-exports the whole contract surface", () => {
expect(PROVIDER_SCHEMA_VERSION).toBe(1);
expect(EXTERNAL_PREFIX).toBe("ext:");
expect(PROVIDER_ID_SHAPE.test("acme-3f2a9c")).toBe(true);
expect([...EXTERNAL_CAPABILITIES]).toContain("simulator-server");
expect([...ALLOWED_SIM_SERVER_ENDPOINTS]).toEqual([
"/api/clipboard/text",
"/api/pointer",
"/api/screenshot",
"/ws",
]);
/**
* The type re-exports matter as much as the value ones and nothing at
* runtime can check them, so they are annotated here. Dropping either from
* the facade fails `typecheck:tests`.
*/
const device: ProviderDevice = providerDeviceSchema.parse(iosDevice());
expect(device.nativeId).toBe(IOS_UDID);
const record: ProviderRecord = providerRecordSchema.parse({
devices: [iosDevice()],
id: "acme",
name: "Acme",
schemaVersion: 1,
});
expect(record.devices).toHaveLength(1);
for (const fn of [
isExternalId,
isExternalDeviceUrn,
makeExternalId,
externalNativeId,
externalProviderId,
externalProviderLabel,
discoverProviders,
providersDirectory,
__resetProviderWarningsForTesting,
]) {
expect(typeof fn).toBe("function");
}
});
});
describe("external device ids", () => {
/**
* Malformed ids reach these helpers from tool params typed as plain strings,
* so none may throw or an agent typo becomes a 500. An
* `undefined`/non-string id arrives the same way, through a wrapper that
* doesn't re-validate the inner schema — the class of bug the blueprint
* `device.id` guards exist for.
*/
it.each([undefined, null, 42, {}])("does not throw on the non-string id %j", (deviceId) => {
expect(() => isExternalId(deviceId as unknown as string)).not.toThrow();
expect(isExternalId(deviceId as unknown as string)).toBe(false);
});
it.each([
"ext:",
"ext::",
"ext:x",
"ext:UPPER:abc",
"ext:acme:",
"ext:acme:-leading-dash",
"ext:acme:has space",
"ext:" + "a".repeat(64) + ":abc",
])("does not throw on the malformed id %j", (deviceId) => {
expect(() => externalNativeId(deviceId)).not.toThrow();
expect(() => externalProviderId(deviceId)).not.toThrow();
expect(() => externalProviderLabel(deviceId)).not.toThrow();
expect(() => classifyDevice(deviceId)).not.toThrow();
expect(() => resolveDevice(deviceId)).not.toThrow();
});
it("classifies by the native id's shape, not by the prefix", () => {
expect(classifyDevice(makeExternalId("acme", IOS_UDID))).toBe("ios");
expect(classifyDevice(makeExternalId("acme", ANDROID_SERIAL))).toBe("android");
expect(resolveDevice(makeExternalId("acme", IOS_UDID))).toEqual({
id: `ext:acme:${IOS_UDID}`,
platform: "ios",
kind: "simulator",
});
/**
* The emulator/device split must see through the prefix too, or an
* attached emulator would be driven by the physical-device controller.
*/
expect(resolveDevice(makeExternalId("acme", ANDROID_SERIAL)).kind).toBe("emulator");
expect(resolveDevice(makeExternalId("acme", "ABC123XYZ")).kind).toBe("device");
});
});
describe("discovery", () => {
it("fans out across several descriptor files", async () => {
const simA = await startSimulatorServer();
const simB = await startSimulatorServer();
useDescriptors(
writeDescriptor("a.json", {
devices: [
iosDevice({ simulatorServer: { apiUrl: simA.apiUrl, streamUrl: simA.streamUrl } }),
],
id: "acme",
name: "Acme",
schemaVersion: 1,
}),
writeDescriptor("b.json", {
devices: [
androidDevice({ simulatorServer: { apiUrl: simB.apiUrl, streamUrl: simB.streamUrl } }),
],
id: "beta",
name: "Beta",
schemaVersion: 1,
})
);
expect(
discoverProviders()
.map((provider) => provider.id)
.sort()
).toEqual(["acme", "beta"]);
const devices = await listExternalDevices();
expect(devices.map((device) => device.id).sort()).toEqual(
[makeExternalId("acme", IOS_UDID), makeExternalId("beta", ANDROID_SERIAL)].sort()
);
});
it("keeps only the first descriptor claiming a given id", async () => {
useDescriptors(
writeDescriptor("a.json", { devices: [], id: "acme", name: "First", schemaVersion: 1 }),
writeDescriptor("b.json", { devices: [], id: "acme", name: "Second", schemaVersion: 1 })
);
const found = discoverProviders();
expect(found).toHaveLength(1);
expect(found[0]!.name).toBe("First");
});
it("skips an unknown schemaVersion without failing, and says so once", async () => {
const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
useDescriptors(
writeDescriptor("v2.json", { devices: [], id: "acme", name: "Acme", schemaVersion: 2 })
);
expect(discoverProviders()).toEqual([]);
await expect(listExternalDevices()).resolves.toEqual([]);
discoverProviders();
discoverProviders();
const versionLines = stderr.mock.calls.filter((call) =>
String(call[0]).includes("unsupported schemaVersion")
);
expect(versionLines).toHaveLength(1);
});
it.each([
["unparseable JSON", "{ not json"],
["a truncated atomic write", '{"schemaVersion":1,"id":"acm'],
])("treats %s as absent", async (_label, body) => {
vi.spyOn(process.stderr, "write").mockReturnValue(true);
useDescriptors(writeDescriptor("broken.json", body));
expect(discoverProviders()).toEqual([]);
await expect(listExternalDevices()).resolves.toEqual([]);
});
it("treats a missing file as absent", async () => {
useDescriptors(path.join(temporaryDirectory, "does-not-exist.json"));
expect(discoverProviders()).toEqual([]);
await expect(listExternalDevices()).resolves.toEqual([]);
});
it("never unlinks a descriptor, whatever is wrong with it", async () => {
vi.spyOn(process.stderr, "write").mockReturnValue(true);
const badVersion = writeDescriptor("bad.json", { schemaVersion: 99 });
const brokenJson = writeDescriptor("broken.json", "{{{");
const deadServer = writeDescriptor("dead.json", {
devices: [iosDevice({ simulatorServer: { apiUrl: DEAD_API_URL, streamUrl: DEAD_API_URL } })],
id: "gone",
name: "Gone",
schemaVersion: 1,
});
useDescriptors(badVersion, brokenJson, deadServer);
await listExternalDevices();
for (const descriptorPath of [badVersion, brokenJson, deadServer]) {
expect(fs.existsSync(descriptorPath)).toBe(true);
}
});
it("skips discovery entirely under ARGENT_DISABLE_DEVICE_PROVIDERS", async () => {
useDescriptors(await liveDescriptor());
process.env.ARGENT_DISABLE_DEVICE_PROVIDERS = "1";
expect(discoverProviders()).toEqual([]);
await expect(listExternalDevices()).resolves.toEqual([]);
});
it("drops one bad device without costing the provider its whole list", async () => {
vi.spyOn(process.stderr, "write").mockReturnValue(true);
const simulatorServer = await startSimulatorServer();
const endpoint = { apiUrl: simulatorServer.apiUrl, streamUrl: simulatorServer.streamUrl };
useDescriptors(
writeDescriptor("acme.json", {
devices: [
iosDevice({ simulatorServer: endpoint }),
{ nativeId: "x", platform: "ios" },
androidDevice({ simulatorServer: endpoint }),
],
id: "acme",
name: "Acme",
schemaVersion: 1,
})
);
const devices = await listExternalDevices();
expect(devices.map((device) => device.nativeId).sort()).toEqual(
[ANDROID_SERIAL, IOS_UDID].sort()
);
});
it("rejects a device whose declared platform disagrees with its id's shape", async () => {
vi.spyOn(process.stderr, "write").mockReturnValue(true);
useDescriptors(await liveDescriptor({}, { nativeId: ANDROID_SERIAL }));
await expect(listExternalDevices()).resolves.toEqual([]);
});
it("filters unknown capability tokens out of the adopted device", async () => {
useDescriptors(
await liveDescriptor({}, { capabilities: ["simulator-server", "teleportation"] })
);
const [device] = await listExternalDevices();
expect(Array.from(device!.capabilities)).toEqual(["simulator-server"]);
});
});
/**
* The signal that replaces an endpoint's `ECONNREFUSED`. It matters most for
* the case a provider cannot handle itself: killed with `SIGKILL` it never
* unlinks its descriptor, so without the probe its devices would linger
* forever.
*/
describe("liveness probe", () => {
it("drops a device whose simulator-server is not listening", async () => {
useDescriptors(
await liveDescriptor(
{},
{ simulatorServer: { apiUrl: DEAD_API_URL, streamUrl: DEAD_API_URL } }
)
);
await expect(listExternalDevices()).resolves.toEqual([]);
});
it("keeps a device whose simulator-server answers, even with a 404", async () => {
useDescriptors(await liveDescriptor());
const devices = await listExternalDevices();
expect(devices).toHaveLength(1);
});
it("stops listing a provider's devices once its server dies", async () => {
const simulatorServer = await startSimulatorServer();
useDescriptors(
await liveDescriptor(
{},
{
simulatorServer: { apiUrl: simulatorServer.apiUrl, streamUrl: simulatorServer.streamUrl },
}
)
);
await expect(listExternalDevices()).resolves.toHaveLength(1);
await simulatorServer.close();
await expect(listExternalDevices()).resolves.toEqual([]);
});
/**
* A device offering only `adb`/`simctl` has no server to probe. Absent
* evidence is not evidence of death. It passes through and fails later on
* its own terms if the mechanism it does grant is broken.
*/
it("passes through a device that publishes no simulator-server", async () => {
useDescriptors(
await liveDescriptor({}, { capabilities: ["simctl"], simulatorServer: undefined })
);
const devices = await listExternalDevices();
expect(devices).toHaveLength(1);
expect(devices[0]!.simulatorServer).toBeUndefined();
});
it("can be skipped, for callers that only want what the file declares", async () => {
useDescriptors(
await liveDescriptor(
{},
{ simulatorServer: { apiUrl: DEAD_API_URL, streamUrl: DEAD_API_URL } }
)
);
await expect(listExternalDevices({ probe: false })).resolves.toHaveLength(1);
});
/**
* Dispatch deliberately does not probe. The caller is about to connect
* anyway and a dead server surfaces as the connection-refused
* `recoverable()` already handles.
*/
it("does not gate lookup — a dead server still resolves", async () => {
useDescriptors(
await liveDescriptor(
{},
{ simulatorServer: { apiUrl: DEAD_API_URL, streamUrl: DEAD_API_URL } }
)
);
await expect(
lookupExternalDevice(makeExternalId("acme-3f2a9c", IOS_UDID))
).resolves.toMatchObject({ nativeId: IOS_UDID });
});
});
describe("lookupExternalDevice", () => {
it("resolves a live device", async () => {
useDescriptors(await liveDescriptor());
const device = await lookupExternalDevice(makeExternalId("acme-3f2a9c", IOS_UDID));
expect(device.name).toBe("iPhone 16 Pro");
expect(device.provider.name).toBe("Acme IDE");
expect(device.provider.workspace).toEqual({ name: "my-app", path: "/Users/me/src/my-app" });
});
it("names the provider when the device has been withdrawn", async () => {
const descriptorPath = await liveDescriptor();
useDescriptors(descriptorPath);
fs.writeFileSync(
descriptorPath,
JSON.stringify({ schemaVersion: 1, id: "acme-3f2a9c", name: "Acme IDE", devices: [] })
);
await expect(lookupExternalDevice(makeExternalId("acme-3f2a9c", IOS_UDID))).rejects.toThrow(
/Acme IDE/
);
});
it("explains that the provider is gone when no descriptor matches", async () => {
useDescriptors();
await expect(lookupExternalDevice(makeExternalId("nobody", IOS_UDID))).rejects.toThrow(
/No device provider named 'nobody'/
);
});
/**
* The file is the source of truth, so no cache can disagree with it. That is
* what lets revocation take effect on the very next call.
*/
it("sees an edit to the file immediately, with no cache in the way", async () => {
const descriptorPath = await liveDescriptor();
useDescriptors(descriptorPath);
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
expect((await lookupExternalDevice(deviceId)).name).toBe("iPhone 16 Pro");
const descriptor = JSON.parse(fs.readFileSync(descriptorPath, "utf8"));
descriptor.devices[0].name = "iPad Pro";
fs.writeFileSync(descriptorPath, JSON.stringify(descriptor));
expect((await lookupExternalDevice(deviceId)).name).toBe("iPad Pro");
});
it("findExternalDevice is the same read, without the throw", async () => {
useDescriptors(await liveDescriptor());
expect(findExternalDevice(makeExternalId("acme-3f2a9c", IOS_UDID))?.nativeId).toBe(IOS_UDID);
expect(findExternalDevice(makeExternalId("nobody", IOS_UDID))).toBeUndefined();
expect(findExternalDevice(IOS_UDID)).toBeUndefined();
});
});
/**
* The raw-udid half of the lookup. Argent's own discovery paths — the simulator
* watcher, `boot-device` — never see an `ext:` id, so without this a provider's
* grant would bind to one spelling of a device rather than to the device.
*/
describe("externalClaimForNativeId", () => {
it("finds the provider claiming a device by its real udid", async () => {
useDescriptors(await liveDescriptor());
const claim = externalClaimForNativeId(IOS_UDID);
expect(claim?.id).toBe(makeExternalId("acme-3f2a9c", IOS_UDID));
expect(claim?.provider.name).toBe("Acme IDE");
});
it("returns nothing for a device no provider claims", async () => {
useDescriptors(await liveDescriptor());
expect(externalClaimForNativeId("99999999-9999-9999-9999-999999999999")).toBeUndefined();
expect(externalClaimForNativeId(ANDROID_SERIAL)).toBeUndefined();
});
it("returns nothing when no provider is registered at all", () => {
expect(externalClaimForNativeId(IOS_UDID)).toBeUndefined();
});
/** The `ext:` spelling has `findExternalDevice`; no provider declares one. */
it("returns nothing for an ext: id", async () => {
useDescriptors(await liveDescriptor());
expect(externalClaimForNativeId(makeExternalId("acme-3f2a9c", IOS_UDID))).toBeUndefined();
});
/**
* A provider killed without unlinking must not keep argent off a simulator it
* legitimately owns. The claim is only as live as the process behind it.
*/
it("ignores a claim whose provider process is gone", async () => {
useDescriptors(await liveDescriptor({ pid: DEAD_PID }));
expect(externalClaimForNativeId(IOS_UDID)).toBeUndefined();
});
it("honors a claim from a live provider process", async () => {
useDescriptors(await liveDescriptor({ pid: process.pid }));
expect(externalClaimForNativeId(IOS_UDID)?.provider.name).toBe("Acme IDE");
});
/** Absent pid is no evidence of death, so the claim binds. */
it("honors a claim from a provider that published no pid", async () => {
useDescriptors(await liveDescriptor());
expect(externalClaimForNativeId(IOS_UDID)?.provider.name).toBe("Acme IDE");
});
});
describe("assertExternalCapability", () => {
it("is a no-op for a device argent booted itself", async () => {
await expect(
assertExternalCapability(
"AXService",
{ id: IOS_UDID, platform: "ios", kind: "simulator" },
"ax-service"
)
).resolves.toBeUndefined();
});
it("allows a declared mechanism", async () => {
useDescriptors(await liveDescriptor());
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
await expect(
assertExternalCapability("AXService", deviceId, "ax-service")
).resolves.toBeUndefined();
});
it("denies an undeclared mechanism, naming the provider", async () => {
useDescriptors(await liveDescriptor());
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
await expect(
assertExternalCapability("NativeDevtools", deviceId, "native-devtools")
).rejects.toThrow(/Acme IDE did not grant the 'native-devtools' capability/);
await expect(
assertExternalCapability("JsRuntimeDebugger", deviceId, "js-debugger")
).rejects.toThrow(/Acme IDE did not grant the 'js-debugger' capability/);
});
/**
* Attribution lives only in the HTTP dispatch edge, which appends it to
* every `ext:` failure. An error spelling out the support URL
* itself would print it twice in the message the agent sees.
*/
it("leaves the support url to the single attribution point", async () => {
useDescriptors(await liveDescriptor());
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
await expect(
assertExternalCapability("NativeDevtools", deviceId, "native-devtools")
).rejects.not.toThrow(/example\.invalid\/issues/);
await expect(lookupExternalDevice(makeExternalId("nobody", IOS_UDID))).rejects.not.toThrow(
/provided by/
);
});
it("denies every mechanism when the provider granted none", async () => {
useDescriptors(await liveDescriptor({}, { capabilities: [], simulatorServer: undefined }));
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
for (const capability of EXTERNAL_CAPABILITIES) {
await expect(assertExternalCapability("Any", deviceId, capability)).rejects.toThrow();
}
});
/**
* The gate authorises the device, not the id it arrived under. Every path
* that reaches a provider's simulator by its real udid — the watcher,
* `boot-device`, anything `additionalDeviceSets` surfaces — walks past a gate
* keyed on the `ext:` spelling alone.
*/
it("denies every mechanism on the raw udid of a claimed device", async () => {
useDescriptors(await liveDescriptor({}, { capabilities: [], simulatorServer: undefined }));
for (const capability of EXTERNAL_CAPABILITIES) {
await expect(assertExternalCapability("Any", IOS_UDID, capability)).rejects.toThrow(
/Acme IDE did not grant/
);
}
});
it("allows a declared mechanism on the raw udid too", async () => {
useDescriptors(await liveDescriptor());
await expect(
assertExternalCapability("AXService", IOS_UDID, "ax-service")
).resolves.toBeUndefined();
await expect(
assertExternalCapability(
"AXService",
{ id: IOS_UDID, platform: "ios", kind: "simulator" },
"ax-service"
)
).resolves.toBeUndefined();
});
it("denies an undeclared mechanism on a DeviceInfo carrying the raw udid", async () => {
useDescriptors(await liveDescriptor());
await expect(
assertExternalCapability(
"NativeDevtools",
{ id: IOS_UDID, platform: "ios", kind: "simulator" },
"native-devtools"
)
).rejects.toThrow(/Acme IDE did not grant the 'native-devtools' capability/);
});
it("leaves the raw udid ungated once its provider's process is gone", async () => {
useDescriptors(await liveDescriptor({ pid: DEAD_PID }, { capabilities: [] }));
await expect(
assertExternalCapability("NativeDevtools", IOS_UDID, "native-devtools")
).resolves.toBeUndefined();
});
});
describe("revocation", () => {
function fakeRegistry(urns: string[], wedged: readonly string[] = []) {
const disposed: string[] = [];
const attempted: string[] = [];
return {
attempted,
disposed,
disposeService: async (urn: string) => {
attempted.push(urn);
if (wedged.includes(urn)) throw new Error(`${urn} will not tear down`);
disposed.push(urn);
},
getSnapshot: () => ({ services: new Map(urns.map((urn) => [urn, {}])) }),
};
}
/**
* The dispatch edge's gate, driven against a registry that holds nothing, for
* the cases that are only about the reading it takes.
*/
function revalidate(id: string): Promise<{ reason?: string; stale: boolean }> {
return enforceExternalDeviceGrant(fakeRegistry([]), id);
}
function rewrite(descriptorPath: string, devices: unknown[]): void {
fs.writeFileSync(
descriptorPath,
JSON.stringify({ schemaVersion: 1, id: "acme-3f2a9c", name: "Acme IDE", devices })
);
}
/** Withdraw every capability but the one the device set is keyed on. */
function narrow(descriptorPath: string): void {
const descriptor = JSON.parse(fs.readFileSync(descriptorPath, "utf8"));
descriptor.devices[0].capabilities = ["simulator-server"];
fs.writeFileSync(descriptorPath, JSON.stringify(descriptor));
}
/**
* No TTL to wait out. The file is re-read on every call, so a narrowed grant
* is visible to the very next dispatch. That immediacy is the dividend of
* the file-based contract.
*/
it("reports staleness immediately when the provider narrows what it grants", async () => {
const descriptorPath = await liveDescriptor();
useDescriptors(descriptorPath);
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
expect(await revalidate(deviceId)).toEqual({ stale: false });
narrow(descriptorPath);
const result = await revalidate(deviceId);
expect(result.stale).toBe(true);
expect(result.reason).toMatch(/capabilities/);
});
it("reports staleness when the device disappears entirely", async () => {
const descriptorPath = await liveDescriptor();
useDescriptors(descriptorPath);
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
expect(await revalidate(deviceId)).toEqual({ stale: false });
rewrite(descriptorPath, []);
expect(await revalidate(deviceId)).toMatchObject({ stale: true });
});
it("reports no staleness while nothing has changed", async () => {
useDescriptors(await liveDescriptor());
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
await lookupExternalDevice(deviceId);
expect(await revalidate(deviceId)).toEqual({ stale: false });
});
/**
* The baseline is the grant a warm handle was built under, so only a dispatch
* may move it. Reads happen constantly and for their own reasons
* (`list-devices` from any session, the simulator watcher every ten seconds,
* every argv builder resolving a claim) and one landing between the change
* and the next dispatch used to re-base the comparison onto the new reading,
* which silently swallowed the revocation it exists to catch.
*/
describe("a read between two dispatches does not mask the change", () => {
it.each([
["list-devices", async () => void (await listExternalDevices({ probe: false }))],
["a watcher-style claim lookup", async () => void externalClaimForNativeId(IOS_UDID)],
[
"an ext: lookup",
async () => void findExternalDevice(makeExternalId("acme-3f2a9c", IOS_UDID)),
],
])("survives %s", async (_label, read) => {
const descriptorPath = await liveDescriptor();
useDescriptors(descriptorPath);
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
expect(await revalidate(deviceId)).toEqual({ stale: false });
narrow(descriptorPath);
await read();
expect(await revalidate(deviceId)).toMatchObject({ stale: true });
});
});
/**
* Both spellings name one device, so a dispatch under either has to answer
* for the handles cached under the other. Without this a session that only
* ever says `ext:` leaves the raw-keyed handle with no reading to compare
* against and its first raw dispatch after a narrowing reports nothing.
*/
it("carries the reading from an ext: dispatch over to the raw spelling", async () => {
const descriptorPath = await liveDescriptor();
useDescriptors(descriptorPath);
expect(await revalidate(makeExternalId("acme-3f2a9c", IOS_UDID))).toEqual({
stale: false,
});
narrow(descriptorPath);
expect(await revalidate(IOS_UDID)).toMatchObject({ stale: true });
});
it("is a no-op for a device argent booted itself", async () => {
expect(await revalidate(IOS_UDID)).toEqual({ stale: false });
});
/**
* The raw spelling is a second door to the same device and the registry keys
* a service by whichever id the caller named. Sweeping only the `ext:`
* spelling left the raw-keyed handle serving a grant that had been narrowed
* or withdrawn, since the capability gate runs in the factory and a warm
* handle never re-enters it.
*/
it("reports staleness for the raw udid the provider claims", async () => {
const descriptorPath = await liveDescriptor();
useDescriptors(descriptorPath);
expect(await revalidate(IOS_UDID)).toEqual({ stale: false });
const descriptor = JSON.parse(fs.readFileSync(descriptorPath, "utf8"));
descriptor.devices[0].capabilities = ["simulator-server"];
fs.writeFileSync(descriptorPath, JSON.stringify(descriptor));
expect(await revalidate(IOS_UDID)).toMatchObject({ stale: true });
});
it("reports staleness for the raw udid when the device is withdrawn", async () => {
const descriptorPath = await liveDescriptor();
useDescriptors(descriptorPath);
expect(await revalidate(IOS_UDID)).toEqual({ stale: false });
rewrite(descriptorPath, []);
expect(await revalidate(IOS_UDID)).toMatchObject({ stale: true });
});
/**
* Both spellings name one simulator, so both handles have to go. Derived
* without reading the descriptor, because a withdrawal (the case that most
* needs disposing) can no longer resolve the device.
*/
it("drops every cached service bound to the device, both spellings and suffixes", async () => {
const deviceId = makeExternalId("acme", IOS_UDID);
const registry = fakeRegistry([
`SimulatorServer:${deviceId}`,
`NativeDevtools:${deviceId}#tcp`,
`AXService:${deviceId}`,
`SimulatorServer:${IOS_UDID}`,
`ChromiumCdp:chromium-cdp-9222`,
]);
const disposed = await disposeExternalDeviceServices(registry, deviceId);
expect(disposed.sort()).toEqual(
[
`SimulatorServer:${deviceId}`,
`NativeDevtools:${deviceId}#tcp`,
`AXService:${deviceId}`,
`SimulatorServer:${IOS_UDID}`,
].sort()
);
/** An unrelated device must survive. */
expect(registry.disposed).not.toContain(`ChromiumCdp:chromium-cdp-9222`);
});
/**
* The three namespaces that key a Metro session on a `(port, device)` pair
* put the port first, so the device id is not at the front of the payload.
* They are also the three that speak CDP to the app, gated only in the
* factory, so leaving them warm is what lets a withdrawn `js-debugger` grant
* keep serving for the life of the process.
*/
it.each(["JsRuntimeDebugger", "NetworkInspector", "ReactProfilerSession"])(
"drops %s, whose payload puts the Metro port before the device",
async (namespace) => {
const deviceId = makeExternalId("acme", IOS_UDID);
const registry = fakeRegistry([
`${namespace}:8081:${deviceId}`,
`${namespace}:8081:${IOS_UDID}`,
]);
expect((await disposeExternalDeviceServices(registry, deviceId)).sort()).toEqual(
[`${namespace}:8081:${deviceId}`, `${namespace}:8081:${IOS_UDID}`].sort()
);
}
);
/**
* A serial is a prefix of every longer serial, so a prefix match would take
* a second emulator's handles down with the first's.
*/
it("leaves a device whose id merely starts with this one's alone", async () => {
const registry = fakeRegistry([
`SimulatorServer:${ANDROID_SERIAL}`,
`SimulatorServer:${ANDROID_SERIAL}5`,
`JsRuntimeDebugger:8081:${ANDROID_SERIAL}5`,
]);
expect(await disposeExternalDeviceServices(registry, ANDROID_SERIAL)).toEqual([
`SimulatorServer:${ANDROID_SERIAL}`,
]);
});
it("drops both spellings when handed the raw udid", async () => {
useDescriptors(await liveDescriptor());
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
const registry = fakeRegistry([
`SimulatorServer:${deviceId}`,
`SimulatorServer:${IOS_UDID}`,
`ChromiumCdp:chromium-cdp-9222`,
]);
const disposed = await disposeExternalDeviceServices(registry, IOS_UDID);
expect(disposed.sort()).toEqual(
[`SimulatorServer:${deviceId}`, `SimulatorServer:${IOS_UDID}`].sort()
);
});
/**
* Each surviving handle is a door to the grant being revoked, so one that
* will not tear down must not shield the ones behind it. The sweep finishes,
* then says it could not.
*/
it("tries every handle even after one refuses, and reports that it did not finish", async () => {
const deviceId = makeExternalId("acme", IOS_UDID);
const wedged = `SimulatorServer:${deviceId}`;
const urns = [wedged, `AXService:${deviceId}`, `JsRuntimeDebugger:8081:${deviceId}`];
const registry = fakeRegistry(urns, [wedged]);
await expect(disposeExternalDeviceServices(registry, deviceId)).rejects.toThrow(
/will not tear down/
);
expect(registry.attempted).toEqual(urns);
expect(registry.disposed).toEqual(urns.slice(1));
});
/**
* A withdrawal reached through the raw spelling cannot look the `ext:` one
* up, because the descriptor that mapped them is gone. It does not have to,
* an `ext:` id ends with the native id it was made from, so the segment match
* finds that handle anyway. The case the descriptor was needed for is the
* reverse one, an `ext:` id that has to reach a raw-keyed handle.
*/
it("still drops both handles when the descriptor is already gone", async () => {
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
const registry = fakeRegistry([`SimulatorServer:${deviceId}`, `SimulatorServer:${IOS_UDID}`]);
expect((await disposeExternalDeviceServices(registry, IOS_UDID)).sort()).toEqual(
[`SimulatorServer:${deviceId}`, `SimulatorServer:${IOS_UDID}`].sort()
);
});
/**
* The reading and the sweep are one step because they used to be two. With
* the baseline recorded first, a request arriving while the sweep was still
* working compared against the new grant, saw nothing had changed and
* dispatched into one of the very handles being swept, built under the
* grant the provider had just narrowed. The sweep is not a tidy-up running
* alongside the request, it is the enforcement point, so nothing may read
* past it while it runs.
*/
describe("a dispatch arriving mid-sweep", () => {
/** A registry whose teardown blocks until the test lets it finish. */
function gatedRegistry(urns: string[]): {
attempted: string[];
disposeService: (urn: string) => Promise<void>;
getSnapshot: () => { services: ReadonlyMap<string, unknown> };
release: () => void;
} {
const attempted: string[] = [];
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
return {
attempted,
disposeService: async (urn: string) => {
attempted.push(urn);
await gate;
},
getSnapshot: () => ({ services: new Map(urns.map((urn) => [urn, {}])) }),
release: () => release(),
};
}
/** Let every already-queued microtask and timer callback run. */
const settle = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
it("waits for the sweep instead of reading the baseline past it", async () => {
const descriptorPath = await liveDescriptor();
useDescriptors(descriptorPath);
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
const registry = gatedRegistry([`SimulatorServer:${deviceId}`]);
expect(await enforceExternalDeviceGrant(registry, deviceId)).toEqual({ stale: false });
narrow(descriptorPath);
const first = enforceExternalDeviceGrant(registry, deviceId);
const second = enforceExternalDeviceGrant(registry, deviceId);
let secondDone = false;
void second.then(() => {
secondDone = true;
});
await settle();
/**
* The gate is still shut, so the sweep has not finished. The second call
* must not have concluded anything yet. Least of all that the grant is
* unchanged.
*/
expect(secondDone).toBe(false);
expect(registry.attempted).toEqual([`SimulatorServer:${deviceId}`]);
registry.release();
expect(await first).toMatchObject({ stale: true });
/**
* Answered from the baseline the first call committed, once the handles
* it was built to drop were gone. One sweep, not two.
*/
expect(await second).toEqual({ stale: false });
expect(registry.attempted).toEqual([`SimulatorServer:${deviceId}`]);
});
/**
* A sweep that could not finish must not move the baseline either. The next
* dispatch has to see the same revocation and refuse again, rather than
* treat it as spent.
*/
it("leaves the reading alone when the sweep fails", async () => {
const descriptorPath = await liveDescriptor();
useDescriptors(descriptorPath);
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
const wedged = `SimulatorServer:${deviceId}`;
const registry = fakeRegistry([wedged], [wedged]);
expect(await enforceExternalDeviceGrant(registry, deviceId)).toEqual({ stale: false });
narrow(descriptorPath);
await expect(enforceExternalDeviceGrant(registry, deviceId)).rejects.toThrow(
/will not tear down/
);
await expect(enforceExternalDeviceGrant(registry, deviceId)).rejects.toThrow(
/will not tear down/
);
});
});
});
describe("simulator-server endpoint parity", () => {
it.each(["/ws", "/api/screenshot", "/api/pointer", "/api/clipboard/text"])(
"allows %s",
(endpoint) => {
expect(() => assertAllowedSimServerEndpoint(endpoint)).not.toThrow();
}
);
/**
* A provider's build may serve more than argent's does. Anything off the list
* is refused, including near-misses of entries that are on it.
*/
it.each(["/api/screenshots", "/api/clipboard", "/api/pointer/reset", "/ws/control"])(
"refuses %s",
(endpoint) => {
expect(() => assertAllowedSimServerEndpoint(endpoint)).toThrow(/Refusing to call/);
}
);
});
describe("simctl argv for a provider device", () => {
it("is the identity for an ordinary device and performs no I/O", async () => {
await expect(
simctlArgsForUdid(IOS_UDID, ["launch", IOS_UDID, "com.example.app"])
).resolves.toEqual(["simctl", "launch", IOS_UDID, "com.example.app"]);
expect(simctlArgsForUdidSync(IOS_UDID, ["spawn", IOS_UDID, "launchctl", "list"])).toEqual([
"simctl",
"spawn",
IOS_UDID,
"launchctl",
"list",
]);
});
it("scopes to the provider's device set and substitutes the native id", async () => {
useDescriptors(await liveDescriptor());
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
await expect(
simctlArgsForUdid(deviceId, ["launch", deviceId, "com.example.app"])
).resolves.toEqual([
"simctl",
"--set",
"/tmp/acme/Devices/iOS",
"launch",
IOS_UDID,
"com.example.app",
]);
});
it("substitutes the id everywhere it appears, in any position", async () => {
useDescriptors(await liveDescriptor());
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
await expect(
simctlArgsForUdid(deviceId, ["privacy", deviceId, "grant", "camera", "com.example.app"])
).resolves.toEqual([
"simctl",
"--set",
"/tmp/acme/Devices/iOS",
"privacy",
IOS_UDID,
"grant",
"camera",
"com.example.app",
]);
});
it("omits --set when the provider keeps its simulators in the default set", async () => {
useDescriptors(await liveDescriptor({}, { deviceSet: undefined }));
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
await expect(
simctlArgsForUdid(deviceId, ["launch", deviceId, "com.example.app"])
).resolves.toEqual(["simctl", "launch", IOS_UDID, "com.example.app"]);
});
it("refuses when the provider withheld the simctl capability", async () => {
useDescriptors(await liveDescriptor({}, { capabilities: ["simulator-server"] }));
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
await expect(
simctlArgsForUdid(deviceId, ["launch", deviceId, "com.example.app"])
).rejects.toThrow(/'simctl' capability/);
});
/**
* The raw udid is in plain sight (embedded in the `ext:` id and printed by
* `simctl list`) so a grant that only recognised the `ext:` spelling was a
* rename away from being no grant at all.
*/
it("refuses the raw udid the provider claims when it withheld simctl", async () => {
useDescriptors(await liveDescriptor({}, { capabilities: ["simulator-server"] }));
await expect(
simctlArgsForUdid(IOS_UDID, ["launch", IOS_UDID, "com.example.app"])
).rejects.toThrow(/'simctl' capability/);
await expect(simctlTargetForUdid(IOS_UDID)).rejects.toThrow(/'simctl' capability/);
expect(() => simctlTargetForUdidSync(IOS_UDID)).toThrow(/'simctl' capability/);
expect(() => simctlArgsForUdidSync(IOS_UDID, ["spawn", IOS_UDID, "launchctl", "list"])).toThrow(
/'simctl' capability/
);
});
/**
* Passing the grant is only half of it. Argv built for the default set would
* reach simctl and be told the device does not exist, so the provider's
* `--set` has to follow the same spelling the gate now admits.
*/
it("targets the provider's device set for the raw udid it claims", async () => {
useDescriptors(await liveDescriptor());
await expect(
simctlArgsForUdid(IOS_UDID, ["launch", IOS_UDID, "com.example.app"])
).resolves.toEqual([
"simctl",
"--set",
"/tmp/acme/Devices/iOS",
"launch",
IOS_UDID,
"com.example.app",
]);
});
it("leaves a udid no provider claims on the default set", async () => {
useDescriptors(await liveDescriptor());
const unclaimed = "99999999-9999-9999-9999-999999999999";
await expect(
simctlArgsForUdid(unclaimed, ["launch", unclaimed, "com.example.app"])
).resolves.toEqual(["simctl", "launch", unclaimed, "com.example.app"]);
});
/** The sync form reads the file directly, so it needs no preceding lookup. */
it("works synchronously with no preceding lookup", async () => {
useDescriptors(await liveDescriptor());
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
expect(simctlArgsForUdidSync(deviceId, ["spawn", deviceId, "launchctl", "list"])).toEqual([
"simctl",
"--set",
"/tmp/acme/Devices/iOS",
"spawn",
IOS_UDID,
"launchctl",
"list",
]);
});
it("throws rather than silently targeting the default set when the device is gone", () => {
useDescriptors();
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
expect(() => simctlArgsForUdidSync(deviceId, ["spawn", deviceId, "launchctl", "list"])).toThrow(
/not currently offered/
);
});
/**
* Callers that issue a run of `simctl` commands for one device resolve the
* prefix and the target id together. Taking only the prefix (the shape this
* replaced) got `--set` right while passing the `ext:` id straight to
* `simctl`, which reaches nothing.
*/
it("pairs the device set with the native id for a run of commands", async () => {
useDescriptors(await liveDescriptor());
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
await expect(simctlTargetForUdid(deviceId)).resolves.toEqual({
nativeId: IOS_UDID,
prefix: ["simctl", "--set", "/tmp/acme/Devices/iOS"],
});
expect(simctlTargetForUdidSync(deviceId)).toEqual({
nativeId: IOS_UDID,
prefix: ["simctl", "--set", "/tmp/acme/Devices/iOS"],
});
});
/** Every shape that yields `simctl` argv checks the same grant. */
it("gates the pair and sync forms exactly like the single-call form", async () => {
useDescriptors(await liveDescriptor({}, { capabilities: ["simulator-server"] }));
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
await expect(simctlTargetForUdid(deviceId)).rejects.toThrow(/'simctl' capability/);
expect(() => simctlTargetForUdidSync(deviceId)).toThrow(/'simctl' capability/);
expect(() => simctlArgsForUdidSync(deviceId, ["spawn", deviceId, "launchctl", "list"])).toThrow(
/'simctl' capability/
);
});
/**
* Blueprints whose mechanism is implemented with `simctl` spawns
* (`ax-service`, `native-profiler`) name their own grant instead.
*/
it("lets the granted mechanism stand in for simctl where it is the implementation", async () => {
useDescriptors(await liveDescriptor({}, { capabilities: ["ax-service"] }));
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
expect(simctlTargetForUdidSync(deviceId, { granted: "ax-service" })).toEqual({
nativeId: IOS_UDID,
prefix: ["simctl", "--set", "/tmp/acme/Devices/iOS"],
});
await expect(simctlTargetForUdid(deviceId)).rejects.toThrow(/'simctl' capability/);
});
/**
* The one resolver that must work on a device whose provider grants nothing.
* It exists to remove Argent's own injection from a simulator the provider
* has claimed and the withheld grant is what puts us in that position. Gating
* it would strand our dylib in the provider's app.
*/
it("resolves the withdrawal target however little the provider grants", async () => {
useDescriptors(await liveDescriptor({}, { capabilities: [] }));
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
await expect(simctlTargetForUdid(deviceId, { granted: "native-devtools" })).rejects.toThrow(
/'native-devtools' capability/
);
await expect(simctlTargetForWithdrawal(deviceId)).resolves.toEqual({
nativeId: IOS_UDID,
prefix: ["simctl", "--set", "/tmp/acme/Devices/iOS"],
});
});
it("leaves an ordinary udid and the default set untouched", async () => {
await expect(simctlTargetForUdid(IOS_UDID)).resolves.toEqual({
nativeId: IOS_UDID,
prefix: ["simctl"],
});
});
/**
* The provider states its device set, so this is answered from the
* descriptor rather than by probing each configured set the way a local
* simulator's is. That is also what makes the answer follow a device that
* moves or is withdrawn, with nothing memoized to go stale.
*/
it("answers the owning device set from the descriptor, without probing", async () => {
useDescriptors(await liveDescriptor());
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
await expect(deviceSetForUdid(deviceId)).resolves.toBe("/tmp/acme/Devices/iOS");
});
it("reports the default set when the provider declared none", async () => {
useDescriptors(await liveDescriptor({}, { deviceSet: undefined }));
const deviceId = makeExternalId("acme-3f2a9c", IOS_UDID);
await expect(deviceSetForUdid(deviceId)).resolves.toBeNull();
});
});
describe("adb argv for a provider device", () => {
async function androidDescriptor(deviceOverrides: Record<string, unknown> = {}): Promise<string> {
const simulatorServer = await startSimulatorServer();
return writeDescriptor("acme-android.json", {
devices: [
androidDevice({
simulatorServer: {
apiUrl: simulatorServer.apiUrl,
streamUrl: simulatorServer.streamUrl,
},
...deviceOverrides,
}),
],
id: "acme-3f2a9c",
name: "Acme IDE",
schemaVersion: 1,
});
}
it("substitutes the serial when the provider granted adb", async () => {
useDescriptors(await androidDescriptor());
const deviceId = makeExternalId("acme-3f2a9c", ANDROID_SERIAL);
expect(adbArgv(["-s", deviceId, "shell", "ls"])).toEqual(["-s", ANDROID_SERIAL, "shell", "ls"]);
});
/**
* Refusing the substitution refuses the mechanism: `adb` itself has never
* heard of an `ext:` id.
*/
it("refuses to reveal the serial when the provider withheld adb", async () => {
useDescriptors(await androidDescriptor({ capabilities: ["simulator-server"] }));
const deviceId = makeExternalId("acme-3f2a9c", ANDROID_SERIAL);
expect(() => adbArgv(["-s", deviceId, "shell", "ls"])).toThrow(/'adb' capability/);
});
it("refuses a device that is no longer offered, rather than passing the ext: id through", async () => {
useDescriptors();
const deviceId = makeExternalId("acme-3f2a9c", ANDROID_SERIAL);
expect(() => adbArgv(["-s", deviceId, "shell", "ls"])).toThrow(/No device provider/);
});
it("leaves an ordinary argv alone without any lookup", () => {
useDescriptors();
expect(adbArgv(["-s", ANDROID_SERIAL, "shell", "ls"])).toEqual([
"-s",
ANDROID_SERIAL,
"shell",
"ls",
]);
});
/**
* `adb devices` reports a provider's emulator under its own serial, so the
* raw spelling is the one a caller is most likely to reach for. Gating only
* the `ext:` form would leave the withheld mechanism a rename away.
*/
it("refuses the raw serial the provider claims when it withheld adb", async () => {
useDescriptors(await androidDescriptor({ capabilities: ["simulator-server"] }));
expect(() => adbArgv(["-s", ANDROID_SERIAL, "shell", "ls"])).toThrow(/'adb' capability/);
});
it("passes the raw serial through when the provider granted adb", async () => {
useDescriptors(await androidDescriptor());
expect(adbArgv(["-s", ANDROID_SERIAL, "shell", "ls"])).toEqual([
"-s",
ANDROID_SERIAL,
"shell",
"ls",
]);
});
});
describe("externalSupportHint", () => {
it("names the provider and where to report", async () => {
useDescriptors(await liveDescriptor());
expect(externalSupportHint(makeExternalId("acme-3f2a9c", IOS_UDID))).toBe(
"This device is provided by Acme IDE. Report issues at https://example.invalid/issues."
);
});
it("still names the provider when it published no support url", async () => {
useDescriptors(await liveDescriptor({ supportUrl: undefined }));
expect(externalSupportHint(makeExternalId("acme-3f2a9c", IOS_UDID))).toBe(
"This device is provided by Acme IDE."
);
});
it("returns undefined for a device argent booted itself", () => {
expect(externalSupportHint(IOS_UDID)).toBeUndefined();
});
});