fix: visuals reference matching overhaul and better workflow

This commit is contained in:
fsylvain committed 2026-09-26 21:04:51 +02:00
1 parent 930b836241
commit d493a181e1
25 files changed
+461 -241

No files matched your search

+35 -6
View File
@@ -40,7 +40,8 @@ const contentTypes: Record<string, string> = {
}
const settleMs = 400
const diffThreshold = 48
const diffThreshold = 24
const maxDiffPercent = 0.5
function slug(text: string) {
return text
@@ -302,11 +303,18 @@ async function captureApp(
await route.fulfill(fulfilment(mock))
})
const page = await context.newPage()
if (setup.now) {
if (setup.paused) {
const time = setup.now ? new Date(setup.now) : new Date()
await page.clock.install({ time })
await page.clock.pauseAt(time)
} else if (setup.now) {
await page.clock.setFixedTime(new Date(setup.now))
}
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.evaluate(() => document.fonts.ready)
await page.waitForTimeout(settleMs)
@@ -386,8 +394,9 @@ async function main() {
const design = await serveDesign()
const app = await serveApp()
const browser = await chromium.launch()
const browser = await chromium.launch({ args: ['--disable-lcd-text'] })
const lines: string[] = []
let failures = 0
try {
for (const viewport of args.viewports) {
@@ -402,8 +411,9 @@ async function main() {
const setup = setups[state]
if (!setup) {
failures++
lines.push(
`${state} · ${viewport} · ${theme}: design only, no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
`FAIL ${state} · ${viewport} · ${theme}: no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
)
continue
}
@@ -419,14 +429,25 @@ async function main() {
appPath,
)
const result = await diff(browser, designPath, appPath, diffPath)
const warnings = [
const percent = result.ratio * 100
const budget = setup.exception?.maxPercent ?? maxDiffPercent
const problems = [
...(percent > budget
? [`${percent.toFixed(2)}% exceeds ${budget}% budget`]
: []),
...(result.sizes.split(' vs ')[0] !== result.sizes.split(' vs ')[1]
? [`size mismatch ${result.sizes}`]
: []),
...unmocked.map((key) => `unmocked ${key}`),
]
if (problems.length) {
failures++
}
const exception = setup.exception
? ` [exception ${setup.exception.maxPercent}% approved by ${setup.exception.approvedBy}: ${setup.exception.reason}]`
: ''
lines.push(
`${state} · ${viewport} · ${theme}: ${(result.ratio * 100).toFixed(1)}% pixels differ → ${relative(repoRoot, directory)}${warnings.length ? ` (${warnings.join('; ')})` : ''}`,
`${problems.length ? 'FAIL' : 'PASS'} ${state} · ${viewport} · ${theme}: ${percent.toFixed(2)}% pixels differ → ${relative(repoRoot, directory)}${problems.length ? ` (${problems.join('; ')})` : ''}${exception}`,
)
}
await board.context().close()
@@ -438,14 +459,22 @@ async function main() {
design.close()
}
const verdict = failures
? `FAIL: ${failures} of ${lines.length} captures failed (budget ${maxDiffPercent}% per capture)`
: `PASS: all ${lines.length} captures within budget (${maxDiffPercent}% unless an approved exception is listed)`
const report = [
`# Visual check: ${screen.name}`,
`Board: ${screen.board}, group "${screen.group}"`,
'',
verdict,
'',
...lines.map((line) => `- ${line}`),
].join('\n')
await writeFile(join(outRoot, screen.name, 'report.md'), `${report}\n`)
console.log(report)
if (failures) {
process.exitCode = 1
}
}
await main()
+20 -2
View File
@@ -7,9 +7,17 @@ export interface ApiMock {
hang?: boolean
}
export interface DiffException {
maxPercent: number
reason: string
approvedBy: string
}
export interface VisualState {
exception?: DiffException
path: string
now?: string
paused?: boolean
api?: Record<string, ApiMock>
steps?: (page: Page) => Promise<void>
}
@@ -182,7 +190,15 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
'log-in': {
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },
'Fresh install': { path: '/login', api: { 'GET /v1/auth/registration': registrationOpen } },
'Fresh install': {
path: '/login',
api: { 'GET /v1/auth/registration': registrationOpen },
exception: {
maxPercent: 1.1,
reason: 'The board draws a design-time "API pending" chip in the footer; the API exists, so the app omits it and the theme select fits on the first row on mobile',
approvedBy: 'Florian Sylvain, 2026-09-26',
},
},
'Validation errors': {
...loginWith({ status: 500 }),
steps: submitLogin('ma', ''),
@@ -290,6 +306,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
'Already verified (403)': {
path: '/register/pending',
paused: true,
api: {
'DELETE /v1/auth/register': { status: 403 },
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },
@@ -298,7 +315,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
await confirmCancelDialog(page)
await page.waitForURL('**/home')
await page.getByText("You're verified").waitFor()
},
},
},
@@ -309,6 +326,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
Success: {
path: '/register/validate?c=abc123',
paused: true,
api: {
'POST /v1/auth/verify': { status: 200, body: { message: 'ok' } },
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },