Files
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

186 lines
6.5 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { selectTarget } from "../../src/utils/debugger/target-selection";
import type { CDPTarget } from "../../src/utils/debugger/discovery";
function makeTarget(overrides: Partial<CDPTarget> = {}): CDPTarget {
return {
id: "page1",
title: "React Native",
description: "",
webSocketDebuggerUrl: "ws://localhost:8081/inspector/debug?device=0&page=1",
...overrides,
};
}
describe("selectTarget", () => {
it("prefers Fusebox target", () => {
const fusebox = makeTarget({
id: "fusebox",
reactNative: { capabilities: { prefersFuseboxFrontend: true } },
});
const legacy = makeTarget({ id: "legacy", title: "React Native Bridge (0)" });
const result = selectTarget([legacy, fusebox], 8081);
expect(result.target.id).toBe("fusebox");
expect(result.isNewDebugger).toBe(true);
});
it("falls back to C++ connection", () => {
const cpp = makeTarget({
id: "cpp",
description: "some [C++ connection]",
});
const legacy = makeTarget({ id: "legacy" });
const result = selectTarget([legacy, cpp], 8081);
expect(result.target.id).toBe("cpp");
expect(result.isNewDebugger).toBe(true);
});
it("falls back to React Native Bridge", () => {
const bridge = makeTarget({
id: "bridge",
title: "React Native Bridge (0)",
});
const other = makeTarget({ id: "other", title: "Other" });
const result = selectTarget([other, bridge], 8081);
expect(result.target.id).toBe("bridge");
expect(result.isNewDebugger).toBe(false);
});
it("falls back to first target", () => {
const t1 = makeTarget({ id: "first", title: "Unknown" });
const t2 = makeTarget({ id: "second", title: "Unknown 2" });
const result = selectTarget([t1, t2], 8081);
expect(result.target.id).toBe("first");
});
it("normalizes WebSocket URL to localhost with correct port", () => {
const target = makeTarget({
webSocketDebuggerUrl: "ws://10.0.2.2:9999/inspector/debug?device=0&page=1",
});
const result = selectTarget([target], 8081);
expect(result.webSocketUrl).toContain("localhost");
expect(result.webSocketUrl).toContain("8081");
expect(result.webSocketUrl).not.toContain("10.0.2.2");
expect(result.webSocketUrl).not.toContain("9999");
});
it("filters by deviceName when specified", () => {
const dev1 = makeTarget({ id: "dev1", deviceName: "iPhone 16" });
const dev2 = makeTarget({ id: "dev2", deviceName: "Pixel 9" });
const result = selectTarget([dev1, dev2], 8081, {
deviceName: "Pixel 9",
});
expect(result.target.id).toBe("dev2");
});
it("filters by deviceId when specified", () => {
const dev1 = makeTarget({
id: "dev1",
reactNative: { logicalDeviceId: "aaa" },
});
const dev2 = makeTarget({
id: "dev2",
reactNative: { logicalDeviceId: "bbb" },
});
const result = selectTarget([dev1, dev2], 8081, { deviceId: "bbb" });
expect(result.target.id).toBe("dev2");
});
it("routes distinct deviceIds to distinct devices (no collapse)", () => {
const dev1 = makeTarget({
id: "dev1",
reactNative: { logicalDeviceId: "aaa", capabilities: { prefersFuseboxFrontend: true } },
});
const dev2 = makeTarget({
id: "dev2",
reactNative: { logicalDeviceId: "bbb", capabilities: { prefersFuseboxFrontend: true } },
});
expect(selectTarget([dev1, dev2], 8081, { deviceId: "aaa" }).target.id).toBe("dev1");
expect(selectTarget([dev1, dev2], 8081, { deviceId: "bbb" }).target.id).toBe("dev2");
});
it("throws instead of silently collapsing when an unmatched deviceId is ambiguous", () => {
const dev1 = makeTarget({ id: "dev1", reactNative: { logicalDeviceId: "aaa" } });
const dev2 = makeTarget({ id: "dev2", reactNative: { logicalDeviceId: "bbb" } });
// An id that matches neither device must NOT resolve to the first target —
// that is the "all debuggers resolve to the same device" bug.
expect(() => selectTarget([dev1, dev2], 8081, { deviceId: "unmatched" })).toThrow(
/No debugger target matches device_id "unmatched"/
);
// The error must surface the valid ids so the caller can re-target.
expect(() => selectTarget([dev1, dev2], 8081, { deviceId: "unmatched" })).toThrow(/aaa.*bbb/);
});
it("surfaces deviceName alongside logicalDeviceId so the caller can map ids to devices", () => {
const ipad = makeTarget({
id: "dev1",
deviceName: "iPad Pro",
reactNative: { logicalDeviceId: "9e7844f7" },
});
const iphone = makeTarget({
id: "dev2",
deviceName: "iPhone 16",
reactNative: { logicalDeviceId: "8a44101d" },
});
// Each listed device shows its human-readable name next to the hash.
expect(() => selectTarget([ipad, iphone], 8081, { deviceId: "some-udid" })).toThrow(
/iPad Pro \(9e7844f7\).*iPhone 16 \(8a44101d\)/
);
});
it("falls back to the bare logicalDeviceId when a device has no deviceName", () => {
const named = makeTarget({
id: "dev1",
deviceName: "iPhone 16",
reactNative: { logicalDeviceId: "aaa" },
});
const unnamed = makeTarget({ id: "dev2", reactNative: { logicalDeviceId: "bbb" } });
expect(() => selectTarget([named, unnamed], 8081, { deviceId: "unmatched" })).toThrow(
/iPhone 16 \(aaa\).*bbb/
);
});
it("still falls back to the only device when a single device is connected", () => {
// Single-device convenience: callers may pass an id Metro never echoes (e.g.
// an iOS simulator UDID, which is not the logicalDeviceId). With one device
// there is no ambiguity, so selection must succeed via fallback.
const fusebox = makeTarget({
id: "only",
reactNative: {
logicalDeviceId: "real-logical-id",
capabilities: { prefersFuseboxFrontend: true },
},
});
const uiPage = makeTarget({
id: "only-ui",
reactNative: { logicalDeviceId: "real-logical-id" },
});
const result = selectTarget([fusebox, uiPage], 8081, { deviceId: "some-ios-udid" });
expect(result.target.id).toBe("only");
});
it("falls back when no target exposes a logicalDeviceId at all", () => {
// Old-arch / pre-Fusebox targets omit reactNative.logicalDeviceId entirely.
const t1 = makeTarget({ id: "first" });
const t2 = makeTarget({
id: "second",
reactNative: { capabilities: { prefersFuseboxFrontend: true } },
});
const result = selectTarget([t1, t2], 8081, { deviceId: "anything" });
expect(result.target.id).toBe("second");
});
});