Bite-sized TDD plan for the Tier-1 local-Docker test foundation: Vitest frontend-unit setup, a four-service docker compose stack (seeded real disposable Gitea + backend + nginx SPA + Mailpit), and a Playwright e2e harness (BASE_URL + Mailpit mail-sink) with one OTC-login smoke spec. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
27 KiB
M3-0 — Test & Local-Env Foundation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Stand up the handbook §10.3 Tier-1 local-Docker test foundation for rfc-app — a docker compose stack (backend + nginx-served SPA + seeded real Gitea + Mailpit), a Vitest frontend-unit setup, and an environment-agnostic Playwright e2e harness with one passing smoke spec — so every later M3 sub-plan can be verified at unit/integration/functional/e2e levels on localhost (and against PPE later by changing BASE_URL).
Architecture: A four-service compose stack. The backend (FastAPI, single uvicorn process, SQLite, migrations on startup) and an nginx container serving the built SPA + proxying /api and /auth — mirroring prod. A real, disposable Gitea container, seeded fresh each run by a one-shot seed service (admin + bot token + OAuth app + org + content repo + webhook), chosen so the SAME e2e suite behaves identically in Tier 1 and Tier 2/PPE (§10.3). Mailpit as the mail sink; e2e logs in via the email OTC flow (/auth/otc/request → read code from Mailpit's API → /auth/otc/verify), which needs no Gitea OAuth consent scripting. Playwright is parameterized by BASE_URL + MAILSINK_URL so the unchanged suite later targets PPE.
Tech Stack: Docker Compose, Gitea (pinned image), Mailpit, nginx, Python 3.11/uvicorn, Vite/React 19, Vitest + @testing-library/react, Playwright (@playwright/test).
Conventions (Wiggleverse): SSH git transport; no inline comments trailing CLI commands; commit messages end with the Co-Authored-By trailer. Branch off main — do not work on main. Suggested branch: feat/m3-0-test-foundation.
Pre-req: This plan creates a new directory testing/ at repo root for harness assets and frontend/src/**/*.test.jsx for unit tests. It does not touch backend app code except adding a Dockerfile.
File Structure
Files created/modified, by responsibility:
testing/docker-compose.yml— the four-service Tier-1 stack (backend, web/nginx, gitea, mailpit) + the one-shotgitea-seedservice.testing/backend.Dockerfile— builds the backend image (Python 3.11 + requirements + app).testing/web.Dockerfile— builds the SPA (node build stage) and serves it via nginx (runtime stage).testing/web.nginx.conf— nginx config for the web container (SPA fallback +/api/authproxy to backend). Adapted fromdeploy/nginx/ohm.wiggleverse.org.conf, TLS stripped.testing/seed-gitea.sh— idempotent seed script: admin user, bot user + token, OAuth app, org, content repo (seeded withrfcs/), webhook.testing/.env.tier1— the env values the compose stack injects into the backend.testing/README.md— how to run Tier 1 locally and how to point the suite at PPE.frontend/vitest.config.js— Vitest config (jsdom env).frontend/src/test/setup.js— testing-library/jsdom setup.frontend/src/lib/brand.js+frontend/src/lib/brand.test.js— a tiny first unit-tested module (proves Vitest wiring; reused by M3c).frontend/package.json— add devDeps +test,test:runscripts (modify).e2e/playwright.config.js— Playwright config;baseURLfromBASE_URL, mail sink fromMAILSINK_URL.e2e/lib/mailpit.js— helper to read the latest OTC email from Mailpit's API.e2e/smoke.spec.js— the one smoke spec (OTC login → landing renders).e2e/package.json— Playwright dep +e2escript (kept separate from the app frontend deps).Makefile(repo root) —tier1-up,tier1-down,e2e,fe-unitconvenience targets (modify or create).
Task 1: Frontend unit testing (Vitest) — independent quick win
Files:
-
Modify:
frontend/package.json -
Create:
frontend/vitest.config.js -
Create:
frontend/src/test/setup.js -
Create:
frontend/src/lib/brand.js -
Test:
frontend/src/lib/brand.test.js -
Step 1: Add Vitest dev dependencies and scripts
Modify frontend/package.json — add to devDependencies:
"vitest": "^3.0.0",
"jsdom": "^25.0.0",
"@testing-library/react": "^16.1.0",
"@testing-library/jest-dom": "^6.6.0"
Add to scripts:
"test": "vitest",
"test:run": "vitest run"
- Step 2: Install
Run: cd frontend && npm install
Expected: lockfile updates, node_modules/.bin/vitest exists.
- Step 3: Create the Vitest config
Create frontend/vitest.config.js:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.js'],
include: ['src/**/*.test.{js,jsx}'],
},
})
- Step 4: Create the test setup file
Create frontend/src/test/setup.js:
import '@testing-library/jest-dom'
- Step 5: Write the failing unit test
Create frontend/src/lib/brand.test.js:
import { describe, it, expect } from 'vitest'
import { brandTitle } from './brand.js'
describe('brandTitle', () => {
it('returns the deployment name when set', () => {
expect(brandTitle('Wiggleverse')).toBe('Wiggleverse')
})
it('falls back to a neutral placeholder when name is empty', () => {
expect(brandTitle('')).toBe('RFC')
expect(brandTitle(undefined)).toBe('RFC')
})
})
- Step 6: Run it to verify it fails
Run: cd frontend && npm run test:run -- src/lib/brand.test.js
Expected: FAIL — Failed to resolve import "./brand.js" (module does not exist yet).
- Step 7: Implement the minimal module
Create frontend/src/lib/brand.js:
export function brandTitle(name) {
const trimmed = (name || '').trim()
return trimmed || 'RFC'
}
- Step 8: Run it to verify it passes
Run: cd frontend && npm run test:run -- src/lib/brand.test.js
Expected: PASS — 2 tests pass.
- Step 9: Commit
git add frontend/package.json frontend/package-lock.json frontend/vitest.config.js frontend/src/test/setup.js frontend/src/lib/brand.js frontend/src/lib/brand.test.js
git commit -m "test(frontend): add Vitest unit-test harness with first brand helper
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Task 2: Backend Docker image
Files:
-
Create:
testing/backend.Dockerfile -
Create:
testing/.env.tier1 -
Step 1: Write the backend Dockerfile
Create testing/backend.Dockerfile:
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
COPY backend/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt uvicorn
COPY backend/ /app/
RUN mkdir -p /data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Note: build context is the repo root (set in compose), so COPY backend/... resolves.
- Step 2: Write the backend env file
Create testing/.env.tier1:
GITEA_URL=http://gitea:3000
GITEA_BOT_USER=rfc-bot
GITEA_BOT_TOKEN=tier1-bot-token-PLACEHOLDER
GITEA_ORG=wiggleverse
META_REPO=ohm-content
REGISTRY_REPO=
OAUTH_CLIENT_ID=tier1-oauth-client-PLACEHOLDER
OAUTH_CLIENT_SECRET=tier1-oauth-secret-PLACEHOLDER
APP_URL=http://localhost:8080
SECRET_KEY=tier1-not-secret
DATABASE_PATH=/data/rfc-app.db
OWNER_GITEA_LOGIN=owner
GITEA_WEBHOOK_SECRET=tier1-webhook-secret
ENABLED_MODELS=claude
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_STARTTLS=false
EMAIL_FROM=rfc@example.test
EMAIL_FROM_NAME=RFC Tier1
EMAIL_ENABLED=true
TURNSTILE_REQUIRED=false
The *-PLACEHOLDER token/oauth values are overwritten at runtime by the seed step (Task 4) which writes the real values into testing/.env.tier1.generated; compose loads both files (Task 5), generated last so it wins. Leaving the placeholders here documents the full contract and lets the backend start to fail loudly if seeding was skipped.
- Step 3: Verify the image builds
Run: docker build -f testing/backend.Dockerfile -t rfc-backend:tier1 .
Expected: image builds, no errors.
- Step 4: Commit
git add testing/backend.Dockerfile testing/.env.tier1
git commit -m "test(tier1): backend Docker image + env contract
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Task 3: Web (nginx + built SPA) Docker image
Files:
-
Create:
testing/web.Dockerfile -
Create:
testing/web.nginx.conf -
Step 1: Write the nginx config (adapted from prod, TLS stripped)
Create testing/web.nginx.conf:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
location /auth/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
- Step 2: Write the web Dockerfile (multi-stage build → nginx)
Create testing/web.Dockerfile:
FROM node:20-slim AS build
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json /app/
RUN npm ci
COPY frontend/ /app/
ENV VITE_APP_NAME="RFC Tier1"
RUN npm run build
FROM nginx:1.27-alpine
COPY testing/web.nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
Note: VITE_APP_NAME is still build-required until M3c does the hard cut (frontend/vite.config.js throws without it). Supplying a test value keeps the build green now; M3c removes this line.
- Step 3: Verify the image builds
Run: docker build -f testing/web.Dockerfile -t rfc-web:tier1 .
Expected: build succeeds; the SPA compiles with the test brand.
- Step 4: Commit
git add testing/web.Dockerfile testing/web.nginx.conf
git commit -m "test(tier1): nginx web image serving the built SPA
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Task 4: Gitea seed script
Files:
- Create:
testing/seed-gitea.sh
This script runs inside a small curl+git-capable container (the gitea-seed service, Task 5). It assumes Gitea is reachable at http://gitea:3000 with install-lock on and a known admin password from env. It is idempotent: every create tolerates "already exists".
- Step 1: Write the seed script
Create testing/seed-gitea.sh:
#!/usr/bin/env sh
set -eu
GITEA="${GITEA_URL:-http://gitea:3000}"
ADMIN_USER="${GITEA_ADMIN_USER:-giteaadmin}"
ADMIN_PASS="${GITEA_ADMIN_PASSWORD:-giteaadmin-pass}"
ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-admin@example.test}"
ORG="${GITEA_ORG:-wiggleverse}"
BOT_USER="${GITEA_BOT_USER:-rfc-bot}"
BOT_PASS="${GITEA_BOT_PASSWORD:-rfc-bot-pass}"
CONTENT_REPO="${META_REPO:-ohm-content}"
APP_URL="${APP_URL:-http://localhost:8080}"
WEBHOOK_SECRET="${GITEA_WEBHOOK_SECRET:-tier1-webhook-secret}"
OUT="${SEED_OUT:-/seed/.env.tier1.generated}"
echo "seed: waiting for gitea at $GITEA"
i=0
while ! curl -sf "$GITEA/api/healthz" >/dev/null 2>&1; do
i=$((i+1)); [ "$i" -gt 60 ] && echo "gitea never came up" && exit 1
sleep 2
done
auth_admin() { curl -sf -u "$ADMIN_USER:$ADMIN_PASS" "$@"; }
echo "seed: ensuring bot user"
auth_admin -X POST "$GITEA/api/v1/admin/users" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$BOT_USER\",\"email\":\"$BOT_USER@example.test\",\"password\":\"$BOT_PASS\",\"must_change_password\":false}" \
|| echo "seed: bot user exists, continuing"
echo "seed: ensuring owner user (for OWNER_GITEA_LOGIN)"
auth_admin -X POST "$GITEA/api/v1/admin/users" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"owner\",\"email\":\"owner@example.test\",\"password\":\"owner-pass\",\"must_change_password\":false}" \
|| echo "seed: owner exists, continuing"
echo "seed: minting bot access token"
TOKEN=$(curl -sf -u "$BOT_USER:$BOT_PASS" -X POST "$GITEA/api/v1/users/$BOT_USER/tokens" \
-H 'Content-Type: application/json' \
-d '{"name":"tier1-bot","scopes":["write:repository","write:organization","write:user","write:admin"]}' \
| sed -n 's/.*"sha1":"\([^"]*\)".*/\1/p')
[ -n "$TOKEN" ] || { echo "seed: failed to mint bot token" ; exit 1; }
echo "seed: ensuring org $ORG (owned by bot)"
curl -sf -H "Authorization: token $TOKEN" -X POST "$GITEA/api/v1/orgs" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$ORG\"}" || echo "seed: org exists, continuing"
echo "seed: ensuring content repo $ORG/$CONTENT_REPO"
curl -sf -H "Authorization: token $TOKEN" -X POST "$GITEA/api/v1/orgs/$ORG/repos" \
-H 'Content-Type: application/json' \
-d "{\"name\":\"$CONTENT_REPO\",\"auto_init\":true,\"default_branch\":\"main\"}" \
|| echo "seed: content repo exists, continuing"
echo "seed: seeding one entry under rfcs/ so the catalog is non-empty"
B64=$(printf '%s' '---
title: Intro
status: graduated
id: RFC-0001
owners: [owner]
---
# Intro
Seed entry for Tier-1 e2e.
' | base64 | tr -d '\n')
curl -s -H "Authorization: token $TOKEN" -X POST \
"$GITEA/api/v1/repos/$ORG/$CONTENT_REPO/contents/rfcs/intro.md" \
-H 'Content-Type: application/json' \
-d "{\"message\":\"seed intro\",\"content\":\"$B64\",\"branch\":\"main\"}" \
|| echo "seed: intro.md exists, continuing"
echo "seed: registering OAuth application"
OAUTH_JSON=$(curl -sf -u "$ADMIN_USER:$ADMIN_PASS" -X POST "$GITEA/api/v1/user/applications/oauth2" \
-H 'Content-Type: application/json' \
-d "{\"name\":\"rfc-app-tier1\",\"redirect_uris\":[\"$APP_URL/auth/callback\"],\"confidential_client\":true}")
CLIENT_ID=$(printf '%s' "$OAUTH_JSON" | sed -n 's/.*"client_id":"\([^"]*\)".*/\1/p')
CLIENT_SECRET=$(printf '%s' "$OAUTH_JSON" | sed -n 's/.*"client_secret":"\([^"]*\)".*/\1/p')
echo "seed: registering webhook on content repo -> backend"
curl -s -H "Authorization: token $TOKEN" -X POST \
"$GITEA/api/v1/repos/$ORG/$CONTENT_REPO/hooks" \
-H 'Content-Type: application/json' \
-d "{\"type\":\"gitea\",\"active\":true,\"events\":[\"push\",\"pull_request\"],\"config\":{\"url\":\"http://backend:8000/api/webhooks/gitea\",\"content_type\":\"json\",\"secret\":\"$WEBHOOK_SECRET\"}}" \
|| echo "seed: webhook exists, continuing"
echo "seed: writing generated env to $OUT"
cat > "$OUT" <<EOF
GITEA_BOT_TOKEN=$TOKEN
OAUTH_CLIENT_ID=$CLIENT_ID
OAUTH_CLIENT_SECRET=$CLIENT_SECRET
EOF
echo "seed: done"
- Step 2: Make it executable
Run: chmod +x testing/seed-gitea.sh
- Step 3: Commit
git add testing/seed-gitea.sh
git commit -m "test(tier1): idempotent Gitea seed script (bot token, org, content repo, OAuth app, webhook)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Verification of this script happens in Task 5 against the real Gitea image. The Gitea admin user is created by the gitea service's own init env (Task 5), so the script can authenticate as admin from its first call. If a Gitea-version API mismatch appears (e.g. the token
scopesvocabulary, or the OAuth-app endpoint path), fix it against the pinned imagegitea/gitea:1.22and keep the script idempotent.
Task 5: Compose the stack and bring it up
Files:
-
Create:
testing/docker-compose.yml -
Create/modify:
Makefile -
Step 1: Write the compose file
Create testing/docker-compose.yml:
name: rfc-tier1
services:
gitea:
image: gitea/gitea:1.22
environment:
GITEA__security__INSTALL_LOCK: "true"
GITEA__server__ROOT_URL: "http://gitea:3000/"
GITEA__server__HTTP_PORT: "3000"
GITEA__database__DB_TYPE: "sqlite3"
GITEA__webhook__ALLOWED_HOST_LIST: "*"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/healthz"]
interval: 5s
timeout: 3s
retries: 30
ports:
- "3001:3000"
gitea-admin-init:
image: gitea/gitea:1.22
depends_on:
gitea:
condition: service_healthy
volumes_from:
- gitea
entrypoint: ["/bin/sh", "-c"]
command:
- >
gitea admin user create --admin --username giteaadmin
--password giteaadmin-pass --email admin@example.test
--must-change-password=false || true
restart: "no"
gitea-seed:
image: alpine:3.20
depends_on:
gitea-admin-init:
condition: service_completed_successfully
env_file:
- .env.tier1
environment:
GITEA_ADMIN_USER: giteaadmin
GITEA_ADMIN_PASSWORD: giteaadmin-pass
GITEA_BOT_PASSWORD: rfc-bot-pass
SEED_OUT: /seed/.env.tier1.generated
volumes:
- ./seed-gitea.sh:/seed-gitea.sh:ro
- ./generated:/seed
entrypoint: ["/bin/sh", "-c"]
command:
- apk add --no-cache curl >/dev/null && sh /seed-gitea.sh
restart: "no"
backend:
build:
context: ..
dockerfile: testing/backend.Dockerfile
depends_on:
gitea-seed:
condition: service_completed_successfully
env_file:
- .env.tier1
- ./generated/.env.tier1.generated
volumes:
- backend-data:/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/api/health').status==200 else 1)"]
interval: 5s
timeout: 3s
retries: 30
web:
build:
context: ..
dockerfile: testing/web.Dockerfile
depends_on:
backend:
condition: service_healthy
ports:
- "8080:80"
mailpit:
image: axllent/mailpit:latest
ports:
- "8025:8025"
- "1025:1025"
volumes:
backend-data:
Notes: the backend reads .env.tier1 then ./generated/.env.tier1.generated (seed-written), so the real bot token / OAuth client overwrite the placeholders. APP_URL=http://localhost:8080 matches the web published port and the OAuth redirect URI the seed registers. Mailpit API is on 8025, SMTP on 1025.
- Step 2: Create the generated dir placeholder
Run: mkdir -p testing/generated && touch testing/generated/.gitkeep
Create testing/generated/.gitignore:
.env.tier1.generated
- Step 3: Add Makefile targets
Create (or append to) Makefile at repo root:
tier1-up:
docker compose -f testing/docker-compose.yml up --build -d
tier1-down:
docker compose -f testing/docker-compose.yml down -v
tier1-logs:
docker compose -f testing/docker-compose.yml logs -f
fe-unit:
cd frontend && npm run test:run
e2e:
cd e2e && BASE_URL=$${BASE_URL:-http://localhost:8080} MAILSINK_URL=$${MAILSINK_URL:-http://localhost:8025} npm run e2e
(Use real tabs for Makefile recipes, not spaces.)
- Step 4: Bring the stack up
Run: make tier1-up
Expected: gitea → admin-init → seed → backend (healthy) → web come up in order. docker compose -f testing/docker-compose.yml ps shows backend healthy.
- Step 5: Verify the app is reachable through nginx
Run: curl -sf http://localhost:8080/api/health
Expected: HTTP 200 with the version JSON (proves web→backend proxy + migrations-on-startup worked).
Run: curl -sf http://localhost:8080/ | grep -i "<title"
Expected: the SPA index.html is served (title present).
- Step 6: Verify seeding produced real credentials
Run: cat testing/generated/.env.tier1.generated
Expected: non-placeholder GITEA_BOT_TOKEN=, OAUTH_CLIENT_ID=, OAUTH_CLIENT_SECRET= lines.
- Step 7: Tear down and commit
Run: make tier1-down
git add testing/docker-compose.yml testing/generated/.gitignore Makefile
git commit -m "test(tier1): docker compose stack (gitea seed + backend + web + mailpit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Task 6: Playwright harness + mail-sink helper
Files:
-
Create:
e2e/package.json -
Create:
e2e/playwright.config.js -
Create:
e2e/lib/mailpit.js -
Step 1: Create the e2e package
Create e2e/package.json:
{
"name": "rfc-e2e",
"private": true,
"type": "module",
"scripts": {
"e2e": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.49.0"
}
}
- Step 2: Install Playwright + its browser
Run: cd e2e && npm install && npx playwright install chromium
Expected: @playwright/test installed; chromium downloaded.
- Step 3: Write the Playwright config
Create e2e/playwright.config.js:
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: '.',
timeout: 30_000,
expect: { timeout: 10_000 },
use: {
baseURL: process.env.BASE_URL || 'http://localhost:8080',
trace: 'on-first-retry',
},
reporter: [['list']],
})
- Step 4: Write the Mailpit helper
Create e2e/lib/mailpit.js:
const MAILSINK = process.env.MAILSINK_URL || 'http://localhost:8025'
export async function waitForLatestOtc(toAddress, { attempts = 20, delayMs = 500 } = {}) {
for (let i = 0; i < attempts; i++) {
const res = await fetch(`${MAILSINK}/api/v1/messages`)
if (res.ok) {
const data = await res.json()
const msg = (data.messages || []).find(
(m) => (m.To || []).some((t) => t.Address === toAddress),
)
if (msg) {
const full = await fetch(`${MAILSINK}/api/v1/message/${msg.ID}`)
const body = await full.json()
const text = `${body.Text || ''} ${body.HTML || ''}`
const code = text.match(/\b(\d{6})\b/)
if (code) return code[1]
}
}
await new Promise((r) => setTimeout(r, delayMs))
}
throw new Error(`no OTC email for ${toAddress} arrived in Mailpit`)
}
export async function clearMailpit() {
await fetch(`${MAILSINK}/api/v1/messages`, { method: 'DELETE' })
}
Note: the \d{6} pattern assumes the OTC code is a 6-digit number. Confirm against app/otc.py / the OTC email template; adjust the regex if the real code shape differs (this is the one detail to verify when the spec first runs).
- Step 5: Commit
git add e2e/package.json e2e/package-lock.json e2e/playwright.config.js e2e/lib/mailpit.js
git commit -m "test(e2e): Playwright harness parameterized by BASE_URL + Mailpit mail sink
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Task 7: The smoke e2e spec (OTC login → landing renders)
Files:
-
Create:
e2e/smoke.spec.js -
Step 1: Write the smoke spec
Create e2e/smoke.spec.js:
import { test, expect } from '@playwright/test'
import { waitForLatestOtc, clearMailpit } from './lib/mailpit.js'
const EMAIL = 'e2e-user@example.test'
test('app loads and an OTC sign-in succeeds', async ({ page, request }) => {
await clearMailpit()
await page.goto('/')
await expect(page).toHaveTitle(/.+/)
const reqRes = await request.post('/auth/otc/request', {
data: { email: EMAIL },
})
expect(reqRes.ok()).toBeTruthy()
const code = await waitForLatestOtc(EMAIL)
const verifyRes = await request.post('/auth/otc/verify', {
data: { email: EMAIL, code },
})
expect(verifyRes.ok()).toBeTruthy()
})
Note: payload field names (email, code) must match app/main.py's /auth/otc/request and /auth/otc/verify request models. Read those two handlers (around app/main.py:259 and :296) and align field names before running. If OTC sign-in requires the account to be pre-provisioned or "granted", seed that state in the spec's setup (an admin call) or document the precondition; the e2e must end with an authenticated session cookie set on page's context.
- Step 2: Bring the stack up
Run: make tier1-up
Wait until curl -sf http://localhost:8080/api/health returns 200.
- Step 3: Run the smoke spec — verify it passes
Run: make e2e
Expected: 1 passed. (If it fails on field names / OTC code shape / provisioning, fix per the notes in Step 1 and e2e/lib/mailpit.js, then re-run.)
- Step 4: Tear down
Run: make tier1-down
- Step 5: Commit
git add e2e/smoke.spec.js
git commit -m "test(e2e): smoke spec — app loads and OTC sign-in succeeds via Mailpit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Task 8: Documentation — running the tiers
Files:
-
Create:
testing/README.md -
Step 1: Write the harness README
Create testing/README.md:
# Test harness (handbook §10.3 two-tier testing)
One environment-agnostic suite, two targets.
## Tier 1 — local Docker (every PR)
```sh
make tier1-up # build + start: gitea(seeded) + backend + web(nginx) + mailpit
make e2e # run Playwright against http://localhost:8080
make fe-unit # run Vitest frontend unit tests
make tier1-down # stop + wipe volumes
- App (SPA + API): http://localhost:8080
- Mailpit UI / API: http://localhost:8025
- Gitea (disposable): http://localhost:3001
The stack is hermetic and disposable — fresh SQLite + fresh seeded Gitea each
tier1-up. e2e signs in via the email OTC flow, reading the code back from
Mailpit, so no real OAuth provider is needed.
Tier 2 — PPE (deploy gate)
The SAME suite, pointed at the PPE instance (once rfc-app-ppe.<base> is stood
up via flotilla — see the engineering handbook §10.1/§10.3):
cd e2e && BASE_URL=https://rfc-app-ppe.<base> MAILSINK_URL=<ppe-mailpit-api> npm run e2e
PPE provides the real nginx/systemd/SQLite topology + its own isolated Gitea + always-pass Turnstile keys. Standing up the PPE VM is an operator task, not part of this repo.
- [ ] **Step 2: Commit**
```bash
git add testing/README.md
git commit -m "docs(testing): how to run Tier-1 local Docker and Tier-2 PPE
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Final verification
- Frontend unit:
make fe-unit→ all pass. - Stack health:
make tier1-upthencurl -sf http://localhost:8080/api/health→ 200. - E2e:
make e2e→ smoke spec passes. - Disposability:
make tier1-down && make tier1-up→ second bring-up is green from scratch (seed is idempotent / fresh-volume clean). - Teardown:
make tier1-downleaves no running containers (docker psclean).
When all five pass, M3-0 is complete and every later M3 sub-plan (M3a–M3d) can add unit/integration/functional tests under backend/tests/ and e2e specs under e2e/, runnable on localhost now and against PPE by setting BASE_URL.
Notes for the executor
- Verify-against-reality points (flagged inline, not placeholders): the Gitea
1.22API specifics inseed-gitea.sh(token scopes vocabulary, OAuth-app endpoint), the OTC request/verify field names inapp/main.py, the OTC code regex inmailpit.js, and whether OTC sign-in needs a pre-granted account. Each has a concrete first guess and a one-line "confirm against X" instruction. - Stay off
main. Branchfeat/m3-0-test-foundation. - Do not modify backend app logic in this plan — only
testing/assets,frontend/test tooling, ande2e/. The one app-adjacent file istesting/backend.Dockerfile, which only packages existing code.