test(proxy): cover the CLI and TUI bottom-of-stack wiring

Addresses review round 5 on #2099 (three suppressed comments, no posted ones).

The `fetch: createProxyFetch()` line in each Node client is the whole of that
client's proxy support — it is what puts the proxy at the BOTTOM of the fetch
stack, where InspectorClient's wrappers compose over it rather than discarding
it. `InspectorClient` sets `fetchFn` unconditionally, so the transport-level
fallback never fires for these clients. Both lines could have been deleted with
every existing test still green.

- CLI: a test that runs a real `tools/list` against a real HTTP test server with
  HTTP_PROXY pointed at a real forwarding proxy, asserting the request traversed
  it. Behavioral rather than structural on purpose — the defect this replaces was
  a runtime dispatch failure no structural assertion would have caught. It clears
  all six proxy vars first so an ambient value cannot route it elsewhere.

- TUI: the mock added earlier always returned undefined, so the proxy branch was
  never exercised. `createProxyFetch`'s return is now controllable, and two tests
  assert the sentinel reaches `environment.fetch` when a proxy is configured and
  that the slot stays unset when one is not.

Both were confirmed to fail with their client's wiring line removed.

Also updates the `InspectorClientEnvironment.fetch` doc in core/mcp/types.ts,
which still told callers that Node leaves this undefined and uses the global
fetch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 22:32:27 -04:00
commit 91e08067dc

View file

@ -102,7 +102,14 @@ const h = vi.hoisted(() => {
// handler for the event (the common single-server case).
type EventEntry = { client: unknown; fn: (event: unknown) => void };
const clientEvents = new Map<string, Set<EventEntry>>();
const clientInstances: Array<{ cfg?: { type?: string; url?: string } }> = [];
const clientInstances: Array<{
cfg?: { type?: string; url?: string };
opts?: { environment?: { fetch?: unknown } };
}> = [];
// What the mocked `createProxyFetch` returns. `undefined` is the real
// behavior with no proxy env var set; a test points this at a sentinel to
// exercise the proxy-configured branch (#2067).
const proxyFetchState: { current: unknown } = { current: undefined };
const fireClientEvent = (event: string, detail?: unknown) => {
clientEvents.get(event)?.forEach((e) => e.fn({ detail }));
};
@ -127,8 +134,15 @@ const h = vi.hoisted(() => {
}
class FakeClient {
cfg: { type?: string; url?: string } | undefined;
constructor(config?: { type?: string; url?: string }) {
// Kept so a test can assert what App.tsx put in the environment — the proxy
// fetch wiring (#2067) is otherwise invisible to these mock-driven tests.
opts: { environment?: { fetch?: unknown } } | undefined;
constructor(
config?: { type?: string; url?: string },
opts?: { environment?: { fetch?: unknown } },
) {
this.cfg = config;
this.opts = opts;
clientInstances.push(this);
}
// Derive the transport type from the server config the client was built
@ -183,6 +197,7 @@ const h = vi.hoisted(() => {
}
return {
ctrl,
proxyFetchState,
connect,
disconnect,
openUrl,
@ -232,10 +247,12 @@ vi.mock("@inspector/core/mcp/state/index.js", () => ({
}));
vi.mock("@inspector/core/mcp/node/index.js", () => ({
createTransportNode: vi.fn(),
// Returns undefined for "no proxy configured", which is what the real one
// does with no proxy env var set — so `environment.fetch` stays unset and the
// client falls back to the built-in fetch, exactly as before #2067.
createProxyFetch: vi.fn(() => undefined),
// Defaults to undefined — "no proxy configured", which is what the real one
// returns with no proxy env var set, so `environment.fetch` stays unset and
// the client falls back to the built-in fetch, exactly as before #2067. A
// test can point `h.proxyFetchResult` at a sentinel to exercise the other
// branch.
createProxyFetch: vi.fn(() => h.proxyFetchState.current),
}));
vi.mock("@inspector/core/react/useInspectorClient.js", () => ({
useInspectorClient: h.useInspectorClient,
@ -637,6 +654,7 @@ beforeEach(() => {
h.callbackStop.mockClear();
h.clientEvents.clear();
h.clientInstances.length = 0;
h.proxyFetchState.current = undefined;
h.runner.override = null;
h.clientSpies.authenticate.mockReset();
h.clientSpies.authenticate.mockResolvedValue(
@ -1612,6 +1630,27 @@ describe("App (OAuth result branches)", () => {
await expectFrame(r, "Authorization updated. Retry your action");
});
it("installs the proxy fetch as environment.fetch when a proxy is configured", async () => {
// The bottom-of-stack wiring for #2067. It is one line in App.tsx and it is
// what makes InspectorClient's own wrappers compose OVER the proxy instead
// of discarding it — delete it and TUI proxy support silently disappears
// while every other test stays green.
const sentinel = (async () => new Response("")) as unknown as typeof fetch;
h.proxyFetchState.current = sentinel;
await mount(oneHttp());
expect(h.clientInstances.length).toBeGreaterThan(0);
expect(h.clientInstances[0]!.opts?.environment?.fetch).toBe(sentinel);
});
it("leaves environment.fetch unset when no proxy is configured", async () => {
// The default path must stay on the built-in fetch — a wrapper installed
// unconditionally would put every TUI user behind an undici fetch.
h.proxyFetchState.current = undefined;
await mount(oneHttp());
expect(h.clientInstances.length).toBeGreaterThan(0);
expect(h.clientInstances[0]!.opts?.environment?.fetch).toBeUndefined();
});
it("ignores auth lifecycle events from a non-selected server", async () => {
const r = await mount(twoHttp()); // web is selected; api is not
await press(r, ["a"]);