214 lines
6.3 KiB
TypeScript
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()
|