Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,14 @@ backend/app/frontend/
/playwright-report/
/blob-report/
/playwright/.cache/

# Disposable TestNexus evidence and local credentials.
.env
.venv/
backend/test-results/
frontend/test-results/
frontend/playwright-report/
frontend/playwright/.auth/
frontend/blob-report/
test-results/
.coverage*
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Full Stack FastAPI Template

For this fork's disposable backend, system and browser testing workflow, see
the [TestNexus setup guide](scripts/testnexus/README.md).

[![Test Docker Compose](../../actions/workflows/test-docker-compose.yml/badge.svg)](../../actions/workflows/test-docker-compose.yml)
[![Test Backend](../../actions/workflows/test-backend.yml/badge.svg)](../../actions/workflows/test-backend.yml)

Expand Down
25 changes: 25 additions & 0 deletions scripts/testnexus/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM node:22-bookworm-slim AS node
FROM oven/bun:1 AS bun
FROM ghcr.io/astral-sh/uv:0.9.26 AS uv
FROM axllent/mailpit:v1.30.2 AS mailpit
FROM python:3.14-bookworm

COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
COPY --from=uv /uv /usr/local/bin/uv
COPY --from=mailpit /mailpit /usr/local/bin/mailpit
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
&& ln -s /usr/local/bin/bun /usr/local/bin/bunx \
&& apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates less \
&& rm -rf /var/lib/apt/lists/*
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
ENV UV_PYTHON_DOWNLOADS=never
RUN npm install --prefix /opt/testnexus --no-audit --no-fund @playwright/test@1.63.0 \
&& /opt/testnexus/node_modules/.bin/playwright install --with-deps chromium \
&& chmod -R a+rX /ms-playwright \
&& rm -rf /var/lib/apt/lists/* /root/.npm
USER 65532:65532
WORKDIR /workspace
150 changes: 150 additions & 0 deletions scripts/testnexus/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# TestNexus full-stack setup

This branch runs the upstream backend suite, a live HTTP item lifecycle, and the
complete Chromium suite (including email password recovery). FastAPI serves the
built React frontend. PostgreSQL and Mailpit are disposable supporting services.
Only use isolated test data: backend fixtures delete application users and items.

## Operator preparation

Build from this repository root, on the Docker host used by the TestNexus worker:

```powershell
docker build -f scripts/testnexus/Dockerfile -t testnexus-fullstack:local .
docker image inspect testnexus-fullstack:local postgres:17 testnexus-runner:local
```

If PostgreSQL is missing, provision it with `docker pull postgres:17`. The normal
TestNexus bootstrap image `testnexus-runner:local` must also already be built.
Append `testnexus-fullstack:local` to both `RUNNER_IMAGES` and `SERVICE_IMAGES`
in the TestNexus backend environment. Preserve existing allowed images. Reload
the API and recreate the worker/dispatcher after active runs finish. No registry
push is needed when Docker Desktop and the worker use the same Docker daemon.

## System and environment

| Field | Value |
|---|---|
| System name | Full-stack FastAPI |
| Repository | https://github.com/Chantal-Marissa-Pande/full-stack-fastapi-template.git |
| Default branch | testnexus |
| Technology stack | Python 3.14, FastAPI, React, TypeScript, Bun, PostgreSQL, Playwright |
| Owner/team | Your QA team |
| Description | Disposable full-stack backend, system and browser verification |
| Environment name | Full-stack Development |
| Environment type | development |
| Target URL | Leave blank; the worker creates a private per-run URL |
| Environment description | Isolated application, database and test email service |

## Pipeline

| Field | Value |
|---|---|
| Runner preset | Browser, then override Runner image with the value below |
| Runner image | testnexus-fullstack:local |
| Pipeline name | Full-stack acceptance |
| Target environment | Full-stack Development |
| Repository branch | testnexus |
| Dependency installation | sh scripts/testnexus/install.sh |
| Minimum pass rate | 100 |
| Minimum line coverage | Leave blank initially |
| Description | Backend, live API/database workflow and Chromium acceptance tests |

## Deployment testing

| Field | Value |
|---|---|
| Application deployment | Isolated/per-run application |
| Startup command | python scripts/testnexus/run.py start |
| Application port | 8000 |
| Memory | 4096 MiB |
| Workspace | 6144 MiB |
| CPU cores | 2 |
| Process limit | 512 |
| Browser shared memory | 512 MiB |
| Temporary storage | 512 MiB |
| Run timeout | 1800 seconds |
| Readiness timeout | 120 seconds |
| Setup timeout | 120 seconds |
| After a stage fails | Continue, to collect evidence from all suites |
| Readiness path | /api/v1/utils/health-check/ |
| Test data setup | Leave blank; startup migrates/seeds and later stages reseed |
| Test data teardown | Leave blank; per-run services and database are removed |
| Diagnostic files | Leave blank initially; never collect browser authentication state |

Application variables (non-secret):

```text
PROJECT_NAME=TestNexus full-stack fixture
FIRST_SUPERUSER=admin@example.com
EMAILS_FROM_EMAIL=noreply@example.com
APP_DATABASE_USER=testnexus_fixture
APP_DATABASE_NAME=testnexus_fixture
CI=true
```

Application secret references (names only):

```text
TEST_DATABASE_PASSWORD=fullstack-db-password
FIRST_SUPERUSER_PASSWORD=fullstack-admin-password
SECRET_KEY=fullstack-signing-key
```

After registering the environment, an operator must provision those three values
under its organization/environment IDs in the worker secret provider. Generate
random test-only values. The database service must reference the **same**
`fullstack-db-password`. Never put actual values in this public repository or in
the pipeline form. The worker assembles the database URL in memory.

Supporting services:

| Field | Database | Email |
|---|---|---|
| Name | database | mail |
| Type | PostgreSQL | Process |
| Image | postgres:17 | testnexus-fullstack:local |
| Startup command | Leave blank (preset) | mailpit --listen 0.0.0.0:8025 --smtp 0.0.0.0:1025 |
| Port | 5432 | 8025 |
| Memory | 512 MiB | 256 MiB |
| Storage | 256 MiB | 256 MiB |
| Variables | POSTGRES_USER=testnexus_fixture; POSTGRES_DB=testnexus_fixture | Leave blank |
| Secret reference | POSTGRES_PASSWORD=fullstack-db-password | Leave blank |

Enter the database variables on separate lines. The email service listens on
SMTP 1025 as well as HTTP 8025; both are internal to the private run network.
No host ports, Docker socket, external database or public email service is needed.

## Ordered test stages

All stages use working directory `.` and are required. Commands generate their
JUnit report relative to that root. Set the backend Cobertura path to
`backend/test-results/coverage.xml`; leave the other coverage paths blank.

| Stage | Suite type | Framework | Command | Timeout | JUnit report |
|---|---|---|---|---|---|
| Backend | integration | pytest | python scripts/testnexus/run.py backend | 600 | backend/test-results/backend.xml |
| Live system workflow | system | pytest | python scripts/testnexus/run.py system | 180 | test-results/system.xml |
| Chromium acceptance | e2e | Playwright | python scripts/testnexus/run.py browser | 1200 | frontend/test-results/browser.xml |

The backend stage includes upstream unit/API/database tests. The system stage
uses real HTTP requests to check frontend availability, login, item creation,
database retrieval and deletion. Chromium covers the upstream browser journeys,
including password reset through Mailpit. Backend teardown deletes the seeded
account, so subsequent stages restore it before authentication.

The overall 1800-second run timeout also includes checkout, installation and
startup; stage timeouts are ceilings, not reserved time. Slow first downloads may
require retrying after caches/image provisioning finish. A gate passes only when
required reports are valid and every suite meets its configured threshold.

## Verified baseline

Verified on Docker Desktop on 2026-10-08 using non-root containers, read-only root
filesystems, a shared bounded workspace, and an internal network. Locked
dependency installation and the frontend build passed. Results were 58 backend
tests, 1 live system workflow and 62 Chromium tests passed; backend line coverage
was 91.23%. JUnit reports and the backend Cobertura report were generated. The
disposable verification database, services, application, network and workspace
were removed afterwards. These results are a local baseline; register the new
TestNexus environment and provision its scoped secrets before submitting a run.
12 changes: 12 additions & 0 deletions scripts/testnexus/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/bin/sh
# Install locked dependencies while outbound access is available, before services start.
set -eu
export UV_PROJECT_ENVIRONMENT=/workspace/venv
export UV_CACHE_DIR=/workspace/uv-cache
export BUN_INSTALL_CACHE_DIR=/workspace/bun-cache
uv sync --frozen --all-packages --all-groups
bun install --frozen-lockfile
cd frontend
# Use same-origin API requests; the private application URL is assigned later.
export VITE_API_URL=
bun run build
133 changes: 133 additions & 0 deletions scripts/testnexus/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Adapt the upstream application/tests to disposable TestNexus services.

Credentials are resolved by the worker and assembled only in process memory.
No .env file or secret-bearing command is written into the shared checkout.
Use only isolated test databases: upstream pytest fixtures remove application data.
"""

import argparse
import os
import subprocess
from pathlib import Path
from urllib.parse import quote

ROOT = Path(__file__).resolve().parents[2]
PYTHON = "/workspace/venv/bin/python"


def environment():
"""Map per-run service addresses to upstream settings without exposing passwords."""
env = os.environ.copy()
host = env["TESTNEXUS_SERVICE_DATABASE_HOST"]
port = env["TESTNEXUS_SERVICE_DATABASE_PORT"]
user = quote(env.get("APP_DATABASE_USER", "testnexus_fixture"), safe="")
password = quote(env["TEST_DATABASE_PASSWORD"], safe="")
database = quote(env.get("APP_DATABASE_NAME", "testnexus_fixture"), safe="")
env["DATABASE_URL"] = (
f"postgresql+psycopg://{user}:{password}@{host}:{port}/{database}"
)
env["FASTAPI_ENV"] = "development"
base_url = env["TESTNEXUS_BASE_URL"]
env["FRONTEND_HOST"] = base_url
env["PLAYWRIGHT_BASE_URL"] = base_url
env["VITE_API_URL"] = base_url
env["SMTP_HOST"] = env["TESTNEXUS_SERVICE_MAIL_HOST"]
env["SMTP_PORT"] = "1025"
env["SMTP_TLS"] = "false"
env["SMTP_SSL"] = "false"
env["MAILPIT_HOST"] = env["TESTNEXUS_SERVICE_MAIL_URL"]
return env


def command(args, directory, env):
"""Run a foreground command and preserve its exit status for TestNexus."""
return subprocess.run(args, cwd=ROOT / directory, env=env, check=False).returncode


def main():
"""Start the app or execute its backend/browser tests with fresh XML evidence."""
parser = argparse.ArgumentParser()
parser.add_argument("mode", choices=["start", "backend", "system", "browser"])
mode = parser.parse_args().mode
env = environment()
if mode == "start":
result = command(
["/workspace/venv/bin/alembic", "upgrade", "head"], "backend", env
)
if result:
return result
result = command([PYTHON, "-m", "app.initial_data"], "backend", env)
if result:
return result
os.chdir(ROOT / "backend")
os.execve(
PYTHON,
[
PYTHON,
"-m",
"uvicorn",
"app.main:app",
"--host",
"0.0.0.0",
"--port",
"8000",
],
env,
)
if mode == "backend":
result = command(
[
PYTHON,
"-m",
"coverage",
"run",
"-m",
"pytest",
"tests",
"--basetemp=/workspace/pytest-tmp",
"--junitxml=test-results/backend.xml",
],
"backend",
env,
)
coverage_result = command(
[PYTHON, "-m", "coverage", "xml", "-o", "test-results/coverage.xml"],
"backend",
env,
)
return result or coverage_result
# Backend fixture teardown deletes users. Restore the browser login account.
result = command([PYTHON, "-m", "app.initial_data"], "backend", env)
if result:
return result
if mode == "system":
return command(
[
PYTHON,
"-m",
"pytest",
"scripts/testnexus/test_system.py",
"--basetemp=/workspace/system-tmp",
"--junitxml=test-results/system.xml",
],
".",
env,
)
env["PLAYWRIGHT_JUNIT_OUTPUT_NAME"] = "test-results/browser.xml"
return command(
[
"bunx",
"--no-install",
"playwright",
"test",
"--project=chromium",
"--workers=1",
"--reporter=line,junit",
],
"frontend",
env,
)


if __name__ == "__main__":
raise SystemExit(main())
47 changes: 47 additions & 0 deletions scripts/testnexus/test_system.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Verify the live frontend/API/database path against a disposable deployment."""

import os
import uuid

import httpx


def test_live_application_item_lifecycle():
"""Authenticate over HTTP, persist an item, read it, and remove our own data."""
with httpx.Client(base_url=os.environ["TESTNEXUS_BASE_URL"], timeout=20) as client:
response = client.get("/api/v1/utils/health-check/")
assert response.status_code == 200
assert response.json() is True
frontend = client.get("/")
assert frontend.status_code == 200
assert "<html" in frontend.text.lower()
login = client.post(
"/api/v1/login/access-token",
data={
"username": os.environ["FIRST_SUPERUSER"],
"password": os.environ["FIRST_SUPERUSER_PASSWORD"],
},
)
assert login.status_code == 200
headers = {"Authorization": "Bearer " + login.json()["access_token"]}
title = "TestNexus " + uuid.uuid4().hex
created = client.post(
"/api/v1/items/",
headers=headers,
json={
"title": title,
"description": "Disposable system verification",
},
)
assert created.status_code == 200
item_id = created.json()["id"]
try:
read = client.get("/api/v1/items/" + item_id, headers=headers)
assert read.status_code == 200
assert read.json()["title"] == title
finally:
deleted = client.delete("/api/v1/items/" + item_id, headers=headers)
assert deleted.status_code == 200
assert (
client.get("/api/v1/items/" + item_id, headers=headers).status_code == 404
)
Loading