903 lines
25 KiB
TypeScript
903 lines
25 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 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()
|