Files
software-mansion__argent/packages/tool-server/test/metro/device-alias.test.ts
filip131311 66e051fe8b fix(debugger): collapse a device's two ids onto one debugger instance (#528)
## The problem

A device is reached through **two id namespaces** that name the same
thing but are different strings:

- the **stable id** the caller connects with — an iOS UDID / Android
serial / Vega serial from `list-devices`;
- the **`logicalDeviceId`** Metro's inspector-proxy echoes back for that
connection (an opaque per-connection handle).

`logicalDeviceId` is not derived by Argent — it is echoed verbatim from
Metro (`js-runtime-debugger.ts`), and Metro never sees the UDID, so
there is **nothing to join on synchronously**.

The debugger service is cached by its URN, which embeds `device_id`
verbatim:

```
JsRuntimeDebugger:<port>:<device_id>
```

`debugger-connect` took the UDID, returned the logicalDeviceId, and its
description told callers to *"forward it as device_id to all subsequent
debugger-\* calls."* Following that instruction produced a **different
URN** → a **second `JsRuntimeDebugger` instance** → a second
`CDPClient`, a second console-log server, and a split log file — **all
for one device**. The second attach also kicks the first off Metro's
single-debugger-per-target proxy. Nothing deduplicated them.

It only *looked* fine because with a single device on a Metro port,
`selectTarget`'s single-device fallback quietly connects anyway; the
split surfaces as "the debugger randomly drops" and logs landing in the
wrong bucket.

Secondary damage the same split caused:
- `classifyDevice()` is shape-based and was fed the logicalDeviceId → an
iOS device's forwarded id classified as **Android** in the capability
gate + telemetry.
- The docs asserted *"iOS simulator UDID, Android logicalDeviceId"* as
if a UDID were the iOS logicalDeviceId — the repo's own test says the
opposite.
- `debugger-component-tree` printed the logicalDeviceId under a `udid:`
label — teaching callers the wrong name.

## The fix — synchronous, within the existing `services()` design

- **Learn an alias** `logicalDeviceId → connect-id` as a side effect of
a successful connect (the one place both ids are known at once; see
`utils/debugger/device-alias.ts`), and **canonicalize `device_id`
through it at every URN-building site**. A forwarded logicalDeviceId now
resolves to the already-open instance; unknown ids (the connect id
itself, Chromium ids) pass through unchanged, so it is safe to call
everywhere. **No Metro round-trip on the hot path — `services()` stays
synchronous.** The alias is 1:1 (a logicalDeviceId is unique per
connection) and is cleared on dispose.
- **Rewrite the `device_id` docs** across `debugger-*`, `network-*`, and
`react-profiler-*`: pass the **same list-devices id you connected
with**; the returned logicalDeviceId is informational (forwarding it
still resolves, via the alias). Removes the instruction that caused the
double instance and the false "UDID = logicalDeviceId" wording.
- **Fix the `debugger-component-tree` header** (`udid:` →
`logicalDeviceId:`).

Covered call sites (all that embed `device_id` into a Metro URN):
`debuggerServiceRef` (connect/evaluate/status/log-registry),
`debugger-reload-metro`, `debugger-component-tree`,
`debugger-inspect-element`, `react-profiler-start` (+ its session URN),
`react-profiler-stop`, `network-logs`, `network-request`.

## Verification

**Live**, against a real iOS sim + Metro (my-app, RN 0.81.5), driving
the branch tool-server directly:

| step | result | URNs instantiated |
|---|---|---|
| `debugger-connect { device_id: <UDID> }` | returns `logicalDeviceId:
d52ad36b…` | `JsRuntimeDebugger:8081:<UDID>` |
| `debugger-evaluate { device_id: d52ad36b… }` (forward the
logicalDeviceId) | `42` ✅ | **no new URN — reused** |
| counterfactual: `debugger-evaluate { device_id: some-unaliased-id }` |
`42` ✅ | **`JsRuntimeDebugger:8081:some-unaliased-id` — a distinct 2nd
instance** (the pre-fix behavior, reproduced) |
| `debugger-component-tree { device_id: <UDID> }` | header reads
`logicalDeviceId: d52ad36b…` ✅ | reused |

