Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 3e459b7

Browse files
william1616connexinwillmrbbot
authored
Add Support for Mocking Requests using Undici MockAgent (#293)
* Suggested changes from #293 - all tests passing * add core module tests * WIP jest testing * make suggested changes * Fix linting/formatting issues and add additional unmocked test case Co-authored-by: Will Kebbell <[email protected]> Co-authored-by: bcoll <[email protected]>
1 parent c776fe4 commit 3e459b7

File tree

12 files changed

+95
-11
lines changed

12 files changed

+95
-11
lines changed

packages/core/src/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
import type { Watcher } from "@miniflare/watcher";
3232
import { dequal } from "dequal/lite";
3333
import { dim } from "kleur/colors";
34+
import { MockAgent } from "undici";
3435
import { MiniflareCoreError } from "./error";
3536
import { formatSize, pathsToString } from "./helpers";
3637
import {
@@ -273,6 +274,7 @@ export class MiniflareCore<
273274
#watcher?: Watcher;
274275
#watcherCallbackMutex?: Mutex;
275276
#previousWatchPaths?: Set<string>;
277+
#previousFetchMock?: MockAgent;
276278

277279
constructor(
278280
plugins: Plugins,
@@ -376,12 +378,18 @@ export class MiniflareCore<
376378

377379
// Build compatibility manager, rebuild all plugins if reloadAll is set,
378380
// compatibility data, root path or any limits have changed
379-
const { compatibilityDate, compatibilityFlags, usageModel, globalAsyncIO } =
380-
options.CorePlugin;
381+
const {
382+
compatibilityDate,
383+
compatibilityFlags,
384+
usageModel,
385+
globalAsyncIO,
386+
fetchMock,
387+
} = options.CorePlugin;
381388
let ctxUpdate =
382389
(this.#previousRootPath && this.#previousRootPath !== rootPath) ||
383390
this.#previousUsageModel !== usageModel ||
384391
this.#previousGlobalAsyncIO !== globalAsyncIO ||
392+
this.#previousFetchMock !== fetchMock ||
385393
reloadAll;
386394
this.#previousRootPath = rootPath;
387395

@@ -398,6 +406,7 @@ export class MiniflareCore<
398406
rootPath,
399407
usageModel,
400408
globalAsyncIO,
409+
fetchMock,
401410
};
402411

403412
// Log options and compatibility flags every time they might've changed

packages/core/src/plugins/core.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import {
3838
SetupResult,
3939
globsToMatcher,
4040
} from "@miniflare/shared";
41-
import { File, FormData, Headers } from "undici";
41+
import { File, FormData, Headers, MockAgent } from "undici";
4242
// @ts-expect-error `urlpattern-polyfill` only provides global types
4343
import { URLPattern } from "urlpattern-polyfill";
4444
import { MiniflareCoreError } from "../error";
@@ -61,6 +61,7 @@ import {
6161
createCrypto,
6262
createDate,
6363
createTimer,
64+
fetch,
6465
withStringFormDataFiles,
6566
} from "../standards";
6667
import { assertsInRequest } from "../standards/helpers";
@@ -111,6 +112,7 @@ export interface CoreOptions {
111112
name?: string;
112113
routes?: string[];
113114
logUnhandledRejections?: boolean;
115+
fetchMock?: MockAgent;
114116
globalAsyncIO?: boolean;
115117
globalTimers?: boolean;
116118
globalRandom?: boolean;
@@ -344,6 +346,9 @@ export class CorePlugin extends Plugin<CoreOptions> implements CoreOptions {
344346
@Option({ type: OptionType.NONE })
345347
logUnhandledRejections?: boolean;
346348

349+
@Option({ type: OptionType.NONE })
350+
fetchMock?: MockAgent;
351+
347352
@Option({
348353
type: OptionType.BOOLEAN,
349354
name: "global-async-io",
@@ -458,7 +463,7 @@ export class CorePlugin extends Plugin<CoreOptions> implements CoreOptions {
458463
TextDecoder,
459464
TextEncoder,
460465

461-
fetch: createCompatFetch(ctx),
466+
fetch: createCompatFetch(ctx, fetch.bind(this.fetchMock)),
462467
Headers,
463468
Request: CompatRequest,
464469
Response: CompatResponse,

packages/core/src/standards/http.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
File,
3535
FormData,
3636
Headers,
37+
MockAgent,
3738
ReferrerPolicy,
3839
RequestCache,
3940
RequestCredentials,
@@ -750,6 +751,7 @@ class MiniflareDispatcher extends Dispatcher {
750751
}
751752

752753
export async function fetch(
754+
this: Dispatcher | void,
753755
input: RequestInfo,
754756
init?: RequestInit
755757
): Promise<Response> {
@@ -796,7 +798,7 @@ export async function fetch(
796798
// TODO: instead of using getGlobalDispatcher() here, we could allow a custom
797799
// one to be passed for easy mocking
798800
const dispatcher = new MiniflareDispatcher(
799-
getGlobalDispatcher(),
801+
this instanceof Dispatcher ? this : getGlobalDispatcher(),
800802
removeHeaders
801803
);
802804
const baseRes = await baseFetch(req, { dispatcher });
@@ -825,6 +827,10 @@ export async function fetch(
825827
return withInputGating(res);
826828
}
827829

830+
export function createFetchMock() {
831+
return new MockAgent();
832+
}
833+
828834
/** @internal */
829835
export function _urlFromRequestInput(input: RequestInfo): URL {
830836
if (input instanceof URL) return input;

packages/core/src/standards/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export {
1919
_getBodyLength,
2020
_kLoopHeader,
2121
fetch,
22+
createFetchMock,
2223
_urlFromRequestInput,
2324
_buildUnknownProtocolWarning,
2425
createCompatFetch,

packages/core/test/index.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
RequestInfo,
1818
RequestInit,
1919
_deepEqual,
20+
createFetchMock,
2021
} from "@miniflare/core";
2122
import { DurableObjectsPlugin } from "@miniflare/durable-objects";
2223
import { HTTPPlugin, createServer } from "@miniflare/http-server";
@@ -1109,6 +1110,38 @@ test("MiniflareCore: dispatchFetch: fetching incoming request responds with upst
11091110
const res = await mf.dispatchFetch("https://random.mf/");
11101111
t.is(await res.text(), "upstream");
11111112
});
1113+
test("MiniflareCore: dispatchFetch: fetching incoming request with mocking enabled, but un-mocked upstream", async (t) => {
1114+
const upstream = (await useServer(t, (req, res) => res.end("upstream"))).http;
1115+
const mockAgent = createFetchMock();
1116+
const mf = useMiniflareWithHandler(
1117+
{},
1118+
{ upstream: upstream.toString(), fetchMock: mockAgent },
1119+
(globals, req) => globals.fetch(req)
1120+
);
1121+
const res = await mf.dispatchFetch("https://random.mf/");
1122+
t.is(await res.text(), "upstream");
1123+
// Disabling net connect should throw as upstream hasn't been mocked
1124+
mockAgent.disableNetConnect();
1125+
try {
1126+
await mf.dispatchFetch("https://random.mf/");
1127+
t.fail();
1128+
} catch (e: any) {
1129+
t.is(e.cause.code, "UND_MOCK_ERR_MOCK_NOT_MATCHED");
1130+
}
1131+
});
1132+
test("MiniflareCore: dispatchFetch: fetching incoming request with mocked upstream", async (t) => {
1133+
const mockAgent = createFetchMock();
1134+
mockAgent.disableNetConnect();
1135+
const client = mockAgent.get("https://random.mf");
1136+
client.intercept({ path: "/" }).reply(200, "Hello World!");
1137+
const mf = useMiniflareWithHandler(
1138+
{},
1139+
{ fetchMock: mockAgent },
1140+
(globals, req) => globals.fetch(req)
1141+
);
1142+
const res = await mf.dispatchFetch("https://random.mf/");
1143+
t.is(await res.text(), "Hello World!");
1144+
});
11121145
test("MiniflareCore: dispatchFetch: request gets immutable headers", async (t) => {
11131146
const mf = useMiniflareWithHandler({}, {}, (globals, req) => {
11141147
req.headers.delete("content-type");

packages/core/test/plugins/core.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ test("CorePlugin: parses options from wrangler config", async (t) => {
204204
upstream: "https://miniflare.dev",
205205
watch: true,
206206
debug: undefined,
207+
fetchMock: undefined,
207208
verbose: undefined,
208209
updateCheck: false,
209210
repl: undefined,

packages/jest-environment-miniflare/src/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import type {
77
import { LegacyFakeTimers, ModernFakeTimers } from "@jest/fake-timers";
88
import type { Circus, Config, Global } from "@jest/types";
99
import { CachePlugin } from "@miniflare/cache";
10-
import { BindingsPlugin, CorePlugin, MiniflareCore } from "@miniflare/core";
10+
import {
11+
BindingsPlugin,
12+
CorePlugin,
13+
MiniflareCore,
14+
createFetchMock,
15+
} from "@miniflare/core";
1116
import {
1217
DurableObjectId,
1318
DurableObjectStorage,
@@ -22,13 +27,15 @@ import { SitesPlugin } from "@miniflare/sites";
2227
import { WebSocketPlugin } from "@miniflare/web-sockets";
2328
import { ModuleMocker } from "jest-mock";
2429
import { installCommonGlobals } from "jest-util";
30+
import { MockAgent } from "undici";
2531
import { StackedMemoryStorageFactory } from "./storage";
2632

2733
declare global {
2834
function getMiniflareBindings<Bindings = Context>(): Bindings;
2935
function getMiniflareDurableObjectStorage(
3036
id: DurableObjectId
3137
): Promise<DurableObjectStorage>;
38+
function getMiniflareFetchMock(): MockAgent;
3239
function flushMiniflareDurableObjectAlarms(
3340
ids: DurableObjectId[]
3441
): Promise<void>;
@@ -74,6 +81,7 @@ export default class MiniflareEnvironment implements JestEnvironment<Timer> {
7481

7582
private readonly storageFactory = new StackedMemoryStorageFactory();
7683
private readonly scriptRunner: VMScriptRunner;
84+
private readonly mockAgent: MockAgent;
7785

7886
constructor(
7987
config:
@@ -94,6 +102,8 @@ export default class MiniflareEnvironment implements JestEnvironment<Timer> {
94102
defineHasInstances(this.context);
95103
this.scriptRunner = new VMScriptRunner(this.context);
96104

105+
this.mockAgent = createFetchMock();
106+
97107
const global = (this.global = vm.runInContext("this", this.context));
98108
global.global = global;
99109
global.self = global;
@@ -205,6 +215,8 @@ export default class MiniflareEnvironment implements JestEnvironment<Timer> {
205215
// context, so we'd be returning the actual time anyway, and this
206216
// might mess with Jest's own mocking.
207217
actualTime: true,
218+
// - We always want getMiniflareFetchMock() to return this MockAgent
219+
fetchMock: this.mockAgent,
208220
}
209221
);
210222

@@ -226,6 +238,7 @@ export default class MiniflareEnvironment implements JestEnvironment<Timer> {
226238
const state = await plugin.getObject(storage, id);
227239
return state.storage;
228240
};
241+
global.getMiniflareFetchMock = () => this.mockAgent;
229242
global.flushMiniflareDurableObjectAlarms = async (
230243
ids?: DurableObjectId[]
231244
): Promise<void> => {
Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import worker from "./module-worker";
22

33
test("handles requests", async () => {
4-
const res = worker.fetch(new Request("http://localhost/"));
4+
const res = await worker.fetch(new Request("http://localhost/"));
55
expect(await res.text()).toBe("fetch:http://localhost/");
66
});
7+
8+
test("handles requests with mocked upstream", async () => {
9+
const mockAgent = getMiniflareFetchMock();
10+
mockAgent.disableNetConnect();
11+
const client = mockAgent.get("https://random.mf");
12+
client.intercept({ path: "/" }).reply(200, "Hello World!");
13+
const res = await fetch(new Request("https://random.mf"));
14+
expect(await res.text()).toBe("Hello World!");
15+
});

packages/jest-environment-miniflare/test/fixtures/module-worker.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export class TestObject {
1010
}
1111

1212
export default {
13-
fetch(request) {
13+
async fetch(request) {
1414
return new Response(`fetch:${request.url}`);
1515
},
1616
};

packages/shared/src/plugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { MockAgent } from "undici";
12
import { Compatibility } from "./compat";
23
import { titleCase } from "./data";
34
import { Log } from "./log";
@@ -94,6 +95,7 @@ export interface PluginContext {
9495
rootPath: string;
9596
usageModel?: UsageModel;
9697
globalAsyncIO?: boolean;
98+
fetchMock?: MockAgent;
9799
}
98100

99101
export abstract class Plugin<Options extends Context = never> {

0 commit comments

Comments
 (0)