Files
rfc-app/docs/superpowers/plans/2026-06-03-m3-0-test-foundation.md
T
Ben Stull 1b011ed483 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>
2026-06-03 22:22:38 -07:00

27 KiB
Raw Blame History

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:

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

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

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-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.