1import * as Playwright from "@alchemy.run/cloudflare-test-tools/e2e/Playwright";
2import { expect, test } from "@playwright/test";
3
4/**
5 * Requests target the server's real socket URL (not miniflare's
6 * `dispatchFetch("http://localhost/…")` helper): production requests always
7 * carry a real host:port, and OpenNext's middleware-rewrite handling
8 * re-fetches the rewritten absolute URL — a port-less `localhost` host would
9 * send that loopback fetch to port 80.
10 */
11const get = (server: { url: URL }, path: string) =>
12 fetch(new URL(path, server.url));
13
14for (const mode of Playwright.SERVER_METHODS) {
15 test.describe(mode, () => {
16 const it = Playwright.make(mode);
17
18 it("renders the SSR page", async ({ server }) => {
19 const response = await get(server, "/");
20 expect(response.status).toBe(200);
21 const body = await response.text();
22 expect(body).toContain("fixtures-nextjs SSR page");
23 expect(body).toContain("rendered-at:");
24 });
25
26 it("serves the API route with the middleware header", async ({
27 server,
28 }) => {
29 const response = await get(server, "/api/hello");
30 expect(response.status).toBe(200);
31 expect(response.headers.get("x-fixture-middleware")).toBe("passed");
32 const json = (await response.json()) as { hello: string };
33 expect(json.hello).toBe("world");
34 });
35
36 it("rewrites via middleware", async ({ server }) => {
37 const response = await get(server, "/mw-rewrite");
38 expect(response.status).toBe(200);
39 const json = (await response.json()) as { hello: string; url: string };
40 expect(json.hello).toBe("world");
41 expect(json.url).toContain("/api/hello");
42 });
43
44 it("reads a Text binding through getCloudflareContext", async ({
45 server,
46 }) => {
47 const response = await get(server, "/api/binding");
48 expect(response.status).toBe(200);
49 const json = (await response.json()) as { value: string | null };
50 expect(json.value).toBe("hello-from-binding");
51 });
52
53 it("serves the ISR page from the prerendered cache", async ({ server }) => {
54 const first = await get(server, "/isr");
55 expect(first.status).toBe(200);
56 const firstBody = await first.text();
57 const firstStamp = firstBody.match(
58 /isr-rendered-at:(?:<!-- -->)?(\d+)/,
59 )?.[1];
60 expect(firstStamp).toBeDefined();
61
62 // The prerendered payload serves as-is: repeated hits inside the
63 // revalidate window return the same build-time stamp.
64 const second = await get(server, "/isr");
65 expect(second.status).toBe(200);
66 const secondBody = await second.text();
67 const secondStamp = secondBody.match(
68 /isr-rendered-at:(?:<!-- -->)?(\d+)/,
69 )?.[1];
70 expect(secondStamp).toBe(firstStamp);
71 });
72
73 it("serves public/ static assets", async ({ server }) => {
74 const response = await get(server, "/static.txt");
75 expect(response.status).toBe(200);
76 expect(await response.text()).toContain("hello from a static asset");
77 });
78
79 it("serves _next/static client chunks", async ({ server }) => {
80 const html = await (await get(server, "/")).text();
81 const chunk = html.match(/\/_next\/static\/[^"'\s\\]+\.js/)?.[0];
82 expect(chunk).toBeDefined();
83 const response = await get(server, chunk!);
84 expect(response.status).toBe(200);
85 });
86
87 it("hydrates the client-interactive page", async ({ page, server }) => {
88 await page.goto(new URL("/counter", server.url.toString()).toString());
89 await expect(page.getByTestId("count")).toHaveText("count:0");
90 await page.getByTestId("increment").click();
91 await expect(page.getByTestId("count")).toHaveText("count:1");
92 await page.getByTestId("increment").click();
93 await expect(page.getByTestId("count")).toHaveText("count:2");
94 });
95
96 it("serves a prerendered dynamic segment (generateStaticParams)", async ({
97 server,
98 }) => {
99 const response = await get(server, "/products/alpha");
100 expect(response.status).toBe(200);
101 const body = await response.text();
102 expect(body).toContain("PRODUCT_PAGE_MARKER");
103 expect(body).toMatch(/product-slug:(?:<!-- -->)?alpha/);
104 });
105
106 it("renders a non-prerendered dynamic segment on demand", async ({
107 server,
108 }) => {
109 const response = await get(server, "/products/gamma");
110 expect(response.status).toBe(200);
111 expect(await response.text()).toMatch(/product-slug:(?:<!-- -->)?gamma/);
112 });
113
114 it("renders a catch-all dynamic segment", async ({ server }) => {
115 const response = await get(server, "/docs/guides/deploy/workers");
116 expect(response.status).toBe(200);
117 const body = await response.text();
118 expect(body).toContain("DOCS_CATCHALL_MARKER");
119 expect(body).toMatch(/docs-path:(?:<!-- -->)?guides\/deploy\/workers/);
120 });
121
122 it("mutates a KV binding through a server action form", async ({
123 page,
124 server,
125 }) => {
126 const name = `visitor-${Date.now()}`;
127 await page.goto(new URL("/guestbook", server.url.toString()).toString());
128 await expect(page.locator("h1")).toHaveText("GUESTBOOK_MARKER");
129 await page.locator('input[name="name"]').fill(name);
130 await page.locator('button[type="submit"]').click();
131 // The action writes to FIXTURE_KV and revalidates; the page re-renders
132 // with the latest entry read back from KV.
133 await expect(page.locator("main")).toContainText(
134 `guestbook-latest:${name}`,
135 );
136 });
137
138 it("streams the Suspense shell before the resolved content", async ({
139 server,
140 }) => {
141 // `accept-encoding: identity`: the local live (miniflare) serving path
142 // buffers gzip bodies to completion, which would hide the progressive
143 // flush this test asserts. Production Cloudflare streams gzip fine.
144 const response = await fetch(new URL("/streaming", server.url), {
145 headers: { "accept-encoding": "identity" },
146 });
147 expect(response.status).toBe(200);
148 const reader = response.body!.getReader();
149 const decoder = new TextDecoder();
150 let sawShellFirst = false;
151 let buffer = "";
152 for (;;) {
153 const { done, value } = await reader.read();
154 if (done) break;
155 buffer += decoder.decode(value, { stream: true });
156 if (
157 !sawShellFirst &&
158 buffer.includes("STREAMING_SUSPENSE_MARKER") &&
159 !buffer.includes("STREAMING_RESOLVED_MARKER")
160 ) {
161 // The shell (with the fallback) flushed before the slow segment.
162 sawShellFirst = true;
163 }
164 }
165 expect(sawShellFirst).toBe(true);
166 expect(buffer).toContain("STREAMING_RESOLVED_MARKER");
167 });
168
169 it("serves the custom not-found boundary for unmatched routes", async ({
170 server,
171 }) => {
172 const response = await get(server, "/definitely/not/a/route");
173 expect(response.status).toBe(404);
174 expect(await response.text()).toContain("CUSTOM_NOT_FOUND_MARKER");
175 });
176
177 it("serves the custom not-found boundary from notFound()", async ({
178 server,
179 }) => {
180 const response = await get(server, "/gone");
181 expect(response.status).toBe(404);
182 expect(await response.text()).toContain("CUSTOM_NOT_FOUND_MARKER");
183 });
184
185 it("renders the segment error boundary after a server render throws", async ({
186 page,
187 server,
188 }) => {
189 await page.goto(new URL("/boom", server.url.toString()).toString());
190 await expect(page.locator("h1")).toHaveText("ERROR_BOUNDARY_MARKER");
191 });
192
193 it("applies next.config redirects", async ({ server }) => {
194 const response = await fetch(new URL("/old-home", server.url), {
195 redirect: "manual",
196 });
197 expect(response.status).toBe(308);
198 expect(response.headers.get("location")).toContain("/");
199 });
200
201 it("applies next.config rewrites", async ({ server }) => {
202 const response = await get(server, "/rewritten-hello");
203 expect(response.status).toBe(200);
204 const json = (await response.json()) as { hello: string };
205 expect(json.hello).toBe("world");
206 });
207
208 it("applies next.config headers", async ({ server }) => {
209 const response = await get(server, "/api/hello");
210 expect(response.headers.get("x-fixture-config-header")).toBe(
211 "from-next-config",
212 );
213 });
214
215 it("serves a Pages Router page with getServerSideProps", async ({
216 server,
217 }) => {
218 const response = await get(server, "/legacy");
219 expect(response.status).toBe(200);
220 const body = await response.text();
221 expect(body).toContain("PAGES_ROUTER_MARKER");
222 expect(body).toMatch(/legacy-stamp:(?:<!-- -->)?pages-router-ssr/);
223 });
224
225 it("serves a Pages Router API route", async ({ server }) => {
226 const response = await get(server, "/api/legacy");
227 expect(response.status).toBe(200);
228 const json = (await response.json()) as { legacy: string };
229 expect(json.legacy).toBe("pages-api");
230 });
231 });
232}