Files
RenewCMS/web/visual/touchTargets.ts
T

214 lines
6.3 KiB
TypeScript

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<string, ApiMock> = {
'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<string, number>()
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()