Add Playwright e2e suite for the landing page

- playwright.config.ts: desktop (1440) + mobile (Pixel 5) projects, static
  webServer over the exported out/; a PW_CHROME env override for the browser
- tests/e2e: render + all core sections, tracked download hrefs with no R2
  exposure, the live counter (mocked function), theme toggle, image loading,
  zero console errors, and responsive nav (desktop links / mobile menu reveal)
- navbar: hide the closed mobile menu with `invisible` so its links stay out of
  the tab order and the accessibility tree, not merely clipped
- add the test:e2e script and ignore test-results/ + playwright-report/
This commit is contained in:
ZacharyZhang-NY committed 2026-07-07 22:30:33 -04:00
1 parent 72e3abacf9
commit 4f0d03ad72
6 files changed
+202 -2

No files matched your search

+2
View File
@@ -3,3 +3,5 @@
out/
node_modules/
npm-debug.log*
test-results/
playwright-report/
+3 -1
View File
@@ -90,7 +90,9 @@ export function Navbar() {
id="mobile-nav"
className={[
"overflow-hidden border-b transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] md:hidden",
mobileOpen ? "max-h-72 border-[var(--border)] bg-[var(--bg)]/95 backdrop-blur-xl" : "max-h-0 border-transparent",
mobileOpen
? "visible max-h-72 border-[var(--border)] bg-[var(--bg)]/95 backdrop-blur-xl"
: "invisible max-h-0 border-transparent",
].join(" ")}
>
<Container className="flex flex-col gap-1 pb-4 pt-1">
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"preview": "wrangler pages dev out"
"preview": "wrangler pages dev out",
"test:e2e": "next build && playwright test"
},
"dependencies": {
"next": "16.2.6",
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig, devices } from "@playwright/test";
const PORT = 4319;
const baseURL = `http://127.0.0.1:${PORT}`;
// Optional override for the Chromium binary (useful when the standard
// `playwright install` revision is unavailable locally); CI uses the bundled
// browser and leaves this unset.
const executablePath = process.env.PW_CHROME || undefined;
const launchOptions = executablePath ? { executablePath } : {};
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? "line" : "list",
use: {
baseURL,
trace: "on-first-retry",
launchOptions,
},
projects: [
{
name: "desktop",
use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } },
},
{
name: "mobile",
use: { ...devices["Pixel 5"] },
},
],
webServer: {
command: `node tests/e2e/serve.mjs out ${PORT}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
});
+99
View File
@@ -0,0 +1,99 @@
import { test, expect, type Page } from "@playwright/test";
// The download counter endpoint is a Cloudflare Pages Function, absent from the
// static export served in tests; mock it so the client fetch resolves.
test.beforeEach(async ({ page }) => {
await page.route("**/api/download-stats", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ downloads: 12345 }),
}),
);
});
/** Step through the page so reveals fire and lazy images load. */
async function scrollThrough(page: Page): Promise<void> {
await page.evaluate(async () => {
const dwell = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const step = Math.round(window.innerHeight * 0.7);
for (let y = 0; y < document.body.scrollHeight; y += step) {
window.scrollTo(0, y);
await dwell(150);
}
window.scrollTo(0, 0);
await dwell(200);
});
}
test("renders the hero and every core section", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { level: 1 })).toContainText("Autonomous AI Company");
for (const id of ["features", "how-it-works", "faq", "download"]) {
await expect(page.locator(`#${id}`)).toHaveCount(1);
}
await scrollThrough(page);
await expect(page.getByRole("heading", { name: "Launch your company now." })).toBeVisible();
});
test("download links use the tracked endpoint and never expose R2 URLs", async ({ page }) => {
await page.goto("/");
await scrollThrough(page);
const tracked = page.locator('#download a[href^="/api/download?asset="]');
expect(await tracked.count()).toBeGreaterThanOrEqual(9);
// Real release URLs must stay behind the server-side redirect.
expect(await page.locator('a[href*="r2.dev"]').count()).toBe(0);
await expect(page.getByText(/12,345 downloads/)).toBeVisible();
});
test("theme toggle flips light and dark", async ({ page }) => {
await page.goto("/");
const toggle = page.locator('button[aria-label*="mode"]:visible').first();
const wasDark = await page.evaluate(() => document.documentElement.classList.contains("dark"));
await toggle.click();
await expect
.poll(() => page.evaluate(() => document.documentElement.classList.contains("dark")))
.toBe(!wasDark);
});
test("all images load without breaking", async ({ page }) => {
await page.goto("/");
await scrollThrough(page);
const broken = await page.evaluate(() =>
[...document.querySelectorAll("img")]
.filter((img) => !img.complete || img.naturalWidth === 0)
.map((img) => img.currentSrc || img.src),
);
expect(broken).toEqual([]);
});
test("produces no console errors", async ({ page }) => {
const errors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
await scrollThrough(page);
expect(errors).toEqual([]);
});
test("desktop navigation exposes the section links", async ({ page }) => {
test.skip(test.info().project.name !== "desktop", "desktop layout only");
await page.goto("/");
for (const href of ["#features", "#how-it-works", "#faq"]) {
await expect(page.locator(`nav a[href="${href}"]:visible`)).toBeVisible();
}
});
test("mobile navigation reveals the menu on tap", async ({ page }) => {
test.skip(test.info().project.name !== "mobile", "mobile layout only");
await page.goto("/");
const links = page.locator('#mobile-nav a[href="#features"]');
await expect(links).toBeHidden();
await page.getByRole("button", { name: /open navigation menu/i }).click();
await expect(links).toBeVisible();
});
+57
View File
@@ -0,0 +1,57 @@
import { createServer } from "node:http";
import { readFile, stat } from "node:fs/promises";
import { extname, join, normalize } from "node:path";
// Minimal static server for the exported `out/` directory, used by the e2e
// suite. Cloudflare Pages Functions are not served here; tests that need
// `/api/*` mock those routes in the browser.
const root = process.argv[2] ?? "out";
const port = Number(process.argv[3] ?? 4319);
const contentTypes = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".webp": "image/webp",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff2": "font/woff2",
".txt": "text/plain; charset=utf-8",
".xml": "application/xml; charset=utf-8",
};
async function resolvePath(pathname) {
const clean = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, "");
let filePath = join(root, clean);
try {
const info = await stat(filePath);
if (info.isDirectory()) filePath = join(filePath, "index.html");
} catch {
if (!extname(filePath)) filePath += ".html";
}
return filePath;
}
createServer(async (request, response) => {
try {
const url = new URL(request.url, `http://127.0.0.1:${port}`);
const filePath = await resolvePath(url.pathname);
const body = await readFile(filePath);
response.writeHead(200, { "Content-Type": contentTypes[extname(filePath)] ?? "application/octet-stream" });
response.end(body);
} catch {
try {
const notFound = await readFile(join(root, "404.html"));
response.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
response.end(notFound);
} catch {
response.writeHead(404);
response.end("Not found");
}
}
}).listen(port, "127.0.0.1", () => console.log(`serving ${root} at http://127.0.0.1:${port}`));