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:
@@ -8,6 +8,8 @@ export const metadata: Metadata = {
|
||||
description: "AI Agent Networking Lab (ANL) lecture courses and curriculum history",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function LecturesPage() {
|
||||
const semesters = (await getSemesters()) ?? staticSemesters;
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ export const metadata: Metadata = {
|
||||
description: "AI Agent Networking Lab (ANL) professor, active researchers, and alumni directory",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DEGREE_LABEL: Record<string, string> = {
|
||||
Professor: "Professor",
|
||||
PhD: "Ph. D.",
|
||||
|
||||
@@ -7,6 +7,8 @@ import { standardsBodies as staticStandards } from "../content/standards";
|
||||
import { researchProjects as staticProjects } from "../content/projects";
|
||||
import { getStatsSummary, getResearchAreaNames, getResearchProjects } from "../lib/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// CUSTOMIZATION HOOK: 14 Core Research Areas
|
||||
const RESEARCH_AREAS: string[] = [
|
||||
"Quick UDP Internet Connection (QUIC)",
|
||||
|
||||
@@ -24,12 +24,7 @@ export function generateMetadata({ params }: PageProps): Metadata {
|
||||
};
|
||||
}
|
||||
|
||||
// SSG static pre-rendering (● SSG) for Next.js 14
|
||||
export function generateStaticParams() {
|
||||
return PUB_CATEGORIES.map((cat) => ({
|
||||
category: cat.slug,
|
||||
}));
|
||||
}
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PublicationCategoryPage({ params }: PageProps) {
|
||||
const { category } = params;
|
||||
|
||||
@@ -8,6 +8,8 @@ export const metadata: Metadata = {
|
||||
description: "AI Agent Networking Lab (ANL) research publications and patents overview",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PublicationsIndexPage() {
|
||||
const publications = (await getPublications()) ?? staticPubs;
|
||||
const patents = (await getPatents()) ?? staticPatents;
|
||||
|
||||
@@ -8,6 +8,8 @@ export const metadata: Metadata = {
|
||||
description: "AI Agent Networking Lab (ANL) international standardization research and contributions",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function StandardizationPage() {
|
||||
const standardsBodies = (await getStandardsBodies()) ?? staticStandards;
|
||||
return (
|
||||
|
||||
@@ -144,6 +144,7 @@ async function apiGet<T>(path: string): Promise<T | null> {
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
|
||||
@@ -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).")
|
||||
|
||||
@@ -1,35 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Python Unittest Suite for ANL Homepage E2E Verification
|
||||
Parses prerendered SSG HTML files to assert DOM structure, text content, and theme attributes.
|
||||
Queries live running Next.js server to assert DOM structure, text content, and theme attributes.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import subprocess
|
||||
import urllib.request
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SERVER_APP_DIR = os.path.join(BASE_DIR, ".next", "server", "app")
|
||||
if not os.path.exists(SERVER_APP_DIR):
|
||||
for candidate in [
|
||||
os.path.join(os.getcwd(), "refer_landing_page", ".next", "server", "app"),
|
||||
os.path.join(os.getcwd(), "website", ".next", "server", "app"),
|
||||
os.path.join(os.getcwd(), ".next", "server", "app"),
|
||||
]:
|
||||
if os.path.exists(candidate):
|
||||
SERVER_APP_DIR = candidate
|
||||
break
|
||||
TEST_SERVER_URL = os.environ.get("TEST_SERVER_URL", "http://localhost:3000")
|
||||
|
||||
class TestANLHomepageE2E(unittest.TestCase):
|
||||
_server_proc = None
|
||||
_html_cache = {}
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Check if server is already responding at TEST_SERVER_URL
|
||||
is_ready = False
|
||||
try:
|
||||
with urllib.request.urlopen(f"{TEST_SERVER_URL}/", timeout=1) as resp:
|
||||
if resp.status == 200:
|
||||
is_ready = True
|
||||
except Exception:
|
||||
is_ready = False
|
||||
|
||||
if not is_ready:
|
||||
# Spawn local next start server
|
||||
cls._server_proc = subprocess.Popen(
|
||||
["npm", "run", "start"],
|
||||
cwd=BASE_DIR,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
for _ in range(30):
|
||||
try:
|
||||
with urllib.request.urlopen(f"{TEST_SERVER_URL}/", timeout=1) as resp:
|
||||
if resp.status == 200:
|
||||
is_ready = True
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
|
||||
if not is_ready:
|
||||
cls._server_proc.terminate()
|
||||
raise RuntimeError(f"Next.js server failed to become ready at {TEST_SERVER_URL}")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
if cls._server_proc:
|
||||
cls._server_proc.terminate()
|
||||
try:
|
||||
cls._server_proc.wait(timeout=5)
|
||||
except Exception:
|
||||
cls._server_proc.kill()
|
||||
|
||||
def get_route_html(self, route):
|
||||
if route in self._html_cache:
|
||||
return self._html_cache[route]
|
||||
url = f"{TEST_SERVER_URL}{route}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "E2ETestRunner"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
html = resp.read().decode("utf-8")
|
||||
self._html_cache[route] = html
|
||||
return html
|
||||
|
||||
def read_html(self, relative_path):
|
||||
full_path = os.path.join(SERVER_APP_DIR, relative_path)
|
||||
self.assertTrue(os.path.exists(full_path), f"File {relative_path} does not exist")
|
||||
with open(full_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
route_map = {
|
||||
"index.html": "/",
|
||||
"members.html": "/members",
|
||||
"publications.html": "/publications",
|
||||
os.path.join("publications", "intl-journal-conf.html"): "/publications/intl-journal-conf",
|
||||
"publications/intl-journal-conf.html": "/publications/intl-journal-conf",
|
||||
os.path.join("publications", "domestic-journal-conf.html"): "/publications/domestic-journal-conf",
|
||||
"publications/domestic-journal-conf.html": "/publications/domestic-journal-conf",
|
||||
os.path.join("publications", "patent.html"): "/publications/patent",
|
||||
"publications/patent.html": "/publications/patent",
|
||||
"lectures.html": "/lectures",
|
||||
"standardization.html": "/standardization",
|
||||
}
|
||||
route = route_map.get(relative_path, f"/{relative_path}")
|
||||
return self.get_route_html(route)
|
||||
|
||||
def test_01_home_page_structure(self):
|
||||
"""TC-T1-F3: Verify Home page SSG HTML content and semantic structures."""
|
||||
"""TC-T1-F3: Verify Home page HTML content and semantic structures."""
|
||||
html = self.read_html("index.html")
|
||||
|
||||
# 1. Brand & Header titles
|
||||
@@ -66,7 +125,7 @@ class TestANLHomepageE2E(unittest.TestCase):
|
||||
self.assertIn("Project Editor", html)
|
||||
|
||||
def test_03_publications_category_routes(self):
|
||||
"""TC-T1-F5: Verify Publications categories SSG static pages."""
|
||||
"""TC-T1-F5: Verify Publications categories live dynamic pages."""
|
||||
cats = [
|
||||
("intl-journal-conf.html", "International Journal"),
|
||||
("domestic-journal-conf.html", "Domestic Journal"),
|
||||
@@ -91,8 +150,6 @@ class TestANLHomepageE2E(unittest.TestCase):
|
||||
def test_06_theme_css_variables(self):
|
||||
"""TC-T1-F2: Verify globals.css and built assets contain theme tokens."""
|
||||
globals_path = os.path.join(BASE_DIR, "app", "globals.css")
|
||||
if not os.path.exists(globals_path):
|
||||
globals_path = os.path.join(os.getcwd(), "refer_landing_page", "app", "globals.css")
|
||||
with open(globals_path, "r", encoding="utf-8") as f:
|
||||
css = f.read()
|
||||
|
||||
@@ -100,7 +157,7 @@ class TestANLHomepageE2E(unittest.TestCase):
|
||||
self.assertIn(token, css, f"Missing CSS variable {token} in globals.css")
|
||||
|
||||
def test_07_research_projects_pageview(self):
|
||||
"""TC-T1-F7: Verify Research Project PageView content in Home static SSG HTML (AC-27 / AC-31)."""
|
||||
"""TC-T1-F7: Verify Research Project PageView content in Home HTML."""
|
||||
html = self.read_html("index.html")
|
||||
|
||||
# 1. Project PageView Title & Projects
|
||||
@@ -118,7 +175,7 @@ class TestANLHomepageE2E(unittest.TestCase):
|
||||
self.assertIn("IEC TC100: Multimedia Systems and Equipment", html)
|
||||
self.assertIn("ISO/IEC JTC1/SC6: Reliable Multicasting", html)
|
||||
|
||||
# 4. Assert zero placeholder Funder labels (AC-31)
|
||||
# 4. Assert zero placeholder Funder labels
|
||||
self.assertNotIn("Funder:", html)
|
||||
|
||||
def test_08_publications_highlights_and_category_nav(self):
|
||||
|
||||
Reference in New Issue
Block a user