| 1 | /** |
| 2 | * Dev v2 ("hmr" mode) spec: spawns `scripts/hmr-server.mjs` — the Framework |
| 3 | * service driving the real `next dev` (Turbopack) with proxied Cloudflare |
| 4 | * bindings — in a CHILD Node process (next's require hooks collide with |
| 5 | * playwright's in-worker transform, and a separate process is how the dev |
| 6 | * server runs in real life), then exercises over HTTP: |
| 7 | * |
| 8 | * - the page serves through the real `next dev` (Turbopack) server |
| 9 | * - `getCloudflareContext().env` sees a binding value in SSR output — |
| 10 | * without `initOpenNextCloudflareForDev()` and without wrangler |
| 11 | * - the same binding read from an API route |
| 12 | * - an edit to a page file is reflected on a subsequent request (bounded |
| 13 | * retry; the file is restored in a finally) |
| 14 | */ |
| 15 | import { expect, test } from "@playwright/test"; |
| 16 | import * as NodeChildProcess from "node:child_process"; |
| 17 | import * as NodeFs from "node:fs/promises"; |
| 18 | import * as NodePath from "node:path"; |
| 19 | import { fileURLToPath } from "node:url"; |
| 20 | |
| 21 | const root = fileURLToPath(new URL("..", import.meta.url)); |
| 22 | const probePage = NodePath.join(root, "app", "hmr-probe", "page.jsx"); |
| 23 | |
| 24 | let child: NodeChildProcess.ChildProcess; |
| 25 | let url: URL; |
| 26 | |
| 27 | /** GET `path` until `predicate(body)` holds — bounded polling. */ |
| 28 | const pollText = async ( |
| 29 | path: string, |
| 30 | predicate: (body: string) => boolean, |
| 31 | { times = 60 }: { times?: number } = {}, |
| 32 | ): Promise<string> => { |
| 33 | let last = ""; |
| 34 | for (let i = 0; i < times; i++) { |
| 35 | try { |
| 36 | const response = await fetch(new URL(path, url)); |
| 37 | const body = await response.text(); |
| 38 | last = `status ${response.status}: ${body.slice(0, 500).replace(/\n/g, " ")}`; |
| 39 | if (response.status === 200 && predicate(body)) return body; |
| 40 | } catch (error) { |
| 41 | last = String(error); |
| 42 | } |
| 43 | await new Promise((resolve) => setTimeout(resolve, 500)); |
| 44 | } |
| 45 | throw new Error(`not ready after ${times} attempts: ${last}`); |
| 46 | }; |
| 47 | |
| 48 | test.beforeAll(async () => { |
| 49 | // Cold `next dev` prepare + first Turbopack compile can be slow. |
| 50 | test.setTimeout(300_000); |
| 51 | child = NodeChildProcess.spawn( |
| 52 | process.execPath, |
| 53 | [NodePath.join(root, "scripts", "hmr-server.mjs")], |
| 54 | { cwd: root, stdio: ["ignore", "pipe", "inherit"] }, |
| 55 | ); |
| 56 | url = await new Promise<URL>((resolve, reject) => { |
| 57 | let buffer = ""; |
| 58 | child.stdout!.on("data", (chunk: Buffer) => { |
| 59 | buffer += chunk.toString(); |
| 60 | const match = buffer.match(/DEV_URL (\S+)/); |
| 61 | if (match !== null) resolve(new URL(match[1]!)); |
| 62 | }); |
| 63 | child.once("exit", (code) => |
| 64 | reject( |
| 65 | new Error( |
| 66 | `hmr-server exited before reporting a URL (code ${code}): ${buffer}`, |
| 67 | ), |
| 68 | ), |
| 69 | ); |
| 70 | }); |
| 71 | }); |
| 72 | |
| 73 | test.afterAll(async () => { |
| 74 | if (child !== undefined && child.exitCode === null) { |
| 75 | const exited = new Promise<void>((resolve) => |
| 76 | child.once("exit", () => resolve()), |
| 77 | ); |
| 78 | child.kill("SIGTERM"); |
| 79 | // The driver closes the Next app + binding proxy on SIGTERM; escalate |
| 80 | // if it wedges so the suite never hangs on teardown. |
| 81 | const timeout = setTimeout(() => child.kill("SIGKILL"), 15_000); |
| 82 | await exited; |
| 83 | clearTimeout(timeout); |
| 84 | } |
| 85 | }); |
| 86 | |
| 87 | test.describe("hmr dev mode", () => { |
| 88 | test("serves the SSR page through next dev", async () => { |
| 89 | const body = await pollText("/", (b) => |
| 90 | b.includes("fixtures-nextjs SSR page"), |
| 91 | ); |
| 92 | expect(body).toContain("rendered-at:"); |
| 93 | }); |
| 94 | |
| 95 | test("getCloudflareContext().env binding is visible in SSR output", async () => { |
| 96 | const body = await pollText("/hmr-probe", (b) => |
| 97 | b.includes("hmr-binding:"), |
| 98 | ); |
| 99 | // React may render an empty comment between adjacent text nodes. |
| 100 | expect(body).toMatch(/hmr-binding:(?:<!-- -->)?hello-from-binding/); |
| 101 | expect(body).toContain("marker:v1"); |
| 102 | }); |
| 103 | |
| 104 | test("reads a Text binding through getCloudflareContext in an API route", async () => { |
| 105 | const response = await fetch(new URL("/api/binding", url)); |
| 106 | expect(response.status).toBe(200); |
| 107 | const json = (await response.json()) as { value: string | null }; |
| 108 | expect(json.value).toBe("hello-from-binding"); |
| 109 | }); |
| 110 | |
| 111 | test("reflects a page edit on a subsequent request", async () => { |
| 112 | test.setTimeout(180_000); |
| 113 | // Warm the page first so the edit is an incremental recompile. |
| 114 | await pollText("/hmr-probe", (b) => b.includes("marker:v1")); |
| 115 | const original = await NodeFs.readFile(probePage, "utf8"); |
| 116 | try { |
| 117 | await NodeFs.writeFile( |
| 118 | probePage, |
| 119 | original.replace("marker:v1", "marker:v2"), |
| 120 | "utf8", |
| 121 | ); |
| 122 | const body = await pollText( |
| 123 | "/hmr-probe", |
| 124 | (b) => b.includes("marker:v2"), |
| 125 | { times: 120 }, |
| 126 | ); |
| 127 | expect(body).toContain("marker:v2"); |
| 128 | } finally { |
| 129 | await NodeFs.writeFile(probePage, original, "utf8"); |
| 130 | } |
| 131 | }); |
| 132 | }); |