Files
software-mansion__argent/packages/tool-server/test/metro/multi-device.test.ts
Ignacy Łątka 71344c40b9 fix(debugger): stop unmatched device_id silently collapsing onto one device (#397)
## Problem

Opening debugger sessions with **different** `device_id`s could all
resolve to the **same** physical device.

`selectTarget` filters Metro's `/json/list` CDP targets by
`reactNative.logicalDeviceId === device_id`, but when the provided
`device_id` matched **no** target it kept *all* candidates and fell
through to the first/priority target:

```ts
const filtered = candidates.filter((t) => t.reactNative?.logicalDeviceId === options.deviceId);
if (filtered.length) candidates = filtered; // unmatched id → silently ignored
```

This silently no-ops surprisingly often: Metro's `logicalDeviceId` is an
internal hash, **not** the iOS simulator UDID. Verified live against a
real device:

- simulator UDID: `6DBF83B4-F341-4F8D-B48D-CD8FF312CCFB`
- Metro `logicalDeviceId`: `a1956f6bd1e3a3ac3dbb505e24d79687a5ea60b1`

With one device the fallback is harmless (it picks the only device).
With **several** devices on one Metro, every unmatched `device_id`
collapses onto whichever device Metro lists first — so two debugger
sessions opened with different `device_id`s drive the same device.

## Fix

In `selectTarget`, only fall back to the priority target when there is
**at most one distinct device** to fall back to. When multiple distinct
devices are connected and the `device_id` matches none, throw an error
that lists the valid `logicalDeviceId`s so the caller can re-target
instead of being silently misrouted. The single-device convenience path
(pass any id — including a UDID — and get the one connected device) is
preserved.

## Verification

- `selectTarget` unit tests: distinct ids → distinct devices; ambiguous
unmatched id throws and surfaces the valid ids; single-device and
no-`logicalDeviceId` fallbacks still work.
- Full-stack multi-device test driving `Registry → JsRuntimeDebugger →
debugger-connect` against a two-device mock Metro: distinct `device_id`s
route to distinct devices; unmatched id is rejected.
- Full `tool-server` suite: **1203 passed**.
- Confirmed the real contract on a live iOS sim: `debugger-connect` with
the UDID still connects (single-device fallback) and returns
`logicalDeviceId a1956f…`.

## Notes

The three RN-only debugger tools (`debugger-component-tree`,
`debugger-inspect-element`, `debugger-reload-metro`) build their Metro
service ref via a raw template literal instead of
`debuggerServiceRef()`. For the Metro path the resulting URN is
byte-identical, so it's not part of this bug; left untouched to keep the
fix scoped.
2026-06-26 16:42:18 +02:00

123 lines
4.2 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { WebSocketServer, WebSocket } from "ws";
import * as http from "node:http";
import { Registry } from "@argent/registry";
import { jsRuntimeDebuggerBlueprint } from "../../src/blueprints/js-runtime-debugger";
import { debuggerConnectTool } from "../../src/tools/debugger/debugger-connect";
/**
* End-to-end regression for the "multiple debuggers all resolve to the same
* device despite being passed different device_id" bug.
*
* Stands up a mock Metro whose /json/list reports TWO distinct devices (each
* with its own logicalDeviceId + deviceName) on a single port, then drives the
* real Registry → JsRuntimeDebugger blueprint → debugger-connect path.
*/
let mockServer: http.Server;
let wss: WebSocketServer;
let mockPort: number;
let registry: Registry;
const DEVICES = [
{ logicalDeviceId: "logical-aaa", deviceName: "iPhone 16 Pro Max" },
{ logicalDeviceId: "logical-bbb", deviceName: "Pixel 9" },
];
function handleCDPMessage(ws: WebSocket, raw: string) {
const { id, method } = JSON.parse(raw) as { id: number; method: string };
switch (method) {
case "Debugger.enable":
ws.send(JSON.stringify({ id, result: { debuggerId: "mock" } }));
ws.send(
JSON.stringify({
method: "Debugger.scriptParsed",
params: {
scriptId: "1",
url: "http://localhost/index.bundle?platform=ios&dev=true",
startLine: 0,
endLine: 1,
sourceMapURL: "index.bundle.map",
},
})
);
break;
default:
ws.send(JSON.stringify({ id, result: {} }));
}
}
beforeAll(async () => {
await new Promise<void>((resolve) => {
mockServer = http.createServer((req, res) => {
if (req.url === "/status") {
res.setHeader("X-React-Native-Project-Root", "/mock/project");
res.end("packager-status:running");
return;
}
if (req.url === "/json/list") {
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify(
DEVICES.map((d, i) => ({
id: `page-${i}`,
title: `app (${d.deviceName})`,
description: "[C++ connection]",
webSocketDebuggerUrl: `ws://localhost:${mockPort}/inspector/debug?device=${d.logicalDeviceId}&page=1`,
deviceName: d.deviceName,
reactNative: {
logicalDeviceId: d.logicalDeviceId,
capabilities: { prefersFuseboxFrontend: true },
},
}))
)
);
return;
}
res.statusCode = 404;
res.end("Not found");
});
wss = new WebSocketServer({ server: mockServer });
wss.on("connection", (ws) => ws.on("message", (raw) => handleCDPMessage(ws, raw.toString())));
mockServer.listen(0, () => {
mockPort = (mockServer.address() as { port: number }).port;
resolve();
});
});
registry = new Registry();
registry.registerBlueprint(jsRuntimeDebuggerBlueprint);
registry.registerTool(debuggerConnectTool);
});
afterAll(async () => {
await registry.dispose();
await new Promise<void>((resolve) => wss.close(() => mockServer.close(() => resolve())));
});
describe("multi-device debugger routing (mock Metro, two devices)", () => {
it("routes each device_id to its own device", async () => {
const a = (await registry.invokeTool("debugger-connect", {
port: mockPort,
device_id: "logical-aaa",
})) as Record<string, unknown>;
const b = (await registry.invokeTool("debugger-connect", {
port: mockPort,
device_id: "logical-bbb",
})) as Record<string, unknown>;
expect(a.deviceName).toBe("iPhone 16 Pro Max");
expect(b.deviceName).toBe("Pixel 9");
// The two connections must NOT collapse onto the same device.
expect(a.logicalDeviceId).not.toBe(b.logicalDeviceId);
});
it("rejects an unmatched device_id instead of silently picking the first device", async () => {
await expect(
registry.invokeTool("debugger-connect", { port: mockPort, device_id: "not-a-real-device" })
).rejects.toThrow(/No debugger target matches device_id "not-a-real-device"/);
});
});