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 -->
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { WebSocketServer, type WebSocket } from "ws";
|
|
|
|
import { CDPClient } from "../src/utils/debugger/cdp-client";
|
|
|
|
/**
|
|
* Argent sets no breakpoints, so a stopped runtime means another debugger
|
|
* sharing it did. That only became reachable once a provider could hand Argent
|
|
* a socket into a session it already owns.
|
|
*
|
|
* Without this, every tool reports `CDP request … timed out`, which reads as a
|
|
* broken app.
|
|
*/
|
|
|
|
interface FakeRuntime {
|
|
close: () => Promise<void>;
|
|
/** Announce a script, so its id can be resolved back to a url. */
|
|
parseScript: (scriptId: string, url: string) => void;
|
|
/** Push a `Debugger.paused` at a fixed location, as a breakpoint would. */
|
|
pause: (reason?: string) => void;
|
|
/** Pause the way Hermes does: a scriptId, and no `url` on the frame. */
|
|
pauseByScriptId: (scriptId: string) => void;
|
|
/**
|
|
* Pause the way an eval'd `debugger` does: a synthetic top frame with an
|
|
* empty url and an unannounced script, above the real frame in the bundle.
|
|
*/
|
|
pauseWithSyntheticTopFrame: (appScriptId: string) => void;
|
|
/** Every method the client actually put on the wire. */
|
|
received: string[];
|
|
resume: () => void;
|
|
url: string;
|
|
}
|
|
|
|
function startRuntime(): Promise<FakeRuntime> {
|
|
return new Promise((resolve) => {
|
|
const received: string[] = [];
|
|
let socket: WebSocket | undefined;
|
|
|
|
const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }, () => {
|
|
const address = wss.address();
|
|
const port = typeof address === "object" && address ? address.port : 0;
|
|
|
|
const emit = (method: string, params: Record<string, unknown>) =>
|
|
socket?.send(JSON.stringify({ method, params }));
|
|
|
|
resolve({
|
|
close: () => new Promise<void>((r) => wss.close(() => r())),
|
|
parseScript: (scriptId: string, url: string) =>
|
|
emit("Debugger.scriptParsed", { endLine: 100, scriptId, startLine: 0, url }),
|
|
pause: (reason = "other") =>
|
|
emit("Debugger.paused", {
|
|
callFrames: [{ location: { lineNumber: 41 }, url: "app:///src/App.tsx" }],
|
|
reason,
|
|
}),
|
|
pauseByScriptId: (scriptId: string) =>
|
|
emit("Debugger.paused", {
|
|
callFrames: [{ location: { lineNumber: 41, scriptId } }],
|
|
reason: "other",
|
|
}),
|
|
pauseWithSyntheticTopFrame: (appScriptId: string) =>
|
|
emit("Debugger.paused", {
|
|
callFrames: [
|
|
{ location: { columnNumber: 23, lineNumber: 0, scriptId: "41" }, url: "" },
|
|
{ location: { columnNumber: 49, lineNumber: 31632, scriptId: appScriptId } },
|
|
],
|
|
reason: "other",
|
|
}),
|
|
received,
|
|
resume: () => emit("Debugger.resumed", {}),
|
|
url: `ws://127.0.0.1:${port}/devtools/page/1`,
|
|
});
|
|
});
|
|
|
|
wss.on("connection", (sock) => {
|
|
socket = sock;
|
|
|
|
sock.on("message", (raw) => {
|
|
const message = JSON.parse(raw.toString());
|
|
received.push(message.method);
|
|
sock.send(JSON.stringify({ id: message.id, result: {} }));
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Give an emitted event a turn to land before asserting on it. */
|
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
|
|
|
|
describe("CDPClient against a runtime someone else paused", () => {
|
|
it("refuses to evaluate, naming the location and who can clear it", async () => {
|
|
const runtime = await startRuntime();
|
|
const cdp = new CDPClient(runtime.url, { sendOrigin: false });
|
|
await cdp.connect();
|
|
|
|
runtime.pause();
|
|
await settle();
|
|
|
|
await expect(cdp.send("Runtime.evaluate", { expression: "1" })).rejects.toThrow(
|
|
/paused at a breakpoint at app:\/\/\/src\/App\.tsx:42.*does not set breakpoints/s
|
|
);
|
|
|
|
/** Never reached the wire. A stopped runtime would simply not have answered. */
|
|
expect(runtime.received).not.toContain("Runtime.evaluate");
|
|
|
|
await cdp.disconnect();
|
|
await runtime.close();
|
|
});
|
|
|
|
/**
|
|
* Hermes leaves `url` off the call frame and identifies the script by id, so
|
|
* reading `url` alone loses the location on the one runtime this is for.
|
|
*/
|
|
it("resolves a location from a scriptId when the frame carries no url", async () => {
|
|
const runtime = await startRuntime();
|
|
const cdp = new CDPClient(runtime.url, { sendOrigin: false });
|
|
await cdp.connect();
|
|
|
|
runtime.parseScript("7", "app:///src/Screen.tsx");
|
|
await settle();
|
|
|
|
runtime.pauseByScriptId("7");
|
|
await settle();
|
|
|
|
expect(cdp.pausedAt()).toEqual({ location: "app:///src/Screen.tsx:42", reason: "other" });
|
|
await expect(cdp.send("Runtime.evaluate", {})).rejects.toThrow(/app:\/\/\/src\/Screen\.tsx:42/);
|
|
|
|
await cdp.disconnect();
|
|
await runtime.close();
|
|
});
|
|
|
|
/**
|
|
* Hermes puts the eval'd frame on top, with an empty url and a script
|
|
* nothing announced, above the real frame in the bundle. Reading only the
|
|
* top frame reports no location at all.
|
|
*/
|
|
it("skips a synthetic top frame to reach the app's own code", async () => {
|
|
const runtime = await startRuntime();
|
|
const cdp = new CDPClient(runtime.url, { sendOrigin: false });
|
|
await cdp.connect();
|
|
|
|
runtime.parseScript("6", "http://localhost:8081/index.bundle");
|
|
await settle();
|
|
|
|
runtime.pauseWithSyntheticTopFrame("6");
|
|
await settle();
|
|
|
|
expect(cdp.pausedAt()).toEqual({
|
|
location: "http://localhost:8081/index.bundle:31633",
|
|
reason: "other",
|
|
});
|
|
|
|
await cdp.disconnect();
|
|
await runtime.close();
|
|
});
|
|
|
|
/**
|
|
* A provider replaying a pause to a client that attached mid-stop delivers
|
|
* it before `Debugger.enable` has re-announced the scripts. Resolving on
|
|
* arrival would find an empty map and lose the location.
|
|
*/
|
|
it("resolves the location against scripts announced after the pause", async () => {
|
|
const runtime = await startRuntime();
|
|
const cdp = new CDPClient(runtime.url, { sendOrigin: false });
|
|
await cdp.connect();
|
|
|
|
/** Pause first, script afterwards: the order a late-joining client sees. */
|
|
runtime.pauseWithSyntheticTopFrame("6");
|
|
await settle();
|
|
expect(cdp.pausedAt()).toEqual({ reason: "other" });
|
|
|
|
runtime.parseScript("6", "http://localhost:8081/index.bundle");
|
|
await settle();
|
|
|
|
expect(cdp.pausedAt()).toEqual({
|
|
location: "http://localhost:8081/index.bundle:31633",
|
|
reason: "other",
|
|
});
|
|
|
|
await cdp.disconnect();
|
|
await runtime.close();
|
|
});
|
|
|
|
/**
|
|
* The url Metro serves a bundle from. Its build configuration is most of the
|
|
* length and none of the meaning.
|
|
*/
|
|
it("drops Metro's build configuration from the reported location", async () => {
|
|
const runtime = await startRuntime();
|
|
const cdp = new CDPClient(runtime.url, { sendOrigin: false });
|
|
await cdp.connect();
|
|
|
|
runtime.parseScript(
|
|
"6",
|
|
"http://localhost:53548/index.bundle//&platform=ios&dev=true&hot=false&lazy=true" +
|
|
"&transform.engine=hermes&transform.bytecode=1&unstable_transformProfile=hermes-stable"
|
|
);
|
|
await settle();
|
|
|
|
runtime.pauseWithSyntheticTopFrame("6");
|
|
await settle();
|
|
|
|
expect(cdp.pausedAt()).toEqual({
|
|
location: "http://localhost:53548/index.bundle:31633",
|
|
reason: "other",
|
|
});
|
|
|
|
await cdp.disconnect();
|
|
await runtime.close();
|
|
});
|
|
|
|
it("still refuses when the script behind the frame was never announced", async () => {
|
|
const runtime = await startRuntime();
|
|
const cdp = new CDPClient(runtime.url, { sendOrigin: false });
|
|
await cdp.connect();
|
|
|
|
runtime.pauseByScriptId("unknown-script");
|
|
await settle();
|
|
|
|
/** No location to give, but the refusal itself must not depend on having one. */
|
|
expect(cdp.pausedAt()).toEqual({ reason: "other" });
|
|
await expect(cdp.send("Runtime.evaluate", {})).rejects.toThrow(/paused at a breakpoint/);
|
|
|
|
await cdp.disconnect();
|
|
await runtime.close();
|
|
});
|
|
|
|
it("says so differently when it stopped on an exception", async () => {
|
|
const runtime = await startRuntime();
|
|
const cdp = new CDPClient(runtime.url, { sendOrigin: false });
|
|
await cdp.connect();
|
|
|
|
runtime.pause("exception");
|
|
await settle();
|
|
|
|
await expect(cdp.send("Runtime.evaluate", {})).rejects.toThrow(/paused on an exception/);
|
|
|
|
await cdp.disconnect();
|
|
await runtime.close();
|
|
});
|
|
|
|
/** Guarding everything would break the calls that recover the session. */
|
|
it("still passes through the calls a paused runtime does answer", async () => {
|
|
const runtime = await startRuntime();
|
|
const cdp = new CDPClient(runtime.url, { sendOrigin: false });
|
|
await cdp.connect();
|
|
|
|
runtime.pause();
|
|
await settle();
|
|
|
|
await expect(cdp.send("Debugger.resume")).resolves.toEqual({});
|
|
await expect(cdp.send("Debugger.removeBreakpoint", { breakpointId: "1" })).resolves.toEqual({});
|
|
expect(runtime.received).toContain("Debugger.resume");
|
|
|
|
await cdp.disconnect();
|
|
await runtime.close();
|
|
});
|
|
|
|
it("evaluates again once the runtime resumes", async () => {
|
|
const runtime = await startRuntime();
|
|
const cdp = new CDPClient(runtime.url, { sendOrigin: false });
|
|
await cdp.connect();
|
|
|
|
runtime.pause();
|
|
await settle();
|
|
expect(cdp.pausedAt()).toEqual({ location: "app:///src/App.tsx:42", reason: "other" });
|
|
|
|
runtime.resume();
|
|
await settle();
|
|
expect(cdp.pausedAt()).toBeUndefined();
|
|
|
|
await expect(cdp.send("Runtime.evaluate", {})).resolves.toEqual({});
|
|
|
|
await cdp.disconnect();
|
|
await runtime.close();
|
|
});
|
|
|
|
it("does not carry a stale pause across a reconnect", async () => {
|
|
const first = await startRuntime();
|
|
const second = await startRuntime();
|
|
const cdp = new CDPClient(first.url, { sendOrigin: false });
|
|
await cdp.connect();
|
|
|
|
first.pause();
|
|
await settle();
|
|
expect(cdp.pausedAt()).toBeDefined();
|
|
|
|
await cdp.reconnect(second.url);
|
|
|
|
expect(cdp.pausedAt()).toBeUndefined();
|
|
await expect(cdp.send("Runtime.evaluate", {})).resolves.toEqual({});
|
|
|
|
await cdp.disconnect();
|
|
await first.close();
|
|
await second.close();
|
|
});
|
|
|
|
it("leaves a running runtime alone", async () => {
|
|
const runtime = await startRuntime();
|
|
const cdp = new CDPClient(runtime.url, { sendOrigin: false });
|
|
await cdp.connect();
|
|
|
|
expect(cdp.pausedAt()).toBeUndefined();
|
|
await expect(cdp.send("Runtime.evaluate", {})).resolves.toEqual({});
|
|
|
|
await cdp.disconnect();
|
|
await runtime.close();
|
|
});
|
|
});
|