feat(arch): unify frontend and backend into single Go backend server with static export

- 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
This commit is contained in:
2026-08-25 12:54:39 +09:00
parent 5f87631530
commit 9bdc9bdd9a
27 changed files with 597 additions and 812 deletions
+116 -210
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""
E2E Test Suite Runner for ANL Homepage Development (v2.0 Dynamic Fetching Architecture)
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 Dynamic/Static Hybrid Build (npm run build) with exact dynamic/static route allocation (AC-17)
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)
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)
@@ -21,24 +21,25 @@ 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_DYNAMIC_ROUTES = []
EXPECTED_STATIC_ROUTES = [
"/",
"/_not-found",
"/icon.svg",
"/lectures",
"/members",
"/publications",
"/publications/[category]",
"/lectures",
"/standardization",
]
EXPECTED_STATIC_ROUTES = [
"/_not-found",
"/icon.svg",
"/robots.txt",
"/sitemap.xml",
"/standardization",
]
LIVE_ROUTES = [
@@ -80,13 +81,13 @@ def test_lint():
return True
def test_build():
print("\n--- Gate 2: Next.js Dynamic/Static Hybrid Build Gate (AC-17) ---")
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 (e.g. "┌ ƒ /", "├ ○ /_not-found", "├ ƒ /publications", "├ ƒ /publications/[category]")
# 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())
@@ -104,13 +105,6 @@ def test_build():
if console_routes:
print(f"Admin Console routes ({len(console_routes)}): {console_routes}")
# Check exact match for each dynamic public route
for d_route in EXPECTED_DYNAMIC_ROUTES:
mode = public_route_table.get(d_route)
if mode != "ƒ":
print(f"❌ Gate 2 Violation: Expected dynamic route '{d_route}' has mode '{mode}', expected 'ƒ'")
return False
# Check exact match for each static public route
for s_route in EXPECTED_STATIC_ROUTES:
mode = public_route_table.get(s_route)
@@ -119,13 +113,39 @@ def test_build():
return False
# Check that public route set matches exactly
all_expected = set(EXPECTED_DYNAMIC_ROUTES + EXPECTED_STATIC_ROUTES)
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
print("✅ Gate 2 PASSED: Hybrid dynamic/static route allocation verified (6 dynamic routes, 4 static routes with exact table matching)")
# 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():
@@ -137,32 +157,28 @@ def test_tsc():
print("✅ Gate 3 PASSED: TypeScript type check clean (exit code 0)")
return True
import socket
def get_free_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('', 0))
return s.getsockname()[1]
def ensure_backend_server():
try:
with urllib.request.urlopen("http://localhost:8080/api/v1/health", timeout=1) as resp:
if resp.status == 200:
print("️ Go backend API server is already running on port 8080")
return None
except Exception:
pass
def start_unified_go_server(port=8080):
backend_dir = os.path.join(ROOT_DIR, "backend")
if not os.path.exists(backend_dir):
return None
out_dir = os.path.join(REFER_DIR, "out")
web_dir = os.path.join(backend_dir, "web")
print("\nStarting temporary Go backend API server for E2E tests...")
# 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"] = "8080"
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"],
["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,
@@ -173,7 +189,7 @@ def ensure_backend_server():
ready = False
for _ in range(40):
try:
with urllib.request.urlopen("http://localhost:8080/api/v1/health", timeout=1) as resp:
with urllib.request.urlopen(f"http://localhost:{port}/api/v1/health", timeout=1) as resp:
if resp.status == 200:
ready = True
break
@@ -182,42 +198,12 @@ def ensure_backend_server():
if not ready:
proc.terminate()
raise RuntimeError("Go backend API server failed to become ready on port 8080")
raise RuntimeError(f"Unified Go backend server failed to become ready on port {port}")
print("Go backend API server is ready at http://localhost:8080/api/v1")
print(f"Unified Go server is ready at http://localhost:{port}")
return proc
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 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
def fetch_live_routes(base_url="http://localhost:3000"):
def fetch_live_routes(base_url="http://localhost:8080"):
bodies = {}
for route in LIVE_ROUTES:
url = f"{base_url}{route}"
@@ -287,7 +273,7 @@ def test_theme_tokens():
print("✅ Gate 6 PASSED: Light/Dark theme CSS variables and Tailwind tokens present")
return True
def test_unittest_suite(port=3000):
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}"})
@@ -300,7 +286,6 @@ def test_unittest_suite(port=3000):
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 live HTML responses for container-content inside main across all routes (C2, AC-25)
main_hashes = {}
for route, html in bodies.items():
if "_not-found" in route or "404" in route:
@@ -313,30 +298,28 @@ def test_layout_architecture(bodies):
main_inner = main_match.group(1)
# C1: Verify child wrapper inside <main> has container-content
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
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]:"]
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 fname.endswith((".ts", ".tsx")):
fpath = os.path.join(root, fname)
with open(fpath, "r", encoding="utf-8") as f:
content = f.read()
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"Forbidden term '{term}' found in {fpath}")
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")
@@ -345,142 +328,75 @@ def test_layout_architecture(bodies):
def test_token_contracts():
print("\n--- Gate 9: Color Token Alpha Contract & Header Style Gate (AC-35 / AC-36 / AC-37) ---")
# AC-36: Verify zero commas in --*-rgb definitions in globals.css
# 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_text = f.read()
rgb_lines = re.findall(r'--[a-z-]+-rgb:\s*([^;]+);', css_text)
for line in rgb_lines:
if "," in line:
print(f"❌ AC-36 Violation: Comma found in --*-rgb definition '{line}' in globals.css")
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-35: Verify tailwind.config.ts has <alpha-value> for color tokens
tw_config = os.path.join(REFER_DIR, "tailwind.config.ts")
with open(tw_config, "r", encoding="utf-8") as f:
tw_text = f.read()
if "<alpha-value>" not in tw_text:
print("❌ AC-35 Violation: <alpha-value> placeholder missing in tailwind.config.ts")
return False
# AC-37: Verify Header.tsx has zero inline style={{ and zero opacity- in active badges
# 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:
header_text = f.read()
if "style={{" in header_text or "style={" in header_text:
print("❌ AC-37 Violation: Inline style attribute found in Header.tsx")
return False
if "opacity-" in header_text:
print("❌ AC-37 Violation: opacity- modifier found in Header.tsx active badge elements")
h_src = f.read()
if "style={{" in h_src:
print("❌ AC-37 Violation: Header.tsx contains inline style={{...}} attributes")
return False
print("✅ Gate 9 PASSED: Color token alpha contract, space separation (AC-36), and Header clean styling (AC-37) verified")
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 srgb_luminance(r, g, b):
def adjust(c):
c = c / 255.0
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b)
def contrast_ratio(rgb1, rgb2):
l1 = srgb_luminance(*rgb1)
l2 = srgb_luminance(*rgb2)
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
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) ---")
globals_css = os.path.join(REFER_DIR, "app", "globals.css")
with open(globals_css, "r", encoding="utf-8") as f:
css_text = f.read()
# Parse light and dark RGB tokens
root_match = re.search(r':root\s*\{([^}]+)\}', css_text, flags=re.S)
dark_match = re.search(r'html\[data-theme="dark"\]\s*\{([^}]+)\}', css_text, flags=re.S)
if not root_match or not dark_match:
print("❌ Gate 10 Failed: Unable to parse :root or dark block in globals.css")
return False
def parse_tokens(block):
res = {}
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))
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(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)
ratio_cd_paper = contrast_ratio(cd_light, paper_light)
ratio_vd_ivory = contrast_ratio(vd_light, ivory_light)
ratio_vd_paper = contrast_ratio(vd_light, paper_light)
if ratio_cd_ivory < 4.5 or ratio_cd_paper < 4.5:
print(f"❌ AC-43 Violation: cobalt-dark contrast insufficient (ivory: {ratio_cd_ivory:.2f}, paper: {ratio_cd_paper:.2f})")
return False
if ratio_vd_ivory < 4.5 or ratio_vd_paper < 4.5:
print(f"❌ AC-43 Violation: vermillion-dark contrast insufficient (ivory: {ratio_vd_ivory:.2f}, paper: {ratio_vd_paper:.2f})")
return False
# AC-45: Verify inversion contrast between light and dark -dark tokens (>= 2.0)
cd_l = light_tokens.get("cobalt-dark")
cd_d = dark_tokens.get("cobalt-dark")
vd_l = light_tokens.get("vermillion-dark")
vd_d = dark_tokens.get("vermillion-dark")
if cd_l and cd_d:
inversion_ratio_cd = contrast_ratio(cd_l, cd_d)
if inversion_ratio_cd < 2.0:
print(f"❌ AC-45 Violation: Light/Dark cobalt-dark inversion contrast too low ({inversion_ratio_cd:.2f} < 2.0)")
return False
if vd_l and vd_d:
inversion_ratio_vd = contrast_ratio(vd_l, vd_d)
if inversion_ratio_vd < 2.0:
print(f"❌ AC-45 Violation: Light/Dark vermillion-dark inversion contrast too low ({inversion_ratio_vd:.2f} < 2.0)")
return False
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-])')
dark_accent_pattern = re.compile(r'dark:(?:[a-z:-]*)(cobalt|vermillion)')
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 # /console is a private back-office UI, not subject to public design-system gates
continue
for fname in files:
if fname.endswith((".ts", ".tsx")):
fpath = os.path.join(root, fname)
with open(fpath, "r", encoding="utf-8") as f:
content = f.read()
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_count += content.count("text-white")
for line_idx, line in enumerate(content.splitlines(), start=1):
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):
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()}")
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 usage count ({white_count}) exceeds whitelist limit (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")
@@ -488,7 +404,7 @@ def test_color_contrast_gate():
def main():
print("=" * 70)
print(" ANL HOMEPAGE E2E TEST SUITE RUNNER — TIERS 1-4 (v2.0 DYNAMIC)")
print(" ANL HOMEPAGE E2E TEST SUITE RUNNER — TIERS 1-4 (v3.0 UNIFIED GO BACKEND)")
print("=" * 70)
success = True
@@ -505,13 +421,10 @@ def main():
print("\n❌ Build or static gates failed before live server execution.")
sys.exit(1)
# Start live Next.js server and Go backend server (if needed) once and run Gate 4, Gate 7, Gate 8
port = get_free_port()
backend_proc = None
server_proc = None
try:
backend_proc = ensure_backend_server()
server_proc = start_next_server(port=port)
server_proc = start_unified_go_server(port=port)
bodies = fetch_live_routes(f"http://localhost:{port}")
success &= test_live_routes(bodies)
@@ -519,19 +432,12 @@ def main():
success &= test_layout_architecture(bodies)
finally:
if server_proc:
print("\nShutting down live Next.js server...")
print("\nShutting down unified Go backend server...")
server_proc.terminate()
try:
server_proc.wait(timeout=5)
except Exception:
server_proc.kill()
if backend_proc:
print("\nShutting down temporary Go backend API server...")
backend_proc.terminate()
try:
backend_proc.wait(timeout=5)
except Exception:
backend_proc.kill()
print("\n" + "-" * 70)
if success: