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 CLIENT_MARKER_FILE = fileURLToPath(
7 new URL("../src/client-marker.ts", import.meta.url),
8);
9const 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.
18const 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
33for (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}