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