**Unit** — new `test/metro/device-alias.test.ts` covers the alias (learn
/ canonicalize / self-alias no-op / missing-id / forget) and that
`debuggerServiceRef` yields the same URN whether called with the UDID or
the forwarded logicalDeviceId. Full tool-server suite green (2726
tests).

## Not in scope

Renaming the `device_id` param to `udid` (or vice-versa) — that is a
breaking MCP wire change. This PR is additive: a forwarded
logicalDeviceId keeps working, it just no longer opens a second
connection.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:21:56 +02:00

66 lines
2.6 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vitest";
import {
rememberDeviceAlias,
canonicalDeviceId,
forgetDeviceAlias,
resetDeviceAliases,
} from "../../src/utils/debugger/device-alias";
import { debuggerServiceRef } from "../../src/tools/debugger/debugger-service-ref";
const IOS_UDID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA";
const LOGICAL_ID = "8a44101d";
const CHROMIUM_ID = "chromium-cdp-19222";
describe("device-alias — canonicalizing a forwarded logicalDeviceId", () => {
beforeEach(() => resetDeviceAliases());
it("passes an unknown id through unchanged", () => {
expect(canonicalDeviceId(IOS_UDID)).toBe(IOS_UDID);
expect(canonicalDeviceId(LOGICAL_ID)).toBe(LOGICAL_ID);
expect(canonicalDeviceId(undefined)).toBeUndefined();
});
it("maps a learned logicalDeviceId back to the id its device connected with", () => {
rememberDeviceAlias(LOGICAL_ID, IOS_UDID);
expect(canonicalDeviceId(LOGICAL_ID)).toBe(IOS_UDID);
// the connect id itself is still a no-op
expect(canonicalDeviceId(IOS_UDID)).toBe(IOS_UDID);
});
it("never records a self-alias (Chromium, where logicalDeviceId === device id)", () => {
rememberDeviceAlias(CHROMIUM_ID, CHROMIUM_ID);
expect(canonicalDeviceId(CHROMIUM_ID)).toBe(CHROMIUM_ID);
});
it("ignores a missing logicalDeviceId (Vega / legacy inspector)", () => {
rememberDeviceAlias(undefined, IOS_UDID);
expect(canonicalDeviceId(IOS_UDID)).toBe(IOS_UDID);
});
it("drops the alias on forget so a reconnect is not shadowed", () => {
rememberDeviceAlias(LOGICAL_ID, IOS_UDID);
forgetDeviceAlias(LOGICAL_ID);
expect(canonicalDeviceId(LOGICAL_ID)).toBe(LOGICAL_ID);
});
});
describe("debuggerServiceRef — collapses a forwarded logicalDeviceId onto one URN", () => {
beforeEach(() => resetDeviceAliases());
it("keys the service by the connect id whether called with the UDID or the logicalDeviceId", () => {
const connectRef = debuggerServiceRef({ port: 8081, device_id: IOS_UDID });
expect(connectRef).toBe(`JsRuntimeDebugger:8081:${IOS_UDID}`);
// After connect learns Metro's handle, a later call that forwards it must
// resolve to the SAME URN — otherwise a second CDP connection is opened.
rememberDeviceAlias(LOGICAL_ID, IOS_UDID);
const forwardedRef = debuggerServiceRef({ port: 8081, device_id: LOGICAL_ID });
expect(forwardedRef).toBe(connectRef);
});
it("does not disturb Chromium routing", () => {
const ref = debuggerServiceRef({ port: 8081, device_id: CHROMIUM_ID });
expect(ref).toMatchObject({ urn: expect.stringContaining(CHROMIUM_ID) });
});
});