import { join, resolve } from 'node:path' import { build, preview } from 'vite' import { chromium, type Browser, type Locator, type Page } from 'playwright' import { listApi } from './articleListStates' import { visualStates, type ApiMock, type VisualState } from './states' interface TouchTarget { name: string find: (page: Page) => Locator todo?: string } interface TouchCheck { label: string setup: VisualState targets: TouchTarget[] } const MOBILE = { width: 390, height: 844 } const MIN_TARGET_PX = 44 const SETTLE_MS = 400 const webRoot = resolve(import.meta.dirname, '..') const distRoot = join(webRoot, '..', 'tmp', 'visual', 'touch-dist') const configFile = join(webRoot, 'vite.config.ts') const shellApi: Record = { 'GET /v1/content-type': { status: 200, body: [] }, } const editorStates = visualStates['localization-editor'] ?? {} function editorState(name: string) { const setup = editorStates[name] if (!setup) { throw new Error(`No localization-editor setup named ${name}`) } return setup } const byRole = (role: 'button' | 'menuitem', name: string | RegExp) => (page: Page) => page.getByRole(role, { name, exact: typeof name === 'string' }) const checks: TouchCheck[] = [ { label: 'Editor header (loc.l5.default)', setup: editorState('loc.l5.default'), targets: [ { name: 'Language switcher', find: byRole('button', /Switch version$/) }, { name: 'Save', find: byRole('button', 'Save') }, { name: 'Publish French', find: byRole('button', 'Publish French') }, { name: 'More actions', find: byRole('button', 'More actions') }, ], }, { label: 'Language sheet rows (loc.l5.menu)', setup: editorState('loc.l5.menu'), targets: ['English', 'French', 'German', 'Arabic'].map((language) => ({ name: `${language} row`, find: byRole('menuitem', new RegExp(`^${language}`)), })), }, { label: 'Article list coverage overflow chip (loc.l7.long)', setup: { path: '/article?site=atelier&status=draft&seo=suggestions&size=5', api: listApi(), }, targets: [ { name: '+N chip', find: byRole('button', /more languages?:/), todo: 'components.md wants a 44 px tap area at 390 px; StoryCoverage draws the 22 px chip with no larger hit area (Phase 4)', }, ], }, ] async function serveApp() { await build({ configFile, root: webRoot, logLevel: 'warn', build: { outDir: distRoot, emptyOutDir: true }, }) const server = await preview({ configFile, root: webRoot, logLevel: 'warn', build: { outDir: distRoot }, preview: { port: 4190, strictPort: false, open: false }, }) const origin = server.resolvedUrls?.local[0]?.replace(/\/$/, '') if (!origin) { throw new Error('Vite preview did not report a local URL') } return { origin, close: () => server.close() } } function fulfilment(mock: ApiMock, url: URL, call: number) { const body = mock.bodyFor ? mock.bodyFor(url, call) : mock.body const text = typeof body === 'string' return { status: mock.status, headers: { ...mock.headers, ...mock.headersFor?.(url, call) }, contentType: text ? 'text/plain; charset=utf-8' : 'application/json', body: body === undefined ? '' : text ? (body as string) : JSON.stringify(body), } } async function openState(browser: Browser, origin: string, setup: VisualState) { const context = await browser.newContext({ viewport: MOBILE, reducedMotion: 'reduce' }) await context.route( (url) => url.hostname.startsWith('api.') && url.hostname !== new URL(origin).hostname, (route) => route.fulfill({ status: 404, body: '' }), ) const calls = new Map() await context.route('**/v1/**', async (route) => { const request = route.request() const url = new URL(request.url()) const key = `${request.method()} ${url.pathname}` const call = (calls.get(key) ?? 0) + 1 calls.set(key, call) const mock = setup.api?.[key] ?? shellApi[key] if (!mock) { await route.fulfill({ status: 404, body: '' }) return } if (mock.hang) { return } await route.fulfill(fulfilment(mock, url, call)) }) const page = await context.newPage() await page.goto(`${origin}${setup.path}`) await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(2000)]) await page.waitForFunction( () => (document.querySelector('#app')?.textContent ?? '').trim().length > 0, ) await setup.steps?.(page) await page.waitForTimeout(SETTLE_MS) return page } async function hitHeight(target: Locator) { return target.evaluate((element) => { const box = element.getBoundingClientRect() const x = box.left + box.width / 2 const middle = box.top + box.height / 2 const hits = (y: number) => { const found = document.elementFromPoint(x, y) return found !== null && (found === element || element.contains(found)) } let top = middle while (top > middle - 40 && hits(top - 1)) { top -= 1 } let bottom = middle while (bottom < middle + 40 && hits(bottom + 1)) { bottom += 1 } return { box: box.height, hit: bottom - top + 1 } }) } async function main() { const app = await serveApp() const browser = await chromium.launch() const lines: string[] = [] let failures = 0 let todos = 0 try { for (const check of checks) { const page = await openState(browser, app.origin, check.setup) for (const target of check.targets) { const located = target.find(page).first() if ((await located.count()) === 0) { failures++ lines.push(`FAIL ${check.label} · ${target.name}: not found`) continue } const { box, hit } = await hitHeight(located) const height = Math.max(Math.round(box), hit) const pass = height >= MIN_TARGET_PX const measured = `box ${box.toFixed(1)} px, tap area ${hit} px` if (target.todo && !pass) { todos++ lines.push(`TODO ${check.label} · ${target.name}: ${measured} (${target.todo})`) continue } if (!pass) { failures++ } lines.push(`${pass ? 'PASS' : 'FAIL'} ${check.label} · ${target.name}: ${measured}`) } await page.context().close() } } finally { await browser.close() await app.close() } console.log(lines.join('\n')) console.log( failures ? `FAIL: ${failures} touch targets under ${MIN_TARGET_PX} px at 390 px` : `PASS: every checked touch target is at least ${MIN_TARGET_PX} px high at 390 px${todos ? `, ${todos} known gap${todos > 1 ? 's' : ''} listed as TODO` : ''}`, ) if (failures) { process.exitCode = 1 } } await main()