From b9bf5d6ecaad06a0726d73bd43727930c63e3919 Mon Sep 17 00:00:00 2001 From: Chantal Marissa Date: Thu, 8 Oct 2026 14:54:29 +0300 Subject: [PATCH 1/2] feat(testnexus): add isolated full-stack testing Add a matching Python 3.14 and Playwright 1.63 runner, locked dependency setup, application startup, and backend/system/browser adapters with per-run PostgreSQL and Mailpit. Validation: frontend build, 58 backend tests, 1 live system workflow and 62 Chromium tests passed in disposable Docker containers; backend line coverage 91.23%. --- .gitignore | 11 +++ README.md | 3 + scripts/testnexus/Dockerfile | 25 ++++++ scripts/testnexus/README.md | 150 +++++++++++++++++++++++++++++++ scripts/testnexus/install.sh | 12 +++ scripts/testnexus/run.py | 74 +++++++++++++++ scripts/testnexus/test_system.py | 36 ++++++++ 7 files changed, 311 insertions(+) create mode 100644 scripts/testnexus/Dockerfile create mode 100644 scripts/testnexus/README.md create mode 100644 scripts/testnexus/install.sh create mode 100644 scripts/testnexus/run.py create mode 100644 scripts/testnexus/test_system.py diff --git a/.gitignore b/.gitignore index 3c697eb7da..b6f7d2b18b 100644 --- a/.gitignore +++ b/.gitignore @@ -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* diff --git a/README.md b/README.md index ff783583f7..2bddd1f072 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/scripts/testnexus/Dockerfile b/scripts/testnexus/Dockerfile new file mode 100644 index 0000000000..d42e597a55 --- /dev/null +++ b/scripts/testnexus/Dockerfile @@ -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 diff --git a/scripts/testnexus/README.md b/scripts/testnexus/README.md new file mode 100644 index 0000000000..f131884f05 --- /dev/null +++ b/scripts/testnexus/README.md @@ -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. diff --git a/scripts/testnexus/install.sh b/scripts/testnexus/install.sh new file mode 100644 index 0000000000..d014505525 --- /dev/null +++ b/scripts/testnexus/install.sh @@ -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 diff --git a/scripts/testnexus/run.py b/scripts/testnexus/run.py new file mode 100644 index 0000000000..66e362e12f --- /dev/null +++ b/scripts/testnexus/run.py @@ -0,0 +1,74 @@ +"""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 +from pathlib import Path +import subprocess +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()) diff --git a/scripts/testnexus/test_system.py b/scripts/testnexus/test_system.py new file mode 100644 index 0000000000..58868f9f71 --- /dev/null +++ b/scripts/testnexus/test_system.py @@ -0,0 +1,36 @@ +"""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 ' Date: Thu, 8 Oct 2026 11:58:35 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format=20and=20update?= =?UTF-8?q?=20with=20pre-commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/testnexus/run.py | 125 +++++++++++++++++++++++-------- scripts/testnexus/test_system.py | 47 +++++++----- 2 files changed, 121 insertions(+), 51 deletions(-) diff --git a/scripts/testnexus/run.py b/scripts/testnexus/run.py index 66e362e12f..73403425c1 100644 --- a/scripts/testnexus/run.py +++ b/scripts/testnexus/run.py @@ -4,35 +4,38 @@ 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 -from pathlib import Path import subprocess +from pathlib import Path from urllib.parse import quote ROOT = Path(__file__).resolve().parents[2] -PYTHON = '/workspace/venv/bin/python' +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'] + 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 @@ -44,31 +47,87 @@ def command(args, directory, env): 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']) + 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 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) + 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) + 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) + 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 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__': +if __name__ == "__main__": raise SystemExit(main()) diff --git a/scripts/testnexus/test_system.py b/scripts/testnexus/test_system.py index 58868f9f71..4b78a37947 100644 --- a/scripts/testnexus/test_system.py +++ b/scripts/testnexus/test_system.py @@ -1,4 +1,5 @@ """Verify the live frontend/API/database path against a disposable deployment.""" + import os import uuid @@ -7,30 +8,40 @@ 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/') + 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('/') + frontend = client.get("/") assert frontend.status_code == 200 - assert '