From 503801517232b9ded8fcb0ffa847dab4993e79a3 Mon Sep 17 00:00:00 2001 From: acharlop Date: Sat, 13 Jun 2026 13:17:00 +0200 Subject: [PATCH 1/3] chore: add Playwright e2e testing and CI workflow Set up end-to-end testing with @playwright/test for the existing site. - playwright.config.ts builds the static site and serves dist/ via a tiny zero-dependency Node static server (tests/static-server.mjs). The Netlify adapter does not support `astro preview`, and `astro dev` needs network access to Netlify, so serving the built static output is the hermetic option. - test:e2e script + .github/workflows/e2e.yml (install, build, browsers, run). - Specs cover: landing + key routes (smoke), hajnalig SimplePlay event screen (next/prev, language toggle, auto-hiding controls, ?q= start), per-question page + GameControls, and og:image/twitter:image meta resolving to real images. - Contact form spec is written but skipped: it is an SSR-only route (prerender=false) emitted as a Netlify function, not served by the static e2e server, and submitting would hit Postgres/Google Sheets. - TODO placeholder for #44 (generated card images) and #41 (new /play screen). Closes #46 --- .github/workflows/e2e.yml | 66 ++++++++++++++++++++ .gitignore | 6 ++ package.json | 2 + playwright.config.ts | 39 ++++++++++++ pnpm-lock.yaml | 38 ++++++++++++ tests/e2e/contact.spec.ts | 52 ++++++++++++++++ tests/e2e/event-play.spec.ts | 90 +++++++++++++++++++++++++++ tests/e2e/og-images.spec.ts | 53 ++++++++++++++++ tests/e2e/question-page.spec.ts | 48 +++++++++++++++ tests/e2e/smoke.spec.ts | 43 +++++++++++++ tests/static-server.mjs | 105 ++++++++++++++++++++++++++++++++ 11 files changed, 542 insertions(+) create mode 100644 .github/workflows/e2e.yml create mode 100644 playwright.config.ts create mode 100644 tests/e2e/contact.spec.ts create mode 100644 tests/e2e/event-play.spec.ts create mode 100644 tests/e2e/og-images.spec.ts create mode 100644 tests/e2e/question-page.spec.ts create mode 100644 tests/e2e/smoke.spec.ts create mode 100644 tests/static-server.mjs 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..95e0187 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,9 @@ pnpm-debug.log* # Local Netlify folder .netlify mocks + +# playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/package.json b/package.json index 6652c08..132b513 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", @@ -67,6 +68,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 9dc1dc0..9b93ebf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,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 @@ -1562,6 +1565,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==} @@ -3172,6 +3180,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} @@ -4504,6 +4517,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'} @@ -7055,6 +7078,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 @@ -8777,6 +8804,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -10475,6 +10505,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..453388f --- /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) + + // The toggle is the only control button without an aria-label; it sits between + // the prev and next buttons (which both have aria-labels). + const toggle = page.locator('button:not([aria-label])').first() + 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..e800368 --- /dev/null +++ b/tests/e2e/og-images.spec.ts @@ -0,0 +1,53 @@ +import {expect, test} from '@playwright/test' + +// Social / Open Graph metadata (rendered by src/layouts/Head.astro): +// - every page exposes og:image and twitter:image +// - question pages point og:image at the committed /images/{lang}/{id}.png +// - other pages fall back to /social.png +// - the referenced image URL must resolve (HTTP 200 + image/* content-type) +// +// 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/question/1', expectImage: /\/images\/en\/1\.png$/}, + {path: '/hu/question/1', expectImage: /\/images\/hu\/1\.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\//) + }) +} + +// Placeholder for follow-up work. Code-generated card images (#44) replace the +// committed /images/{lang}/{id}.png files with dynamically generated ones, and +// the new /play screen (#41) introduces its own routes. Those each warrant +// dedicated OG/route coverage once they land in this branch. +test.fixme( + 'TODO(#44, #41): code-generated card images and the new /play screen get dedicated tests', + async () => { + // Intentionally unimplemented until #44 (generated card images) and + // #41 (new /play screen) are merged. See tests/e2e/contact.spec.ts for the + // current limitation around SSR routes under the static e2e server. + } +) diff --git a/tests/e2e/question-page.spec.ts b/tests/e2e/question-page.spec.ts new file mode 100644 index 0000000..a9793d7 --- /dev/null +++ b/tests/e2e/question-page.spec.ts @@ -0,0 +1,48 @@ +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/data/questions.json', import.meta.url)), 'utf8') +) + +// Per-question page: src/pages/[language]/question/[id].astro +// - renders the question text for the [language]/[id] in an

