Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02637a8b52 | |||
| eb28c9fcc6 | |||
| 1433b4fcb4 | |||
| cd6a6ea069 |
@@ -0,0 +1,44 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: [macos, host]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Unit/Component tests (Vitest)
|
||||
run: npm test
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: E2E tests (Playwright)
|
||||
run: npm run test:e2e -- --reporter=line
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [macos, host]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
# 메인플랜: 랜딩 페이지 빌더
|
||||
|
||||
## 목표
|
||||
- 비개발자도 사용 가능한 단일 페이지 랜딩 페이지 빌더.
|
||||
- 결과물: 정적 ZIP(HTML/CSS/JS) 다운로드. 이미지: 외부 URL 또는 로컬 업로드(서버 저장 없음, ZIP에 패키징). 폰트는 외부 CDN 허용.
|
||||
- 폼: 다양한 필드 타입(파일 업로드 제외), 커스텀 name, action URL 설정. action 미지정 시 Google Sheets 저장.
|
||||
- 다국어 지원(i18n), 접근성 준수, SEO/OG 메타, 분석 스니펫 삽입.
|
||||
|
||||
## 확정 요구사항
|
||||
- 단일 페이지 기준.
|
||||
- 이미지: 외부 URL 또는 로컬 업로드 허용(서버 저장 없음, ZIP 패키징). CDN 허용(예: Google Fonts 등).
|
||||
- 폼 필드 타입: 텍스트/이메일/전화/셀렉트/라디오/체크박스/텍스트에어리어/날짜 등(파일 업로드는 추후).
|
||||
- 검증 규칙 필요. 스팸 방지: 기본(honeypot, 제출지연 타임스탬프).
|
||||
- 제출 경로: 1) 사용자가 action URL 지정 → 2) 미지정 시 Google Sheets.
|
||||
- 기본: Google Apps Script 웹앱 템플릿 가이드 제공(사용자 배포 URL 사용)。
|
||||
- 대안: 서비스계정 + Google Sheets API(선택).
|
||||
- 다국어 지원 필요. 접근성 준수. SEO/OG 포함. 분석 스니펫 삽입 기능 필요.
|
||||
- 스택: React 기반. Next.js(App Router) + TS + Tailwind + shadcn/ui 권장.
|
||||
|
||||
## 아키텍처 개요
|
||||
- 빌더 앱: Next.js(App Router, TS), Tailwind, shadcn/ui, dnd-kit, Zustand.
|
||||
- 도메인 스키마: Page → Section[] → Block[] → Form(fields, validation, actionUrl) → Theme.
|
||||
- 에셋 매니저: 사용자가 업로드한 이미지를 메모리/브라우저에서 관리, 파일명 해싱 후 `assets/` 경로로 참조 재작성.
|
||||
- HTML Exporter: JSON 스키마 → 정적 HTML/CSS/JS 생성. 로컬 업로드 이미지는 `assets/`로 참조, 외부 URL은 그대로 유지.
|
||||
- 제출 경로: action 사용 또는 Google Sheets(기본 Apps Script 템플릿 제공).
|
||||
- i18n: next-intl. a11y: Testing Library + axe-core 스모크. SEO/OG 메타 생성.
|
||||
- 내보내기: ZIP(브라우저 JSZip 우선). 필요 시 서버(API Route) 대안 제공. 로컬 업로드 이미지는 ZIP 내 `assets/` 폴더에 파일로 포함.
|
||||
|
||||
## UI/UX 리디자인 계획(합의안)
|
||||
- **정보 구조(3패널)**
|
||||
- 좌: 컴포넌트 패널(Hero/FAQ/CTA + Form 프리셋)
|
||||
- 중: 캔버스(dnd-kit, 섹션/필드 배치·정렬·삭제)
|
||||
- 우: 인스펙터(선택 요소 속성/검증/스타일 편집)
|
||||
- **상단 툴바**: 언어, 테마, 프리셋, Export ZIP
|
||||
- **프리뷰**: 실시간 렌더, 반응형 뷰포트 토글
|
||||
- **계약 고정**: 스키마(섹션/폼/검증), 상태 인터페이스, Exporter 입·출력
|
||||
|
||||
### 단계별 TDD 진행
|
||||
1) 레이아웃 스캐폴딩(shadcn/ui) + 접근성 라벨링 테스트
|
||||
2) dnd 캔버스 1차: 섹션 드래그 정렬/삭제 테스트 → 최소 구현
|
||||
3) 인스펙터 1차: 선택 섹션/필드 기본 속성 편집 테스트 → 연동
|
||||
4) 컴포넌트 패널: 카드 추가(섹션/필드 프리셋) 테스트 → 드래그 추가
|
||||
5) 프리뷰 강화: 실시간 반영/뷰포트 토글 스모크 테스트
|
||||
|
||||
### 현재 상태(MVP)
|
||||
- 섹션 추가/삭제, 폼 필드 구성(옵션/필수/패턴/최대길이), i18n, ZIP Export, 로컬 에셋 포함 동작 확인
|
||||
- Next.js 16: `middleware` → `proxy` 마이그레이션 완료
|
||||
- dnd 상호작용 테스트 및 구현 안정화(정렬/삭제 버튼 폴백 포함)
|
||||
- 섹션 프리셋 확장: Features/Testimonials 카드 추가 및 프리뷰/Exporter 스텁 반영 완료
|
||||
- SEO/Analytics/OG 인스펙터 추가 및 프리뷰 반영
|
||||
- Exporter: og:image 메타 태그 반영 및 테스트 추가
|
||||
- a11y: jest-axe 스모크 테스트 통과(FormBuilderPanel), dnd-kit 라이브 리전/안내 존재 검증
|
||||
- 라벨-id 매핑 강화(중앙 입력/인스펙터/FAQ/CTA/Features/Testimonials)
|
||||
- 프리뷰 뷰포트 토글(Desktop/Mobile) + localStorage 영속화
|
||||
- Features/Testimonials 인스펙터 편집 UI(리스트 추가/삭제/편집) + 테스트
|
||||
- Exporter 타입 보강(any 제거) 및 CSS 보강(Features/Testimonials)
|
||||
- E2E(Playwright):
|
||||
- 기본 플로우(섹션 추가→편집→프리뷰→Export 클릭)
|
||||
- dnd 키보드 폴백(Up/Down) 재정렬
|
||||
- SEO/Analytics/OG 프리뷰 반영 검증
|
||||
- Export ZIP 메타(SEO/OG/Analytics) 검증(테스트 훅 사용)
|
||||
- 프리뷰 뷰포트 토글(aria-pressed/키보드 접근) 검증
|
||||
|
||||
### a11y/반응형 정책(Exporter)
|
||||
- 문서 내비게이션: skip link, `<main id="main" role="main">` 적용
|
||||
- 섹션 식별: `aria-labelledby` + 섹션 헤딩 id(features/testimonials/faq)
|
||||
- 이미지 대체텍스트: hero img `alt` = heading 파생
|
||||
- 포커스 표시: 포커스 가능한 요소 outline 표시
|
||||
- 반응형: 모바일 컨테이너 패딩 축소, grid 자동 칼럼, `prefers-reduced-motion` 준수
|
||||
|
||||
### 최근 보강 내역(접근성/테스트)
|
||||
- 인스펙터 입력에 `aria-describedby` 연결(SEO/Analytics/OG/Hero/CTA)
|
||||
- SEO/Analytics/OG/Hero/CTA/FAQ 헬프텍스트 추가
|
||||
- Exporter 구조 스냅샷 강화: `<main>` 내부 섹션/폼 포함 검증
|
||||
- Exporter CSS 토큰 스냅샷: skip-link/focus outline/반응형/모션 감소 토큰 존재 검증
|
||||
- Features/Testimonials `aria-labelledby`/헤딩 id 일치 검증 추가
|
||||
- Exporter 헤딩 레벨 일관성 전수 테스트(hero=h1, others=h2, FAQ=h3) 추가
|
||||
- Exporter 색상/타이포그래피 토큰 스모크 테스트 추가
|
||||
- Exporter(Form): helptext ↔ aria-describedby 연결 TDD 완료
|
||||
- 각 필드 `id=field-<name>`와 `<label for>` 연결
|
||||
- `help`가 있을 경우 `id=field-<name>-help` 요소 생성 및 컨트롤에 `aria-describedby` 부여
|
||||
- 라디오 그룹은 `fieldset`에 `aria-describedby` 적용, 옵션별 `id/htmlFor` 연결
|
||||
- next-intl 기본 timeZone 설정: `Asia/Seoul`로 ENVIRONMENT_FALLBACK 경고 제거
|
||||
- a11y 색/폰트 최소 기준 스모크(본문 컬러/폰트 패밀리/모바일 h1 폰트 크기) 추가
|
||||
- CTA 섹션에 `aria-labelledby` + `<h2 id="cta-heading">` 추가(헤딩 레벨 일관성)
|
||||
- 전체 테스트 그린(유닛/컴포넌트/E2E)
|
||||
- Inspector a11y 스모크: SEO/Analytics/OG 입력과 헬프텍스트의 `aria-describedby` 연결 검증
|
||||
- Exporter CSS 스모크: 본문 텍스트 컬러/포커스 outline/모바일 h1 폰트 크기/MQ 토큰 존재 검증
|
||||
- FAQ 인스펙터 Q/A 입력 `aria-describedby`(ins-faq-help) 연결 보강
|
||||
- Inspector 키보드 UX 스모크: Features/Testimonials 항목 추가( Space/Enter ) 검증 추가
|
||||
- Inspector 키보드 UX 스모크: Features/Testimonials 항목 삭제(Remove) Enter/Space 검증 추가
|
||||
- 인스펙터 Remove 버튼 키보드 처리(onKeyDown/onKeyUp) 반영
|
||||
- Exporter 회귀 스냅샷: heading/ARIA/반응형 토큰 통합 검증 추가
|
||||
- Preview 토글 ARIA 회귀 스냅샷(Desktop/Mobile: aria-pressed/controls/describedby)
|
||||
- Exporter CSS 토큰 회귀 확대(:root --primary, body font, skip-link base/focus, focus outline, reduced-motion)
|
||||
- 컴포넌트 i18n 회귀 스냅샷(en/ko 주요 라벨/버튼 키 존재)
|
||||
- 섹션 a11y 자동 검증 템플릿(testUtils/sectionA11y) 도입 및 템플릿 테스트 추가
|
||||
- 신규 섹션 추가: Benefits
|
||||
- 스키마 확장(`benefits`): props.items:string[]
|
||||
- Exporter 렌더 추가: `<section class="benefits" aria-labelledby>`, `<h2 id="benefits-heading">`, 리스트 렌더
|
||||
- 테스트: sections.benefits.test.ts (h2/aria-labelledby/리스트 아이템 검증)
|
||||
- 섹션 회귀 스냅샷(benefits 포함) 보강: sections.regression.benefits.test.ts 추가(ARIA/헤딩/리스트 구조 전수 검증)
|
||||
- 섹션 회귀 스냅샷(CTA/Testimonials): sections.regression.cta-testimonials.test.ts 추가(figure/blockquote/figcaption, CTA 링크 클래스/URL 검증)
|
||||
- 섹션 회귀 스냅샷(Features/FAQ): sections.regression.features-faq.test.ts 추가(리스트/클래스/아이템 수 검증)
|
||||
- Exporter 이스케이프 회귀: sections.escape.regression.test.ts 추가(특수문자 <, >, &, ") 안전 이스케이프 검증)
|
||||
- 인스펙터 a11y 자동 체크 헬퍼 도입: components/testUtils/inspectorA11y.ts + 템플릿 테스트 2종(InspectorA11y.template/sections)
|
||||
- i18n 회귀 확장: Sidebar Add 버튼군(en/ko) 라벨 스모크(i18n.sidebar.actions.test.tsx)
|
||||
- E2E 스모크: 프리뷰/Export 마커(heading/ARIA/반응형 CSS) 존재 검증 추가
|
||||
- SEO description 기본값 설정으로 Export 스키마 안정화
|
||||
- 프리뷰 토글 버튼에 `aria-controls="preview-container"` 추가로 제어 영역 명시
|
||||
- 프리뷰 토글에 sr-only 설명 추가 및 두 토글 버튼 `aria-describedby` 연결
|
||||
- SectionCanvas 제어 버튼 `aria-label` 스모크(Select/Up/Down/Remove) 추가
|
||||
- CSS focus-visible 토큰 스모크(3px solid + HEX 컬러) 추가
|
||||
- 인스펙터 Add 버튼 title 보조 힌트 추가(Features/Testimonials/FAQ) 및 일관성 테스트
|
||||
- SectionCanvas 버튼 title/aria-label 일관성 스모크
|
||||
- 전역 버튼 네이밍 일관성 보강: Inspector Add/Remove 및 SectionCanvas Select/Up/Down/Remove의 title/aria-label 일치 확인 및 스모크 완료
|
||||
- a11y 커버리지 최종 점검: 전 섹션 인스펙터 입력 aria-describedby 연결 확인(체크리스트 충족)
|
||||
|
||||
### 다음 작업(계획)
|
||||
- Exporter 헤딩 레벨 일관성 테스트: hero는 h1, 나머지 섹션은 h2, FAQ 항목은 h3 검증
|
||||
- a11y 색/폰트 최소 기준 스모크: 대비/폰트크기 관련 CSS 토큰/규칙 존재 여부 스냅샷
|
||||
|
||||
### Inspector a11y 커버리지 체크리스트
|
||||
- [x] Hero: `Hero Heading` 입력 `aria-describedby` → `ins-hero-heading-help`
|
||||
- [x] CTA: `CTA Text`/`CTA Href` 입력 `aria-describedby` → `ins-cta-text-help`/`ins-cta-href-help`
|
||||
- [x] FAQ: `FAQ Q n`/`FAQ A n` 입력 `aria-describedby` → `ins-faq-help`
|
||||
- [x] Features: `Feature n` 입력 `aria-describedby` → `ins-features-help`
|
||||
- [x] Testimonials: `Author n`/`Quote n` 입력 `aria-describedby` → `ins-testimonials-help`
|
||||
- [x] Preview 토글: sr-only 설명(`preview-toggle-help`)과 버튼 `aria-describedby` 연결, `aria-controls="preview-container"`
|
||||
|
||||
### CI/테스트 실행
|
||||
- 유닛/컴포넌트: `npm test` (Vitest)
|
||||
- E2E: `npm run test:e2e` (Playwright)
|
||||
- 로컬 개발 서버는 Playwright 설정에서 자동 기동(webServer: `npm run dev`)
|
||||
- GitHub Actions 워크플로: `.github/workflows/ci.yml`
|
||||
- Node 20 + `npm ci`
|
||||
- Vitest 실행 → Playwright 브라우저 설치(`npx playwright install --with-deps`) → E2E 실행
|
||||
|
||||
### CI 최종 점검
|
||||
- main/master 브랜치 푸시/PR 시 워크플로 자동 실행 확인
|
||||
- 새로 추가된 테스트(헤딩 레벨 전수, 색/폰트 스모크) CI 통합 확인
|
||||
- 작업 브랜치 푸시 완료: `chore/ci-verify-a11y-headings` (원격: origin)
|
||||
- PR 생성 및 Actions 결과 기록 예정(base: main)
|
||||
- CI verification note: 러너 온라인 이후, 사소 변경으로 CI 트리거 검증 예정
|
||||
- CI rerun: 워크플로 재실행용 ping 커밋
|
||||
|
||||
#### 결과: CI 통과 (self-hosted Gitea Actions)
|
||||
- PR: https://gitea.jaybe.dev/jaybe/landing-builder/pulls/2
|
||||
- 러너: self-hosted, labels: [macos, host]
|
||||
- 테스트: Unit/Component + E2E 총 7/7 green
|
||||
- 수정 요약(E2E 포커스):
|
||||
- 초기 포커스 선점 방지: `body` 클릭으로 포커스 부여
|
||||
- 스킵링크 등 선행 포커서블 회피: Desktop 버튼에 명시적 `focus()`
|
||||
- 키보드 시나리오: Tab → Mobile 포커스 확인 → Space로 토글 → Shift+Tab → Enter
|
||||
|
||||
#### 머지 상태
|
||||
- 머지 방식: Squash and merge
|
||||
- 브랜치 정리: `feat/ci-verify-run` 삭제 완료
|
||||
|
||||
### 다음 스프린트 후보 과제
|
||||
- Exporter 회귀 스냅샷 보강(폼 토큰/검증 토큰 범위 확대)
|
||||
- 인스펙터 신규 섹션 추가 시 a11y 베이스라인 재사용 가이드 마련
|
||||
- 버튼 네이밍 일관성 전수 스캔 스모크 범위 확대
|
||||
- node 20 + npm ci
|
||||
- Vitest 실행 → Playwright 브라우저 설치 → E2E 실행
|
||||
|
||||
## 마일스톤
|
||||
1. 스캐폴딩 및 테스트 환경 구성
|
||||
2. 스키마 정의(Zod) 및 HTML Exporter TDD
|
||||
3. 컴포넌트/상태관리(Zustand) 및 인스펙터 기본 기능
|
||||
4. 접근성/키보드 네비게이션 강화 및 테스트 안정화
|
||||
5. CI/CD(Self-hosted Gitea) 연동 및 그린 검증
|
||||
|
||||
## 릴리스
|
||||
### v0.1.0
|
||||
- CI 검증 완료(Self-hosted Gitea runner, labels: [macos, host])
|
||||
- E2E 안정화: 초기 포커스 이슈 제거(Desktop 명시 focus), 스킵링크 고려
|
||||
- Exporter(Form) a11y: helptext ↔ aria-describedby, id/htmlFor 연결, 라디오 fieldset 설명 연결
|
||||
- i18n: en/ko 중첩 구조, 누락 키 보강(builder.action.*, builder.form.*)
|
||||
- next-intl: 기본 timeZone=Asia/Seoul 설정으로 ENVIRONMENT_FALLBACK 경고 제거
|
||||
- 태그: v0.1.0
|
||||
|
||||
## 접근성 베이스라인 가이드(신규 섹션/인스펙터 적용 규칙)
|
||||
- **레이블 연결**: 모든 입력은 `id=ins-...` 또는 `id=field-...` 부여, `<label for>`로 연결
|
||||
- **헬프텍스트 연결**: 헬프 요소 `id=...-help` 생성, 입력에 `aria-describedby`로 연결
|
||||
- **라디오 그룹**: 개별 `<input>` 대신 `<fieldset>`에 `aria-describedby` 적용, 옵션은 `id/htmlFor`로 연결
|
||||
- **버튼 네이밍**: 보이는 텍스트 또는 `aria-label`로 안정적 접근 가능한 이름 제공
|
||||
- **키보드 활성화**: Enter/Space 활성(onClick와 동일 동작), `focus-visible` 스타일 유지
|
||||
- **스킵링크**: `<a class="skip-link">` 제공, 포커스 시 화면 내 표시
|
||||
- **헤딩 레벨**: hero=h1, 1뎁스 섹션=h2, FAQ 항목=h3 일관성 유지
|
||||
- **미리보기 토글**: 토글 버튼에 `aria-pressed`, `aria-controls`, `aria-describedby` 연결
|
||||
- **i18n 키**: 중첩 객체 구조, 신규 키 추가 시 en/ko 동시 보강
|
||||
- **테스트 원칙**: TDD로 유닛/컴포넌트/E2E 및 스냅샷 추가. 포커스/ARIA/토큰 스모크 포함
|
||||
|
||||
### 적용 가이드(신규 섹션 추가 시)
|
||||
- **1) 섹션 구현**: `<section class="<name>" aria-labelledby="<name>-heading">`와 `<h2 id="<name>-heading">` 구성
|
||||
- **2) 인스펙터 입력**: `id=ins-...`, `<label for>`, 헬프텍스트 `...-help` + `aria-describedby` 연결
|
||||
- **3) Exporter 반영**: 섹션 렌더 함수에 위 구조/속성 부여
|
||||
- **4) 테스트 추가**:
|
||||
- 공통 템플릿 사용: `lib/exporter/testUtils/sectionA11y.ts` → `assertSectionA11y(html)`
|
||||
- 샘플: `lib/exporter/sectionA11y.template.test.ts` 참고하여 새 섹션 추가해 검증
|
||||
- 필요 시 개별 스냅샷/스모크: 헤딩/ARIA/키보드/토큰 추가 검증
|
||||
|
||||
## 다음 단계(E2E 초안)
|
||||
- 시나리오: 섹션 추가(Hero/FAQ/Features/Testimonials/CTA) → 인스펙터 편집 → Export ZIP 생성 → HTML 메타/섹션/폼 렌더 확인
|
||||
- 환경: Playwright
|
||||
- 체크리스트:
|
||||
- 섹션 추가/정렬/삭제 동작
|
||||
- 인스펙터 편집 반영(미리보기/Export HTML)
|
||||
- i18n 라벨 안정성(data-testid 사용)
|
||||
- a11y 스모크 무결성 유지
|
||||
|
||||
## TDD 전략
|
||||
- 각 기능 단위로: 실패 테스트 작성 → 최소 구현 → 리팩터 → 커버리지 확인.
|
||||
- 단위: Vitest + Testing Library + jsdom. E2E: Playwright.
|
||||
- 접근성: axe-core 스모크 테스트.
|
||||
|
||||
## 작업 보드(요약)
|
||||
- 스키마/도메인(Zod)
|
||||
- HTML Exporter
|
||||
- 에셋 매니저
|
||||
- 폼 엔진
|
||||
- Sheets 연동
|
||||
- 빌더 UI
|
||||
- 내보내기 ZIP
|
||||
- i18n
|
||||
- a11y/SEO/OG/Analytics
|
||||
|
||||
## 오류/버그 기록
|
||||
- [2025-11-13 22:55] BuilderClientPage 렌더 중 ref 접근 경고 / useRef.current를 렌더 중 참조하여 ESLint 경고 발생 / FormBuilderPanel 통합 시 `useRef(createFormStore())` 사용 / `useMemo(() => createFormStore(), [])`로 스토어 인스턴스 고정하고 `useSyncExternalStore`에 직접 주입하여 해결 / 렌더 중 ref 접근 금지 원칙 재확인
|
||||
- [2025-11-14 09:35] Vitest 로그에서 i18n 누락 메시지 경고 확인(en: `builder.action.addSelect/addRadio`, `builder.form.actionUrl/honeypot/minSeconds`) → `messages/en.json`에 키 존재 여부 점검 및 누락 보완 후 재실행, 모든 테스트 그린 유지
|
||||
- [2025-11-14 09:45] 프리뷰 텍스트 매칭 불안정으로 테스트 실패 → `data-testid` 기반 안정화(`preview-seo-title` 등) 및 텍스트 매칭 지점 `Title:`→`Locale:`로 조정, 전면 그린
|
||||
|
||||
## 의사결정 기록(ADR)
|
||||
- [YYYY-MM-DD] Next.js(App Router) 채택: 생태계/배포/서버리스/SEO 편의성.
|
||||
- [YYYY-MM-DD] Sheets 기본 방안: Apps Script 웹앱 템플릿(사용자 편의/트래픽 분산).
|
||||
- [YYYY-MM-DD] 로컬 이미지 업로드 허용: 서버 저장 없이 ZIP에 `assets/`로 패키징(브라우저 JSZip 우선).
|
||||
@@ -13,7 +13,8 @@ export default function I18nProvider({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
// next-intl: Provide a default timeZone to avoid ENVIRONMENT_FALLBACK mismatches
|
||||
<NextIntlClientProvider locale={locale} messages={messages} timeZone="Asia/Seoul">
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import React from 'react'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
import I18nProvider from '@/components/I18nProvider'
|
||||
import en from '@/messages/en.json'
|
||||
import { assertDescribedBy } from '@/components/testUtils/inspectorA11y'
|
||||
|
||||
describe('Inspector a11y sections template - Hero/CTA/FAQ describedby wiring', () => {
|
||||
it('Hero, CTA, FAQ inspector inputs are wired to helptexts', () => {
|
||||
render(
|
||||
<I18nProvider locale="en" messages={en as unknown as Record<string, string>}>
|
||||
<BuilderClientPage />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
// Add hero to enable inspector
|
||||
const addHero = screen.getByRole('button', { name: 'Add Hero' })
|
||||
fireEvent.click(addHero)
|
||||
|
||||
// Select first section (Hero)
|
||||
const selectButtons = screen.getAllByRole('button', { name: /Select \d+/ })
|
||||
fireEvent.click(selectButtons[0])
|
||||
|
||||
const heroHeading = screen.getByLabelText('Hero Heading')
|
||||
assertDescribedBy(heroHeading, 'ins-hero-heading-help')
|
||||
|
||||
// Replace with CTA and test describedby
|
||||
const addCta = screen.getByRole('button', { name: 'Add CTA' })
|
||||
fireEvent.click(addCta)
|
||||
const selectSecond = screen.getAllByRole('button', { name: /Select \d+/ })[1]
|
||||
fireEvent.click(selectSecond)
|
||||
|
||||
const ctaText = screen.getByLabelText('CTA Text')
|
||||
assertDescribedBy(ctaText, 'ins-cta-text-help')
|
||||
const ctaHref = screen.getByLabelText('CTA Href')
|
||||
assertDescribedBy(ctaHref, 'ins-cta-href-help')
|
||||
|
||||
// Add FAQ and test using shared help id
|
||||
const addFaq = screen.getByRole('button', { name: 'Add FAQ' })
|
||||
fireEvent.click(addFaq)
|
||||
const selectThird = screen.getAllByRole('button', { name: /Select \d+/ })[2]
|
||||
fireEvent.click(selectThird)
|
||||
// Add one FAQ item to render inputs
|
||||
const addFaqItem = screen.getByRole('button', { name: 'Add FAQ Item' })
|
||||
fireEvent.click(addFaqItem)
|
||||
|
||||
const faqQ = screen.getByLabelText('FAQ Q 0')
|
||||
const faqA = screen.getByLabelText('FAQ A 0')
|
||||
assertDescribedBy(faqQ, 'ins-faq-help')
|
||||
assertDescribedBy(faqA, 'ins-faq-help')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import React from 'react'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
import I18nProvider from '@/components/I18nProvider'
|
||||
import en from '@/messages/en.json'
|
||||
import { assertDescribedBy } from '@/components/testUtils/inspectorA11y'
|
||||
|
||||
describe('Inspector a11y template - describedby wiring', () => {
|
||||
it('SEO/Analytics inputs are wired to their helptexts via aria-describedby', () => {
|
||||
render(
|
||||
<I18nProvider locale="en" messages={en as unknown as Record<string, string>}>
|
||||
<BuilderClientPage />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
const seoTitle = screen.getByLabelText('SEO Title')
|
||||
const seoDesc = screen.getByLabelText('SEO Description')
|
||||
const ogImage = screen.getByLabelText('OG Image URL')
|
||||
const ga4 = screen.getByLabelText('GA4 Measurement ID')
|
||||
const meta = screen.getByLabelText('Meta Pixel ID')
|
||||
|
||||
assertDescribedBy(seoTitle, 'ins-seo-title-help')
|
||||
assertDescribedBy(seoDesc, 'ins-seo-desc-help')
|
||||
assertDescribedBy(ogImage, 'ins-og-image-help')
|
||||
assertDescribedBy(ga4, 'ins-ga4-help')
|
||||
assertDescribedBy(meta, 'ins-meta-pixel-help')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import React from 'react'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
import I18nProvider from '@/components/I18nProvider'
|
||||
import en from '@/messages/en.json'
|
||||
|
||||
describe('Buttons naming/aria-label consistency (smoke)', () => {
|
||||
it('renders primary action buttons with stable accessible names', () => {
|
||||
render(
|
||||
<I18nProvider locale="en" messages={en as unknown as Record<string, string>}>
|
||||
<BuilderClientPage />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
// Sidebar/toolbar actions
|
||||
expect(screen.getByRole('button', { name: 'Add Hero' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add FAQ' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add CTA' })).toBeInTheDocument()
|
||||
|
||||
// Export button
|
||||
expect(screen.getByRole('button', { name: 'Export ZIP' })).toBeInTheDocument()
|
||||
|
||||
// Preview viewport toggle buttons have explicit aria-labels as names
|
||||
expect(screen.getByRole('button', { name: 'Desktop' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Mobile' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import React from 'react'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
import I18nProvider from '@/components/I18nProvider'
|
||||
import en from '@/messages/en.json'
|
||||
import ko from '@/messages/ko.json'
|
||||
|
||||
describe('i18n regression - form add actions (en/ko)', () => {
|
||||
function renderWith(locale: 'en' | 'ko') {
|
||||
const messages = locale === 'en' ? en : ko
|
||||
return render(
|
||||
<I18nProvider locale={locale} messages={messages as unknown as Record<string, string>}>
|
||||
<BuilderClientPage />
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
it('en labels exist for form field add buttons', () => {
|
||||
renderWith('en')
|
||||
expect(screen.getByRole('button', { name: 'Add Text' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Email' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Tel' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Date' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Textarea' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Checkbox' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Select' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Radio' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ko labels exist for form field add buttons', () => {
|
||||
renderWith('ko')
|
||||
expect(screen.getByRole('button', { name: '텍스트 필드 추가' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '이메일 필드 추가' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '전화번호 필드 추가' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '날짜 필드 추가' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '텍스트영역 추가' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '체크박스 추가' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '셀렉트 추가' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '라디오 추가' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import React from 'react'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
import I18nProvider from '@/components/I18nProvider'
|
||||
import en from '@/messages/en.json'
|
||||
import ko from '@/messages/ko.json'
|
||||
|
||||
describe('i18n regression - misc labels (title/preview/sections)', () => {
|
||||
function renderWith(locale: 'en' | 'ko') {
|
||||
const messages = locale === 'en' ? en : ko
|
||||
return render(
|
||||
<I18nProvider locale={locale} messages={messages as unknown as Record<string, string>}>
|
||||
<BuilderClientPage />
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
it('en: builder title, preview text, sections heading present', () => {
|
||||
renderWith('en')
|
||||
expect(screen.getByRole('heading', { name: 'Landing Page Builder' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Preview (static)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sections')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ko: builder title, preview text, sections heading present', () => {
|
||||
renderWith('ko')
|
||||
expect(screen.getByRole('heading', { name: '랜딩 페이지 빌더' })).toBeInTheDocument()
|
||||
expect(screen.getByText('미리보기(정적)')).toBeInTheDocument()
|
||||
expect(screen.getByText('섹션')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import React from 'react'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
import I18nProvider from '@/components/I18nProvider'
|
||||
import en from '@/messages/en.json'
|
||||
import ko from '@/messages/ko.json'
|
||||
|
||||
function renderWith(locale: 'en' | 'ko') {
|
||||
const messages = locale === 'en' ? en : ko
|
||||
return render(
|
||||
<I18nProvider locale={locale} messages={messages as unknown as Record<string, string>}>
|
||||
<BuilderClientPage />
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('i18n regression - key presence on main UI', () => {
|
||||
it('en: shows expected labels and buttons', () => {
|
||||
renderWith('en')
|
||||
expect(screen.getByRole('heading', { name: 'Landing Page Builder' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Title')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Locale')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Primary Color')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Font Family')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Export ZIP' })).toBeInTheDocument()
|
||||
// form actions
|
||||
expect(screen.getByRole('button', { name: 'Add Text' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Email' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Tel' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Date' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Textarea' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Checkbox' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Select' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Radio' })).toBeInTheDocument()
|
||||
// preview
|
||||
expect(screen.getByRole('button', { name: 'Desktop' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Mobile' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ko: shows expected labels and buttons', () => {
|
||||
renderWith('ko')
|
||||
expect(screen.getByRole('heading', { name: '랜딩 페이지 빌더' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('제목')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('로케일')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('대표 색상')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('폰트')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'ZIP 내보내기' })).toBeInTheDocument()
|
||||
// form actions
|
||||
expect(screen.getByRole('button', { name: '텍스트 필드 추가' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '이메일 필드 추가' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '전화번호 필드 추가' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '날짜 필드 추가' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '텍스트영역 추가' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '체크박스 추가' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '셀렉트 추가' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '라디오 추가' })).toBeInTheDocument()
|
||||
// preview
|
||||
expect(screen.getByRole('button', { name: 'Desktop' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Mobile' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import React from 'react'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
import I18nProvider from '@/components/I18nProvider'
|
||||
import en from '@/messages/en.json'
|
||||
import ko from '@/messages/ko.json'
|
||||
|
||||
describe('i18n regression - sidebar add actions (en/ko)', () => {
|
||||
function renderWith(locale: 'en' | 'ko') {
|
||||
const messages = locale === 'en' ? en : ko
|
||||
return render(
|
||||
<I18nProvider locale={locale} messages={messages as unknown as Record<string, string>}>
|
||||
<BuilderClientPage />
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
it('en labels exist', () => {
|
||||
renderWith('en')
|
||||
expect(screen.getByRole('button', { name: 'Add Hero' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add FAQ' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add CTA' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ko labels exist', () => {
|
||||
renderWith('ko')
|
||||
expect(screen.getByRole('button', { name: '히어로 추가' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'FAQ 추가' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'CTA 추가' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import React from 'react'
|
||||
import BuilderClientPage from '@/components/BuilderClientPage'
|
||||
import I18nProvider from '@/components/I18nProvider'
|
||||
import en from '@/messages/en.json'
|
||||
|
||||
describe('Preview viewport toggle ARIA regression', () => {
|
||||
it('Desktop/Mobile buttons expose stable ARIA attributes', () => {
|
||||
render(
|
||||
<I18nProvider locale="en" messages={en as unknown as Record<string, string>}>
|
||||
<BuilderClientPage />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
const desktop = screen.getByRole('button', { name: 'Desktop' })
|
||||
const mobile = screen.getByRole('button', { name: 'Mobile' })
|
||||
|
||||
// describedby shared help
|
||||
expect(desktop).toHaveAttribute('aria-describedby', 'preview-toggle-help')
|
||||
expect(mobile).toHaveAttribute('aria-describedby', 'preview-toggle-help')
|
||||
|
||||
// controls preview container
|
||||
expect(desktop).toHaveAttribute('aria-controls', 'preview-container')
|
||||
expect(mobile).toHaveAttribute('aria-controls', 'preview-container')
|
||||
|
||||
// default pressed state: Desktop true, Mobile false
|
||||
expect(desktop).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(mobile).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
// Inspector a11y helper: verify input ↔ helptext wiring via aria-describedby
|
||||
import { expect } from 'vitest'
|
||||
|
||||
export function assertDescribedBy(input: HTMLElement, helpId: string) {
|
||||
expect(input).toHaveAttribute('aria-describedby', helpId)
|
||||
const help = (input.ownerDocument || document).getElementById(helpId)
|
||||
expect(help).not.toBeNull()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
|
||||
function makePage(): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'CSS Tokens Expand',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
sections: [],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 'SEO', description: 'Desc' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Exporter CSS tokens - expanded regression', () => {
|
||||
it('includes root var, skip-link base/focus, focus outline, reduced-motion, body font', () => {
|
||||
const out = exportPage(makePage())
|
||||
const css = out.css
|
||||
|
||||
// :root primary color var
|
||||
expect(css).toMatch(/:root\{\s*--primary:\s*#0ea5e9\s*;\s*\}/)
|
||||
|
||||
// body font family includes configured font first
|
||||
expect(css).toMatch(/body\{[^}]*font-family:\s*Inter,\s*system-ui[\s\S]*\}/)
|
||||
|
||||
// skip-link base and focus tokens
|
||||
expect(css).toMatch(/a\.skip-link\{[\s\S]*left:-9999px[\s\S]*\}/)
|
||||
expect(css).toMatch(/a\.skip-link:focus\{[\s\S]*left:1rem[\s\S]*background:[^;]+;[\s\S]*\}/)
|
||||
|
||||
// focus-visible/outline tokens (any focusable)
|
||||
expect(css).toMatch(/button:focus, a:focus, input:focus, select:focus, textarea:focus\{[\s\S]*outline:\s*3px solid\s*#2563eb[\s\S]*outline-offset:\s*2px[\s\S]*\}/)
|
||||
|
||||
// reduced-motion detailed rule
|
||||
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)\{\s*\*\{\s*transition:\s*none !important;\s*animation:\s*none !important\s*\}\s*\}/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
|
||||
const makePage = (): Page =>
|
||||
pageSchema.parse({
|
||||
title: 'Form Help A11y',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
sections: [],
|
||||
form: {
|
||||
actionUrl: '',
|
||||
fields: [
|
||||
{ type: 'text', name: 'name', label: 'Name', required: true, help: 'Your full name' },
|
||||
{ type: 'email', name: 'email', label: 'Email', required: true },
|
||||
{ type: 'select', name: 'plan', label: 'Plan', required: false, options: ['A','B'], help: 'Choose your plan' },
|
||||
{ type: 'radio', name: 'size', label: 'Size', required: false, options: ['S','M','L'], help: 'Pick one size' },
|
||||
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true, help: 'You must agree to proceed' },
|
||||
{ type: 'textarea', name: 'msg', label: 'Message', required: false },
|
||||
],
|
||||
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||
},
|
||||
seo: { title: 'Form Help A11y', description: 'desc' },
|
||||
})
|
||||
|
||||
/**
|
||||
* Requirements (a11y):
|
||||
* - Each control has an id `field-<name>` and its label has htmlFor pointing to it.
|
||||
* - If field.help exists:
|
||||
* - Render help element with id `field-<name>-help` containing help text.
|
||||
* - Control has `aria-describedby` referencing the help id.
|
||||
* - For radio group, set aria-describedby on the fieldset instead of individual radios.
|
||||
*/
|
||||
|
||||
describe('Exporter - form helptext and aria-describedby', () => {
|
||||
it('renders id/htmlFor and help + aria-describedby wiring', () => {
|
||||
const page = makePage()
|
||||
const out = exportPage(page)
|
||||
const html = out.html
|
||||
|
||||
// text with help
|
||||
expect(html).toContain('id="field-name"')
|
||||
expect(html).toContain('<label for="field-name">Name')
|
||||
expect(html).toContain('id="field-name-help"')
|
||||
expect(html).toContain('Your full name')
|
||||
expect(html).toContain('aria-describedby="field-name-help"')
|
||||
|
||||
// email without help → no aria-describedby
|
||||
expect(html).toContain('id="field-email"')
|
||||
expect(html).toContain('<label for="field-email">Email')
|
||||
expect(html).not.toContain('field-email-help')
|
||||
|
||||
// select with help
|
||||
expect(html).toContain('id="field-plan"')
|
||||
expect(html).toContain('<label for="field-plan">Plan')
|
||||
expect(html).toContain('id="field-plan-help"')
|
||||
expect(html).toContain('Choose your plan')
|
||||
|
||||
// radio group help → fieldset has aria-describedby
|
||||
expect(html).toContain('<fieldset')
|
||||
expect(html).toContain('aria-describedby="field-size-help"')
|
||||
expect(html).toContain('id="field-size-help"')
|
||||
expect(html).toContain('Pick one size')
|
||||
|
||||
// checkbox with help
|
||||
expect(html).toContain('id="field-agree"')
|
||||
expect(html).toContain('<label for="field-agree">')
|
||||
expect(html).toContain('id="field-agree-help"')
|
||||
expect(html).toContain('You must agree to proceed')
|
||||
|
||||
// textarea without help
|
||||
expect(html).toContain('id="field-msg"')
|
||||
expect(html).toContain('<label for="field-msg">Message')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
|
||||
function makePage(fields: Page['form']['fields']): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'Matrix',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
sections: [],
|
||||
form: {
|
||||
actionUrl: '',
|
||||
fields,
|
||||
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||
},
|
||||
seo: { title: 'SEO', description: 'Desc' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Exporter - form helptext matrix (required/maxLength/pattern/help)', () => {
|
||||
it('text: required + maxLength + help → has required, maxlength, help id and aria-describedby', () => {
|
||||
const page = makePage([
|
||||
{ type: 'text', name: 'nick', label: 'Nickname', required: true, maxLength: 10, help: 'Public name' },
|
||||
])
|
||||
const html = exportPage(page).html
|
||||
expect(html).toContain('id="field-nick"')
|
||||
expect(html).toMatch(/name="nick"[^"]*[^>]*required/)
|
||||
expect(html).toMatch(/name="nick"[^"]*[^>]*maxlength="10"/)
|
||||
expect(html).toContain('id="field-nick-help"')
|
||||
expect(html).toContain('aria-describedby="field-nick-help"')
|
||||
})
|
||||
|
||||
it('tel: pattern only, no help → has pattern, no aria-describedby', () => {
|
||||
const page = makePage([
|
||||
{ type: 'tel', name: 'phone', label: 'Phone', required: false, pattern: '^\\+?[0-9\\- ]{7,}$' },
|
||||
])
|
||||
const html = exportPage(page).html
|
||||
expect(html).toMatch(/name="phone"[^"]*[^>]*pattern="\^\\\+\?\[0-9\\\- \]\{7,\}\$"/)
|
||||
expect(html).not.toContain('field-phone-help')
|
||||
// ensure no false aria-describedby
|
||||
expect(html).not.toMatch(/name="phone"[^"]*[^>]*aria-describedby=/)
|
||||
})
|
||||
|
||||
it('select: help present → select has aria-describedby and help element exists', () => {
|
||||
const page = makePage([
|
||||
{ type: 'select', name: 'plan', label: 'Plan', required: false, options: ['Free','Pro'], help: 'Choose one' },
|
||||
])
|
||||
const html = exportPage(page).html
|
||||
expect(html).toContain('id="field-plan"')
|
||||
expect(html).toContain('aria-describedby="field-plan-help"')
|
||||
expect(html).toContain('id="field-plan-help"')
|
||||
})
|
||||
|
||||
it('radio: help present → fieldset has aria-describedby; radios do not individually', () => {
|
||||
const page = makePage([
|
||||
{ type: 'radio', name: 'size', label: 'Size', required: false, options: ['S','M'], help: 'Pick one' },
|
||||
])
|
||||
const html = exportPage(page).html
|
||||
// fieldset wiring
|
||||
expect(html).toContain('<fieldset')
|
||||
expect(html).toContain('aria-describedby="field-size-help"')
|
||||
expect(html).toContain('id="field-size-help"')
|
||||
// radios present
|
||||
expect(html).toContain('type="radio"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
|
||||
const makePage = (): Page =>
|
||||
pageSchema.parse({
|
||||
title: 'Form Help Snapshot',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
sections: [],
|
||||
form: {
|
||||
actionUrl: '',
|
||||
fields: [
|
||||
{ type: 'text', name: 'name', label: 'Name', required: true, help: 'Your full name' },
|
||||
{ type: 'email', name: 'email', label: 'Email', required: true },
|
||||
{ type: 'radio', name: 'size', label: 'Size', options: ['S','M'], help: 'Pick one size' },
|
||||
{ type: 'select', name: 'plan', label: 'Plan', options: ['Free','Pro'], help: 'Choose plan' },
|
||||
{ type: 'checkbox', name: 'agree', label: 'Agree', required: true, help: 'Must agree' },
|
||||
],
|
||||
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||
},
|
||||
seo: { title: 'SEO', description: 'Desc' },
|
||||
})
|
||||
|
||||
function extractForm(html: string): string {
|
||||
const m = html.match(/<form[\s\S]*?<\/form>/)
|
||||
return m ? m[0]
|
||||
// normalize whitespace for snapshot stability
|
||||
.replace(/\s+\n/g, '\n')
|
||||
.replace(/\n\s+/g, '\n')
|
||||
.trim()
|
||||
: ''
|
||||
}
|
||||
|
||||
describe('Exporter - form helptext snapshot', () => {
|
||||
it('contains stable wiring for ids/labels/help/aria', () => {
|
||||
const out = exportPage(makePage())
|
||||
const form = extractForm(out.html)
|
||||
|
||||
// name with help
|
||||
expect(form).toContain('id="field-name"')
|
||||
expect(form).toContain('<label for="field-name">Name')
|
||||
expect(form).toContain('id="field-name-help"')
|
||||
expect(form).toContain('aria-describedby="field-name-help"')
|
||||
|
||||
// email present (no help)
|
||||
expect(form).toContain('id="field-email"')
|
||||
expect(form).toContain('<label for="field-email">Email')
|
||||
expect(form).not.toContain('field-email-help')
|
||||
|
||||
// radio group help on fieldset
|
||||
expect(form).toContain('<fieldset')
|
||||
expect(form).toContain('aria-describedby="field-size-help"')
|
||||
expect(form).toContain('id="field-size-help"')
|
||||
expect(form).toContain('type="radio"')
|
||||
|
||||
// select with help
|
||||
expect(form).toContain('id="field-plan"')
|
||||
expect(form).toContain('aria-describedby="field-plan-help"')
|
||||
expect(form).toContain('id="field-plan-help"')
|
||||
|
||||
// checkbox with help
|
||||
expect(form).toContain('id="field-agree"')
|
||||
expect(form).toContain('aria-describedby="field-agree-help"')
|
||||
expect(form).toContain('id="field-agree-help"')
|
||||
})
|
||||
})
|
||||
@@ -37,7 +37,8 @@ describe('Exporter - form rendering', () => {
|
||||
// select/radio 옵션 존재
|
||||
expect(out.html).toContain('<select')
|
||||
expect(out.html).toContain('<option value="A">A</option>')
|
||||
expect(out.html).toContain('<input type="radio"')
|
||||
// 라디오 인풋은 id 속성이 먼저 오므로 type만으로 확인
|
||||
expect(out.html).toContain('type="radio"')
|
||||
|
||||
// checkbox/textarea
|
||||
expect(out.html).toContain('type="checkbox"')
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
|
||||
const makePage = (): Page =>
|
||||
pageSchema.parse({
|
||||
title: 'Form Tokens Regression',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
sections: [],
|
||||
form: {
|
||||
actionUrl: '',
|
||||
fields: [
|
||||
{ type: 'text', name: 'fullName', label: 'Full Name', required: true, maxLength: 50 },
|
||||
{ type: 'email', name: 'email', label: 'Email', required: true },
|
||||
{ type: 'tel', name: 'phone', label: 'Phone', pattern: '^\\+?[0-9\\- ]{7,}$' },
|
||||
{ type: 'radio', name: 'size', label: 'Size', options: ['S','M','L'] },
|
||||
{ type: 'select', name: 'plan', label: 'Plan', options: ['Free','Pro'] },
|
||||
{ type: 'checkbox', name: 'agree', label: 'Agree to terms', required: true },
|
||||
{ type: 'textarea', name: 'message', label: 'Message' },
|
||||
],
|
||||
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||
},
|
||||
seo: { title: 'SEO', description: 'Desc' },
|
||||
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||
})
|
||||
|
||||
describe('Exporter - form tokens regression', () => {
|
||||
test('renders inputs with required/pattern/maxLength and spam-protection markers', () => {
|
||||
const { html, js } = exportPage(makePage())
|
||||
|
||||
// required + maxlength on text
|
||||
expect(html).toMatch(/name="fullName"[^"]*[^>]*required/)
|
||||
expect(html).toMatch(/name="fullName"[^"]*[^>]*maxlength="50"/)
|
||||
|
||||
// email required
|
||||
expect(html).toMatch(/name="email"[^"]*[^>]*required/)
|
||||
|
||||
// tel pattern
|
||||
expect(html).toMatch(/name="phone"[^"]*[^>]*pattern="\^\\\+\?\[0-9\\\- \]\{7,\}\$"/)
|
||||
|
||||
// radio/select options present
|
||||
// 라디오 인풋은 id 속성이 먼저 오므로 type만으로 확인
|
||||
expect(html).toContain('type="radio"')
|
||||
expect(html).toContain('<select')
|
||||
expect(html).toContain('<option value="Free">Free</option>')
|
||||
|
||||
// checkbox/textarea
|
||||
expect(html).toContain('type="checkbox"')
|
||||
expect(html).toContain('<textarea')
|
||||
|
||||
// spam-protection hidden inputs
|
||||
expect(html).toContain('type="hidden"')
|
||||
expect(html).toContain('name="_hp"')
|
||||
expect(html).toContain('name="_ts"')
|
||||
|
||||
// js fills timestamp
|
||||
expect(js).toMatch(/querySelector\('input\[name="_ts"\]'\)/)
|
||||
})
|
||||
})
|
||||
+55
-11
@@ -40,6 +40,20 @@ function renderFeatures(section: { props: { items: string[] } }) {
|
||||
`
|
||||
}
|
||||
|
||||
function renderBenefits(section: { props: { items: string[] } }) {
|
||||
const items: string[] = Array.isArray(section.props?.items) ? section.props.items : []
|
||||
return `
|
||||
<section class="benefits" aria-labelledby="benefits-heading">
|
||||
<div class="container">
|
||||
<h2 id="benefits-heading">Benefits</h2>
|
||||
<ul class="benefits-list">
|
||||
${items.map((it) => `<li class="benefit-item">${escapeHtml(String(it))}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
`
|
||||
}
|
||||
|
||||
function renderTestimonials(section: { props: { items: Array<{ author: string; quote: string }> } }) {
|
||||
const items: Array<{ author: string; quote: string }> = Array.isArray(section.props?.items)
|
||||
? section.props.items
|
||||
@@ -102,45 +116,74 @@ function renderHero(section: { props: { heading: string; subheading?: string; im
|
||||
}
|
||||
|
||||
function renderField(field: FormField) {
|
||||
const id = `field-${escapeHtml(field.name)}`
|
||||
const helpId = `field-${escapeHtml(field.name)}-help`
|
||||
const hasHelp = !!field.help
|
||||
const helpHtml = hasHelp ? `<div id="${helpId}" class="form-help">${escapeHtml(field.help!)}</div>` : ''
|
||||
|
||||
const pat = 'pattern' in field && field.pattern ? ` pattern="${escapeHtml(field.pattern)}"` : ''
|
||||
const max = 'maxLength' in field && field.maxLength ? ` maxlength="${field.maxLength}"` : ''
|
||||
const common = `name="${escapeHtml(field.name)}" aria-label="${escapeHtml(field.label)}" ${field.required ? 'required' : ''}${pat}${max}`
|
||||
const describedBy = hasHelp ? ` aria-describedby="${helpId}"` : ''
|
||||
const common = `id="${id}" name="${escapeHtml(field.name)}" aria-label="${escapeHtml(field.label)}" ${field.required ? 'required' : ''}${pat}${max}${describedBy}`
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
case 'email':
|
||||
case 'tel':
|
||||
case 'date':
|
||||
return `<label>${escapeHtml(field.label)}<input type="${field.type}" ${common} /></label>`
|
||||
return `
|
||||
<div class="form-control">
|
||||
<label for="${id}">${escapeHtml(field.label)}</label>
|
||||
<input type="${field.type}" ${common} />
|
||||
${helpHtml}
|
||||
</div>
|
||||
`
|
||||
case 'textarea':
|
||||
return `<label>${escapeHtml(field.label)}<textarea ${common}></textarea></label>`
|
||||
return `
|
||||
<div class="form-control">
|
||||
<label for="${id}">${escapeHtml(field.label)}</label>
|
||||
<textarea ${common}></textarea>
|
||||
${helpHtml}
|
||||
</div>
|
||||
`
|
||||
case 'checkbox':
|
||||
return `<label><input type="checkbox" ${common} />${escapeHtml(field.label)}</label>`
|
||||
return `
|
||||
<div class="form-control">
|
||||
<label for="${id}">${escapeHtml(field.label)}</label>
|
||||
<input type="checkbox" ${common} />
|
||||
${helpHtml}
|
||||
</div>
|
||||
`
|
||||
case 'radio': {
|
||||
const { options } = field as Extract<FormField, { type: 'radio' }>
|
||||
const fsDescribed = hasHelp ? ` aria-describedby="${helpId}"` : ''
|
||||
return `
|
||||
<fieldset>
|
||||
<fieldset${fsDescribed}>
|
||||
<legend>${escapeHtml(field.label)}</legend>
|
||||
${options
|
||||
.map(
|
||||
(opt: string) =>
|
||||
`<label><input type="radio" ${common} value="${escapeHtml(
|
||||
opt
|
||||
)}" />${escapeHtml(opt)}</label>`
|
||||
(opt: string, i: number) => {
|
||||
const optId = `${id}-opt-${i}`
|
||||
return `<div><input id="${optId}" type="radio" name="${escapeHtml(field.name)}" aria-label="${escapeHtml(field.label)}" value="${escapeHtml(opt)}" ${field.required ? 'required' : ''}${pat}${max} /><label for="${optId}">${escapeHtml(opt)}</label></div>`
|
||||
}
|
||||
)
|
||||
.join('')}
|
||||
${helpHtml}
|
||||
</fieldset>
|
||||
`
|
||||
}
|
||||
case 'select': {
|
||||
const { options } = field as Extract<FormField, { type: 'select' }>
|
||||
return `
|
||||
<label>${escapeHtml(field.label)}
|
||||
<div class="form-control">
|
||||
<label for="${id}">${escapeHtml(field.label)}</label>
|
||||
<select ${common}>
|
||||
${options
|
||||
.map((opt: string) => `<option value="${escapeHtml(opt)}">${escapeHtml(opt)}</option>`)
|
||||
.join('')}
|
||||
</select>
|
||||
</label>
|
||||
${helpHtml}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
default:
|
||||
@@ -250,6 +293,7 @@ export function exportPage(page: Page, opts?: ExportOptions): Exported {
|
||||
if (s.type === 'faq') return renderFaq(s)
|
||||
if (s.type === 'cta') return renderCta(s)
|
||||
if (s.type === 'features') return renderFeatures(s)
|
||||
if (s.type === 'benefits') return renderBenefits(s)
|
||||
if (s.type === 'testimonials') return renderTestimonials(s)
|
||||
return ''
|
||||
})
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it } from 'vitest'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
import { assertSectionA11y } from '@/lib/exporter/testUtils/sectionA11y'
|
||||
|
||||
function makeFullPage(): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'A11y Template',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
sections: [
|
||||
{ type: 'hero', props: { heading: 'Welcome', subheading: '', imageUrl: 'https://via.placeholder.com/800x400' } },
|
||||
{ type: 'features', props: { items: ['A','B','C'] } },
|
||||
{ type: 'testimonials', props: { items: [{ author: 'Jane', quote: 'Great!' }] } },
|
||||
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
|
||||
{ type: 'cta', props: { text: 'Get Started', href: '#' } },
|
||||
],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 'SEO', description: 'Desc' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Exporter - section a11y template', () => {
|
||||
it('validates basic a11y landmarks, headings and tokens', () => {
|
||||
const out = exportPage(makeFullPage())
|
||||
assertSectionA11y(out.html)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
|
||||
const makePage = (): Page =>
|
||||
pageSchema.parse({
|
||||
title: 'Benefits Test',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
sections: [
|
||||
{ type: 'benefits', props: { items: ['Fast setup', 'No code', 'Accessible by default'] } },
|
||||
],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 'SEO', description: 'Desc' },
|
||||
})
|
||||
|
||||
describe('Exporter - benefits section', () => {
|
||||
it('renders benefits as a section with h2 and aria-labelledby + list items', () => {
|
||||
const out = exportPage(makePage())
|
||||
const html = out.html
|
||||
expect(html).toContain('<section class="benefits" aria-labelledby="benefits-heading">')
|
||||
expect(html).toContain('<h2 id="benefits-heading">Benefits</h2>')
|
||||
expect(html).toContain('<ul class="benefits-list">')
|
||||
expect(html).toContain('<li class="benefit-item">Fast setup</li>')
|
||||
expect(html).toContain('<li class="benefit-item">No code</li>')
|
||||
expect(html).toContain('<li class="benefit-item">Accessible by default</li>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
|
||||
function makePage(): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'Escape Regression',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
sections: [
|
||||
{ type: 'features', props: { items: ['F<&>"', 'Safe'] } },
|
||||
{ type: 'faq', props: { items: [{ q: 'Q<&>"', a: 'A<&>"' }] } },
|
||||
],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 'SEO', description: 'Desc' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Exporter - HTML escape regression (Features/FAQ)', () => {
|
||||
it('escapes HTML special characters in features items', () => {
|
||||
const out = exportPage(makePage())
|
||||
const html = out.html
|
||||
// feature item should contain escaped characters < & > "
|
||||
expect(html).toContain('<li class="feature-item">F<&>"</li>')
|
||||
})
|
||||
|
||||
it('escapes HTML special characters in faq q/a', () => {
|
||||
const out = exportPage(makePage())
|
||||
const html = out.html
|
||||
expect(html).toContain('<h3 class="faq-q">Q<&>"</h3>')
|
||||
expect(html).toContain('<p class="faq-a">A<&>"</p>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
|
||||
function makePage(): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'Sections Regression',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
sections: [
|
||||
{ type: 'hero', props: { heading: 'Welcome', subheading: '', imageUrl: 'https://via.placeholder.com/800x400' } },
|
||||
{ type: 'features', props: { items: ['F1','F2'] } },
|
||||
{ type: 'benefits', props: { items: ['B1','B2','B3'] } },
|
||||
{ type: 'testimonials', props: { items: [{ author: 'Ada', quote: 'Great' }] } },
|
||||
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }] } },
|
||||
{ type: 'cta', props: { text: 'Start', href: '#' } },
|
||||
],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 'SEO', description: 'Desc' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Exporter - sections regression (includes benefits)', () => {
|
||||
it('renders sections with correct ARIA/heading/list structures', () => {
|
||||
const out = exportPage(makePage())
|
||||
const html = out.html
|
||||
|
||||
// hero h1
|
||||
expect(html).toMatch(/<section class="hero" aria-labelledby="hero-heading">/)
|
||||
expect(html).toMatch(/<h1 id="hero-heading">Welcome<\/h1>/)
|
||||
|
||||
// features
|
||||
expect(html).toMatch(/<section class="features" aria-labelledby="features-heading">/)
|
||||
expect(html).toMatch(/<h2 id="features-heading">Features<\/h2>/)
|
||||
expect(html).toMatch(/<ul class="features-list">[\s\S]*<li class="feature-item">F1<\/li>[\s\S]*<li class="feature-item">F2<\/li>[\s\S]*<\/ul>/)
|
||||
|
||||
// benefits
|
||||
expect(html).toMatch(/<section class="benefits" aria-labelledby="benefits-heading">/)
|
||||
expect(html).toMatch(/<h2 id="benefits-heading">Benefits<\/h2>/)
|
||||
expect(html).toMatch(/<ul class="benefits-list">[\s\S]*<li class="benefit-item">B1<\/li>[\s\S]*<li class="benefit-item">B2<\/li>[\s\S]*<li class="benefit-item">B3<\/li>[\s\S]*<\/ul>/)
|
||||
|
||||
// testimonials
|
||||
expect(html).toMatch(/<section class="testimonials" aria-labelledby="testimonials-heading">/)
|
||||
expect(html).toMatch(/<h2 id="testimonials-heading">Testimonials<\/h2>/)
|
||||
|
||||
// faq with h3
|
||||
expect(html).toMatch(/<section class="faq" aria-labelledby="faq-heading">/)
|
||||
expect(html).toMatch(/<h2 id="faq-heading">FAQ<\/h2>/)
|
||||
expect(html).toMatch(/<h3 class="faq-q">Q1<\/h3>/)
|
||||
|
||||
// cta
|
||||
expect(html).toMatch(/<section class="cta" aria-labelledby="cta-heading">/)
|
||||
expect(html).toMatch(/<h2 id="cta-heading">CTA<\/h2>/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
|
||||
function makePage(): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'Sections Regression CTA/Testimonials',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
sections: [
|
||||
{ type: 'testimonials', props: { items: [{ author: 'Linus', quote: 'Simple and fast' }] } },
|
||||
{ type: 'cta', props: { text: 'Get Started', href: '/start' } },
|
||||
],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 'SEO', description: 'Desc' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Exporter - CTA and Testimonials regression', () => {
|
||||
it('renders testimonials figure/blockquote/figcaption structure', () => {
|
||||
const out = exportPage(makePage())
|
||||
const html = out.html
|
||||
expect(html).toMatch(/<section class="testimonials" aria-labelledby="testimonials-heading">/)
|
||||
expect(html).toMatch(/<h2 id="testimonials-heading">Testimonials<\/h2>/)
|
||||
expect(html).toMatch(/<figure class="testimonial">[\s\S]*<blockquote>“Simple and fast”<\/blockquote>[\s\S]*<figcaption>— Linus<\/figcaption>[\s\S]*<\/figure>/)
|
||||
})
|
||||
|
||||
it('renders CTA link with correct class and href', () => {
|
||||
const out = exportPage(makePage())
|
||||
const html = out.html
|
||||
expect(html).toMatch(/<section class="cta" aria-labelledby="cta-heading">/)
|
||||
expect(html).toMatch(/<h2 id="cta-heading">CTA<\/h2>/)
|
||||
expect(html).toMatch(/<a class="btn-cta" href="\/start">Get Started<\/a>/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
import { pageSchema, type Page } from '@/lib/schema/page'
|
||||
|
||||
function makePage(): Page {
|
||||
return pageSchema.parse({
|
||||
title: 'Sections Regression Features/FAQ',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
sections: [
|
||||
{ type: 'features', props: { items: ['F1', 'F2', 'F3'] } },
|
||||
{ type: 'faq', props: { items: [{ q: 'Q1', a: 'A1' }, { q: 'Q2', a: 'A2' }] } },
|
||||
],
|
||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
||||
seo: { title: 'SEO', description: 'Desc' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Exporter - Features/FAQ regression (structure/classes/count)', () => {
|
||||
it('renders features with list, class names, and correct item count', () => {
|
||||
const out = exportPage(makePage())
|
||||
const html = out.html
|
||||
expect(html).toMatch(/<section class="features" aria-labelledby="features-heading">/)
|
||||
expect(html).toMatch(/<h2 id="features-heading">Features<\/h2>/)
|
||||
expect(html).toMatch(/<ul class="features-list">/)
|
||||
const featureItems = html.match(/<li class="feature-item">/g) || []
|
||||
expect(featureItems.length).toBe(3)
|
||||
})
|
||||
|
||||
it('renders faq with list, h3 question class, and correct item count', () => {
|
||||
const out = exportPage(makePage())
|
||||
const html = out.html
|
||||
expect(html).toMatch(/<section class="faq" aria-labelledby="faq-heading">/)
|
||||
expect(html).toMatch(/<h2 id="faq-heading">FAQ<\/h2>/)
|
||||
expect(html).toMatch(/<ul class="faq-list">/)
|
||||
const qCount = html.match(/<h3 class="faq-q">/g) || []
|
||||
const aCount = html.match(/<p class="faq-a">/g) || []
|
||||
expect(qCount.length).toBe(2)
|
||||
expect(aCount.length).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect } from 'vitest'
|
||||
|
||||
export function assertSectionA11y(html: string) {
|
||||
// basic landmarks
|
||||
expect(html).toContain('<main id="main" role="main">')
|
||||
expect(html).toContain('class="skip-link"')
|
||||
|
||||
// hero h1
|
||||
expect(html).toMatch(/<h1 id="hero-heading">[\s\S]*<\/h1>/)
|
||||
|
||||
// features h2 and aria-labelledby
|
||||
expect(html).toMatch(/<h2 id="features-heading">Features<\/h2>/)
|
||||
expect(html).toMatch(/<section class="features" aria-labelledby="features-heading">/)
|
||||
|
||||
// testimonials h2 and aria-labelledby
|
||||
expect(html).toMatch(/<h2 id="testimonials-heading">Testimonials<\/h2>/)
|
||||
expect(html).toMatch(/<section class="testimonials" aria-labelledby="testimonials-heading">/)
|
||||
|
||||
// FAQ heading levels and structure
|
||||
expect(html).toMatch(/<h2 id="faq-heading">FAQ<\/h2>/)
|
||||
// at least one h3 under FAQ
|
||||
expect(html).toMatch(/<h3 class="faq-q">[\s\S]*<\/h3>/)
|
||||
|
||||
// CTA h2 & aria-labelledby
|
||||
expect(html).toMatch(/<h2 id="cta-heading">CTA<\/h2>/)
|
||||
expect(html).toMatch(/<section class="cta" aria-labelledby="cta-heading">/)
|
||||
|
||||
// focus-visible tokens (CSS)
|
||||
expect(html).toContain('outline: 3px solid')
|
||||
}
|
||||
@@ -37,6 +37,11 @@ const sectionTestimonialsSchema = z.object({
|
||||
props: z.object({ items: z.array(z.object({ author: z.string().min(1), quote: z.string().min(1) })).min(1) }),
|
||||
})
|
||||
|
||||
const sectionBenefitsSchema = z.object({
|
||||
type: z.literal('benefits'),
|
||||
props: z.object({ items: z.array(z.string().min(1)).min(1) }),
|
||||
})
|
||||
|
||||
// 확장: hero, faq, cta, features, testimonials 허용
|
||||
const sectionSchema = z.union([
|
||||
sectionHeroSchema,
|
||||
@@ -44,6 +49,7 @@ const sectionSchema = z.union([
|
||||
sectionCtaSchema,
|
||||
sectionFeaturesSchema,
|
||||
sectionTestimonialsSchema,
|
||||
sectionBenefitsSchema,
|
||||
])
|
||||
|
||||
const baseField = {
|
||||
@@ -52,6 +58,7 @@ const baseField = {
|
||||
required: z.boolean().optional().default(false),
|
||||
pattern: z.string().optional(),
|
||||
maxLength: z.number().int().positive().optional(),
|
||||
help: z.string().optional(),
|
||||
}
|
||||
|
||||
const textField = z.object({ type: z.literal('text'), ...baseField })
|
||||
|
||||
+37
-25
@@ -1,27 +1,39 @@
|
||||
{
|
||||
"app.title": "Landing Page Builder",
|
||||
"builder.title": "Landing Page Builder",
|
||||
"builder.preview": "Preview (static)",
|
||||
"builder.field.title": "Title",
|
||||
"builder.field.locale": "Locale",
|
||||
"builder.field.primaryColor": "Primary Color",
|
||||
"builder.field.fontFamily": "Font Family",
|
||||
"builder.upload.hero": "Hero Image Upload",
|
||||
"builder.action.addHero": "Add Hero",
|
||||
"builder.action.addFAQ": "Add FAQ",
|
||||
"builder.action.addCTA": "Add CTA",
|
||||
"builder.action.export": "Export ZIP",
|
||||
"builder.action.addText": "Add Text",
|
||||
"builder.action.addEmail": "Add Email",
|
||||
"builder.action.addTel": "Add Tel",
|
||||
"builder.action.addDate": "Add Date",
|
||||
"builder.action.addTextarea": "Add Textarea",
|
||||
"builder.action.addCheckbox": "Add Checkbox",
|
||||
"builder.action.addSelect": "Add Select",
|
||||
"builder.action.addRadio": "Add Radio",
|
||||
"builder.sections": "Sections",
|
||||
"builder.form.fields": "Form Fields",
|
||||
"builder.form.actionUrl": "Action URL",
|
||||
"builder.form.honeypot": "Honeypot Field",
|
||||
"builder.form.minSeconds": "Min Submit Seconds"
|
||||
"app": {
|
||||
"title": "Landing Page Builder"
|
||||
},
|
||||
"builder": {
|
||||
"title": "Landing Page Builder",
|
||||
"preview": "Preview (static)",
|
||||
"field": {
|
||||
"title": "Title",
|
||||
"locale": "Locale",
|
||||
"primaryColor": "Primary Color",
|
||||
"fontFamily": "Font Family"
|
||||
},
|
||||
"upload": {
|
||||
"hero": "Hero Image Upload"
|
||||
},
|
||||
"action": {
|
||||
"addHero": "Add Hero",
|
||||
"addFAQ": "Add FAQ",
|
||||
"addCTA": "Add CTA",
|
||||
"export": "Export ZIP",
|
||||
"addText": "Add Text",
|
||||
"addEmail": "Add Email",
|
||||
"addTel": "Add Tel",
|
||||
"addDate": "Add Date",
|
||||
"addTextarea": "Add Textarea",
|
||||
"addCheckbox": "Add Checkbox",
|
||||
"addSelect": "Add Select",
|
||||
"addRadio": "Add Radio"
|
||||
},
|
||||
"sections": "Sections",
|
||||
"form": {
|
||||
"fields": "Form Fields",
|
||||
"actionUrl": "Action URL",
|
||||
"honeypot": "Honeypot Field",
|
||||
"minSeconds": "Min Submit Seconds"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+37
-14
@@ -1,16 +1,39 @@
|
||||
{
|
||||
"app.title": "랜딩 페이지 빌더",
|
||||
"builder.title": "랜딩 페이지 빌더",
|
||||
"builder.preview": "미리보기(정적)",
|
||||
"builder.field.title": "제목",
|
||||
"builder.field.locale": "로케일",
|
||||
"builder.field.primaryColor": "대표 색상",
|
||||
"builder.field.fontFamily": "폰트",
|
||||
"builder.upload.hero": "히어로 이미지 업로드",
|
||||
"builder.action.addHero": "히어로 추가",
|
||||
"builder.action.addFAQ": "FAQ 추가",
|
||||
"builder.action.addCTA": "CTA 추가",
|
||||
"builder.action.export": "ZIP 내보내기",
|
||||
"builder.sections": "섹션",
|
||||
"builder.form.fields": "폼 필드"
|
||||
"app": {
|
||||
"title": "랜딩 페이지 빌더"
|
||||
},
|
||||
"builder": {
|
||||
"title": "랜딩 페이지 빌더",
|
||||
"preview": "미리보기(정적)",
|
||||
"field": {
|
||||
"title": "제목",
|
||||
"locale": "로케일",
|
||||
"primaryColor": "대표 색상",
|
||||
"fontFamily": "폰트"
|
||||
},
|
||||
"upload": {
|
||||
"hero": "히어로 이미지 업로드"
|
||||
},
|
||||
"action": {
|
||||
"addHero": "히어로 추가",
|
||||
"addFAQ": "FAQ 추가",
|
||||
"addCTA": "CTA 추가",
|
||||
"export": "ZIP 내보내기",
|
||||
"addText": "텍스트 필드 추가",
|
||||
"addEmail": "이메일 필드 추가",
|
||||
"addTel": "전화번호 필드 추가",
|
||||
"addDate": "날짜 필드 추가",
|
||||
"addTextarea": "텍스트영역 추가",
|
||||
"addCheckbox": "체크박스 추가",
|
||||
"addSelect": "셀렉트 추가",
|
||||
"addRadio": "라디오 추가"
|
||||
},
|
||||
"sections": "섹션",
|
||||
"form": {
|
||||
"fields": "폼 필드",
|
||||
"actionUrl": "액션 URL",
|
||||
"honeypot": "허니팟 필드",
|
||||
"minSeconds": "최소 제출 대기(초)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
type WindowWithExport = Window & {
|
||||
__exportPreview?: () => { html: string; css: string; js: string }
|
||||
}
|
||||
|
||||
const builderUrl = '/en/builder'
|
||||
|
||||
// This test exports the preview, then loads the exported HTML/CSS in a fresh page
|
||||
// and verifies that the skip-link becomes visible on focus.
|
||||
test('Exported page skip-link becomes visible on focus', async ({ page, context }) => {
|
||||
await page.goto(builderUrl)
|
||||
|
||||
// Minimal content to allow export to produce sections and styles
|
||||
await page.getByTestId('card-hero').click()
|
||||
|
||||
await page.waitForFunction(() => Boolean((window as unknown as WindowWithExport).__exportPreview), { timeout: 5000 })
|
||||
const exported = await page.evaluate(() => (window as unknown as WindowWithExport).__exportPreview?.() ?? null)
|
||||
expect(exported).not.toBeNull()
|
||||
|
||||
// Open a clean page to render exported HTML/CSS and interact with it
|
||||
const preview = await context.newPage()
|
||||
await preview.setContent(exported!.html)
|
||||
|
||||
// Inject CSS into the same document head if not already present
|
||||
await preview.addStyleTag({ content: exported!.css })
|
||||
|
||||
// Ensure skip-link exists and is initially off-screen
|
||||
const skip = preview.locator('a.skip-link')
|
||||
await expect(skip).toHaveCount(1)
|
||||
|
||||
// Focus the skip-link and assert it moved on-screen (left not -9999px)
|
||||
await skip.focus()
|
||||
const box = await skip.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
// left should be >= 0 when focused as per our CSS rule (left:1rem)
|
||||
if (!box) throw new Error('skip-link bounding box was null')
|
||||
expect(box.x).toBeGreaterThanOrEqual(0)
|
||||
|
||||
await preview.close()
|
||||
})
|
||||
@@ -4,10 +4,14 @@ const builderUrl = '/en/builder'
|
||||
|
||||
test('Preview viewport toggle via keyboard (Tab + Enter/Space)', async ({ page }) => {
|
||||
await page.goto(builderUrl)
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
// Ensure the page has focus before sending keyboard events
|
||||
await page.locator('body').click({ position: { x: 1, y: 1 } })
|
||||
|
||||
// Tab to first button (Desktop)
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByRole('button', { name: 'Desktop' })).toBeFocused()
|
||||
// Focus the Desktop button explicitly to avoid flakiness from skip-link/initial focus
|
||||
const desktopBtn = page.getByRole('button', { name: 'Desktop' })
|
||||
await desktopBtn.focus()
|
||||
await expect(desktopBtn).toBeFocused()
|
||||
|
||||
// Press Space to toggle Mobile (first ensure Desktop is selected, then move to Mobile with Tab)
|
||||
await page.keyboard.press('Tab')
|
||||
|
||||
Reference in New Issue
Block a user