|
| 1 | +import fs from 'node:fs/promises' |
| 2 | +import path from 'node:path' |
| 3 | +import playwright from '../src/frontend/node_modules/playwright/index.js' |
| 4 | + |
| 5 | +const { chromium } = playwright |
| 6 | + |
| 7 | +const root = path.resolve(new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')) |
| 8 | +const stamp = new Date().toISOString().replace(/[:.]/g, '-') |
| 9 | +const outDir = path.join(root, 'output', 'playwright', `simple-easy-audit-${stamp}`) |
| 10 | +await fs.mkdir(outDir, { recursive: true }) |
| 11 | + |
| 12 | +const routes = (process.env.AUDIT_ROUTES ?? '/sites,/settings') |
| 13 | + .split(',') |
| 14 | + .map(x => x.trim()) |
| 15 | + .filter(Boolean) |
| 16 | +const viewportsAll = [ |
| 17 | + { name: 'desktop', width: 1440, height: 960 }, |
| 18 | + { name: 'tablet', width: 768, height: 900 }, |
| 19 | + { name: 'mobile', width: 390, height: 844 }, |
| 20 | +] |
| 21 | +const viewportFilter = new Set((process.env.AUDIT_VIEWPORTS ?? '') |
| 22 | + .split(',') |
| 23 | + .map(x => x.trim()) |
| 24 | + .filter(Boolean)) |
| 25 | +const viewports = viewportFilter.size > 0 |
| 26 | + ? viewportsAll.filter(v => viewportFilter.has(v.name)) |
| 27 | + : viewportsAll |
| 28 | +const themes = (process.env.AUDIT_THEMES ?? 'dark,light') |
| 29 | + .split(',') |
| 30 | + .map(x => x.trim()) |
| 31 | + .filter(Boolean) |
| 32 | + |
| 33 | +const report = { |
| 34 | + startedAt: new Date().toISOString(), |
| 35 | + outDir, |
| 36 | + screens: [], |
| 37 | + console: [], |
| 38 | + pageErrors: [], |
| 39 | + badResponses: [], |
| 40 | +} |
| 41 | + |
| 42 | +function safeName(input) { |
| 43 | + return input.replace(/^\/+/, '').replace(/[^\w.-]+/g, '_') || 'root' |
| 44 | +} |
| 45 | + |
| 46 | +async function currentMainPage(browser) { |
| 47 | + const contexts = browser.contexts() |
| 48 | + for (const context of contexts) { |
| 49 | + const pages = context.pages() |
| 50 | + const appPage = pages.find(p => p.url().includes('127.0.0.1:5190') || p.url().includes('localhost:5190')) |
| 51 | + if (appPage) return appPage |
| 52 | + if (pages[0]) return pages[0] |
| 53 | + } |
| 54 | + throw new Error('No Electron page available over CDP') |
| 55 | +} |
| 56 | + |
| 57 | +async function waitSettled(page) { |
| 58 | + await page.waitForLoadState('domcontentloaded').catch(() => {}) |
| 59 | + await page.locator('.sites-simple, .settings-page').first().waitFor({ state: 'visible', timeout: 6000 }).catch(() => {}) |
| 60 | + await page.locator('.splash-overlay, .splash-screen, .app-loading').first().waitFor({ state: 'hidden', timeout: 6000 }).catch(() => {}) |
| 61 | + await page.waitForTimeout(500) |
| 62 | +} |
| 63 | + |
| 64 | +async function setSimpleMode(page) { |
| 65 | + const mode = process.env.AUDIT_UI_MODE ?? 'simple' |
| 66 | + await page.evaluate((mode) => localStorage.setItem('wdc-ui-mode', mode), mode) |
| 67 | +} |
| 68 | + |
| 69 | +async function setTheme(page, theme) { |
| 70 | + await page.evaluate((theme) => { |
| 71 | + localStorage.setItem('nks-wdc-theme', theme) |
| 72 | + document.documentElement.classList.toggle('dark', theme === 'dark') |
| 73 | + }, theme) |
| 74 | +} |
| 75 | + |
| 76 | +async function screenshot(page, name) { |
| 77 | + const file = path.join(outDir, `${name}.png`) |
| 78 | + await page.screenshot({ path: file, fullPage: true, timeout: 15000 }) |
| 79 | + return path.relative(root, file).replaceAll('\\', '/') |
| 80 | +} |
| 81 | + |
| 82 | +async function metrics(page) { |
| 83 | + return await page.evaluate(() => { |
| 84 | + const parseColor = (raw) => { |
| 85 | + const match = String(raw).match(/rgba?\(([^)]+)\)/) |
| 86 | + if (!match) return null |
| 87 | + const parts = match[1].split(',').map(x => Number.parseFloat(x.trim())) |
| 88 | + return { r: parts[0], g: parts[1], b: parts[2], a: parts[3] ?? 1 } |
| 89 | + } |
| 90 | + const luminance = (c) => { |
| 91 | + const vals = [c.r, c.g, c.b].map(v => { |
| 92 | + const s = v / 255 |
| 93 | + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4) |
| 94 | + }) |
| 95 | + return 0.2126 * vals[0] + 0.7152 * vals[1] + 0.0722 * vals[2] |
| 96 | + } |
| 97 | + const ratio = (fg, bg) => { |
| 98 | + const a = luminance(fg) |
| 99 | + const b = luminance(bg) |
| 100 | + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) |
| 101 | + } |
| 102 | + const backgroundFor = (el) => { |
| 103 | + let cur = el |
| 104 | + while (cur && cur !== document.documentElement) { |
| 105 | + const bg = parseColor(getComputedStyle(cur).backgroundColor) |
| 106 | + if (bg && bg.a > 0.05) return bg |
| 107 | + cur = cur.parentElement |
| 108 | + } |
| 109 | + return parseColor(getComputedStyle(document.body).backgroundColor) || { r: 255, g: 255, b: 255, a: 1 } |
| 110 | + } |
| 111 | + const visible = (el) => { |
| 112 | + const rect = el.getBoundingClientRect() |
| 113 | + const cs = getComputedStyle(el) |
| 114 | + return rect.width > 0 && rect.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' |
| 115 | + } |
| 116 | + const textNodes = [...document.querySelectorAll('h1,h2,h3,h4,p,label,button,a,td,th,li,span,.el-tag,.hint,.form-hint,.tab-desc')] |
| 117 | + .filter(visible) |
| 118 | + .filter(el => (el.textContent || '').trim().length > 0) |
| 119 | + .slice(0, 500) |
| 120 | + const lowContrast = [] |
| 121 | + const smallText = [] |
| 122 | + const clipped = [] |
| 123 | + for (const el of textNodes) { |
| 124 | + const rect = el.getBoundingClientRect() |
| 125 | + const cs = getComputedStyle(el) |
| 126 | + const fg = parseColor(cs.color) |
| 127 | + const bg = backgroundFor(el) |
| 128 | + const text = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 90) |
| 129 | + const fontSize = Number.parseFloat(cs.fontSize) |
| 130 | + if (fontSize < 11) smallText.push({ text, fontSize: Number(fontSize.toFixed(1)) }) |
| 131 | + if (rect.right > window.innerWidth + 2 || rect.left < -2) clipped.push({ text, left: Math.round(rect.left), right: Math.round(rect.right) }) |
| 132 | + if (fg && bg) { |
| 133 | + const r = ratio(fg, bg) |
| 134 | + const required = fontSize >= 18 || (fontSize >= 14 && Number.parseInt(cs.fontWeight, 10) >= 600) ? 3 : 4.5 |
| 135 | + if (r < required) lowContrast.push({ text, ratio: Number(r.toFixed(2)), required }) |
| 136 | + } |
| 137 | + } |
| 138 | + const targets = [...document.querySelectorAll('button,a,[role="button"],input,select,textarea,.el-switch,.el-checkbox,.el-radio')] |
| 139 | + .filter(visible) |
| 140 | + .slice(0, 350) |
| 141 | + const smallTargets = [] |
| 142 | + for (const el of targets) { |
| 143 | + const rect = el.getBoundingClientRect() |
| 144 | + const text = (el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('placeholder') || el.tagName).trim().replace(/\s+/g, ' ').slice(0, 90) |
| 145 | + if (rect.width < 32 || rect.height < 32) smallTargets.push({ text, width: Math.round(rect.width), height: Math.round(rect.height) }) |
| 146 | + } |
| 147 | + return { |
| 148 | + url: location.href, |
| 149 | + bodyTextLength: document.body.innerText.length, |
| 150 | + horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth + 2, |
| 151 | + scrollWidth: document.documentElement.scrollWidth, |
| 152 | + innerWidth: window.innerWidth, |
| 153 | + lowContrast: lowContrast.slice(0, 40), |
| 154 | + smallText: smallText.slice(0, 40), |
| 155 | + smallTargets: smallTargets.slice(0, 40), |
| 156 | + clipped: clipped.slice(0, 40), |
| 157 | + } |
| 158 | + }) |
| 159 | +} |
| 160 | + |
| 161 | +const browser = await chromium.connectOverCDP('http://127.0.0.1:9222') |
| 162 | +const page = await currentMainPage(browser) |
| 163 | +page.setDefaultTimeout(4000) |
| 164 | +page.on('console', (msg) => { |
| 165 | + if (['error', 'warning'].includes(msg.type())) report.console.push({ type: msg.type(), text: msg.text(), url: page.url() }) |
| 166 | +}) |
| 167 | +page.on('pageerror', (err) => report.pageErrors.push({ message: err.message, stack: err.stack, url: page.url() })) |
| 168 | +page.on('response', (res) => { |
| 169 | + if (res.status() >= 400) report.badResponses.push({ status: res.status(), url: res.url(), page: page.url() }) |
| 170 | +}) |
| 171 | + |
| 172 | +try { |
| 173 | + await setSimpleMode(page) |
| 174 | + for (const theme of themes) { |
| 175 | + await setTheme(page, theme) |
| 176 | + for (const vp of viewports) { |
| 177 | + await page.setViewportSize({ width: vp.width, height: vp.height }) |
| 178 | + for (const route of routes) { |
| 179 | + await page.goto(`http://127.0.0.1:5190/?audit=${Date.now()}#${route}`, { waitUntil: 'domcontentloaded', timeout: 6000 }) |
| 180 | + await waitSettled(page) |
| 181 | + const shot = await screenshot(page, `${theme}-${vp.name}-${safeName(route)}`) |
| 182 | + report.screens.push({ theme, viewport: vp, route, screenshot: shot, metrics: await metrics(page) }) |
| 183 | + } |
| 184 | + } |
| 185 | + } |
| 186 | + const siteLinks = await page.locator('a,button,.site-card,.site-mobile-main').count().catch(() => 0) |
| 187 | + report.siteClickTargets = siteLinks |
| 188 | + report.finishedAt = new Date().toISOString() |
| 189 | +} finally { |
| 190 | + await fs.writeFile(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2)) |
| 191 | + const lines = [ |
| 192 | + '# Simple/Easy UI Audit', |
| 193 | + '', |
| 194 | + `Started: ${report.startedAt}`, |
| 195 | + `Finished: ${report.finishedAt ?? '(interrupted)'}`, |
| 196 | + `Screens: ${report.screens.length}`, |
| 197 | + `Console warnings/errors: ${report.console.length}`, |
| 198 | + `Page errors: ${report.pageErrors.length}`, |
| 199 | + `HTTP >=400 responses: ${report.badResponses.length}`, |
| 200 | + '', |
| 201 | + '## Screens', |
| 202 | + ...report.screens.map(s => `- ${s.theme} ${s.viewport.name} ${s.route}: overflow=${s.metrics.horizontalOverflow} lowContrast=${s.metrics.lowContrast.length} clipped=${s.metrics.clipped.length} smallTargets=${s.metrics.smallTargets.length} screenshot=\`${s.screenshot}\``), |
| 203 | + ] |
| 204 | + await fs.writeFile(path.join(outDir, 'summary.md'), lines.join('\n')) |
| 205 | + console.log(JSON.stringify({ outDir, screens: report.screens.length, console: report.console.length, pageErrors: report.pageErrors.length, badResponses: report.badResponses.length }, null, 2)) |
| 206 | + await page.setViewportSize({ width: 1440, height: 960 }).catch(() => {}) |
| 207 | + await setTheme(page, 'dark').catch(() => {}) |
| 208 | + await page.goto('http://127.0.0.1:5190/#/sites', { waitUntil: 'domcontentloaded', timeout: 5000 }).catch(() => {}) |
| 209 | + await browser.close().catch(() => {}) |
| 210 | +} |
0 commit comments