diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..0676348 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,66 @@ +name: E2E + +on: + push: + branches: [main] + pull_request: + +# Cancel superseded runs on the same ref. +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + playwright: + name: Playwright (chromium) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + + # `--ignore-scripts` skips sharp's postinstall build step, which tries to + # build libvips from source and fails without node-gyp. The prebuilt + # platform binary (@img/sharp-*) is installed from the lockfile and is what + # Astro actually uses at build time, so the site still builds correctly. + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Get Playwright version + id: pw + run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + id: pw-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }} + + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps chromium + + # Build the static site explicitly so a build failure is its own step. + # The Playwright config's webServer also runs `pnpm build`, but with the + # output already present that is fast/idempotent. + - name: Build site + run: pnpm build + + - name: Run e2e tests + run: pnpm test:e2e + + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 2ee23e9..72257ba 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,12 @@ pnpm-debug.log* # Local Netlify folder .netlify mocks + +# playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ + +# Claude Code agent worktrees (harness-managed) +.claude/worktrees/ diff --git a/package.json b/package.json index 5e00abe..add4f32 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "start": "astro dev", "build": "astro build", "preview": "astro preview", + "test:e2e": "playwright test", "pretty": "prettier --write . --plugin=prettier-plugin-astro", "prepare": "is-ci || husky install", "analyze": "pnpx unlighthouse --site https://calmly.whocards.cc", @@ -71,6 +72,7 @@ "devDependencies": { "@astrojs/node": "^10.1.1", "@netlify/functions": "^2.3.0", + "@playwright/test": "^1.60.0", "@types/node": "^20.8.6", "dotenv": "^16.3.1", "drizzle-kit": "^0.24.2", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f50b584 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,39 @@ +import {defineConfig, devices} from '@playwright/test' + +// The site is built with `output: 'static'` + the Netlify adapter. That adapter +// does not support `astro preview`, and `astro dev` requires network access to +// Netlify, so we build once and serve the static `dist/` output ourselves with a +// tiny zero-dependency Node server (see tests/static-server.mjs). +// +// Astro's `env.ts` defaults SITE_URL to http://localhost:4321 for non-prod builds, +// so absolute og:image/twitter:image URLs are baked as http://localhost:4321/... +// We therefore serve on port 4321 so those image URLs resolve against this server. +const PORT = 4321 +const baseURL = `http://localhost:${PORT}` + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? [['github'], ['html', {open: 'never'}]] : 'list', + use: { + baseURL, + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: {...devices['Desktop Chrome']}, + }, + ], + webServer: { + // Build the static site, then serve dist/ on PORT. + command: 'pnpm build && node tests/static-server.mjs', + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + env: {PORT: String(PORT)}, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64c2ad5..723a9ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,9 @@ importers: '@netlify/functions': specifier: ^2.3.0 version: 2.3.0 + '@playwright/test': + specifier: ^1.60.0 + version: 1.60.0 '@types/node': specifier: ^20.8.6 version: 20.8.6 @@ -1574,6 +1577,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@posthog/core@1.29.13': resolution: {integrity: sha512-7Me5zaeAue/wmA364Go8ChYbsVAfNAHbtDxXopWu3D6hq9PVScUcauRgjD1njgvP8NzN91SrIllE+pri3XvJVw==} @@ -3303,6 +3311,11 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4648,6 +4661,16 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -7219,6 +7242,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@posthog/core@1.29.13': dependencies: '@posthog/types': 1.376.4 @@ -9023,6 +9050,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -10735,6 +10765,14 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-selector-parser@6.0.10: diff --git a/tests/e2e/contact.spec.ts b/tests/e2e/contact.spec.ts new file mode 100644 index 0000000..e59c789 --- /dev/null +++ b/tests/e2e/contact.spec.ts @@ -0,0 +1,52 @@ +import {expect, test} from '@playwright/test' + +// Contact / card-request form lives at src/pages/contact.astro with +// `export const prerender = false`, i.e. it is a server-rendered route. Under +// the Netlify adapter that route is emitted as a serverless *function*, not as a +// static file, so it is NOT served by the static e2e server (tests/static-server.mjs) +// nor by `astro preview` (the Netlify adapter rejects `astro preview`), and +// `astro dev` requires network access to Netlify to boot. +// +// Submitting the form on a real server would also call insertUser() (Postgres) and +// createContactSheetRow() (Google Sheets) on a valid payload — external services we +// must not hit from e2e. +// +// These tests are therefore SKIPPED in the current static-server setup. They are +// written out so they can be enabled once the suite runs against a server that can +// execute SSR routes (e.g. `netlify dev` with network, or a Node-adapter preview +// target) — submitting only INVALID data so the request short-circuits at Zod +// validation before any external call. +// +// The assertions below validate the client-side form contract (native HTML5 +// validation on required fields + the honeypot) which needs no external services. +test.describe('contact / card request form', () => { + test.skip( + true, + 'SSR-only route (prerender=false) is not served by the static e2e server; ' + + 'enable when running against a server that can execute SSR routes (e.g. netlify dev).' + ) + + test('renders the request form', async ({page}) => { + await page.goto('/contact') + await expect(page.getByRole('heading', {name: 'Request WhoCards'})).toBeVisible() + await expect(page.locator('form#email-form')).toBeVisible() + await expect(page.locator('input[name="name"]')).toBeVisible() + await expect(page.locator('input[name="email"]')).toBeVisible() + }) + + test('blocks submission of an empty form via native validation', async ({page}) => { + await page.goto('/contact') + + // The name field is `required`; submitting empty should be blocked by the browser + // and never reach the server (no external services touched). + await page.getByRole('button', {name: /submit|request/i}).click() + + const nameValid = await page + .locator('input[name="name"]') + .evaluate((el: HTMLInputElement) => el.checkValidity()) + expect(nameValid).toBe(false) + + // We should still be on /contact, not on a success state. + await expect(page).toHaveURL(/\/contact$/) + }) +}) diff --git a/tests/e2e/event-play.spec.ts b/tests/e2e/event-play.spec.ts new file mode 100644 index 0000000..ed5ae02 --- /dev/null +++ b/tests/e2e/event-play.spec.ts @@ -0,0 +1,90 @@ +import {readFileSync} from 'node:fs' +import {fileURLToPath} from 'node:url' +import {expect, test} from '@playwright/test' + +// Loaded via fs (rather than a JSON import) so the older repo Prettier/ESLint +// toolchain doesn't trip on import attributes. +const questions: Record = JSON.parse( + readFileSync( + fileURLToPath( + new URL('../../src/pages/events/hajnalig/_data/hajnalig-questions.json', import.meta.url) + ), + 'utf8' + ) +) + +// The hajnalig "SimplePlay" island (src/pages/events/hajnalig/_components/play.tsx): +// - reads the starting question from ?q= +// - defaults language to Hungarian (hu) on first visit +// - shows the question in an

