diff --git a/.changeset/tame-melons-promise.md b/.changeset/tame-melons-promise.md new file mode 100644 index 000000000000..40ad4438f1e3 --- /dev/null +++ b/.changeset/tame-melons-promise.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/vitest-pool-workers": minor +--- + +feature: add support for testing Pages Functions diff --git a/fixtures/vitest-pool-workers-examples/README.md b/fixtures/vitest-pool-workers-examples/README.md index 66ad156279ab..c4f796cdf5c2 100644 --- a/fixtures/vitest-pool-workers-examples/README.md +++ b/fixtures/vitest-pool-workers-examples/README.md @@ -2,18 +2,19 @@ This directory contains example projects tested with `@cloudflare/vitest-pool-workers`. It aims to provide the building blocks for you to write tests for your own Workers. Note the examples in this directory define `singleWorker: true` options. We recommend you enable this option if you have lots of small test files. Isolated storage is enabled by default meaning writes performed in each test are automatically undone when the test finishes. -| Directory | Overview | -| --------------------------------------------------------------- | --------------------------------------------------------- | -| [โœ… basics-unit-integration-self](basics-unit-integration-self) | Basic unit tests and integration tests using `SELF` | -| [โš ๏ธ basics-integration-auxiliary](basics-integration-auxiliary) | Basic integration tests using an auxiliary worker[^1] | -| [๐Ÿ“ฆ kv-r2-caches](kv-r2-caches) | Isolated tests using KV, R2 and the Cache API | -| [๐Ÿ“š d1](d1) | Isolated tests using D1 with migrations | -| [๐Ÿ“Œ durable-objects](durable-objects) | Isolated tests using Durable Objects with direct access | -| [๐Ÿšฅ queues](queues) | Tests using Queue producers and consumers | -| [๐Ÿš€ hyperdrive](hyperdrive) | Tests using Hyperdrive with a Vitest managed TCP server | -| [๐Ÿคน request-mocking](request-mocking) | Tests using declarative/imperative outbound request mocks | -| [๐Ÿ”Œ multiple-workers](multiple-workers) | Tests using multiple auxiliary workers and request mocks | -| [โš™๏ธ web-assembly](web-assembly) | Tests importing WebAssembly modules | -| [๐Ÿคท misc](misc) | Tests for other assorted Vitest features | +| Directory | Overview | +| ---------------------------------------------------------------------------------- | --------------------------------------------------------- | +| [โœ… basics-unit-integration-self](basics-unit-integration-self) | Basic unit tests and integration tests using `SELF` | +| [โš ๏ธ basics-integration-auxiliary](basics-integration-auxiliary) | Basic integration tests using an auxiliary worker[^1] | +| [โšก๏ธ pages-functions-unit-integration-self](pages-functions-unit-integration-self) | Functions unit tests and integration tests using `SELF` | +| [๐Ÿ“ฆ kv-r2-caches](kv-r2-caches) | Isolated tests using KV, R2 and the Cache API | +| [๐Ÿ“š d1](d1) | Isolated tests using D1 with migrations | +| [๐Ÿ“Œ durable-objects](durable-objects) | Isolated tests using Durable Objects with direct access | +| [๐Ÿšฅ queues](queues) | Tests using Queue producers and consumers | +| [๐Ÿš€ hyperdrive](hyperdrive) | Tests using Hyperdrive with a Vitest managed TCP server | +| [๐Ÿคน request-mocking](request-mocking) | Tests using declarative/imperative outbound request mocks | +| [๐Ÿ”Œ multiple-workers](multiple-workers) | Tests using multiple auxiliary workers and request mocks | +| [โš™๏ธ web-assembly](web-assembly) | Tests importing WebAssembly modules | +| [๐Ÿคท misc](misc) | Tests for other assorted Vitest features | [^1]: When using `SELF` for integration tests, your worker code runs in the same context as the test runner. This means you can use global mocks to control your worker, but also means your worker uses the same subtly different module resolution behaviour provided by Vite. Usually this isn't a problem, but if you'd like to run your worker in a fresh environment that's as close to production as possible, using an auxiliary worker may be a good idea. Note this prevents global mocks from controlling your worker, and requires you to build your worker ahead-of-time. This means your tests won't re-run automatically if you change your worker's source code, but could be useful if you have a complicated build process (e.g. full-stack framework). diff --git a/fixtures/vitest-pool-workers-examples/misc/test/env.d.ts b/fixtures/vitest-pool-workers-examples/misc/test/env.d.ts index 4ee5f9468cb8..080605e0931e 100644 --- a/fixtures/vitest-pool-workers-examples/misc/test/env.d.ts +++ b/fixtures/vitest-pool-workers-examples/misc/test/env.d.ts @@ -1,5 +1,7 @@ declare module "cloudflare:test" { interface ProvidedEnv { + ASSETS?: Fetcher; + KV_NAMESPACE: KVNamespace; OTHER_OBJECT: DurableObjectNamespace; } } diff --git a/fixtures/vitest-pool-workers-examples/misc/test/pages-functions.test.ts b/fixtures/vitest-pool-workers-examples/misc/test/pages-functions.test.ts new file mode 100644 index 000000000000..3fc6260d5537 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/misc/test/pages-functions.test.ts @@ -0,0 +1,184 @@ +import { + createPagesEventContext, + env, + ProvidedEnv, + waitOnExecutionContext, +} from "cloudflare:test"; +import { expect, it, onTestFinished } from "vitest"; + +// This will improve in the next major version of `@cloudflare/workers-types`, +// but for now you'll need to do something like this to get a correctly-typed +// `Request` to pass to `createPagesEventContext()`. +const IncomingRequest = Request; + +type BareFunction = PagesFunction>; + +it("can consume body in middleware and in next request", async () => { + const fn: BareFunction = async (ctx) => { + const requestText = await ctx.request.text(); + const nextResponse = await ctx.next(); + const nextResponseText = await nextResponse.text(); + return Response.json({ requestText, nextResponseText }); + }; + const request = new IncomingRequest("https://example.com", { + method: "POST", + body: "body", + }); + const ctx = createPagesEventContext({ + request, + async next(nextRequest) { + const nextRequestText = await nextRequest.text(); + return new Response(nextRequestText); + }, + }); + const response = await fn(ctx); + await waitOnExecutionContext(ctx); + expect(await response.json()).toStrictEqual({ + requestText: "body", + nextResponseText: "body", + }); +}); + +it("can rewrite to absolute and relative urls in next", async () => { + const fn: BareFunction = async (ctx) => { + const { pathname } = new URL(ctx.request.url); + if (pathname === "/absolute") { + return ctx.next("https://example.com/new-absolute", { method: "PUT" }); + } else if (pathname === "/relative/") { + return ctx.next("./new", { method: "PATCH" }); + } else { + return new Response(null, { status: 404 }); + } + }; + + // Check with absolute rewrite + let request = new IncomingRequest("https://example.com/absolute"); + let ctx = createPagesEventContext({ + request, + async next(nextRequest) { + return new Response(`next:${nextRequest.method} ${nextRequest.url}`); + }, + }); + let response = await fn(ctx); + await waitOnExecutionContext(ctx); + expect(await response.text()).toBe( + "next:PUT https://example.com/new-absolute" + ); + + // Check with relative rewrite + request = new IncomingRequest("https://example.com/relative/"); + ctx = createPagesEventContext({ + request, + async next(nextRequest) { + return new Response(`next:${nextRequest.method} ${nextRequest.url}`); + }, + }); + response = await fn(ctx); + await waitOnExecutionContext(ctx); + expect(await response.text()).toBe( + "next:PATCH https://example.com/relative/new" + ); +}); + +it("requires next property to call next()", async () => { + const fn: BareFunction = (ctx) => ctx.next(); + const request = new IncomingRequest("https://example.com"); + const ctx = createPagesEventContext({ request }); + expect(fn(ctx)).rejects.toThrowErrorMatchingInlineSnapshot( + `[TypeError: Cannot call \`EventContext#next()\` without including \`next\` property in 2nd argument to \`createPagesEventContext()\`]` + ); +}); + +it("requires ASSETS service binding", async () => { + let originalASSETS = env.ASSETS; + onTestFinished(() => { + env.ASSETS = originalASSETS; + }); + delete env.ASSETS; + + const request = new IncomingRequest("https://example.com", { + method: "POST", + body: "body", + }); + expect(() => + createPagesEventContext({ request }) + ).toThrowErrorMatchingInlineSnapshot( + `[TypeError: Cannot call \`createPagesEventContext()\` without defining \`ASSETS\` service binding]` + ); +}); + +it("waits for waitUntil()ed promises", async () => { + const fn: BareFunction = (ctx) => { + ctx.waitUntil(ctx.env.KV_NAMESPACE.put("key", "value")); + return new Response(); + }; + const request = new IncomingRequest("https://example.com"); + const ctx = createPagesEventContext({ request }); + await fn(ctx); + await waitOnExecutionContext(ctx); + expect(await env.KV_NAMESPACE.get("key")).toBe("value"); +}); + +it("correctly types parameters", async () => { + const request = new IncomingRequest("https://example.com"); + + // Check no params and no data required + { + type Fn = PagesFunction>; + createPagesEventContext({ request }); + createPagesEventContext({ request, params: {} }); + // @ts-expect-error no params required + createPagesEventContext({ request, params: { a: "1" } }); + createPagesEventContext({ request, data: {} }); + // @ts-expect-error no data required + createPagesEventContext({ request, data: { b: "1" } }); + } + + // Check no params but data required + { + type Fn = PagesFunction; + // @ts-expect-error data required + createPagesEventContext({ request }); + // @ts-expect-error data required + createPagesEventContext({ request, params: {} }); + // @ts-expect-error no params but data required + createPagesEventContext({ request, params: { a: "1" } }); + // @ts-expect-error data required + createPagesEventContext({ request, data: {} }); + createPagesEventContext({ request, data: { b: "1" } }); + } + + // Check no data but params required + { + type Fn = PagesFunction>; + // @ts-expect-error params required + createPagesEventContext({ request }); + // @ts-expect-error params required + createPagesEventContext({ request, params: {} }); + createPagesEventContext({ request, params: { a: ["1"] } }); + // @ts-expect-error no data but params required + createPagesEventContext({ request, data: {} }); + // @ts-expect-error no data but params required + createPagesEventContext({ request, data: { b: "1" } }); + } + + // Check params and data required + { + type Fn = PagesFunction; + // @ts-expect-error params required + createPagesEventContext({ request }); + // @ts-expect-error params required + createPagesEventContext({ request, params: {} }); + // @ts-expect-error data required + createPagesEventContext({ request, params: { a: "1" } }); + // @ts-expect-error params required + createPagesEventContext({ request, data: {} }); + // @ts-expect-error params required + createPagesEventContext({ request, data: { b: "1" } }); + createPagesEventContext({ + request, + params: { a: "1" }, + data: { b: "1" }, + }); + } +}); diff --git a/fixtures/vitest-pool-workers-examples/misc/vitest.config.ts b/fixtures/vitest-pool-workers-examples/misc/vitest.config.ts index de98f2b4db76..c4ab72e27dfb 100644 --- a/fixtures/vitest-pool-workers-examples/misc/vitest.config.ts +++ b/fixtures/vitest-pool-workers-examples/misc/vitest.config.ts @@ -11,9 +11,15 @@ export default defineWorkersProject({ workers: { singleWorker: true, miniflare: { + kvNamespaces: ["KV_NAMESPACE"], outboundService(request) { return new Response(`fallthrough:${request.method} ${request.url}`); }, + serviceBindings: { + ASSETS(request) { + return new Response(`assets:${request.method} ${request.url}`); + }, + }, workers: [ { name: "other", diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/.gitignore b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/.gitignore new file mode 100644 index 000000000000..83e62786fb29 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/.gitignore @@ -0,0 +1 @@ +dist-functions diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/README.md b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/README.md new file mode 100644 index 000000000000..b322f1e23848 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/README.md @@ -0,0 +1,8 @@ +# โšก๏ธ pages-functions-unit-integration-self + +This project uses Pages Functions. Integration tests dispatch events using the `SELF` helper from the `cloudflare:test` module. Unit tests call handler functions directly. [`global-setup.ts`](global-setup.ts) builds Pages Functions into a Worker for integration testing, watching for changes. + +| Test | Overview | +| --------------------------------------------------------- | ------------------------------------------------------------- | +| [integration-self.test.ts](test/integration-self.test.ts) | Basic `fetch` integration test using `SELF` **(recommended)** | +| [unit.test.ts](test/unit.test.ts) | Basic unit test calling `worker.fetch()` directly | diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/_middleware.ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/_middleware.ts new file mode 100644 index 000000000000..2aaa5c5f9e6e --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/_middleware.ts @@ -0,0 +1,11 @@ +// Add data to the request and make all bodies uppercase +export const onRequest: PagesFunction< + Env, + never, + Data | Record +> = async (ctx) => { + ctx.data = { user: "ada" }; + const response = await ctx.next(); + const text = await response.text(); + return new Response(text.toUpperCase(), response); +}; diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/kv/[key].ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/kv/[key].ts new file mode 100644 index 000000000000..67a0fc988168 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/kv/[key].ts @@ -0,0 +1,11 @@ +export const onRequestGet: PagesFunction = async (ctx) => { + const key = `${ctx.data.user}:${ctx.params.key}`; + const value = await ctx.env.KV_NAMESPACE.get(key, "stream"); + return new Response(value, { status: value === null ? 204 : 200 }); +}; + +export const onRequestPut: PagesFunction = async (ctx) => { + const key = `${ctx.data.user}:${ctx.params.key}`; + await ctx.env.KV_NAMESPACE.put(key, ctx.request.body ?? ""); + return new Response(null, { status: 204 }); +}; diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/ping.ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/ping.ts new file mode 100644 index 000000000000..d8842eababff --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/api/ping.ts @@ -0,0 +1,3 @@ +export const onRequest: PagesFunction = ({ request }) => { + return new Response(`${request.method} pong`); +}; diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/env.d.ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/env.d.ts new file mode 100644 index 000000000000..055338de4f41 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/env.d.ts @@ -0,0 +1,6 @@ +interface Env { + KV_NAMESPACE: KVNamespace; + ASSETS: Fetcher; +} + +type Data = { user: string }; diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/tsconfig.json b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/tsconfig.json new file mode 100644 index 000000000000..0141323e2fc0 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/functions/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/global-setup.ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/global-setup.ts new file mode 100644 index 000000000000..0ba0b7fb9178 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/global-setup.ts @@ -0,0 +1,25 @@ +import childProcess from "node:child_process"; +import events from "node:events"; + +// Global setup runs inside Node.js, not `workerd` +export default async function () { + console.log( + "Building pages-functions-unit-integration-self and watching for changes..." + ); + + // Not building to `dist` here as Vitest ignores changes in `dist` by default + const buildProcess = childProcess.spawn( + "wrangler pages functions build --outdir dist-functions --watch", + { cwd: __dirname, shell: true } + ); + buildProcess.stdout.pipe(process.stdout); + buildProcess.stderr.pipe(process.stderr); + + // Wait for first build + await events.once(buildProcess.stdout, "data"); + + // Stop watching for changes on teardown + return () => { + buildProcess.kill(); + }; +} diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/404.html b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/404.html new file mode 100644 index 000000000000..5854f2f223db --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/404.html @@ -0,0 +1 @@ +

