docs(plan): M3-0 test & local-env foundation implementation plan (§22 / handbook §10.3)

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>
This commit is contained in:
Ben Stull
2026-06-03 22:07:50 -07:00
parent 6f356d3598
commit 2cf7db4bff
@@ -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 <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`:
```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 <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`:
```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 <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`:
```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" <<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**
```bash
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 `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 "<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`
```bash
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`:
```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 <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`:
```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 <noreply@anthropic.com>"
```
---
## 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.<base>` is stood
up via flotilla — see the engineering handbook §10.1/§10.3):
```sh
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-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 (M3aM3d) 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.
```