| 1 | import * as Playwright from "@alchemy.run/cloudflare-test-tools/e2e/Playwright"; |
| 2 | import { expect, test } from "@playwright/test"; |
| 3 | |
| 4 | const SECRET = "s3cret-from-binding"; |
| 5 | const UUID_REGEX = |
| 6 | /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; |
| 7 | |
| 8 | for (const mode of Playwright.SERVER_METHODS) { |
| 9 | test.describe(mode, () => { |
| 10 | const it = Playwright.make(mode); |
| 11 | |
| 12 | it("server-renders the home page with platform.env", async ({ |
| 13 | page, |
| 14 | server, |
| 15 | }) => { |
| 16 | const response = await page.goto(server.url.toString()); |
| 17 | expect(response?.status()).toBe(200); |
| 18 | await expect(page.locator("#secret")).toHaveText(`secret:${SECRET}`); |
| 19 | await expect(page.locator("#ctx")).toHaveText("ctx:yes"); |
| 20 | await expect(page.locator("#devalued")).toHaveText("devalued:{n:1}"); |
| 21 | }); |
| 22 | |
| 23 | it("hydrates the client-interactive counter", async ({ page, server }) => { |
| 24 | await page.goto(new URL("/counter", server.url).toString()); |
| 25 | await expect(page.locator("#count")).toHaveText("count:0"); |
| 26 | // wait for hydration before interacting (the effect flips the marker) |
| 27 | await expect(page.locator("#increment")).toHaveAttribute( |
| 28 | "data-hydrated", |
| 29 | "true", |
| 30 | ); |
| 31 | await page.click("#increment"); |
| 32 | await expect(page.locator("#count")).toHaveText("count:1"); |
| 33 | await page.click("#increment"); |
| 34 | await expect(page.locator("#count")).toHaveText("count:2"); |
| 35 | }); |
| 36 | |
| 37 | it("serves the prerendered page", async ({ server }) => { |
| 38 | const response = await server.fetch("/prerendered"); |
| 39 | expect(response.status).toBe(200); |
| 40 | expect(await response.text()).toContain("this-page-is-prerendered"); |
| 41 | }); |
| 42 | |
| 43 | it("serves static assets", async ({ server }) => { |
| 44 | const response = await server.fetch("/robots.txt"); |
| 45 | expect(response.status).toBe(200); |
| 46 | expect(await response.text()).toContain("User-agent: *"); |
| 47 | }); |
| 48 | |
| 49 | it("runs the server endpoint (cookie, uuid, node:crypto, platform.env)", async ({ |
| 50 | server, |
| 51 | }) => { |
| 52 | const body = await server.fetchJson<{ |
| 53 | uuid: string; |
| 54 | nodeUuid: string; |
| 55 | cookie: string; |
| 56 | secret: string; |
| 57 | }>("/api/hello"); |
| 58 | expect(body.cookie).toBe("fixture=ok"); |
| 59 | expect(body.secret).toBe(SECRET); |
| 60 | expect(body.uuid).toMatch(UUID_REGEX); |
| 61 | expect(body.nodeUuid).toMatch(UUID_REGEX); |
| 62 | }); |
| 63 | |
| 64 | it("honors the user vite.config.ts (kit alias, user plugin, adapter override)", async ({ |
| 65 | server, |
| 66 | }) => { |
| 67 | // `$fixture` (a user kit alias) and `virtual:fixture-marker` (a user |
| 68 | // Vite plugin) both live in the fixture's own vite.config.ts — this |
| 69 | // endpoint only works when that file is loaded natively. The config |
| 70 | // also declares a user adapter that THROWS from `adapt()`, so live |
| 71 | // builds succeeding at all proves the deploy target's adapter replaced |
| 72 | // it. |
| 73 | const body = await server.fetchJson<{ marker: string; greeting: string }>( |
| 74 | "/api/user-config", |
| 75 | ); |
| 76 | expect(body.marker).toBe("user-vite-plugin-active"); |
| 77 | expect(body.greeting).toBe("greeting-via-user-alias"); |
| 78 | }); |
| 79 | |
| 80 | it("runs a form action via progressive enhancement (no reload)", async ({ |
| 81 | page, |
| 82 | server, |
| 83 | }) => { |
| 84 | await page.goto(new URL("/form", server.url).toString()); |
| 85 | await expect(page.locator("#form-result")).toHaveText("result:none"); |
| 86 | // wait for hydration, then plant a marker a full-page navigation would lose |
| 87 | await expect(page.locator("#submit")).toHaveAttribute( |
| 88 | "data-hydrated", |
| 89 | "true", |
| 90 | ); |
| 91 | await page.evaluate(() => { |
| 92 | (window as { __enhanced?: boolean }).__enhanced = true; |
| 93 | }); |
| 94 | await page.fill("#name", "playwright"); |
| 95 | await page.click("#submit"); |
| 96 | await expect(page.locator("#form-result")).toHaveText( |
| 97 | "result:hello playwright", |
| 98 | ); |
| 99 | await expect(page.locator("#form-secret")).toHaveText(`secret:${SECRET}`); |
| 100 | const marker = await page.evaluate( |
| 101 | () => (window as { __enhanced?: boolean }).__enhanced, |
| 102 | ); |
| 103 | expect(marker).toBe(true); |
| 104 | }); |
| 105 | |
| 106 | it("runs the form action without JavaScript (plain POST re-render)", async ({ |
| 107 | request, |
| 108 | server, |
| 109 | }) => { |
| 110 | // a real HTTP POST (no client-side kit runtime involved), as a |
| 111 | // JavaScript-less browser would submit it |
| 112 | const response = await request.post( |
| 113 | new URL("/form?/greet", server.url).toString(), |
| 114 | { |
| 115 | form: { name: "curl" }, |
| 116 | headers: { |
| 117 | // kit's CSRF check requires a same-origin `origin` header on form |
| 118 | // posts; `accept: text/html` selects the no-JS HTML re-render (kit |
| 119 | // answers JSON for enhanced submissions) |
| 120 | origin: server.url.origin, |
| 121 | accept: "text/html", |
| 122 | }, |
| 123 | }, |
| 124 | ); |
| 125 | expect(response.status()).toBe(200); |
| 126 | expect(await response.text()).toContain("hello curl"); |
| 127 | }); |
| 128 | |
| 129 | it("round-trips cookies through the +page.server load", async ({ |
| 130 | page, |
| 131 | server, |
| 132 | }) => { |
| 133 | await page.goto(new URL("/cookies", server.url).toString()); |
| 134 | await expect(page.locator("#visits")).toHaveText("visits:1"); |
| 135 | await page.reload(); |
| 136 | await expect(page.locator("#visits")).toHaveText("visits:2"); |
| 137 | await page.reload(); |
| 138 | await expect(page.locator("#visits")).toHaveText("visits:3"); |
| 139 | }); |
| 140 | |
| 141 | it("serves a binary endpoint response intact", async ({ server }) => { |
| 142 | const response = await server.fetch("/api/binary"); |
| 143 | expect(response.status).toBe(200); |
| 144 | expect(response.headers.get("content-type")).toBe( |
| 145 | "application/octet-stream", |
| 146 | ); |
| 147 | expect(response.headers.get("x-binary-length")).toBe("256"); |
| 148 | const bytes = new Uint8Array(await response.arrayBuffer()); |
| 149 | expect(bytes.byteLength).toBe(256); |
| 150 | expect(bytes[0]).toBe(0); |
| 151 | expect(bytes[128]).toBe(128); |
| 152 | expect(bytes[255]).toBe(255); |
| 153 | }); |
| 154 | |
| 155 | it("serves a route-group page with layout data (group not in URL)", async ({ |
| 156 | page, |
| 157 | server, |
| 158 | }) => { |
| 159 | const response = await page.goto( |
| 160 | new URL("/about", server.url).toString(), |
| 161 | ); |
| 162 | expect(response?.status()).toBe(200); |
| 163 | await expect(page.locator("#layout-section")).toHaveText( |
| 164 | "section:marketing", |
| 165 | ); |
| 166 | await expect(page.locator("#layout-secret")).toHaveText( |
| 167 | `layout-secret:${SECRET}`, |
| 168 | ); |
| 169 | await expect(page.locator("#about-marker")).toHaveText( |
| 170 | "about-inside-group", |
| 171 | ); |
| 172 | // the group directory name itself is not routable |
| 173 | const literal = await server.fetch("/(marketing)/about"); |
| 174 | expect(literal.status).toBe(404); |
| 175 | }); |
| 176 | |
| 177 | it("round-trips platform.caches (put on miss, hit on the second request)", async ({ |
| 178 | server, |
| 179 | }) => { |
| 180 | // a fresh key per invocation so retries never see a warm cache |
| 181 | const key = crypto.randomUUID(); |
| 182 | type CacheProbe = { supported: boolean; cached: boolean; key: string }; |
| 183 | const first = await server.fetchJson<CacheProbe>(`/api/cache?key=${key}`); |
| 184 | expect(first.supported).toBe(true); |
| 185 | expect(first.cached).toBe(false); |
| 186 | expect(first.key).toBe(key); |
| 187 | const second = await server.fetchJson<CacheProbe>( |
| 188 | `/api/cache?key=${key}`, |
| 189 | ); |
| 190 | expect(second.supported).toBe(true); |
| 191 | // live runs on workerd with the real Cache API; dev serves `caches` |
| 192 | // through cloudflare-runtime's platform proxy, which round-trips too |
| 193 | // (unlike wrangler's no-op dev cache). |
| 194 | expect(second.cached).toBe(true); |
| 195 | }); |
| 196 | |
| 197 | it("serves real bindings, env overrides, and cf through platform", async ({ |
| 198 | page, |
| 199 | server, |
| 200 | }) => { |
| 201 | const response = await page.goto( |
| 202 | new URL("/platform", server.url).toString(), |
| 203 | ); |
| 204 | expect(response?.status()).toBe(200); |
| 205 | // FIXTURE_KV is a real KV namespace binding — in dev the put/get pair |
| 206 | // round-trips through the platform proxy's workerd instance |
| 207 | await expect(page.locator("#kv")).toHaveText("kv:kv-round-trip"); |
| 208 | // FIXTURE_OVERRIDE: the dev `env` literal wins over the proxied Text |
| 209 | // binding; live serves the binding value |
| 210 | await expect(page.locator("#override")).toHaveText( |
| 211 | mode === "dev" ? "override:literal-override" : "override:proxied-value", |
| 212 | ); |
| 213 | // `cf` is populated in both modes (wrangler-parity mock in dev) |
| 214 | await expect(page.locator("#cf")).toHaveText("cf-colo:present"); |
| 215 | }); |
| 216 | }); |
| 217 | } |