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
+7 -6
View File
@@ -4,10 +4,10 @@
| 항목 | 내용 | | 항목 | 내용 |
| :--- | :--- | | :--- | :--- |
| **문서 ID** | `REQ-ANL-WEB-001` | | **문서 ID** | `REQ-ANL-WEB-001` |
| **버전** | `1.14` | | **버전** | `2.0` |
| **작성일** | 2026-07-30 | | **작성일** | 2026-08-24 |
| **작성자** | claude (`canary-projects-landing-page-creator-claude`) · Role: planner | | **작성자** | agy (`herdr:creator-agy-01`) / claude (`herdr:planner-reviewer-claude-01`) · Role: worker / 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 심사 전에는 집행하지 않는다** | | **상태** | 승인 (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), 지도교수 | | **대상 독자** | 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) | | **선행 문서** | [`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 애플리케이션) 전체 | | **적용 범위** | `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-01 | 콘텐츠 데이터는 페이지 컴포넌트에서 분리해 **`content/` 디렉터리의 타입 지정 모듈**로 이관한다 (`content/members.ts`, `content/publications.ts`, `content/lectures.ts`, `content/standards.ts`) | MUST |
| DM-02 | 각 모듈은 명시적 TypeScript 인터페이스를 export 한다. `any` 사용 금지 | 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 | | DM-04 | 각 페이지 상단의 `// CUSTOMIZATION HOOK` 주석 규약(`DESIGN.md` "콘텐츠 우선" 원칙)은 `content/` 모듈로 이전하되 **규약 자체는 유지**한다 | MUST |
### 7.2 스키마 정의 (계약) ### 7.2 스키마 정의 (계약)
@@ -648,7 +648,7 @@ Job `343b5808` 검증에서 본문 좌우 여백·최대 폭 미적용(B1)이,
**품질 게이트** **품질 게이트**
- [ ] AC-15 `npm run lint` 통과 (경고 0) - [ ] AC-15 `npm run lint` 통과 (경고 0)
- [ ] AC-16 `npx tsc --noEmit` 통과 - [ ] 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-18 First Load JS 증가분 ≤ 30 kB
- [ ] AC-19 4종 뷰포트에서 가로 스크롤 없음 - [ ] AC-19 4종 뷰포트에서 가로 스크롤 없음
- [ ] AC-20 신규 Tailwind 반응형 유틸리티가 빌드 CSS에 실제 생성됨을 확인 (JIT 누락 시 빌드는 성공하나 조판이 무너짐) - [ ] 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.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.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 정규식의 **부정 후방탐색 `(?<!dark:)`로 다크 절반을 설계상 면제**하고 있었다(**G-7** — G-1·G-2·G-3에 이은 네 번째 "검사를 절반만 한다" 유형). **처방을 토큰 조정에서 `dark:` 변형 제거로 재설계** — `-dark`는 반전 토큰이므로 `dark:` 없이 4개 셀 전부(5.26/4.76/6.57/5.96)를 통과한다. **R-8.29 강화**(강조색 `dark:` 금지 · 정본 패턴 3종 · 현행 위반 **38건**), **R-8.26·AC-43·AC-44·AC-48(C11-9)·AC-49(F10·F11, 총 11건) 개정**. **D-14 신설**(🔴 `--cobalt` 반전 1.00 — A안 브랜드 고정 예외 권고 / B안 대칭 상향 정정 수치 `#A8BCFF`+`#C7D4FF`). A안은 본 계획자가 작성한 R-8.28의 완화를 포함하므로 **cline·GM 별도 심사 대상**으로 명시했고, 안전망 유지 근거로 `#1B3A8A` 역주입 시 AC-43 즉시 실패(1.80)를 제시했다. Job `d35bc3e7` | | 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 정규식의 **부정 후방탐색 `(?<!dark:)`로 다크 절반을 설계상 면제**하고 있었다(**G-7** — G-1·G-2·G-3에 이은 네 번째 "검사를 절반만 한다" 유형). **처방을 토큰 조정에서 `dark:` 변형 제거로 재설계** — `-dark`는 반전 토큰이므로 `dark:` 없이 4개 셀 전부(5.26/4.76/6.57/5.96)를 통과한다. **R-8.29 강화**(강조색 `dark:` 금지 · 정본 패턴 3종 · 현행 위반 **38건**), **R-8.26·AC-43·AC-44·AC-48(C11-9)·AC-49(F10·F11, 총 11건) 개정**. **D-14 신설**(🔴 `--cobalt` 반전 1.00 — A안 브랜드 고정 예외 권고 / B안 대칭 상향 정정 수치 `#A8BCFF`+`#C7D4FF`). A안은 본 계획자가 작성한 R-8.28의 완화를 포함하므로 **cline·GM 별도 심사 대상**으로 명시했고, 안전망 유지 근거로 `#1B3A8A` 역주입 시 AC-43 즉시 실패(1.80)를 제시했다. Job `d35bc3e7` |
| 2.0 | 2026-08-24 | claude (planner) / agy (creator) | **실시간 동적 런타임 페칭 아키텍처 도입 (DM-03·AC-17·R-08 개정)**. Go/Gin 백엔드 REST API(`http://localhost:8080/api/v1`) 연동 및 `force-dynamic` + `cache: 'no-store'` 적용으로 전 페이지 실시간 데이터 바인딩 지원. 6개 데이터 라우트를 `ƒ (Dynamic)`으로, 4개 프레임워크 라우트를 `○ (Static)`으로 분리. 기존 `content/*.ts` 정적 상수를 장애 시 그레이스풀 폴백 데이터셋으로 유지. E2E 테스트 게이트 2, 4, 7, 8을 라이브 서버 기반 검증으로 리팩터링. Job `258f6ff7` |
+2
View File
@@ -8,6 +8,8 @@ export const metadata: Metadata = {
description: "AI Agent Networking Lab (ANL) lecture courses and curriculum history", description: "AI Agent Networking Lab (ANL) lecture courses and curriculum history",
}; };
export const dynamic = "force-dynamic";
export default async function LecturesPage() { export default async function LecturesPage() {
const semesters = (await getSemesters()) ?? staticSemesters; 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", description: "AI Agent Networking Lab (ANL) professor, active researchers, and alumni directory",
}; };
export const dynamic = "force-dynamic";
const DEGREE_LABEL: Record<string, string> = { const DEGREE_LABEL: Record<string, string> = {
Professor: "Professor", Professor: "Professor",
PhD: "Ph. D.", 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 { researchProjects as staticProjects } from "../content/projects";
import { getStatsSummary, getResearchAreaNames, getResearchProjects } from "../lib/api"; import { getStatsSummary, getResearchAreaNames, getResearchProjects } from "../lib/api";
export const dynamic = "force-dynamic";
// CUSTOMIZATION HOOK: 14 Core Research Areas // CUSTOMIZATION HOOK: 14 Core Research Areas
const RESEARCH_AREAS: string[] = [ const RESEARCH_AREAS: string[] = [
"Quick UDP Internet Connection (QUIC)", "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 const dynamic = "force-dynamic";
export function generateStaticParams() {
return PUB_CATEGORIES.map((cat) => ({
category: cat.slug,
}));
}
export default async function PublicationCategoryPage({ params }: PageProps) { export default async function PublicationCategoryPage({ params }: PageProps) {
const { category } = params; const { category } = params;
@@ -8,6 +8,8 @@ export const metadata: Metadata = {
description: "AI Agent Networking Lab (ANL) research publications and patents overview", description: "AI Agent Networking Lab (ANL) research publications and patents overview",
}; };
export const dynamic = "force-dynamic";
export default async function PublicationsIndexPage() { export default async function PublicationsIndexPage() {
const publications = (await getPublications()) ?? staticPubs; const publications = (await getPublications()) ?? staticPubs;
const patents = (await getPatents()) ?? staticPatents; const patents = (await getPatents()) ?? staticPatents;
@@ -8,6 +8,8 @@ export const metadata: Metadata = {
description: "AI Agent Networking Lab (ANL) international standardization research and contributions", description: "AI Agent Networking Lab (ANL) international standardization research and contributions",
}; };
export const dynamic = "force-dynamic";
export default async function StandardizationPage() { export default async function StandardizationPage() {
const standardsBodies = (await getStandardsBodies()) ?? staticStandards; const standardsBodies = (await getStandardsBodies()) ?? staticStandards;
return ( 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 timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(`${API_BASE_URL}${path}`, { const res = await fetch(`${API_BASE_URL}${path}`, {
signal: controller.signal, signal: controller.signal,
cache: "no-store",
}); });
clearTimeout(timeout); clearTimeout(timeout);
+157 -82
View File
@@ -1,44 +1,64 @@
#!/usr/bin/env python3 #!/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: Verifies:
0. Clean step (npm run clean -> removes .next and tsconfig.tsbuildinfo) 0. Clean step (npm run clean -> removes .next and tsconfig.tsbuildinfo)
1. ESLint (npm run lint) 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) 3. TypeScript type check (npx tsc --noEmit)
4. Existence and content of static SSG routes HTML/body files 4. Live Server Route Verification (Live HTTP status 200 & content)
5. Data count losslessness & integrity gate (verify_counts.py) 5. Data count losslessness & fallback dataset integrity (verify_counts.py)
6. Light/Dark mode theme tokens and CSS attributes in source and build assets 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 os
import sys import sys
import time
import subprocess import subprocess
import re import re
import urllib.request
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 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") REFER_DIR = os.path.join(ROOT_DIR, "refer_landing_page")
# Expected Static SSG Routes HTML/Body output paths relative to REFER_DIR EXPECTED_DYNAMIC_ROUTES = [
EXPECTED_SSG_ROUTES = [ "/",
os.path.join(".next", "server", "app", "index.html"), "/members",
os.path.join(".next", "server", "app", "members.html"), "/publications",
os.path.join(".next", "server", "app", "publications.html"), "/publications/[category]",
os.path.join(".next", "server", "app", "publications", "intl-journal-conf.html"), "/lectures",
os.path.join(".next", "server", "app", "publications", "domestic-journal-conf.html"), "/standardization",
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"),
] ]
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})") 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 return res
def test_clean(): def test_clean():
@@ -60,22 +80,37 @@ def test_lint():
return True return True
def test_build(): 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"]) res = run_cmd(["npm", "run", "build"])
if res.returncode != 0: 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 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() lines = res.stdout.splitlines()
dynamic_lines = [l for l in lines if "ƒ" in l or "Dynamic" in l]
if dynamic_lines: # Verify exact dynamic routes
print(f"❌ Found dynamic route(s) in build report: {dynamic_lines}") 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 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 return True
def test_tsc(): def test_tsc():
@@ -87,35 +122,74 @@ def test_tsc():
print("✅ Gate 3 PASSED: TypeScript type check clean (exit code 0)") print("✅ Gate 3 PASSED: TypeScript type check clean (exit code 0)")
return True return True
def test_ssg_routes(): def start_next_server(port=3000):
print("\n--- Gate 4: Static SSG HTML Route Verification ---") print(f"\nStarting live Next.js server on port {port}...")
missing = [] server_env = os.environ.copy()
for rel_path in EXPECTED_SSG_ROUTES: server_env["PORT"] = str(port)
full_path = os.path.join(REFER_DIR, rel_path) proc = subprocess.Popen(
if not os.path.exists(full_path): ["npm", "run", "start"],
missing.append(rel_path) 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"):
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: else:
size = os.path.getsize(full_path) raise e
print(f" [OK] {rel_path} ({size} bytes)") return bodies
if missing: def test_live_routes(bodies):
print(f"❌ Missing {len(missing)} SSG routes:") print("\n--- Gate 4: Live Server Route Verification ---")
for m in missing: for route, body in bodies.items():
print(f" - {m}") if len(body) == 0:
print(f"❌ Empty response body for route '{route}'")
return False return False
print(f" [OK] {route} ({len(body)} bytes)")
print(f"✅ Gate 4 PASSED: All {len(EXPECTED_SSG_ROUTES)} static SSG routes exist and are non-empty") print(f"✅ Gate 4 PASSED: All {len(bodies)} live routes served non-empty responses")
return True return True
def test_data_counts(): 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") script_path = os.path.join(REFER_DIR, "scripts", "extract", "verify_counts.py")
res = run_cmd([sys.executable, script_path], cwd=ROOT_DIR) res = run_cmd([sys.executable, script_path], cwd=ROOT_DIR)
if res.returncode != 0: if res.returncode != 0:
print(f"❌ Data count verification failed:\n{res.stdout}\n{res.stderr}") print(f"❌ Data count verification failed:\n{res.stdout}\n{res.stderr}")
return False return False
print(res.stdout.strip()) 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 return True
def test_theme_tokens(): def test_theme_tokens():
@@ -151,50 +225,40 @@ def test_theme_tokens():
def test_unittest_suite(): def test_unittest_suite():
print("\n--- Gate 7: DOM & HTML Content Assertions (Unittest) ---") print("\n--- Gate 7: DOM & HTML Content Assertions (Unittest) ---")
test_path = os.path.join(REFER_DIR, "tests", "e2e_test_suite.py") 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: if res.returncode != 0:
print(f"❌ Unittest suite failed:\n{res.stdout}\n{res.stderr}") print(f"❌ Unittest suite failed:\n{res.stdout}\n{res.stderr}")
return False 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 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) ---") 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) # 1) Check live HTML responses for container-content inside main across all routes (C2, AC-25)
routes_to_check = EXPECTED_SSG_ROUTES
main_hashes = {} main_hashes = {}
for rel_p in routes_to_check: for route, html in bodies.items():
if not rel_p.endswith(".html"): if "_not-found" in route or "404" in route:
continue 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) main_match = re.search(r'<main(?:\s+[^>]*)?>(.*?)</main>', html, flags=re.S)
if not main_match: if not main_match:
print(f"❌ <main> tag not found in {rel_p}") print(f"❌ <main> tag not found in {route}")
return False return False
main_inner = main_match.group(1) main_inner = main_match.group(1)
# C1: Verify child wrapper inside <main> has container-content # 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: if "container-content" not in main_inner:
print(f"❌ container-content missing inside <main> in {rel_p}") print(f"❌ container-content missing inside <main> in {route}")
return False return False
# AC-41: Check for duplicate <main> normalized content hashes (except 404 pages) # AC-41: Check for duplicate <main> normalized content hashes
if "404" not in rel_p and "_not-found" not in rel_p:
norm_content = re.sub(r'\s+', '', main_inner) norm_content = re.sub(r'\s+', '', main_inner)
if norm_content in main_hashes: if norm_content in main_hashes:
print(f"❌ AC-41 Violation: Duplicate <main> content found between {rel_p} and {main_hashes[norm_content]}") print(f"❌ AC-41 Violation: Duplicate <main> content found between {route} and {main_hashes[norm_content]}")
return False return False
main_hashes[norm_content] = rel_p main_hashes[norm_content] = route
# 2) Check source for forbidden escaped arbitrary classes (AC-26) # 2) Check source for forbidden escaped arbitrary classes (AC-26)
forbidden_terms = ["w-screen", "-mx-[50vw]", "min-[1240px]:"] forbidden_terms = ["w-screen", "-mx-[50vw]", "min-[1240px]:"]
@@ -276,21 +340,20 @@ def test_color_contrast_gate():
def parse_tokens(block): def parse_tokens(block):
res = {} 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))) res[m.group(1)] = (int(m.group(2)), int(m.group(3)), int(m.group(4)))
return res return res
light_tokens = parse_tokens(root_match.group(1)) light_tokens = parse_tokens(root_match.group(1))
dark_tokens = parse_tokens(dark_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") cd_light = light_tokens.get("cobalt-dark")
vd_light = light_tokens.get("vermillion-dark") vd_light = light_tokens.get("vermillion-dark")
ivory_light = light_tokens.get("ivory") ivory_light = light_tokens.get("ivory")
paper_light = light_tokens.get("paper") paper_light = light_tokens.get("paper")
if not all([cd_light, vd_light, ivory_light, paper_light]): 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 return False
ratio_cd_ivory = contrast_ratio(cd_light, ivory_light) 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)") print(f"❌ AC-45 Violation: Light/Dark vermillion-dark inversion contrast too low ({inversion_ratio_vd:.2f} < 2.0)")
return False 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 white_count = 0
small_size_pattern = re.compile(r'text-(xs|sm|\[0\.65rem\]|\[0\.78rem\])') small_size_pattern = re.compile(r'text-(xs|sm|\[0\.65rem\]|\[0\.78rem\])')
default_color_pattern = re.compile(r'text-(cobalt|vermillion)(?![a-z-])') 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: with open(fpath, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
# AC-47 count
white_count += content.count("text-white") white_count += content.count("text-white")
# AC-44 & C11-9 check line by line
for line_idx, line in enumerate(content.splitlines(), start=1): 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): 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()}") print(f"❌ C11-9 (AC-48) Violation: Forbidden dark: modifier on accent color at {fpath}:{line_idx}\n {line.strip()}")
return False return False
if small_size_pattern.search(line) and default_color_pattern.search(line): 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: if "Pillar #" in line or "w-1.5 h-1.5" in line or "text-3xl" in line:
continue continue
print(f"❌ AC-44 Violation: Small text combined with DEFAULT accent color at {fpath}:{line_idx}\n {line.strip()}") 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(): def main():
print("=" * 70) 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) print("=" * 70)
success = True success = True
@@ -373,14 +429,33 @@ def main():
success &= test_lint() success &= test_lint()
success &= test_build() success &= test_build()
success &= test_tsc() success &= test_tsc()
success &= test_ssg_routes()
success &= test_data_counts() success &= test_data_counts()
success &= test_theme_tokens() success &= test_theme_tokens()
success &= test_unittest_suite()
success &= test_layout_architecture()
success &= test_token_contracts() success &= test_token_contracts()
success &= test_color_contrast_gate() 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) print("\n" + "-" * 70)
if success: if success:
print("ALL E2E TEST SUITES PASSED CLEAN (Exit Code 0).") print("ALL E2E TEST SUITES PASSED CLEAN (Exit Code 0).")
+78 -21
View File
@@ -1,35 +1,94 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Python Unittest Suite for ANL Homepage E2E Verification 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 unittest
import os import os
import re import re
import time
import subprocess
import urllib.request
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SERVER_APP_DIR = os.path.join(BASE_DIR, ".next", "server", "app") TEST_SERVER_URL = os.environ.get("TEST_SERVER_URL", "http://localhost:3000")
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
class TestANLHomepageE2E(unittest.TestCase): 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): def read_html(self, relative_path):
full_path = os.path.join(SERVER_APP_DIR, relative_path) route_map = {
self.assertTrue(os.path.exists(full_path), f"File {relative_path} does not exist") "index.html": "/",
with open(full_path, "r", encoding="utf-8") as f: "members.html": "/members",
return f.read() "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): 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") html = self.read_html("index.html")
# 1. Brand & Header titles # 1. Brand & Header titles
@@ -66,7 +125,7 @@ class TestANLHomepageE2E(unittest.TestCase):
self.assertIn("Project Editor", html) self.assertIn("Project Editor", html)
def test_03_publications_category_routes(self): 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 = [ cats = [
("intl-journal-conf.html", "International Journal"), ("intl-journal-conf.html", "International Journal"),
("domestic-journal-conf.html", "Domestic Journal"), ("domestic-journal-conf.html", "Domestic Journal"),
@@ -91,8 +150,6 @@ class TestANLHomepageE2E(unittest.TestCase):
def test_06_theme_css_variables(self): def test_06_theme_css_variables(self):
"""TC-T1-F2: Verify globals.css and built assets contain theme tokens.""" """TC-T1-F2: Verify globals.css and built assets contain theme tokens."""
globals_path = os.path.join(BASE_DIR, "app", "globals.css") 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: with open(globals_path, "r", encoding="utf-8") as f:
css = f.read() css = f.read()
@@ -100,7 +157,7 @@ class TestANLHomepageE2E(unittest.TestCase):
self.assertIn(token, css, f"Missing CSS variable {token} in globals.css") self.assertIn(token, css, f"Missing CSS variable {token} in globals.css")
def test_07_research_projects_pageview(self): 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") html = self.read_html("index.html")
# 1. Project PageView Title & Projects # 1. Project PageView Title & Projects
@@ -118,7 +175,7 @@ class TestANLHomepageE2E(unittest.TestCase):
self.assertIn("IEC TC100: Multimedia Systems and Equipment", html) self.assertIn("IEC TC100: Multimedia Systems and Equipment", html)
self.assertIn("ISO/IEC JTC1/SC6: Reliable Multicasting", 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) self.assertNotIn("Funder:", html)
def test_08_publications_highlights_and_category_nav(self): def test_08_publications_highlights_and_category_nav(self):