From e8fd7eb01d1813b530483d3bbeac64dcc64878f6 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Mon, 16 Feb 2026 12:22:58 +0100 Subject: [PATCH] test: add end-to-end browser tests with Playwright Set up tests/e2e/ with a login page test covering branding, accessibility, form structure, theme colors, and static asset serving. Includes run.sh that manages the app lifecycle (start, test, stop) automatically. --- tests/e2e/.gitignore | 1 + tests/e2e/package.json | 12 ++++ tests/e2e/run.sh | 78 +++++++++++++++++++++ tests/e2e/test_login.js | 145 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 236 insertions(+) create mode 100644 tests/e2e/.gitignore create mode 100644 tests/e2e/package.json create mode 100755 tests/e2e/run.sh create mode 100644 tests/e2e/test_login.js diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/tests/e2e/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000..a5c3ec2 --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,12 @@ +{ + "private": true, + "name": "porchlight-e2e", + "description": "End-to-end browser tests for Porchlight", + "scripts": { + "test": "./run.sh", + "setup": "npx playwright install chromium" + }, + "dependencies": { + "playwright": "^1.52.0" + } +} diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh new file mode 100755 index 0000000..09e506b --- /dev/null +++ b/tests/e2e/run.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Run Porchlight end-to-end browser tests. +# +# Usage: +# ./run.sh # run all test_*.js files +# ./run.sh test_login.js # run a specific test +# +# Prerequisites: +# npm install && npm run setup (once, to install playwright + chromium) +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +PORT="${E2E_PORT:-8099}" +export TARGET_URL="http://127.0.0.1:${PORT}" + +# --- Start the app --- +echo "Starting Porchlight on port ${PORT}..." +OIDC_OP_ISSUER="${TARGET_URL}" \ +OIDC_OP_DEBUG=true \ +OIDC_OP_SQLITE_PATH=:memory: \ + uv run --directory "$PROJECT_ROOT" \ + uvicorn fastapi_oidc_op.app:create_app \ + --factory --host 127.0.0.1 --port "$PORT" \ + --log-level warning & +SERVER_PID=$! + +cleanup() { + echo "Stopping server (pid ${SERVER_PID})..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true +} +trap cleanup EXIT + +# --- Wait for healthy --- +echo "Waiting for server..." +for i in $(seq 1 30); do + if curl -sf "${TARGET_URL}/health" >/dev/null 2>&1; then + echo "Server ready." + break + fi + if [ "$i" -eq 30 ]; then + echo "Server failed to start within 30 seconds." >&2 + exit 1 + fi + sleep 1 +done + +# --- Run tests --- +FAILED=0 + +if [ $# -gt 0 ]; then + TEST_FILES=("$@") +else + TEST_FILES=("$SCRIPT_DIR"/test_*.js) +fi + +for test_file in "${TEST_FILES[@]}"; do + echo "" + echo "=== Running $(basename "$test_file") ===" + if node "$test_file"; then + echo "=== $(basename "$test_file"): OK ===" + else + echo "=== $(basename "$test_file"): FAILED ===" + FAILED=1 + fi +done + +echo "" +if [ "$FAILED" -eq 0 ]; then + echo "All e2e tests passed." +else + echo "Some e2e tests failed." >&2 +fi + +exit "$FAILED" diff --git a/tests/e2e/test_login.js b/tests/e2e/test_login.js new file mode 100644 index 0000000..c504371 --- /dev/null +++ b/tests/e2e/test_login.js @@ -0,0 +1,145 @@ +// Porchlight — Login page e2e test +// +// Tests branding, page structure, accessibility, theme, and responsive layout. +// Requires: TARGET_URL env var or defaults to http://localhost:8099 +// +const { chromium } = require('playwright'); + +const TARGET_URL = process.env.TARGET_URL || 'http://localhost:8099'; + +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + let passed = 0; + let failed = 0; + + function assert(condition, description) { + if (condition) { + console.log(` PASS: ${description}`); + passed++; + } else { + console.log(` FAIL: ${description}`); + failed++; + } + } + + // ---- Branding ---- + console.log('\n--- Branding ---'); + await page.goto(`${TARGET_URL}/login`); + + const title = await page.title(); + assert(title.includes('Porchlight'), `Page title contains "Porchlight" (got: "${title}")`); + assert(!title.includes('FastAPI'), `Page title does not contain "FastAPI" (got: "${title}")`); + + // ---- Favicon ---- + console.log('\n--- Favicon ---'); + const favicon = await page.locator('link[rel="icon"]').getAttribute('href'); + assert(favicon === '/static/favicon.png', `Favicon link points to /static/favicon.png (got: "${favicon}")`); + + const faviconResp = await page.request.get(`${TARGET_URL}/static/favicon.png`); + assert(faviconResp.ok(), `Favicon file is served (status: ${faviconResp.status()})`); + + // ---- Site header & logo ---- + console.log('\n--- Site header & logo ---'); + const header = page.locator('.site-header'); + assert(await header.isVisible(), 'Site header is visible'); + + const logo = page.locator('.site-logo'); + assert(await logo.isVisible(), 'Logo image is visible'); + + const logoSrc = await logo.getAttribute('src'); + assert(logoSrc === '/static/logo.svg', `Logo src is /static/logo.svg (got: "${logoSrc}")`); + + const logoResp = await page.request.get(`${TARGET_URL}/static/logo.svg`); + assert(logoResp.ok(), `Logo SVG file is served (status: ${logoResp.status()})`); + + const siteTitle = page.locator('.site-title'); + assert(await siteTitle.isVisible(), 'Site title text is visible'); + const siteTitleText = await siteTitle.textContent(); + assert(siteTitleText.trim() === 'Porchlight', `Site title text is "Porchlight" (got: "${siteTitleText.trim()}")`); + + // ---- Accessibility ---- + console.log('\n--- Accessibility ---'); + const skipLink = page.locator('.skip-link'); + assert(await skipLink.count() === 1, 'Skip link is present'); + + const main = page.locator('main#main'); + assert(await main.count() === 1, 'Main landmark with id="main" exists'); + + const liveRegion = page.locator('#live[aria-live="polite"]'); + assert(await liveRegion.count() === 1, 'Polite live region exists'); + + // ---- Login form structure ---- + console.log('\n--- Login form structure ---'); + const h1 = page.locator('h1'); + assert(await h1.textContent() === 'Sign in', `H1 says "Sign in" (got: "${await h1.textContent()}")`); + + const passwordForm = page.locator('form[hx-post="/login/password"]'); + assert(await passwordForm.count() === 1, 'Password login form exists'); + + const usernameInput = page.locator('#username'); + assert(await usernameInput.isVisible(), 'Username input is visible'); + + const passwordInput = page.locator('#password'); + assert(await passwordInput.isVisible(), 'Password input is visible'); + + const submitBtn = passwordForm.locator('button[type="submit"]'); + assert(await submitBtn.isVisible(), 'Submit button is visible'); + + const webauthnForm = page.locator('#webauthn-login-form'); + assert(await webauthnForm.count() === 1, 'WebAuthn login form exists'); + + // ---- Theme / styling ---- + console.log('\n--- Theme / styling ---'); + const bgColor = await page.evaluate(() => { + return getComputedStyle(document.body).backgroundColor; + }); + assert( + bgColor === 'rgb(250, 250, 249)' || bgColor === 'rgb(28, 25, 23)', + `Body background is themed (got: "${bgColor}")` + ); + + const btnBg = await page.evaluate(() => { + const btn = document.querySelector('button[type="submit"]'); + return getComputedStyle(btn).backgroundColor; + }); + assert( + btnBg === 'rgb(217, 119, 6)' || btnBg === 'rgb(245, 158, 11)', + `Button uses amber accent color (got: "${btnBg}")` + ); + + // ---- Section card styling ---- + console.log('\n--- Section cards ---'); + const sectionBg = await page.evaluate(() => { + const section = document.querySelector('section'); + return getComputedStyle(section).backgroundColor; + }); + assert( + sectionBg === 'rgb(245, 245, 244)' || sectionBg === 'rgb(41, 37, 36)', + `Sections have surface background (got: "${sectionBg}")` + ); + + const sectionBorder = await page.evaluate(() => { + const section = document.querySelector('section'); + return getComputedStyle(section).borderStyle; + }); + assert(sectionBorder === 'solid', `Sections have solid border (got: "${sectionBorder}")`); + + // ---- Static assets ---- + console.log('\n--- Static assets ---'); + const cssResp = await page.request.get(`${TARGET_URL}/static/style.css`); + assert(cssResp.ok(), `style.css is served (status: ${cssResp.status()})`); + const cssBody = await cssResp.text(); + assert(cssBody.includes('--accent'), 'CSS contains --accent custom property'); + assert(cssBody.includes('#d97706'), 'CSS contains amber accent color #d97706'); + assert(cssBody.includes('prefers-color-scheme: dark'), 'CSS contains dark mode media query'); + assert(cssBody.includes('prefers-reduced-motion'), 'CSS contains reduced motion media query'); + + // ---- Summary ---- + console.log(`\n========================================`); + console.log(`Results: ${passed} passed, ${failed} failed`); + console.log(`========================================\n`); + + await browser.close(); + process.exit(failed > 0 ? 1 : 0); +})();