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:
parent
84e61464c7
commit
e8fd7eb01d
4 changed files with 236 additions and 0 deletions
1
tests/e2e/.gitignore
vendored
Normal file
1
tests/e2e/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules/
|
||||
12
tests/e2e/package.json
Normal file
12
tests/e2e/package.json
Normal 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
78
tests/e2e/run.sh
Executable 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
145
tests/e2e/test_login.js
Normal 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);
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue