Files
RenewCMS/web/visual/check.ts
T

903 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createServer } from 'node:http'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import type { AddressInfo } from 'node:net'
import { extname, join, relative, resolve } from 'node:path'
import { build, preview } from 'vite'
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'
import { icons as lucide } from '@iconify-json/lucide'
import { getIconData, getIcons, iconToHTML, iconToSVG, replaceIDs } from '@iconify/utils'
import { visualStates, type ApiMock, type DiffException, type VisualState } from './states'
type Viewport = 'desktop' | 'mobile'
type Theme = 'light' | 'dark'
interface Source {
board: string
group: string
states: string[]
alias: Record<string, string>
}
interface Screen {
name: string
board: string
group: string
states: string[]
sources: Source[]
}
const VISUAL_REFERENCE =
/Visual reference:\*{0,2}\s*`(?:source\/)?([^`]+)`,?\s*groups?\s+((?:"[^"]+"(?:,\s*)?)+)/
const officeBoard = 'RenewCMS Screens Back-office.dc.html'
const extraSources: Record<string, Source[]> = {
articles: [
{
board: officeBoard,
group: 'Articles: pagination',
states: [
'First page',
'Middle page',
'Last page',
'Single page',
'Loading next page',
'Error loading a page',
'Page out of range',
'Search resets to page 1',
'Empty after search',
'Page size menu',
'Focus-visible',
],
alias: {},
},
],
activity: [
{
board: officeBoard,
group: 'Activity: filter cards',
states: [
'Default, no filter',
'Card hover',
'Card focus-visible',
'Failed card active',
'Active card hover',
'Security card active',
'Chip × hover',
'Clear all hover',
'Clear all focus-visible',
'Card plus other filters',
],
alias: {},
},
{
board: officeBoard,
group: 'Activity: export',
states: [
'Menu open',
'Menu, item hover',
'Menu, filtered view',
'Menu, nothing to export',
'Menu, no permission',
'Export button focus-visible',
'Dialog: configure, CSV only',
'Dialog: configure, CSV or JSON (proposed)',
'Dialog: preparing download',
'Dialog: download started',
'Dialog: failed',
'Dialog: nothing to export',
'Dialog: not allowed (403)',
'Toast: current view',
'Toast: export all',
],
alias: {},
},
{
board: officeBoard,
group: 'Activity details',
states: [
'Edit with a diff',
'Settings diff, long text',
'Failed action, very long target title',
'Deleted target, no diff',
'System-initiated, no human actor',
'Security event, failed login',
],
alias: {},
},
{
board: officeBoard,
group: 'My activity',
states: ['Default', 'Empty', 'Details'],
alias: {},
},
],
settings: [
{
board: officeBoard,
group: 'Settings: Media cleanup',
states: [
'Default: off, never run',
'On: 30 days, last run',
'Saving',
'Saved',
'Save failed',
'Validation error',
'Read-only (non-admin)',
'Loading skeleton',
'Offline',
],
alias: {},
},
{
board: officeBoard,
group: 'Settings: Retention',
states: [
'Default, 90 days or 5 GB',
'Age only (no size limit)',
'Size only (forever)',
'Shortening the period',
'Forever',
'Save error',
],
alias: {},
},
{
board: officeBoard,
group: 'Purge dialog',
states: [
'Loading preview',
'Confirm',
'Confirm: many groups',
'In progress',
'Nothing to purge',
'Error',
'Partial failure',
'Conflict (409)',
],
alias: {},
},
{
board: officeBoard,
group: 'Purge results',
states: ['Success', 'Success, automatic purge on'],
alias: {},
},
],
media: [
{
board: officeBoard,
group: 'Media duplicates entry',
states: ['Duplicates tab with the button', 'Duplicates tab: offline'],
alias: {},
},
],
}
const boardAlias: Record<string, Record<string, string>> = {
media: { 'Duplicates: purge all': 'Purge duplicates: confirm' },
}
const webRoot = resolve(import.meta.dirname, '..')
const repoRoot = resolve(webRoot, '..')
const designRoot = join(repoRoot, 'docs', 'design')
const sourceRoot = join(designRoot, 'source')
const outRoot = join(repoRoot, 'tmp', 'visual')
const distRoot = join(outRoot, 'dist')
const configFile = join(webRoot, 'vite.config.ts')
const frames: Record<Viewport, { width: number; height: number }> = {
desktop: { width: 1440, height: 900 },
mobile: { width: 390, height: 844 },
}
const contentTypes: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript',
'.css': 'text/css',
'.svg': 'image/svg+xml',
'.json': 'application/json',
}
const distContentTypes: Record<string, string> = {
...contentTypes,
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ico': 'image/x-icon',
}
const settleMs = 400
const diffThreshold = 24
const maxDiffPercent = 10
function budgetFor(cap: DiffException['maxPercent'] | undefined, viewport: Viewport) {
if (cap === undefined) {
return maxDiffPercent
}
return typeof cap === 'number' ? cap : cap[viewport]
}
function describeCap(cap: DiffException['maxPercent']) {
return typeof cap === 'number' ? `${cap}%` : `${cap.desktop}% desktop, ${cap.mobile}% mobile`
}
function slug(text: string) {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
function parseArgs(argv: string[]) {
const flags = new Map<string, string>()
const positional: string[] = []
for (const arg of argv) {
const flag = /^--(\w+)=(.+)$/.exec(arg)
if (flag) {
flags.set(flag[1]!, flag[2]!)
} else {
positional.push(arg)
}
}
const [screen, ...stateWords] = positional
if (!screen) {
throw new Error(
'Usage: task visual:check -- <screen> [state] [--viewport=desktop|mobile] [--theme=light|dark]',
)
}
const viewports = (flags.get('viewport')?.split(',') ?? ['desktop', 'mobile']) as Viewport[]
const themes = (flags.get('theme')?.split(',') ?? ['light', 'dark']) as Theme[]
return { screen, state: stateWords.join(' ') || undefined, viewports, themes }
}
function tableStates(markdown: string) {
return [...markdown.matchAll(/^\| ([^|]+?) \|/gm)]
.map((match) => match[1]!.trim())
.filter((cell) => cell !== 'State' && !/^-+$/.test(cell))
}
const variantsBoard = officeBoard
function numberedStates(prefix: string, names: string[]) {
return Object.fromEntries(
names.map((state, index) => [
state,
`${prefix}${String.fromCharCode(97 + index)} · ${state}`,
]),
)
}
function variantsScreen(): Screen {
const listNames = [
'Loaded',
'Loading',
'Empty',
'Error loading',
'Forbidden',
'Row menu',
'Row action in progress',
'Purge confirm',
'Delete confirm',
'Delete in progress',
'Request reference',
'Entry: sidebar',
]
const formNames = [
'Create: pristine',
'Create: dirty',
'Field errors',
'No dimension',
'Name already taken',
'Saving',
'Save error',
'Edit: pristine',
'Edit: dirty',
'Unsaved changes',
'PNG: no quality',
]
const sources: Source[] = [
{
board: variantsBoard,
group: 'Variants list',
states: listNames,
alias: numberedStates('V1', listNames),
},
{
board: variantsBoard,
group: 'Add edit variant',
states: formNames,
alias: numberedStates('V2', formNames),
},
]
const libraryNames = [
'V3a · Grid with badges',
'V3b · Sort menu',
'V3c · Filter: With variants, sorted by total size',
'V3d · Filter: Without variants',
'V3e · Filter: nothing to show',
'V3f · Delete confirm',
]
const detailNames = [
'V4a · Partly generated',
'V4b · Nothing generated yet',
'V4c · Generating',
'V4d · Generation failed',
'V4e · No variants defined',
'V4f · Source is an SVG',
'V4h · Preview dialog',
]
const editorNames = [
'V6a · Variant select',
'V6b · Select open',
'V6c · No variants defined',
'V6d · Variant deleted',
'V6e · Variant disabled',
]
const cacheNames = [
'V5a · Loaded',
'V5b · Loading',
'V5c · Empty cache',
'V5d · Purging',
'V5e · Purge done',
'V5f · Purge failed',
'V5g · Error loading',
'V5h · Purge all: confirm',
'V5i · Purge all: confirm, typed',
'V5j · Purge unused: confirm',
]
const navNames = [
'N1 · Parent selected',
'N2 · Child selected: Variants',
'N3 · Child selected: Cache',
'N4 · Mobile drawer, parent selected',
'N5 · Mobile drawer, child selected',
]
sources.push(
{ board: officeBoard, group: 'Settings navigation', states: navNames, alias: {} },
{ board: variantsBoard, group: 'Cache panel', states: cacheNames, alias: {} },
{ board: variantsBoard, group: 'Media library', states: libraryNames, alias: {} },
{ board: variantsBoard, group: 'Image detail', states: detailNames, alias: {} },
{ board: variantsBoard, group: 'Editor picker', states: editorNames, alias: {} },
)
return {
name: 'image-variants',
board: variantsBoard,
group: 'Variants list',
states: [
...listNames,
...formNames,
...cacheNames,
...libraryNames,
...detailNames,
...editorNames,
...navNames,
],
sources,
}
}
function groupSectionRows(markdown: string, group: string) {
const heading = new RegExp(`^### .*\\(group "${group}", \\d+ states?\\)$`, 'm').exec(markdown)
if (!heading) {
return []
}
const rest = markdown.slice(heading.index + heading[0].length)
const end = rest.search(/^##/m)
const section = end === -1 ? rest : rest.slice(0, end)
return [...section.matchAll(/^\| \d+ \| ([^|]+?) \|/gm)].map((match) => match[1]!.trim())
}
async function readScreen(name: string): Promise<Screen> {
if (name === 'image-variants') {
return variantsScreen()
}
const markdown = await readFile(join(designRoot, 'screens', `${name}.md`), 'utf8')
const reference = VISUAL_REFERENCE.exec(markdown)
if (!reference) {
throw new Error(`docs/design/screens/${name}.md has no visual reference line`)
}
const referenceGroups = [...reference[2]!.matchAll(/"([^"]+)"/g)].map((match) => match[1]!)
const numberedRows = [...markdown.matchAll(/^\| \d+ \| ([^|]+?) \|/gm)].map((match) =>
match[1]!.trim(),
)
const statesStart = markdown.search(/^## States$/m)
const afterStates = statesStart === -1 ? markdown : markdown.slice(statesStart)
const nextSection = afterStates.slice(1).search(/^## /m)
const statesMarkdown = nextSection === -1 ? afterStates : afterStates.slice(0, nextSection + 1)
const headingStates = [...statesMarkdown.matchAll(/^### (.+)$/gm)].map((match) =>
match[1]!.trim(),
)
const groupRows = groupSectionRows(markdown, referenceGroups[0]!)
const states = groupRows.length
? groupRows
: numberedRows.length
? numberedRows
: headingStates.length
? headingStates
: tableStates(statesMarkdown)
const primary: Source = {
board: reference[1]!,
group: referenceGroups[0]!,
states,
alias: boardAlias[name] ?? {},
}
const configured = extraSources[name] ?? []
const documentedExtras = referenceGroups
.slice(1)
.filter((group) => !configured.some((source) => source.group === group))
.map((group): Source => ({ board: primary.board, group, states: [], alias: {} }))
return {
name,
board: primary.board,
group: primary.group,
states,
sources: [
primary,
...configured.filter((source) => source.group !== primary.group),
...documentedExtras,
],
}
}
function pickSources(screen: Screen, wanted?: string) {
if (!wanted) {
return screen.sources
}
const matches = (state: string) =>
state.toLowerCase() === wanted.toLowerCase() || slug(state) === slug(wanted)
const picked = screen.sources
.map((source) => ({ ...source, states: source.states.filter(matches) }))
.filter((source) => source.states.length > 0)
if (picked.length === 0) {
const known = screen.sources.flatMap((source) => source.states)
throw new Error(`Unknown state "${wanted}". Known: ${known.join(', ')}`)
}
return picked
}
async function serveDesign() {
const server = createServer(async (request, response) => {
const pathname = decodeURIComponent(
new URL(request.url ?? '/', 'http://localhost').pathname,
)
const file = join(sourceRoot, pathname)
if (relative(sourceRoot, file).startsWith('..')) {
response.writeHead(403).end()
return
}
try {
const body = await readFile(file)
response
.writeHead(200, {
'Content-Type': contentTypes[extname(file)] ?? 'application/octet-stream',
})
.end(body)
} catch {
response.writeHead(404).end()
}
})
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done))
const { port } = server.address() as AddressInfo
return { origin: `http://127.0.0.1:${port}`, close: () => server.close() }
}
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: 4180, 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() }
}
const iconHosts = new Set(['api.iconify.design', 'api.simplesvg.com', 'api.unisvg.com'])
async function serveIconsLocally(context: BrowserContext) {
await context.route(
(url) => iconHosts.has(url.hostname),
async (route) => {
const url = new URL(route.request().url())
const svg = /^\/lucide\/([a-z0-9-]+)\.svg$/.exec(url.pathname)
if (svg) {
const data = getIconData(lucide, svg[1]!)
if (!data) {
await route.fulfill({ status: 404, body: '' })
return
}
const { attributes, body } = iconToSVG(data)
await route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: iconToHTML(replaceIDs(body), attributes),
})
return
}
if (url.pathname === '/lucide.json') {
const names = url.searchParams.get('icons')?.split(',') ?? []
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
getIcons(lucide, names) ?? { prefix: 'lucide', icons: {} },
),
})
return
}
await route.fulfill({ status: 404, body: '' })
},
)
}
async function serveDistLocally(context: BrowserContext, origin: string) {
await context.route(
(url) => url.origin === origin && !url.pathname.startsWith('/v1'),
async (route) => {
const pathname = decodeURIComponent(new URL(route.request().url()).pathname)
const candidate = pathname === '/' || !extname(pathname) ? '/index.html' : pathname
const file = join(distRoot, candidate)
try {
const body = await readFile(file)
await route.fulfill({
status: 200,
contentType: distContentTypes[extname(file)] ?? 'application/octet-stream',
body,
})
} catch {
await route.fulfill({ status: 404, body: '' })
}
},
)
}
async function openBoard(browser: Browser, origin: string, screen: Screen, theme: Theme) {
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } })
await serveIconsLocally(context)
const page = await context.newPage()
await page.goto(`${origin}/${encodeURIComponent(screen.board)}`)
await page.locator(`section[data-screen-label="${screen.group}"]`).waitFor()
await page.getByRole('radio', { name: theme === 'dark' ? 'Dark' : 'Light' }).click()
await page.evaluate(() => document.fonts.ready)
return page
}
async function captureDesign(
board: Page,
screen: Screen,
state: string,
viewport: Viewport,
path: string,
) {
const found = await board.evaluate(
({ group, state, width, height }) => {
document
.querySelectorAll('[data-visual-check]')
.forEach((element) => element.removeAttribute('data-visual-check'))
const section = [...document.querySelectorAll('section[data-screen-label]')].find(
(element) => element.getAttribute('data-screen-label') === group,
)
const headers = section
? [...section.querySelectorAll<HTMLElement>('div')].filter(
(div) => getComputedStyle(div).maxWidth === '1040px',
)
: []
const title = headers.find(
(div) => div.querySelector('span')?.textContent?.trim() === state,
)
const block = title?.parentElement
const target = block
? [...block.querySelectorAll<HTMLElement>('div')].find(
(div) => div.style.width === `${width}px`,
)
: undefined
if (!target) {
return false
}
const container = target.parentElement!
container.style.border = 'none'
container.style.boxSizing = 'content-box'
container.style.width = `${width}px`
container.style.height = `${height}px`
target.style.zoom = '1'
target.dataset.visualCheck = 'frame'
return true
},
{ group: screen.group, state, ...frames[viewport] },
)
if (!found) {
throw new Error(`State "${state}" not found in group "${screen.group}" of ${screen.board}`)
}
const frame = board.locator('[data-visual-check="frame"]')
await frame.scrollIntoViewIfNeeded()
await frame.evaluate(async (root) => {
const urls = new Set<string>()
for (const element of [root, ...root.querySelectorAll('*')]) {
const mask = getComputedStyle(element).maskImage
for (const match of mask.matchAll(/url\("([^"]+)"\)/g)) {
urls.add(match[1]!)
}
}
await Promise.all(
[...urls].map(
(url) =>
new Promise<void>((done) => {
const image = new Image()
image.onload = () => done()
image.onerror = () => done()
image.src = url
}),
),
)
})
await board.waitForTimeout(settleMs)
await frame.screenshot({ path, animations: 'disabled' })
}
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,
contentType: text ? 'text/plain; charset=utf-8' : 'application/json',
body: body === undefined ? '' : text ? (body as string) : JSON.stringify(body),
}
}
async function captureApp(
browser: Browser,
origin: string,
setup: VisualState,
viewport: Viewport,
theme: Theme,
path: string,
) {
const { width, height } = frames[viewport]
const context = await browser.newContext({
viewport: { width, height },
colorScheme: theme,
reducedMotion: 'reduce',
...(setup.now ? { timezoneId: 'UTC' } : {}),
})
await serveIconsLocally(context)
const unmocked = new Set<string>()
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]
if (!mock) {
unmocked.add(key)
await route.fulfill({ status: 404, body: '' })
return
}
if (mock.hang) {
return
}
await route.fulfill(fulfilment(mock, url, call))
})
if (setup.origin) {
await serveDistLocally(context, setup.origin)
await context.grantPermissions(['clipboard-read', 'clipboard-write'], {
origin: setup.origin,
})
}
const page = await context.newPage()
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(`${setup.origin ?? 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)
await page.screenshot({ path, animations: 'disabled' })
await context.close()
return [...unmocked]
}
async function diff(browser: Browser, designPath: string, appPath: string, diffPath: string) {
const [design, app] = await Promise.all([readFile(designPath), readFile(appPath)])
const page = await browser.newPage()
const result = await page.evaluate(
async ({ design, app, threshold }) => {
const load = (src: string) =>
new Promise<HTMLImageElement>((done, fail) => {
const image = new Image()
image.onload = () => done(image)
image.onerror = fail
image.src = src
})
const [a, b] = await Promise.all([load(design), load(app)])
const width = Math.max(a.width, b.width)
const height = Math.max(a.height, b.height)
const pixels = (image: HTMLImageElement) => {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')!
context.drawImage(image, 0, 0)
return context.getImageData(0, 0, width, height).data
}
const left = pixels(a)
const right = pixels(b)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')!
const output = context.createImageData(width, height)
let changed = 0
for (let i = 0; i < left.length; i += 4) {
const delta =
Math.abs(left[i]! - right[i]!) +
Math.abs(left[i + 1]! - right[i + 1]!) +
Math.abs(left[i + 2]! - right[i + 2]!)
if (delta > threshold) {
changed++
output.data.set([230, 0, 60, 255], i)
} else {
const gray = (left[i]! + left[i + 1]! + left[i + 2]!) / 3
const faded = 255 - (255 - gray) * 0.2
output.data.set([faded, faded, faded, 255], i)
}
}
context.putImageData(output, 0, 0)
return {
png: canvas.toDataURL('image/png'),
ratio: changed / (width * height),
sizes: `${a.width}x${a.height} vs ${b.width}x${b.height}`,
}
},
{
design: `data:image/png;base64,${design.toString('base64')}`,
app: `data:image/png;base64,${app.toString('base64')}`,
threshold: diffThreshold,
},
)
await page.close()
await writeFile(diffPath, Buffer.from(result.png.split(',')[1]!, 'base64'))
return result
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const screen = await readScreen(args.screen)
const sources = pickSources(screen, args.state)
const setups = visualStates[screen.name] ?? {}
const design = await serveDesign()
const app = await serveApp()
const browser = await chromium.launch({ args: ['--disable-lcd-text'] })
const lines: string[] = []
let failures = 0
let skipped = 0
let pending = 0
try {
for (const viewport of args.viewports) {
for (const theme of args.themes) {
for (const source of sources) {
const sourceScreen = { ...screen, board: source.board, group: source.group }
const board = await openBoard(browser, design.origin, sourceScreen, theme)
for (const state of source.states) {
const skip = setups[state]?.skip
if (skip === 'pending') {
pending++
lines.push(`PENDING ${state} · ${viewport} · ${theme}: slice B2`)
continue
}
if (skip) {
skipped++
lines.push(`SKIP ${state} · ${viewport} · ${theme}: ${skip}`)
continue
}
const directory = join(outRoot, screen.name, slug(state))
await mkdir(directory, { recursive: true })
const suffix = `${viewport}-${theme}.png`
const designPath = join(directory, `design-${suffix}`)
await captureDesign(
board,
sourceScreen,
source.alias[state] ?? state,
viewport,
designPath,
)
const setup = setups[state]
if (!setup) {
failures++
lines.push(
`FAIL ${state} · ${viewport} · ${theme}: no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
)
continue
}
const appPath = join(directory, `app-${suffix}`)
const diffPath = join(directory, `diff-${suffix}`)
const unmocked = await captureApp(
browser,
app.origin,
setup,
viewport,
theme,
appPath,
)
const result = await diff(browser, designPath, appPath, diffPath)
const percent = result.ratio * 100
const budget = budgetFor(setup.exception?.maxPercent, viewport)
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 ${describeCap(setup.exception.maxPercent)} approved by ${setup.exception.approvedBy}: ${setup.exception.reason}]`
: ''
lines.push(
`${problems.length ? 'FAIL' : 'PASS'} ${state} · ${viewport} · ${theme}: ${percent.toFixed(2)}% pixels differ → ${relative(repoRoot, directory)}${problems.length ? ` (${problems.join('; ')})` : ''}${exception}`,
)
}
await board.context().close()
}
}
}
} finally {
await browser.close()
await app.close()
design.close()
}
const checked = lines.length - skipped - pending
const skippedNote =
(skipped ? `, ${skipped} skipped` : '') + (pending ? `, ${pending} pending` : '')
const verdict = failures
? `FAIL: ${failures} of ${checked} captures failed (budget ${maxDiffPercent}% per capture${skippedNote})`
: `PASS: all ${checked} captures within budget (${maxDiffPercent}% unless an approved exception is listed${skippedNote})`
const report = [
`# Visual check: ${screen.name}`,
`Boards: ${sources.map((source) => `${source.board} / ${source.group}`).join('; ')}`,
'',
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()