| 1 | import * as Playwright from "@alchemy.run/cloudflare-test-tools/e2e/Playwright"; |
| 2 | import { 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 | */ |
| 11 | const get = (server: { url: URL }, path: string) => |
| 12 | fetch(new URL(path, server.url)); |
| 13 | |
| 14 | for (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 | } |