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

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();
});
});