/** * Ecosystem integration tests — verifies popular third-party libraries * work correctly with vinext. * * Uses subprocess-based testing: starts Vite dev server as a child process, * waits for it to be ready, makes HTTP requests, and asserts SSR output. * This approach is necessary because the RSC module runner in programmatic * createServer() bypasses Vite's resolveId for `next` package resolution. * * Run with: npx vitest run tests/ecosystem.test.ts */ import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; import type { ChildProcess } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import { FIXTURE_HOOK_TIMEOUT_MS, startFixtureDevServer, stopFixtureDevServer, } from "./fixture-dev-server.js"; const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "ecosystem"); function startFixture(name: string, port: number) { return startFixtureDevServer({ name, port, root: path.join(FIXTURES_DIR, name), }); } // ─── next-themes ────────────────────────────────────────────────────────────── describe("next-themes", () => { let proc: ChildProcess | null = null; let fetchPage: (path: string) => Promise<{ html: string; status: number }>; beforeAll(async () => { const fixture = await startFixture("next-themes", 4400); proc = fixture.process; fetchPage = fixture.fetchPage; }, FIXTURE_HOOK_TIMEOUT_MS); afterAll(() => stopFixtureDevServer(proc)); it("renders SSR content", async () => { const { html, status } = await fetchPage("/"); expect(status).toBe(200); expect(html).toContain("next-themes test"); expect(html).toContain('data-testid="ssr-content"'); expect(html).toContain("Server-rendered content"); }); it("injects theme detection script", async () => { const { html } = await fetchPage("/"); expect(html).toContain("prefers-color-scheme"); expect(html).toContain("localStorage"); }); it("sets html lang attribute", async () => { const { html } = await fetchPage("/"); expect(html).toContain(' { const { html } = await fetchPage("/"); expect(html).toContain('data-testid="theme-loading"'); }); }); // ─── next-view-transitions ──────────────────────────────────────────────────── describe("next-view-transitions", () => { let proc: ChildProcess | null = null; let fetchPage: (path: string) => Promise<{ html: string; status: number }>; beforeAll(async () => { const fixture = await startFixture("next-view-transitions", 4401); proc = fixture.process; fetchPage = fixture.fetchPage; }, FIXTURE_HOOK_TIMEOUT_MS); afterAll(() => stopFixtureDevServer(proc)); it("renders home page with view transition styles", async () => { const { html, status } = await fetchPage("/"); expect(status).toBe(200); expect(html).toContain("Home Page"); expect(html).toContain("view-transition-name:title"); }); it("renders Link component from next-view-transitions", async () => { const { html } = await fetchPage("/"); expect(html).toContain('data-testid="about-link"'); expect(html).toContain('href="/about"'); }); it("renders about page", async () => { const { html, status } = await fetchPage("/about"); expect(status).toBe(200); expect(html).toContain("About Page"); expect(html).toContain("view-transition-name:title"); }); it("renders navigation links", async () => { const { html } = await fetchPage("/"); expect(html).toContain('Home'); expect(html).toContain('About'); }); }); // ─── nuqs ───────────────────────────────────────────────────────────────────── describe("nuqs", () => { let proc: ChildProcess | null = null; let fetchPage: (path: string) => Promise<{ html: string; status: number }>; const fixtureRoot = path.join(FIXTURES_DIR, "nuqs"); beforeAll(async () => { const fixture = await startFixture("nuqs", 4402); proc = fixture.process; fetchPage = fixture.fetchPage; }, FIXTURE_HOOK_TIMEOUT_MS); afterAll(() => stopFixtureDevServer(proc)); it("renders SSR content", async () => { const { html, status } = await fetchPage("/"); expect(status).toBe(200); expect(html).toContain("nuqs test"); expect(html).toContain('data-testid="ssr-content"'); }); it("renders search input with default value", async () => { const { html } = await fetchPage("/"); expect(html).toContain('data-testid="search-input"'); expect(html).toContain('placeholder="Type a query..."'); }); it("renders default query state", async () => { const { html } = await fetchPage("/"); expect(html).toContain("(empty)"); expect(html).toContain("Page:"); }); it("renders pagination buttons", async () => { const { html } = await fetchPage("/"); expect(html).toContain('data-testid="prev-page"'); expect(html).toContain('data-testid="next-page"'); }); it("prebundles next/navigation.js imports against vinext shims", async () => { await fetchPage("/"); const depsDir = path.join(fixtureRoot, "node_modules", ".vite", "deps"); const metadata = JSON.parse(readFileSync(path.join(depsDir, "_metadata.json"), "utf8")) as { optimized?: Record; }; const optimizedAdapterFile = metadata.optimized?.["nuqs/adapters/next/app"]?.file; expect(optimizedAdapterFile).toBeDefined(); const optimizedAdapter = readFileSync(path.join(depsDir, optimizedAdapterFile!), "utf8"); expect(optimizedAdapter).toContain("vinext.navigationRuntime"); expect(optimizedAdapter).toContain("vinext.navigation.readonlySearchParams"); expect(optimizedAdapter).not.toContain("node_modules/.pnpm/next@"); }); }); // ─── next-intl ─────────────────────────────────────────────────────────────── describe("next-intl", () => { let proc: ChildProcess | null = null; let fetchPage: (path: string) => Promise<{ html: string; status: number }>; beforeAll(async () => { const fixture = await startFixture("next-intl", 4403); proc = fixture.process; fetchPage = fixture.fetchPage; }, FIXTURE_HOOK_TIMEOUT_MS); afterAll(() => stopFixtureDevServer(proc)); it("renders English SSR content", async () => { const { html, status } = await fetchPage("/en"); expect(status).toBe(200); expect(html).toContain(' { const { html, status } = await fetchPage("/de"); expect(status).toBe(200); expect(html).toContain(' { let proc: ChildProcess | null = null; let baseUrl: string; let fetchPage: (path: string) => Promise<{ html: string; status: number }>; /** Strip Set-Cookie attributes — keep only name=value pairs for Cookie header */ function toCookieHeader(setCookies: string[]): string { return setCookies.map((c) => c.split(";")[0]).join("; "); } /** Sign up a user, return session cookies as a Cookie header string */ async function signUpUser(email: string, password: string, name: string) { const res = await fetch(`${baseUrl}/api/auth/sign-up/email`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, name }), signal: AbortSignal.timeout(10000), }); return { res, cookieHeader: toCookieHeader(res.headers.getSetCookie()) }; } function nextEmail(prefix: string) { return `${prefix}-${Math.random().toString(36).slice(2, 10)}@example.com`; } beforeAll(async () => { const fixture = await startFixture("better-auth", 4404); proc = fixture.process; baseUrl = fixture.baseUrl; fetchPage = fixture.fetchPage; }, FIXTURE_HOOK_TIMEOUT_MS); afterAll(() => stopFixtureDevServer(proc)); it("renders SSR content", async () => { const { html, status } = await fetchPage("/"); expect(status).toBe(200); expect(html).toContain("better-auth test"); expect(html).toContain('data-testid="ssr-content"'); }); it("renders client-side auth status component", async () => { const { html } = await fetchPage("/"); // The AuthStatus client component should be SSR-rendered in its loading // or signed-out state expect(html).toMatch(/data-testid="auth-(loading|signed-out)"/); }); it("auth API catch-all route responds to GET", async () => { // better-auth exposes GET /api/auth/get-session const res = await fetch(`${baseUrl}/api/auth/get-session`, { signal: AbortSignal.timeout(10000), }); expect(res.status).toBe(200); }); it("sign-up flow creates user and returns session", async () => { const email = nextEmail("signup-test"); const { res } = await signUpUser(email, "password123456", "Signup Test"); expect(res.status).toBe(200); const data = await res.json(); expect(data.user).toBeDefined(); expect(data.user.email).toBe(email); expect(data.user.name).toBe("Signup Test"); }); it("session is accessible after sign-in with cookie", async () => { // Create a user first (self-contained — no dependency on other tests) const email = nextEmail("signin-test"); await signUpUser(email, "password123456", "Signin Test"); // Sign in to get a session cookie const signinRes = await fetch(`${baseUrl}/api/auth/sign-in/email`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password: "password123456", }), signal: AbortSignal.timeout(10000), }); expect(signinRes.status).toBe(200); const cookieHeader = toCookieHeader(signinRes.headers.getSetCookie()); // Fetch session using the cookie const sessionRes = await fetch(`${baseUrl}/api/auth/get-session`, { headers: { cookie: cookieHeader }, signal: AbortSignal.timeout(10000), }); expect(sessionRes.status).toBe(200); const session = await sessionRes.json(); expect(session.user.email).toBe(email); }); it("server component can access session via headers()", async () => { // Create and sign in a user (self-contained) const email = nextEmail("protected-test"); await signUpUser(email, "password123456", "Protected Test"); const signinRes = await fetch(`${baseUrl}/api/auth/sign-in/email`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password: "password123456", }), signal: AbortSignal.timeout(10000), }); const cookieHeader = toCookieHeader(signinRes.headers.getSetCookie()); // Fetch the protected server component page with the session cookie. // This exercises: headers() shim -> auth.api.getSession() -> SSR render const res = await fetch(`${baseUrl}/protected`, { headers: { cookie: cookieHeader }, signal: AbortSignal.timeout(15000), }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain('data-testid="protected-heading"'); expect(html).toContain(`Logged in as ${email}`); }); }); // ─── shadcn (radix-ui) ─────────────────────────────────────────────────────── describe("shadcn", () => { let proc: ChildProcess | null = null; let fetchPage: (path: string) => Promise<{ html: string; status: number }>; beforeAll(async () => { const fixture = await startFixture("shadcn", 4405); proc = fixture.process; fetchPage = fixture.fetchPage; }, FIXTURE_HOOK_TIMEOUT_MS); afterAll(() => stopFixtureDevServer(proc)); it("renders SSR content", async () => { const { html, status } = await fetchPage("/"); expect(status).toBe(200); expect(html).toContain("shadcn test"); expect(html).toContain('data-testid="ssr-content"'); expect(html).toContain("Server-rendered content"); }); it("renders Button component with variants", async () => { const { html } = await fetchPage("/"); expect(html).toContain('data-testid="default-button"'); expect(html).toContain('data-testid="destructive-button"'); expect(html).toContain('data-testid="outline-button"'); expect(html).toContain('data-testid="secondary-button"'); expect(html).toContain('data-testid="ghost-button"'); expect(html).toContain('data-testid="link-button"'); }); it("renders Button as server component with @radix-ui/react-slot", async () => { const { html } = await fetchPage("/"); // Button without "use client" renders directly as a