Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33cd06dae0 | |||
| 335389cd1f | |||
| d4650b6f61 | |||
| 81f8c671ae | |||
| 4ad1c9fcc0 | |||
| dc92126ffd | |||
| 311fba19ab |
@@ -1,38 +0,0 @@
|
|||||||
name: Auto PR
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'feat/**'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
open-pr:
|
|
||||||
runs-on: [macos, host]
|
|
||||||
steps:
|
|
||||||
- name: Create Pull Request via Gitea API
|
|
||||||
env:
|
|
||||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
|
||||||
GITEA_BASE_URL: https://gitea.jaybe.dev
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
HEAD_BRANCH: ${{ github.ref_name }}
|
|
||||||
BASE_BRANCH: main
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -z "${CI_TOKEN:-}" ]; then
|
|
||||||
echo "GITEA_TOKEN not set; skipping auto-pr"; exit 0; fi
|
|
||||||
OWNER=$(echo "$REPO" | cut -d'/' -f1)
|
|
||||||
NAME=$(echo "$REPO" | cut -d'/' -f2)
|
|
||||||
TITLE="auto: PR for $HEAD_BRANCH"
|
|
||||||
BODY="Automated PR created on push to $HEAD_BRANCH"
|
|
||||||
echo "Creating PR $HEAD_BRANCH -> $BASE_BRANCH for $OWNER/$NAME"
|
|
||||||
HTTP_CODE=$(curl -sS -o /tmp/pr_resp.json -w "%{http_code}" -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: token ${CI_TOKEN}" \
|
|
||||||
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/pulls" \
|
|
||||||
-d "{\"head\":\"${HEAD_BRANCH}\",\"base\":\"${BASE_BRANCH}\",\"title\":\"${TITLE}\",\"body\":\"${BODY}\"}") || true
|
|
||||||
echo "API status: $HTTP_CODE"
|
|
||||||
if [ "$HTTP_CODE" = "201" ]; then
|
|
||||||
echo "PR created"; cat /tmp/pr_resp.json
|
|
||||||
else
|
|
||||||
echo "PR not created (possibly exists). Response:"; cat /tmp/pr_resp.json || true
|
|
||||||
fi
|
|
||||||
@@ -42,24 +42,3 @@ jobs:
|
|||||||
path: playwright-report
|
path: playwright-report
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
- name: Auto-merge PR on green
|
|
||||||
if: ${{ github.event_name == 'pull_request' }}
|
|
||||||
env:
|
|
||||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
|
||||||
GITEA_BASE_URL: https://gitea.jaybe.dev
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -z "${CI_TOKEN:-}" ]; then
|
|
||||||
echo "CI_TOKEN not set; skipping auto-merge"; exit 0; fi
|
|
||||||
OWNER=$(echo "$REPO" | cut -d'/' -f1)
|
|
||||||
NAME=$(echo "$REPO" | cut -d'/' -f2)
|
|
||||||
echo "Merging PR #$PR_NUMBER for $OWNER/$NAME"
|
|
||||||
RESP=$(curl -sS -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: token ${CI_TOKEN}" \
|
|
||||||
"${GITEA_BASE_URL}/api/v1/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/merge" \
|
|
||||||
-d '{"Do":"merge","MergeMessage":"auto-merge: CI green"}' || true)
|
|
||||||
echo "API response: $RESP"
|
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ export default function I18nProvider({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
// next-intl: Provide a default timeZone to avoid ENVIRONMENT_FALLBACK mismatches
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
<NextIntlClientProvider locale={locale} messages={messages} timeZone="Asia/Seoul">
|
|
||||||
{children}
|
{children}
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// 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()
|
|
||||||
}
|
|
||||||
@@ -41,7 +41,7 @@ describe('Exporter a11y/responsive enhancements', () => {
|
|||||||
test('hero image alt derives from heading', () => {
|
test('hero image alt derives from heading', () => {
|
||||||
const out = exportPage(makePage())
|
const out = exportPage(makePage())
|
||||||
expect(out.html).toContain('<h1 id="hero-heading">Welcome</h1>')
|
expect(out.html).toContain('<h1 id="hero-heading">Welcome</h1>')
|
||||||
expect(out.html).toMatch(/<img[^>]*src="https:\/\/example\.com\/img\.png"[^>]*alt="Welcome"[^>]*\/>/)
|
expect(out.html).toContain('<img src="https://example.com/img.png" alt="Welcome" />')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('responsive and a11y CSS tokens exist', () => {
|
test('responsive and a11y CSS tokens exist', () => {
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
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*\}/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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"')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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,8 +37,7 @@ describe('Exporter - form rendering', () => {
|
|||||||
// select/radio 옵션 존재
|
// select/radio 옵션 존재
|
||||||
expect(out.html).toContain('<select')
|
expect(out.html).toContain('<select')
|
||||||
expect(out.html).toContain('<option value="A">A</option>')
|
expect(out.html).toContain('<option value="A">A</option>')
|
||||||
// 라디오 인풋은 id 속성이 먼저 오므로 type만으로 확인
|
expect(out.html).toContain('<input type="radio"')
|
||||||
expect(out.html).toContain('type="radio"')
|
|
||||||
|
|
||||||
// checkbox/textarea
|
// checkbox/textarea
|
||||||
expect(out.html).toContain('type="checkbox"')
|
expect(out.html).toContain('type="checkbox"')
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
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"\]'\)/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
+12
-69
@@ -40,20 +40,6 @@ 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 }> } }) {
|
function renderTestimonials(section: { props: { items: Array<{ author: string; quote: string }> } }) {
|
||||||
const items: Array<{ author: string; quote: string }> = Array.isArray(section.props?.items)
|
const items: Array<{ author: string; quote: string }> = Array.isArray(section.props?.items)
|
||||||
? section.props.items
|
? section.props.items
|
||||||
@@ -108,7 +94,7 @@ function renderHero(section: { props: { heading: string; subheading?: string; im
|
|||||||
${subheading ? `<p>${escapeHtml(subheading)}</p>` : ''}
|
${subheading ? `<p>${escapeHtml(subheading)}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" loading="lazy" decoding="async" />
|
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -116,74 +102,45 @@ function renderHero(section: { props: { heading: string; subheading?: string; im
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderField(field: FormField) {
|
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 pat = 'pattern' in field && field.pattern ? ` pattern="${escapeHtml(field.pattern)}"` : ''
|
||||||
const max = 'maxLength' in field && field.maxLength ? ` maxlength="${field.maxLength}"` : ''
|
const max = 'maxLength' in field && field.maxLength ? ` maxlength="${field.maxLength}"` : ''
|
||||||
const describedBy = hasHelp ? ` aria-describedby="${helpId}"` : ''
|
const common = `name="${escapeHtml(field.name)}" aria-label="${escapeHtml(field.label)}" ${field.required ? 'required' : ''}${pat}${max}`
|
||||||
const common = `id="${id}" name="${escapeHtml(field.name)}" aria-label="${escapeHtml(field.label)}" ${field.required ? 'required' : ''}${pat}${max}${describedBy}`
|
|
||||||
|
|
||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
case 'email':
|
case 'email':
|
||||||
case 'tel':
|
case 'tel':
|
||||||
case 'date':
|
case 'date':
|
||||||
return `
|
return `<label>${escapeHtml(field.label)}<input type="${field.type}" ${common} /></label>`
|
||||||
<div class="form-control">
|
|
||||||
<label for="${id}">${escapeHtml(field.label)}</label>
|
|
||||||
<input type="${field.type}" ${common} />
|
|
||||||
${helpHtml}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
case 'textarea':
|
case 'textarea':
|
||||||
return `
|
return `<label>${escapeHtml(field.label)}<textarea ${common}></textarea></label>`
|
||||||
<div class="form-control">
|
|
||||||
<label for="${id}">${escapeHtml(field.label)}</label>
|
|
||||||
<textarea ${common}></textarea>
|
|
||||||
${helpHtml}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
case 'checkbox':
|
case 'checkbox':
|
||||||
return `
|
return `<label><input type="checkbox" ${common} />${escapeHtml(field.label)}</label>`
|
||||||
<div class="form-control">
|
|
||||||
<label for="${id}">${escapeHtml(field.label)}</label>
|
|
||||||
<input type="checkbox" ${common} />
|
|
||||||
${helpHtml}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
case 'radio': {
|
case 'radio': {
|
||||||
const { options } = field as Extract<FormField, { type: 'radio' }>
|
const { options } = field as Extract<FormField, { type: 'radio' }>
|
||||||
const fsDescribed = hasHelp ? ` aria-describedby="${helpId}"` : ''
|
|
||||||
return `
|
return `
|
||||||
<fieldset${fsDescribed}>
|
<fieldset>
|
||||||
<legend>${escapeHtml(field.label)}</legend>
|
<legend>${escapeHtml(field.label)}</legend>
|
||||||
${options
|
${options
|
||||||
.map(
|
.map(
|
||||||
(opt: string, i: number) => {
|
(opt: string) =>
|
||||||
const optId = `${id}-opt-${i}`
|
`<label><input type="radio" ${common} value="${escapeHtml(
|
||||||
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>`
|
opt
|
||||||
}
|
)}" />${escapeHtml(opt)}</label>`
|
||||||
)
|
)
|
||||||
.join('')}
|
.join('')}
|
||||||
${helpHtml}
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
case 'select': {
|
case 'select': {
|
||||||
const { options } = field as Extract<FormField, { type: 'select' }>
|
const { options } = field as Extract<FormField, { type: 'select' }>
|
||||||
return `
|
return `
|
||||||
<div class="form-control">
|
<label>${escapeHtml(field.label)}
|
||||||
<label for="${id}">${escapeHtml(field.label)}</label>
|
|
||||||
<select ${common}>
|
<select ${common}>
|
||||||
${options
|
${options
|
||||||
.map((opt: string) => `<option value="${escapeHtml(opt)}">${escapeHtml(opt)}</option>`)
|
.map((opt: string) => `<option value="${escapeHtml(opt)}">${escapeHtml(opt)}</option>`)
|
||||||
.join('')}
|
.join('')}
|
||||||
</select>
|
</select>
|
||||||
${helpHtml}
|
</label>
|
||||||
</div>
|
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -237,7 +194,6 @@ function buildHead(page: Page) {
|
|||||||
return `
|
return `
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="canonical" href="/" />
|
|
||||||
<title>${escapeHtml(page.seo.title)}</title>
|
<title>${escapeHtml(page.seo.title)}</title>
|
||||||
<meta name="description" content="${escapeHtml(page.seo.description)}" />
|
<meta name="description" content="${escapeHtml(page.seo.description)}" />
|
||||||
<meta property="og:title" content="${escapeHtml(page.seo.title)}" />
|
<meta property="og:title" content="${escapeHtml(page.seo.title)}" />
|
||||||
@@ -273,7 +229,6 @@ function buildCss(page: Page) {
|
|||||||
.testimonial blockquote{ margin:0; font-style:italic; color:#1f2937 }
|
.testimonial blockquote{ margin:0; font-style:italic; color:#1f2937 }
|
||||||
.testimonial figcaption{ color:#475569; margin-top:.25rem }
|
.testimonial figcaption{ color:#475569; margin-top:.25rem }
|
||||||
@media (max-width: 640px){ .container{ padding: 1rem } h1{ font-size: 1.5rem } }
|
@media (max-width: 640px){ .container{ padding: 1rem } h1{ font-size: 1.5rem } }
|
||||||
@media (prefers-color-scheme: dark){ body{ color:#e5e7eb; background:#0b1020 } .feature-item{ background:#0f172a; border-color:#1f2937 } }
|
|
||||||
@media (prefers-reduced-motion: reduce){ *{ transition: none !important; animation: none !important } }
|
@media (prefers-reduced-motion: reduce){ *{ transition: none !important; animation: none !important } }
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
@@ -284,17 +239,6 @@ function buildJs() {
|
|||||||
(function(){
|
(function(){
|
||||||
var ts = document.querySelector('input[name="_ts"]');
|
var ts = document.querySelector('input[name="_ts"]');
|
||||||
if(ts){ ts.value = String(Date.now()); }
|
if(ts){ ts.value = String(Date.now()); }
|
||||||
// Ensure skip-link moves focus into main for a11y
|
|
||||||
var skip = document.querySelector('a.skip-link');
|
|
||||||
var main = document.getElementById('main');
|
|
||||||
if (skip && main) {
|
|
||||||
skip.addEventListener('click', function(){
|
|
||||||
if (!main.hasAttribute('tabindex')) {
|
|
||||||
main.setAttribute('tabindex', '-1');
|
|
||||||
}
|
|
||||||
main.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
@@ -306,7 +250,6 @@ export function exportPage(page: Page, opts?: ExportOptions): Exported {
|
|||||||
if (s.type === 'faq') return renderFaq(s)
|
if (s.type === 'faq') return renderFaq(s)
|
||||||
if (s.type === 'cta') return renderCta(s)
|
if (s.type === 'cta') return renderCta(s)
|
||||||
if (s.type === 'features') return renderFeatures(s)
|
if (s.type === 'features') return renderFeatures(s)
|
||||||
if (s.type === 'benefits') return renderBenefits(s)
|
|
||||||
if (s.type === 'testimonials') return renderTestimonials(s)
|
if (s.type === 'testimonials') return renderTestimonials(s)
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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>')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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>')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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>/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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>/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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: 'SEO/Perf',
|
|
||||||
locale: 'en',
|
|
||||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
|
||||||
sections: [
|
|
||||||
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://img.example/hero.png' } },
|
|
||||||
],
|
|
||||||
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
|
|
||||||
seo: { title: 'SEO', description: 'Desc' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Exporter - small SEO/Perf improvements', () => {
|
|
||||||
it('includes canonical link and dark mode tokens; hero image lazy/async', () => {
|
|
||||||
const out = exportPage(makePage())
|
|
||||||
const html = out.html
|
|
||||||
const css = out.css
|
|
||||||
|
|
||||||
// canonical link present in head
|
|
||||||
expect(html).toMatch(/<link[^>]+rel="canonical"/)
|
|
||||||
|
|
||||||
// hero image has loading/decoding attributes
|
|
||||||
expect(html).toMatch(/<img[^>]*loading="lazy"/)
|
|
||||||
expect(html).toMatch(/<img[^>]*decoding="async"/)
|
|
||||||
|
|
||||||
// dark mode media query token exists
|
|
||||||
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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,11 +37,6 @@ const sectionTestimonialsSchema = z.object({
|
|||||||
props: z.object({ items: z.array(z.object({ author: z.string().min(1), quote: z.string().min(1) })).min(1) }),
|
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 허용
|
// 확장: hero, faq, cta, features, testimonials 허용
|
||||||
const sectionSchema = z.union([
|
const sectionSchema = z.union([
|
||||||
sectionHeroSchema,
|
sectionHeroSchema,
|
||||||
@@ -49,7 +44,6 @@ const sectionSchema = z.union([
|
|||||||
sectionCtaSchema,
|
sectionCtaSchema,
|
||||||
sectionFeaturesSchema,
|
sectionFeaturesSchema,
|
||||||
sectionTestimonialsSchema,
|
sectionTestimonialsSchema,
|
||||||
sectionBenefitsSchema,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const baseField = {
|
const baseField = {
|
||||||
@@ -58,7 +52,6 @@ const baseField = {
|
|||||||
required: z.boolean().optional().default(false),
|
required: z.boolean().optional().default(false),
|
||||||
pattern: z.string().optional(),
|
pattern: z.string().optional(),
|
||||||
maxLength: z.number().int().positive().optional(),
|
maxLength: z.number().int().positive().optional(),
|
||||||
help: z.string().optional(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const textField = z.object({ type: z.literal('text'), ...baseField })
|
const textField = z.object({ type: z.literal('text'), ...baseField })
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test'
|
|
||||||
|
|
||||||
type WindowWithExport = Window & {
|
|
||||||
__exportPreview?: () => { html: string; css: string; js: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
const builderUrl = '/en/builder'
|
|
||||||
|
|
||||||
// Verify that focus outline tokens from CSS are applied in a real browser context
|
|
||||||
// by focusing the submit button in the exported page and inspecting computed styles.
|
|
||||||
test('Exported page shows 3px solid outline color on focused button', async ({ page, context }) => {
|
|
||||||
await page.goto(builderUrl)
|
|
||||||
// Minimal content to export
|
|
||||||
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()
|
|
||||||
|
|
||||||
const preview = await context.newPage()
|
|
||||||
await preview.setContent(exported!.html)
|
|
||||||
await preview.addStyleTag({ content: exported!.css })
|
|
||||||
|
|
||||||
const submit = preview.locator('button[type="submit"]')
|
|
||||||
await expect(submit).toHaveCount(1)
|
|
||||||
await submit.focus()
|
|
||||||
|
|
||||||
// Read computed style in the page context
|
|
||||||
const style = await preview.evaluate(() => {
|
|
||||||
const el = document.querySelector('button[type="submit"]') as HTMLElement | null
|
|
||||||
if (!el) return null
|
|
||||||
const cs = window.getComputedStyle(el)
|
|
||||||
return { outlineWidth: cs.outlineWidth, outlineStyle: cs.outlineStyle, outlineColor: cs.outlineColor }
|
|
||||||
})
|
|
||||||
expect(style).not.toBeNull()
|
|
||||||
// Expect 3px solid and brand color (#2563eb => rgb(37, 99, 235))
|
|
||||||
expect(style!.outlineWidth).toBe('3px')
|
|
||||||
expect(style!.outlineStyle).toBe('solid')
|
|
||||||
expect(style!.outlineColor.replace(/\s+/g, '')).toMatch(/rgb\(37,99,235\)|#2563eb/i)
|
|
||||||
|
|
||||||
await preview.close()
|
|
||||||
})
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test'
|
|
||||||
|
|
||||||
type WindowWithExport = Window & {
|
|
||||||
__exportPreview?: () => { html: string; css: string; js: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
const builderUrl = '/en/builder'
|
|
||||||
|
|
||||||
// Verify tab order after activating the skip-link: focus should move into <main>
|
|
||||||
// Then the first Tab should reach a focusable descendant (e.g., form submit button)
|
|
||||||
test('Skip-link Enter moves focus into main; next Tab reaches first focusable', async ({ page, context }) => {
|
|
||||||
await page.goto(builderUrl)
|
|
||||||
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()
|
|
||||||
|
|
||||||
const preview = await context.newPage()
|
|
||||||
await preview.setContent(exported!.html)
|
|
||||||
await preview.addStyleTag({ content: exported!.css })
|
|
||||||
|
|
||||||
const skip = preview.locator('a.skip-link')
|
|
||||||
await expect(skip).toHaveCount(1)
|
|
||||||
await skip.focus()
|
|
||||||
await expect(skip).toBeFocused()
|
|
||||||
|
|
||||||
// Activate skip-link
|
|
||||||
await preview.keyboard.press('Enter')
|
|
||||||
|
|
||||||
// Focus should be inside #main or on #main
|
|
||||||
const focusedInMain = await preview.evaluate(() => {
|
|
||||||
const main = document.getElementById('main')!
|
|
||||||
const active = document.activeElement as HTMLElement | null
|
|
||||||
if (!active) return false
|
|
||||||
return active === main || main.contains(active)
|
|
||||||
})
|
|
||||||
expect(focusedInMain).toBeTruthy()
|
|
||||||
|
|
||||||
// Press Tab and expect a focusable inside main (submit button) to receive focus
|
|
||||||
await preview.keyboard.press('Tab')
|
|
||||||
const isSubmitFocused = await preview.evaluate(() => {
|
|
||||||
const active = document.activeElement as HTMLElement | null
|
|
||||||
return !!active && active.tagName.toLowerCase() === 'button' && (active as HTMLButtonElement).type === 'submit'
|
|
||||||
})
|
|
||||||
expect(isSubmitFocused).toBeTruthy()
|
|
||||||
|
|
||||||
await preview.close()
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user