1import * as Playwright from "@alchemy.run/cloudflare-test-tools/e2e/Playwright";
2import { expect, test } from "@playwright/test";
3import * as NodeFs from "node:fs/promises";
4import * as NodePath from "node:path";
5
6for (const mode of Playwright.SERVER_METHODS) {
7 test.describe(mode, () => {
8 const it = Playwright.make(mode);
9
10 it("SSR renders the dynamic page with the Text binding", async ({
11 page,
12 server,
13 }) => {
14 const response = await page.goto(server.url.toString());
15 expect(response?.status()).toBe(200);
16 await expect(page.getByTestId("page-marker")).toHaveText("PAGE_MARKER");
17 await expect(page.getByTestId("layout-marker")).toHaveText(
18 "LAYOUT_MARKER",
19 );
20 // Read at request time from `cloudflare:workers` env — proves the rsc
21 // environment runs against the worker runtime in both modes.
22 await expect(page.getByTestId("env-message")).toHaveText(
23 "MESSAGE=hello-from-binding",
24 );
25 });
26
27 it("hydrates the client component", async ({ page, server }) => {
28 await page.goto(server.url.toString());
29 const counter = page.getByTestId("counter");
30 await expect(counter).toHaveText("count: 0");
31 // The first click can land before hydration attaches the listener;
32 // retry until the click observably increments.
33 await expect(async () => {
34 await counter.click();
35 await expect(counter).toHaveText(/count: [1-9]\d*/, { timeout: 500 });
36 }).toPass();
37 });
38
39 it("client-navigates to the static page", async ({ page, server }) => {
40 await page.goto(server.url.toString());
41 await page.click("a[href='/about']");
42 await page.waitForURL("**/about");
43 await expect(page.getByTestId("about-marker")).toHaveText(
44 "ABOUT_STATIC_MARKER",
45 );
46 });
47
48 it("serves the static asset", async ({ server }) => {
49 const response = await server.fetch("/hello.txt");
50 expect(response.status).toBe(200);
51 expect(await response.text()).toContain("hello from public/");
52 });
53
54 it("round-trips a server action (form mutation) through the worker", async ({
55 page,
56 server,
57 }) => {
58 await page.goto(server.url.toString());
59 await page.fill("[data-testid=greet-name]", "Waku");
60 // The submit can land before hydration attaches the action; retry
61 // until the round-trip observably completes. The MESSAGE suffix proves
62 // the action executed inside the worker runtime with bindings.
63 await expect(async () => {
64 await page.click("[data-testid=greet-submit]");
65 await expect(page.getByTestId("greet-output")).toHaveText(
66 "Hello, Waku! MESSAGE=hello-from-binding",
67 { timeout: 1_000 },
68 );
69 }).toPass();
70 });
71
72 it("SSRs the dynamic route with a path param", async ({ page, server }) => {
73 const response = await page.goto(
74 new URL("/items/42", server.url).toString(),
75 );
76 expect(response?.status()).toBe(200);
77 await expect(page.getByTestId("item-marker")).toHaveText(
78 "ITEM_MARKER id=42",
79 );
80 await expect(page.getByTestId("item-env")).toHaveText(
81 "MESSAGE=hello-from-binding",
82 );
83 });
84
85 it("serves the API route (GET) from the worker", async ({ server }) => {
86 const response = await server.fetch("/echo");
87 expect(response.status).toBe(200);
88 expect(await response.json()).toEqual({ message: "hello-from-binding" });
89 });
90
91 it("round-trips a POST through the API route", async ({ server }) => {
92 // Plain HTTP (not `server.fetch`): the live-mode dispatchFetch wrapper
93 // in the harness currently drops the RequestInit, losing POST bodies
94 // (packages/cloudflare-test-tools/src/miniflare/miniflare.ts `fetch(path)` has no init
95 // parameter). Both modes listen on a real socket, so this exercises
96 // the same worker path.
97 const response = await fetch(new URL("/echo", server.url), {
98 method: "POST",
99 headers: { "content-type": "application/json" },
100 body: JSON.stringify({ ping: "pong" }),
101 });
102 expect(response.status).toBe(200);
103 expect(await response.json()).toEqual({
104 echoed: { ping: "pong" },
105 message: "hello-from-binding",
106 });
107 });
108
109 it("runs the src/middleware header middleware on worker responses", async ({
110 server,
111 }) => {
112 // "/" is dynamic, so it always goes through the worker (in live mode
113 // static assets bypass the middleware by design).
114 const response = await server.fetch("/");
115 expect(response.status).toBe(200);
116 expect(response.headers.get("x-waku-middleware")).toBe("fixtures-waku");
117 });
118
119 it("serves the page with a top-level cloudflare:workers import", async ({
120 page,
121 server,
122 }) => {
123 const response = await page.goto(
124 new URL("/ssg-env", server.url).toString(),
125 );
126 expect(response?.status()).toBe(200);
127 await expect(page.getByTestId("ssg-env-marker")).toHaveText(
128 "SSG_ENV_MARKER",
129 );
130 await expect(page.getByTestId("ssg-env-message")).toHaveText(
131 "MESSAGE=hello-from-binding",
132 );
133 });
134
135 it("honors waku.config.ts (user vite plugin's virtual module)", async ({
136 page,
137 server,
138 }) => {
139 // The page imports `virtual:fixtures-waku/user-config-marker`, served
140 // by a USER vite plugin declared in waku.config.ts — it renders only
141 // when the integration loads the user's config file natively and
142 // merges its own plugins over it instead of replacing the file.
143 const response = await page.goto(
144 new URL("/config-marker", server.url).toString(),
145 );
146 expect(response?.status()).toBe(200);
147 await expect(page.getByTestId("config-marker")).toHaveText(
148 "hello-from-waku-config",
149 );
150 });
151
152 it("keeps layout client state across client navigation", async ({
153 page,
154 server,
155 }) => {
156 await page.goto(server.url.toString());
157 const counter = page.getByTestId("nav-counter");
158 await expect(counter).toHaveText("nav-count: 0");
159 await expect(async () => {
160 await counter.click();
161 await expect(counter).toHaveText(/nav-count: [1-9]\d*/, {
162 timeout: 500,
163 });
164 }).toPass();
165 const count = await counter.textContent();
166 await page.click("a[href='/about']");
167 await page.waitForURL("**/about");
168 await expect(page.getByTestId("about-marker")).toHaveText(
169 "ABOUT_STATIC_MARKER",
170 );
171 // The layout stays mounted through waku's client navigation, so the
172 // client component's state survives the page swap.
173 await expect(counter).toHaveText(count!);
174 });
175 });
176}
177
178// SSG output only exists in the production build: `dist/public` contains the
179// prerendered HTML and RSC payload, served by the assets layer in live mode.
180test.describe("live ssg", () => {
181 const it = Playwright.make("live");
182
183 it("serves the SSG page from assets", async ({ server }) => {
184 const response = await server.fetch("/about");
185 expect(response.status).toBe(200);
186 expect(await response.text()).toContain("ABOUT_STATIC_MARKER");
187 });
188
189 it("serves the SSG RSC payload from assets under the user-configured rscBase", async ({
190 server,
191 }) => {
192 // waku.config.ts sets `rscBase: "custom-rsc"` (default: "RSC") — the
193 // payload only lives here when the user's config file is honored.
194 const response = await server.fetch("/custom-rsc/R/about.txt");
195 expect(response.status).toBe(200);
196 const missing = await server.fetch("/RSC/R/about.txt");
197 expect(missing.status).toBe(404);
198 });
199
200 it("prerendered the cloudflare:workers page inside workerd with real bindings", async () => {
201 // The strongest SSG-in-workerd proof: the emitted static HTML for the
202 // page with a TOP-LEVEL `cloudflare:workers` import contains the binding
203 // value — the prerender ran inside workerd with the worker's env (in
204 // Node it would have failed with ERR_UNSUPPORTED_ESM_URL_SCHEME).
205 const html = await NodeFs.readFile(
206 NodePath.join(
207 import.meta.dirname,
208 "..",
209 "dist",
210 "public",
211 "ssg-env",
212 "index.html",
213 ),
214 "utf8",
215 );
216 expect(html).toContain("SSG_ENV_MARKER");
217 // React separates adjacent text nodes with `<!-- -->` in SSR output, so
218 // match with the comment stripped: `MESSAGE=<!-- -->hello-from-binding`.
219 expect(html.replaceAll("<!-- -->", "")).toContain(
220 "MESSAGE=hello-from-binding",
221 );
222 });
223});