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 } interface Screen { name: string board: string group: string states: string[] sources: Source[] } const officeBoard = 'RenewCMS Screens Back-office.dc.html' const extraSources: Record = { 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> = { 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 = { desktop: { width: 1440, height: 900 }, mobile: { width: 390, height: 844 }, } const contentTypes: Record = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.json': 'application/json', } const distContentTypes: Record = { ...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() 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 -- [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)) } async function readScreen(name: string): Promise { 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((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('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('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() 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((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() const calls = new Map() 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((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()