Skip to content

feat(wrangler): createTestHarness API#14169

Open
edmundhung wants to merge 14 commits into
mainfrom
edmundhung/wrangler-create-server
Open

feat(wrangler): createTestHarness API#14169
edmundhung wants to merge 14 commits into
mainfrom
edmundhung/wrangler-create-server

Conversation

@edmundhung
Copy link
Copy Markdown
Member

@edmundhung edmundhung commented Jun 3, 2026

Fixes n/a.

This introduces createTestHarness() for integration testing Workers

It runs Workers in a local preview environment using production build output and works with both Wrangler projects and Workers built by the Cloudflare Vite plugin.

Use it from any Node.js test runner to send requests to individual Workers, trigger scheduled events, reset the server between tests, and mock outbound requests with libraries that intercept globalThis.fetch(), such as MSW:

import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, describe, test } from "vitest";
import { createTestHarness } from "wrangler";

// Vite builds each Worker to a generated Wrangler config. Test those generated
// configs instead of the source configs you pass to the Vite plugin.
const server = createTestHarness({
    workers: [
        { configPath: "./dist/web_worker/wrangler.json" },
        { configPath: "./dist/api_worker/wrangler.json" },
    ],
});

// server.getWorker() would return the first Worker, but naming it makes it explicit.
const webWorker = server.getWorker("web-worker");
const apiWorker = server.getWorker("api-worker");

// createServer's route Workers outbound fetches to globalThis.fetch().
// You can use libraries like MSW to intercept those requests.
const mock = setupServer();

describe("createServer: vite project setup", () => {
    beforeAll(async () => {
        mock.listen({ onUnhandledRequest: "error" });
        await server.listen();
    });

    afterAll(async () => {
        mock.close();
        await server.close();
    });

    afterEach(async () => {
        // Keep tests isolated while reusing the same running server.
        mock.resetHandlers();
        await server.reset();
    });

    test("fetches the primary Worker with a relative URL", async ({ expect }) => {
        // Relative URLs are dispatched to the primary Worker (the first one listed).
        const response = await server.fetch("/");
        expect(await response.text()).toBe("Hello World");
    });

    test("mocks outbound requests", async ({ expect }) => {
        mock.use(
            http.get("http://upstream.example.com/users/:id", ({ params }) => {
                return HttpResponse.json({ id: params.id, name: "Ada Lovelace" });
            })
        );

        const userResponse = await apiWorker.fetch(
            "http://example.com/api/users/123"
        );
        expect(await userResponse.json()).toEqual({
            id: "123",
            name: "Ada Lovelace",
        });
    });

    test("dispatches requests using configured routes", async ({ expect }) => {
        mock.use(
            http.get("http://upstream.example.com/users/:id", ({ params }) => {
                return HttpResponse.json({ id: params.id, name: "Ada Lovelace" });
            })
        );

        // server.fetch() matches requests to workers based on routes.
        const apiResponse = await server.fetch("http://example.com/api/users/123");
        expect(await apiResponse.json()).toEqual({
            id: "123",
            name: "Ada Lovelace",
        });

        const webResponse = await server.fetch("http://example.com/users/123");
        expect(await webResponse.text()).toBe("Profile: Ada Lovelace");
    });

    test("runs scheduled jobs and stores the result", async ({ expect }) => {
        mock.use(
            http.get("http://upstream.example.com/users/:id", ({ params }) => {
                return HttpResponse.json({ id: params.id, name: "Ada Lovelace" });
            })
        );

        // Seed user data that the scheduled job will read to generate a report.
        await apiWorker.fetch("http://example.com/api/users/123");
        await apiWorker.fetch("http://example.com/api/users/456");

        const initialResponse = await webWorker.fetch(
            "http://example.com/reports/2026-05-29"
        );
        expect(initialResponse.status).toBe(404);
        expect(await initialResponse.text()).toBe("No report");

        expect(
            await apiWorker.scheduled({
                cron: "0 0 * * *",
                scheduledTime: new Date("2026-05-29T00:00:00.000Z"),
            })
        ).toEqual({ outcome: "ok", noRetry: false });

        const webResponse = await webWorker.fetch(
            "http://example.com/reports/2026-05-29"
        );
        expect(await webResponse.text()).toBe(
            "Daily report (2026-05-29): active users 123, 456"
        );
    });
});

  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: Documentation will come later.

A picture of a cute animal (not mandatory, but encouraged)

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Jun 3, 2026

🦋 Changeset detected

Latest commit: c30299f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
wrangler Minor
@cloudflare/vite-plugin Patch
@cloudflare/vitest-pool-workers Patch
@cloudflare/wrangler-bundler Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@edmundhung edmundhung marked this pull request as ready for review June 3, 2026 11:53
@github-project-automation github-project-automation Bot moved this to Untriaged in workers-sdk Jun 3, 2026
@workers-devprod workers-devprod requested review from a team and james-elicx and removed request for a team June 3, 2026 11:53
@workers-devprod
Copy link
Copy Markdown
Contributor

workers-devprod commented Jun 3, 2026

Codeowners approval required for this PR:

  • ✅ @cloudflare/wrangler
Show detailed file reviewers

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 3, 2026

✅ All changesets look good

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 3, 2026

Review posted successfully to PR #14169.

Summary of what I flagged:

  1. scheduled() timestamp type mismatch (server.ts:596) — calls scheduledTime.getTime() assuming a Date, but the public FetcherScheduledOptions.scheduledTime is typed as number, so a user following the type signature would hit a runtime crash. Provided a suggestion to handle both.

  2. Default outboundService streaming bodies (server.ts:332) — globalThis.fetch(request.url, request) may throw on outbound POST requests with streaming bodies due to undici's duplex requirement. Suggested forwarding the Request directly. Marked non-blocking.

github run

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 3, 2026

create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@14169

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/@cloudflare/deploy-helpers@14169

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@14169

miniflare

npm i https://pkg.pr.new/miniflare@14169

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@14169

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@14169

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@14169

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@14169

@cloudflare/workers-auth

npm i https://pkg.pr.new/@cloudflare/workers-auth@14169

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@14169

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@14169

wrangler

npm i https://pkg.pr.new/wrangler@14169

@cloudflare/wrangler-bundler

npm i https://pkg.pr.new/@cloudflare/wrangler-bundler@14169

commit: c30299f

devin-ai-integration[bot]

This comment was marked as resolved.

@edmundhung edmundhung force-pushed the edmundhung/wrangler-create-server branch from 32239cf to e906464 Compare June 3, 2026 19:32
devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Member

@dario-piotrowicz dario-piotrowicz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great to me! 🤩

I just left some minor comments

(It is a late in the day today, I might have missed some bits, I might have another review tomorrow as well to go a bit more in depth in a few parts 😄 )

Comment thread packages/wrangler/e2e/create-server.test.ts Outdated
Comment thread packages/wrangler/e2e/createTestHarness.test.ts
Comment thread packages/wrangler/src/api/startDevWorker/BaseController.ts Outdated

