fix(fetch): honor mocked global fetch with dispatchers

This commit is contained in:
Vincent Koc
2026-04-05 08:41:49 +01:00
parent ef5f47bd39
commit 49f52ddf36
2 changed files with 61 additions and 10 deletions

View File

@@ -233,9 +233,11 @@ describe("fetchWithSsrFGuard hardening", () => {
it("uses runtime undici fetch when attaching a dispatcher", async () => {
const runtimeFetch = vi.fn(async () => okResponse());
const originalGlobalFetch = globalThis.fetch;
const globalFetch = vi.fn(async () => {
let globalFetchCalls = 0;
const globalFetch = async () => {
globalFetchCalls += 1;
throw new Error("global fetch should not be used when a dispatcher is attached");
});
};
class MockAgent {
constructor(readonly options: unknown) {}
@@ -262,7 +264,46 @@ describe("fetchWithSsrFGuard hardening", () => {
});
expect(runtimeFetch).toHaveBeenCalledTimes(1);
expect(globalFetch).not.toHaveBeenCalled();
expect(globalFetchCalls).toBe(0);
await result.release();
} finally {
(globalThis as Record<string, unknown>).fetch = originalGlobalFetch;
}
});
it("uses mocked global fetch when tests stub it", async () => {
const runtimeFetch = vi.fn(async () => {
throw new Error("runtime fetch should not be used when global fetch is mocked");
});
const originalGlobalFetch = globalThis.fetch;
const globalFetch = vi.fn(async () => okResponse());
class MockAgent {
constructor(readonly options: unknown) {}
}
class MockEnvHttpProxyAgent {
constructor(readonly options: unknown) {}
}
class MockProxyAgent {
constructor(readonly options: unknown) {}
}
(globalThis as Record<string, unknown>).fetch = globalFetch as typeof fetch;
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
Agent: MockAgent,
EnvHttpProxyAgent: MockEnvHttpProxyAgent,
ProxyAgent: MockProxyAgent,
fetch: runtimeFetch,
};
try {
const result = await fetchWithSsrFGuard({
url: "https://public.example/resource",
lookupFn: createPublicLookup(),
});
expect(globalFetch).toHaveBeenCalledTimes(1);
expect(runtimeFetch).not.toHaveBeenCalled();
await result.release();
} finally {
(globalThis as Record<string, unknown>).fetch = originalGlobalFetch;

View File

@@ -158,6 +158,13 @@ function isRedirectStatus(status: number): boolean {
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
}
function isMockedFetch(fetchImpl: FetchLike | undefined): boolean {
if (typeof fetchImpl !== "function") {
return false;
}
return typeof (fetchImpl as FetchLike & { mock?: unknown }).mock === "object";
}
export function retainSafeHeadersForCrossOriginRedirectHeaders(
headers?: HeadersInit,
): Record<string, string> | undefined {
@@ -296,14 +303,17 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
const supportsDispatcherInit =
params.fetchImpl !== undefined ||
isMockedFetch(defaultFetch) ||
(defaultFetch as DispatcherCompatibleFetch).__openclawAcceptsDispatcher === true;
// Use caller-provided fetch stubs when present; otherwise fall back to
// undici's fetch whenever we attach a dispatcher because the global fetch
// path will not honor per-request dispatchers.
const response =
dispatcher && !supportsDispatcherInit
? await fetchWithRuntimeDispatcher(parsedUrl.toString(), init)
: await defaultFetch(parsedUrl.toString(), init);
// Explicit caller stubs, test-installed global fetch mocks, and
// dispatcher-aware wrappers should win.
// Otherwise, fall back to undici's fetch whenever we attach a dispatcher,
// because the default global fetch path will not honor per-request
// dispatchers.
const shouldUseRuntimeFetch = Boolean(dispatcher) && !supportsDispatcherInit;
const response = shouldUseRuntimeFetch
? await fetchWithRuntimeDispatcher(parsedUrl.toString(), init)
: await defaultFetch(parsedUrl.toString(), init);
if (isRedirectStatus(response.status)) {
const location = response.headers.get("location");