diff --git a/docs/developer-testing-guidelines.md b/docs/developer-testing-guidelines.md index d85c670..61abd75 100644 --- a/docs/developer-testing-guidelines.md +++ b/docs/developer-testing-guidelines.md @@ -54,6 +54,21 @@ Use `-q` for a compact summary and `-v` to see individual test names. ./venv/bin/python -m pytest tests/ -q ``` +### Web UI suite (#436) + +Hermetic unittest modules matching `test_webui_*.py` cover route rendering, +read-only guards, registry/prompt/queue loaders, and optional child-issue +modules when present on the branch. + +```bash +./scripts/test-webui +./scripts/ci-webui-check # skip unless the diff touches web UI paths +WEBUI_CI_FORCE=1 ./scripts/ci-webui-check +``` + +Wire `scripts/ci-webui-check` into Jenkins (or equivalent) for PRs that touch +`webui/`, `tests/test_webui_*`, or `docs/webui*`. + ### Run targeted tests ```bash diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 990914e..c1eafc6 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -140,4 +140,25 @@ no tokens or MCP restart actions are exposed. pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_gated_actions.py -q pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q + +## Tests (#436) + +Run the full hermetic web UI suite (all `test_webui_*.py` modules): + +```bash +./scripts/test-webui +``` + +CI / Jenkins multibranch can call the path-filtered gate (runs only when the +diff touches `webui/`, `tests/test_webui_*`, or web UI docs/scripts): + +```bash +./scripts/ci-webui-check +WEBUI_CI_FORCE=1 ./scripts/ci-webui-check # always run +``` + +Or invoke unittest directly: + +```bash +python3 -m unittest discover -s tests -p 'test_webui_*.py' -q ``` \ No newline at end of file diff --git a/scripts/ci-webui-check b/scripts/ci-webui-check new file mode 100755 index 0000000..a679993 --- /dev/null +++ b/scripts/ci-webui-check @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Path-filtered CI gate for the internal web UI (#436). +# Runs the hermetic web UI unittest suite when a change touches UI surfaces. +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +cd "$repo_root" + +if [[ "${WEBUI_CI_FORCE:-}" == "1" ]]; then + exec "$script_dir/test-webui" +fi + +base_ref="${WEBUI_CI_BASE_REF:-}" +if [[ -z "$base_ref" ]]; then + if git rev-parse --verify prgs/master >/dev/null 2>&1; then + base_ref="$(git merge-base HEAD prgs/master 2>/dev/null || true)" + fi + if [[ -z "$base_ref" ]]; then + base_ref="${GITHUB_BASE_REF:-${CHANGE_TARGET:-master}}" + if git rev-parse --verify "origin/$base_ref" >/dev/null 2>&1; then + base_ref="$(git merge-base HEAD "origin/$base_ref")" + elif git rev-parse --verify "$base_ref" >/dev/null 2>&1; then + base_ref="$(git merge-base HEAD "$base_ref")" + else + base_ref="HEAD~1" + fi + fi +fi + +changed="$(git diff --name-only "$base_ref" HEAD 2>/dev/null || true)" +if [[ -z "$changed" ]]; then + echo "ci-webui-check: no changed files vs $base_ref; skipping web UI suite" + exit 0 +fi + +python_bin="${WEBUI_TEST_PYTHON:-python3}" +if [[ -x "$repo_root/venv/bin/python" ]]; then + python_bin="$repo_root/venv/bin/python" +fi + +if printf '%s\n' "$changed" | "$python_bin" -c " +import sys +from webui.ci_paths import should_run_webui_ci +paths = [line.strip() for line in sys.stdin if line.strip()] +raise SystemExit(0 if should_run_webui_ci(paths) else 1) +"; then + echo "ci-webui-check: web UI surface changed; running suite" + exec "$script_dir/test-webui" +fi + +echo "ci-webui-check: no web UI paths in diff; skipping suite" +exit 0 \ No newline at end of file diff --git a/scripts/test-webui b/scripts/test-webui new file mode 100755 index 0000000..d7d4e31 --- /dev/null +++ b/scripts/test-webui @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +cd "$repo_root" + +python_bin="${WEBUI_TEST_PYTHON:-python3}" +if [[ -x "$repo_root/venv/bin/python" ]]; then + python_bin="$repo_root/venv/bin/python" +fi + +pattern="${WEBUI_TEST_PATTERN:-test_webui_*.py}" +exec "$python_bin" -m unittest discover -s tests -p "$pattern" "$@" \ No newline at end of file diff --git a/tests/test_webui_ci_gate.py b/tests/test_webui_ci_gate.py new file mode 100644 index 0000000..2f4a577 --- /dev/null +++ b/tests/test_webui_ci_gate.py @@ -0,0 +1,54 @@ +"""Tests for web UI CI path-filter gate (#436).""" +import os +import subprocess +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from webui.ci_paths import should_run_webui_ci, touches_webui_surface + +_REPO_ROOT = Path(__file__).resolve().parent.parent + + +class TestCiPathMatching(unittest.TestCase): + def test_positive_paths(self): + for path in ( + "webui/app.py", + "tests/test_webui_skeleton.py", + "docs/webui-local-dev.md", + "scripts/test-webui", + ): + with self.subTest(path=path): + self.assertTrue(touches_webui_surface(path)) + + def test_negative_paths(self): + for path in ("mcp_server.py", "tests/test_mcp_server.py", "README.md"): + with self.subTest(path=path): + self.assertFalse(touches_webui_surface(path)) + + def test_should_run_aggregate(self): + self.assertTrue(should_run_webui_ci(["README.md", "webui/layout.py"])) + self.assertFalse(should_run_webui_ci(["mcp_server.py", "gitea_auth.py"])) + + +class TestCiRunnerScript(unittest.TestCase): + def test_test_webui_smoke(self): + script = _REPO_ROOT / "scripts" / "test-webui" + result = subprocess.run( + ["bash", str(script), "-q"], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + timeout=60, + env={ + **os.environ, + "WEBUI_TEST_PATTERN": "test_webui_skeleton.py", + }, + ) + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_webui_mvp_suite.py b/tests/test_webui_mvp_suite.py new file mode 100644 index 0000000..2d5bd54 --- /dev/null +++ b/tests/test_webui_mvp_suite.py @@ -0,0 +1,156 @@ +"""Consolidated MVP coverage for the internal web UI (#436).""" +from __future__ import annotations + +import importlib +import os +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient +from starlette.routing import Route + +from webui.app import create_app + +_REPO_ROOT = Path(__file__).resolve().parent.parent + +# HTML pages and JSON exports registered on master MVP. +MVP_GET_ROUTES: tuple[tuple[str, str | tuple[str, ...]], ...] = ( + ("/", "Operator console"), + ("/health", "mcp-control-plane-webui"), + ("/queue", "Live queue"), + ("/api/queue", ("prs", "issues")), + ("/projects", "Gitea-Tools"), + ("/api/projects", "projects"), + ("/prompts", "Prompt library"), + ("/api/prompts", "prompts"), + ("/runtime", "Runtime"), + ("/audit", "Audit"), + ("/worktrees", "Worktrees"), + ("/leases", "Leases"), +) + +MUTATION_METHODS = ("POST", "PUT", "PATCH", "DELETE") + + +def _import_optional(module_name: str): + try: + return importlib.import_module(module_name) + except ImportError: + return None + + +class TestWebuiServerStartup(unittest.TestCase): + def test_create_app_imports_cleanly(self): + app = create_app() + self.assertIsNotNone(app) + + def test_main_module_imports(self): + import webui.__main__ as main_mod + + self.assertTrue(callable(main_mod.main)) + + +class TestWebuiRouteRendering(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_all_mvp_get_routes_render(self): + for path, needle in MVP_GET_ROUTES: + with self.subTest(path=path): + response = self.client.get(path) + self.assertEqual(response.status_code, 200, path) + if path == "/health": + assert isinstance(needle, str) + self.assertEqual(response.json()["service"], needle) + elif path.startswith("/api/"): + data = response.json() + if isinstance(needle, tuple): + for key in needle: + self.assertIn(key, data) + else: + self.assertIn(needle, data) + else: + assert isinstance(needle, str) + self.assertIn(needle, response.text) + + def test_registered_routes_include_mvp_paths(self): + app = create_app() + get_paths = { + route.path + for route in app.routes + if isinstance(route, Route) and "GET" in route.methods + } + for path, _ in MVP_GET_ROUTES: + with self.subTest(path=path): + self.assertIn(path, get_paths) + + +class TestWebuiReadOnlyGuards(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_mutation_methods_rejected_on_core_paths(self): + paths = ("/health", "/queue", "/projects", "/prompts", "/api/queue") + for path in paths: + for method in MUTATION_METHODS: + with self.subTest(path=path, method=method): + response = self.client.request(method, path) + self.assertEqual(response.status_code, 405) + self.assertEqual(response.json()["error"], "read-only-mvp") + + +class TestWebuiOptionalModules(unittest.TestCase): + """Exercise child-issue modules when present on the branch under test.""" + + def test_audit_validator_basics_when_present(self): + mod = _import_optional("webui.audit_validator") + if mod is None: + self.skipTest("webui.audit_validator not merged on this branch") + report = "## Controller Handoff\n- Task: review PR #1\n" + self.assertEqual(mod.infer_task_kind(report), "review_pr") + result = mod.audit_report(report) + self.assertIn("grade", result) + + def test_gated_actions_fail_closed_when_present(self): + mod = _import_optional("webui.gated_actions") + if mod is None: + self.skipTest("webui.gated_actions not merged on this branch") + registry = mod.build_action_registry() + for action in registry.actions: + with self.subTest(action=action.action_id): + self.assertFalse(action.enabled) + result = mod.attempt_action("merge_pr", pr_number=1) + self.assertFalse(result["success"]) + + def test_deployment_boundary_when_present(self): + mod = _import_optional("webui.deployment_boundary") + if mod is None: + self.skipTest("webui.deployment_boundary not merged on this branch") + assessment = mod.assess_bind_host("0.0.0.0") + self.assertEqual(assessment.disposition, "refuse") + + +class TestWebuiTestInventory(unittest.TestCase): + def test_webui_test_modules_exist(self): + modules = sorted(_REPO_ROOT.glob("tests/test_webui_*.py")) + names = [path.name for path in modules] + self.assertIn("test_webui_skeleton.py", names) + self.assertIn("test_webui_project_registry.py", names) + self.assertIn("test_webui_prompt_library.py", names) + self.assertIn("test_webui_queue_dashboard.py", names) + self.assertGreaterEqual(len(names), 5) + + +class TestWebuiCiScripts(unittest.TestCase): + def test_runner_scripts_exist(self): + for name in ("test-webui", "ci-webui-check"): + script = _REPO_ROOT / "scripts" / name + self.assertTrue(script.is_file(), name) + self.assertTrue(os.access(script, os.X_OK), name) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/webui/ci_paths.py b/webui/ci_paths.py new file mode 100644 index 0000000..779acf4 --- /dev/null +++ b/webui/ci_paths.py @@ -0,0 +1,20 @@ +"""Path filters for web UI CI gating (#436).""" + +from __future__ import annotations + +import re +from collections.abc import Iterable + +_WEBUI_TOUCH_RE = re.compile( + r"^(?:webui/|tests/test_webui_|docs/webui|scripts/(?:run-webui|test-webui|ci-webui-check)$)" +) + + +def touches_webui_surface(path: str) -> bool: + """Return True when *path* is a web UI surface file.""" + return bool(_WEBUI_TOUCH_RE.match(path.strip())) + + +def should_run_webui_ci(changed_paths: Iterable[str]) -> bool: + """Return True when any changed path should trigger the web UI test suite.""" + return any(touches_webui_surface(path) for path in changed_paths) \ No newline at end of file