+// - renders GameControls (#prev / #next anchors + a language button) +// - GameControls wires #prev/#next hrefs to other /[lang]/question/ pages +// (order is randomized client-side, so we assert the *shape* of navigation). + +const q1en = questions['1'].en + +test.beforeEach(async ({page}) => { + await page.addInitScript(() => window.localStorage.clear()) +}) + +test('renders the question text for /en/question/1', async ({page}) => { + await page.goto('/en/question/1') + await expect(page.getByRole('heading', {level: 1})).toHaveText(q1en) +}) + +test('renders GameControls', async ({page}) => { + await page.goto('/en/question/1') + + await expect(page.locator('#prev')).toBeAttached() + await expect(page.locator('#next')).toBeAttached() + + // Language button shows the current language code and is wired to open the modal. + await expect(page.getByRole('button', {name: 'change language'})).toHaveText('en') +}) + +test('next control navigates to another question page', async ({page}) => { + await page.goto('/en/question/1') + + const next = page.locator('#next') + // GameControls populates the href client-side after hydration. + await expect(next).toHaveAttribute('href', /\/en\/question\/\d+$/) + + await next.click() + await expect(page).toHaveURL(/\/en\/question\/\d+$/) + await expect(page.getByRole('heading', {level: 1})).not.toBeEmpty() +}) diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000..cc2d3d4 --- /dev/null +++ b/tests/e2e/smoke.spec.ts @@ -0,0 +1,43 @@ +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', + '/en/question/1', + '/hu/question/1', + '/events/hajnalig', + '/events/hajnalig/play', +] + +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}`) +}) From 80ce51e62e5d389b13ba4f7431cac12088006850 Mon Sep 17 00:00:00 2001 From: acharlop Date: Sat, 13 Jun 2026 15:04:10 +0200 Subject: [PATCH 2/3] test(e2e): update specs for play redesign + generated cards - drop question-page.spec.ts (per-question pages removed; now 301 -> /play, covered by Netlify redirect-rules, not the static dist/ harness) - smoke: remove /:lang/question/:id routes (now redirects / SSR, not static) - og-images: replace question-page samples with static-page fallbacks; turn the #44 placeholder into real assertions that generated /og/{lang}/{id}.png cards resolve (incl. RTL he); add fixme noting /play SSR og needs a functions server - gitignore harness agent worktrees --- .gitignore | 3 +++ tests/e2e/og-images.spec.ts | 41 +++++++++++++++++++--------- tests/e2e/question-page.spec.ts | 48 --------------------------------- tests/e2e/smoke.spec.ts | 6 +++-- 4 files changed, 36 insertions(+), 62 deletions(-) delete mode 100644 tests/e2e/question-page.spec.ts diff --git a/.gitignore b/.gitignore index 95e0187..72257ba 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ mocks /playwright-report/ /blob-report/ /playwright/.cache/ + +# Claude Code agent worktrees (harness-managed) +.claude/worktrees/ diff --git a/tests/e2e/og-images.spec.ts b/tests/e2e/og-images.spec.ts index e800368..d99f3bf 100644 --- a/tests/e2e/og-images.spec.ts +++ b/tests/e2e/og-images.spec.ts @@ -2,18 +2,23 @@ import {expect, test} from '@playwright/test' // Social / Open Graph metadata (rendered by src/layouts/Head.astro): // - every page exposes og:image and twitter:image -// - question pages point og:image at the committed /images/{lang}/{id}.png -// - other pages fall back to /social.png +// - 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/question/1', expectImage: /\/images\/en\/1\.png$/}, - {path: '/hu/question/1', expectImage: /\/images\/hu\/1\.png$/}, + {path: '/en', expectImage: /\/social\.png$/}, + {path: '/mission', expectImage: /\/social\.png$/}, ] const metaContent = (page: import('@playwright/test').Page, property: string) => @@ -39,15 +44,27 @@ for (const {path, expectImage} of sample) { }) } -// Placeholder for follow-up work. Code-generated card images (#44) replace the -// committed /images/{lang}/{id}.png files with dynamically generated ones, and -// the new /play screen (#41) introduces its own routes. Those each warrant -// dedicated OG/route coverage once they land in this branch. +// 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(#44, #41): code-generated card images and the new /play screen get dedicated tests', + 'TODO: SSR /play exposes per-question og:image (needs functions-capable server)', async () => { - // Intentionally unimplemented until #44 (generated card images) and - // #41 (new /play screen) are merged. See tests/e2e/contact.spec.ts for the - // current limitation around SSR routes under the static e2e server. + // 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/question-page.spec.ts b/tests/e2e/question-page.spec.ts deleted file mode 100644 index a9793d7..0000000 --- a/tests/e2e/question-page.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -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/data/questions.json', import.meta.url)), 'utf8') -) - -// Per-question page: src/pages/[language]/question/[id].astro -// - renders the question text for the [language]/[id] in an

