1import * as Playwright from "@alchemy.run/cloudflare-test-tools/e2e/Playwright";
2import { expect, test } from "@playwright/test";
3import { readFile, writeFile } from "node:fs/promises";
4import { fileURLToPath } from "node:url";
5
6const SECRET = "s3cret-from-binding";
7
8// Both modes run the shared contract: `event.context.cloudflare` is served
9// natively by the cloudflare_module preset in live mode, and by the
10// platform-proxy dev bridge (nitro's SSR worker thread → workerd, wrangler-
11// free) in dev mode.
12for (const mode of Playwright.SERVER_METHODS) {
13 test.describe(mode, () => {
14 const it = Playwright.make(mode);
15
16 it("server-renders the home page with the cloudflare env binding", async ({
17 page,
18 server,
19 }) => {
20 const response = await page.goto(server.url.toString());
21 expect(response?.status()).toBe(200);
22 await expect(page.getByTestId("page-marker")).toHaveText("NUXT_FIXTURE");
23 // `runtimeConfig.public.fixtureMarker` comes from the user's own
24 // nuxt.config.ts — proves the config file loaded natively (in dev
25 // too: the dev driver injects only the overrides layer).
26 await expect(page.getByTestId("config-marker")).toHaveText(
27 "user-nuxt-config-loaded",
28 );
29 // Read during SSR from event.context.cloudflare.env.
30 await expect(page.getByTestId("env-secret")).toHaveText(
31 `secret:${SECRET}`,
32 );
33 });
34
35 it("hydrates the client-interactive counter", async ({ page, server }) => {
36 await page.goto(new URL("/counter", server.url).toString());
37 await expect(page.locator("#count")).toHaveText("count:0");
38 // wait for hydration before interacting (onMounted flips the marker)
39 await expect(page.locator("#increment")).toHaveAttribute(
40 "data-hydrated",
41 "true",
42 );
43 await page.click("#increment");
44 await expect(page.locator("#count")).toHaveText("count:1");
45 await page.click("#increment");
46 await expect(page.locator("#count")).toHaveText("count:2");
47 });
48
49 it("serves the prerendered page", async ({ server }) => {
50 // Live: written into .output/public at build time and served by the
51 // assets layer. Dev: rendered on demand (dev does not prerender).
52 const response = await server.fetch("/prerendered");
53 expect(response.status).toBe(200);
54 expect(await response.text()).toContain("this-page-is-prerendered");
55 });
56
57 it("serves static assets", async ({ server }) => {
58 const response = await server.fetch("/robots.txt");
59 expect(response.status).toBe(200);
60 expect(await response.text()).toContain("User-agent: *");
61 });
62
63 it("runs the API route with the cloudflare runtime context", async ({
64 server,
65 }) => {
66 const body = await server.fetchJson<{
67 marker: string;
68 secret: string | null;
69 hasWaitUntil: boolean;
70 }>("/api/hello");
71 expect(body.marker).toBe("api-route-ok");
72 expect(body.secret).toBe(SECRET);
73 expect(body.hasWaitUntil).toBe(true);
74 });
75
76 it("round-trips KV through event.context.cloudflare.env", async ({
77 server,
78 }) => {
79 // Random payload data (not a resource name) — fine per the naming
80 // doctrine, and proves the read observes THIS run's write.
81 const value = `value-${Math.random().toString(36).slice(2)}`;
82 const put = await fetch(
83 new URL(
84 `/api/kv?key=e2e-key&value=${encodeURIComponent(value)}`,
85 server.url,
86 ),
87 { method: "POST" },
88 );
89 expect(put.status).toBe(200);
90 expect(((await put.json()) as { put: boolean }).put).toBe(true);
91 const read = await server.fetchJson<{ value: string | null }>(
92 "/api/kv?key=e2e-key",
93 );
94 expect(read.value).toBe(value);
95 });
96
97 if (mode === "live") {
98 // Live-only: the Counter DO class reaches the worker through nitro's
99 // entry/exports seam (`worker-entry.ts`), which only exists in the
100 // production build — the dev platform proxy cannot host it (yet).
101 it("increments the Counter DO exported through the entry/exports seam", async ({
102 server,
103 }) => {
104 // Plain HTTP fetch (not `server.fetch`): the live-mode dispatchFetch
105 // wrapper drops RequestInit, losing the POST method.
106 const post = async (): Promise<number> => {
107 const response = await fetch(new URL("/api/counter", server.url), {
108 method: "POST",
109 });
110 expect(response.status).toBe(200);
111 const body = (await response.json()) as { count: number };
112 return body.count;
113 };
114
115 const first = await post();
116 const second = await post();
117 // Relative assertion: DO storage may persist across runs locally.
118 expect(second).toBe(first + 1);
119
120 // GET observes the state written by the POSTs — same DO instance.
121 const read = await fetch(new URL("/api/counter", server.url));
122 expect(read.status).toBe(200);
123 expect(((await read.json()) as { count: number }).count).toBe(second);
124 });
125 }
126
127 if (mode === "dev") {
128 // Dev-only: a server-route edit is reflected on the next request
129 // (nitro dev rebuild + worker-thread replacement; the bridge plugin
130 // reconnects on its own with binding state intact).
131 it("reflects a server-route edit on the next request", async ({
132 server,
133 }) => {
134 const file = fileURLToPath(
135 new URL("../server/api/hmr.ts", import.meta.url),
136 );
137 const source = await readFile(file, "utf8");
138 const edited = source.replace('"hmr-marker-v1"', '"hmr-marker-v2"');
139 expect(edited).not.toBe(source);
140
141 // Bounded poll: mid-rebuild requests may fail; retry until the
142 // expected marker (or give up well inside the test timeout).
143 const pollFor = async (expected: string) => {
144 for (let attempt = 0; attempt < 30; attempt++) {
145 try {
146 const body = await server.fetchJson<{ marker: string }>(
147 "/api/hmr",
148 );
149 if (body.marker === expected) return;
150 } catch {
151 // mid-rebuild — retry
152 }
153 await new Promise((resolve) => setTimeout(resolve, 500));
154 }
155 throw new Error(
156 `timed out waiting for /api/hmr to serve "${expected}"`,
157 );
158 };
159
160 await pollFor("hmr-marker-v1");
161 try {
162 await writeFile(file, edited);
163 await pollFor("hmr-marker-v2");
164 } finally {
165 await writeFile(file, source);
166 }
167 // Wait for the restore to land so later specs see a settled server.
168 await pollFor("hmr-marker-v1");
169
170 // The bridge survived the reloads: bindings still resolve.
171 const hello = await server.fetchJson<{ secret: string | null }>(
172 "/api/hello",
173 );
174 expect(hello.secret).toBe(SECRET);
175 });
176 }
177 });
178}