From 321b0042c4694d5e25d9c1072d5633e7d3d4555f Mon Sep 17 00:00:00 2001 From: Devin Date: Sat, 18 Apr 2026 19:26:34 +0000 Subject: [PATCH] test(e2e): add make e2e-full target, full-stack Playwright spec, CI wiring, docs Closes the 'e2e tests only hit production; no local full-stack harness' finding from the review. The existing e2e suite (scripts/e2e-explorer-frontend.spec.ts) runs against explorer.d-bis.org and so can't validate a PR before it merges -- it's a production canary, not a pre-merge gate. This PR adds a parallel harness that stands the entire stack up locally (postgres + elasticsearch + redis via docker-compose, backend API, and a production build of the frontend) and runs a Playwright smoke spec against it. It is wired into Make and into a dedicated CI workflow. Changes: scripts/e2e-full.sh (new, chmod +x): - docker compose -p explorer-e2e up -d postgres elasticsearch redis. - Waits for postgres readiness (pg_isready loop). - Runs database/migrations/migrate.go so schema + seeds including the new 0016_jwt_revocations table from PR #8 are applied. - Starts 'go run ./backend/api/rest' on :8080; waits for /healthz. - Builds + starts 'npm run start' on :3000; waits for a 200. - npx playwright install --with-deps chromium; runs the full-stack spec; tears down docker and kills the backend+frontend processes via an EXIT trap. E2E_KEEP_STACK=1 bypasses teardown for interactive debugging. - Generates an ephemeral JWT_SECRET per run so stale tokens don't bleed across runs (and the fail-fast check from PR #3 passes). - Provides a dev-safe CSP_HEADER default so PR #3's hardened production CSP check doesn't reject localhost connections. scripts/e2e-full-stack.spec.ts (new): - Playwright spec that exercises public routes + a couple of backend endpoints. Takes a full-page screenshot of each route into test-results/screenshots/.png so reviewers can eyeball the render from CI artefacts. - Covers: /healthz, /, /blocks, /transactions, /addresses, /tokens, /pools, /search, /wallet, /routes, /api/v1/access/products (YAML catalogue from PR #7), /api/v1/auth/nonce (SIWE kickoff). - Sticks to Track-1 (no wallet auth needed) so it can run in CI without provisioning a test wallet. playwright.config.ts: - Broadened testMatch from a single filename to /e2e-.*\.spec\.ts/ so the new spec is picked up alongside the existing production canary spec. fullyParallel, worker, timeout, reporter, and project configuration unchanged. Makefile: - New 'e2e-full' target -> ./scripts/e2e-full.sh. Listed in 'help'. - test-e2e (production canary) left untouched. .github/workflows/e2e-full.yml (new): - Dedicated workflow, NOT on every push/PR (the full stack takes minutes and requires docker). Triggers: * workflow_dispatch (manual) * PRs labelled run-e2e-full (opt-in for changes that touch migrations, auth, or routing) * nightly schedule (04:00 UTC) - Uses Go 1.23.x and Node 20 to match PR #5's pinning. - Uploads two artefacts on every run: e2e-screenshots (test-results/screenshots/) and playwright-report. docs/TESTING.md (new): - Four-tier test pyramid: unit -> static analysis -> production canary -> full-stack Playwright. - Env var reference table for e2e-full.sh. - How to trigger the CI workflow. Verification: bash -n scripts/e2e-full.sh clean The spec imports compile cleanly against the existing @playwright /test v1.40 declared in the root package.json; no new runtime dependencies are added. Existing scripts/e2e-explorer-frontend.spec.ts still matched by the broadened testMatch regex. Advances completion criterion 7 (end-to-end coverage): 'make e2e-full boots the real stack, Playwright runs against it, CI uploads screenshots, a nightly job catches regressions that only show up when all services are live.' --- .github/workflows/e2e-full.yml | 71 +++++++++++++++++++ Makefile | 6 +- docs/TESTING.md | 86 +++++++++++++++++++++++ playwright.config.ts | 2 +- scripts/e2e-full-stack.spec.ts | 79 +++++++++++++++++++++ scripts/e2e-full.sh | 123 +++++++++++++++++++++++++++++++++ 6 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/e2e-full.yml create mode 100644 docs/TESTING.md create mode 100644 scripts/e2e-full-stack.spec.ts create mode 100755 scripts/e2e-full.sh diff --git a/.github/workflows/e2e-full.yml b/.github/workflows/e2e-full.yml new file mode 100644 index 0000000..aa551a6 --- /dev/null +++ b/.github/workflows/e2e-full.yml @@ -0,0 +1,71 @@ +name: e2e-full + +# Boots the full explorer stack (docker-compose deps + backend + frontend) +# and runs the Playwright full-stack smoke spec against it. Not on every +# PR (too expensive) — runs on: +# +# * workflow_dispatch (manual) +# * pull_request when the 'run-e2e-full' label is applied +# * nightly at 04:00 UTC +# +# Screenshots from every route are uploaded as a build artefact so +# reviewers can eyeball the render without having to boot the stack. + +on: + workflow_dispatch: + pull_request: + types: [labeled, opened, synchronize, reopened] + schedule: + - cron: '0 4 * * *' + +jobs: + e2e-full: + if: > + github.event_name == 'workflow_dispatch' || + github.event_name == 'schedule' || + (github.event_name == 'pull_request' && + contains(github.event.pull_request.labels.*.name, 'run-e2e-full')) + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-go@v5 + with: + go-version: '1.23.x' + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install root Playwright dependency + run: npm ci --no-audit --no-fund --prefix . + + - name: Run full-stack e2e + env: + JWT_SECRET: ${{ secrets.JWT_SECRET || 'ci-ephemeral-jwt-secret-not-for-prod' }} + CSP_HEADER: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' http://localhost:8080 ws://localhost:8080" + run: make e2e-full + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-screenshots + path: test-results/screenshots/ + if-no-files-found: warn + + - name: Upload playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + if-no-files-found: warn diff --git a/Makefile b/Makefile index 00b7406..c5a2f19 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install dev build test test-e2e clean migrate +.PHONY: help install dev build test test-e2e e2e-full clean migrate help: @echo "Available targets:" @@ -7,6 +7,7 @@ help: @echo " build - Build all services" @echo " test - Run backend + frontend tests (go test, lint, type-check)" @echo " test-e2e - Run Playwright E2E tests (default: explorer.d-bis.org)" + @echo " e2e-full - Boot full stack locally (docker compose + backend + frontend) and run Playwright" @echo " clean - Clean build artifacts" @echo " migrate - Run database migrations" @@ -35,6 +36,9 @@ test: test-e2e: npx playwright test +e2e-full: + ./scripts/e2e-full.sh + clean: cd backend && go clean ./... cd frontend && rm -rf .next node_modules diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..e419f38 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,86 @@ +# Testing + +The explorer has four test tiers. Run them in order of fidelity when +debugging a regression. + +## 1. Unit / package tests + +Fast. Run on every PR. + +```bash +# Backend +cd backend && go test ./... + +# Frontend +cd frontend && npm test # lint + type-check +cd frontend && npm run test:unit # vitest +``` + +## 2. Static analysis + +Blocking on CI since PR #5 (`chore(ci): align Go to 1.23.x, add +staticcheck/govulncheck/gitleaks gates`). + +```bash +cd backend && staticcheck ./... +cd backend && govulncheck ./... +git diff master... | gitleaks protect --staged --config ../.gitleaks.toml +``` + +## 3. Production-targeting Playwright + +Runs against `https://explorer.d-bis.org` (or the URL in `EXPLORER_URL`) +and only checks public routes. Useful as a production canary; wired +into the `test-e2e` Make target. + +```bash +EXPLORER_URL=https://explorer.d-bis.org make test-e2e +``` + +## 4. Full-stack Playwright (`make e2e-full`) + +Spins up the entire stack locally — `postgres`, `elasticsearch`, +`redis` via docker-compose, plus a local build of `backend/api/rest` +and `frontend` — then runs the full-stack Playwright spec against it. + +```bash +make e2e-full +``` + +What it does, in order: + +1. `docker compose -p explorer-e2e up -d postgres elasticsearch redis` +2. Wait for Postgres readiness. +3. Run `go run database/migrations/migrate.go` to apply schema + + seeds (including `0016_jwt_revocations` from PR #8). +4. `go run ./backend/api/rest` on port `8080`. +5. `npm ci && npm run build && npm run start` on port `3000`. +6. `npx playwright test scripts/e2e-full-stack.spec.ts`. +7. Tear everything down (unless `E2E_KEEP_STACK=1`). + +Screenshots of every route are written to +`test-results/screenshots/.png`. + +### Env vars + +| Var | Default | Purpose | +|-----|---------|---------| +| `EXPLORER_URL` | `http://localhost:3000` | Frontend base URL for the spec | +| `EXPLORER_API_URL` | `http://localhost:8080` | Backend base URL | +| `JWT_SECRET` | generated per-run | Required by backend fail-fast check (PR #3) | +| `CSP_HEADER` | dev-safe default | Same | +| `E2E_KEEP_STACK` | `0` | If `1`, leave the stack up after the run | +| `E2E_SKIP_DOCKER` | `0` | If `1`, assume docker services already running | +| `E2E_SCREENSHOT_DIR` | `test-results/screenshots` | Where to write PNGs | + +### CI integration + +`.github/workflows/e2e-full.yml` runs `make e2e-full` on: + +* **Manual** trigger (`workflow_dispatch`). +* **PRs labelled `run-e2e-full`** — apply the label when a change + warrants full-stack validation (migrations, auth, routing changes). +* **Nightly** at 04:00 UTC. + +Screenshots and the Playwright HTML report are uploaded as build +artefacts. diff --git a/playwright.config.ts b/playwright.config.ts index 0e18ab3..54d27a9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -7,7 +7,7 @@ if (process.env.NO_COLOR !== undefined) { export default defineConfig({ testDir: './scripts', - testMatch: 'e2e-explorer-frontend.spec.ts', + testMatch: /e2e-.*\.spec\.ts$/, fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, diff --git a/scripts/e2e-full-stack.spec.ts b/scripts/e2e-full-stack.spec.ts new file mode 100644 index 0000000..67f0093 --- /dev/null +++ b/scripts/e2e-full-stack.spec.ts @@ -0,0 +1,79 @@ +import { expect, test, type Page } from '@playwright/test' +import { mkdirSync } from 'node:fs' +import path from 'node:path' + +// e2e-full-stack.spec.ts +// +// Playwright spec that exercises the golden-path behaviours of the +// explorer against a *locally booted* backend + frontend, rather than +// against the production deploy that `e2e-explorer-frontend.spec.ts` +// targets. `make e2e-full` stands up the stack, points this spec at +// it via EXPLORER_URL / EXPLORER_API_URL, and tears it down afterwards. +// +// The spec intentionally sticks to Track-1 (public, no auth) routes so +// it can run without provisioning wallet credentials in CI. Track 2-4 +// behaviours are covered by the Go and unit-test layers. + +const EXPLORER_URL = process.env.EXPLORER_URL || 'http://localhost:3000' +const EXPLORER_API_URL = process.env.EXPLORER_API_URL || 'http://localhost:8080' +const SCREENSHOT_DIR = process.env.E2E_SCREENSHOT_DIR || 'test-results/screenshots' + +mkdirSync(SCREENSHOT_DIR, { recursive: true }) + +async function snapshot(page: Page, name: string) { + const file = path.join(SCREENSHOT_DIR, `${name}.png`) + await page.screenshot({ path: file, fullPage: true }) +} + +async function expectHeading(page: Page, name: RegExp) { + await expect(page.getByRole('heading', { name })).toBeVisible({ timeout: 15000 }) +} + +test.describe('Explorer full-stack smoke', () => { + test('backend /healthz responds 200', async ({ request }) => { + const response = await request.get(`${EXPLORER_API_URL}/healthz`) + expect(response.status()).toBeLessThan(500) + }) + + for (const route of [ + { path: '/', heading: /SolaceScan/i, name: 'home' }, + { path: '/blocks', heading: /^Blocks$/i, name: 'blocks' }, + { path: '/transactions', heading: /^Transactions$/i, name: 'transactions' }, + { path: '/addresses', heading: /^Addresses$/i, name: 'addresses' }, + { path: '/tokens', heading: /^Tokens$/i, name: 'tokens' }, + { path: '/pools', heading: /^Pools$/i, name: 'pools' }, + { path: '/search', heading: /^Search$/i, name: 'search' }, + { path: '/wallet', heading: /Wallet & MetaMask/i, name: 'wallet' }, + { path: '/routes', heading: /Route/i, name: 'routes' }, + ]) { + test(`frontend route ${route.path} renders`, async ({ page }) => { + await page.goto(`${EXPLORER_URL}${route.path}`, { + waitUntil: 'domcontentloaded', + timeout: 30000, + }) + await expectHeading(page, route.heading) + await snapshot(page, route.name) + }) + } + + test('access products endpoint is reachable', async ({ request }) => { + // Covers the YAML-backed catalogue wired up in PR #7. The endpoint + // is public (lists available RPC products) so no auth is needed. + const response = await request.get(`${EXPLORER_API_URL}/api/v1/access/products`) + expect(response.status()).toBe(200) + const body = await response.json() + expect(Array.isArray(body.products)).toBe(true) + expect(body.products.length).toBeGreaterThanOrEqual(3) + }) + + test('auth nonce endpoint issues a nonce', async ({ request }) => { + // Covers wallet auth kickoff: /api/v1/auth/nonce must issue a + // fresh nonce even without credentials. This is Track-1-safe. + const response = await request.post(`${EXPLORER_API_URL}/api/v1/auth/nonce`, { + data: { address: '0x4A666F96fC8764181194447A7dFdb7d471b301C8' }, + }) + expect(response.status()).toBe(200) + const body = await response.json() + expect(typeof body.nonce === 'string' && body.nonce.length > 0).toBe(true) + }) +}) diff --git a/scripts/e2e-full.sh b/scripts/e2e-full.sh new file mode 100755 index 0000000..32a2bb1 --- /dev/null +++ b/scripts/e2e-full.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# scripts/e2e-full.sh +# +# Boots the full explorer stack (postgres, elasticsearch, redis, backend +# API, frontend), waits for readiness, runs the Playwright full-stack +# smoke spec against it, and tears everything down. Used by the +# `make e2e-full` target and by the e2e-full CI workflow. +# +# Env vars: +# E2E_KEEP_STACK=1 # don't tear down on exit (for debugging) +# E2E_SKIP_DOCKER=1 # assume backend + deps already running +# EXPLORER_URL # defaults to http://localhost:3000 +# EXPLORER_API_URL # defaults to http://localhost:8080 +# E2E_SCREENSHOT_DIR # defaults to test-results/screenshots +# JWT_SECRET # required; generated ephemerally if unset +# CSP_HEADER # required; a dev-safe default is injected + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +COMPOSE="deployment/docker-compose.yml" +COMPOSE_PROJECT="${COMPOSE_PROJECT:-explorer-e2e}" + +export EXPLORER_URL="${EXPLORER_URL:-http://localhost:3000}" +export EXPLORER_API_URL="${EXPLORER_API_URL:-http://localhost:8080}" +export E2E_SCREENSHOT_DIR="${E2E_SCREENSHOT_DIR:-$ROOT/test-results/screenshots}" +mkdir -p "$E2E_SCREENSHOT_DIR" + +# Generate ephemeral JWT secret if the caller didn't set one. Real +# deployments use fail-fast validation (see PR #3); for a local run we +# want a fresh value each invocation so stale tokens don't bleed across +# runs. +export JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}" +export CSP_HEADER="${CSP_HEADER:-default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' http://localhost:8080 ws://localhost:8080}" + +log() { printf '[e2e-full] %s\n' "$*"; } + +teardown() { + local ec=$? + if [[ "${E2E_KEEP_STACK:-0}" == "1" ]]; then + log "E2E_KEEP_STACK=1; leaving stack running." + return $ec + fi + log "tearing down stack" + if [[ "${E2E_SKIP_DOCKER:-0}" != "1" ]]; then + docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE" down -v --remove-orphans >/dev/null 2>&1 || true + fi + if [[ -n "${BACKEND_PID:-}" ]]; then kill "$BACKEND_PID" 2>/dev/null || true; fi + if [[ -n "${FRONTEND_PID:-}" ]]; then kill "$FRONTEND_PID" 2>/dev/null || true; fi + return $ec +} +trap teardown EXIT + +wait_for() { + local url="$1" label="$2" retries="${3:-60}" + log "waiting for $label at $url" + for ((i=0; i/dev/null 2>&1; then + log " $label ready" + return 0 + fi + sleep 2 + done + log " $label never became ready" + return 1 +} + +if [[ "${E2E_SKIP_DOCKER:-0}" != "1" ]]; then + log "starting postgres, elasticsearch, redis via docker compose" + docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE" up -d postgres elasticsearch redis + + log "waiting for postgres" + for ((i=0; i<60; i++)); do + if docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE" exec -T postgres pg_isready -U explorer >/dev/null 2>&1; then + break + fi + sleep 2 + done +fi + +export DB_HOST="${DB_HOST:-localhost}" +export DB_PORT="${DB_PORT:-5432}" +export DB_USER="${DB_USER:-explorer}" +export DB_PASSWORD="${DB_PASSWORD:-changeme}" +export DB_NAME="${DB_NAME:-explorer}" +export REDIS_HOST="${REDIS_HOST:-localhost}" +export REDIS_PORT="${REDIS_PORT:-6379}" +export ELASTICSEARCH_URL="${ELASTICSEARCH_URL:-http://localhost:9200}" + +log "running migrations" +(cd backend && go run database/migrations/migrate.go) || { + log "migrations failed; continuing so tests can report the real backend state" +} + +log "starting backend API on :8080" +(cd backend/api/rest && go run . >/tmp/e2e-backend.log 2>&1) & +BACKEND_PID=$! + +wait_for "$EXPLORER_API_URL/healthz" backend 120 || { + log "backend log tail:"; tail -n 60 /tmp/e2e-backend.log || true + exit 1 +} + +log "building frontend" +(cd frontend && npm ci --no-audit --no-fund --loglevel=error && npm run build) + +log "starting frontend on :3000" +(cd frontend && PORT=3000 HOST=127.0.0.1 NEXT_PUBLIC_API_URL="$EXPLORER_API_URL" npm run start >/tmp/e2e-frontend.log 2>&1) & +FRONTEND_PID=$! + +wait_for "$EXPLORER_URL" frontend 60 || { + log "frontend log tail:"; tail -n 60 /tmp/e2e-frontend.log || true + exit 1 +} + +log "running Playwright full-stack smoke" +npx playwright install --with-deps chromium >/dev/null +EXPLORER_URL="$EXPLORER_URL" EXPLORER_API_URL="$EXPLORER_API_URL" \ + npx playwright test scripts/e2e-full-stack.spec.ts --reporter=list + +log "done; screenshots in $E2E_SCREENSHOT_DIR"