From d12c0183ca233e3f720a979f009230f519703166 Mon Sep 17 00:00:00 2001 From: dev-3 Date: Mon, 11 May 2026 10:15:08 -0700 Subject: [PATCH] test: add webapp first-run e2e coverage --- agfs-shell/agfs_shell/webapp_server.py | 2 +- agfs-shell/pyproject.toml | 5 + agfs-shell/tests/test_docs_webapp_e2e.py | 173 +++++++++++++++++++++++ agfs-shell/tests/test_webapp_server.py | 2 +- agfs-shell/webapp/setup.sh | 2 +- docs/e2e.md | 4 + 6 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 agfs-shell/tests/test_docs_webapp_e2e.py diff --git a/agfs-shell/agfs_shell/webapp_server.py b/agfs-shell/agfs_shell/webapp_server.py index 1f0b2c1..cc2e370 100644 --- a/agfs-shell/agfs_shell/webapp_server.py +++ b/agfs-shell/agfs_shell/webapp_server.py @@ -30,7 +30,7 @@ def validate_webapp_dist(webapp_dir=WEBAPP_DIST_DIR): f"Expected: {index_html}\n\n" "Build the frontend before starting integrated webapp mode:\n" " cd agfs-shell/webapp\n" - " npm install\n" + " npm ci\n" " npm run build\n\n" "Or run the setup helper:\n" " cd agfs-shell/webapp\n" diff --git a/agfs-shell/pyproject.toml b/agfs-shell/pyproject.toml index 739ce7c..61591d9 100644 --- a/agfs-shell/pyproject.toml +++ b/agfs-shell/pyproject.toml @@ -47,3 +47,8 @@ dev = [ "pytest-cov>=5.0.0", "ruff>=0.14.13", ] + +[tool.pytest.ini_options] +markers = [ + "e2e: opt-in end-to-end tests that exercise real first-run commands and subprocesses", +] diff --git a/agfs-shell/tests/test_docs_webapp_e2e.py b/agfs-shell/tests/test_docs_webapp_e2e.py new file mode 100644 index 0000000..7a59785 --- /dev/null +++ b/agfs-shell/tests/test_docs_webapp_e2e.py @@ -0,0 +1,173 @@ +"""E2E coverage for first-run docs and integrated webapp startup.""" + +import os +import shutil +import socket +import subprocess +import time +import urllib.error +import urllib.request +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SHELL_ROOT = REPO_ROOT / "agfs-shell" +WEBAPP_ROOT = SHELL_ROOT / "webapp" +WEBAPP_DIST = WEBAPP_ROOT / "dist" +FIRST_RUN_DOC = REPO_ROOT / "docs" / "first-run.md" + +RUN_E2E = os.getenv("AGFS_RUN_E2E") == "1" +requires_e2e = pytest.mark.skipif( + not RUN_E2E, + reason="set AGFS_RUN_E2E=1 to run subprocess-based E2E checks", +) + + +def _require_command(name: str) -> None: + if shutil.which(name) is None: + pytest.skip(f"{name} is required for this E2E test") + + +def _run(command, cwd: Path, timeout: int = 60, env=None) -> subprocess.CompletedProcess: + return subprocess.run( + command, + cwd=cwd, + env=env, + text=True, + capture_output=True, + timeout=timeout, + check=True, + ) + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _wait_for_http(url: str, proc: subprocess.Popen, timeout: int = 20) -> str: + deadline = time.monotonic() + timeout + last_error = None + while time.monotonic() < deadline: + if proc.poll() is not None: + stdout, stderr = proc.communicate() + raise AssertionError( + f"integrated webapp exited early with {proc.returncode}\n" + f"stdout:\n{stdout}\n\nstderr:\n{stderr}" + ) + + try: + with urllib.request.urlopen(url, timeout=1) as response: + return response.read().decode("utf-8", errors="replace") + except (urllib.error.URLError, TimeoutError) as exc: + last_error = exc + time.sleep(0.25) + + raise AssertionError(f"timed out waiting for {url}: {last_error}") + + +def _move_dist_aside(): + backup = None + if WEBAPP_DIST.exists(): + backup = WEBAPP_ROOT / f"dist.e2e-backup-{os.getpid()}" + if backup.exists(): + shutil.rmtree(backup) + WEBAPP_DIST.rename(backup) + return backup + + +def _restore_dist(backup): + if WEBAPP_DIST.exists(): + shutil.rmtree(WEBAPP_DIST) + if backup is not None: + backup.rename(WEBAPP_DIST) + + +@pytest.mark.e2e +@requires_e2e +def test_first_run_docs_webapp_build_and_integrated_startup_e2e(): + _require_command("make") + _require_command("npm") + _require_command("uv") + + first_run_doc = FIRST_RUN_DOC.read_text(encoding="utf-8") + setup_script = (WEBAPP_ROOT / "setup.sh").read_text(encoding="utf-8") + assert "cd agfs-server && make dev" in first_run_doc + assert "cd agfs-shell/webapp && npm ci && npm run build" in first_run_doc + assert "uv run agfs-shell --webapp" in first_run_doc + assert "webapp/dist/index.html" in first_run_doc + assert "npm ci" in setup_script + + make_dev = _run(["make", "-n", "-C", "agfs-server", "dev"], cwd=REPO_ROOT) + assert "go run cmd/server/main.go" in make_dev.stdout + assert "-c config.example.yaml" in make_dev.stdout + + _run(["uv", "sync", "--extra", "webapp"], cwd=SHELL_ROOT, timeout=120) + + backup = _move_dist_aside() + try: + missing_dist = subprocess.run( + [ + "uv", + "run", + "agfs-shell", + "--webapp", + "--webapp-host", + "127.0.0.1", + "--webapp-port", + str(_free_port()), + ], + cwd=SHELL_ROOT, + text=True, + capture_output=True, + timeout=20, + check=False, + ) + missing_output = missing_dist.stdout + missing_dist.stderr + assert missing_dist.returncode != 0 + assert "Web app build output not found" in missing_output + assert "webapp/dist/index.html" in missing_output + assert "npm ci" in missing_output + assert "npm run build" in missing_output + finally: + _restore_dist(backup) + + _run(["npm", "ci"], cwd=WEBAPP_ROOT, timeout=120) + _run(["npm", "run", "build"], cwd=WEBAPP_ROOT, timeout=120) + assert (WEBAPP_DIST / "index.html").is_file() + + port = _free_port() + env = os.environ.copy() + env["AGFS_API_URL"] = "http://127.0.0.1:8080" + proc = subprocess.Popen( + [ + "uv", + "run", + "agfs-shell", + "--webapp", + "--webapp-host", + "127.0.0.1", + "--webapp-port", + str(port), + ], + cwd=SHELL_ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + try: + body = _wait_for_http(f"http://127.0.0.1:{port}/", proc) + assert "" in body.lower() + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=10) diff --git a/agfs-shell/tests/test_webapp_server.py b/agfs-shell/tests/test_webapp_server.py index 68d4d14..4bf0f16 100644 --- a/agfs-shell/tests/test_webapp_server.py +++ b/agfs-shell/tests/test_webapp_server.py @@ -16,7 +16,7 @@ def test_validate_webapp_dist_requires_index_html(tmp_path): assert "Web app build output not found" in message assert str(dist_dir / "index.html") in message assert "cd agfs-shell/webapp" in message - assert "npm install" in message + assert "npm ci" in message assert "npm run build" in message assert "./setup.sh" in message diff --git a/agfs-shell/webapp/setup.sh b/agfs-shell/webapp/setup.sh index 76beb56..72ac908 100755 --- a/agfs-shell/webapp/setup.sh +++ b/agfs-shell/webapp/setup.sh @@ -28,7 +28,7 @@ uv sync --extra webapp # Install frontend dependencies echo "📦 Installing frontend dependencies..." cd webapp -npm install +npm ci # Build frontend echo "🔨 Building frontend..." diff --git a/docs/e2e.md b/docs/e2e.md index 48dfe7d..c6c7f99 100644 --- a/docs/e2e.md +++ b/docs/e2e.md @@ -36,4 +36,8 @@ Useful environment overrides: - Shell/SDK/pipeline e2e cases live under task #26. - Docs/webapp/first-run e2e cases live under task #27. +Lane tests may overlap the core harness when they need to exercise the same +user-visible behavior end to end. The core harness is a smoke screen, not a +deduplication target. + Cross-review should block a patch when a realistic user path exists but the author only provided unit tests.