Files
RenewCMS/web/visual/check.ts
T

1566 lines
41 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createServer } from 'node:http'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import type { AddressInfo } from 'node:net'
import { extname, join, relative, resolve } from 'node:path'
import { build, preview } from 'vite'
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'
import { icons as lucide } from '@iconify-json/lucide'
import { getIconData, getIcons, iconToHTML, iconToSVG, replaceIDs } from '@iconify/utils'
import { visualStates, type ApiMock, type DiffException, type VisualState } from './states'
type Viewport = 'desktop' | 'mobile'
type Theme = 'light' | 'dark'
interface Source {
board: string
group: string
states: string[]
alias: Record<string, string>
}
interface Screen {
name: string
board: string
group: string
states: string[]
sources: Source[]
}
const VISUAL_REFERENCE =
/Visual reference:\*{0,2}\s*`(?:source\/)?([^`]+)`,?\s*groups?\s+((?:"[^"]+"(?:,\s*)?)+)/
const officeBoard = 'RenewCMS Screens Back-office.dc.html'
const 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 rolesBoard = 'RenewCMS Screens Roles.dc.html'
const extraSources: Record<string, Source[]> = {
roles: [
{
board: rolesBoard,
group: '12 Toasts',
states: [
'Role changed',
'Role edited elsewhere (409)',
'Role deleted elsewhere',
"Role not changed: member's role changed first (409)",
"Ownership not transferred: target's role changed first (409)",
"User not removed: member's role changed first (409)",
],
alias: {},
},
{
board: rolesBoard,
group: '13 Inline errors',
states: [
'Role editor: save failed (500)',
'Change role: failed (500)',
'Delete role: target role deleted',
'Delete role: role gained members',
'Delete role: move failed (500)',
'Invite: invalid email',
'Invite: address already has an account (409)',
'Invite: role above yours (403)',
'Invite: send failed (500)',
],
alias: {},
},
{
board: rolesBoard,
group: '14 Read = Own',
states: [
'Role editor: Read set to Own',
'Articles list: Read = Own',
'Articles list: no drafts of their own',
'Entry list: Read = Own',
'Entry list: no entries of their own',
],
alias: {},
},
{
board: rolesBoard,
group: '15 409 conflicts',
states: [
'Stale save: banner',
'Stale save: review differences',
'Stale save: edits kept after reload',
"Change role: member's role changed first (409)",
"Transfer ownership: target's role changed first (409)",
],
alias: {},
},
],
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: '7 Relations',
states: ['Relation in the builder'],
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: {},
},
{
board: schemaBoard,
group: '7 Relations',
states: ['Relation in the list'],
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: '7 Relations',
states: ['Relation in the entry form', 'Relation picker: loading more'],
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',
states: [
'8a. Search: trigger focused',
'8b. Search: open, empty',
'8c. Search: typing, results',
'8d. Search: keyboard-selected, status code',
'8e. Search: body field match',
'8f. Search: session-only results',
'8g. Search: no results',
'8h. Search: after selecting an endpoint',
'9a. Content types: create, curl',
'9b. Entries: list, Response tab',
'9c. Entries: replace, 422 and 409',
'9d. Errors: 404, 409 and 422 rows',
'9e. Session only: 6 sub-groups',
'9f. Search: "entry"',
'9g. Search: "capacity" (body field)',
'9h. Search: "schema_incompatible" (error)',
'9i. Search: "content type" (sections)',
'9j. Navigation: scroll-spy on Content types',
],
alias: {},
},
{
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: rolesBoard,
group: '9 Profile',
states: ['Role card: custom role', 'Role card: Owner'],
alias: {},
},
{
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<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
const shellApi: Record<string, ApiMock> = {
'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<string, string>()
const positional: string[] = []
for (const arg of argv) {
const flag = /^--(\w+)=(.+)$/.exec(arg)
if (flag) {
flags.set(flag[1]!, flag[2]!)
} else {
positional.push(arg)
}
}
const [screen, ...stateWords] = positional
if (!screen) {
throw new Error(
'Usage: task visual:check -- <screen> [state] [--viewport=desktop|mobile] [--theme=light|dark]',
)
}
const viewports = (flags.get('viewport')?.split(',') ?? ['desktop', 'mobile']) as Viewport[]
const themes = (flags.get('theme')?.split(',') ?? ['light', 'dark']) as Theme[]
return { screen, state: stateWords.join(' ') || undefined, viewports, themes }
}
function tableStates(markdown: string) {
return [...markdown.matchAll(/^\| ([^|]+?) \|/gm)]
.map((match) => match[1]!.trim())
.filter((cell) => cell !== 'State' && !/^-+$/.test(cell))
}
const variantsBoard = officeBoard
function numberedStates(prefix: string, names: string[]) {
return Object.fromEntries(
names.map((state, index) => [
state,
`${prefix}${String.fromCharCode(97 + index)} · ${state}`,
]),
)
}
function variantsScreen(): Screen {
const listNames = [
'Loaded',
'Loading',
'Empty',
'Error loading',
'Forbidden',
'Row menu',
'Row action in progress',
'Purge confirm',
'Delete confirm',
'Delete in progress',
'Request reference',
'Entry: sidebar',
]
const formNames = [
'Create: pristine',
'Create: dirty',
'Field errors',
'No dimension',
'Name already taken',
'Saving',
'Save error',
'Edit: pristine',
'Edit: dirty',
'Unsaved changes',
'PNG: no quality',
]
const sources: Source[] = [
{
board: variantsBoard,
group: 'Variants list',
states: listNames,
alias: numberedStates('V1', listNames),
},
{
board: variantsBoard,
group: 'Add edit variant',
states: formNames,
alias: numberedStates('V2', formNames),
},
]
const libraryNames = [
'V3a · Grid with badges',
'V3b · Sort menu',
'V3c · Filter: With variants, sorted by total size',
'V3d · Filter: Without variants',
'V3e · Filter: nothing to show',
'V3f · Delete confirm',
]
const detailNames = [
'V4a · Partly generated',
'V4b · Nothing generated yet',
'V4c · Generating',
'V4d · Generation failed',
'V4e · No variants defined',
'V4f · Source is an SVG',
'V4h · Preview dialog',
]
const editorNames = [
'V6a · Variant select',
'V6b · Select open',
'V6c · No variants defined',
'V6d · Variant deleted',
'V6e · Variant disabled',
]
const cacheNames = [
'V5a · Loaded',
'V5b · Loading',
'V5c · Empty cache',
'V5d · Purging',
'V5e · Purge done',
'V5f · Purge failed',
'V5g · Error loading',
'V5h · Purge all: confirm',
'V5i · Purge all: confirm, typed',
'V5j · Purge unused: confirm',
]
const navNames = [
'N1 · Parent selected',
'N2 · Child selected: Variants',
'N3 · Child selected: Cache',
'N4 · Mobile drawer, parent selected',
'N5 · Mobile drawer, child selected',
]
sources.push(
{ board: officeBoard, group: 'Settings navigation', states: navNames, alias: {} },
{ board: variantsBoard, group: 'Cache panel', states: cacheNames, alias: {} },
{ board: variantsBoard, group: 'Media library', states: libraryNames, alias: {} },
{ board: variantsBoard, group: 'Image detail', states: detailNames, alias: {} },
{ board: variantsBoard, group: 'Editor picker', states: editorNames, alias: {} },
)
return {
name: 'image-variants',
board: variantsBoard,
group: 'Variants list',
states: [
...listNames,
...formNames,
...cacheNames,
...libraryNames,
...detailNames,
...editorNames,
...navNames,
],
sources,
}
}
function groupSectionRows(markdown: string, group: string) {
const heading = new RegExp(`^### .*\\(group "${group}", \\d+ states?\\)$`, 'm').exec(markdown)
if (!heading) {
return []
}
const rest = markdown.slice(heading.index + heading[0].length)
const end = rest.search(/^##/m)
const section = end === -1 ? rest : rest.slice(0, end)
return [...section.matchAll(/^\| \d+ \| ([^|]+?) \|/gm)].map((match) => match[1]!.trim())
}
const boardGroupLabels: Record<string, Record<string, string>> = {
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<string, string> = {
'integration-api-keys': 'API keys',
}
function referenceWithoutGroup(name: string, markdown: string) {
const group = referenceGroups[name]
const board = /Visual reference:\*{0,2}\s*`(?:source\/)?([^`]+)`/.exec(markdown)
return group && board ? [board[0], board[1]!, `"${group}"`] : null
}
const accountBoard = 'RenewCMS Screens Account.dc.html'
const authBoard = 'RenewCMS Screens Auth.dc.html'
function accountScreen(): Screen {
const sources: Source[] = [
{
board: accountBoard,
group: 'Change email',
states: [
'Default',
'Form open',
'Invalid format',
'Same as current',
'Password missing',
'Wrong password (403)',
'Sending',
'Pending',
'Pending: resending',
'Pending: resent',
'Pending: resend rate limited (429)',
'Cancelled',
'Rate limited (429)',
'Server or network error',
],
alias: {},
},
{
board: authBoard,
group: 'Verify new email',
states: [
'Confirming',
'Success',
'Invalid or expired (403)',
'Address unavailable (409)',
'No session (401)',
'No session, retrying',
'Missing code',
'Server error',
],
alias: {},
},
{
board: accountBoard,
group: 'Your data',
states: [
'Direct (recommended): default',
'Direct: preparing',
'Direct: started',
'Direct: rate limited (429)',
'Direct: error',
'Alternative, emailed link: default',
'Alternative: requested',
'Alternative: already in progress',
'Alternative: error',
],
alias: {},
},
{
board: accountBoard,
group: 'Delete account',
states: [
'Danger zone',
'Confirm: empty',
'Confirm: typing',
'Confirm: ready',
'Delete: Wrong password (403)',
'Deleting',
'Delete: Rate limited (429)',
'Delete: Server error',
'Blocked: last administrator',
'Blocked: cannot be deleted',
],
alias: {
'Delete: Wrong password (403)': 'Wrong password (403)',
'Delete: Rate limited (429)': 'Rate limited (429)',
'Delete: Server error': 'Server error',
},
},
{
board: authBoard,
group: 'Account deleted',
states: ['Account deleted'],
alias: {},
},
]
const [first] = sources
return {
name: 'account',
board: first!.board,
group: first!.group,
states: sources.flatMap((source) => source.states),
sources,
}
}
function rolesScreen(): Screen {
const sources = extraSources.roles!
const [first] = sources
return {
name: 'roles',
board: first!.board,
group: first!.group,
states: sources.flatMap((source) => source.states),
sources,
}
}
async function readScreen(name: string): Promise<Screen> {
if (name === 'image-variants') {
return variantsScreen()
}
if (name === 'roles') {
return rolesScreen()
}
if (name === 'account') {
return accountScreen()
}
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<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.evaluate(() => document.fonts.ready)
await page.reload()
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
}
const REALIGNED_BOARD_STATES = new Set(['9j. Navigation: scroll-spy on Content types'])
const SCROLL_REALIGN_MAX_DRIFT = 60
const BOARD_SCROLL_OFFSETS = {
desktop: [32],
mobile: [64],
}
async function realignBoardScroll(frame: ReturnType<Page['locator']>, viewport: Viewport) {
await frame.evaluate(
(root, { offsets, maxDrift }) => {
const anchors = [...root.querySelectorAll<HTMLElement>('[data-anchor]')].filter(
(anchor) => !anchor.dataset.anchor?.startsWith('ep-'),
)
const first = anchors[0]
if (!first) {
return
}
let scroller: HTMLElement | null = first.parentElement
while (scroller && !['auto', 'scroll'].includes(getComputedStyle(scroller).overflowY)) {
scroller = scroller.parentElement
}
if (!scroller || scroller.scrollTop === 0) {
return
}
const offsetTopWithin = (element: HTMLElement, within: HTMLElement) => {
const parent = element.offsetParent as HTMLElement | null
return element.offsetTop + (parent && parent !== within ? parent.offsetTop : 0)
}
let best: number | null = null
for (const anchor of anchors) {
for (const offset of offsets) {
const target = Math.max(0, offsetTopWithin(anchor, scroller) - offset)
const drift = Math.abs(target - scroller.scrollTop)
if (
drift <= maxDrift &&
(best === null || drift < Math.abs(best - scroller.scrollTop))
) {
best = target
}
}
}
if (best !== null) {
scroller.scrollTop = best
}
},
{ offsets: BOARD_SCROLL_OFFSETS[viewport], maxDrift: SCROLL_REALIGN_MAX_DRIFT },
)
}
const STABLE_SAMPLES = 3
const STABLE_INTERVAL_MS = 200
const STABLE_MAX_POLLS = 40
async function waitForStableFrame(board: Page, frame: ReturnType<Page['locator']>) {
let previous: Buffer | null = null
let steady = 0
for (let poll = 0; poll < STABLE_MAX_POLLS && steady < STABLE_SAMPLES; poll++) {
const shot = await frame.screenshot({ animations: 'disabled' })
steady = previous !== null && shot.equals(previous) ? steady + 1 : 0
previous = shot
await board.waitForTimeout(STABLE_INTERVAL_MS)
}
}
async function captureDesign(
board: Page,
screen: Screen,
state: string,
viewport: Viewport,
path: string,
) {
const found = await board.evaluate(
({ group, state, width, height, widths }) => {
document
.querySelectorAll('[data-visual-check]')
.forEach((element) => element.removeAttribute('data-visual-check'))
document.querySelectorAll<HTMLElement>('[data-visual-hidden]').forEach((element) => {
element.style.visibility = ''
element.removeAttribute('data-visual-hidden')
})
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 wideBlock = section
? [...section.querySelectorAll<HTMLElement>('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<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'
const frameWidths = new Set(widths.map((value) => `${value}px`))
for (const other of section!.querySelectorAll<HTMLElement>('div')) {
if (
other !== target &&
frameWidths.has(other.style.width) &&
!other.contains(target) &&
!target.contains(other)
) {
other.dataset.visualHidden = 'true'
other.style.visibility = 'hidden'
}
}
return true
},
{
group: screen.group,
state,
...frames[viewport],
widths: [frames.desktop.width, frames.mobile.width],
},
)
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)
if (REALIGNED_BOARD_STATES.has(state)) {
await realignBoardScroll(frame, viewport)
}
await waitForStableFrame(board, frame)
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 || setup.timezone ? { timezoneId: setup.timezone ?? 'UTC' } : {}),
})
await serveIconsLocally(context)
const unmocked = new Set<string>()
const fallback = 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] ?? 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<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, 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()