cloudflare/vinext
Publicmirrored from https://github.com/cloudflare/vinextAvailable
tests/ecosystem.test.ts
326lines · modecode
| 1 | /** |
| 2 | * Ecosystem integration tests — verifies popular third-party libraries |
| 3 | * work correctly with vinext. |
| 4 | * |
| 5 | * Uses subprocess-based testing: starts Vite dev server as a child process, |
| 6 | * waits for it to be ready, makes HTTP requests, and asserts SSR output. |
| 7 | * This approach is necessary because the RSC module runner in programmatic |
| 8 | * createServer() bypasses Vite's resolveId for `next` package resolution. |
| 9 | * |
| 10 | * Run with: npx vitest run tests/ecosystem.test.ts |
| 11 | */ |
| 12 | import { describe, it, expect, beforeAll, afterAll } from "vitest"; |
| 13 | import { spawn, type ChildProcess } from "node:child_process"; |
| 14 | import path from "node:path"; |
| 15 | |
| 16 | const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "ecosystem"); |
| 17 | |
| 18 | /** |
| 19 | * Start a Vite dev server as a child process and wait for it to be ready. |
| 20 | */ |
| 21 | async function startFixture( |
| 22 | name: string, |
| 23 | port: number, |
| 24 | ): Promise<{ |
| 25 | process: ChildProcess; |
| 26 | baseUrl: string; |
| 27 | fetchPage: (pathname: string) => Promise<{ html: string; status: number }>; |
| 28 | }> { |
| 29 | const root = path.join(FIXTURES_DIR, name); |
| 30 | const baseUrl = `http://localhost:${port}`; |
| 31 | |
| 32 | const proc = spawn("npx", ["vite", "--port", String(port), "--strictPort"], { |
| 33 | cwd: root, |
| 34 | stdio: ["pipe", "pipe", "pipe"], |
| 35 | env: { ...process.env }, |
| 36 | }); |
| 37 | |
| 38 | // Wait for the server to be ready |
| 39 | await new Promise<void>((resolve, reject) => { |
| 40 | const timeoutId = setTimeout(() => { |
| 41 | reject(new Error(`Fixture "${name}" did not start within 30s`)); |
| 42 | }, 30000); |
| 43 | |
| 44 | let output = ""; |
| 45 | const onData = (data: Buffer) => { |
| 46 | output += data.toString(); |
| 47 | if (output.includes("ready in") || output.includes("Local:")) { |
| 48 | clearTimeout(timeoutId); |
| 49 | resolve(); |
| 50 | } |
| 51 | }; |
| 52 | |
| 53 | proc.stdout?.on("data", onData); |
| 54 | proc.stderr?.on("data", onData); |
| 55 | proc.on("error", (err) => { |
| 56 | clearTimeout(timeoutId); |
| 57 | reject(err); |
| 58 | }); |
| 59 | proc.on("exit", (code) => { |
| 60 | if (code !== null && code !== 0) { |
| 61 | clearTimeout(timeoutId); |
| 62 | reject( |
| 63 | new Error(`Fixture "${name}" exited with code ${code}: ${output}`), |
| 64 | ); |
| 65 | } |
| 66 | }); |
| 67 | }); |
| 68 | |
| 69 | // Give the server a moment to be fully ready for requests |
| 70 | await new Promise((r) => setTimeout(r, 500)); |
| 71 | |
| 72 | async function fetchPage(pathname: string) { |
| 73 | const res = await fetch(`${baseUrl}${pathname}`, { |
| 74 | signal: AbortSignal.timeout(10000), |
| 75 | }); |
| 76 | const html = await res.text(); |
| 77 | return { html, status: res.status }; |
| 78 | } |
| 79 | |
| 80 | return { process: proc, baseUrl, fetchPage }; |
| 81 | } |
| 82 | |
| 83 | function killProcess(proc: ChildProcess | null) { |
| 84 | if (proc && !proc.killed) { |
| 85 | proc.kill("SIGTERM"); |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | // ─── next-themes ────────────────────────────────────────────────────────────── |
| 90 | describe("next-themes", () => { |
| 91 | let proc: ChildProcess | null = null; |
| 92 | let fetchPage: (path: string) => Promise<{ html: string; status: number }>; |
| 93 | |
| 94 | beforeAll(async () => { |
| 95 | const fixture = await startFixture("next-themes", 4400); |
| 96 | proc = fixture.process; |
| 97 | fetchPage = fixture.fetchPage; |
| 98 | }, 30000); |
| 99 | |
| 100 | afterAll(() => killProcess(proc)); |
| 101 | |
| 102 | it("renders SSR content", async () => { |
| 103 | const { html, status } = await fetchPage("/"); |
| 104 | expect(status).toBe(200); |
| 105 | expect(html).toContain("next-themes test"); |
| 106 | expect(html).toContain('data-testid="ssr-content"'); |
| 107 | expect(html).toContain("Server-rendered content"); |
| 108 | }); |
| 109 | |
| 110 | it("injects theme detection script", async () => { |
| 111 | const { html } = await fetchPage("/"); |
| 112 | expect(html).toContain("prefers-color-scheme"); |
| 113 | expect(html).toContain("localStorage"); |
| 114 | }); |
| 115 | |
| 116 | it("sets html lang attribute", async () => { |
| 117 | const { html } = await fetchPage("/"); |
| 118 | expect(html).toContain('<html lang="en"'); |
| 119 | }); |
| 120 | |
| 121 | it("renders theme toggle buttons", async () => { |
| 122 | const { html } = await fetchPage("/"); |
| 123 | expect(html).toContain('data-testid="theme-loading"'); |
| 124 | }); |
| 125 | }); |
| 126 | |
| 127 | // ─── next-view-transitions ──────────────────────────────────────────────────── |
| 128 | describe("next-view-transitions", () => { |
| 129 | let proc: ChildProcess | null = null; |
| 130 | let fetchPage: (path: string) => Promise<{ html: string; status: number }>; |
| 131 | |
| 132 | beforeAll(async () => { |
| 133 | const fixture = await startFixture("next-view-transitions", 4401); |
| 134 | proc = fixture.process; |
| 135 | fetchPage = fixture.fetchPage; |
| 136 | }, 30000); |
| 137 | |
| 138 | afterAll(() => killProcess(proc)); |
| 139 | |
| 140 | it("renders home page with view transition styles", async () => { |
| 141 | const { html, status } = await fetchPage("/"); |
| 142 | expect(status).toBe(200); |
| 143 | expect(html).toContain("Home Page"); |
| 144 | expect(html).toContain("view-transition-name:title"); |
| 145 | }); |
| 146 | |
| 147 | it("renders Link component from next-view-transitions", async () => { |
| 148 | const { html } = await fetchPage("/"); |
| 149 | expect(html).toContain('data-testid="about-link"'); |
| 150 | expect(html).toContain('href="/about"'); |
| 151 | }); |
| 152 | |
| 153 | it("renders about page", async () => { |
| 154 | const { html, status } = await fetchPage("/about"); |
| 155 | expect(status).toBe(200); |
| 156 | expect(html).toContain("About Page"); |
| 157 | expect(html).toContain("view-transition-name:title"); |
| 158 | }); |
| 159 | |
| 160 | it("renders navigation links", async () => { |
| 161 | const { html } = await fetchPage("/"); |
| 162 | expect(html).toContain('<a href="/">Home</a>'); |
| 163 | expect(html).toContain('<a href="/about">About</a>'); |
| 164 | }); |
| 165 | }); |
| 166 | |
| 167 | // ─── nuqs ───────────────────────────────────────────────────────────────────── |
| 168 | describe("nuqs", () => { |
| 169 | let proc: ChildProcess | null = null; |
| 170 | let fetchPage: (path: string) => Promise<{ html: string; status: number }>; |
| 171 | |
| 172 | beforeAll(async () => { |
| 173 | const fixture = await startFixture("nuqs", 4402); |
| 174 | proc = fixture.process; |
| 175 | fetchPage = fixture.fetchPage; |
| 176 | }, 30000); |
| 177 | |
| 178 | afterAll(() => killProcess(proc)); |
| 179 | |
| 180 | it("renders SSR content", async () => { |
| 181 | const { html, status } = await fetchPage("/"); |
| 182 | expect(status).toBe(200); |
| 183 | expect(html).toContain("nuqs test"); |
| 184 | expect(html).toContain('data-testid="ssr-content"'); |
| 185 | }); |
| 186 | |
| 187 | it("renders search input with default value", async () => { |
| 188 | const { html } = await fetchPage("/"); |
| 189 | expect(html).toContain('data-testid="search-input"'); |
| 190 | expect(html).toContain('placeholder="Type a query..."'); |
| 191 | }); |
| 192 | |
| 193 | it("renders default query state", async () => { |
| 194 | const { html } = await fetchPage("/"); |
| 195 | expect(html).toContain("(empty)"); |
| 196 | expect(html).toContain("Page:"); |
| 197 | }); |
| 198 | |
| 199 | it("renders pagination buttons", async () => { |
| 200 | const { html } = await fetchPage("/"); |
| 201 | expect(html).toContain('data-testid="prev-page"'); |
| 202 | expect(html).toContain('data-testid="next-page"'); |
| 203 | }); |
| 204 | }); |
| 205 | |
| 206 | // ─── better-auth ────────────────────────────────────────────────────────────── |
| 207 | describe("better-auth", () => { |
| 208 | let proc: ChildProcess | null = null; |
| 209 | let baseUrl: string; |
| 210 | let fetchPage: (path: string) => Promise<{ html: string; status: number }>; |
| 211 | |
| 212 | /** Strip Set-Cookie attributes — keep only name=value pairs for Cookie header */ |
| 213 | function toCookieHeader(setCookies: string[]): string { |
| 214 | return setCookies.map((c) => c.split(";")[0]).join("; "); |
| 215 | } |
| 216 | |
| 217 | /** Sign up a user, return session cookies as a Cookie header string */ |
| 218 | async function signUpUser(email: string, password: string, name: string) { |
| 219 | const res = await fetch(`${baseUrl}/api/auth/sign-up/email`, { |
| 220 | method: "POST", |
| 221 | headers: { "Content-Type": "application/json" }, |
| 222 | body: JSON.stringify({ email, password, name }), |
| 223 | signal: AbortSignal.timeout(10000), |
| 224 | }); |
| 225 | return { res, cookieHeader: toCookieHeader(res.headers.getSetCookie()) }; |
| 226 | } |
| 227 | |
| 228 | beforeAll(async () => { |
| 229 | const fixture = await startFixture("better-auth", 4403); |
| 230 | proc = fixture.process; |
| 231 | baseUrl = fixture.baseUrl; |
| 232 | fetchPage = fixture.fetchPage; |
| 233 | }, 30000); |
| 234 | |
| 235 | afterAll(() => killProcess(proc)); |
| 236 | |
| 237 | it("renders SSR content", async () => { |
| 238 | const { html, status } = await fetchPage("/"); |
| 239 | expect(status).toBe(200); |
| 240 | expect(html).toContain("better-auth test"); |
| 241 | expect(html).toContain('data-testid="ssr-content"'); |
| 242 | }); |
| 243 | |
| 244 | it("renders client-side auth status component", async () => { |
| 245 | const { html } = await fetchPage("/"); |
| 246 | // The AuthStatus client component should be SSR-rendered in its loading |
| 247 | // or signed-out state |
| 248 | expect(html).toMatch(/data-testid="auth-(loading|signed-out)"/); |
| 249 | }); |
| 250 | |
| 251 | it("auth API catch-all route responds to GET", async () => { |
| 252 | // better-auth exposes GET /api/auth/get-session |
| 253 | const res = await fetch(`${baseUrl}/api/auth/get-session`, { |
| 254 | signal: AbortSignal.timeout(10000), |
| 255 | }); |
| 256 | expect(res.status).toBe(200); |
| 257 | }); |
| 258 | |
| 259 | it("sign-up flow creates user and returns session", async () => { |
| 260 | const { res } = await signUpUser( |
| 261 | "signup-test@example.com", |
| 262 | "password123456", |
| 263 | "Signup Test", |
| 264 | ); |
| 265 | expect(res.status).toBe(200); |
| 266 | const data = await res.json(); |
| 267 | expect(data.user).toBeDefined(); |
| 268 | expect(data.user.email).toBe("signup-test@example.com"); |
| 269 | expect(data.user.name).toBe("Signup Test"); |
| 270 | }); |
| 271 | |
| 272 | it("session is accessible after sign-in with cookie", async () => { |
| 273 | // Create a user first (self-contained — no dependency on other tests) |
| 274 | await signUpUser("signin-test@example.com", "password123456", "Signin Test"); |
| 275 | |
| 276 | // Sign in to get a session cookie |
| 277 | const signinRes = await fetch(`${baseUrl}/api/auth/sign-in/email`, { |
| 278 | method: "POST", |
| 279 | headers: { "Content-Type": "application/json" }, |
| 280 | body: JSON.stringify({ |
| 281 | email: "signin-test@example.com", |
| 282 | password: "password123456", |
| 283 | }), |
| 284 | signal: AbortSignal.timeout(10000), |
| 285 | }); |
| 286 | expect(signinRes.status).toBe(200); |
| 287 | |
| 288 | const cookieHeader = toCookieHeader(signinRes.headers.getSetCookie()); |
| 289 | |
| 290 | // Fetch session using the cookie |
| 291 | const sessionRes = await fetch(`${baseUrl}/api/auth/get-session`, { |
| 292 | headers: { cookie: cookieHeader }, |
| 293 | signal: AbortSignal.timeout(10000), |
| 294 | }); |
| 295 | expect(sessionRes.status).toBe(200); |
| 296 | const session = await sessionRes.json(); |
| 297 | expect(session.user.email).toBe("signin-test@example.com"); |
| 298 | }); |
| 299 | |
| 300 | it("server component can access session via headers()", async () => { |
| 301 | // Create and sign in a user (self-contained) |
| 302 | await signUpUser("protected-test@example.com", "password123456", "Protected Test"); |
| 303 | |
| 304 | const signinRes = await fetch(`${baseUrl}/api/auth/sign-in/email`, { |
| 305 | method: "POST", |
| 306 | headers: { "Content-Type": "application/json" }, |
| 307 | body: JSON.stringify({ |
| 308 | email: "protected-test@example.com", |
| 309 | password: "password123456", |
| 310 | }), |
| 311 | signal: AbortSignal.timeout(10000), |
| 312 | }); |
| 313 | const cookieHeader = toCookieHeader(signinRes.headers.getSetCookie()); |
| 314 | |
| 315 | // Fetch the protected server component page with the session cookie. |
| 316 | // This exercises: headers() shim -> auth.api.getSession() -> SSR render |
| 317 | const res = await fetch(`${baseUrl}/protected`, { |
| 318 | headers: { cookie: cookieHeader }, |
| 319 | signal: AbortSignal.timeout(15000), |
| 320 | }); |
| 321 | expect(res.status).toBe(200); |
| 322 | const html = await res.text(); |
| 323 | expect(html).toContain('data-testid="protected-heading"'); |
| 324 | expect(html).toContain("Logged in as protected-test@example.com"); |
| 325 | }); |
| 326 | }); |
| 327 | |