mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
71344c40b9
## 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.
186 lines
6.5 KiB
TypeScript
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");
|
|
});
|
|
});
|