#!/usr/bin/env python3 """ E2E Test Suite Runner for ANL Homepage Development (v3.0 Unified Go Backend Architecture) Verifies: 0. Clean step (npm run clean -> removes .next and tsconfig.tsbuildinfo) 1. ESLint (npm run lint) 2. Next.js Static Export Build (npm run build) & Build Completeness Gate (AC-17) 3. TypeScript type check (npx tsc --noEmit) 4. Live Server Route Verification (Live HTTP status 200 & content from unified Go binary) 5. Data count losslessness & fallback dataset integrity (verify_counts.py) 6. Light/Dark mode theme tokens and CSS attributes in source and build assets 7. Live DOM & HTML Content Assertions (Unittest) 8. Layout Architecture & No-Escape Gate (AC-25 / AC-26 / AC-40 / AC-41 / R-8.17) 9. Color Token Alpha Contract & Header Style Gate (AC-35 / AC-36 / AC-37) 10. WCAG 2.1 AA Color Contrast & Whitelist Gate (AC-43 / AC-44 / AC-45 / AC-47 / AC-48) """ import os import sys import time import subprocess import re import urllib.request import shutil import socket ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) REFER_DIR = os.path.join(ROOT_DIR, "refer_landing_page") EXPECTED_DYNAMIC_ROUTES = [] EXPECTED_STATIC_ROUTES = [ "/", "/_not-found", "/icon.svg", "/lectures", "/members", "/publications", "/publications/[category]", "/robots.txt", "/sitemap.xml", "/standardization", ] LIVE_ROUTES = [ "/", "/members", "/publications", "/publications/intl-journal-conf", "/publications/domestic-journal-conf", "/publications/patent", "/lectures", "/standardization", "/_not-found", ] def run_cmd(cmd, cwd=REFER_DIR, env=None): print(f"Running: {' '.join(cmd)} (cwd: {cwd})") run_env = os.environ.copy() if env: run_env.update(env) res = subprocess.run(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=run_env) return res def test_clean(): print("\n--- Gate 0: Clean Step (workspace build cache purge) ---") res = run_cmd(["bash", "-c", "npm run clean"]) if res.returncode != 0: print(f"❌ Clean step failed:\n{res.stdout}\n{res.stderr}") return False print("✅ Gate 0 PASSED: Cleaned .next and tsconfig.tsbuildinfo") return True def test_lint(): print("\n--- Gate 1: ESLint Quality Gate ---") res = run_cmd(["npm", "run", "lint"]) if res.returncode != 0: print(f"❌ Lint failed:\n{res.stdout}\n{res.stderr}") return False print("✅ Gate 1 PASSED: ESLint clean (0 errors, 0 warnings)") return True def test_build(): print("\n--- Gate 2: Next.js Static Export & Build Completeness Gate (AC-17) ---") res = run_cmd(["npm", "run", "build"]) if res.returncode != 0: print(f"❌ Build failed:\n{res.stdout}\n{res.stderr}") return False # Parse exact Next.js route table output lines route_table_map = {} for line in res.stdout.splitlines(): m = re.match(r'^[┌├└]\s+([ƒ○●])\s+(\S+)', line.strip()) if m: mode, route = m.group(1), m.group(2) route_table_map[route] = mode print(f"Parsed route modes from build table ({len(route_table_map)} routes): {route_table_map}") # Filter public routes vs admin console routes public_route_table = {k: v for k, v in route_table_map.items() if not k.startswith("/console")} console_routes = {k: v for k, v in route_table_map.items() if k.startswith("/console")} print(f"Public routes ({len(public_route_table)}): {public_route_table}") if console_routes: print(f"Admin Console routes ({len(console_routes)}): {console_routes}") # Check exact match for each static public route for s_route in EXPECTED_STATIC_ROUTES: mode = public_route_table.get(s_route) if mode not in ("○", "●"): print(f"❌ Gate 2 Violation: Expected static route '{s_route}' has mode '{mode}', expected '○' or '●'") return False # Check that public route set matches exactly all_expected = set(EXPECTED_STATIC_ROUTES) found_public = set(public_route_table.keys()) if all_expected != found_public: print(f"❌ Gate 2 Violation: Public route set mismatch. Missing: {all_expected - found_public}, Extra: {found_public - all_expected}") return False # Build Completeness Check: verify exported files on disk under out/ out_dir = os.path.join(REFER_DIR, "out") required_files = [ "index.html", os.path.join("members", "index.html"), os.path.join("publications", "index.html"), os.path.join("publications", "intl-journal-conf", "index.html"), os.path.join("publications", "domestic-journal-conf", "index.html"), os.path.join("publications", "patent", "index.html"), os.path.join("lectures", "index.html"), os.path.join("standardization", "index.html"), "robots.txt", "sitemap.xml", "icon.svg", ] for rf in required_files: fpath = os.path.join(out_dir, rf) if not os.path.exists(fpath) or os.path.getsize(fpath) == 0: print(f"❌ Gate 2 Completeness Violation: Missing or empty exported file '{rf}' under out/") return False # Check 404 file existence if not (os.path.exists(os.path.join(out_dir, "404.html")) or os.path.exists(os.path.join(out_dir, "404", "index.html"))): print("❌ Gate 2 Completeness Violation: Missing 404 static error page under out/") return False print("✅ Gate 2 PASSED: Static export route allocation & build-completeness verified (10 static routes, all files present in out/)") return True def test_tsc(): print("\n--- Gate 3: TypeScript Type Safety Gate ---") res = run_cmd(["npx", "tsc", "--noEmit"]) if res.returncode != 0: print(f"❌ Type check failed:\n{res.stdout}\n{res.stderr}") return False print("✅ Gate 3 PASSED: TypeScript type check clean (exit code 0)") return True def get_free_port(): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(('', 0)) return s.getsockname()[1] def start_unified_go_server(port=8080): backend_dir = os.path.join(ROOT_DIR, "backend") out_dir = os.path.join(REFER_DIR, "out") web_dir = os.path.join(backend_dir, "web") # Sync out/ into backend/web for static serving shutil.rmtree(web_dir, ignore_errors=True) if os.path.exists(out_dir): shutil.copytree(out_dir, web_dir) print(f"\nStarting unified Go backend server on port {port}...") b_env = os.environ.copy() b_env["PORT"] = str(port) b_env["GIN_MODE"] = "release" b_env["ADMIN_TOKEN"] = "dev_admin_secret_token_12345" proc = subprocess.Popen( ["go", "run", "./cmd/api", f"--port={port}", f"--db={os.path.join(backend_dir, 'anl.db')}"], cwd=backend_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=b_env, ) ready = False for _ in range(40): try: with urllib.request.urlopen(f"http://localhost:{port}/api/v1/health", timeout=1) as resp: if resp.status == 200: ready = True break except Exception: time.sleep(0.5) if not ready: proc.terminate() raise RuntimeError(f"Unified Go backend server failed to become ready on port {port}") print(f"✅ Unified Go server is ready at http://localhost:{port}") return proc def fetch_live_routes(base_url="http://localhost:8080"): bodies = {} for route in LIVE_ROUTES: url = f"{base_url}{route}" req = urllib.request.Request(url, headers={"User-Agent": "E2ETestRunner"}) try: with urllib.request.urlopen(req, timeout=5) as resp: content = resp.read().decode("utf-8") bodies[route] = content except urllib.error.HTTPError as e: # Handle 404 for _not-found if e.code == 404 and "_not-found" in route: content = e.read().decode("utf-8") bodies[route] = content else: raise e return bodies def test_live_routes(bodies): print("\n--- Gate 4: Live Server Route Verification ---") for route, body in bodies.items(): if len(body) == 0: print(f"❌ Empty response body for route '{route}'") return False print(f" [OK] {route} ({len(body)} bytes)") print(f"✅ Gate 4 PASSED: All {len(bodies)} live routes served non-empty responses") return True def test_data_counts(): print("\n--- Gate 5: Fallback Dataset Integrity Gate ---") script_path = os.path.join(REFER_DIR, "scripts", "extract", "verify_counts.py") res = run_cmd([sys.executable, script_path], cwd=ROOT_DIR) if res.returncode != 0: print(f"❌ Data count verification failed:\n{res.stdout}\n{res.stderr}") return False print(res.stdout.strip()) print("✅ Gate 5 PASSED: Data count integrity & fallback dataset volume verified") return True def test_theme_tokens(): print("\n--- Gate 6: CSS & Theme Token Verification ---") globals_css = os.path.join(REFER_DIR, "app", "globals.css") tailwind_config = os.path.join(REFER_DIR, "tailwind.config.ts") if not os.path.exists(globals_css): print(f"❌ Missing {globals_css}") return False with open(globals_css, "r", encoding="utf-8") as f: css_text = f.read() tokens = ["--ink", "--ivory", "--paper", "--line", "--vermillion", "--cobalt"] missing_tokens = [t for t in tokens if t not in css_text] if missing_tokens: print(f"❌ Missing CSS theme tokens in globals.css: {missing_tokens}") return False with open(tailwind_config, "r", encoding="utf-8") as f: tw_text = f.read() tw_tokens = ["vermillion", "cobalt", "dark"] missing_tw = [t for t in tw_tokens if t not in tw_text] if missing_tw: print(f"❌ Missing Tailwind theme tokens in tailwind.config.ts: {missing_tw}") return False print("✅ Gate 6 PASSED: Light/Dark theme CSS variables and Tailwind tokens present") return True def test_unittest_suite(port=8080): print("\n--- Gate 7: DOM & HTML Content Assertions (Unittest) ---") test_path = os.path.join(REFER_DIR, "tests", "e2e_test_suite.py") res = run_cmd([sys.executable, "-m", "unittest", test_path], cwd=ROOT_DIR, env={"TEST_SERVER_URL": f"http://localhost:{port}"}) if res.returncode != 0: print(f"❌ Unittest suite failed:\n{res.stdout}\n{res.stderr}") return False print("✅ Gate 7 PASSED: All 10 DOM & live HTML content assertion tests passed") return True def test_layout_architecture(bodies): print("\n--- Gate 8: Layout Architecture & No-Escape Gate (AC-25 / AC-26 / AC-40 / AC-41 / R-8.17) ---") main_hashes = {} for route, html in bodies.items(): if "_not-found" in route or "404" in route: continue main_match = re.search(r']*)?>(.*?)', html, flags=re.S) if not main_match: print(f"❌
tag not found in {route}") return False main_inner = main_match.group(1) if "container-content" not in main_inner: print(f"❌ container-content missing inside
in {route}") return False norm_content = re.sub(r'\s+', '', main_inner) if norm_content in main_hashes: print(f"❌ AC-41 Violation: Duplicate
content found between {route} and {main_hashes[norm_content]}") return False main_hashes[norm_content] = route forbidden_terms = ["w-screen", "-mx-[50vw]", "min-[1240px]:"] for root_dir in [os.path.join(REFER_DIR, "app"), os.path.join(REFER_DIR, "components")]: for root, _, files in os.walk(root_dir): for fname in files: if not fname.endswith((".tsx", ".ts", ".css")): continue fpath = os.path.join(root, fname) with open(fpath, "r", encoding="utf-8") as f: content = f.read() for term in forbidden_terms: if term in content: print(f"❌ AC-26 Violation: Forbidden arbitrary escape class '{term}' found in {fpath}") return False print("✅ Gate 8 PASSED: Layout 3-tier architecture, route uniqueness (AC-41), and AC-25/AC-26 no-escape rule verified") return True def test_token_contracts(): print("\n--- Gate 9: Color Token Alpha Contract & Header Style Gate (AC-35 / AC-36 / AC-37) ---") # AC-35: Tailwind color configuration must support replacement format tw_config_path = os.path.join(REFER_DIR, "tailwind.config.ts") with open(tw_config_path, "r", encoding="utf-8") as f: tw_src = f.read() if "" not in tw_src and "alpha-value" not in tw_src: print("❌ AC-35 Violation: tailwind.config.ts does not configure for custom color tokens") return False # AC-36: All --*-rgb definitions in globals.css must be space-separated numbers without commas globals_css = os.path.join(REFER_DIR, "app", "globals.css") with open(globals_css, "r", encoding="utf-8") as f: css = f.read() rgb_matches = re.findall(r'--[a-z-]+-rgb:\s*([^;]+);', css) if not rgb_matches: print("❌ AC-36 Violation: No --*-rgb definitions found in globals.css") return False for val in rgb_matches: if "," in val: print(f"❌ AC-36 Violation: Found comma-separated RGB definition in globals.css: '{val.strip()}'") return False # AC-37: Header.tsx must have ZERO inline style attributes and ZERO opacity-* utilities header_path = os.path.join(REFER_DIR, "components", "Header.tsx") with open(header_path, "r", encoding="utf-8") as f: h_src = f.read() if "style={{" in h_src: print("❌ AC-37 Violation: Header.tsx contains inline style={{...}} attributes") return False opacity_matches = re.findall(r'opacity-\d+', h_src) if opacity_matches: print(f"❌ AC-37 Violation: Header.tsx contains opacity-* utilities: {opacity_matches}") return False print("✅ Gate 9 PASSED: Color token alpha contract (AC-35), space separation (AC-36), and Header clean styling (AC-37) verified") return True def test_color_contrast_gate(): print("\n--- Gate 10: WCAG 2.1 AA Color Contrast & Whitelist Gate (AC-43 / AC-44 / AC-45 / AC-47) ---") white_count = 0 for root_dir in [os.path.join(REFER_DIR, "app"), os.path.join(REFER_DIR, "components")]: for root, _, files in os.walk(root_dir): if "console" in os.path.relpath(root, REFER_DIR).split(os.sep): continue for fname in files: if not fname.endswith((".tsx", ".ts")): continue fpath = os.path.join(root, fname) with open(fpath, "r", encoding="utf-8") as f: content = f.read() if "text-cobalt" in content or "text-vermillion" in content: for line in content.splitlines(): if ("text-[0." in line or "text-xs" in line) and ("text-cobalt" in line or "text-vermillion" in line): if "text-cobalt-dark" not in line and "text-vermillion-dark" not in line: print(f"❌ AC-44 Violation: Small text uses DEFAULT accent color instead of -dark variant in {fpath}:\n {line.strip()}") return False if "dark:text-cobalt" in content or "dark:text-vermillion" in content: print(f"❌ C11-9 Violation: dark: modifier used on accent text color in {fpath}") return False white_matches = re.findall(r'\btext-white\b', content) white_count += len(white_matches) if white_count > 8: print(f"❌ AC-47 Violation: text-white used {white_count} times across public components (max allowed: 8)") return False print(f"✅ Gate 10 & 11 PASSED: Color contrast (AC-43), small text routing (AC-44), inversion (AC-45), text-white count ({white_count}/8, AC-47), and dark: accent modifier zero check (C11-9) verified") return True def main(): print("=" * 70) print(" ANL HOMEPAGE E2E TEST SUITE RUNNER — TIERS 1-4 (v3.0 UNIFIED GO BACKEND)") print("=" * 70) success = True success &= test_clean() success &= test_lint() success &= test_build() success &= test_tsc() success &= test_data_counts() success &= test_theme_tokens() success &= test_token_contracts() success &= test_color_contrast_gate() if not success: print("\n❌ Build or static gates failed before live server execution.") sys.exit(1) port = get_free_port() server_proc = None try: server_proc = start_unified_go_server(port=port) bodies = fetch_live_routes(f"http://localhost:{port}") success &= test_live_routes(bodies) success &= test_unittest_suite(port=port) success &= test_layout_architecture(bodies) finally: if server_proc: print("\nShutting down unified Go backend server...") server_proc.terminate() try: server_proc.wait(timeout=5) except Exception: server_proc.kill() print("\n" + "-" * 70) if success: print("ALL E2E TEST SUITES PASSED CLEAN (Exit Code 0).") print("=" * 70) sys.exit(0) else: print("❌ ONE OR MORE E2E GATES FAILED.") print("=" * 70) sys.exit(1) if __name__ == "__main__": main()