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 */
15import { expect, test } from "@playwright/test";
16import * as NodeChildProcess from "node:child_process";
17import * as NodeFs from "node:fs/promises";
18import * as NodePath from "node:path";
19import { fileURLToPath } from "node:url";
20
21const root = fileURLToPath(new URL("..", import.meta.url));
22const probePage = NodePath.join(root, "app", "hmr-probe", "page.jsx");
23
24let child: NodeChildProcess.ChildProcess;
25let url: URL;
26
27/** GET `path` until `predicate(body)` holds — bounded polling. */
28const 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
48test.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
73test.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
87test.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});