From 1d2ecb79d3ddf443f59bd38fd1aa64180a717045 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:45:48 +0000 Subject: [PATCH] Run the frontend container smoke in CI Add a frontend-container job that builds the nginx frontend and backend images and runs make test-frontend-container for every scope except docs-only. The compose project is unique per run, attempt and job, the frontend port is ephemeral, and an always() step tears down only that project. Unit tests pin the path trigger, isolation, cleanup and the make target's exit status and teardown. --- .github/workflows/ci.yml | 30 +++++ tests/unit/test_ci_frontend_container.py | 149 +++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 tests/unit/test_ci_frontend_container.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66d23921..4df243f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,3 +119,33 @@ jobs: - name: Skipped (out of scope) if: needs.detect-changes.outputs.scope != 'backend' && needs.detect-changes.outputs.scope != 'mixed' run: echo "Backend scope not touched (scope=${{ needs.detect-changes.outputs.scope }}) — skipping integration tests." + + # Production frontend image (nginx) + backend, smoke-tested through the nginx proxy + # (SPA, REST, WebSocket, /health). Runs for every scope except docs-only: the classifier + # puts Dockerfiles, compose files, nginx config, scripts/, Makefile and this workflow + # under "mixed", so container changes are never filtered out. + frontend-container: + runs-on: ubuntu-latest + needs: detect-changes + timeout-minutes: 30 + env: + # One compose project per run, attempt and job: containers, network and the images + # compose builds (-) belong to this job only, and cleanup touches + # nothing else on the runner. + COMPOSE_PROJECT_NAME: dnd-fe-smoke-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + # The smoke reaches nginx over the project network; publish on an ephemeral host port. + FRONTEND_PORT: "0" + steps: + - uses: actions/checkout@v4 + + - name: Run frontend container smoke + if: needs.detect-changes.outputs.scope != 'docs' + run: make test-frontend-container + + - name: Clean up compose project + if: always() && needs.detect-changes.outputs.scope != 'docs' + run: docker compose -f docker-compose.test.yml --profile frontend down --volumes --remove-orphans --rmi local + + - name: Skipped (out of scope) + if: needs.detect-changes.outputs.scope == 'docs' + run: echo "Docs-only change (scope=${{ needs.detect-changes.outputs.scope }}) — skipping frontend container smoke." diff --git a/tests/unit/test_ci_frontend_container.py b/tests/unit/test_ci_frontend_container.py new file mode 100644 index 00000000..5333de40 --- /dev/null +++ b/tests/unit/test_ci_frontend_container.py @@ -0,0 +1,149 @@ +"""Regression tests for the CI frontend-container smoke job. + +Covers the path trigger (scripts/classify-scope.sh + the job's scope gate), the per-run compose +project isolation and cleanup of the job, and the exit status / teardown semantics of +`make test-frontend-container` (exercised against a fake `docker` on PATH, no daemon needed). +""" + +from __future__ import annotations + +import os +import stat +import subprocess +from pathlib import Path +from typing import Any + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +CLASSIFIER = REPO_ROOT / "scripts" / "classify-scope.sh" +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "ci.yml" + +SCOPE = "needs.detect-changes.outputs.scope" + +# Every path whose change can alter the images or the smoke run. +CONTAINER_RELEVANT_PATHS = [ + "frontend/Dockerfile", + "frontend/.dockerignore", + "frontend/nginx.conf.template", + "frontend/package-lock.json", + "frontend/src/main.tsx", + "frontend/vite.config.ts", + "Dockerfile", + "Dockerfile.test", + ".dockerignore", + "docker-compose.test.yml", + "src/dnd_simulator/adapters/api.py", + "content/worlds/default/manifest.yaml", + "tests/frontend_proxy/test_frontend_proxy.py", + "tests/integration/content/manifest.yaml", + "pyproject.toml", + "uv.lock", + ".python-version", + "Makefile", + "scripts/classify-scope.sh", + ".github/workflows/ci.yml", +] + + +def _classify(paths: list[str]) -> str: + result = subprocess.run( + [str(CLASSIFIER)], input="\n".join(paths) + "\n", capture_output=True, text=True, check=True + ) + return result.stdout.strip() + + +def _job() -> dict[str, Any]: + workflow = yaml.safe_load(WORKFLOW.read_text()) + job = workflow["jobs"]["frontend-container"] + assert isinstance(job, dict) + return job + + +def _step(job: dict[str, Any], name: str) -> dict[str, Any]: + steps = [s for s in job["steps"] if s.get("name") == name] + assert len(steps) == 1, name + step = steps[0] + assert isinstance(step, dict) + return step + + +class TestPathTrigger: + @pytest.mark.parametrize("path", CONTAINER_RELEVANT_PATHS) + def test_container_relevant_path_is_never_docs(self, path: str) -> None: + assert _classify([path]) != "docs" + assert _classify([path, "docs/ROADMAP.md"]) != "docs" + + def test_docs_only_change_is_docs(self) -> None: + assert _classify(["docs/ROADMAP.md", "README.md"]) == "docs" + + def test_smoke_runs_for_every_scope_but_docs(self) -> None: + job = _job() + assert job["needs"] == "detect-changes" + smoke = _step(job, "Run frontend container smoke") + assert smoke["if"] == f"{SCOPE} != 'docs'" + assert smoke["run"] == "make test-frontend-container" + assert _step(job, "Skipped (out of scope)")["if"] == f"{SCOPE} == 'docs'" + + +class TestIsolationAndCleanup: + def test_compose_project_is_unique_per_run_attempt_and_job(self) -> None: + project = _job()["env"]["COMPOSE_PROJECT_NAME"] + for part in ("${{ github.run_id }}", "${{ github.run_attempt }}", "${{ github.job }}"): + assert part in project + + def test_frontend_port_is_ephemeral(self) -> None: + assert _job()["env"]["FRONTEND_PORT"] == "0" + + def test_cleanup_runs_always_and_only_on_the_job_project(self) -> None: + job = _job() + cleanup = _step(job, "Clean up compose project") + assert cleanup["if"].startswith("always() && ") + command = cleanup["run"] + assert command.startswith("docker compose -f docker-compose.test.yml --profile frontend down") + # The project comes from the job env; nothing may override it or reach beyond it. + assert " -p " not in command and "--project-name" not in command + for step in job["steps"]: + assert "prune" not in step.get("run", "") + + +def _fake_docker(tmp_path: Path, up_status: int) -> tuple[dict[str, str], Path]: + calls = tmp_path / "docker-calls.log" + fake = tmp_path / "bin" / "docker" + fake.parent.mkdir() + fake.write_text( + "#!/bin/sh\n" + f'echo "project=$COMPOSE_PROJECT_NAME $*" >> "{calls}"\n' + 'for arg in "$@"; do\n' + f' [ "$arg" = up ] && exit {up_status}\n' + "done\n" + "exit 0\n" + ) + fake.chmod(fake.stat().st_mode | stat.S_IXUSR) + env = { + **os.environ, + "PATH": f"{fake.parent}{os.pathsep}{os.environ['PATH']}", + "COMPOSE_PROJECT_NAME": "dnd-fe-smoke-test", + } + return env, calls + + +class TestMakeTargetSemantics: + @pytest.mark.parametrize("up_status", [0, 1, 7]) + def test_exit_status_follows_smoke_and_project_is_torn_down(self, tmp_path: Path, up_status: int) -> None: + env, calls = _fake_docker(tmp_path, up_status) + result = subprocess.run( + ["make", "-s", "-C", str(REPO_ROOT), "test-frontend-container"], + env=env, + capture_output=True, + text=True, + check=False, + ) + assert (result.returncode == 0) == (up_status == 0), result.stderr + lines = calls.read_text().splitlines() + assert len(lines) == 2 + up, down = lines + assert up.startswith("project=dnd-fe-smoke-test compose -f docker-compose.test.yml --profile frontend up ") + assert "--exit-code-from frontend-smoke" in up + assert down.startswith("project=dnd-fe-smoke-test compose -f docker-compose.test.yml --profile frontend down")