this.on("error", (event: ErrorEvent) => {
if (event.recoverable) {
return;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does this do? (could you add a code comment?)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DevEnv suppress bundling errors right now as they are likely recoverable by the users (e.g. build failed / fail to parse an invalid config) and there is no way to know if the build fails outside. I am now populating those errors with recoverable: true so createServer() can capture those through the error events and throw a proper error in the test environment.

I will add some code comments :)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a code comment in 268c779

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like reusing the error event here could be problematic as we are using event.once() in several places which will throw when it sees an error event. I might need to populate these using a different event name.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to a new event (buildFailed) for these recoverable errors in 0589d35.

Comment thread packages/wrangler/src/api/server.ts Outdated
* Cloudflare account ID used when an operation requires account context.
* Defaults to the account selected by Wrangler auth when needed.
*/
accountId?: string | undefined;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about also including the account token as input? 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. It works with the account token defined in the environment variables already. If we add an option for that, users are likely still reading from the env vars:

const server = createServer({
  workers: [ ... ],
  apiToken: process.env.MY_API_TOKEN,
});

Something like this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah that's what I was thinking 🙂

users could create different servers with different accounts and tokens, that would be super flexible (although I am not 100% sure why someone would need it 😅)

For example

const serverA = createServer({
  workers: [ ... ],
  accountId: "xxx",
  apiToken: process.env.MY_API_TOKEN_A,
});

const serverA = createServer({
  workers: [ ... ],
  accountId: "xxx",
  apiToken: process.env.MY_API_TOKEN_B, // note: this token has different permissions compared to token A
});

// This server simulates workers present on a completely different account
const externalServer = createServer({
  workers: [ ... ],
  accountId: "yyy",
  apiToken: process.env.ACCOUNT_Y_API_TOKEN,
});

By the way, if I remember correctly, this isn't even possible today with startWorker since Wrangler assumes that the api token is a single one for the whole process 😓

So this might be difficult to implement here... maybe it could be considered as an improvement later on it we want? 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that makes sense! But I would prefer not to support it for now. Since we have dropped the dev server aspect of this API, I have simplified the options it accepts and removed accountId as well.

We can revisit these options later. For now, let's keep the API simple and recommend users set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN instead.

Copy link
Copy Markdown
Contributor

@workers-devprod workers-devprod left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codeowners reviews satisfied

@github-project-automation github-project-automation Bot moved this from Untriaged to Approved in workers-sdk Jun 3, 2026
devin-ai-integration[bot]

This comment was marked as resolved.


const server = createServer({
workers: [
{ configPath: path.resolve(helper.tmpPath, "./wrangler.jsonc") },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small q: is this path.resolve() needed? will it work with just "wrangler.jsonc"?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It resolves relative path with process.cwd() by default. So a plain "wrangler.jsonc" should work for most users.

);
});

it("routes fetches based on worker routes", async ({ expect }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh, this is interesting! We don't really expose this anywhere else—we should make a big deal of it in the changelog

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some similar ideas about routing to Workers based on routes in the Vite plugin so this fits in nicely.

Comment on lines +244 to +247
name: "auxiliary-worker",
main: "src/auxiliary.ts",
compatibility_date: "2026-05-20",
routes: ["auxiliary.example.com/*"],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this is the Wrangler config format rather than the new one we're working on. I think that's fine for now, and we can add support for the new config format under an experimental flag in future. @jamesopstad something to be aware of as a follow-on to the wrangler config PR

expect(workerCountAfterRejectedUpdate).toBe(1);
});

it("rejects update when a worker fails to build", async ({ expect }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the not-very-distant future I expect we'll want to not support builds at all with this API, and require a build output directory to be passed in. Does that line up with your thinking?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. I added this because build errors are considered recoverable in wrangler dev, but should be fatal in this test API.

Comment on lines +466 to +467
const response = await server.getWorker("missing-worker").fetch("/");
expect(response.status).toBe(404);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you expand on this choice? I think I'd expect getWorker() to throw in this case, but I haven't thought about it as much as you have

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should throw. Updated in fbc78f4

Comment on lines +49 to +50
/** The configuration path of the worker, or a normalized configuration object. */
config?: string | Config;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't really how this is intended to work. The purpose of config is to provide the base config for a Worker, which will then be overriden by the other SDW properties. Is there a reason people need to pass in custom properties to config rather than the other SDW properties? Everything relevant in config should be overrideable.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed this with Samuel in person.

For context, this is mostly a minimal hack to avoid introducing a translation layer to map wrangler config back to the sdw interface.

/** Treat this as the primary worker in a multiworker setup (i.e. the first Worker in Miniflare's options) */
multiworkerPrimary?: boolean;
/** Whether to infer the local request origin from configured routes. */
inferOriginFromRoutes?: boolean;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this need to be configurable? Should it also be configurable in a config file?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrangler currently override the host of the request url based on the first Worker routes in https://github.com/cloudflare/workers-sdk/pull/14169/changes#diff-524c0b6c44e4e63168f1849abe9420fbb7c44f64c3a6166f44515fdd00b2118dR155

But I find it quite confusing for multi workers especially in a test environment, so I added this option and disable it for createServer().

I don't think we have such logic in the vite plugin.

Comment on lines +528 to +535
const secrets = configParam.secrets
? {
...configParam.secrets,
required: configParam.secrets?.required?.filter(
(secret) => inputBindings?.[secret]?.type !== "secret_text"
),
}
: undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this doing?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createServer() can override vars and secrets per Worker.

This checks whether a required secret was already injected by that override via inputBindings, so the required secrets logic doesn't consider them missing even they are absent from .env or .dev.vars.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added code comment in ca7e4bb

outboundService?: ServiceFetch | undefined;
};

export type WorkerHandle = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

followup—this should support other handlers, not just scheduled

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am planning to add email() handler support next. Any other handlers you have in mind?

},
},
legacy: {
site: (config) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we just not support Workers Sites here? They're very legacy and I'm not sure we should be adding them to a net-new API

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I agree. Dropped workers site support in c5e2e18.

@edmundhung edmundhung force-pushed the edmundhung/wrangler-create-server branch from 1c09491 to 58e8d93 Compare June 5, 2026 11:47
@edmundhung edmundhung changed the title feat(wrangler): createServer API feat(wrangler): createTestHarness API Jun 5, 2026
@edmundhung edmundhung force-pushed the edmundhung/wrangler-create-server branch from 778a379 to c5e2e18 Compare June 5, 2026 11:55
Copy link
Copy Markdown
Contributor

@jamesopstad jamesopstad left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great! Nice work @edmundhung.

For my understanding, is the only thing preventing us using Miniflare directly rather than startDevWorker here that this currently needs to do bundling?

);
});

it("routes fetches based on worker routes", async ({ expect }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some similar ideas about routing to Workers based on routes in the Vite plugin so this fits in nicely.

],
}))
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: Updating the number of workers running in the server is not supported.]`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this, out of interest?

await expect(response.text()).resolves.toBe("Hello from inline config");
});

it("loads default .env files for config path workers", async ({ expect }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these loaded relative to the Worker's config file?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wanted to say that this might be the nicest test file I've seen in workers-sdk! Very easy to follow.

Comment on lines +140 to +143
/**
* Wrangler environment to load from the config file.
*/
env?: string;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is OK for now but note that with the Build Output API the config will always be flat.

Comment on lines +144 to +146
/**
* Test-only vars that override vars from the Wrangler config.
*/
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK for now but In the new config we're splitting vars into text and json to better reflect the underlying types and how they're shown in the dashboard.

const request = new Request(input, init);
const headers = new Headers(request.headers);

headers.set("MF-Route-Override", worker);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a note for the future (maybe next Miniflare version). I think we should make these MF- prefixed headers internal to Miniflare and instead expose the properties we need on miniflare.dispatchFetch().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Approved

Development

Successfully merging this pull request may close these issues.

5 participants