From 7bd8a0a54eff6b9a8c43a048b0937cd860267dd4 Mon Sep 17 00:00:00 2001 From: raed04 Date: Wed, 25 Mar 2026 11:31:41 +0300 Subject: [PATCH 1/2] test: add static export E2E tests (#564) Add Playwright E2E tests for `output: "export"` static builds covering both App Router and Pages Router. Includes a lightweight static file server with path traversal protection, proper error handling, and startup validation. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 1 + playwright.config.ts | 15 ++ tests/e2e/static-export/app-router.spec.ts | 84 +++++++++++ tests/e2e/static-export/pages-router.spec.ts | 66 +++++++++ tests/e2e/static-export/serve-static.mjs | 143 +++++++++++++++++++ 5 files changed, 309 insertions(+) create mode 100644 tests/e2e/static-export/app-router.spec.ts create mode 100644 tests/e2e/static-export/pages-router.spec.ts create mode 100644 tests/e2e/static-export/serve-static.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07735f00..1baa62cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,6 +138,7 @@ jobs: - cloudflare-workers - cloudflare-dev - cloudflare-pages-router-dev + - static-export steps: - uses: actions/checkout@v6 - uses: voidzero-dev/setup-vp@v1 diff --git a/playwright.config.ts b/playwright.config.ts index e28c64a4..2ce721b8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -107,6 +107,21 @@ const projectServers = { timeout: 30_000, }, }, + "static-export": { + testDir: "./tests/e2e/static-export", + use: { baseURL: "http://localhost:4180" }, + server: { + // Build the static export fixture, then serve the output with a + // lightweight static file server. No vinext runtime is needed — + // the output is pure pre-rendered HTML files. + command: + "npx tsc -p ../../../packages/vinext/tsconfig.json && node ../../../packages/vinext/dist/cli.js build && node ../../../tests/e2e/static-export/serve-static.mjs dist/client 4180", + cwd: "./tests/fixtures/static-export", + port: 4180, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + }, }; type ProjectName = keyof typeof projectServers; diff --git a/tests/e2e/static-export/app-router.spec.ts b/tests/e2e/static-export/app-router.spec.ts new file mode 100644 index 00000000..c7017c7e --- /dev/null +++ b/tests/e2e/static-export/app-router.spec.ts @@ -0,0 +1,84 @@ +import { test, expect } from "@playwright/test"; + +/** + * Static export E2E tests for the App Router. + * + * These tests run against a `vinext build` output served as static files. + * The static export fixture uses `output: "export"` in next.config.mjs, + * so no server-side rendering is involved — all pages are pre-rendered + * HTML files served by a lightweight HTTP server on port 4180. + */ +const BASE = "http://localhost:4180"; + +test.describe("Static Export — App Router", () => { + test("home page renders with correct content", async ({ page }) => { + const response = await page.goto(`${BASE}/`); + expect(response?.status()).toBe(200); + await expect(page.locator("h1")).toHaveText("Static Export — App Router"); + await expect(page.locator("body")).toContainText( + "This page is pre-rendered at build time by the App Router.", + ); + }); + + test("about page renders", async ({ page }) => { + const response = await page.goto(`${BASE}/about`); + expect(response?.status()).toBe(200); + await expect(page.locator("h1")).toHaveText("About"); + await expect(page.locator("body")).toContainText( + "A static App Router page with no dynamic data.", + ); + }); + + test("blog/hello-world renders", async ({ page }) => { + const response = await page.goto(`${BASE}/blog/hello-world`); + expect(response?.status()).toBe(200); + await expect(page.locator("h1")).toHaveText("Blog Post"); + await expect(page.locator("body")).toContainText("Slug: hello-world"); + }); + + test("blog/getting-started renders", async ({ page }) => { + const response = await page.goto(`${BASE}/blog/getting-started`); + expect(response?.status()).toBe(200); + await expect(page.locator("h1")).toHaveText("Blog Post"); + await expect(page.locator("body")).toContainText("Slug: getting-started"); + }); + + test("blog/advanced-guide renders", async ({ page }) => { + const response = await page.goto(`${BASE}/blog/advanced-guide`); + expect(response?.status()).toBe(200); + await expect(page.locator("h1")).toHaveText("Blog Post"); + await expect(page.locator("body")).toContainText("Slug: advanced-guide"); + }); + + test("blog page includes dynamic metadata in title", async ({ page }) => { + await page.goto(`${BASE}/blog/hello-world`); + await expect(page).toHaveTitle("Blog: hello-world"); + }); + + test("home page navigation links are present", async ({ page }) => { + await page.goto(`${BASE}/`); + const nav = page.locator("nav"); + await expect(nav.locator('a[href="/about"]')).toBeVisible(); + await expect(nav.locator('a[href="/blog/hello-world"]')).toBeVisible(); + await expect(nav.locator('a[href="/blog/getting-started"]')).toBeVisible(); + await expect(nav.locator('a[href="/old-school"]')).toBeVisible(); + await expect(nav.locator('a[href="/products/widget"]')).toBeVisible(); + }); + + test("client-side navigation works between pages", async ({ page }) => { + await page.goto(`${BASE}/`); + await page.locator('a[href="/about"]').click(); + await page.waitForURL(`${BASE}/about`); + await expect(page.locator("h1")).toHaveText("About"); + }); + + test("root layout metadata is applied", async ({ page }) => { + await page.goto(`${BASE}/`); + await expect(page).toHaveTitle("Static Export Fixture"); + }); + + test("404 page for non-existent route", async ({ page }) => { + const response = await page.goto(`${BASE}/nonexistent-page`); + expect(response?.status()).toBe(404); + }); +}); diff --git a/tests/e2e/static-export/pages-router.spec.ts b/tests/e2e/static-export/pages-router.spec.ts new file mode 100644 index 00000000..3df846e8 --- /dev/null +++ b/tests/e2e/static-export/pages-router.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from "@playwright/test"; + +/** + * Static export E2E tests for the Pages Router. + * + * These tests run against a `vinext build` output served as static files. + * The fixture uses `output: "export"` with Pages Router pages that use + * getStaticProps and getStaticPaths for pre-rendering. + */ +const BASE = "http://localhost:4180"; + +test.describe("Static Export — Pages Router", () => { + test("old-school page renders with correct content", async ({ page }) => { + const response = await page.goto(`${BASE}/old-school`); + expect(response?.status()).toBe(200); + await expect(page.locator("h1")).toHaveText("Old-school Page (Pages Router)"); + await expect(page.locator("body")).toContainText( + "A static Pages Router page rendered with getStaticProps.", + ); + }); + + test("product widget renders with getStaticProps data", async ({ page }) => { + const response = await page.goto(`${BASE}/products/widget`); + expect(response?.status()).toBe(200); + await expect(page.locator("h1")).toHaveText("The Widget"); + await expect(page.locator("body")).toContainText("Product ID: widget"); + }); + + test("product gadget renders with getStaticProps data", async ({ page }) => { + const response = await page.goto(`${BASE}/products/gadget`); + expect(response?.status()).toBe(200); + await expect(page.locator("h1")).toHaveText("The Gadget"); + await expect(page.locator("body")).toContainText("Product ID: gadget"); + }); + + test("product doohickey renders with getStaticProps data", async ({ page }) => { + const response = await page.goto(`${BASE}/products/doohickey`); + expect(response?.status()).toBe(200); + await expect(page.locator("h1")).toHaveText("The Doohickey"); + await expect(page.locator("body")).toContainText("Product ID: doohickey"); + }); + + test("__NEXT_DATA__ is present in Pages Router output", async ({ page }) => { + await page.goto(`${BASE}/old-school`); + const nextData = await page.evaluate(() => (window as any).__NEXT_DATA__); + expect(nextData).toBeDefined(); + expect(nextData.props).toBeDefined(); + expect(nextData.props.pageProps).toBeDefined(); + }); + + test("product page __NEXT_DATA__ contains props from getStaticProps", async ({ page }) => { + await page.goto(`${BASE}/products/widget`); + const nextData = await page.evaluate(() => (window as any).__NEXT_DATA__); + expect(nextData).toBeDefined(); + expect(nextData.props).toBeDefined(); + expect(nextData.props.pageProps).toBeDefined(); + expect(nextData.props.pageProps.id).toBe("widget"); + expect(nextData.props.pageProps.name).toBe("The Widget"); + }); + + test("non-pre-rendered dynamic route returns 404", async ({ page }) => { + // getStaticPaths uses fallback: false, so unknown IDs should 404 + const response = await page.goto(`${BASE}/products/unknown`); + expect(response?.status()).toBe(404); + }); +}); diff --git a/tests/e2e/static-export/serve-static.mjs b/tests/e2e/static-export/serve-static.mjs new file mode 100644 index 00000000..5d8df706 --- /dev/null +++ b/tests/e2e/static-export/serve-static.mjs @@ -0,0 +1,143 @@ +/** + * Lightweight static file server for the static export E2E tests. + * + * Serves pre-built HTML files from dist/client/ with correct MIME types + * and 404 handling. Used by the Playwright webServer config to serve + * the static export output without requiring external dependencies. + * + * Usage: node serve-static.mjs + */ +import { createServer } from "node:http"; +import { readFile, stat } from "node:fs/promises"; +import { join, extname, resolve, sep } from "node:path"; + +const rawRoot = process.argv[2]; +const rawPort = process.argv[3]; + +if (!rawRoot || !rawPort) { + console.error("Usage: node serve-static.mjs "); + process.exit(1); +} + +const rootDir = resolve(rawRoot); +const rootPrefix = rootDir.endsWith(sep) ? rootDir : rootDir + sep; +const port = parseInt(rawPort, 10); + +if (!Number.isInteger(port) || port < 1 || port > 65535) { + console.error(`Invalid port: "${rawPort}". Must be an integer between 1 and 65535.`); + process.exit(1); +} + +// Verify root directory exists and is readable before starting the server +try { + const rootStat = await stat(rootDir); + if (!rootStat.isDirectory()) { + console.error(`Root path is not a directory: ${rootDir}`); + process.exit(1); + } +} catch (err) { + if (err.code === "ENOENT") { + console.error(`Root directory does not exist: ${rootDir}`); + console.error("Did the build step complete successfully?"); + } else if (err.code === "EACCES") { + console.error(`Root directory is not accessible (permission denied): ${rootDir}`); + } else { + console.error(`Cannot access root directory ${rootDir}:`, err); + } + process.exit(1); +} + +const MIME_TYPES = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".png": "image/png", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".rsc": "text/x-component", +}; + +async function tryFile(filePath) { + try { + const s = await stat(filePath); + if (!s.isFile()) return null; + return await readFile(filePath); + } catch (err) { + if (err.code === "ENOENT" || err.code === "ENOTDIR" || err.code === "ERR_INVALID_ARG_VALUE") + return null; + throw err; + } +} + +function isInsideRoot(filePath) { + return filePath === rootDir || filePath.startsWith(rootPrefix); +} + +const server = createServer(async (req, res) => { + try { + const parsed = new URL(req.url ?? "/", "http://localhost"); + let pathname = parsed.pathname; + + // Directory index + if (pathname.endsWith("/")) pathname += "index.html"; + + // Resolve file path — try exact match, then .html extension + let filePath = resolve(join(rootDir, pathname)); + + // Prevent path traversal + if (!isInsideRoot(filePath)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + let content = await tryFile(filePath); + if (!content && !extname(filePath)) { + const htmlPath = filePath + ".html"; + if (isInsideRoot(htmlPath)) { + content = await tryFile(htmlPath); + if (content) filePath = htmlPath; + } + } + + if (!content) { + const notFoundPath = join(rootDir, "404.html"); + const notFoundContent = await tryFile(notFoundPath); + if (notFoundContent) { + res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); + res.end(notFoundContent); + } else { + res.writeHead(404); + res.end("Not Found"); + } + return; + } + + const ext = extname(filePath); + const contentType = MIME_TYPES[ext] ?? "application/octet-stream"; + res.writeHead(200, { "Content-Type": contentType }); + res.end(content); + } catch (err) { + console.error(`Request handler error for ${req.url}:`, err); + if (!res.headersSent) { + res.writeHead(500); + res.end("Internal Server Error"); + } + } +}); + +server.on("error", (err) => { + if (err.code === "EADDRINUSE") { + console.error( + `Port ${port} is already in use. Kill the existing process or choose a different port.`, + ); + } else { + console.error("Server failed to start:", err); + } + process.exit(1); +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`Static server listening on http://localhost:${port}`); +}); From eafaebacaea87142f696e7b4c04e4bc070e2742e Mon Sep 17 00:00:00 2001 From: Raed Alharbi <91495228+raed04@users.noreply.github.com> Date: Sat, 28 Mar 2026 10:54:31 +0300 Subject: [PATCH 2/2] Update tests/e2e/static-export/serve-static.mjs Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com> --- tests/e2e/static-export/serve-static.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/static-export/serve-static.mjs b/tests/e2e/static-export/serve-static.mjs index 5d8df706..29620130 100644 --- a/tests/e2e/static-export/serve-static.mjs +++ b/tests/e2e/static-export/serve-static.mjs @@ -77,7 +77,7 @@ function isInsideRoot(filePath) { const server = createServer(async (req, res) => { try { const parsed = new URL(req.url ?? "/", "http://localhost"); - let pathname = parsed.pathname; + let pathname = decodeURIComponent(parsed.pathname); // Directory index if (pathname.endsWith("/")) pathname += "index.html";