741 lines
21 KiB
TypeScript
741 lines
21 KiB
TypeScript
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 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 officeBoard = 'RenewCMS Screens Back-office.dc.html'
|
|
|
|
const extraSources: Record<string, Source[]> = {
|
|
activity: [
|
|
{
|
|
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 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 = 'RenewCMS Screens Image variants.dc.html'
|
|
|
|
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('1', listNames),
|
|
},
|
|
{
|
|
board: variantsBoard,
|
|
group: 'Add edit variant',
|
|
states: formNames,
|
|
alias: numberedStates('2', formNames),
|
|
},
|
|
]
|
|
return {
|
|
name: 'image-variants',
|
|
board: variantsBoard,
|
|
group: 'Variants list',
|
|
states: [...listNames, ...formNames],
|
|
sources,
|
|
}
|
|
}
|
|
|
|
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:\*\* `source\/([^`]+)`, group "([^"]+)"/.exec(markdown)
|
|
if (!reference) {
|
|
throw new Error(`docs/design/screens/${name}.md has no visual reference line`)
|
|
}
|
|
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 states = headingStates.length ? headingStates : tableStates(statesMarkdown)
|
|
const primary: Source = {
|
|
board: reference[1]!,
|
|
group: reference[2]!,
|
|
states,
|
|
alias: boardAlias[name] ?? {},
|
|
}
|
|
return {
|
|
name,
|
|
board: primary.board,
|
|
group: primary.group,
|
|
states,
|
|
sources: [primary, ...(extraSources[name] ?? [])],
|
|
}
|
|
}
|
|
|
|
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 = 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(
|
|
`${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()
|