diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 00bc7af..5971bed 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -4,10 +4,10 @@ | 항목 | 내용 | | :--- | :--- | | **문서 ID** | `REQ-ANL-WEB-001` | -| **버전** | `1.14` | -| **작성일** | 2026-07-30 | -| **작성자** | claude (`canary-projects-landing-page-creator-claude`) · Role: planner | -| **상태** | 승인 (Approved) — v1.5에서 **D-01** 확정(A·B안 기각 / `intl-conf` 폐기 · 3개 카테고리), v1.6에서 **D-06** 확정(레이아웃 컨테이너 3계층 분리). v1.7에서 **§6.1.2 Research Project PageView** 신설 — 신규 미결 **D-07**(🔴 Funder 실데이터 부재)·**D-08**(🟡 프로젝트 집합 범위)은 각 기본값으로 구현을 차단하지 않는다. 잔여 미결 D-02·D-03·D-05는 🟡 등급으로 각 기본값이 적용되어 승인을 보류하지 않는다. v1.9에서 **§8.7 색 토큰 알파 계약**(R-8.21~R-8.24)을 신설하고 **D-09**(Publications 인덱스 라우트)를 확정했다. v1.10에서 개발 팀장(Agy) 이의제기를 반영해 **D-09를 Overview 뷰로 재확정**하고 R-8.25·AC-41을 신설했다. v1.11에서 강조색 교체 커밋(`eaddc4f`)의 대비 붕괴를 규명해 **§8.7에 R-8.26~R-8.28**(강조색 대비 바닥값 · 색값 변경 재측정 의무 · 명암 반전 계약)을 신설하고 **D-10을 확정**(`--cobalt` #4A7CE8 유지 + `--cobalt-dark` #32549E 재조정), **AC-43~AC-46**(대비 게이트 Gate 10 및 그 폴트 인젝션)을 신설했다. 신규 미결 **D-11**(🟡 `text-vermillion` 소형 텍스트 30여 곳)은 현행 유지를 기본값으로 하여 구현을 차단하지 않는다. v1.12에서 개발 팀장(Agy) 이의제기(Job `7f136931`)를 반영해 **D-10의 배지·칩 전경/배경을 재확정**(`bg-cobalt text-ink` 폐기 → `bg-{accent}-dark text-ivory`)하고 **R-8.29**(반전 토큰 쌍의 `dark:` 불필요 원칙)·**AC-47**(팔레트 외 색 상한)을 신설했다. v1.13에서 Primary #4E77FF 전면 적용 커밋군(`ca4a7ee`~`082d71e`)을 검증해 **Gate 0~10 전량 통과 상태에서 AA 미달 6건·통일성 결함 9건**을 실측 확정하고, 게이트 범위 결함 3종에 대해 **R-8.30~R-8.32**(비텍스트 UI 대비 · 역할 단위 색 통일 · 게이트 면제 형식 제한)와 **AC-48~AC-50**(Gate 11 · 폴트 인젝션 F1~F9 · `docs/DESIGN.md` 동기화)을 신설했다. **§8.1을 실제 토큰값으로 동기화**하고 **D-10 확정값을 #345AD2로 개정**했다. 신규 미결 **D-12**(🔴 Primary 적용의 판정 기준 및 히어로 `Orchestration` 시맨틱 규약)는 **A안+B안 병행**을 기본값으로 하여 구현을 차단하지 않으며, **D-13**(🟡 `--vermillion` 반전 1.25)은 현행 유지 + 화이트리스트 등재를 기본값으로 한다. v1.14에서 개발 팀장(Agy) 이의제기(Job `4962a667`)를 반영해 **R-8.33**(토큰 등급 불변식)을 신설하고 **R-8.29를 강화**(강조색 `dark:` 변형 금지 · 현행 위반 38건)했으며, 다크 테마 `bg-paper` 소형 텍스트 **8곳의 AA 미달(4.37)** 과 **G-7**(AC-44 정규식이 다크 절반을 설계상 면제)을 확정해 **AC-51**을 신설했다. 신규 미결 **D-14**(🔴 `--cobalt` 반전 1.00)는 **A안(브랜드 고정 예외)** 을 기본값으로 하되 규격 완화를 포함하므로 **cline·GM 심사 전에는 집행하지 않는다** | +| **버전** | `2.0` | +| **작성일** | 2026-08-24 | +| **작성자** | agy (`herdr:creator-agy-01`) / claude (`herdr:planner-reviewer-claude-01`) · Role: worker / planner | +| **상태** | 승인 (Approved) — v2.0에서 **DM-03 및 AC-17 개정**(Go/Gin 백엔드 REST API 연동 및 `force-dynamic` + `cache: 'no-store'` 기반 실시간 동적 런타임 페칭 도입, 6개 데이터 라우트 `ƒ Dynamic` 및 4개 프레임워크 라우트 `○ Static` 분리 확정). | | **대상 독자** | General Manager, Developer Team Leader(Agy), Reviewer Team Leader(Cline), 지도교수 | | **선행 문서** | [`README.md`](./README.md), [`.agents/MULTI_AGENT_RULES.md`](./.agents/MULTI_AGENT_RULES.md), [`refer_landing_page/docs/DESIGN.md`](./refer_landing_page/docs/DESIGN.md) | | **적용 범위** | `refer_landing_page/` (Next.js 14 App Router 애플리케이션) 전체 | @@ -287,7 +287,7 @@ Home의 슬로건 히어로를 **실제 연구 프로젝트 카드 뷰**로 전 | :--- | :--- | :-- | | DM-01 | 콘텐츠 데이터는 페이지 컴포넌트에서 분리해 **`content/` 디렉터리의 타입 지정 모듈**로 이관한다 (`content/members.ts`, `content/publications.ts`, `content/lectures.ts`, `content/standards.ts`) | MUST | | DM-02 | 각 모듈은 명시적 TypeScript 인터페이스를 export 한다. `any` 사용 금지 | MUST | -| DM-03 *(v1.1 문구 정정)* | 데이터는 **빌드 타임 정적 상수**로 유지한다. 런타임 fetch·외부 API·DB를 도입하지 않는다. 전 라우트가 **빌드 타임 정적 프리렌더**(`○ Static` 또는 `● SSG`)를 유지해야 하며, `ƒ Dynamic` 라우트가 발생해서는 안 된다 (§8.5 R-8.16) | MUST | +| DM-03 *(v2.0 개정)* | 데이터는 **실시간 동적 런타임 페칭(`force-dynamic`, `cache: 'no-store'`)**으로 백엔드 API(`http://localhost:8080/api/v1`)로부터 조회한다. `content/*.ts` 모듈은 백엔드 미가동/장애 시의 **그레이스풀 폴백(Fallback) 데이터셋**으로 유지한다. 6개 데이터 라우트는 `ƒ Dynamic`으로 렌더링되며, 4개 프레임워크 라우트는 `○ Static`을 유지한다 (§8.5 R-8.16, §10 AC-17) | MUST | | DM-04 | 각 페이지 상단의 `// CUSTOMIZATION HOOK` 주석 규약(`DESIGN.md` "콘텐츠 우선" 원칙)은 `content/` 모듈로 이전하되 **규약 자체는 유지**한다 | MUST | ### 7.2 스키마 정의 (계약) @@ -648,7 +648,7 @@ Job `343b5808` 검증에서 본문 좌우 여백·최대 폭 미적용(B1)이, **품질 게이트** - [ ] AC-15 `npm run lint` 통과 (경고 0) - [ ] AC-16 `npx tsc --noEmit` 통과 -- [ ] AC-17 *(v1.1 개정)* `npm run build` 성공. 빌드 리포트에 **`ƒ (Dynamic)` 라우트가 0건**이고 전 라우트가 `○ (Static)` 또는 `● (SSG)`이다 — `●`도 빌드 타임 정적 프리렌더이므로 충족으로 판정한다 (§8.5 R-8.16) +- [ ] AC-17 *(v2.0 개정)* `npm run build` 성공. 빌드 리포트에 **정확히 6개 데이터 라우트가 `ƒ (Dynamic)`**(`force-dynamic`)이고 **4개 프레임워크/메타데이터 라우트가 `○ (Static)`**으로 분리되어 생성됨을 확인한다 (§7.1 DM-03) - [ ] AC-18 First Load JS 증가분 ≤ 30 kB - [ ] AC-19 4종 뷰포트에서 가로 스크롤 없음 - [ ] AC-20 신규 Tailwind 반응형 유틸리티가 빌드 CSS에 실제 생성됨을 확인 (JIT 누락 시 빌드는 성공하나 조판이 무너짐) @@ -1049,3 +1049,4 @@ G-1은 **N6**(Gate 8 C1/C2 공허 단정, 10라운드 미해소)과 **동일 유 | 1.12 | 2026-07-31 | claude (planner) | 개발 팀장(Agy) 기술 이의제기(Job `7f136931`) 반영. **D-10 배지·칩 결정 재확정** — v1.11의 `bg-cobalt text-ink dark:text-ivory`(5.03 / 4.82)는 채택안 C의 원칙(작은 요소는 `-dark` 라우팅)을 **소형 텍스트 12곳에만 적용하고 동일 크기 등급의 상태 칩 2곳에는 적용하지 않은 일관성 결함**이었음을 인정하고 폐기, **`bg-{accent}-dark text-ivory`** 로 교체(6.38~6.43 / 6.57~6.72). 이의제기의 권장 전경 `text-white dark:text-ivory`(7.25)는 **미채택** — `-dark` 배경에서는 `ivory`만으로 양 테마가 통과해 **팔레트 외 색 `white`와 `dark:` 변형이 모두 불필요**하다(**R-8.29 신설**). 아울러 권장안을 그대로 적용하면 `standardization:87-89` 삼항식의 나란한 두 칩 상대휘도 대비가 **1.19 → 1.54로 악화**됨을 실측해, vermillion 칩도 `-dark`로 통일하여 **1.01** 균형을 달성(**R-8.26에 배지·칩 라우팅 및 짝 칩 등급 일치 조항 추가**). 배지류를 **부류 A(`text-[0.65rem]` 상태 칩 4곳) / 부류 B(인터랙티브 내비·토글 5곳)** 로 분리해 **AC-42 개정**, `-dark`가 **배경**으로 쓰이는 조합을 **AC-43 검사 대상에 추가**(v1.11 목록은 전경만 다루어 칩 배경을 놓쳤다), **AC-47 신설**(팔레트 외 색 상한 8건 화이트리스트), **AC-46에 폴트 인젝션 ⑤·⑥ 추가**(폐기된 v1.11 칩 패턴 및 `white` 증가 검출). 부류 A vermillion 칩 2곳의 배경이 #D9342B → #A8231C로 어두워지는 **시각 변경은 GM 승인 사항으로 명시**하되 차단 사유가 아님을 기록. Job `3d3b823a` | | 1.13 | 2026-07-31 | claude (planner) | 테마 색상 통일성 검증 계획 수립 (`eaddc4f`→`082d71e` 4개 커밋). **Gate 0~10 전량 통과·빌드 exit 0인 상태에서 WCAG AA 미달 6건·통일성 결함 9건이 실재**함을 실측으로 확정 — 게이트 통과가 곧 오탐 신호였다. 원인은 게이트 **범위 결함 3종**: ⓐ **AC-44의 vermillion 절반이 구조적으로 무효**(면제 조항 `"text-vermillion" in line`이 판정 정규식과 항상 동시 매치 — 실소스 4라인 + 주입 2건으로 재현), ⓑ AC-45가 `-dark` 2종만 검사해 **`--cobalt` 반전 대비 1.00**(D-10 근본 원인의 재발)을 미검출, ⓒ AC-43이 소스의 전경/배경 쌍을 보지 않아 `bg-cobalt-dark text-white`(다크 2.85)를 통과. **R-8.30 신설**(비텍스트 UI 3:1 · 알파는 합성 후 산정 — `focus:ring-cobalt/50` **1.80** · `hover:border-cobalt/60` **1.95** 검출), **R-8.31 신설**(역할 시그니처 단위 색 통일 — 괘선 6:1 · 키커 11:1 · 배지 전경 6:4 · hover 테두리 4종 · 포커스 2종, 지도교수 이메일 1값이 3색 3대비), **R-8.32 신설**(게이트 면제는 `(파일,행,사유)` 등재만 허용 · 라인 부분문자열 면제 금지 — **N6과 동일 유형**). **R-8.5·R-8.26·R-8.28 개정**, **AC-42·43·44·45·47 개정**, **AC-48~AC-50 신설**(Gate 11 C11-1~8 · 폴트 인젝션 F1~F9 · `docs/DESIGN.md` 동기화 4건). **§8.1 색상표를 실제 토큰값 `#4E77FF`/`#345AD2`로 동기화**하고 **D-10 확정값을 #345AD2로 개정**(paper 4.76, 여유 0.26 → R-12). **D-12 신설**(🔴 Primary 적용의 판정 기준 — 라이트 테마에서 #4E77FF가 정적으로 보이는 곳이 **2곳뿐**이며 인지색은 #345AD2. 히어로 `Orchestration`이 R-8.5·DESIGN.md §2를 위반해 cobalt로 변경되어 한 페이지 안에서 규약이 분열), **D-13 신설**(🟡 `--vermillion` 반전 1.25). **A-1 `lectures:89` 4.16 미교정 잔존**·**A-2 `not-found.tsx:22` 4.16 신규 발견**(어떤 AC에도 없던 항목)·**A-7 `::selection` 4.16 미등재** 확정. **D-11 실측 갱신**(23건, 최저 3.77). 색 교체 5회 연속 대비표 미첨부(R-10). Job `43f3f74a` | | 1.14 | 2026-07-31 | claude (planner) | 개발 팀장(Agy) 기술 이의제기(Job `4962a667`) 반영. **구조적 통찰 채택** — 다크 `--cobalt`만 단독 상향하면 다크 지면 대비가 DEFAULT 9.2 > `-dark` 5.96으로 **계층이 역전**됨을 실측 확인, v1.13 §7 각주의 누락을 인정하고 **R-8.33 신설**(토큰 등급 불변식). 다만 불변식을 **절대 휘도 순서가 아니라 테마 상대 대비**로 정식화 — 절대 순서의 테마 간 뒤집힘은 vermillion 쌍에서도 동일한 **정상 동작**이며 현행 8개 셀 전부에서 성립하므로, 절대 휘도로 판정하면 정상 상태를 오판한다. **제안 수치 미채택** — 주 권장값 `#85ACFF`의 반전 대비는 **1.73**으로 조정을 촉발한 R-8.28(≥2.0) 자체를 충족하지 못하며, 제안 표의 `#345AD2` L=0.095는 **폐기된 #32549E의 값**(실제 0.1270)이라 `#B0CDFF` 반전 대비 2.50도 실제 **3.68**이다. **이의제기 지점에서 더 큰 결함 발굴** — 다크 `--cobalt`(#4D77FF)는 `bg-paper` 위 **4.37**이므로 v1.12가 지시한 `text-cobalt-dark dark:text-cobalt` 라우팅이 **다크 절반을 DEFAULT로 되돌려 8곳이 AA 미달**이다(**AC-51 신설**). 원인은 R-8.26의 "`paper` 기준 산정" 원칙을 **본 계획자가 라이트 테마에만 적용한 오류**로, v1.11 §6.5와 같은 형태의 누락이다. 게이트도 AC-44 정규식의 **부정 후방탐색 `(? = { Professor: "Professor", PhD: "Ph. D.", diff --git a/refer_landing_page/app/page.tsx b/refer_landing_page/app/page.tsx index 3af860f..8a2d6ba 100644 --- a/refer_landing_page/app/page.tsx +++ b/refer_landing_page/app/page.tsx @@ -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)", diff --git a/refer_landing_page/app/publications/[category]/page.tsx b/refer_landing_page/app/publications/[category]/page.tsx index 4ad2a28..0d6366f 100644 --- a/refer_landing_page/app/publications/[category]/page.tsx +++ b/refer_landing_page/app/publications/[category]/page.tsx @@ -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; diff --git a/refer_landing_page/app/publications/page.tsx b/refer_landing_page/app/publications/page.tsx index c75b58a..8c28a2a 100644 --- a/refer_landing_page/app/publications/page.tsx +++ b/refer_landing_page/app/publications/page.tsx @@ -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; diff --git a/refer_landing_page/app/standardization/page.tsx b/refer_landing_page/app/standardization/page.tsx index 77c05d6..c62b3cb 100644 --- a/refer_landing_page/app/standardization/page.tsx +++ b/refer_landing_page/app/standardization/page.tsx @@ -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 ( diff --git a/refer_landing_page/lib/api.ts b/refer_landing_page/lib/api.ts index d839c5d..f022ce6 100644 --- a/refer_landing_page/lib/api.ts +++ b/refer_landing_page/lib/api.ts @@ -144,6 +144,7 @@ async function apiGet(path: string): Promise { const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); const res = await fetch(`${API_BASE_URL}${path}`, { signal: controller.signal, + cache: "no-store", }); clearTimeout(timeout); diff --git a/refer_landing_page/scripts/run_e2e_tests.py b/refer_landing_page/scripts/run_e2e_tests.py index 088ecb8..90c3872 100755 --- a/refer_landing_page/scripts/run_e2e_tests.py +++ b/refer_landing_page/scripts/run_e2e_tests.py @@ -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
tag exists and does NOT contain max-w- or px- main_match = re.search(r']*)?>(.*?)
', html, flags=re.S) if not main_match: - print(f"❌
tag not found in {rel_p}") + print(f"❌
tag not found in {route}") return False main_inner = main_match.group(1) # C1: Verify child wrapper inside
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
in {rel_p}") + if "container-content" not in main_inner: + print(f"❌ container-content missing inside
in {route}") return False - # AC-41: Check for duplicate
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
content found between {rel_p} and {main_hashes[norm_content]}") - return False - main_hashes[norm_content] = rel_p + # AC-41: Check for duplicate
normalized content hashes + norm_content = re.sub(r'\s+', '', main_inner) + if norm_content in main_hashes: + print(f"❌ AC-41 Violation: Duplicate
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).") diff --git a/refer_landing_page/tests/e2e_test_suite.py b/refer_landing_page/tests/e2e_test_suite.py index 3528258..4218034 100644 --- a/refer_landing_page/tests/e2e_test_suite.py +++ b/refer_landing_page/tests/e2e_test_suite.py @@ -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):