Not found ๐Ÿ˜ญ

diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/_headers b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/_headers new file mode 100644 index 000000000000..0b19dbaadd77 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/_headers @@ -0,0 +1,4 @@ +/secure + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Referrer-Policy: no-referrer diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/_redirects b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/_redirects new file mode 100644 index 000000000000..54dab3198a15 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/_redirects @@ -0,0 +1 @@ +/take-me-home / 302 diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/index.html b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/index.html new file mode 100644 index 000000000000..640142f9e693 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/index.html @@ -0,0 +1 @@ +

Homepage ๐Ÿก

diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/secure.html b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/secure.html new file mode 100644 index 000000000000..acbd884c6987 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/public/secure.html @@ -0,0 +1 @@ +

Secure page ๐Ÿ”

diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/env.d.ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/env.d.ts new file mode 100644 index 000000000000..bd9eb32999df --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/env.d.ts @@ -0,0 +1,4 @@ +declare module "cloudflare:test" { + // Controls the type of `import("cloudflare:test").env` + interface ProvidedEnv extends Env {} +} diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/integration-self.test.ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/integration-self.test.ts new file mode 100644 index 000000000000..3236a878c9eb --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/integration-self.test.ts @@ -0,0 +1,70 @@ +import { SELF } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import "../dist-functions/index.js"; // Currently required to automatically rerun tests when `main` changes + +describe("functions", () => { + it("calls function", async () => { + // `SELF` here points to the worker running in the current isolate. + // This gets its handler from the `main` option in `vitest.config.ts`. + const response = await SELF.fetch("http://example.com/api/ping"); + // All `/api/*` requests go through `functions/api/_middleware.ts`, + // which makes all response bodies uppercase + expect(await response.text()).toBe("GET PONG"); + }); + + it("calls function with params", async () => { + let response = await SELF.fetch("https://example.com/api/kv/key", { + method: "PUT", + body: "value", + }); + expect(response.status).toBe(204); + + response = await SELF.fetch("https://example.com/api/kv/key"); + expect(response.status).toBe(200); + expect(await response.text()).toBe("VALUE"); + }); + + it("uses isolated storage for each test", async () => { + // Check write in previous test undone + const response = await SELF.fetch("https://example.com/api/kv/key"); + expect(response.status).toBe(204); + }); +}); + +describe("assets", () => { + it("serves static assets", async () => { + const response = await SELF.fetch("http://example.com/"); + expect(await response.text()).toMatchInlineSnapshot(` + "

Homepage ๐Ÿก

+ " + `); + }); + + it("respects 404.html", async () => { + // `404.html` should be served for all unmatched requests + const response = await SELF.fetch("http://example.com/not-found"); + expect(await response.text()).toMatchInlineSnapshot(` + "

Not found ๐Ÿ˜ญ

+ " +`); + }); + + it("respects _redirects", async () => { + const response = await SELF.fetch("http://example.com/take-me-home", { + redirect: "manual", + }); + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/"); + }); + + it("respects _headers", async () => { + let response = await SELF.fetch("http://example.com/secure"); + expect(response.headers.get("X-Frame-Options")).toBe("DENY"); + expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(response.headers.get("Referrer-Policy")).toBe("no-referrer"); + + // Check headers only added to matching requests + response = await SELF.fetch("http://example.com/"); + expect(response.headers.get("X-Frame-Options")).toBe(null); + }); +}); diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/tsconfig.json new file mode 100644 index 000000000000..3da51873d526 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../functions/env.d.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/unit.test.ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/unit.test.ts new file mode 100644 index 000000000000..62c380a35786 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/test/unit.test.ts @@ -0,0 +1,79 @@ +import { + createPagesEventContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import * as apiMiddleware from "../functions/api/_middleware"; +import * as apiKVKeyFunction from "../functions/api/kv/[key]"; +import * as apiPingFunction from "../functions/api/ping"; + +// This will improve in the next major version of `@cloudflare/workers-types`, +// but for now you'll need to do something like this to get a correctly-typed +// `Request` to pass to `createPagesEventContext()`. +const IncomingRequest = Request; + +describe("functions", () => { + it("calls function", async () => { + const request = new IncomingRequest("http://example.com/api/ping"); + const ctx = createPagesEventContext({ + request, + data: { user: "test" }, + }); + const response = await apiPingFunction.onRequest(ctx); + await waitOnExecutionContext(ctx); + expect(await response.text()).toBe("GET pong"); + }); + + it("calls function with params", async () => { + let request = new IncomingRequest("http://example.com/api/kv/key", { + method: "PUT", + body: "value", + }); + let ctx = createPagesEventContext({ + request, + data: { user: "test" }, + params: { key: "key" }, + }); + let response = await apiKVKeyFunction.onRequestPut(ctx); + await waitOnExecutionContext(ctx); + expect(response.status).toBe(204); + + request = new IncomingRequest("http://example.com/api/kv/key"); + ctx = createPagesEventContext({ + request, + data: { user: "test" }, + params: { key: "key" }, + }); + response = await apiKVKeyFunction.onRequestGet(ctx); + await waitOnExecutionContext(ctx); + expect(response.status).toBe(200); + expect(await response.text()).toBe("value"); + }); + + it("uses isolated storage for each test", async () => { + // Check write in previous test undone + const request = new IncomingRequest("http://example.com/api/kv/key"); + const ctx = createPagesEventContext({ + request, + data: { user: "test" }, + params: { key: "key" }, + }); + const response = await apiKVKeyFunction.onRequestGet(ctx); + await waitOnExecutionContext(ctx); + expect(response.status).toBe(204); + }); + + it("calls middleware", async () => { + const request = new IncomingRequest("http://example.com/api/ping"); + const ctx = createPagesEventContext({ + request, + async next(request) { + expect(ctx.data).toStrictEqual({ user: "ada" }); + return new Response(`next:${request.method} ${request.url}`); + }, + }); + const response = await apiMiddleware.onRequest(ctx); + await waitOnExecutionContext(ctx); + expect(await response.text()).toBe("NEXT:GET HTTP://EXAMPLE.COM/API/PING"); + }); +}); diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/tsconfig.json b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/tsconfig.json new file mode 100644 index 000000000000..90e58bf03ef0 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/vitest.config.ts b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/vitest.config.ts new file mode 100644 index 000000000000..b07237640b5f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/pages-functions-unit-integration-self/vitest.config.ts @@ -0,0 +1,27 @@ +import path from "node:path"; +import { + buildPagesASSETSBinding, + defineWorkersProject, +} from "@cloudflare/vitest-pool-workers/config"; + +const assetsPath = path.join(__dirname, "public"); + +export default defineWorkersProject(async () => ({ + test: { + globalSetup: ["./global-setup.ts"], // Only required for integration tests + poolOptions: { + workers: { + main: "./dist-functions/index.js", // Built by `global-setup.ts` + singleWorker: true, + miniflare: { + compatibilityFlags: ["nodejs_compat"], + compatibilityDate: "2024-01-01", + kvNamespaces: ["KV_NAMESPACE"], + serviceBindings: { + ASSETS: await buildPagesASSETSBinding(assetsPath), + }, + }, + }, + }, + }, +})); diff --git a/packages/vitest-pool-workers/src/config/d1.ts b/packages/vitest-pool-workers/src/config/d1.ts index a07daea4cf58..32a62d629bf5 100644 --- a/packages/vitest-pool-workers/src/config/d1.ts +++ b/packages/vitest-pool-workers/src/config/d1.ts @@ -9,6 +9,13 @@ import type { D1Migration } from "../shared/d1"; export async function readD1Migrations( migrationsPath: string ): Promise { + // noinspection SuspiciousTypeOfGuard + if (typeof migrationsPath !== "string") { + throw new TypeError( + "Failed to execute 'readD1Migrations': parameter 1 is not of type 'string'." + ); + } + const { unstable_splitSqlQuery } = await import("wrangler"); // (lazy) const names = fs .readdirSync(migrationsPath) diff --git a/packages/vitest-pool-workers/src/config/index.ts b/packages/vitest-pool-workers/src/config/index.ts index 4b6a98cfbec5..5c3c331b94ee 100644 --- a/packages/vitest-pool-workers/src/config/index.ts +++ b/packages/vitest-pool-workers/src/config/index.ts @@ -130,3 +130,4 @@ export function defineWorkersProject( } export * from "./d1"; +export * from "./pages"; diff --git a/packages/vitest-pool-workers/src/config/pages.ts b/packages/vitest-pool-workers/src/config/pages.ts new file mode 100644 index 000000000000..a98d3343c58a --- /dev/null +++ b/packages/vitest-pool-workers/src/config/pages.ts @@ -0,0 +1,22 @@ +import type { Request, Response } from "miniflare"; +import type { UnstableASSETSBindingsOptions } from "wrangler"; + +export async function buildPagesASSETSBinding( + assetsPath: string +): Promise<(request: Request) => Promise> { + // noinspection SuspiciousTypeOfGuard + if (typeof assetsPath !== "string") { + throw new TypeError( + "Failed to execute 'buildPagesASSETSBinding': parameter 1 is not of type 'string'." + ); + } + + const { unstable_generateASSETSBinding } = await import("wrangler"); // (lazy) + const log = { + ...console, + debugWithSanitization: console.debug, + loggerLevel: "info", + columns: process.stdout.columns, + } as unknown as UnstableASSETSBindingsOptions["log"]; + return unstable_generateASSETSBinding({ log, directory: assetsPath }); +} diff --git a/packages/vitest-pool-workers/src/worker/events.ts b/packages/vitest-pool-workers/src/worker/events.ts index 14afa8eb117d..4ee22e1dc1b6 100644 --- a/packages/vitest-pool-workers/src/worker/events.ts +++ b/packages/vitest-pool-workers/src/worker/events.ts @@ -1,3 +1,5 @@ +import { env } from "./env"; + // `workerd` doesn't allow these internal classes to be constructed directly. // To replicate this behaviour require this unique symbol to be specified as the // first constructor argument. If this is missing, throw `Illegal invocation`. @@ -76,14 +78,20 @@ class ExecutionContext { export function createExecutionContext(): ExecutionContext { return new ExecutionContext(kConstructFlag); } -export async function waitOnExecutionContext( - ctx: ExecutionContext -): Promise { - // noinspection SuspiciousTypeOfGuard - if (!(ctx instanceof ExecutionContext)) { + +function isExecutionContextLike(v: unknown): v is { [kWaitUntil]: unknown[] } { + return ( + typeof v === "object" && + v !== null && + kWaitUntil in v && + Array.isArray(v[kWaitUntil]) + ); +} +export async function waitOnExecutionContext(ctx: unknown): Promise { + if (!isExecutionContextLike(ctx)) { throw new TypeError( "Failed to execute 'getWaitUntil': parameter 1 is not of type 'ExecutionContext'.\n" + - "You must call 'createExecutionContext()' to get an 'ExecutionContext' instance." + "You must call 'createExecutionContext()' or 'createPagesEventContext()' to get an 'ExecutionContext' instance." ); } return waitForWaitUntil(ctx[kWaitUntil]); @@ -341,12 +349,12 @@ export function createMessageBatch( if (arguments.length === 0) { // `queueName` will be coerced to a `string`, but it must be defined throw new TypeError( - "TypeError: Failed to execute 'createMessageBatch': parameter 1 is not of type 'string'." + "Failed to execute 'createMessageBatch': parameter 1 is not of type 'string'." ); } if (!Array.isArray(messages)) { throw new TypeError( - "TypeError: Failed to execute 'createMessageBatch': parameter 2 is not of type 'Array'." + "Failed to execute 'createMessageBatch': parameter 2 is not of type 'Array'." ); } return new QueueController(kConstructFlag, queueName, messages); @@ -391,3 +399,107 @@ export async function getQueueResult( explicitAcks, }; } + +// ============================================================================= +// Pages Functions `EventContext` +// ============================================================================= + +function hasASSETSServiceBinding( + value: Record +): value is Record & { ASSETS: Fetcher } { + return ( + "ASSETS" in value && + typeof value.ASSETS === "object" && + value.ASSETS !== null && + "fetch" in value.ASSETS && + typeof value.ASSETS.fetch === "function" + ); +} + +interface EventContextInit { + request: Request; + functionPath?: string; + next?(request: Request): Response | Promise; + params?: Record; + data?: Record; +} + +export function createPagesEventContext( + opts: EventContextInit +): Parameters[0] & { [kWaitUntil]: unknown[] } { + if (typeof opts !== "object" || opts === null) { + throw new TypeError( + "Failed to execute 'createPagesEventContext': parameter 1 is not of type 'EventContextInit'." + ); + } + if (!(opts.request instanceof Request)) { + throw new TypeError( + "Incorrect type for the 'request' field on 'EventContextInit': the provided value is not of type 'Request'." + ); + } + // noinspection SuspiciousTypeOfGuard + if ( + opts.functionPath !== undefined && + typeof opts.functionPath !== "string" + ) { + throw new TypeError( + "Incorrect type for the 'functionPath' field on 'EventContextInit': the provided value is not of type 'string'." + ); + } + if (opts.next !== undefined && typeof opts.next !== "function") { + throw new TypeError( + "Incorrect type for the 'next' field on 'EventContextInit': the provided value is not of type 'function'." + ); + } + if ( + opts.params !== undefined && + !(typeof opts.params === "object" && opts.params !== null) + ) { + throw new TypeError( + "Incorrect type for the 'params' field on 'EventContextInit': the provided value is not of type 'object'." + ); + } + if ( + opts.data !== undefined && + !(typeof opts.data === "object" && opts.data !== null) + ) { + throw new TypeError( + "Incorrect type for the 'data' field on 'EventContextInit': the provided value is not of type 'object'." + ); + } + + if (!hasASSETSServiceBinding(env)) { + throw new TypeError( + "Cannot call `createPagesEventContext()` without defining `ASSETS` service binding" + ); + } + + const ctx = createExecutionContext(); + return { + // If we might need to re-use this request, clone it + request: opts.next ? opts.request.clone() : opts.request, + functionPath: opts.functionPath ?? "", + [kWaitUntil]: ctx[kWaitUntil], + waitUntil: ctx.waitUntil.bind(ctx), + passThroughOnException: ctx.passThroughOnException.bind(ctx), + async next(nextInput, nextInit) { + if (opts.next === undefined) { + throw new TypeError( + "Cannot call `EventContext#next()` without including `next` property in 2nd argument to `createPagesEventContext()`" + ); + } + if (nextInput === undefined) { + return opts.next(opts.request); + } else { + if (typeof nextInput === "string") { + nextInput = new URL(nextInput, opts.request.url).toString(); + } + const nextRequest = new Request(nextInput, nextInit); + return opts.next(nextRequest); + } + }, + env, + params: opts.params ?? {}, + data: opts.data ?? {}, + }; +} diff --git a/packages/vitest-pool-workers/src/worker/lib/cloudflare/test.ts b/packages/vitest-pool-workers/src/worker/lib/cloudflare/test.ts index fd67f6dfbb49..3804f049caa7 100644 --- a/packages/vitest-pool-workers/src/worker/lib/cloudflare/test.ts +++ b/packages/vitest-pool-workers/src/worker/lib/cloudflare/test.ts @@ -11,4 +11,5 @@ export { createMessageBatch, getQueueResult, applyD1Migrations, + createPagesEventContext, } from "cloudflare:test-internal"; diff --git a/packages/vitest-pool-workers/types/cloudflare-test.d.ts b/packages/vitest-pool-workers/types/cloudflare-test.d.ts index 83f79b52486a..79619a51298a 100644 --- a/packages/vitest-pool-workers/types/cloudflare-test.d.ts +++ b/packages/vitest-pool-workers/types/cloudflare-test.d.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ declare module "cloudflare:test" { // eslint-disable-next-line @typescript-eslint/no-empty-interface interface ProvidedEnv {} @@ -72,10 +73,12 @@ declare module "cloudflare:test" { export function createExecutionContext(): ExecutionContext; /** * Waits for all `ExecutionContext#waitUntil()`ed `Promise`s to settle. Only - * accepts instances of `ExecutionContext` returned by - * `createExecutionContext()`. + * accepts `ExecutionContext`s returned by `createExecutionContext()` or + * `EventContext`s return by `createPagesEventContext()`. */ - export function waitOnExecutionContext(ctx: ExecutionContext): Promise; + export function waitOnExecutionContext( + ctx: ExecutionContext | EventContext + ): Promise; /** * Creates an instance of `ScheduledController` for use as the 1st argument to * modules-format `scheduled()` exported handlers. @@ -120,6 +123,32 @@ declare module "cloudflare:test" { migrationsTableName?: string ): Promise; + // Only require `params` and `data` to be specified if they're non-empty + interface EventContextInitBase { + request: Request; + functionPath?: string; + next?(request: Request): Response | Promise; + } + type EventContextInitParams = [Params] extends [never] + ? { params?: Record } + : { params: Record }; + type EventContextInitData = + Data extends Record ? { data?: Data } : { data: Data }; + type EventContextInit> = + E extends EventContext + ? EventContextInitBase & + EventContextInitParams & + EventContextInitData + : never; + + /** + * Creates an instance of `EventContext` for use as the argument to Pages + * Functions. + */ + export function createPagesEventContext< + F extends PagesFunction, + >(init: EventContextInit[0]>): Parameters[0]; + // Taken from `undici` (/~https://github.com/nodejs/undici/tree/main/types) with // no dependency on `@types/node` and with unusable functions removed // diff --git a/packages/wrangler/src/cli.ts b/packages/wrangler/src/cli.ts index 2828a35dd1ab..222574e48ddd 100644 --- a/packages/wrangler/src/cli.ts +++ b/packages/wrangler/src/cli.ts @@ -4,6 +4,8 @@ import { unstable_dev, DevEnv as unstable_DevEnv, unstable_pages } from "./api"; import { FatalError } from "./errors"; import { main } from "."; import type { UnstableDevOptions, UnstableDevWorker } from "./api"; +import type { Logger } from "./logger"; +import type { Request, Response } from "miniflare"; /** * The main entrypoint for the CLI. @@ -28,5 +30,26 @@ export { unstable_dev, unstable_pages, unstable_DevEnv }; export type { UnstableDevWorker, UnstableDevOptions }; export * from "./api/integrations"; + +// Export internal APIs required by the Vitest integration as `unstable_` export { default as unstable_splitSqlQuery } from "./d1/splitter"; export { startWorkerRegistryServer as unstable_startWorkerRegistryServer } from "./dev-registry"; + +// `miniflare-cli/assets` dynamically imports`@cloudflare/pages-shared/environment-polyfills`. +// `@cloudflare/pages-shared/environment-polyfills/types.ts` defines `global` +// augmentations that pollute the `import`-site's typing environment. +// +// We `require` instead of `import`ing here to avoid polluting the main +// `wrangler` TypeScript project with the `global` augmentations. This +// relies on the fact that `require` is untyped. +export interface UnstableASSETSBindingsOptions { + log: Logger; + proxyPort?: number; + directory?: string; +} +const generateASSETSBinding: ( + opts: UnstableASSETSBindingsOptions +) => (request: Request) => Promise = + // eslint-disable-next-line @typescript-eslint/no-var-requires + require("./miniflare-cli/assets").default; +export { generateASSETSBinding as unstable_generateASSETSBinding };