Files
RenewCMS/web/visual/check.ts
T

801 lines
23 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 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 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 = '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),
},
]
const libraryNames = [
'3a · Grid with badges',
'3b · Sort menu',
'3c · Filter: With variants, sorted by total size',
'3d · Filter: Without variants',
'3e · Filter: nothing to show',
'3f · Delete confirm',
]
const detailNames = [
'4a · Partly generated',
'4b · Nothing generated yet',
'4c · Generating',
'4d · Generation failed',
'4e · No variants defined',
'4f · Source is an SVG',
'4h · Preview dialog',
]
const editorNames = [
'6a · Variant select',
'6b · Select open',
'6c · No variants defined',
'6d · Variant deleted',
'6e · Variant disabled',
]
const cacheNames = [
'5a · Loaded',
'5b · Loading',
'5c · Empty cache',
'5d · Purging',
'5e · Purge done',
'5f · Purge failed',
'5g · Error loading',
'5h · Purge all: confirm',
'5i · Purge all: confirm, typed',
'5j · Purge unused: confirm',
]
sources.push(
{ 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,
],
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 = 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()