Files
James Anderson bacee9ef52 fix(pages): emit canonical __NEXT_DATA__ JSON (#2043)
* fix(pages): emit canonical next data script

* test(pages): expect canonical next data markup

* fix(pages): initialize canonical next data before routing

* test(pages): parse canonical next data markup

* fix(pages): preserve router bootstrap ordering

* test(pages): expect readiness bootstrap import

* fix(pages): guard readiness bootstrap without next data
2026-06-15 23:16:23 +01:00

349 lines
13 KiB
TypeScript

/**
* Static export E2E tests — verify exported files work when served via HTTP.
*
* Unlike the unit tests in pages-router.test.ts and app-router.test.ts which
* only check file existence and content, these tests:
* 1. Run static export for both Pages Router and App Router
* 2. Serve the exported files with a real HTTP server
* 3. Make HTTP requests to verify correct responses
* 4. Check Content-Type, status codes, and asset references
*/
import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test";
import { createServer, type Server } from "node:http";
import fs from "node:fs";
import path from "node:path";
import { buildPagesFixture, buildAppFixture } from "./helpers.js";
const PAGES_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/pages-basic");
const APP_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/app-basic");
// ─── Helpers ────────────────────────────────────────────────────────────────
function decodeHtmlText(text: string): string {
return text
.replaceAll("<!-- -->", "")
.replaceAll("&quot;", '"')
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&amp;", "&");
}
function textContentByTestId(html: string, testId: string): string {
const attrIndex = html.indexOf(`data-testid="${testId}"`);
if (attrIndex === -1) {
throw new Error(`Missing data-testid="${testId}"`);
}
const contentStart = html.indexOf(">", attrIndex);
if (contentStart === -1) {
throw new Error(`Missing opening tag end for data-testid="${testId}"`);
}
const contentEnd = html.indexOf("</", contentStart);
if (contentEnd === -1) {
throw new Error(`Missing closing tag for data-testid="${testId}"`);
}
return decodeHtmlText(html.slice(contentStart + 1, contentEnd));
}
/** Simple static file server for testing. */
function createStaticServer(rootDir: string): Promise<{ server: Server; baseUrl: string }> {
const MIME_TYPES: Record<string, string> = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".svg": "image/svg+xml",
};
return new Promise((resolve) => {
const server = createServer((req, res) => {
const url = req.url ?? "/";
let pathname = url.split("?")[0];
let filePath = path.join(rootDir, pathname);
if (pathname.endsWith("/")) {
filePath = path.join(rootDir, pathname, "index.html");
} else if (!path.extname(pathname)) {
const htmlPath = `${filePath}.html`;
if (fs.existsSync(htmlPath)) {
filePath = htmlPath;
} else if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
filePath = path.join(filePath, "index.html");
}
}
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
// Serve 404.html if it exists
const notFoundPath = path.join(rootDir, "404.html");
if (fs.existsSync(notFoundPath)) {
const content = fs.readFileSync(notFoundPath);
res.writeHead(404, { "Content-Type": "text/html" });
res.end(content);
} else {
res.writeHead(404);
res.end("Not Found");
}
return;
}
const ext = path.extname(filePath);
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
const content = fs.readFileSync(filePath);
res.writeHead(200, { "Content-Type": contentType });
res.end(content);
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
resolve({ server, baseUrl: `http://127.0.0.1:${port}` });
});
});
}
// ─── Pages Router Static Export E2E ─────────────────────────────────────────
describe("Static export — Pages Router (served via HTTP)", () => {
let staticServer: Server;
let baseUrl: string;
const exportDir = path.resolve(PAGES_FIXTURE, "out-e2e");
beforeAll(async () => {
// 1. Build the fixture and run static export
const pagesBundlePath = await buildPagesFixture(PAGES_FIXTURE);
const { staticExportPages } = await import("../packages/vinext/src/build/static-export.js");
const { pagesRouter } = await import("../packages/vinext/src/routing/pages-router.js");
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const pagesDir = path.resolve(PAGES_FIXTURE, "pages");
const routes = await pagesRouter(pagesDir);
const pageRoutes = routes.filter((r: any) => !r.filePath.includes("/api/"));
const apiRoutes = routes.filter((r: any) => r.filePath.includes("/api/"));
const config = await resolveNextConfig({ output: "export" });
await staticExportPages({
pagesBundlePath,
routes: pageRoutes,
apiRoutes,
pagesDir,
outDir: exportDir,
config,
});
// 2. Start a static file server on the exported directory
const srv = await createStaticServer(exportDir);
staticServer = srv.server;
baseUrl = srv.baseUrl;
}, 60_000);
afterAll(() => {
staticServer?.close();
fs.rmSync(exportDir, { recursive: true, force: true });
});
it("serves index.html at / with text/html content type", async () => {
const res = await fetch(`${baseUrl}/`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("text/html");
const html = await res.text();
expect(html).toContain("<!DOCTYPE html>");
expect(html).toContain("Hello, vinext!");
});
it("serves about page", async () => {
const res = await fetch(`${baseUrl}/about`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("About");
});
it("serves pre-rendered dynamic route pages", async () => {
const res = await fetch(`${baseUrl}/blog/hello-world`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("hello-world");
});
it("serves 404.html for missing pages", async () => {
const res = await fetch(`${baseUrl}/nonexistent-page`);
expect(res.status).toBe(404);
const html = await res.text();
expect(html).toContain("404");
});
it("includes __NEXT_DATA__ in served pages", async () => {
const res = await fetch(`${baseUrl}/`);
const html = await res.text();
expect(html).toContain("__NEXT_DATA__");
// Verify it's valid JSON inside the script tag
const match = html.match(
/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/,
);
expect(match).toBeTruthy();
const data = JSON.parse(match![1]);
expect(data.props).toBeDefined();
expect(data.page).toBeDefined();
});
it("includes HTML document structure", async () => {
const res = await fetch(`${baseUrl}/`);
const html = await res.text();
expect(html).toContain("<html");
expect(html).toContain("<head>");
expect(html).toContain("</head>");
expect(html).toContain("<body");
expect(html).toContain("</body>");
expect(html).toContain("</html>");
expect(html).toContain('<div id="__next">');
});
it("getStaticProps pages have correct data in __NEXT_DATA__", async () => {
const res = await fetch(`${baseUrl}/blog/hello-world`);
const html = await res.text();
const match = html.match(
/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/,
);
expect(match).toBeTruthy();
const data = JSON.parse(match![1]);
expect(data.props.pageProps).toBeDefined();
});
});
// ─── App Router Static Export E2E ───────────────────────────────────────────
describe("Static export — App Router (served via HTTP)", () => {
let staticServer: Server;
let baseUrl: string;
let appExportResult: { files: string[]; errors: Array<{ route: string; error: string }> };
const exportDir = path.resolve(APP_FIXTURE, "out-e2e");
beforeAll(async () => {
fs.rmSync(path.join(exportDir, "metadata-dynamic-static"), {
recursive: true,
force: true,
});
// 1. Build the fixture and run static export
const rscBundlePath = await buildAppFixture(APP_FIXTURE);
const { staticExportApp } = await import("../packages/vinext/src/build/static-export.js");
const { appRouter } = await import("../packages/vinext/src/routing/app-router.js");
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const appDir = path.resolve(APP_FIXTURE, "app");
const routes = await appRouter(appDir);
const config = await resolveNextConfig({ output: "export" });
appExportResult = await staticExportApp({
rscBundlePath,
routes,
appDir,
outDir: exportDir,
config,
});
expect(
appExportResult.errors.some((error) => error.route.startsWith("/metadata-dynamic-static")),
).toBe(false);
// 2. Start a static file server on the exported directory
const srv = await createStaticServer(exportDir);
staticServer = srv.server;
baseUrl = srv.baseUrl;
}, 120_000);
afterAll(() => {
staticServer?.close();
fs.rmSync(exportDir, { recursive: true, force: true });
});
it("serves index.html at / with text/html content type", async () => {
const res = await fetch(`${baseUrl}/`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("text/html");
const html = await res.text();
expect(html).toContain("Welcome to App Router");
});
it("serves about page", async () => {
const res = await fetch(`${baseUrl}/about`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("About");
});
it("serves pre-rendered dynamic route pages", async () => {
const res = await fetch(`${baseUrl}/blog/hello-world`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("hello-world");
});
it("serves static metadata files under dynamic segments from placeholder paths", async () => {
// Ported from Next.js: test/e2e/app-dir/metadata-static-file/metadata-static-file-dynamic-route.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata-static-file/metadata-static-file-dynamic-route.test.ts
const outputPath = path.join(exportDir, "metadata-dynamic-static", "-", "apple-icon.png");
expect(fs.existsSync(outputPath)).toBe(true);
expect(appExportResult.files).toContain("metadata-dynamic-static/-/apple-icon.png");
const res = await fetch(`${baseUrl}/metadata-dynamic-static/-/apple-icon.png`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("image/png");
expect((await res.arrayBuffer()).byteLength).toBeGreaterThan(0);
});
it("serves pre-rendered encoded dynamic route params from generateStaticParams", async () => {
// Ported from Next.js: test/e2e/app-dir/prerender-encoding/prerender-encoding.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/prerender-encoding/prerender-encoding.test.ts
const res = await fetch(`${baseUrl}/prerender-encoding/sticks%20%26%20stones`);
expect(res.status).toBe(200);
const html = await res.text();
expect(textContentByTestId(html, "prerender-encoding-id")).toBe(
"params.id is sticks%20%26%20stones",
);
});
it("serves 404.html for missing pages", async () => {
const res = await fetch(`${baseUrl}/nonexistent-page`);
expect(res.status).toBe(404);
const html = await res.text();
// App Router 404 page
expect(html.toLowerCase()).toMatch(/not found|404/);
});
it("includes complete HTML document structure", async () => {
const res = await fetch(`${baseUrl}/`);
const html = await res.text();
expect(html).toContain("<!DOCTYPE html>");
expect(html).toContain("<html");
expect(html).toContain("<head>");
expect(html).toContain("</head>");
expect(html).toContain("<body");
expect(html).toContain("</body>");
});
it("HTML contains charset and viewport meta tags", async () => {
const res = await fetch(`${baseUrl}/`);
const html = await res.text();
// React renders charset as charSet in JSX
expect(html.toLowerCase()).toMatch(/charset/);
expect(html).toContain("viewport");
});
it("multiple exported pages return distinct content", async () => {
const [indexRes, aboutRes] = await Promise.all([
fetch(`${baseUrl}/`),
fetch(`${baseUrl}/about`),
]);
const indexHtml = await indexRes.text();
const aboutHtml = await aboutRes.text();
// Pages should have different content
expect(indexHtml).not.toBe(aboutHtml);
expect(indexHtml).toContain("Welcome to App Router");
expect(aboutHtml).toContain("About");
});
});