#!/usr/bin/env node import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { readFile, mkdir } from 'node:fs/promises'; import { extname, resolve } from 'node:path'; import { chromium } from 'playwright'; const root = resolve('frontend'); const shots = resolve('plans/rec-card-shots'); await mkdir(shots, { recursive: true }); const mime = { '.html':'text/html', '.js':'text/javascript', '.css':'text/css', '.json':'application/json', '.woff2':'font/woff2', '.png':'image/png', '.svg':'image/svg+xml', '.webmanifest':'application/manifest+json' }; const server = createServer(async (req, res) => { const pathname = new URL(req.url, 'http://local').pathname; const file = resolve(root, `.${pathname === '/' ? '/index.html' : pathname}`); if (!file.startsWith(root + '/')) { res.writeHead(403).end(); return; } try { const body = await readFile(file); res.writeHead(200, { 'Content-Type': mime[extname(file)] || 'application/octet-stream' }).end(body); } catch { res.writeHead(404).end(); } }); await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)); const origin = `http://127.0.0.1:${server.address().port}`; const layouts = ['glass-stage', 'classic', 'bento-hub', 'f7-swipe']; const themes = ['dark', 'light', 'contrast']; const failures = []; const svg = encodeURIComponent(''); const thumb = `data:image/svg+xml,${svg}`; const cards = [ { id:'one', title:'You Have Said — A Long Live Worship Recording with a Carefully Wrapped Title', channel:'Cathedral of Praise Worship', reason:'One of your most played', thumbnail:thumb }, { id:'two', title:'A Second Recommendation with a Long Title to Verify the Two-Line Clamp', channel:'The Choir and Orchestra', reason:'Because you saved several long-form worship sessions', thumbnail:thumb }, { id:'three', title:'Missing artwork and reason', channel:'Grace Community', reason:'', thumbnail:'' }, { id:'four', title:'Fourth card with the normal metadata length', channel:'Hesed Worship', reason:'A familiar favorite', thumbnail:thumb }, { id:'five', title:'Fifth card', channel:'Worship Archive', reason:'A discovery', thumbnail:thumb }, { id:'six', title:'Sixth card', channel:'Live Sessions', reason:'More from your listening', thumbnail:thumb }, ]; const browser = await chromium.launch({ headless: true }); try { const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1, serviceWorkers: 'block' }); await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort()); await context.route('**/api/**', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, results: [] }) })); await context.route('**/api/recommendations*', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, results: cards }) })); const page = await context.newPage(); page.on('pageerror', error => failures.push(`pageerror: ${error.message}`)); page.on('requestfailed', request => failures.push(`requestfailed: ${request.url()} ${request.failure()?.errorText}`)); page.on('response', response => { if (response.url().includes('/api/recommendations') && response.status() >= 400) failures.push(`recommendations response: ${response.status()} ${response.url()}`); }); const matrix = []; for (const layout of layouts) for (const theme of themes) { const widths = layout === 'glass-stage' ? [320, 390, 768, 1280] : [390, 1280]; await context.addInitScript(({ layout, theme }) => localStorage.setItem('_ytpdata', JSON.stringify({ playlists: [], history: [], queue: [], settings: { layout, theme } })), { layout, theme }); await page.setViewportSize({ width: 390, height: 844 }); await page.goto(origin, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(({ layout, theme }) => document.documentElement.dataset.layout === layout && document.documentElement.dataset.theme === theme, { layout, theme }, { timeout: 10000 }); try { await page.waitForFunction(() => document.querySelectorAll('#homeRecommendations .rec-item').length === 6, null, { timeout: 5000 }); } catch (error) { console.error('render debug', JSON.stringify({ layout, theme, errors: failures, state: await page.evaluate(() => ({ title: document.title, layout: document.documentElement.dataset.layout, theme: document.documentElement.dataset.theme, body: document.body.innerText.slice(0, 500), recommendation: document.querySelector('#homeRecommendations')?.outerHTML.slice(0, 1000) })) })); throw error; } await page.locator('#homeRecommendations img').first().evaluate(img => img.decode().catch(() => {})); for (const width of widths) { await page.setViewportSize({ width, height: 844 }); await page.waitForTimeout(50); const metrics = await page.locator('#homeRecommendations').evaluate(section => { const rgba = value => { const channels = (value.match(/[\d.]+/g) || []).map(Number); if (value.startsWith('color(srgb')) return [channels[0] * 255, channels[1] * 255, channels[2] * 255, channels[3] ?? 1]; return [channels[0], channels[1], channels[2], channels[3] ?? 1]; }; const rgb = value => rgba(value).slice(0, 3); const luminance = value => { const [r,g,b] = rgb(value).map(channel => channel / 255).map(channel => channel <= .04045 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4); return .2126 * r + .7152 * g + .0722 * b; }; const contrast = (a,b) => { const [r,g,bl,alpha] = rgba(b), composite = `rgb(${r * alpha + 255 * (1-alpha)},${g * alpha + 255 * (1-alpha)},${bl * alpha + 255 * (1-alpha)})`; const x = luminance(a), y = luminance(composite); return (Math.max(x,y) + .05) / (Math.min(x,y) + .05); }; const rect = el => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, height: r.height, width: r.width }; }; const cards = [...section.querySelectorAll('.rec-item')].map(item => { const play = item.querySelector('.rec-play'), cover = item.querySelector('.rec-cover'), img = cover.querySelector('img'); const actions = item.querySelector('.rec-actions'), reason = item.querySelector('.rec-reason'); const title = item.querySelector('.rec-title'), channel = item.querySelector('.rec-channel'); const textColor = getComputedStyle(title).color, surface = getComputedStyle(item).backgroundColor; const shellStyle = getComputedStyle(item); const actionButtons = [...actions.querySelectorAll('button')].map(button => ({ rect:rect(button), contrast:contrast(getComputedStyle(button).color, getComputedStyle(button).backgroundColor) })); return { item:rect(item), play:rect(play), cover:rect(cover), image:rect(img), reason:rect(reason), actions:rect(actions), title:rect(title), channel:rect(channel), scrollWidth:item.scrollWidth, clientWidth:item.clientWidth, imageFit:getComputedStyle(img).objectFit, titleClamp:getComputedStyle(title).webkitLineClamp, textColor, surface, reasonColor:getComputedStyle(reason).color, reasonSurface:getComputedStyle(reason).backgroundColor, shellBorderWidth:parseFloat(shellStyle.borderTopWidth), shellRadius:parseFloat(shellStyle.borderTopLeftRadius), textContrast:contrast(textColor, surface), reasonContrast:contrast(getComputedStyle(reason).color, getComputedStyle(reason).backgroundColor), actionButtons, coverRatio:rect(cover).width / rect(cover).height, hasImage:!!img.getAttribute('src'), fallbackSurface:getComputedStyle(cover).backgroundImage }; }); return { cards, gridColumns:getComputedStyle(section.querySelector('.rec-grid')).gridTemplateColumns }; }); matrix.push({ layout, theme, width, ...metrics }); for (const card of metrics.cards) { const bottom = card.item.bottom; for (const [name, box] of Object.entries({ play:card.play, cover:card.cover, title:card.title, channel:card.channel, reason:card.reason, actions:card.actions })) { if (box.bottom > bottom + 1) failures.push(`${layout}/${theme}/${width}: ${name} spills ${Math.ceil(box.bottom - bottom)}px past rec-item`); } if (card.scrollWidth > card.clientWidth + 1) failures.push(`${layout}/${theme}/${width}: rec-item horizontal overflow ${card.scrollWidth}/${card.clientWidth}`); if (layout === 'glass-stage') { if (card.shellBorderWidth < 1 || card.shellRadius < 16) failures.push(`${layout}/${theme}/${width}: unified glass shell is missing its rounded border`); if (card.textContrast < 4.5 || card.reasonContrast < 4.5 || card.actionButtons.some(button => button.contrast < 4.5)) failures.push(`${layout}/${theme}/${width}: text contrast below 4.5:1`); if (card.actionButtons.some(button => button.rect.width < 44 || button.rect.height < 44)) failures.push(`${layout}/${theme}/${width}: action target below 44px`); if (card.titleClamp !== '2') failures.push(`${layout}/${theme}/${width}: title clamp is ${card.titleClamp}`); if (Math.abs(card.coverRatio - 16 / 9) > .03) failures.push(`${layout}/${theme}/${width}: cover aspect ratio ${card.coverRatio}`); if (card.imageFit !== 'cover') failures.push(`${layout}/${theme}/${width}: thumbnail does not cover-fit`); if (!card.hasImage && card.fallbackSurface === 'none') failures.push(`${layout}/${theme}/${width}: missing-art fallback has no visual treatment`); } } if (layout === 'glass-stage') { const focusStyles = await page.locator('#homeRecommendations .rec-play').first().evaluate(button => { button.focus(); const playShadow = getComputedStyle(button).boxShadow; const action = button.closest('.rec-item').querySelector('.rec-actions button'); action.focus(); return { playShadow, actionShadow:getComputedStyle(action).boxShadow }; }); if (focusStyles.playShadow === 'none' || focusStyles.actionShadow === 'none') failures.push(`${layout}/${theme}/${width}: keyboard focus cue missing`); } if (width === 390 || width === 1280) { await page.setViewportSize({ width, height: 2000 }); await page.evaluate(() => document.activeElement?.blur()); await page.locator('#homeRecommendations').scrollIntoViewIfNeeded(); await page.locator('#homeRecommendations .rec-grid').screenshot({ path: resolve(shots, `${layout}-${theme}-${width}.png`), style: '.topbar,#bottomNav,#miniBar { display: none !important; }' }); } } } console.log(JSON.stringify({ cases:matrix.length, cards:matrix.reduce((n, entry) => n + entry.cards.length, 0), failures, matrix }, null, 2)); await context.close(); } finally { await browser.close(); await new Promise(resolveClose => server.close(resolveClose)); } if (failures.length) process.exitCode = 1;