also looking at this
Expand BunPress guide and pre-E2E quality gates
#35
40 files
+1524
-373
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -214,6 +214,9 @@ jobs: | ||
| 214 | 214 | - name: Validate UI accessibility |
| 215 | 215 | run: bun run ui:validate |
| 216 | 216 | |
| 217 | - name: Validate responsive visual contracts | |
| 218 | run: bun run visual:validate | |
| 219 | ||
| 217 | 220 | - name: Verify generated files are current |
| 218 | 221 | run: git diff --exit-code |
| 219 | 222 | |
| @@ -31,6 +31,7 @@ | ||
| 31 | 31 | "build": "./buddy build", |
| 32 | 32 | "docs:validate": "bun scripts/validate-docs.ts", |
| 33 | 33 | "ui:validate": "bun scripts/validate-ui.ts", |
| 34 | "visual:validate": "bun scripts/validate-visual.ts", | |
| 34 | 35 | "prod": "./buddy build", |
| 35 | 36 | "deploy": "./buddy deploy", |
| 36 | 37 | "lint": "./buddy lint", |
| @@ -0,0 +1,330 @@ | ||
| 1 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 2 | import { tmpdir } from 'node:os' | |
| 3 | import { join, resolve } from 'node:path' | |
| 4 | ||
| 5 | interface Viewport { | |
| 6 | name: string | |
| 7 | width: number | |
| 8 | height: number | |
| 9 | mobile: boolean | |
| 10 | } | |
| 11 | ||
| 12 | interface PageAudit { | |
| 13 | contrast: string[] | |
| 14 | focusable: number | |
| 15 | imagesWithoutAlt: number | |
| 16 | overflow: number | |
| 17 | unresolved: boolean | |
| 18 | unnamedControls: string[] | |
| 19 | } | |
| 20 | ||
| 21 | interface Pending { | |
| 22 | reject: (error: Error) => void | |
| 23 | resolve: (value: any) => void | |
| 24 | timer: ReturnType<typeof setTimeout> | |
| 25 | } | |
| 26 | ||
| 27 | const DIST = resolve('dist') | |
| 28 | const SSR_OUTPUT = resolve('.output') | |
| 29 | const SSR_PAGES = resolve(SSR_OUTPUT, 'server/pages') | |
| 30 | const SSR_PUBLIC = resolve(SSR_OUTPUT, 'public') | |
| 31 | const OUTPUT = resolve('storage/logs/visual-contract') | |
| 32 | const VIEWPORTS: Viewport[] = [ | |
| 33 | { name: 'desktop', width: 1440, height: 1000, mobile: false }, | |
| 34 | { name: 'tablet', width: 768, height: 1024, mobile: false }, | |
| 35 | { name: 'mobile', width: 390, height: 844, mobile: true }, | |
| 36 | ] | |
| 37 | const THEMES = ['light', 'dark'] as const | |
| 38 | const ROUTE_CANDIDATES = [ | |
| 39 | '/', | |
| 40 | '/login', | |
| 41 | '/register', | |
| 42 | '/dashboard', | |
| 43 | '/dashboard/commshq', | |
| 44 | '/reports', | |
| 45 | '/projects', | |
| 46 | ] | |
| 47 | ||
| 48 | function chromePath(): string { | |
| 49 | const candidates = [ | |
| 50 | process.env.CHROME_PATH, | |
| 51 | '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', | |
| 52 | '/usr/bin/google-chrome', | |
| 53 | '/usr/bin/google-chrome-stable', | |
| 54 | '/usr/bin/chromium', | |
| 55 | '/usr/bin/chromium-browser', | |
| 56 | ] | |
| 57 | const found = candidates.find(candidate => candidate && existsSync(candidate)) | |
| 58 | if (!found) throw new Error('Chrome was not found. Set CHROME_PATH to a Chrome or Chromium executable.') | |
| 59 | return found | |
| 60 | } | |
| 61 | ||
| 62 | async function waitFor<T>(read: () => Promise<T> | T, timeout = 12_000): Promise<T> { | |
| 63 | const started = Date.now() | |
| 64 | let value = await read() | |
| 65 | while (!value && Date.now() - started < timeout) { | |
| 66 | await Bun.sleep(100) | |
| 67 | value = await read() | |
| 68 | } | |
| 69 | if (!value) throw new Error(`Timed out after ${timeout}ms`) | |
| 70 | return value | |
| 71 | } | |
| 72 | ||
| 73 | class Cdp { | |
| 74 | private id = 0 | |
| 75 | private pending = new Map<number, Pending>() | |
| 76 | ||
| 77 | constructor(private socket: WebSocket) { | |
| 78 | socket.addEventListener('message', (event) => { | |
| 79 | const message = JSON.parse(String(event.data)) | |
| 80 | if (!message.id) return | |
| 81 | const pending = this.pending.get(message.id) | |
| 82 | if (!pending) return | |
| 83 | this.pending.delete(message.id) | |
| 84 | clearTimeout(pending.timer) | |
| 85 | if (message.error) pending.reject(new Error(message.error.message)) | |
| 86 | else pending.resolve(message.result) | |
| 87 | }) | |
| 88 | } | |
| 89 | ||
| 90 | send(method: string, params: Record<string, any> = {}): Promise<any> { | |
| 91 | const id = ++this.id | |
| 92 | return new Promise((resolve, reject) => { | |
| 93 | const timer = setTimeout(() => { | |
| 94 | this.pending.delete(id) | |
| 95 | reject(new Error(`Chrome command timed out: ${method}`)) | |
| 96 | }, 15_000) | |
| 97 | this.pending.set(id, { reject, resolve, timer }) | |
| 98 | this.socket.send(JSON.stringify({ id, method, params })) | |
| 99 | }) | |
| 100 | } | |
| 101 | ||
| 102 | async evaluate<T>(expression: string): Promise<T> { | |
| 103 | const result = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }) | |
| 104 | if (result.exceptionDetails) | |
| 105 | throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text) | |
| 106 | return result.result?.value as T | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | async function connect(url: string): Promise<Cdp> { | |
| 111 | const socket = new WebSocket(url) | |
| 112 | await new Promise<void>((resolve, reject) => { | |
| 113 | socket.addEventListener('open', () => resolve(), { once: true }) | |
| 114 | socket.addEventListener('error', () => reject(new Error('Could not connect to Chrome')), { once: true }) | |
| 115 | }) | |
| 116 | return new Cdp(socket) | |
| 117 | } | |
| 118 | ||
| 119 | function routeName(route: string): string { | |
| 120 | return route === '/' ? 'home' : route.slice(1).replaceAll('/', '-') | |
| 121 | } | |
| 122 | ||
| 123 | function routeFile(route: string): { kind: 'html' | 'ssr', path: string } | undefined { | |
| 124 | if (existsSync(DIST)) { | |
| 125 | const base = route === '/' ? join(DIST, 'index.html') : join(DIST, `${route.slice(1)}.html`) | |
| 126 | if (existsSync(base)) return { kind: 'html', path: base } | |
| 127 | const index = join(DIST, route.slice(1), 'index.html') | |
| 128 | if (existsSync(index)) return { kind: 'html', path: index } | |
| 129 | } | |
| 130 | ||
| 131 | const name = route === '/' ? 'index' : route.slice(1).replaceAll('/', '-') | |
| 132 | const compiled = join(SSR_PAGES, `${name}.compiled.json`) | |
| 133 | return existsSync(compiled) ? { kind: 'ssr', path: compiled } : undefined | |
| 134 | } | |
| 135 | ||
| 136 | function pageResponse(page: { kind: 'html' | 'ssr', path: string }): Response { | |
| 137 | if (page.kind === 'html') return new Response(Bun.file(page.path)) | |
| 138 | const compiled = JSON.parse(readFileSync(page.path, 'utf8')) as { html?: string } | |
| 139 | return new Response(compiled.html || '', { headers: { 'content-type': 'text/html; charset=utf-8' } }) | |
| 140 | } | |
| 141 | ||
| 142 | const auditExpression = `(() => { | |
| 143 | const visible = element => { | |
| 144 | const style = getComputedStyle(element) | |
| 145 | const rect = element.getBoundingClientRect() | |
| 146 | if (element.closest('details:not([open])') && element.tagName !== 'SUMMARY') return false | |
| 147 | return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) > 0 | |
| 148 | && rect.width > 0 && rect.height > 0 | |
| 149 | } | |
| 150 | const label = element => { | |
| 151 | if (element.getAttribute('aria-label')) return element.getAttribute('aria-label') | |
| 152 | const labelledBy = element.getAttribute('aria-labelledby') | |
| 153 | if (labelledBy && labelledBy.split(/\\s+/).some(id => document.getElementById(id)?.textContent?.trim())) return labelledBy | |
| 154 | if (element.id && document.querySelector('label[for="' + CSS.escape(element.id) + '"]')?.textContent?.trim()) return element.id | |
| 155 | if (element.closest('label')?.textContent?.trim()) return 'wrapped' | |
| 156 | if (element.tagName === 'BUTTON' && element.textContent?.trim()) return 'text' | |
| 157 | if (element.tagName === 'A' && element.textContent?.trim()) return 'text' | |
| 158 | if (element.getAttribute('title')) return element.getAttribute('title') | |
| 159 | return '' | |
| 160 | } | |
| 161 | const parseRgb = value => { | |
| 162 | const match = value.match(/rgba?\\(([^)]+)\\)/) | |
| 163 | if (!match) return null | |
| 164 | const parts = match[1].split(/[ ,/]+/).filter(Boolean).map(Number) | |
| 165 | if (parts.length < 3 || parts.some(Number.isNaN)) return null | |
| 166 | return { r: parts[0], g: parts[1], b: parts[2], a: parts[3] ?? 1 } | |
| 167 | } | |
| 168 | const luminance = color => { | |
| 169 | const channel = value => { | |
| 170 | const normalized = value / 255 | |
| 171 | return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4 | |
| 172 | } | |
| 173 | return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b) | |
| 174 | } | |
| 175 | const background = element => { | |
| 176 | let current = element | |
| 177 | while (current) { | |
| 178 | const color = parseRgb(getComputedStyle(current).backgroundColor) | |
| 179 | if (color && color.a >= 0.98) return color | |
| 180 | current = current.parentElement | |
| 181 | } | |
| 182 | return { r: 255, g: 255, b: 255, a: 1 } | |
| 183 | } | |
| 184 | const controls = [...document.querySelectorAll('a[href], button, input:not([type="hidden"]), select, textarea')].filter(visible) | |
| 185 | const unnamedControls = controls.filter(element => !label(element)).map(element => { | |
| 186 | const identity = element.id ? '#' + element.id : element.getAttribute('name') ? '[name="' + element.getAttribute('name') + '"]' : '' | |
| 187 | return element.tagName.toLowerCase() + identity | |
| 188 | }) | |
| 189 | const contrast = [] | |
| 190 | const textElements = [...document.querySelectorAll('h1, h2, h3, h4, p, label, button, a, summary, span')] | |
| 191 | for (const element of textElements) { | |
| 192 | if (!visible(element) || !element.textContent?.trim()) continue | |
| 193 | if (element.children.length && !['A', 'BUTTON', 'SUMMARY'].includes(element.tagName)) continue | |
| 194 | const style = getComputedStyle(element) | |
| 195 | const foreground = parseRgb(style.color) | |
| 196 | const behind = background(element) | |
| 197 | if (!foreground || foreground.a < 0.98) continue | |
| 198 | const lighter = Math.max(luminance(foreground), luminance(behind)) | |
| 199 | const darker = Math.min(luminance(foreground), luminance(behind)) | |
| 200 | const ratio = (lighter + 0.05) / (darker + 0.05) | |
| 201 | const large = Number.parseFloat(style.fontSize) >= 24 || (Number.parseFloat(style.fontSize) >= 18.66 && Number(style.fontWeight) >= 700) | |
| 202 | if (ratio + 0.01 < (large ? 3 : 4.5)) { | |
| 203 | const sample = element.textContent.trim().replace(/\\s+/g, ' ').slice(0, 42) | |
| 204 | contrast.push(sample + ' (' + ratio.toFixed(2) + ':1)') | |
| 205 | } | |
| 206 | if (contrast.length >= 10) break | |
| 207 | } | |
| 208 | return { | |
| 209 | contrast, | |
| 210 | focusable: controls.length, | |
| 211 | imagesWithoutAlt: [...document.images].filter(image => !image.hasAttribute('alt')).length, | |
| 212 | overflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth), | |
| 213 | unresolved: document.body.innerText.includes('{{') || document.body.innerText.includes('@if'), | |
| 214 | unnamedControls, | |
| 215 | } | |
| 216 | })()` | |
| 217 | ||
| 218 | async function main(): Promise<void> { | |
| 219 | if (!existsSync(DIST) && !existsSync(SSR_PAGES)) | |
| 220 | throw new Error(`Built site not found at ${DIST} or ${SSR_OUTPUT}. Run bun run build first.`) | |
| 221 | const routes = ROUTE_CANDIDATES.filter(route => routeFile(route)) | |
| 222 | if (routes.length < 3) throw new Error(`Expected at least three visual routes, found ${routes.join(', ') || 'none'}.`) | |
| 223 | ||
| 224 | mkdirSync(OUTPUT, { recursive: true }) | |
| 225 | const server = Bun.serve({ | |
| 226 | hostname: '127.0.0.1', | |
| 227 | port: 0, | |
| 228 | fetch(request) { | |
| 229 | const url = new URL(request.url) | |
| 230 | const route = decodeURIComponent(url.pathname) | |
| 231 | const page = routeFile(route) | |
| 232 | if (page) return pageResponse(page) | |
| 233 | const publicRoot = existsSync(DIST) ? DIST : SSR_PUBLIC | |
| 234 | const asset = resolve(publicRoot, `.${route}`) | |
| 235 | const file = asset.startsWith(`${publicRoot}/`) && existsSync(asset) ? asset : undefined | |
| 236 | return file ? new Response(Bun.file(file)) : new Response('Not found', { status: 404 }) | |
| 237 | }, | |
| 238 | }) | |
| 239 | const profile = mkdtempSync(join(tmpdir(), 'hq-visual-')) | |
| 240 | const chrome = Bun.spawn([ | |
| 241 | chromePath(), | |
| 242 | '--headless=new', | |
| 243 | '--remote-debugging-port=0', | |
| 244 | `--user-data-dir=${profile}`, | |
| 245 | '--no-first-run', | |
| 246 | '--no-default-browser-check', | |
| 247 | '--disable-background-networking', | |
| 248 | '--disable-component-update', | |
| 249 | '--disable-extensions', | |
| 250 | '--hide-scrollbars', | |
| 251 | '--no-sandbox', | |
| 252 | 'about:blank', | |
| 253 | ], { stdout: 'ignore', stderr: 'ignore' }) | |
| 254 | ||
| 255 | const failures: string[] = [] | |
| 256 | let screenshots = 0 | |
| 257 | try { | |
| 258 | const portFile = join(profile, 'DevToolsActivePort') | |
| 259 | await waitFor(() => existsSync(portFile), 10_000) | |
| 260 | const port = Number(readFileSync(portFile, 'utf8').split('\n')[0]) | |
| 261 | const targets = await waitFor(async () => { | |
| 262 | const items = await fetch(`http://127.0.0.1:${port}/json/list`).then(response => response.json()).catch(() => []) | |
| 263 | return Array.isArray(items) && items.length ? items : null | |
| 264 | }) as Array<{ type: string, url: string, webSocketDebuggerUrl: string }> | |
| 265 | const target = targets.find(item => item.type === 'page' && item.url === 'about:blank') || targets.find(item => item.type === 'page') | |
| 266 | if (!target) throw new Error('Chrome did not expose a page target.') | |
| 267 | ||
| 268 | const cdp = await connect(target.webSocketDebuggerUrl) | |
| 269 | await cdp.send('Page.enable') | |
| 270 | await cdp.send('Runtime.enable') | |
| 271 | await cdp.send('Emulation.setScriptExecutionDisabled', { value: true }) | |
| 272 | ||
| 273 | for (const route of routes) { | |
| 274 | for (const viewport of VIEWPORTS) { | |
| 275 | await cdp.send('Emulation.setDeviceMetricsOverride', { | |
| 276 | width: viewport.width, | |
| 277 | height: viewport.height, | |
| 278 | deviceScaleFactor: 1, | |
| 279 | mobile: viewport.mobile, | |
| 280 | }) | |
| 281 | await cdp.send('Page.navigate', { url: `http://127.0.0.1:${server.port}${route}` }) | |
| 282 | await waitFor(() => cdp.evaluate("document.readyState === 'complete'")) | |
| 283 | await Bun.sleep(100) | |
| 284 | for (const theme of THEMES) { | |
| 285 | await cdp.evaluate(`(() => { | |
| 286 | const theme = ${JSON.stringify(theme)} | |
| 287 | document.documentElement.classList.toggle('dark', theme === 'dark') | |
| 288 | document.documentElement.setAttribute('data-theme', theme) | |
| 289 | document.documentElement.setAttribute('data-color-mode', theme) | |
| 290 | document.documentElement.setAttribute('color-mode', theme) | |
| 291 | })()`) | |
| 292 | await Bun.sleep(500) | |
| 293 | const audit = await cdp.evaluate<PageAudit>(auditExpression) | |
| 294 | const key = `${route} at ${viewport.name}/${theme}` | |
| 295 | if (audit.overflow > 1) failures.push(`${key}: ${audit.overflow}px horizontal overflow`) | |
| 296 | if (audit.unresolved) failures.push(`${key}: unresolved template expression is visible`) | |
| 297 | if (audit.imagesWithoutAlt) failures.push(`${key}: ${audit.imagesWithoutAlt} image(s) have no alt attribute`) | |
| 298 | if (audit.unnamedControls.length) failures.push(`${key}: unnamed controls ${audit.unnamedControls.join(', ')}`) | |
| 299 | for (const issue of audit.contrast) failures.push(`${key}: low text contrast ${issue}`) | |
| 300 | ||
| 301 | if (audit.focusable) { | |
| 302 | await cdp.evaluate("document.body.setAttribute('tabindex', '-1'); document.body.focus()") | |
| 303 | await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }) | |
| 304 | await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }) | |
| 305 | const focused = await cdp.evaluate("document.activeElement && document.activeElement !== document.body && document.activeElement !== document.documentElement") | |
| 306 | if (!focused) failures.push(`${key}: Tab did not move focus to a control`) | |
| 307 | } | |
| 308 | ||
| 309 | const shot = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, fromSurface: true }) | |
| 310 | writeFileSync(join(OUTPUT, `${routeName(route)}-${theme}-${viewport.name}.png`), Buffer.from(shot.data, 'base64')) | |
| 311 | screenshots += 1 | |
| 312 | } | |
| 313 | } | |
| 314 | } | |
| 315 | } | |
| 316 | finally { | |
| 317 | chrome.kill() | |
| 318 | await chrome.exited | |
| 319 | server.stop(true) | |
| 320 | rmSync(profile, { force: true, recursive: true }) | |
| 321 | } | |
| 322 | ||
| 323 | console.log(`Rendered ${routes.length} routes in ${THEMES.length} themes at ${VIEWPORTS.length} viewports and wrote ${screenshots} screenshots.`) | |
| 324 | if (failures.length) { | |
| 325 | for (const failure of failures) console.error(`- ${failure}`) | |
| 326 | process.exit(1) | |
| 327 | } | |
| 328 | } | |
| 329 | ||
| 330 | await main() | |