test: expand exporter/css/a11y/i18n regressions and add e2e skip-link focus check
This commit is contained in:
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user