-
Notifications
You must be signed in to change notification settings - Fork 276
test: add static export E2E tests (#564) #686
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test verifies client-side navigation works, which is great for a static export. One thing to note: since this is a full page navigation (no vinext runtime for SPA transitions in a static export), Also, consider verifying the navigation didn't produce a network error (checking response status on the target page), similar to other tests in this file. |
||
| 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 }) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 404 test depends on Either way the test passes, but the behavior being tested is subtly different. It might be worth adding a follow-up test that checks the 404 content (e.g., whether the custom 404 page is rendered) to verify vinext's static export actually produces a Not blocking for this PR — just a suggestion for a follow-up. |
||
| const response = await page.goto(`${BASE}/nonexistent-page`); | ||
| expect(response?.status()).toBe(404); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good test — verifying |
||
| 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); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 <root-dir> <port> | ||||||||||
| */ | ||||||||||
| 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 <root-dir> <port>"); | ||||||||||
| 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 = { | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice set of MIME types. Consider adding |
||||||||||
| ".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 = decodeURIComponent(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)); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Just noting this is fine as-is.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth noting for future readers: The two-step |
||||||||||
|
|
||||||||||
| // 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", () => { | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: The server binds to
Suggested change
Not blocking — |
||||||||||
| console.log(`Static server listening on http://localhost:${port}`); | ||||||||||
| }); | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
tscstep compiles the entire vinext package before building — same pattern aspages-router-prod. Just confirming: is this needed because the static-export fixture depends onvinextfrom source (workspace dependency), and the CLI invocation (dist/cli.js) needs the compiled output? If so, this is correct.One concern: in CI, the
e2ejob already runsvp run build(line 146 ofci.yml) before Playwright. Sonpx tsc -p ../../../packages/vinext/tsconfig.jsonhere is redundant in CI but necessary for local development (where you might not have run the build). Thepages-router-prodproject has the same pattern, so this is consistent.