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 -->
468 lines
15 KiB
TypeScript
468 lines
15 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { Readable } from "node:stream";
|
|
import { providers } from "../src/providers.js";
|
|
|
|
/**
|
|
* Drive the real `argent providers` entry point against a sandboxed home.
|
|
* Nothing below the command layer is mocked. These descriptors go to a real
|
|
* disk and come back through the same `@argent/device-providers` the
|
|
* tool-server uses, which is the only way to prove `publish` and `list` agree
|
|
* about where the file goes.
|
|
*/
|
|
|
|
/**
|
|
* `process.exit` replaced by a throw, so a command's exit code is assertable.
|
|
*/
|
|
class Exit extends Error {
|
|
constructor(readonly code: number) {
|
|
super(`process.exit(${code})`);
|
|
}
|
|
}
|
|
|
|
let home: string;
|
|
let providersDir: string;
|
|
let out: string[];
|
|
let err: string[];
|
|
|
|
const IOS_UDID = "1A2B3C4D-5E6F-7081-92A3-B4C5D6E7F809";
|
|
const DEAD_PID = 0x7fffffff;
|
|
|
|
function descriptor(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
devices: [
|
|
{
|
|
capabilities: ["simctl"],
|
|
kind: "simulator",
|
|
name: "iPhone 16 Pro",
|
|
nativeId: IOS_UDID,
|
|
platform: "ios",
|
|
state: "Booted",
|
|
},
|
|
],
|
|
id: "acme-3f2a9c",
|
|
name: "Acme IDE",
|
|
schemaVersion: 1,
|
|
supportUrl: "https://example.invalid/issues",
|
|
workspace: { name: "my-app", path: "/Users/me/src/my-app" },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function withStdin(body: string): void {
|
|
Object.defineProperty(process, "stdin", {
|
|
configurable: true,
|
|
value: Readable.from([Buffer.from(body, "utf8")]),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Run a subcommand, returning the exit code it asked for (0 when it returned).
|
|
*/
|
|
async function runProviders(...argv: string[]): Promise<number> {
|
|
try {
|
|
await providers(argv);
|
|
return 0;
|
|
} catch (e) {
|
|
if (e instanceof Exit) return e.code;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function stdout(): string {
|
|
return out.join("\n");
|
|
}
|
|
|
|
function stderr(): string {
|
|
return err.join("\n");
|
|
}
|
|
|
|
const realStdin = Object.getOwnPropertyDescriptor(process, "stdin")!;
|
|
|
|
beforeEach(() => {
|
|
home = fs.mkdtempSync(path.join(os.tmpdir(), "argent-cli-providers-"));
|
|
vi.stubEnv("HOME", home);
|
|
vi.stubEnv("USERPROFILE", home);
|
|
delete process.env.ARGENT_DEVICE_PROVIDERS;
|
|
delete process.env.ARGENT_DISABLE_DEVICE_PROVIDERS;
|
|
providersDir = path.join(home, ".argent", "providers");
|
|
|
|
out = [];
|
|
err = [];
|
|
vi.spyOn(console, "log").mockImplementation((...args) => void out.push(args.join(" ")));
|
|
vi.spyOn(console, "error").mockImplementation((...args) => void err.push(args.join(" ")));
|
|
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
throw new Exit(code ?? 0);
|
|
}) as never);
|
|
});
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(process, "stdin", realStdin);
|
|
vi.unstubAllEnvs();
|
|
vi.restoreAllMocks();
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("argent providers publish", () => {
|
|
it("writes the descriptor from stdin to its canonical path", async () => {
|
|
withStdin(JSON.stringify(descriptor()));
|
|
|
|
expect(await runProviders("publish", "--stdin", "--pid", String(process.pid))).toBe(0);
|
|
|
|
const file = path.join(providersDir, "acme-3f2a9c.json");
|
|
expect(stdout()).toContain(file);
|
|
expect(JSON.parse(fs.readFileSync(file, "utf8")).pid).toBe(process.pid);
|
|
});
|
|
|
|
it("reads a descriptor from --file", async () => {
|
|
const source = path.join(home, "descriptor.json");
|
|
fs.writeFileSync(source, JSON.stringify(descriptor()));
|
|
|
|
expect(await runProviders("publish", "--file", source)).toBe(0);
|
|
expect(fs.existsSync(path.join(providersDir, "acme-3f2a9c.json"))).toBe(true);
|
|
});
|
|
|
|
it("reports an unchanged republish without touching the file", async () => {
|
|
withStdin(JSON.stringify(descriptor()));
|
|
await runProviders("publish", "--stdin", "--pid", String(process.pid));
|
|
|
|
withStdin(JSON.stringify(descriptor()));
|
|
out = [];
|
|
expect(await runProviders("publish", "--stdin", "--json", "--pid", String(process.pid))).toBe(
|
|
0
|
|
);
|
|
|
|
expect(JSON.parse(stdout()).changed).toBe(false);
|
|
});
|
|
|
|
it("exits 1 with the issues on stderr for a descriptor argent would reject", async () => {
|
|
withStdin(JSON.stringify(descriptor({ id: "Not A Slug" })));
|
|
|
|
expect(await runProviders("publish", "--stdin")).toBe(1);
|
|
expect(stderr()).toContain("id:");
|
|
expect(fs.existsSync(providersDir)).toBe(false);
|
|
});
|
|
|
|
it("exits 1 for a document that is not JSON", async () => {
|
|
withStdin("{ not json");
|
|
expect(await runProviders("publish", "--stdin")).toBe(1);
|
|
expect(stderr()).toContain("not valid JSON");
|
|
});
|
|
|
|
it("exits 1 for a schemaVersion argent does not understand", async () => {
|
|
withStdin(JSON.stringify(descriptor({ schemaVersion: 2 })));
|
|
expect(await runProviders("publish", "--stdin")).toBe(1);
|
|
expect(stderr()).toContain("schemaVersion is 2");
|
|
});
|
|
|
|
/** A real hole, but not a failure — not every provider can name a process. */
|
|
it("warns, but succeeds, when nothing supplies a pid", async () => {
|
|
withStdin(JSON.stringify(descriptor()));
|
|
|
|
expect(await runProviders("publish", "--stdin")).toBe(0);
|
|
expect(stderr()).toContain("no pid recorded");
|
|
});
|
|
|
|
it("exits 2 when given neither --file nor --stdin", async () => {
|
|
expect(await runProviders("publish")).toBe(2);
|
|
expect(stderr()).toContain("--file");
|
|
});
|
|
|
|
it("exits 2 for a --pid that is not a positive integer", async () => {
|
|
expect(await runProviders("publish", "--stdin", "--pid", "nope")).toBe(2);
|
|
});
|
|
|
|
/**
|
|
* `Number.parseInt` reads a leading run of digits and stops, so these used to
|
|
* be accepted as the number in front of the garbage: `--pid=12.5` recorded
|
|
* pid 12, and a bad `--schema-version` published as version 1.
|
|
*/
|
|
it.each(["12.5", "12junk", "1e3", " 12 5"])(
|
|
"exits 2 for a --pid of %j rather than reading its numeric prefix",
|
|
async (value) => {
|
|
expect(await runProviders("publish", "--stdin", "--pid", value)).toBe(2);
|
|
}
|
|
);
|
|
|
|
it.each(["1junk", "1.0"])("exits 2 for a --schema-version of %j", async (value) => {
|
|
expect(await runProviders("publish", "--stdin", "--schema-version", value)).toBe(2);
|
|
});
|
|
|
|
/**
|
|
* This replaces a provider's startup cleanup, so it must happen on publish.
|
|
* The crashed window carries a different id from the one publishing now, ids
|
|
* being unique per live instance, so what the two share is the vendor.
|
|
*/
|
|
it("prunes the publishing vendor's own crashed instances on the way through", async () => {
|
|
fs.mkdirSync(providersDir, { recursive: true });
|
|
const orphan = path.join(providersDir, "acme-dead.json");
|
|
|
|
fs.writeFileSync(
|
|
orphan,
|
|
JSON.stringify(descriptor({ devices: [], id: "acme-dead", pid: DEAD_PID }))
|
|
);
|
|
|
|
withStdin(JSON.stringify(descriptor()));
|
|
expect(await runProviders("publish", "--stdin", "--json", "--pid", String(process.pid))).toBe(
|
|
0
|
|
);
|
|
|
|
expect(JSON.parse(stdout()).pruned).toEqual([orphan]);
|
|
expect(fs.existsSync(orphan)).toBe(false);
|
|
});
|
|
|
|
/**
|
|
* Saving your own descriptor is not a mandate to tidy up after a competitor.
|
|
* `argent providers prune` is where a user asks for all of them.
|
|
*/
|
|
it("leaves another vendor's orphan alone", async () => {
|
|
fs.mkdirSync(providersDir, { recursive: true });
|
|
const foreign = path.join(providersDir, "zenith-dead.json");
|
|
|
|
fs.writeFileSync(
|
|
foreign,
|
|
JSON.stringify(descriptor({ devices: [], id: "zenith-dead", pid: DEAD_PID }))
|
|
);
|
|
|
|
withStdin(JSON.stringify(descriptor()));
|
|
expect(await runProviders("publish", "--stdin", "--json", "--pid", String(process.pid))).toBe(
|
|
0
|
|
);
|
|
|
|
expect(JSON.parse(stdout()).pruned).toEqual([]);
|
|
expect(fs.existsSync(foreign)).toBe(true);
|
|
|
|
/** Still reachable the way a user asks for it. */
|
|
expect(await runProviders("prune", "--json")).toBe(0);
|
|
expect(fs.existsSync(foreign)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("argent providers withdraw", () => {
|
|
it("removes a published descriptor", async () => {
|
|
withStdin(JSON.stringify(descriptor()));
|
|
await runProviders("publish", "--stdin", "--pid", String(process.pid));
|
|
|
|
out = [];
|
|
|
|
expect(await runProviders("withdraw", "acme-3f2a9c", "--json")).toBe(0);
|
|
expect(JSON.parse(stdout()).removed).toBe(true);
|
|
expect(fs.existsSync(path.join(providersDir, "acme-3f2a9c.json"))).toBe(false);
|
|
});
|
|
|
|
it("succeeds, reporting nothing removed, for an id that was never published", async () => {
|
|
expect(await runProviders("withdraw", "never-published", "--json")).toBe(0);
|
|
expect(JSON.parse(stdout()).removed).toBe(false);
|
|
});
|
|
|
|
it("exits 2 rather than letting a path escape the providers directory", async () => {
|
|
expect(await runProviders("withdraw", "../../etc/passwd")).toBe(2);
|
|
expect(stderr()).toContain("not a valid provider id");
|
|
});
|
|
|
|
it("exits 2 when no id is given", async () => {
|
|
expect(await runProviders("withdraw")).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("argent providers list", () => {
|
|
it("reports nothing when no provider is registered", async () => {
|
|
expect(await runProviders("list", "--json")).toBe(0);
|
|
expect(JSON.parse(stdout())).toMatchObject({ ok: true, providers: [] });
|
|
});
|
|
|
|
it("reports the provider, its devices and its liveness", async () => {
|
|
withStdin(JSON.stringify(descriptor()));
|
|
await runProviders("publish", "--stdin", "--pid", String(process.pid));
|
|
out = [];
|
|
|
|
expect(await runProviders("list", "--json")).toBe(0);
|
|
|
|
const payload = JSON.parse(stdout());
|
|
expect(payload.directory).toBe(providersDir);
|
|
expect(payload.providers).toHaveLength(1);
|
|
|
|
const [provider] = payload.providers;
|
|
|
|
expect(provider).toMatchObject({
|
|
id: "acme-3f2a9c",
|
|
invalidDevices: 0,
|
|
name: "Acme IDE",
|
|
pid: process.pid,
|
|
processAlive: true,
|
|
supportUrl: "https://example.invalid/issues",
|
|
workspace: { name: "my-app", path: "/Users/me/src/my-app" },
|
|
});
|
|
|
|
expect(provider.devices).toEqual([
|
|
{
|
|
capabilities: ["simctl"],
|
|
id: `ext:acme-3f2a9c:${IOS_UDID}`,
|
|
name: "iPhone 16 Pro",
|
|
nativeId: IOS_UDID,
|
|
platform: "ios",
|
|
state: "Booted",
|
|
},
|
|
]);
|
|
});
|
|
|
|
/**
|
|
* `list` is the support view: a partly-rejected descriptor must still show
|
|
* up, or the user goes looking in the wrong place.
|
|
*/
|
|
it("counts device entries argent would reject rather than hiding the provider", async () => {
|
|
fs.mkdirSync(providersDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(providersDir, "acme-3f2a9c.json"),
|
|
JSON.stringify(descriptor({ devices: [{ nativeId: "junk" }] }))
|
|
);
|
|
|
|
expect(await runProviders("list", "--json")).toBe(0);
|
|
|
|
const [provider] = JSON.parse(stdout()).providers;
|
|
expect(provider.devices).toEqual([]);
|
|
expect(provider.invalidDevices).toBe(1);
|
|
});
|
|
|
|
it("leaves the providers directory untouched", async () => {
|
|
withStdin(JSON.stringify(descriptor({ pid: DEAD_PID })));
|
|
await runProviders("publish", "--stdin");
|
|
const before = fs.readdirSync(providersDir);
|
|
|
|
await runProviders("list", "--json");
|
|
|
|
expect(fs.readdirSync(providersDir)).toEqual(before);
|
|
});
|
|
});
|
|
|
|
describe("argent providers prune", () => {
|
|
function writeOrphan(id: string, pid: number): string {
|
|
fs.mkdirSync(providersDir, { recursive: true });
|
|
const file = path.join(providersDir, `${id}.json`);
|
|
fs.writeFileSync(file, JSON.stringify(descriptor({ devices: [], id, pid })));
|
|
return file;
|
|
}
|
|
|
|
it("removes a descriptor whose process is gone", async () => {
|
|
const file = writeOrphan("acme-dead", DEAD_PID);
|
|
|
|
expect(await runProviders("prune", "--json")).toBe(0);
|
|
expect(JSON.parse(stdout()).pruned.map((entry: { path: string }) => entry.path)).toEqual([
|
|
file,
|
|
]);
|
|
expect(fs.existsSync(file)).toBe(false);
|
|
});
|
|
|
|
it("removes nothing under --dry-run", async () => {
|
|
const file = writeOrphan("acme-dead", DEAD_PID);
|
|
|
|
expect(await runProviders("prune", "--dry-run", "--json")).toBe(0);
|
|
|
|
const payload = JSON.parse(stdout());
|
|
expect(payload.dryRun).toBe(true);
|
|
expect(payload.pruned).toHaveLength(1);
|
|
expect(fs.existsSync(file)).toBe(true);
|
|
});
|
|
|
|
it("keeps a descriptor whose process is alive", async () => {
|
|
const file = writeOrphan("acme-live", process.pid);
|
|
expect(await runProviders("prune", "--json")).toBe(0);
|
|
expect(JSON.parse(stdout()).pruned).toEqual([]);
|
|
expect(fs.existsSync(file)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("argent providers dispatch", () => {
|
|
it("exits 2 on an unknown subcommand", async () => {
|
|
expect(await runProviders("frobnicate")).toBe(2);
|
|
});
|
|
|
|
it("exits 2 with no subcommand at all", async () => {
|
|
expect(await runProviders()).toBe(2);
|
|
});
|
|
|
|
it("prints help for every subcommand without doing anything", async () => {
|
|
for (const subcommand of ["check", "publish", "withdraw", "list", "prune"]) {
|
|
expect(await runProviders(subcommand, "--help")).toBe(0);
|
|
}
|
|
|
|
expect(stdout()).toContain("Usage: argent providers <subcommand>");
|
|
expect(fs.existsSync(providersDir)).toBe(false);
|
|
});
|
|
|
|
it("exits 2 on a stray operand for a subcommand that takes none", async () => {
|
|
expect(await runProviders("list", "acme")).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("argent providers check", () => {
|
|
it("reports a conformant descriptor as ok", async () => {
|
|
withStdin(JSON.stringify(descriptor()));
|
|
await runProviders("publish", "--stdin", "--pid", String(process.pid));
|
|
out = [];
|
|
|
|
expect(await runProviders("check", "--json")).toBe(0);
|
|
|
|
const payload = JSON.parse(stdout());
|
|
expect(payload.ok).toBe(true);
|
|
expect(payload.providers[0].id).toBe("acme-3f2a9c");
|
|
});
|
|
|
|
it("warns about a descriptor with no pid", async () => {
|
|
const source = path.join(home, "descriptor.json");
|
|
fs.writeFileSync(source, JSON.stringify(descriptor()));
|
|
|
|
expect(await runProviders("check", "--file", source, "--json")).toBe(0);
|
|
|
|
const messages = JSON.parse(stdout()).providers[0].findings.map(
|
|
(finding: { message: string }) => finding.message
|
|
);
|
|
expect(messages.join("\n")).toContain("no pid");
|
|
});
|
|
|
|
it("exits 1 and names the field for a descriptor argent would reject", async () => {
|
|
const source = path.join(home, "bad.json");
|
|
fs.writeFileSync(source, JSON.stringify(descriptor({ id: "Not A Slug" })));
|
|
|
|
expect(await runProviders("check", "--file", source, "--json")).toBe(1);
|
|
expect(stdout()).toContain("id:");
|
|
});
|
|
|
|
/**
|
|
* The tool-server classifies a native id by its shape and ignores a device
|
|
* whose declared platform disagrees, so a descriptor that passes the check
|
|
* and then shows no device is the one divergence this command exists to make
|
|
* impossible. Both directions, because a provider can get it wrong either
|
|
* way.
|
|
*/
|
|
it.each([
|
|
["ios", "emulator-5554", "an android serial"],
|
|
["android", IOS_UDID, "an iOS udid"],
|
|
])("exits 1 for a %s device whose nativeId is %s", async (platform, nativeId, shape) => {
|
|
const source = path.join(home, "mismatched.json");
|
|
|
|
fs.writeFileSync(
|
|
source,
|
|
JSON.stringify(
|
|
descriptor({
|
|
devices: [
|
|
{
|
|
capabilities: [],
|
|
kind: platform === "ios" ? "simulator" : "emulator",
|
|
name: "Device",
|
|
nativeId,
|
|
platform,
|
|
state: "Booted",
|
|
},
|
|
],
|
|
})
|
|
)
|
|
);
|
|
|
|
expect(await runProviders("check", "--file", source, "--json")).toBe(1);
|
|
expect(stdout()).toContain(shape);
|
|
});
|
|
});
|