diff --git a/.changeset/curly-beans-test.md b/.changeset/curly-beans-test.md new file mode 100644 index 00000000000..10cd0a82eb2 --- /dev/null +++ b/.changeset/curly-beans-test.md @@ -0,0 +1,24 @@ +--- +"wrangler": minor +--- + +Allow Wrangler projects to build a Worker once and reuse it in `createTestHarness()` + +Build the Worker once: + +```sh +wrangler deploy --dry-run --outdir ./worker-output +``` + +Then reuse the emitted Worker during test harness startup and reset: + +```ts +const server = createTestHarness({ + workers: [ + { + configPath: "./wrangler.jsonc", + prebuiltWorkerDir: "./worker-output", + }, + ], +}); +``` diff --git a/fixtures/create-test-harness-example/README.md b/fixtures/create-test-harness-example/README.md index 671f54a92bf..0ff55ef71c4 100644 --- a/fixtures/create-test-harness-example/README.md +++ b/fixtures/create-test-harness-example/README.md @@ -16,7 +16,7 @@ The app has three Workers: ## What this fixture covers -- Setup [Vitest](https://vitest.dev/) against Workers developed with Wrangler. +- Setup [Vitest](https://vitest.dev/) against prebuilt output from Wrangler projects. - Setup [Playwright](https://playwright.dev/) against Workers built by the Cloudflare Vite plugin. - Route dispatch across multiple Workers. - Direct Worker calls with `server.getWorker(name)`. @@ -31,12 +31,14 @@ The app has three Workers: ## Run this example -To build the packages, run: +To build the Wrangler and Vite output, run: ```sh pnpm build --filter @fixture/create-test-harness-example ``` +This builds each Wrangler Worker once with `wrangler deploy --dry-run --outdir`, then the Vitest setup reuses that output with `prebuiltWorkerDir`. It also creates the Vite output used by Playwright. + Then run the tests with: ```sh @@ -48,8 +50,8 @@ pnpm --filter @fixture/create-test-harness-example test:playwright | File | Purpose | | -------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| [`tests/vitest.test.ts`](tests/vitest.test.ts) | Example for testing with Vitest. | -| [`tests/playwright.test.ts`](tests/playwright.test.ts) | Example for testing with Playwright. | +| [`tests/vitest.test.ts`](tests/vitest.test.ts) | Tests prebuilt Wrangler output with Vitest. | +| [`tests/playwright.test.ts`](tests/playwright.test.ts) | Tests Vite build output with Playwright. | | [`vite.config.ts`](vite.config.ts) | Builds Vite-generated Worker configs. | | [`workers/web/index.ts`](workers/web/index.ts) | User-facing Worker with service and Browser Rendering binding logic. | | [`workers/web/wrangler.jsonc`](workers/web/wrangler.jsonc) | Web Worker config. | diff --git a/fixtures/create-test-harness-example/package.json b/fixtures/create-test-harness-example/package.json index 42260df44bc..62a4e388d0c 100644 --- a/fixtures/create-test-harness-example/package.json +++ b/fixtures/create-test-harness-example/package.json @@ -8,7 +8,12 @@ "typegen:web": "wrangler types ./workers/web/worker-configuration.d.ts -c ./workers/web/wrangler.jsonc -c ./workers/api/wrangler.jsonc --env-interface WebEnv --no-include-runtime", "typegen:api": "wrangler types ./workers/api/worker-configuration.d.ts -c ./workers/api/wrangler.jsonc --env-interface ApiEnv --no-include-runtime", "check:type": "tsc", - "build": "vite build", + "build": "pnpm build:vite && pnpm build:wrangler", + "build:vite": "vite build", + "build:wrangler": "pnpm build:wrangler:web && pnpm build:wrangler:api && pnpm build:wrangler:mock-browser", + "build:wrangler:api": "cd ./workers/api && wrangler deploy --dry-run --config ./wrangler.jsonc --outdir ../../dist/wrangler/api", + "build:wrangler:mock-browser": "cd ./workers/mock-browser && wrangler deploy --dry-run --config ./wrangler.jsonc --outdir ../../dist/wrangler/mock-browser", + "build:wrangler:web": "cd ./workers/web && wrangler deploy --dry-run --config ./wrangler.jsonc --outdir ../../dist/wrangler/web", "playwright:install": "pnpm playwright install chromium", "pretest:ci": "pnpm playwright:install", "pretest:playwright": "pnpm playwright:install", diff --git a/fixtures/create-test-harness-example/tests/vitest.test.ts b/fixtures/create-test-harness-example/tests/vitest.test.ts index 482943616d7..e40648a0d1e 100644 --- a/fixtures/create-test-harness-example/tests/vitest.test.ts +++ b/fixtures/create-test-harness-example/tests/vitest.test.ts @@ -10,15 +10,22 @@ import { } from "vitest"; import { createTestHarness } from "wrangler"; -// Point each worker to the Wrangler config you want to test. +// Point each worker to its Wrangler config and previously built output. const server = createTestHarness({ workers: [ { configPath: "./workers/web/wrangler.jsonc", + prebuiltWorkerDir: "./dist/wrangler/web", bindingOverrides: { BROWSER: "mock-browser" }, }, - { configPath: "./workers/api/wrangler.jsonc" }, - { configPath: "./workers/mock-browser/wrangler.jsonc" }, + { + configPath: "./workers/api/wrangler.jsonc", + prebuiltWorkerDir: "./dist/wrangler/api", + }, + { + configPath: "./workers/mock-browser/wrangler.jsonc", + prebuiltWorkerDir: "./dist/wrangler/mock-browser", + }, ], }); diff --git a/packages/wrangler/e2e/createTestHarness.test.ts b/packages/wrangler/e2e/createTestHarness.test.ts index 745b75ebce8..a15f7c4a524 100644 --- a/packages/wrangler/e2e/createTestHarness.test.ts +++ b/packages/wrangler/e2e/createTestHarness.test.ts @@ -1,5 +1,7 @@ +import { readFile } from "node:fs/promises"; import path from "node:path"; import { setTimeout } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; import { mockConsoleMethods } from "@cloudflare/workers-utils/test-helpers"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; @@ -95,6 +97,192 @@ describe("createTestHarness", () => { ); }); + it("runs existing dry-run output without rebuilding it", async ({ + expect, + onTestFailed, + }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "prebuilt-worker", + "main": "src/worker.ts", + "compatibility_date": "2026-05-20", + "vars": { "ENVIRONMENT": "top-level" }, + "rules": [ + { "type": "Text", "globs": ["**/*.txt"] } + ], + "build": { "command": "node build.mjs" }, + "env": { + "test": { + "vars": { "ENVIRONMENT": "test" } + } + } + } + `, + "build.mjs": dedent` + import { writeFileSync } from "node:fs"; + + writeFileSync( + "src/generated.ts", + 'export default "from custom build";' + ); + `, + "src/message.txt": "from emitted text module", + "src/worker.ts": dedent` + import generated from "./generated"; + import message from "./message.txt"; + + export default { + fetch(_request, env) { + return new Response(generated + ":" + message + ":" + env.ENVIRONMENT); + } + }; + `, + }); + + await helper.run( + "wrangler deploy --dry-run --env test --outdir worker-output" + ); + + const outputPath = path.join(helper.tmpPath, "worker-output", "worker.js"); + const originalOutput = await readFile(outputPath); + await helper.seed({ + "build.mjs": 'throw new Error("The test harness ran the custom build");', + }); + + const server = createTestHarness({ + root: helper.tmpPath, + workers: [ + { + configPath: "./wrangler.jsonc", + env: "test", + prebuiltWorkerDir: pathToFileURL( + path.join(helper.tmpPath, "worker-output") + ), + }, + ], + }); + onTestFinished(server.close); + onTestFailed(server.debug); + + await server.listen(); + const response = await server.fetch("/"); + await expect(response.text()).resolves.toBe( + "from custom build:from emitted text module:test" + ); + expect(await readFile(outputPath)).toEqual(originalOutput); + + await server.reset(); + const resetResponse = await server.fetch("/"); + await expect(resetResponse.text()).resolves.toBe( + "from custom build:from emitted text module:test" + ); + expect(await readFile(outputPath)).toEqual(originalOutput); + }); + + it("fails when the expected dry-run entrypoint is missing", async ({ + expect, + }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "missing-prebuilt-worker", + "main": "src/index.ts", + "compatibility_date": "2026-05-20" + } + `, + "src/index.ts": dedent` + export default { + fetch() { + return new Response("source fallback"); + } + }; + `, + "missing-output/README.md": "", + }); + + const server = createTestHarness({ + root: helper.tmpPath, + workers: [ + { + configPath: "./wrangler.jsonc", + prebuiltWorkerDir: "./missing-output", + }, + ], + }); + onTestFinished(server.close); + + await expect(server.listen()).rejects.toThrow( + 'wrangler deploy --dry-run --config "./wrangler.jsonc" --outdir "./missing-output"' + ); + }); + + it("fails when the prebuilt directory does not exist", async ({ expect }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "unbuilt-worker", + "main": "src/index.ts", + "compatibility_date": "2026-05-20", + "account_id": "" + } + `, + "src/index.ts": dedent` + export default { + fetch() { + return new Response("source fallback"); + } + }; + `, + }); + + const server = createTestHarness({ + root: helper.tmpPath, + workers: [ + { + configPath: "./wrangler.jsonc", + prebuiltWorkerDir: "./missing-output", + }, + ], + }); + onTestFinished(server.close); + + await expect(server.listen()).rejects.toThrow( + 'The `prebuiltWorkerDir` directory "./missing-output" does not exist. Build the Worker first by running `wrangler deploy --dry-run --config "./wrangler.jsonc" --outdir "./missing-output"`.' + ); + expect(logs.warn).toBe(""); + }); + + it("rejects prebuilt output for an assets-only Worker", async ({ + expect, + }) => { + await helper.seed({ + "wrangler.jsonc": dedent` + { + "name": "assets-only-worker", + "compatibility_date": "2026-05-20", + "assets": { "directory": "./public" } + } + `, + "public/index.html": "assets-only Worker", + }); + + const server = createTestHarness({ + root: helper.tmpPath, + workers: [ + { + configPath: "./wrangler.jsonc", + prebuiltWorkerDir: "./missing-output", + }, + ], + }); + onTestFinished(server.close); + + await expect(server.listen()).rejects.toThrow( + "The `prebuiltWorkerDir` option only loads a prebuilt Worker script. This Wrangler config is assets-only, so the test harness loads its assets from `assets.directory` instead. Remove `prebuiltWorkerDir` from the test harness configuration." + ); + }); + it("can be configured after creation", async ({ expect }) => { await helper.seed({ "wrangler.jsonc": dedent` diff --git a/packages/wrangler/src/api/test-harness.ts b/packages/wrangler/src/api/test-harness.ts index c26a4d0440f..b26b1926ff8 100644 --- a/packages/wrangler/src/api/test-harness.ts +++ b/packages/wrangler/src/api/test-harness.ts @@ -13,6 +13,7 @@ import { WorkflowIntrospectorHandle, } from "@cloudflare/workflows-shared/src/introspection"; import { CorePaths, Headers, Request } from "miniflare"; +import { readConfig } from "../config"; import { buildMigrationQuery, getCreateMigrationsTableQuery, @@ -25,6 +26,7 @@ import { splitSqlQuery } from "../d1/splitter"; import { getDatabaseInfoFromConfig } from "../d1/utils"; import { validateNodeCompatMode } from "../deployment-bundle/node-compat"; import { getDurableObjectClassNameToUseSQLiteMap } from "../dev/class-names-sqlite"; +import { runWithLogLevel } from "../logger"; import { requireApiToken, requireAuth } from "../user"; import { DevEnv } from "./startDevWorker/DevEnv"; import { MultiworkerRuntimeController } from "./startDevWorker/MultiworkerRuntimeController"; @@ -402,6 +404,24 @@ type WorkerInput = * Wrangler environment to load from the config file. */ env?: string; + /** + * Avoids rebuilding a Worker in a Wrangler project each time the test + * harness starts or resets. Build the Worker once with + * `wrangler deploy --dry-run --outdir`, then specify the same output + * directory here. + * + * When using a named Wrangler environment, `env` must match the environment + * used to build the output. Relative paths resolve from server `root`. + * + * @example + * ```sh + * wrangler deploy --dry-run --env test --outdir ./worker-output + * ``` + * ```ts + * { configPath: "./wrangler.jsonc", env: "test", prebuiltWorkerDir: "./worker-output" } + * ``` + */ + prebuiltWorkerDir?: string | URL; /** * Test-only vars that override vars from the Wrangler config. */ @@ -517,6 +537,75 @@ export function createTestHarness(options?: TestHarnessOptions): TestHarness { return normalizedConfig; } + function resolveWorkerConfig( + input: WorkerInput, + root: string + ): string | Config { + if ("config" in input) { + return normalizeInlineWorkerConfig(input.config, root); + } + + const configPath = resolvePath(root, input.configPath); + if (input.prebuiltWorkerDir === undefined) { + return configPath; + } + + const config = runWithLogLevel("none", () => + readConfig({ config: configPath, env: input.env }) + ); + if (config.main === undefined) { + throw new UserError( + "The `prebuiltWorkerDir` option only loads a prebuilt Worker script. This Wrangler config is assets-only, so the test harness loads its assets from `assets.directory` instead. Remove `prebuiltWorkerDir` from the test harness configuration.", + { + telemetryMessage: + "test harness prebuilt assets only worker unsupported", + } + ); + } + + const prebuiltWorkerDir = resolvePath(root, input.prebuiltWorkerDir); + const envOption = input.env ? ` --env ${JSON.stringify(input.env)}` : ""; + const commandConfigPath = + typeof input.configPath === "string" + ? input.configPath + : path.relative(root, configPath) || "."; + const commandOutDir = + typeof input.prebuiltWorkerDir === "string" + ? input.prebuiltWorkerDir + : path.relative(root, prebuiltWorkerDir) || "."; + const buildCommand = `wrangler deploy --dry-run --config ${JSON.stringify(commandConfigPath)}${envOption} --outdir ${JSON.stringify(commandOutDir)}`; + if (!fs.existsSync(prebuiltWorkerDir)) { + throw new UserError( + `The \`prebuiltWorkerDir\` directory "${commandOutDir}" does not exist. Build the Worker first by running \`${buildCommand}\`.`, + { telemetryMessage: "test harness prebuilt directory missing" } + ); + } + + let main = config.main; + if (main !== undefined) { + const outputFileName = config.no_bundle + ? path.basename(main) + : `${path.parse(main).name}.js`; + main = path.join(prebuiltWorkerDir, outputFileName); + if (!fs.existsSync(main)) { + const commandEntrypoint = path.join(commandOutDir, outputFileName); + throw new UserError( + `Could not find the prebuilt Worker entrypoint at "${commandEntrypoint}". Run \`${buildCommand}\` before starting the test harness.`, + { telemetryMessage: "test harness prebuilt entrypoint missing" } + ); + } + } + + return { + ...config, + main, + base_dir: prebuiltWorkerDir, + no_bundle: true, + find_additional_modules: true, + build: { ...config.build, command: undefined }, + }; + } + function resolveWorkerInputs( serverOptions: TestHarnessOptions ): WranglerStartDevWorkerInput[] { @@ -544,10 +633,7 @@ export function createTestHarness(options?: TestHarnessOptions): TestHarness { } return { - config: - "config" in input - ? normalizeInlineWorkerConfig(input.config, root) - : resolvePath(root, input.configPath), + config: resolveWorkerConfig(input, root), env: "env" in input ? input.env : undefined, bindings, dev: {