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 } 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 PAGINATION_STATES = [ 'First page', 'Middle page', 'Last page', 'Single page', 'Page size menu', 'Loading a page', 'Error loading a page', 'Empty after search', 'Search resets to page 1', 'Long labels on mobile', ] const schemaBoard = 'RenewCMS Screens Schema.dc.html' const extraSources: Record = { schema: [ { board: schemaBoard, group: '2 Schema builder', states: [ 'New type', 'Editing an existing type', 'Locked key: tooltip', 'Unsaved changes', 'Field errors', 'Type-level errors', 'Field key error', 'Choice list editor', 'Keyboard reorder', 'API preview open', 'Saving', 'Saved', 'Save failed', 'Key already taken', 'Schema incompatible', 'Offline', 'Media field selected', 'New media field, seven types', 'Media field, required', ], alias: {}, }, { board: schemaBoard, group: '3 Review and delete', states: [ 'Review before saving', 'Applying', 'Refused (schema_incompatible)', 'Delete type: blocked', 'Delete type: confirm', 'Delete type: server fallback (409)', 'Review: required media field refused', 'Review: optional media field', ], alias: {}, }, ], content: [ { board: schemaBoard, group: '5 Content and entry list', states: ['Thumbnail column', 'Columns menu with Thumbnail', 'Missing image: tooltip'], alias: {}, }, ], entry: [ { board: schemaBoard, group: '6 Entry editor', states: [ 'Media control: empty, optional', 'Media control: empty, required', 'Media control: filled', 'Media control: hover', 'Media control: focus', 'Media control: validation error', 'Media control: image no longer in the library', 'Media control: missing image, required', 'Media control: saving', 'Media control: read-only, type changed', ], alias: {}, }, { board: schemaBoard, group: '10 Media picker for fields', states: [ 'Library: nothing chosen', 'Library: image chosen', 'Library: No image chosen', 'Library: required field', 'Library: empty', 'Library: failed to load', 'Library: loading', 'Upload tab', ], alias: {}, }, ], sites: [ { board: officeBoard, group: 'Sites: pagination', states: PAGINATION_STATES, alias: {}, }, { 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: pagination', states: PAGINATION_STATES, alias: {}, }, { 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: {}, }, { board: schemaBoard, group: '11 Media library: usage', states: [ 'Grid: usage badge tooltip', 'List view: usage badge', 'Detail panel: used in entries', 'Delete: image used by entries', ], 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 const shellApi: Record = { 'GET /v1/content-type': { status: 200, body: [] }, } 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() 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)) } 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 boardGroupLabels: Record> = { schema: { 'Schema list': '1 Schema list', 'Schema builder': '2 Schema builder', 'Review and delete': '3 Review and delete', 'Field types': '4 Field types', }, content: { 'Content and entry list': '5 Content and entry list', }, entry: { 'Entry editor': '6 Entry editor', }, } const referenceGroups: Record = { '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 { 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) => boardGroupLabels[name]?.[match[1]!] ?? 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 configured = extraSources[name] ?? [] const sameGroup = configured.filter((source) => source.group === referenceGroups[0]!) const mergedStates = [ ...states, ...sameGroup.flatMap((source) => source.states).filter((state) => !states.includes(state)), ] const primary: Source = { board: reference[1]!, group: referenceGroups[0]!, states: mergedStates, alias: Object.assign( {}, boardAlias[name] ?? {}, ...sameGroup.map((source) => source.alias), ), } 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: mergedStates, 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((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 wideBlock = section ? [...section.querySelectorAll('div')].find( (div) => div.style.width === '1037px' && div.querySelector('span > .sc-interp')?.textContent?.trim() === state, ) : undefined const block = title ? title.parentElement : wideBlock 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, ...mock.headersFor?.(url, call) }, 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 fallback = 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] ?? shellApi[key] if (mock && !setup.api?.[key]) { fallback.add(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: [...unmocked], fallback: [...fallback] } } 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, fallback } = 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}${fallback.length ? ` [shellApi fallback: ${fallback.join(', ')}]` : ''}`, ) } 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()