Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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)},
},
})
38 changes: 38 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 52 additions & 0 deletions tests/e2e/contact.spec.ts
Original file line number Diff line number Diff line change
@@ -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$/)
})
})
90 changes: 90 additions & 0 deletions tests/e2e/event-play.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, {en: string; hu: string}> = 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=<id>
// - defaults language to Hungarian (hu) on first visit
// - shows the question in an <h1>, 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')
})
Loading
Loading