mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
bacee9ef52
* 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
349 lines
13 KiB
TypeScript
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(""", '"')
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll("&", "&");
|
|
}
|
|
|
|
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");
|
|
});
|
|
});
|