, with prev / language-toggle / next controls +// - prev is disabled at the first question +// - controls auto-hide after 3s of no mouse movement and reappear on mousemove +// +// Starting from ?q=1 makes the first rendered question deterministic (question id 1). + +const q1 = questions['1'] +const q2 = questions['2'] + +const heading = (page: import('@playwright/test').Page) => page.getByRole('heading', {level: 1}) + +test.beforeEach(async ({page}) => { + // Fresh localStorage so language defaults to Hungarian deterministically. + await page.addInitScript(() => window.localStorage.clear()) +}) + +test('loads the question selected by ?q= in the default (hu) language', async ({page}) => { + await page.goto('/events/hajnalig/play?q=1') + await expect(heading(page)).toHaveText(q1.hu) +}) + +test('next and previous navigate between questions', async ({page}) => { + await page.goto('/events/hajnalig/play?q=1') + await expect(heading(page)).toHaveText(q1.hu) + + // Previous is disabled on the first question. + const prev = page.getByRole('button', {name: 'previous question'}) + await expect(prev).toBeDisabled() + + // With ?q=1 the order is the natural question order, so next -> question 2. + await page.getByRole('button', {name: 'next question'}).click() + await expect(heading(page)).toHaveText(q2.hu) + + // Previous becomes enabled and takes us back to question 1. + await expect(prev).toBeEnabled() + await prev.click() + await expect(heading(page)).toHaveText(q1.hu) +}) + +test('language toggle switches between Hungarian and English', async ({page}) => { + await page.goto('/events/hajnalig/play?q=1') + await expect(heading(page)).toHaveText(q1.hu) + + // With two languages the control is a toggle button (between prev/next), + // labelled "change language". + const toggle = page.getByRole('button', {name: 'change language'}) + await toggle.click() + await expect(heading(page)).toHaveText(q1.en) + + await toggle.click() + await expect(heading(page)).toHaveText(q1.hu) +}) + +test('controls auto-hide after inactivity and reappear on mouse movement', async ({page}) => { + await page.goto('/events/hajnalig/play?q=1') + + const next = page.getByRole('button', {name: 'next question'}) + await expect(next).toBeVisible() + + // The controls live in a container that fades to opacity-0 / pointer-events-none + // after 3s of inactivity. (opacity:0 still counts as "visible" to Playwright, so + // we assert the computed opacity rather than visibility.) + const controls = page + .locator('button[aria-label="next question"]') + .locator('xpath=ancestor::div[2]') + await expect(controls).toHaveCSS('opacity', '0', {timeout: 6000}) + + // Any mouse movement brings the controls back. + await page.mouse.move(400, 300) + await page.mouse.move(420, 320) + await expect(controls).toHaveCSS('opacity', '1') +}) diff --git a/tests/e2e/og-images.spec.ts b/tests/e2e/og-images.spec.ts new file mode 100644 index 0000000..d99f3bf --- /dev/null +++ b/tests/e2e/og-images.spec.ts @@ -0,0 +1,70 @@ +import {expect, test} from '@playwright/test' + +// Social / Open Graph metadata (rendered by src/layouts/Head.astro): +// - every page exposes og:image and twitter:image +// - static pages fall back to /social.png +// - the referenced image URL must resolve (HTTP 200 + image/* content-type) +// +// Per-question OG images now live on the SSR /play?lang=&q= page (og:image -> +// /og/{lang}/{id}.png). /play is a Netlify function and isn't served by the +// static dist/ e2e harness, so its meta is asserted on the deploy preview, not +// here — see the test.fixme at the bottom. The generated card images themselves +// ARE prerendered static, so we verify those resolve directly below. +// +// SITE_URL defaults to http://localhost:4321 for non-prod builds, so the meta +// hold absolute URLs against this origin; the e2e server runs on 4321 so they +// resolve back to it. + +const sample = [ + {path: '/', expectImage: /\/social\.png$/}, + {path: '/en', expectImage: /\/social\.png$/}, + {path: '/mission', expectImage: /\/social\.png$/}, +] + +const metaContent = (page: import('@playwright/test').Page, property: string) => + page.locator(`meta[property="${property}"]`).getAttribute('content') + +for (const {path, expectImage} of sample) { + test(`${path} exposes resolvable og:image and twitter:image`, async ({page, request}) => { + await page.goto(path) + + const ogImage = await metaContent(page, 'og:image') + const twitterImage = await metaContent(page, 'twitter:image') + + expect(ogImage, 'og:image should be present').toBeTruthy() + expect(twitterImage, 'twitter:image should be present').toBeTruthy() + expect(ogImage!).toMatch(expectImage) + // og and twitter images are the same source of truth. + expect(twitterImage).toBe(ogImage) + + // The image URL must resolve to an actual image. + const response = await request.get(ogImage!) + expect(response.status()).toBe(200) + expect(response.headers()['content-type'] || '').toMatch(/^image\//) + }) +} + +// Generated card images (#44): rendered from questions.json at build time and +// prerendered to /og/{lang}/{id}.png. Verify a sample resolves, including an +// RTL language (he), since that path had the trickiest rendering. +for (const card of ['/og/en/1.png', '/og/he/1.png']) { + test(`generated card ${card} resolves to a PNG`, async ({request}) => { + const response = await request.get(card) + expect(response.status()).toBe(200) + expect(response.headers()['content-type'] || '').toMatch(/^image\/png/) + }) +} + +// The new /play screen (#41) is SSR, so the static dist/ e2e harness can't serve +// it (same limitation as /contact). Asserting its per-question og:image -> +// /og/{lang}/{id}.png needs an SSR-capable server (e.g. `netlify dev`); enable +// this once the e2e harness can run functions. +test.fixme( + 'TODO: SSR /play exposes per-question og:image (needs functions-capable server)', + async () => { + // Verified manually on the deploy preview: + // /play?lang=en&q=1 -> og:image .../og/en/1.png + // /play?lang=he&q=2 -> og:image .../og/he/2.png + // /play (no q) -> og:image .../social.png + } +) diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000..45c0958 --- /dev/null +++ b/tests/e2e/smoke.spec.ts @@ -0,0 +1,45 @@ +import {expect, test} from '@playwright/test' + +// Smoke: the landing page renders and the key statically-built routes return 200. +// SSR-only routes (/contact, /api/*) are intentionally excluded here — they are +// emitted as a Netlify function, not static files (see tests/static-server.mjs). + +test('landing page renders', async ({page}) => { + const response = await page.goto('/') + expect(response?.status()).toBe(200) + + // Hero copy is stable, server-rendered content. + await expect(page.getByRole('heading', {level: 1})).toContainText('Conversation') + + // Primary calls to action. + await expect(page.getByRole('link', {name: 'Request Cards'}).first()).toBeVisible() + + // Document title comes from the shared Head.astro layout. + await expect(page).toHaveTitle(/WhoCards/) +}) + +const staticRoutes = [ + '/', + '/en', + '/mission', + '/print', + '/images', + '/events/hajnalig', + '/events/hajnalig/play', +] + +// /en/question/:id now 301-redirects to /play?lang=en&q=:id (netlify.toml), and +// /play is an SSR function — neither is served by the static dist/ e2e harness, +// so they're covered by Netlify's redirect-rules check + the deploy preview, not here. + +for (const route of staticRoutes) { + test(`route ${route} returns 200`, async ({request}) => { + const response = await request.get(route) + expect(response.status()).toBe(200) + }) +} + +test('unknown route serves the 404 page', async ({request}) => { + const response = await request.get('/this-route-does-not-exist') + expect(response.status()).toBe(404) +}) diff --git a/tests/static-server.mjs b/tests/static-server.mjs new file mode 100644 index 0000000..0fc5f94 --- /dev/null +++ b/tests/static-server.mjs @@ -0,0 +1,105 @@ +// Minimal zero-dependency static file server for serving the built `dist/` +// output during Playwright e2e runs. +// +// Why a custom server instead of `astro preview`? +// The Netlify adapter (@astrojs/netlify) does NOT support `astro preview` +// ("The @astrojs/netlify adapter does not support the preview command."), +// and `astro dev` tries to start Netlify Dev, which requires network access +// to api.netlify.com. Neither is viable for a hermetic CI run. The site is +// built with `output: 'static'`, so the vast majority of routes are emitted +// as plain HTML/asset files we can serve directly. +// +// Astro is configured with `build.format: 'file'` and `trailingSlash: 'never'`, +// so a request for `/en/question/1` maps to `dist/en/question/1.html` and `/` +// maps to `dist/index.html`. +// +// Note: SSR-only routes (e.g. `/contact`, `/api/*` which are `prerender = false`) +// are emitted as a Netlify function, not as static files, and are therefore NOT +// served here. Tests that need those are skipped (see tests/e2e/contact.spec.ts). + +import {createServer} from 'node:http' +import {createReadStream, existsSync, statSync} from 'node:fs' +import {fileURLToPath} from 'node:url' +import {dirname, extname, join, normalize} from 'node:path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const distDir = join(__dirname, '..', 'dist') +const port = Number(process.env.PORT) || 4321 + +const mimeTypes = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.txt': 'text/plain; charset=utf-8', + '.xml': 'application/xml; charset=utf-8', + '.webmanifest': 'application/manifest+json', +} + +if (!existsSync(distDir)) { + console.error(`[static-server] dist directory not found at ${distDir}. Run the build first.`) + process.exit(1) +} + +/** Resolve a request pathname to a file inside dist, honoring Astro's `format: 'file'`. */ +const resolveFile = (pathname) => { + // Decode and strip query/hash, then normalize to prevent path traversal. + let p = decodeURIComponent(pathname.split('?')[0].split('#')[0]) + const safe = normalize(p).replace(/^(\.\.[/\\])+/, '') + let filePath = join(distDir, safe) + + if (!filePath.startsWith(distDir)) return null // traversal guard + + // Exact file hit (assets, *.html requested directly). + if (existsSync(filePath) && statSync(filePath).isFile()) return filePath + + // Astro `format: 'file'` clean URL -> sibling .html. + // NOTE: a path like /en can be BOTH a directory (dist/en/) and a page + // (dist/en.html). Prefer the .html sibling, matching Astro's emitted routes. + if (!extname(filePath)) { + const html = `${filePath}.html` + if (existsSync(html) && statSync(html).isFile()) return html + } + + // Directory (or root) -> index.html inside it. + if (existsSync(filePath) && statSync(filePath).isDirectory()) { + const index = join(filePath, 'index.html') + if (existsSync(index) && statSync(index).isFile()) return index + } + + return null +} + +const server = createServer((req, res) => { + const filePath = resolveFile(req.url || '/') + + if (!filePath) { + const notFound = join(distDir, '404.html') + res.statusCode = 404 + if (existsSync(notFound)) { + res.setHeader('Content-Type', mimeTypes['.html']) + createReadStream(notFound).pipe(res) + } else { + res.end('Not Found') + } + return + } + + res.statusCode = 200 + res.setHeader('Content-Type', mimeTypes[extname(filePath)] || 'application/octet-stream') + createReadStream(filePath).pipe(res) +}) + +server.listen(port, () => { + console.log(`[static-server] serving ${distDir} on http://localhost:${port}`) +})