feat(frontend): convert to live real-time dynamic runtime fetching with force-dynamic and cache no-store
- Set cache: 'no-store' in lib/api.ts to trigger real-time REST API requests on every page load - Add export const dynamic = 'force-dynamic' across all 6 app pages (/home, /members, /publications, /lectures, /standardization) - Update REQUIREMENTS.md and E2E test runner to validate live server routes and dynamic runtime fetching - Pass all 12 quality gates cleanly
This commit is contained in:
@@ -1,44 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
E2E Test Suite Runner for ANL Homepage Development
|
||||
E2E Test Suite Runner for ANL Homepage Development (v2.0 Dynamic Fetching Architecture)
|
||||
Verifies:
|
||||
0. Clean step (npm run clean -> removes .next and tsconfig.tsbuildinfo)
|
||||
1. ESLint (npm run lint)
|
||||
2. Next.js Static SSG Build (npm run build) with 0 dynamic routes
|
||||
2. Next.js Dynamic/Static Hybrid Build (npm run build) with exact dynamic/static route allocation (AC-17)
|
||||
3. TypeScript type check (npx tsc --noEmit)
|
||||
4. Existence and content of static SSG routes HTML/body files
|
||||
5. Data count losslessness & integrity gate (verify_counts.py)
|
||||
4. Live Server Route Verification (Live HTTP status 200 & content)
|
||||
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. DOM & HTML Content Assertions (Unittest)
|
||||
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
|
||||
|
||||
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 Static SSG Routes HTML/Body output paths relative to REFER_DIR
|
||||
EXPECTED_SSG_ROUTES = [
|
||||
os.path.join(".next", "server", "app", "index.html"),
|
||||
os.path.join(".next", "server", "app", "members.html"),
|
||||
os.path.join(".next", "server", "app", "publications.html"),
|
||||
os.path.join(".next", "server", "app", "publications", "intl-journal-conf.html"),
|
||||
os.path.join(".next", "server", "app", "publications", "domestic-journal-conf.html"),
|
||||
os.path.join(".next", "server", "app", "publications", "patent.html"),
|
||||
os.path.join(".next", "server", "app", "lectures.html"),
|
||||
os.path.join(".next", "server", "app", "standardization.html"),
|
||||
os.path.join(".next", "server", "app", "_not-found.html"),
|
||||
os.path.join(".next", "server", "app", "robots.txt.body"),
|
||||
os.path.join(".next", "server", "app", "sitemap.xml.body"),
|
||||
os.path.join(".next", "server", "pages", "404.html"),
|
||||
EXPECTED_DYNAMIC_ROUTES = [
|
||||
"/",
|
||||
"/members",
|
||||
"/publications",
|
||||
"/publications/[category]",
|
||||
"/lectures",
|
||||
"/standardization",
|
||||
]
|
||||
|
||||
def run_cmd(cmd, cwd=REFER_DIR):
|
||||
EXPECTED_STATIC_ROUTES = [
|
||||
"/_not-found",
|
||||
"/icon.svg",
|
||||
"/robots.txt",
|
||||
"/sitemap.xml",
|
||||
]
|
||||
|
||||
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})")
|
||||
res = subprocess.run(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
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():
|
||||
@@ -60,22 +80,37 @@ def test_lint():
|
||||
return True
|
||||
|
||||
def test_build():
|
||||
print("\n--- Gate 2: Next.js Static SSG Build Gate ---")
|
||||
print("\n--- Gate 2: Next.js Dynamic/Static Hybrid Build Gate (AC-17) ---")
|
||||
res = run_cmd(["npm", "run", "build"])
|
||||
if res.returncode != 0:
|
||||
print(f"❌ SSG Build failed:\n{res.stdout}\n{res.stderr}")
|
||||
print(f"❌ Build failed:\n{res.stdout}\n{res.stderr}")
|
||||
return False
|
||||
|
||||
# Check stdout/stderr for dynamic routes (f Dynamic)
|
||||
if "ƒ" in res.stdout or "Dynamic" in res.stdout:
|
||||
# Search specifically for dynamic routes output line
|
||||
lines = res.stdout.splitlines()
|
||||
dynamic_lines = [l for l in lines if "ƒ" in l or "Dynamic" in l]
|
||||
if dynamic_lines:
|
||||
print(f"❌ Found dynamic route(s) in build report: {dynamic_lines}")
|
||||
lines = res.stdout.splitlines()
|
||||
|
||||
# Verify exact dynamic routes
|
||||
for d_route in EXPECTED_DYNAMIC_ROUTES:
|
||||
found = False
|
||||
for line in lines:
|
||||
if d_route in line and ("ƒ" in line or "Dynamic" in line):
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
print(f"❌ Gate 2 Violation: Expected dynamic route '{d_route}' not marked with 'ƒ Dynamic' in build report")
|
||||
return False
|
||||
|
||||
print("✅ Gate 2 PASSED: Static SSG Build successful (0 dynamic routes)")
|
||||
# Verify exact static routes
|
||||
for s_route in EXPECTED_STATIC_ROUTES:
|
||||
found = False
|
||||
for line in lines:
|
||||
if s_route in line and ("○" in line or "Static" in line):
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
print(f"❌ Gate 2 Violation: Expected static route '{s_route}' not marked with '○ Static' in build report")
|
||||
return False
|
||||
|
||||
print("✅ Gate 2 PASSED: Hybrid dynamic/static route allocation verified (6 dynamic routes, 4 static routes)")
|
||||
return True
|
||||
|
||||
def test_tsc():
|
||||
@@ -87,35 +122,74 @@ def test_tsc():
|
||||
print("✅ Gate 3 PASSED: TypeScript type check clean (exit code 0)")
|
||||
return True
|
||||
|
||||
def test_ssg_routes():
|
||||
print("\n--- Gate 4: Static SSG HTML Route Verification ---")
|
||||
missing = []
|
||||
for rel_path in EXPECTED_SSG_ROUTES:
|
||||
full_path = os.path.join(REFER_DIR, rel_path)
|
||||
if not os.path.exists(full_path):
|
||||
missing.append(rel_path)
|
||||
else:
|
||||
size = os.path.getsize(full_path)
|
||||
print(f" [OK] {rel_path} ({size} bytes)")
|
||||
def start_next_server(port=3000):
|
||||
print(f"\nStarting live Next.js server on port {port}...")
|
||||
server_env = os.environ.copy()
|
||||
server_env["PORT"] = str(port)
|
||||
proc = subprocess.Popen(
|
||||
["npm", "run", "start"],
|
||||
cwd=REFER_DIR,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
env=server_env,
|
||||
)
|
||||
|
||||
ready = False
|
||||
for _ in range(30):
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://localhost:{port}/", timeout=1) as resp:
|
||||
if resp.status == 200:
|
||||
ready = True
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
|
||||
if missing:
|
||||
print(f"❌ Missing {len(missing)} SSG routes:")
|
||||
for m in missing:
|
||||
print(f" - {m}")
|
||||
return False
|
||||
if not ready:
|
||||
proc.terminate()
|
||||
raise RuntimeError(f"Next.js server failed to become ready on port {port}")
|
||||
|
||||
print(f"Live Next.js server is ready at http://localhost:{port}")
|
||||
return proc
|
||||
|
||||
print(f"✅ Gate 4 PASSED: All {len(EXPECTED_SSG_ROUTES)} static SSG routes exist and are non-empty")
|
||||
def fetch_live_routes(base_url="http://localhost:3000"):
|
||||
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: Data Losslessness Integrity Gate ---")
|
||||
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 & volume losslessness verified")
|
||||
print("✅ Gate 5 PASSED: Data count integrity & fallback dataset volume verified")
|
||||
return True
|
||||
|
||||
def test_theme_tokens():
|
||||
@@ -151,50 +225,40 @@ def test_theme_tokens():
|
||||
def test_unittest_suite():
|
||||
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)
|
||||
res = run_cmd([sys.executable, "-m", "unittest", test_path], cwd=ROOT_DIR, env={"TEST_SERVER_URL": "http://localhost:3000"})
|
||||
if res.returncode != 0:
|
||||
print(f"❌ Unittest suite failed:\n{res.stdout}\n{res.stderr}")
|
||||
return False
|
||||
print("✅ Gate 7 PASSED: All 7 DOM & static HTML content assertion tests passed")
|
||||
print("✅ Gate 7 PASSED: All 10 DOM & live HTML content assertion tests passed")
|
||||
return True
|
||||
|
||||
def test_layout_architecture():
|
||||
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) ---")
|
||||
|
||||
# 1) Check HTML SSG pages for container-content inside main across ALL SSG routes (C2, AC-25)
|
||||
routes_to_check = EXPECTED_SSG_ROUTES
|
||||
|
||||
# 1) Check live HTML responses for container-content inside main across all routes (C2, AC-25)
|
||||
main_hashes = {}
|
||||
for rel_p in routes_to_check:
|
||||
if not rel_p.endswith(".html"):
|
||||
for route, html in bodies.items():
|
||||
if "_not-found" in route or "404" in route:
|
||||
continue
|
||||
full_p = os.path.join(REFER_DIR, rel_p)
|
||||
if not os.path.exists(full_p):
|
||||
print(f"❌ Missing SSG route HTML: {rel_p}")
|
||||
return False
|
||||
with open(full_p, "r", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
|
||||
# Verify <main> tag exists and does NOT contain max-w- or px-
|
||||
main_match = re.search(r'<main(?:\s+[^>]*)?>(.*?)</main>', html, flags=re.S)
|
||||
if not main_match:
|
||||
print(f"❌ <main> tag not found in {rel_p}")
|
||||
print(f"❌ <main> tag not found in {route}")
|
||||
return False
|
||||
|
||||
main_inner = main_match.group(1)
|
||||
|
||||
# C1: Verify child wrapper inside <main> has container-content
|
||||
if "container-content" not in main_inner and "_not-found" not in rel_p and "404" not in rel_p:
|
||||
print(f"❌ container-content missing inside <main> in {rel_p}")
|
||||
if "container-content" not in main_inner:
|
||||
print(f"❌ container-content missing inside <main> in {route}")
|
||||
return False
|
||||
|
||||
# AC-41: Check for duplicate <main> normalized content hashes (except 404 pages)
|
||||
if "404" not in rel_p and "_not-found" not in rel_p:
|
||||
norm_content = re.sub(r'\s+', '', main_inner)
|
||||
if norm_content in main_hashes:
|
||||
print(f"❌ AC-41 Violation: Duplicate <main> content found between {rel_p} and {main_hashes[norm_content]}")
|
||||
return False
|
||||
main_hashes[norm_content] = rel_p
|
||||
# AC-41: Check for duplicate <main> normalized content hashes
|
||||
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
|
||||
|
||||
# 2) Check source for forbidden escaped arbitrary classes (AC-26)
|
||||
forbidden_terms = ["w-screen", "-mx-[50vw]", "min-[1240px]:"]
|
||||
@@ -276,21 +340,20 @@ def test_color_contrast_gate():
|
||||
|
||||
def parse_tokens(block):
|
||||
res = {}
|
||||
for m in re.finditer(r'--([a-z-]+)-rgb:\s*(\d+)\s+(\d+)\s+(\d+);', block):
|
||||
for m in re.finditer(r'--([a-z-]+-?d?a?r?k?)-rgb:\s*(\d+)\s+(\d+)\s+(\d+);', block):
|
||||
res[m.group(1)] = (int(m.group(2)), int(m.group(3)), int(m.group(4)))
|
||||
return res
|
||||
|
||||
light_tokens = parse_tokens(root_match.group(1))
|
||||
dark_tokens = parse_tokens(dark_match.group(1))
|
||||
|
||||
# AC-43: Verify contrast thresholds for cobalt-dark and vermillion-dark on ivory and paper
|
||||
cd_light = light_tokens.get("cobalt-dark")
|
||||
vd_light = light_tokens.get("vermillion-dark")
|
||||
ivory_light = light_tokens.get("ivory")
|
||||
paper_light = light_tokens.get("paper")
|
||||
|
||||
if not all([cd_light, vd_light, ivory_light, paper_light]):
|
||||
print("❌ AC-43 Failed: Missing required light RGB tokens")
|
||||
print(f"❌ AC-43 Failed: Missing required light RGB tokens (found: {list(light_tokens.keys())})")
|
||||
return False
|
||||
|
||||
ratio_cd_ivory = contrast_ratio(cd_light, ivory_light)
|
||||
@@ -323,9 +386,6 @@ def test_color_contrast_gate():
|
||||
print(f"❌ AC-45 Violation: Light/Dark vermillion-dark inversion contrast too low ({inversion_ratio_vd:.2f} < 2.0)")
|
||||
return False
|
||||
|
||||
# AC-44: Verify text-cobalt or text-vermillion (DEFAULT) is not combined with small text sizes
|
||||
# AC-47: Verify text-white utility usage <= 8 instances
|
||||
# C11-9 (AC-48): Verify zero dark: modifier usage on accent colors (R-8.29)
|
||||
white_count = 0
|
||||
small_size_pattern = re.compile(r'text-(xs|sm|\[0\.65rem\]|\[0\.78rem\])')
|
||||
default_color_pattern = re.compile(r'text-(cobalt|vermillion)(?![a-z-])')
|
||||
@@ -339,18 +399,14 @@ def test_color_contrast_gate():
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# AC-47 count
|
||||
white_count += content.count("text-white")
|
||||
|
||||
# AC-44 & C11-9 check line by line
|
||||
for line_idx, line in enumerate(content.splitlines(), start=1):
|
||||
# C11-9: Zero dark: modifier on accent colors (allow dark:text-ivory / dark:group-open:text-ivory exceptions)
|
||||
if dark_accent_pattern.search(line):
|
||||
print(f"❌ C11-9 (AC-48) Violation: Forbidden dark: modifier on accent color at {fpath}:{line_idx}\n {line.strip()}")
|
||||
return False
|
||||
|
||||
if small_size_pattern.search(line) and default_color_pattern.search(line):
|
||||
# Ignore allowed exceptions (e.g. HeroComposition, Pillar bullets, or explicitly exempted titles)
|
||||
if "Pillar #" in line or "w-1.5 h-1.5" in line or "text-3xl" in line:
|
||||
continue
|
||||
print(f"❌ AC-44 Violation: Small text combined with DEFAULT accent color at {fpath}:{line_idx}\n {line.strip()}")
|
||||
@@ -365,7 +421,7 @@ def test_color_contrast_gate():
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print(" ANL HOMEPAGE E2E TEST SUITE RUNNER — TIERS 1-4")
|
||||
print(" ANL HOMEPAGE E2E TEST SUITE RUNNER — TIERS 1-4 (v2.0 DYNAMIC)")
|
||||
print("=" * 70)
|
||||
|
||||
success = True
|
||||
@@ -373,14 +429,33 @@ def main():
|
||||
success &= test_lint()
|
||||
success &= test_build()
|
||||
success &= test_tsc()
|
||||
success &= test_ssg_routes()
|
||||
success &= test_data_counts()
|
||||
success &= test_theme_tokens()
|
||||
success &= test_unittest_suite()
|
||||
success &= test_layout_architecture()
|
||||
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)
|
||||
|
||||
# Start live Next.js server once and run Gate 4, Gate 7, Gate 8
|
||||
server_proc = None
|
||||
try:
|
||||
server_proc = start_next_server(port=3000)
|
||||
bodies = fetch_live_routes("http://localhost:3000")
|
||||
|
||||
success &= test_live_routes(bodies)
|
||||
success &= test_unittest_suite()
|
||||
success &= test_layout_architecture(bodies)
|
||||
finally:
|
||||
if server_proc:
|
||||
print("\nShutting down live Next.js 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).")
|
||||
|
||||
Reference in New Issue
Block a user