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:
1 parent
72e3abacf9
commit
4f0d03ad72
6 files changed
+202
-2
No files matched your search
@@ -3,3 +3,5 @@
|
||||
out/
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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}`));
|
||||
Reference in new issue
Block a user