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 -->
1479 lines
52 KiB
TypeScript
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();
|
|
});
|
|
});
|