fix: login match design reference
This commit is contained in:
1 parent
001f9f4774
commit
f1e22f7073
16 files changed
+1183
-57
No files matched your search
@@ -0,0 +1,447 @@
|
||||
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 Screen {
|
||||
name: string
|
||||
board: string
|
||||
group: string
|
||||
states: string[]
|
||||
}
|
||||
|
||||
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 settleMs = 400
|
||||
const diffThreshold = 48
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
async function readScreen(name: string): Promise<Screen> {
|
||||
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 states = [...markdown.matchAll(/^### (.+)$/gm)].map((match) => match[1]!.trim())
|
||||
return { name, board: reference[1]!, group: reference[2]!, states }
|
||||
}
|
||||
|
||||
function pickStates(screen: Screen, wanted?: string) {
|
||||
if (!wanted) {
|
||||
return screen.states
|
||||
}
|
||||
const match = screen.states.find(
|
||||
(state) => state.toLowerCase() === wanted.toLowerCase() || slug(state) === slug(wanted),
|
||||
)
|
||||
if (!match) {
|
||||
throw new Error(`Unknown state "${wanted}". Known: ${screen.states.join(', ')}`)
|
||||
}
|
||||
return [match]
|
||||
}
|
||||
|
||||
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 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 title = section
|
||||
? [...section.querySelectorAll('span')].find(
|
||||
(span) => span.textContent?.trim() === state,
|
||||
)
|
||||
: undefined
|
||||
const block = title?.closest('div')?.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) {
|
||||
const text = typeof mock.body === 'string'
|
||||
return {
|
||||
status: mock.status,
|
||||
headers: mock.headers,
|
||||
contentType: text ? 'text/plain; charset=utf-8' : 'application/json',
|
||||
body:
|
||||
mock.body === undefined ? '' : text ? (mock.body as string) : JSON.stringify(mock.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',
|
||||
})
|
||||
await serveIconsLocally(context)
|
||||
const unmocked = new Set<string>()
|
||||
await context.route('**/v1/**', async (route) => {
|
||||
const request = route.request()
|
||||
const key = `${request.method()} ${new URL(request.url()).pathname}`
|
||||
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))
|
||||
})
|
||||
const page = await context.newPage()
|
||||
await page.goto(`${origin}${setup.path}`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
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 states = pickStates(screen, args.state)
|
||||
const setups = visualStates[screen.name] ?? {}
|
||||
|
||||
const design = await serveDesign()
|
||||
const app = await serveApp()
|
||||
const browser = await chromium.launch()
|
||||
const lines: string[] = []
|
||||
|
||||
try {
|
||||
for (const viewport of args.viewports) {
|
||||
for (const theme of args.themes) {
|
||||
const board = await openBoard(browser, design.origin, screen, theme)
|
||||
for (const state of states) {
|
||||
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, screen, state, viewport, designPath)
|
||||
|
||||
const setup = setups[state]
|
||||
if (!setup) {
|
||||
lines.push(
|
||||
`${state} · ${viewport} · ${theme}: design only, 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 warnings = [
|
||||
...(result.sizes.split(' vs ')[0] !== result.sizes.split(' vs ')[1]
|
||||
? [`size mismatch ${result.sizes}`]
|
||||
: []),
|
||||
...unmocked.map((key) => `unmocked ${key}`),
|
||||
]
|
||||
lines.push(
|
||||
`${state} · ${viewport} · ${theme}: ${(result.ratio * 100).toFixed(1)}% pixels differ → ${relative(repoRoot, directory)}${warnings.length ? ` (${warnings.join('; ')})` : ''}`,
|
||||
)
|
||||
}
|
||||
await board.context().close()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close()
|
||||
await app.close()
|
||||
design.close()
|
||||
}
|
||||
|
||||
const report = [
|
||||
`# Visual check: ${screen.name}`,
|
||||
`Board: ${screen.board}, group "${screen.group}"`,
|
||||
'',
|
||||
...lines.map((line) => `- ${line}`),
|
||||
].join('\n')
|
||||
await writeFile(join(outRoot, screen.name, 'report.md'), `${report}\n`)
|
||||
console.log(report)
|
||||
}
|
||||
|
||||
await main()
|
||||
Reference in new issue
Block a user