From 2cf7db4bff46bfc2b5c264b93454ccf4dea120d7 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 3 Jun 2026 22:07:50 -0700 Subject: [PATCH] =?UTF-8?q?docs(plan):=20M3-0=20test=20&=20local-env=20fou?= =?UTF-8?q?ndation=20implementation=20plan=20(=C2=A722=20/=20handbook=20?= =?UTF-8?q?=C2=A710.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../plans/2026-06-03-m3-0-test-foundation.md | 823 ++++++++++++++++++ 1 file changed, 823 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-03-m3-0-test-foundation.md diff --git a/docs/superpowers/plans/2026-06-03-m3-0-test-foundation.md b/docs/superpowers/plans/2026-06-03-m3-0-test-foundation.md new file mode 100644 index 0000000..8d5751a --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-m3-0-test-foundation.md @@ -0,0 +1,823 @@ +# 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-shot `gitea-seed` service. +- `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` `/auth` proxy to backend). Adapted from `deploy/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 with `rfcs/`), 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:run` scripts (modify). +- `e2e/playwright.config.js` — Playwright config; `baseURL` from `BASE_URL`, mail sink from `MAILSINK_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 + `e2e` script (kept separate from the app frontend deps). +- `Makefile` (repo root) — `tier1-up`, `tier1-down`, `e2e`, `fe-unit` convenience 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`: + +```json +"vitest": "^3.0.0", +"jsdom": "^25.0.0", +"@testing-library/react": "^16.1.0", +"@testing-library/jest-dom": "^6.6.0" +``` + +Add to `scripts`: + +```json +"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`: + +```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`: + +```js +import '@testing-library/jest-dom' +``` + +- [ ] **Step 5: Write the failing unit test** + +Create `frontend/src/lib/brand.test.js`: + +```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`: + +```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** + +```bash +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 " +``` + +--- + +## Task 2: Backend Docker image + +**Files:** +- Create: `testing/backend.Dockerfile` +- Create: `testing/.env.tier1` + +- [ ] **Step 1: Write the backend Dockerfile** + +Create `testing/backend.Dockerfile`: + +```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** + +```bash +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 " +``` + +--- + +## 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`: + +```nginx +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`: + +```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** + +```bash +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 " +``` + +--- + +## 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`: + +```bash +#!/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" <" +``` + +> 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 `scopes` vocabulary, or the OAuth-app endpoint path), fix it against the pinned image `gitea/gitea:1.22` and 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`: + +```yaml +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: + +```makefile +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 "" +``` + +--- + +## 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`: + +```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`: + +```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`: + +```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** + +```bash +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 " +``` + +--- + +## 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`: + +```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** + +```bash +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 " +``` + +--- + +## Task 8: Documentation — running the tiers + +**Files:** +- Create: `testing/README.md` + +- [ ] **Step 1: Write the harness README** + +Create `testing/README.md`: + +```markdown +# 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.` is stood +up via flotilla — see the engineering handbook §10.1/§10.3): + +```sh +cd e2e && BASE_URL=https://rfc-app-ppe. MAILSINK_URL= 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 " +``` + +--- + +## Final verification + +- [ ] **Frontend unit:** `make fe-unit` → all pass. +- [ ] **Stack health:** `make tier1-up` then `curl -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-down` leaves no running containers (`docker ps` clean). + +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.22` API specifics in `seed-gitea.sh` (token scopes vocabulary, OAuth-app endpoint), the OTC request/verify field names in `app/main.py`, the OTC code regex in `mailpit.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`.** Branch `feat/m3-0-test-foundation`. +- **Do not** modify backend app logic in this plan — only `testing/` assets, `frontend/` test tooling, and `e2e/`. The one app-adjacent file is `testing/backend.Dockerfile`, which only packages existing code. +```