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:
2026-08-24 19:25:26 +09:00
parent 9cb6258d3d
commit 5041ab1ea8
10 changed files with 260 additions and 121 deletions
+2
View File
@@ -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;
+2
View File
@@ -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.",
+2
View File
@@ -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 (
+1
View File
@@ -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);
+163 -88
View File
@@ -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).")
+78 -21
View File
@@ -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):