') + expect(html).toContain('class="skip-link"') + + // hero h1 + expect(html).toMatch(/

[\s\S]*<\/h1>/) + + // features h2 and aria-labelledby + expect(html).toMatch(/

Features<\/h2>/) + expect(html).toMatch(/
/) + + // testimonials h2 and aria-labelledby + expect(html).toMatch(/

Testimonials<\/h2>/) + expect(html).toMatch(/
/) + + // FAQ heading levels and structure + expect(html).toMatch(/

FAQ<\/h2>/) + // at least one h3 under FAQ + expect(html).toMatch(/

[\s\S]*<\/h3>/) + + // CTA h2 & aria-labelledby + expect(html).toMatch(/

CTA<\/h2>/) + expect(html).toMatch(/
/) + + // focus-visible tokens (CSS) + expect(html).toContain('outline: 3px solid') +} diff --git a/lib/schema/page.ts b/lib/schema/page.ts index faf52d9..5cbd4e2 100644 --- a/lib/schema/page.ts +++ b/lib/schema/page.ts @@ -37,6 +37,11 @@ const sectionTestimonialsSchema = z.object({ props: z.object({ items: z.array(z.object({ author: z.string().min(1), quote: z.string().min(1) })).min(1) }), }) +const sectionBenefitsSchema = z.object({ + type: z.literal('benefits'), + props: z.object({ items: z.array(z.string().min(1)).min(1) }), +}) + // 확장: hero, faq, cta, features, testimonials 허용 const sectionSchema = z.union([ sectionHeroSchema, @@ -44,6 +49,7 @@ const sectionSchema = z.union([ sectionCtaSchema, sectionFeaturesSchema, sectionTestimonialsSchema, + sectionBenefitsSchema, ]) const baseField = { @@ -52,6 +58,7 @@ const baseField = { required: z.boolean().optional().default(false), pattern: z.string().optional(), maxLength: z.number().int().positive().optional(), + help: z.string().optional(), } const textField = z.object({ type: z.literal('text'), ...baseField }) diff --git a/tests/e2e/export-skiplink-focus.e2e.ts b/tests/e2e/export-skiplink-focus.e2e.ts new file mode 100644 index 0000000..b1daa1e --- /dev/null +++ b/tests/e2e/export-skiplink-focus.e2e.ts @@ -0,0 +1,41 @@ +import { test, expect } from '@playwright/test' + +type WindowWithExport = Window & { + __exportPreview?: () => { html: string; css: string; js: string } +} + +const builderUrl = '/en/builder' + +// This test exports the preview, then loads the exported HTML/CSS in a fresh page +// and verifies that the skip-link becomes visible on focus. +test('Exported page skip-link becomes visible on focus', async ({ page, context }) => { + await page.goto(builderUrl) + + // Minimal content to allow export to produce sections and styles + await page.getByTestId('card-hero').click() + + await page.waitForFunction(() => Boolean((window as unknown as WindowWithExport).__exportPreview), { timeout: 5000 }) + const exported = await page.evaluate(() => (window as unknown as WindowWithExport).__exportPreview?.() ?? null) + expect(exported).not.toBeNull() + + // Open a clean page to render exported HTML/CSS and interact with it + const preview = await context.newPage() + await preview.setContent(exported!.html) + + // Inject CSS into the same document head if not already present + await preview.addStyleTag({ content: exported!.css }) + + // Ensure skip-link exists and is initially off-screen + const skip = preview.locator('a.skip-link') + await expect(skip).toHaveCount(1) + + // Focus the skip-link and assert it moved on-screen (left not -9999px) + await skip.focus() + const box = await skip.boundingBox() + expect(box).not.toBeNull() + // left should be >= 0 when focused as per our CSS rule (left:1rem) + if (!box) throw new Error('skip-link bounding box was null') + expect(box.x).toBeGreaterThanOrEqual(0) + + await preview.close() +})