| 1 | import { test, expect } from '@playwright/test'; |
| 2 | |
| 3 | const NOISE_SUPPRESSION_CSS = ` |
| 4 | .cost-dashboard__date, |
| 5 | [data-cost-date], |
| 6 | .run-date, |
| 7 | .last-updated { visibility: hidden !important; } |
| 8 | .dynamic-counter, .repo-count, [data-live] { visibility: hidden !important; } |
| 9 | *, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; } |
| 10 | `; |
| 11 | |
| 12 | const VIEWPORTS = [ |
| 13 | { width: 320, height: 568 }, |
| 14 | { width: 360, height: 640 }, |
| 15 | { width: 390, height: 844 }, |
| 16 | { width: 414, height: 896 }, |
| 17 | { width: 768, height: 1024 }, |
| 18 | ]; |
| 19 | |
| 20 | const PAGES = [ |
| 21 | { key: 'home', label: 'home', path: '/' }, |
| 22 | { key: 'weekly', label: 'weekly', path: '/weekly/2026/w22/' }, |
| 23 | { key: 'monthly', label: 'monthly', path: '/monthly/2026/05/' }, |
| 24 | { key: 'yearly', label: 'yearly', path: '/yearly/2026/' }, |
| 25 | ]; |
| 26 | |
| 27 | async function settle(page) { |
| 28 | await page.waitForLoadState('networkidle').catch(() => page.waitForTimeout(1000)); |
| 29 | await page.addStyleTag({ content: NOISE_SUPPRESSION_CSS }); |
| 30 | await page.waitForTimeout(300); |
| 31 | } |
| 32 | |
| 33 | async function openPage(page, path, viewport) { |
| 34 | await page.setViewportSize(viewport); |
| 35 | await page.emulateMedia({ colorScheme: 'light' }); |
| 36 | await page.goto(path); |
| 37 | await settle(page); |
| 38 | } |
| 39 | |
| 40 | function formatViewport({ width }) { |
| 41 | return `${width}px`; |
| 42 | } |
| 43 | |
| 44 | function skipNonMatrixProject(testInfo) { |
| 45 | test.skip(testInfo.project.name !== 'desktop-light', 'A11y matrix is defined in-spec.'); |
| 46 | } |
| 47 | |
| 48 | for (const pageConfig of PAGES) { |
| 49 | test.describe(pageConfig.label, () => { |
| 50 | for (const viewport of VIEWPORTS) { |
| 51 | const viewportLabel = formatViewport(viewport); |
| 52 | |
| 53 | test(`${pageConfig.label} — no horizontal overflow at ${viewportLabel}`, async ({ page }, testInfo) => { |
| 54 | skipNonMatrixProject(testInfo); |
| 55 | await openPage(page, pageConfig.path, viewport); |
| 56 | |
| 57 | const hasNoOverflow = await page.evaluate(() => { |
| 58 | const root = document.documentElement; |
| 59 | return root.scrollWidth <= root.clientWidth; |
| 60 | }); |
| 61 | |
| 62 | expect(hasNoOverflow).toBe(true); |
| 63 | }); |
| 64 | |
| 65 | test(`${pageConfig.label} — tap targets ≥ 44x44 at ${viewportLabel}`, async ({ page }, testInfo) => { |
| 66 | skipNonMatrixProject(testInfo); |
| 67 | await openPage(page, pageConfig.path, viewport); |
| 68 | |
| 69 | const violations = await page.evaluate(() => { |
| 70 | const minSize = 44; |
| 71 | const round = value => Math.round(value * 10) / 10; |
| 72 | |
| 73 | return Array.from(document.querySelectorAll('a, button')) |
| 74 | .filter(element => !element.hasAttribute('data-small-ok')) |
| 75 | .map(element => { |
| 76 | const rect = element.getBoundingClientRect(); |
| 77 | const style = window.getComputedStyle(element); |
| 78 | |
| 79 | if ( |
| 80 | style.display === 'none' || |
| 81 | style.visibility === 'hidden' || |
| 82 | rect.width <= 0 || |
| 83 | rect.height <= 0 |
| 84 | ) { |
| 85 | return null; |
| 86 | } |
| 87 | |
| 88 | if (rect.width >= minSize && rect.height >= minSize) { |
| 89 | return null; |
| 90 | } |
| 91 | |
| 92 | const label = |
| 93 | element.getAttribute('aria-label') || |
| 94 | element.textContent?.replace(/\s+/g, ' ').trim() || |
| 95 | element.getAttribute('href') || |
| 96 | element.tagName.toLowerCase(); |
| 97 | |
| 98 | return { |
| 99 | tag: element.tagName.toLowerCase(), |
| 100 | label, |
| 101 | width: round(rect.width), |
| 102 | height: round(rect.height), |
| 103 | }; |
| 104 | }) |
| 105 | .filter(Boolean); |
| 106 | }); |
| 107 | |
| 108 | expect(violations, `Tap target violations: ${JSON.stringify(violations, null, 2)}`).toEqual([]); |
| 109 | }); |
| 110 | |
| 111 | if (pageConfig.key === 'home' && viewport.width <= 414) { |
| 112 | test(`${pageConfig.label} — main content starts within 600px at ${viewportLabel}`, async ({ page }, testInfo) => { |
| 113 | skipNonMatrixProject(testInfo); |
| 114 | await openPage(page, pageConfig.path, viewport); |
| 115 | |
| 116 | const mainTop = await page.evaluate(() => { |
| 117 | const main = |
| 118 | document.querySelector('.main') || |
| 119 | document.querySelector('main') || |
| 120 | document.querySelector('#main-content'); |
| 121 | |
| 122 | if (!main) { |
| 123 | throw new Error('No main content element found'); |
| 124 | } |
| 125 | |
| 126 | return main.getBoundingClientRect().top; |
| 127 | }); |
| 128 | |
| 129 | expect(mainTop).toBeLessThanOrEqual(600); |
| 130 | }); |
| 131 | } |
| 132 | } |
| 133 | }); |
| 134 | } |