/**
* 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