Files
RenewCMS/web/visual/check.ts
T

1093 lines
29 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[]> = {
sites: [
{
board: officeBoard,
group: 'Sites: form',
states: [
'Add: empty',
'Add: key from the name',
'Add: URL not valid',
'Add: duplicate key',
'Add: save failed',
'Edit: default site',
'Edit: other site, noindex',
],
alias: {},
},
{
board: officeBoard,
group: 'Sites: delete',
states: ['With articles', 'Choose destination', 'No articles'],
alias: {},
},
{
board: officeBoard,
group: 'Sites: SEO defaults',
states: [
'Edit: SEO defaults',
'Add: defaults',
'Template: custom',
'Template: not valid',
'Save failed',
],
alias: {},
},
],
authors: [
{
board: officeBoard,
group: 'Authors: form',
states: [
'Add: empty',
'Slug from the name',
'Slug collision',
'Validation',
'Social links',
'Linked user: choose',
'Edit: linked author',
'Avatar: picker',
'Save failed',
'Unlink: confirm',
'Re-link',
],
alias: {},
},
{
board: officeBoard,
group: 'Authors: delete',
states: [
'Reassign (recommended)',
'Choose who',
'Nobody',
'Failed (shared by site and author delete)',
'Server fallback (409)',
'No articles',
],
alias: {},
},
],
integration: [
{
board: officeBoard,
group: 'Integration: images',
states: [
'I1 · Many variants',
'I2 · One variant',
'I3 · No variants, admin',
'I4 · No variants, not an admin',
'I5 · Variant removed',
'I6 · Loading',
'I7 · Error loading',
'I8 · Snippet copied',
'I9 · URL builder copied',
],
alias: {},
},
],
editor: [
{
board: officeBoard,
group: 'Editor: site and authors',
states: [
'Open: one site, one author',
'Open: several sites',
'Site select open',
'No authors, with hint',
'Many authors',
'Add author: open',
'Add author: no match',
'Add author: open (no create)',
'Add author: no match (no create)',
'Add author: error',
'Reorder: keyboard',
'Reorder: pointer',
'Create author: dialog',
'Create author: for me',
'Create author: slug taken',
'Create author: failed',
],
alias: {},
},
{
board: officeBoard,
group: 'Editor: search and sharing',
states: [
'Collapsed: ready',
'Collapsed: suggestions',
'Collapsed: hidden',
'Open: ready',
'Open: suggestions',
'Open: checking',
'Open: hidden',
'Open: other site',
'Panel: defaults',
'Panel: filled in',
'Panel: outside the soft ranges',
'Panel: slug edited',
'Panel: slug taken',
'Panel: cover without alt',
'Panel: choose cover',
'Panel: social sharing open',
'Panel: social customised',
'Panel: advanced, invalid canonical',
'Panel: hidden from search',
'Panel: mobile search preview',
'Panel: checking',
'Panel: saving',
'Panel: save failed',
'Saved (toast)',
],
alias: { 'Saved (toast)': 'Saved' },
},
{
board: officeBoard,
group: 'Editor: what the frontend gets',
states: [
'Formatted',
'Raw JSON',
'Copied',
'Draft article',
'Dialog: loading',
'Dialog: error',
],
alias: { 'Dialog: loading': 'Loading', 'Dialog: error': 'Error' },
},
],
profile: [
{
board: officeBoard,
group: 'Profile: author profile',
states: [
'No author profile',
'Error',
'Created',
'Created, slug adjusted',
'Edit own author',
],
alias: {},
},
],
articles: [
{
board: officeBoard,
group: 'Articles: sites and authors',
states: ['Several sites', 'Site filter open', 'No articles on a site'],
alias: {},
},
{
board: officeBoard,
group: 'Articles: search status',
states: ['Status chips', 'Needs attention on', 'Needs attention: none'],
alias: {},
},
{
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())
}
const referenceGroups: Record<string, string> = {
'integration-api-keys': 'API keys',
}
function referenceWithoutGroup(name: string, markdown: string) {
const group = referenceGroups[name]
const board = /Visual reference:\*{0,2}\s*`(?:source\/)?([^`]+)`/.exec(markdown)
return group && board ? [board[0], board[1]!, `"${group}"`] : null
}
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) ?? referenceWithoutGroup(name, 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()