From 4f0d03ad72865efe3b50ef49672eaed94b145c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 7 Jul 2026 22:30:33 -0400 Subject: [PATCH] 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/ --- landing/.gitignore | 2 + landing/components/layout/navbar.tsx | 4 +- landing/package.json | 3 +- landing/playwright.config.ts | 39 +++++++++++ landing/tests/e2e/landing.spec.ts | 99 ++++++++++++++++++++++++++++ landing/tests/e2e/serve.mjs | 57 ++++++++++++++++ 6 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 landing/playwright.config.ts create mode 100644 landing/tests/e2e/landing.spec.ts create mode 100644 landing/tests/e2e/serve.mjs diff --git a/landing/.gitignore b/landing/.gitignore index 8fec6031..45ef892a 100644 --- a/landing/.gitignore +++ b/landing/.gitignore @@ -3,3 +3,5 @@ out/ node_modules/ npm-debug.log* +test-results/ +playwright-report/ diff --git a/landing/components/layout/navbar.tsx b/landing/components/layout/navbar.tsx index ee36084c..42dfb4ad 100644 --- a/landing/components/layout/navbar.tsx +++ b/landing/components/layout/navbar.tsx @@ -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(" ")} > diff --git a/landing/package.json b/landing/package.json index dc174518..10bfd159 100644 --- a/landing/package.json +++ b/landing/package.json @@ -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", diff --git a/landing/playwright.config.ts b/landing/playwright.config.ts new file mode 100644 index 00000000..c0adced0 --- /dev/null +++ b/landing/playwright.config.ts @@ -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, + }, +}); diff --git a/landing/tests/e2e/landing.spec.ts b/landing/tests/e2e/landing.spec.ts new file mode 100644 index 00000000..77954c01 --- /dev/null +++ b/landing/tests/e2e/landing.spec.ts @@ -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 { + 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(); +}); diff --git a/landing/tests/e2e/serve.mjs b/landing/tests/e2e/serve.mjs new file mode 100644 index 00000000..ac96cc8b --- /dev/null +++ b/landing/tests/e2e/serve.mjs @@ -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}`));