| 1 | import * as Playwright from "@alchemy.run/cloudflare-test-tools/e2e/Playwright"; |
| 2 | import { expect, test } from "@playwright/test"; |
| 3 | import { readFile, writeFile } from "node:fs/promises"; |
| 4 | import { fileURLToPath } from "node:url"; |
| 5 | |
| 6 | const CLIENT_MARKER_FILE = fileURLToPath( |
| 7 | new URL("../src/client-marker.ts", import.meta.url), |
| 8 | ); |
| 9 | const SERVER_MARKER_FILE = fileURLToPath( |
| 10 | new URL("../src/server-marker.ts", import.meta.url), |
| 11 | ); |
| 12 | |
| 13 | // Write `content` and poll `check` until the dev server reflects it, |
| 14 | // re-writing periodically: linux CI's watcher occasionally drops the change |
| 15 | // event for a file edited twice in quick succession, and a re-write (same |
| 16 | // bytes, new mtime) re-fires it. Each write is a legitimate hot update, so |
| 17 | // specs asserting update semantics (state preservation) remain valid. |
| 18 | const writeUntil = async ( |
| 19 | file: string, |
| 20 | content: string, |
| 21 | check: () => Promise<boolean>, |
| 22 | ) => { |
| 23 | for (let attempt = 0; attempt < 12; attempt++) { |
| 24 | await writeFile(file, content, "utf8"); |
| 25 | for (let poll = 0; poll < 10; poll++) { |
| 26 | if (await check()) return; |
| 27 | await new Promise((resolve) => setTimeout(resolve, 500)); |
| 28 | } |
| 29 | } |
| 30 | throw new Error(`edit to ${file} never propagated to the dev server`); |
| 31 | }; |
| 32 | |
| 33 | for (const mode of Playwright.SERVER_METHODS) { |
| 34 | test.describe(mode, () => { |
| 35 | const it = Playwright.make(mode); |
| 36 | |
| 37 | // ---- mode-shared smoke: the fixture pulls its weight in live mode too. |
| 38 | |
| 39 | it("loads the page and hydrates the counter", async ({ page, server }) => { |
| 40 | const response = await page.goto(server.url.toString()); |
| 41 | expect(response?.status()).toBe(200); |
| 42 | await expect(page.locator("#client-marker")).toHaveText( |
| 43 | "client-marker-v1", |
| 44 | ); |
| 45 | await expect(page.locator("#counter")).toHaveText("Count is 0"); |
| 46 | await page.click("#counter"); |
| 47 | await expect(page.locator("#counter")).toHaveText("Count is 1"); |
| 48 | }); |
| 49 | |
| 50 | it("serves the worker API route", async ({ server }) => { |
| 51 | const body = await server.fetchJson<{ marker: string }>("/api/marker"); |
| 52 | expect(body.marker).toBe("server-marker-v1"); |
| 53 | }); |
| 54 | |
| 55 | it("serves static assets from public/", async ({ server }) => { |
| 56 | const response = await server.fetch("/hello.txt"); |
| 57 | expect(response.status).toBe(200); |
| 58 | expect(await response.text()).toContain("hello-from-public"); |
| 59 | }); |
| 60 | |
| 61 | // ---- dev-only: edit propagation under the Cloudflare vite plugin. |
| 62 | |
| 63 | it("applies a client-module edit as a hot update that preserves state", async ({ |
| 64 | page, |
| 65 | server, |
| 66 | }) => { |
| 67 | test.skip(mode === "live", "HMR is a dev-server behavior"); |
| 68 | |
| 69 | await page.goto(server.url.toString()); |
| 70 | await expect(page.locator("#client-marker")).toHaveText( |
| 71 | "client-marker-v1", |
| 72 | ); |
| 73 | |
| 74 | // Establish client state that only survives a HOT update: a full page |
| 75 | // reload re-runs client.ts and resets the counter to 0. |
| 76 | await page.click("#counter"); |
| 77 | await expect(page.locator("#counter")).toHaveText("Count is 1"); |
| 78 | |
| 79 | const marker = page.locator("#client-marker"); |
| 80 | const showsMarker = (value: string) => async () => |
| 81 | (await marker.textContent()) === value; |
| 82 | const source = await readFile(CLIENT_MARKER_FILE, "utf8"); |
| 83 | const edited = source.replace('"client-marker-v1"', '"client-marker-v2"'); |
| 84 | expect(edited).not.toBe(source); |
| 85 | try { |
| 86 | await writeUntil( |
| 87 | CLIENT_MARKER_FILE, |
| 88 | edited, |
| 89 | showsMarker("client-marker-v2"), |
| 90 | ); |
| 91 | // The proof of real HMR: the accept callback swapped the marker while |
| 92 | // the module (and its counter state) stayed alive. |
| 93 | await expect(page.locator("#counter")).toHaveText("Count is 1"); |
| 94 | } finally { |
| 95 | // Restore (and wait for it) so later specs see a settled server. |
| 96 | await writeUntil( |
| 97 | CLIENT_MARKER_FILE, |
| 98 | source, |
| 99 | showsMarker("client-marker-v1"), |
| 100 | ); |
| 101 | } |
| 102 | await expect(page.locator("#counter")).toHaveText("Count is 1"); |
| 103 | }); |
| 104 | |
| 105 | it("reflects a worker-module edit on a subsequent request", async ({ |
| 106 | server, |
| 107 | }) => { |
| 108 | test.skip(mode === "live", "edit propagation is a dev-server behavior"); |
| 109 | |
| 110 | // Bounded poll: mid-update requests may fail while the module graph |
| 111 | // invalidates; treat request errors as "not yet". |
| 112 | const servesMarker = (value: string) => async () => { |
| 113 | try { |
| 114 | const body = await server.fetchJson<{ marker: string }>( |
| 115 | "/api/marker", |
| 116 | ); |
| 117 | return body.marker === value; |
| 118 | } catch { |
| 119 | // mid-update — the module graph may be invalidating |
| 120 | return false; |
| 121 | } |
| 122 | }; |
| 123 | const expectMarker = async (value: string) => { |
| 124 | for (let attempt = 0; attempt < 60; attempt++) { |
| 125 | if (await servesMarker(value)()) return; |
| 126 | await new Promise((resolve) => setTimeout(resolve, 500)); |
| 127 | } |
| 128 | throw new Error( |
| 129 | `timed out waiting for /api/marker to serve "${value}"`, |
| 130 | ); |
| 131 | }; |
| 132 | |
| 133 | await expectMarker("server-marker-v1"); |
| 134 | const source = await readFile(SERVER_MARKER_FILE, "utf8"); |
| 135 | const edited = source.replace('"server-marker-v1"', '"server-marker-v2"'); |
| 136 | expect(edited).not.toBe(source); |
| 137 | try { |
| 138 | await writeUntil( |
| 139 | SERVER_MARKER_FILE, |
| 140 | edited, |
| 141 | servesMarker("server-marker-v2"), |
| 142 | ); |
| 143 | } finally { |
| 144 | // Restore (and wait for it) so later specs see a settled server. |
| 145 | await writeUntil( |
| 146 | SERVER_MARKER_FILE, |
| 147 | source, |
| 148 | servesMarker("server-marker-v1"), |
| 149 | ); |
| 150 | } |
| 151 | }); |
| 152 | }); |
| 153 | } |