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.
This commit is contained in:
Johan Lundberg 2026-02-16 12:22:58 +01:00
parent 84e61464c7
commit e8fd7eb01d
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
4 changed files with 236 additions and 0 deletions

1
tests/e2e/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/

12
tests/e2e/package.json Normal file
View file

@ -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"
}
}

78
tests/e2e/run.sh Executable file
View file

@ -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"

145
tests/e2e/test_login.js Normal file
View file

@ -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);
})();