const { test, expect } = require('@playwright/test'); const { mkdirSync } = require('node:fs'); const path = require('node:path'); function contrast(foreground, background) { const luminance = color => { const channels = color.match(/[\d.]+/g).slice(0, 3).map(Number).map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2); return channels[0] * .2126 + channels[1] * .7152 + channels[2] * .0722; }; const a = luminance(foreground), b = luminance(background); return (Math.max(a, b) + .05) / (Math.min(a, b) + .05); } test('capture the new admin tools screens for visual review', async ({ page }) => { const dir = path.resolve('plans/admin-tools-shots'); mkdirSync(dir, { recursive: true }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.route('**/api/**', async route => { const request = route.request(), url = new URL(request.url()); let body = { ok: true }; if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true }; if (url.pathname === '/api/uploads') body.uploads = []; if (url.pathname === '/api/admin/media') body.media = []; if (url.pathname === '/api/admin/flags') body = { ok: true, open: 1, flags: [{ id: 1, videoId: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', text: 'Grace will lead me home', reporter: 'listener', reason: 'words', status: 'open', createdAt: 1791300000, note: 'One word is wrong' }] }; if (url.pathname === '/api/admin/lyrics/quality') body = { ok: true, total: 1, offset: 0, limit: 50, songs: [{ id: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', lines: 4, source: 'auto', untimed: true, openReports: 1, reasons: ['automatic', 'untimed', 'reports'] }] }; if (url.pathname === '/api/admin/lyrics/search') body = { ok: true, query: '', hasLyrics: 'yes', hasChapters: 'all', total: 1, limit: 30, offset: 0, results: [{ videoId: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', snippet: '\u0001Grace will lead\u0002 me home', hasChapters: true, rev: 2 }] }; if (url.pathname === '/api/admin/cache/health') body = { ok: true, total: 2, offset: 0, budget: 10_000_000, pinnedBudget: 2_000_000, bytes: 4_800_000, pinnedBytes: 1_200_000, utilization: .48, statuses: [{ status: 'ready', count: 1, bytes: 4_000_000 }, { status: 'queued', count: 1, bytes: 800_000 }, { status: 'failed', count: 1, bytes: 0 }], media: [{ id: 'dQw4w9WgXcQ', status: 'ready', bytes: 4_000_000, lastAccess: 1791900000000, gen: 4, pinned: true }] }; if (url.pathname === '/api/admin/index/health') body = { ok: true, total: 1200, urlCount: 1190, detailCount: 980, thumbnailCount: 850, urlCoverage: .99, detailCoverage: .82, thumbnailCoverage: .71, sources: [{ source: 'deep', videos: 600, discoveries: 940, lastSeen: 1791900000 }, { source: 'search', videos: 500, discoveries: 1200, lastSeen: 1791800000 }], lastRun: { id: 'run-1', status: 'complete', createdAt: 1791800000, updatedAt: 1791900000, progress: { queriesDone: 160, newVideos: 340 } }, backfill: [{ source: 'history', cursor: 840, ceiling: 1000, done: false }] }; if (url.pathname === '/api/admin/backups') body = { ok: true, backups: [{ date: '2026-10-14', bytes: 20480, notes: 124, valid: true }] }; if (url.pathname === '/api/admin/backups/2026-10-14') body = { ok: true, date: '2026-10-14', total: 124, diff: { added: 2, removed: 1, changed: 3, preview: [{ key: 'dQw4w9WgXcQ:lyrics', change: 'changed' }] } }; if (url.pathname === '/api/admin/audit') body = { ok: true, total: 42, offset: 0, events: [{ id: 8, ts: 1791900000, actor: 'admin', action: 'lyrics.save', target: 'dQw4w9WgXcQ', detail: { rev: 4, lines: 22 } }] }; if (url.pathname === '/api/admin/lyrics/bulk/library') body = { ok: true, total: 1, limit: 50, offset: 0, songs: [{ id: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', lines: 22, rev: 4, cacheStatus: 'ready' }] }; if (url.pathname === '/api/admin/lyrics/jobs') body = { ok: true, jobs: [{ id: 'bulk-1', action: 'lrclib', status: 'running', state: { done: 3, total: 12, results: [{ id: 'dQw4w9WgXcQ', ok: true, message: 'Saved revision 4' }], logs: [] } }] }; if (url.pathname === '/api/admin/notes/recent') body = { ok: true, revs: [] }; if (url.pathname === '/api/admin/analytics') body = { ok: true }; await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }); }); await page.setViewportSize({ width: 1280, height: 900 }); await page.goto('/admin.html#editor'); for (const tab of ['quality', 'lyric-search', 'cache', 'index', 'reports', 'backups', 'audit', 'library']) { await page.locator(`#tabsTop [data-tab="${tab}"]`).click(); await expect(page.locator(`#p-${tab}`)).toBeVisible(); await page.waitForTimeout(100); await page.screenshot({ path: path.join(dir, `${tab}.png`), fullPage: true }); } for (const theme of ['dark', 'light', 'contrast']) { await page.evaluate(theme => { document.documentElement.dataset.editorTheme = theme; }, theme); for (const width of [390, 1280]) { await page.setViewportSize({ width, height: 844 }); if (width === 390) { const nav = page.locator('#tabsBottom'); const dimensions = await nav.evaluate(node => ({ client: node.clientWidth, scroll: node.scrollWidth, document: document.documentElement.scrollWidth })); expect(dimensions.scroll, 'phone tab strip should retain all admin destinations').toBeGreaterThan(dimensions.client); expect(dimensions.document, 'phone tab strip must not overflow the page').toBeLessThanOrEqual(width); const lastTab = nav.locator('[data-tab]').last(); await lastTab.scrollIntoViewIfNeeded(); await expect(lastTab).toBeInViewport(); } const targetHeight = await page.locator('#bulkStart').evaluate(node => node.getBoundingClientRect().height); expect(targetHeight, `bulk action target in ${theme} at ${width}px`).toBeGreaterThanOrEqual(44); const colors = await page.locator('#bulkLyricsCard').evaluate(node => { const card = getComputedStyle(node), muted = getComputedStyle(node.querySelector('.muted')); const action = getComputedStyle(node.querySelector('#bulkStart')); return { text: muted.color, surface: card.backgroundColor, actionText: action.color, actionSurface: action.backgroundColor }; }); expect(contrast(colors.text, colors.surface), `muted text contrast ${theme} ${width}px`).toBeGreaterThanOrEqual(4.5); expect(contrast(colors.actionText, colors.actionSurface), `primary action contrast ${theme} ${width}px ${JSON.stringify(colors)}`).toBeGreaterThanOrEqual(4.5); const overflow = await page.locator('#bulkLyricsCard').evaluate(node => node.scrollWidth > node.clientWidth); expect(overflow, `bulk card overflow in ${theme} at ${width}px`).toBe(false); await page.locator('#bulkLyricsCard').screenshot({ path: path.join(dir, `bulk-${theme}-${width}.png`) }); } } });