- Configure Next.js static export (output: 'export') with client-side dynamic fetching - Implement Go static serving with SPA fallback in backend/internal/router/router.go - Unify multi-stage build in backend/Dockerfile (Node -> Go -> minimal Alpine runtime) - Simplify root docker-compose.yml to single anl-app service on port 8080 - Update REQUIREMENTS.md DM-03 and AC-17 normative contracts to v3.0 Unified Go Backend - Restore strict regression verification in Gate 9 (AC-35/36/37) and unittest suite - All Go unit tests, TypeScript type checks, ESLint, and E2E gates passed clean
454 lines
18 KiB
Python
Executable File
454 lines
18 KiB
Python
Executable File
#!/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'<main(?:\s+[^>]*)?>(.*?)</main>', html, flags=re.S)
|
|
if not main_match:
|
|
print(f"❌ <main> 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 <main> in {route}")
|
|
return False
|
|
|
|
norm_content = re.sub(r'\s+', '', main_inner)
|
|
if norm_content in main_hashes:
|
|
print(f"❌ AC-41 Violation: Duplicate <main> 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 <alpha-value> 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 "<alpha-value>" not in tw_src and "alpha-value" not in tw_src:
|
|
print("❌ AC-35 Violation: tailwind.config.ts does not configure <alpha-value> 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()
|