Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tsc step compiles the entire vinext package before building — same pattern as pages-router-prod. Just confirming: is this needed because the static-export fixture depends on vinext from source (workspace dependency), and the CLI invocation (dist/cli.js) needs the compiled output? If so, this is correct.

One concern: in CI, the e2e job already runs vp run build (line 146 of ci.yml) before Playwright. So npx tsc -p ../../../packages/vinext/tsconfig.json here is redundant in CI but necessary for local development (where you might not have run the build). The pages-router-prod project has the same pattern, so this is consistent.

cwd: "./tests/fixtures/static-export",
port: 4180,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
},
};

type ProjectName = keyof typeof projectServers;
Expand Down
84 changes: 84 additions & 0 deletions tests/e2e/static-export/app-router.spec.ts
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 }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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), page.waitForURL will work fine. But if static export eventually supports client-side hydration with the router, this test's behavior would change. Worth a brief comment.

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 }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 404 test depends on serve-static.mjs returning a 404 status for missing files. If vinext's static export generates a 404.html file, the server serves it with status 404 (line 108 of serve-static.mjs). If vinext does not generate 404.html, the server still returns 404 with a plain text body.

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 404.html. That would be a more meaningful signal for issue #564 coverage.

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);
});
});
66 changes: 66 additions & 0 deletions tests/e2e/static-export/pages-router.spec.ts
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 }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good test — verifying __NEXT_DATA__ is present and correctly shaped is exactly the right thing to check for Pages Router static export parity. This catches the case where vinext's static export might emit the HTML but forget the data script tag.

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);
});
});
143 changes: 143 additions & 0 deletions tests/e2e/static-export/serve-static.mjs
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 = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice set of MIME types. Consider adding .txt (text/plain) and .woff2 (font/woff2) for completeness — static exports sometimes include font files or robots.txt. Not blocking.

".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));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: resolve(join(rootDir, pathname)) is redundant since resolve with an absolute first segment in the join result already normalizes. But pathname starts with /, so join(rootDir, pathname) actually works correctly (join strips the leading / from pathname when the first arg is absolute).

Just noting this is fine as-is.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting for future readers: join(rootDir, pathname) works correctly here even though pathname starts with /. Node's path.join strips the leading / from non-first arguments, so join('/srv/root', '/about') produces /srv/root/about, not /about. The subsequent resolve() then normalizes any remaining .. segments.

The two-step resolve(join(...)) + isInsideRoot() is the correct pattern — resolve handles normalization, and the prefix check catches any traversal that survives.


// 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", () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The server binds to 127.0.0.1 but the log message says localhost. This is fine in practice (they resolve to the same thing), but if someone were to change this to 0.0.0.0 for debugging, the log would be misleading. Consider using the actual bind address in the log:

Suggested change
server.listen(port, "127.0.0.1", () => {
server.listen(port, "127.0.0.1", () => {
console.log(`Static server listening on http://127.0.0.1:${port}`);
});

Not blocking — localhost is clearer for most people.

console.log(`Static server listening on http://localhost:${port}`);
});
Loading