All checks were successful
e2e-full / e2e-full (pull_request) Has been skipped
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/<route>.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.'
80 lines
3.4 KiB
TypeScript
80 lines
3.4 KiB
TypeScript
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)
|
|
})
|
|
})
|