-// - renders GameControls (#prev / #next anchors + a language button) -// - GameControls wires #prev/#next hrefs to other /[lang]/question/ pages -// (order is randomized client-side, so we assert the *shape* of navigation). - -const q1en = questions['1'].en - -test.beforeEach(async ({page}) => { - await page.addInitScript(() => window.localStorage.clear()) -}) - -test('renders the question text for /en/question/1', async ({page}) => { - await page.goto('/en/question/1') - await expect(page.getByRole('heading', {level: 1})).toHaveText(q1en) -}) - -test('renders GameControls', async ({page}) => { - await page.goto('/en/question/1') - - await expect(page.locator('#prev')).toBeAttached() - await expect(page.locator('#next')).toBeAttached() - - // Language button shows the current language code and is wired to open the modal. - await expect(page.getByRole('button', {name: 'change language'})).toHaveText('en') -}) - -test('next control navigates to another question page', async ({page}) => { - await page.goto('/en/question/1') - - const next = page.locator('#next') - // GameControls populates the href client-side after hydration. - await expect(next).toHaveAttribute('href', /\/en\/question\/\d+$/) - - await next.click() - await expect(page).toHaveURL(/\/en\/question\/\d+$/) - await expect(page.getByRole('heading', {level: 1})).not.toBeEmpty() -}) diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index cc2d3d4..45c0958 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -24,12 +24,14 @@ const staticRoutes = [ '/mission', '/print', '/images', - '/en/question/1', - '/hu/question/1', '/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) From 3382e7b7c185fe64ac7e4faa75790c2fa737f8cd Mon Sep 17 00:00:00 2001 From: acharlop Date: Sat, 13 Jun 2026 15:11:06 +0200 Subject: [PATCH 3/3] test(e2e): fix hajnalig language-toggle locator after Play refactor The generalized control now has aria-label='change language' (the old hajnalig button had none), so locate it by accessible name instead of button:not([aria-label]). --- tests/e2e/event-play.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/event-play.spec.ts b/tests/e2e/event-play.spec.ts index 453388f..ed5ae02 100644 --- a/tests/e2e/event-play.spec.ts +++ b/tests/e2e/event-play.spec.ts @@ -59,9 +59,9 @@ 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) - // The toggle is the only control button without an aria-label; it sits between - // the prev and next buttons (which both have aria-labels). - const toggle = page.locator('button:not([aria-label])').first() + // 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)