Files
cloudflare__vinext/tests/helpers.ts
T
James Anderson b2ea860284 fix(proxy): match Next.js file conventions (#2155)
* fix(proxy): match Next.js file conventions

* test(proxy): keep convention fixture out of workspace

* test(proxy): provide middleware path in handler fixture
2026-06-18 23:54:39 +01:00

335 lines
11 KiB
TypeScript

/**
* Test helpers for vinext integration tests.
*
* Eliminates boilerplate for:
* - Creating Pages Router / App Router dev servers
* - Fetching pages and asserting on responses
* - Static export setup
*/
import http, { type IncomingHttpHeaders } from "node:http";
import fs from "node:fs/promises";
import os from "node:os";
import { pathToFileURL } from "node:url";
import { createServer, build, type ViteDevServer } from "vite";
import vinext from "../packages/vinext/src/index.js";
import path from "node:path";
// ── Fixture paths ─────────────────────────────────────────────
export const PAGES_FIXTURE_DIR = path.resolve(import.meta.dirname, "./fixtures/pages-basic");
export const APP_FIXTURE_DIR = path.resolve(import.meta.dirname, "./fixtures/app-basic");
export const PAGES_I18N_DOMAINS_FIXTURE_DIR = path.resolve(
import.meta.dirname,
"./fixtures/pages-i18n-domains",
);
export const PAGES_I18N_DOMAINS_BASEPATH_FIXTURE_DIR = path.resolve(
import.meta.dirname,
"./fixtures/pages-i18n-domains-basepath",
);
// ── Shared RSC virtual module entries (used by @vitejs/plugin-rsc) ──
export const RSC_ENTRIES = {
rsc: "virtual:vinext-rsc-entry",
ssr: "virtual:vinext-app-ssr-entry",
client: "virtual:vinext-app-browser-entry",
} as const;
// ── Server lifecycle helper ───────────────────────────────────
export type TestServerResult = {
server: ViteDevServer;
baseUrl: string;
};
/**
* Start a Vite dev server against a fixture directory.
*
* vinext() auto-registers @vitejs/plugin-rsc when an app/ directory is
* detected, so callers do NOT need to inject rsc() manually.
*
* @param fixtureDir - Path to the fixture directory
* @param opts.listen - If false, creates server without listening (default: true)
*/
export async function startFixtureServer(
fixtureDir: string,
opts?: {
appDir?: string | null;
appRouter?: boolean;
listen?: boolean;
server?: {
host?: string;
allowedHosts?: true | string[];
cors?: boolean;
port?: number;
};
},
): Promise<TestServerResult> {
// vinext() auto-registers @vitejs/plugin-rsc when app/ is detected.
// Pass appDir explicitly since tests run with configFile: false and
// cwd may not be the fixture directory.
// Note: opts.appRouter is accepted but unused — vinext auto-detects.
let plugin: ReturnType<typeof vinext>;
if (opts?.appDir === null) {
const previousCwd = process.cwd();
try {
process.chdir(fixtureDir);
plugin = vinext();
} finally {
process.chdir(previousCwd);
}
} else {
plugin = vinext({ appDir: opts?.appDir ?? fixtureDir });
}
const plugins = [plugin];
const server = await createServer({
root: fixtureDir,
configFile: false,
plugins,
// Vite may discover additional deps after the first request (especially
// with @vitejs/plugin-rsc environments) and trigger a re-optimization.
// In non-browser test clients, we can't "reload" and would otherwise
// see Vite's "outdated pre-bundle" error responses.
optimizeDeps: {
holdUntilCrawlEnd: true,
},
server: {
port: 0,
cors: false,
...opts?.server,
},
logLevel: "silent",
});
let baseUrl = "";
if (opts?.listen !== false) {
await server.listen();
const addr = server.httpServer?.address();
if (addr && typeof addr === "object") {
baseUrl = `http://localhost:${addr.port}`;
}
}
return { server, baseUrl };
}
// ── Fetch helpers ─────────────────────────────────────────────
/**
* Fetch a page and return both the Response and the HTML text.
*/
export async function fetchHtml(
baseUrl: string,
urlPath: string,
init?: RequestInit,
): Promise<{ res: Response; html: string }> {
const res = await fetch(`${baseUrl}${urlPath}`, init);
const html = await res.text();
return { res, html };
}
/**
* Fetch a JSON endpoint and return both the Response and parsed data.
*/
export async function fetchJson(
baseUrl: string,
urlPath: string,
init?: RequestInit,
// oxlint-disable-next-line typescript/no-explicit-any
): Promise<{ res: Response; data: any }> {
const res = await fetch(`${baseUrl}${urlPath}`, init);
const data = await res.json();
return { res, data };
}
export type NodeHttpResponse = {
status: number;
headers: IncomingHttpHeaders;
body: string;
};
export async function createIsolatedFixture(
fixtureDir: string,
prefix: string,
filter?: (src: string) => boolean,
nodeModulesDir?: string,
): Promise<string> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
// Skip node_modules during copy — we'll replace it with a symlink to either
// the fixture's own node_modules (when it has one) or the workspace root
// node_modules so fixtures don't need their own install.
await fs.cp(fixtureDir, tmpDir, {
recursive: true,
filter: (src) => !src.includes(`${path.sep}node_modules`) && (filter == null || filter(src)),
});
const resolvedNodeModules =
nodeModulesDir ?? path.resolve(import.meta.dirname, "../node_modules");
const nodeModulesLink = path.join(tmpDir, "node_modules");
// Prefer a directory symlink over a junction. When the source node_modules
// contains pnpm's relative symlinks that climb out of it (e.g. a nested
// package's `react` → `../../../../node_modules/.pnpm/...`), Windows resolves
// those escaping `..` segments against a junction's mount path — landing
// outside the real tree so resolution fails — but against a dir symlink's real
// target, which resolves correctly. Fall back to a junction where creating a
// symlink isn't permitted (Windows without the privilege); there the escaping
// case is unsupported, but the common non-escaping node_modules still works.
// On POSIX the type argument is ignored, so this is always a plain symlink.
try {
await fs.symlink(resolvedNodeModules, nodeModulesLink, "dir");
} catch {
await fs.symlink(resolvedNodeModules, nodeModulesLink, "junction");
}
return tmpDir;
}
export async function requestNodeServerWithHost(
port: number,
requestPath: string,
host: string,
headers: Record<string, string> = {},
): Promise<NodeHttpResponse> {
return new Promise((resolve, reject) => {
const req = http.request(
{
host: "127.0.0.1",
port,
path: requestPath,
method: "GET",
headers: {
Host: host,
...headers,
},
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
res.on("end", () => {
resolve({
status: res.statusCode ?? 0,
headers: res.headers,
body: Buffer.concat(chunks).toString("utf8"),
});
});
},
);
req.on("error", reject);
req.end();
});
}
/**
* Build a Pages Router fixture's SSR server bundle into a fresh tmpdir.
*
* Returns the path to the built bundle (`entry.js`).
*/
export async function buildPagesFixture(fixtureDir: string): Promise<string> {
const serverOutDir = path.join(
await fs.mkdtemp(path.join(os.tmpdir(), "vinext-pages-build-")),
"server",
);
// Use disableAppRouter: true so the RSC/App Router pipeline is not activated.
// This is required when the fixture has both app/ and pages/ directories
// (hybrid); we only want the Pages Router SSR bundle here.
await build({
root: fixtureDir,
configFile: false,
plugins: [vinext({ disableAppRouter: true })],
logLevel: "silent",
build: {
outDir: serverOutDir,
emptyOutDir: true,
ssr: "virtual:vinext-server-entry",
rollupOptions: {
output: { entryFileNames: "entry.js" },
},
},
});
return path.join(serverOutDir, "entry.js");
}
/**
* Build an App Router fixture's RSC/SSR/client bundles into a fresh isolated
* output directory. The fixture source stays in place — only the output lands
* in a per-call tmpdir so sequential test suites never clobber each other.
*
* Returns the path to the built RSC bundle (`<tmp>/server/index.js`).
*/
export async function buildAppFixture(fixtureDir: string): Promise<string> {
const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-app-build-"));
const rscOutDir = path.join(outDir, "server");
const ssrOutDir = path.join(outDir, "server", "ssr");
const clientOutDir = path.join(outDir, "client");
const { createBuilder } = await import("vite");
const builder = await createBuilder({
root: fixtureDir,
configFile: false,
plugins: [vinext({ appDir: fixtureDir, rscOutDir, ssrOutDir, clientOutDir })],
logLevel: "silent",
});
await builder.buildApp();
// The SSR bundle externalizes React packages (react, react-dom) so that
// Node loads them natively. When the bundle lives in a temp dir, Node can't
// find react-dom via normal node_modules traversal. Symlink the project's
// node_modules into the temp dir so the external imports resolve correctly.
const projectNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fs.symlink(projectNodeModules, path.join(outDir, "node_modules"));
return path.join(outDir, "server", "index.js");
}
/**
* Build the `tests/fixtures/cf-app-basic` fixture as a Cloudflare Workers
* bundle in-process using `createBuilder` + `@cloudflare/vite-plugin`.
*
* The CF plugin is loaded via a path-based import resolved from the fixture's
* own node_modules (it is not installed at the workspace root).
*
* Both the App Router and Pages Router are served by the same Workers bundle —
* there is no separate plain-Node SSR bundle for Pages Router. All prerendering
* for both routers goes through a locally-spawned prod server over HTTP, the
* same path used for plain Node builds.
*
* Returns `{ root, rscBundlePath }`.
*/
export async function buildCloudflareAppFixture(fixtureDir: string): Promise<{
root: string;
rscBundlePath: string;
}> {
const tmpDir = await createIsolatedFixture(
fixtureDir,
"vinext-cf-build-",
undefined,
path.join(fixtureDir, "node_modules"),
);
const cfPluginPath = path.join(tmpDir, "node_modules/@cloudflare/vite-plugin/dist/index.mjs");
const { cloudflare } = (await import(pathToFileURL(cfPluginPath).href)) as unknown as {
cloudflare: (opts?: {
viteEnvironment?: { name: string; childEnvironments?: string[] };
}) => import("vite").Plugin;
};
const { createBuilder } = await import("vite");
const builder = await createBuilder({
root: tmpDir,
configFile: false,
plugins: [
vinext({ appDir: tmpDir }),
cloudflare({ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] } }),
],
logLevel: "silent",
});
await builder.buildApp();
return {
root: tmpDir,
rscBundlePath: path.join(tmpDir, "dist", "server", "index.js"),
};
}