diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f46a8bd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [main] + tags: + - 'v*.*.*' + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Install dependencies + run: npm ci + - name: Install Playwright browsers (chromium only) + run: npx playwright install chromium --with-deps + - name: Smoke run against example.com + run: npm run smoke + - name: Upload smoke artifacts (report + shots) + if: always() + uses: actions/upload-artifact@v4 + with: + name: smoke-artifacts + path: | + /tmp/uxray-ci.json + /tmp/uxray-shots + + publish: + needs: test + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + - name: Install dependencies + run: npm ci + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --provenance diff --git a/README.md b/README.md index 9741e45..5aa9e90 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,10 @@ # UXRay -Audit any hosted web app for quick UI/UX health: - -- Layout issues: horizontal overflow offenders, scroll snapshots. -- Touch ergonomics: finds tap targets smaller than 44px. -- Accessibility: runs axe-core via Playwright. -- Theme signals: samples top colors and fonts. -- Stability: logs console errors/warnings and failed network requests. -- Evidence: full-page + stepped viewport screenshots. +Fast UI/UX audit CLI for live web apps. Produces a JSON report plus evidence screenshots, and can emit an HTML summary. ## Prereqs -- Node.js 18+ (Playwright uses modern APIs). -- One-time: download bundled browsers +- Node.js 20+ +- One-time browser install: ```bash npx playwright install @@ -23,10 +16,10 @@ npx playwright install npm install ``` -Or run directly after cloning with `npx` (no global install): +Or run without installing (after cloning): ```bash -npx ./ui-review.js --url https://example.com +npx uxray --url https://example.com ``` ## Usage @@ -37,47 +30,42 @@ npm run review -- --url https://your-app.com \ --viewport 1366x768 \ --steps 4 \ --wait 2000 \ + --wait-until load \ + --ready-selector '#app' \ + --target-policy wcag22-aa \ + --axe-tags wcag21aa,wcag2aa \ + --html \ + --max-a11y 5 \ + --max-small-targets 10 \ + --max-overflow 2 \ + --max-console 3 \ + --max-http-errors 0 \ + --trace \ --out ./reports/uxray-report.json \ --shots ./reports/shots ``` -Short flags: -- `--url` (required) target page. -- `--mobile` also run an iPhone 12 emulation pass. -- `--viewport` desktop viewport, e.g. `1440x900` (default 1280x720). -- `--steps` number of viewport screenshots while scrolling (default 4). -- `--wait` extra ms to settle after load (default 1500ms). -- `--out` output report path (JSON). -- `--shots` screenshots root folder. - -After a run you’ll see: -- `reports/ui-report-.json` with counts and offenders. -- `reports/shots-/desktop|mobile` PNGs. +## CLI flags (high-value) +- `--url` target page (required) +- `--mobile` add iPhone 12 pass +- `--viewport 1440x900` +- `--steps` viewport screenshots while scrolling +- `--wait` extra settle time (ms) +- `--wait-until` load|domcontentloaded|networkidle|commit +- `--ready-selector` wait for selector after navigation +- `--target-policy` wcag22-aa | wcag21-aaa | lighthouse +- `--axe-tags` comma tags for axe rules +- `--html` emit HTML report (optional path) +- `--trace` capture Playwright trace +- Budget gates: `--max-a11y`, `--max-small-targets`, `--max-overflow`, `--max-console`, `--max-http-errors` -## JSON report shape (excerpt) - -```json -{ - "url": "https://example.com", - "runAt": "2026-04-10T18:00:00.000Z", - "desktop": { - "viewport": "1280x720", - "navTimeMs": 2310, - "perf": { "domContentLoaded": 980, "load": 1500, "renderBlocking": 210 }, - "overflow": { "hasOverflowX": false, "offenders": [] }, - "tapTargets": { "smallTargetCount": 2, "samples": [...] }, - "viewportMeta": true, - "styles": { "topColors": [...], "topFonts": [...] }, - "axe": { "summary": { "violations": 3, "passes": 150, "incomplete": 0 }, "topViolations": [...] }, - "screenshots": ["reports/shots.../desktop-full.png", "..."], - "networkIssues": [], - "consoleIssues": [] - }, - "mobile": { ... } -} -``` +## Outputs +- JSON report: `reports/ui-report-.json` +- Screenshots: `reports/shots-/desktop|mobile` +- Optional HTML: `reports/ui-report-.html` +- Optional trace: `reports/shots-/desktop|mobile-trace.zip` +- Crops: `reports/shots-/desktop|mobile/crops/` -## Notes -- Tool runs headless; remove `headless: true` in `ui-review.js` if you want to watch it. -- If Playwright can’t launch browsers on macOS due to permissions, try running outside sandboxed shells or re-run `npx playwright install`. -- Reports and screenshots are git-ignored. +## CI & release +- GitHub Actions runs `npm run smoke` on PRs and publishes artifacts. +- Tag `v*.*.*` to publish to npm (requires `NPM_TOKEN`). diff --git a/package.json b/package.json index 892b58d..74c9593 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "uxray": "./ui-review.js" }, "scripts": { - "review": "node ui-review.js" + "review": "node ui-review.js", + "smoke": "node scripts/smoke.js", + "ci": "npm run smoke" }, "repository": { "type": "git", diff --git a/scripts/smoke.js b/scripts/smoke.js new file mode 100644 index 0000000..75a5ae5 --- /dev/null +++ b/scripts/smoke.js @@ -0,0 +1,21 @@ +#!/usr/bin/env node +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const outFile = path.join(os.tmpdir(), 'uxray-ci.json'); +const shotsDir = path.join(os.tmpdir(), 'uxray-shots'); + +const args = [ + path.join(__dirname, '..', 'ui-review.js'), + '--url', 'https://example.com', + '--steps', '1', + '--wait', '800', + '--wait-until', 'load', + '--target-policy', 'wcag21-aaa', + '--out', outFile, + '--shots', shotsDir, +]; + +const result = spawnSync('node', args, { stdio: 'inherit' }); +process.exit(result.status === null ? 1 : result.status); diff --git a/src/audits/a11y.js b/src/audits/a11y.js new file mode 100644 index 0000000..671614c --- /dev/null +++ b/src/audits/a11y.js @@ -0,0 +1,27 @@ +const { AxeBuilder } = require('@axe-core/playwright'); + +async function runAxe(page, { axeTags }) { + let builder = new AxeBuilder({ page }); + if (axeTags && Array.isArray(axeTags) && axeTags.length) { + builder = builder.withTags(axeTags); + } + const results = await builder.analyze(); + return { + summary: { + violations: results.violations.length, + passes: results.passes.length, + incomplete: results.incomplete.length, + }, + topViolations: results.violations.slice(0, 10).map((v) => ({ + id: v.id, + description: v.description, + impact: v.impact, + help: v.help, + nodes: v.nodes.slice(0, 4).map((n) => ({ target: n.target, summary: n.failureSummary })), + })), + }; +} + +module.exports = { + runAxe, +}; diff --git a/src/audits/dom.js b/src/audits/dom.js new file mode 100644 index 0000000..ccd4567 --- /dev/null +++ b/src/audits/dom.js @@ -0,0 +1,227 @@ +async function collectDomSignals(page) { + return page.evaluate(async () => { + const format = (el) => { + const cls = typeof el.className === 'string' && el.className.trim() + ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` + : ''; + return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${cls}`; + }; + + const visible = (el) => { + const rect = el.getBoundingClientRect(); + if (!rect || !rect.width || !rect.height) return false; + if (rect.bottom < 0 || rect.right < 0) return false; + if (rect.top > window.innerHeight || rect.left > window.innerWidth) return false; + return true; + }; + + const textClips = []; + const missingImages = []; + const coveredElements = []; + const deadLinks = []; + const buttonsNoText = []; + const inputsMissingLabels = []; + const formsWithoutSubmit = []; + const genericCtas = []; + const loremText = []; + const externalBlankNoRel = []; + const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6')); + const headingOrderIssues = []; + + const stateSignals = { + ariaLive: 0, + roleAlert: 0, + ariaBusy: 0, + loadingHints: 0, + errorHints: 0, + emptyHints: 0, + }; + + const textClipCandidates = Array.from(document.querySelectorAll('body *')).slice(0, 500); + textClipCandidates.forEach((el) => { + if (!el.textContent || !el.textContent.trim()) return; + const style = getComputedStyle(el); + if (!['hidden', 'clip'].includes(style.overflow) && !['hidden', 'clip'].includes(style.overflowX) && !['hidden', 'clip'].includes(style.overflowY)) return; + if (el.scrollWidth > el.clientWidth + 2 || el.scrollHeight > el.clientHeight + 2) { + if (textClips.length < 20) textClips.push({ selector: format(el) }); + } + }); + + const imgs = Array.from(document.querySelectorAll('img')).slice(0, 200); + imgs.forEach((img) => { + const inView = visible(img); + const loading = (img.getAttribute('loading') || '').toLowerCase(); + const isLazy = loading === 'lazy' || img.loading === 'lazy'; + const hasLazyData = Boolean(img.getAttribute('data-src') || img.getAttribute('data-srcset') || img.getAttribute('data-lazy')); + if (!inView && (isLazy || hasLazyData)) return; + if (img.complete && img.naturalWidth === 0) { + if (missingImages.length < 20) missingImages.push({ selector: format(img), src: img.currentSrc || img.src }); + } + }); + + const candidates = Array.from(document.querySelectorAll('body *')).filter(visible).slice(0, 120); + candidates.forEach((el) => { + const rect = el.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const topEl = document.elementFromPoint(cx, cy); + if (!topEl || topEl === el || el.contains(topEl)) return; + if (coveredElements.length < 20) coveredElements.push({ selector: format(el), coveredBy: format(topEl) }); + }); + + const links = Array.from(document.querySelectorAll('a')).slice(0, 300); + links.forEach((a) => { + const href = (a.getAttribute('href') || '').trim(); + const hasDeadHref = !href || href === '#' || href.toLowerCase().startsWith('javascript:'); + const role = (a.getAttribute('role') || '').toLowerCase(); + const hasRoleAction = ['button', 'tab', 'menuitem', 'switch', 'option'].includes(role); + const hasHandlers = Boolean( + a.getAttribute('onclick') + || a.getAttribute('onmousedown') + || a.getAttribute('onmouseup') + || a.getAttribute('onkeydown') + || a.getAttribute('onkeyup') + || a.getAttribute('onkeypress'), + ); + const hasAriaAction = Boolean( + a.getAttribute('aria-controls') + || a.getAttribute('aria-expanded') + || a.getAttribute('aria-haspopup'), + ); + const hasDataAction = a.dataset && Object.keys(a.dataset).length > 0; + const likelyJsAction = hasRoleAction || hasHandlers || hasAriaAction || hasDataAction; + if (hasDeadHref && !likelyJsAction && deadLinks.length < 20) deadLinks.push({ selector: format(a), href }); + if (a.target === '_blank') { + const rel = (a.getAttribute('rel') || '').toLowerCase(); + if (!rel.includes('noopener') && externalBlankNoRel.length < 20) { + externalBlankNoRel.push({ selector: format(a), href: a.href }); + } + } + }); + + const buttons = Array.from(document.querySelectorAll('button, [role="button"]')).slice(0, 200); + buttons.forEach((btn) => { + const text = (btn.innerText || '').trim(); + const aria = btn.getAttribute('aria-label') || btn.getAttribute('aria-labelledby'); + if (!text && !aria && buttonsNoText.length < 20) { + buttonsNoText.push({ selector: format(btn) }); + } + if (/^(click here|submit|go|next|ok)$/i.test(text) && genericCtas.length < 20) { + genericCtas.push({ selector: format(btn), text }); + } + }); + + const inputs = Array.from(document.querySelectorAll('input, select, textarea')).slice(0, 300); + inputs.forEach((el) => { + const inputType = (el.type || '').toLowerCase(); + if (['hidden', 'submit', 'button', 'reset', 'image'].includes(inputType)) return; + const hasLabel = (el.labels && el.labels.length > 0) + || el.getAttribute('aria-label') + || el.getAttribute('aria-labelledby'); + if (!hasLabel && inputsMissingLabels.length < 30) { + inputsMissingLabels.push({ selector: format(el), name: el.getAttribute('name') || '' }); + } + const placeholder = (el.getAttribute('placeholder') || '').trim(); + if (/lorem ipsum|dolor sit|consectetur/i.test(placeholder) && loremText.length < 20) { + loremText.push({ selector: format(el), text: placeholder }); + } + }); + + const forms = Array.from(document.querySelectorAll('form')).slice(0, 80); + forms.forEach((form) => { + const hasSubmit = form.querySelector('button[type="submit"], button:not([type]), input[type="submit"]'); + if (!hasSubmit && formsWithoutSubmit.length < 20) formsWithoutSubmit.push({ selector: format(form) }); + }); + + const allText = Array.from(document.querySelectorAll('body *')).slice(0, 500); + allText.forEach((el) => { + const text = (el.textContent || '').trim(); + if (!text) return; + if (/lorem ipsum|dolor sit|consectetur/i.test(text) && loremText.length < 20) { + loremText.push({ selector: format(el), text: text.slice(0, 80) }); + } + }); + + headings.forEach((h, idx) => { + const level = Number(h.tagName.slice(1)); + const prev = headings[idx - 1]; + if (prev) { + const prevLevel = Number(prev.tagName.slice(1)); + if (level - prevLevel > 1 && headingOrderIssues.length < 20) { + headingOrderIssues.push({ selector: format(h), from: prev.tagName.toLowerCase(), to: h.tagName.toLowerCase() }); + } + } + }); + if (headings.length && headings[0].tagName.toLowerCase() !== 'h1') { + headingOrderIssues.unshift({ selector: format(headings[0]), from: 'none', to: headings[0].tagName.toLowerCase() }); + } + + const ariaLive = document.querySelectorAll('[aria-live]').length; + const roleAlert = document.querySelectorAll('[role="alert"]').length; + const ariaBusy = document.querySelectorAll('[aria-busy="true"]').length; + stateSignals.ariaLive = ariaLive; + stateSignals.roleAlert = roleAlert; + stateSignals.ariaBusy = ariaBusy; + + const classHints = (name) => document.querySelectorAll(`[class*="${name}"]`).length; + stateSignals.loadingHints = classHints('loading') + classHints('spinner') + classHints('skeleton'); + stateSignals.errorHints = classHints('error') + classHints('alert'); + stateSignals.emptyHints = classHints('empty'); + + return { + textClips, + missingImages, + coveredElements, + deadLinks, + buttonsNoText, + inputsMissingLabels, + formsWithoutSubmit, + genericCtas, + loremText, + externalBlankNoRel, + headingOrderIssues, + stateSignals, + }; + }); +} + +async function checkFocusVisibility(page, limit = 20) { + return page.evaluate(async (maxCount) => { + const format = (el) => { + const cls = typeof el.className === 'string' && el.className.trim() + ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` + : ''; + return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${cls}`; + }; + const focusables = Array.from(document.querySelectorAll('a,button,input,select,textarea,[tabindex]:not([tabindex="-1"])')) + .filter((el) => el.isConnected) + .filter((el) => !el.hasAttribute('disabled')) + .filter((el) => { + const style = getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden' || Number.parseFloat(style.opacity) === 0) return false; + if (el.getClientRects().length === 0) return false; + if (el.offsetWidth === 0 && el.offsetHeight === 0) return false; + return true; + }) + .slice(0, maxCount); + const missing = []; + let visibleCount = 0; + for (const el of focusables) { + el.focus(); + await new Promise((r) => requestAnimationFrame(r)); + const style = getComputedStyle(el); + const hasOutline = style.outlineStyle !== 'none' && Number.parseFloat(style.outlineWidth) > 0; + const hasBoxShadow = style.boxShadow && style.boxShadow !== 'none'; + if (hasOutline || hasBoxShadow) visibleCount += 1; + if (!hasOutline && !hasBoxShadow && missing.length < 10) { + missing.push({ selector: format(el) }); + } + } + return { total: focusables.length, visibleCount, missing }; + }, limit); +} + +module.exports = { + collectDomSignals, + checkFocusVisibility, +}; diff --git a/src/audits/layout.js b/src/audits/layout.js new file mode 100644 index 0000000..51f74ae --- /dev/null +++ b/src/audits/layout.js @@ -0,0 +1,127 @@ +const { TARGET_POLICIES } = require('../config'); + +async function detectOverflow(page) { + return page.evaluate(() => { + const doc = document.documentElement; + const hasOverflowX = doc.scrollWidth - doc.clientWidth > 2; + const offenders = []; + + if (hasOverflowX) { + const nodes = Array.from(document.querySelectorAll('body *')); + const format = (el) => { + const cls = typeof el.className === 'string' && el.className.trim() + ? `.${el.className.trim().split(/\s+/).slice(0, 3).join('.')}` + : ''; + return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${cls}`; + }; + + for (const el of nodes) { + const rect = el.getBoundingClientRect(); + if (!rect || !rect.width || !rect.height) continue; + if (rect.right > doc.clientWidth + 1 || rect.left < -1) { + offenders.push({ + selector: format(el), + rect: { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + right: Math.round(rect.right), + }, + }); + if (offenders.length >= 40) break; + } + } + } + + return { hasOverflowX, offenders }; + }); +} + +async function detectTapTargets(page, policy) { + const effectivePolicy = TARGET_POLICIES[policy] || TARGET_POLICIES['wcag21-aaa']; + return page.evaluate((policyDef) => { + const nodes = Array.from( + document.querySelectorAll('button, a, input, select, textarea, [role="button"], [role="link"], [role="menuitem"]'), + ); + + const tiny = []; + const format = (el) => { + const cls = typeof el.className === 'string' && el.className.trim() + ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` + : ''; + return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${cls}`; + }; + + const rects = nodes.map((el) => ({ el, rect: el.getBoundingClientRect() })).filter(({ rect }) => rect?.width && rect?.height); + + const distanceBetween = (a, b) => { + const dx = Math.max(0, Math.max(b.rect.left - a.rect.right, a.rect.left - b.rect.right)); + const dy = Math.max(0, Math.max(b.rect.top - a.rect.bottom, a.rect.top - b.rect.bottom)); + return Math.hypot(dx, dy); + }; + + rects.forEach((entry, idx) => { + const { rect, el } = entry; + const isBigEnough = rect.width >= policyDef.minSize && rect.height >= policyDef.minSize; + if (isBigEnough) return; + + let spacedEnough = false; + if (policyDef.spacing > 0) { + let minGap = Infinity; + for (let j = 0; j < rects.length; j += 1) { + if (j === idx) continue; + const gap = distanceBetween(entry, rects[j]); + if (gap < minGap) minGap = gap; + if (minGap < policyDef.spacing) break; + } + spacedEnough = minGap >= policyDef.spacing; + } + + if (!spacedEnough) { + tiny.push({ + selector: format(el), + rect: { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }, + size: { width: Math.round(rect.width), height: Math.round(rect.height) }, + text: (el.innerText || '').trim().slice(0, 80), + }); + } + }); + + return { + policy: policyDef, + smallTargetCount: tiny.length, + samples: tiny.slice(0, 30), + }; + }, effectivePolicy); +} + +async function checkReflow(page) { + const original = page.viewportSize && page.viewportSize(); + if (!original || !original.width || !original.height) return { width: null, overflowX: null }; + try { + await page.setViewportSize({ width: 320, height: original.height }); + await page.waitForTimeout(200); + const overflowX = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth > 1); + await page.setViewportSize(original); + return { width: 320, overflowX }; + } catch (err) { + try { + await page.setViewportSize(original); + } catch (_) { + // ignore + } + return { width: 320, overflowX: null }; + } +} + +module.exports = { + detectOverflow, + detectTapTargets, + checkReflow, +}; diff --git a/src/audits/style.js b/src/audits/style.js new file mode 100644 index 0000000..960ad5a --- /dev/null +++ b/src/audits/style.js @@ -0,0 +1,96 @@ +async function sampleStyles(page) { + return page.evaluate(() => { + const toHex = (rgb) => { + const parts = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); + if (!parts) return null; + return `#${[1, 2, 3] + .map((i) => Number(parts[i]).toString(16).padStart(2, '0')) + .join('')}`; + }; + + const parseRgb = (rgb) => { + const parts = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/i); + if (!parts) return null; + return { + r: Number(parts[1]), + g: Number(parts[2]), + b: Number(parts[3]), + a: parts[4] !== undefined ? Number(parts[4]) : 1, + }; + }; + + const relLuminance = ({ r, g, b }) => { + const f = (c) => { + const v = c / 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); + }; + + const contrastRatio = (fg, bg) => { + const L1 = relLuminance(fg); + const L2 = relLuminance(bg); + return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05); + }; + + const colorCounts = new Map(); + const fontCounts = new Map(); + const contrastRisks = []; + + const elements = Array.from(document.querySelectorAll('body *')).slice(0, 400); + + elements.forEach((el) => { + const style = getComputedStyle(el); + const fgHex = toHex(style.color); + const bgHex = toHex(style.backgroundColor); + const rawBg = parseRgb(style.backgroundColor); + const font = style.fontFamily.split(',')[0].replace(/['"]/g, '').trim(); + + if (fgHex) colorCounts.set(fgHex, (colorCounts.get(fgHex) || 0) + 1); + if (bgHex && (!rawBg || rawBg.a !== 0)) colorCounts.set(bgHex, (colorCounts.get(bgHex) || 0) + 1); + if (font) fontCounts.set(font, (fontCounts.get(font) || 0) + 1); + + if (!el.textContent || !el.textContent.trim()) return; + const fg = parseRgb(style.color); + let bg = parseRgb(style.backgroundColor); + if (!bg || bg.a === 0) { + const bodyBg = parseRgb(getComputedStyle(document.body).backgroundColor); + bg = bodyBg || { r: 255, g: 255, b: 255, a: 1 }; + } + if (!fg || !bg) return; + const ratio = contrastRatio(fg, bg); + if (ratio < 4.5 && contrastRisks.length < 20) { + const cls = typeof el.className === 'string' && el.className.trim() + ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` + : ''; + contrastRisks.push({ + selector: `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${cls}`, + ratio: Number(ratio.toFixed(2)), + sampleText: el.textContent.trim().slice(0, 60), + }); + } + }); + + const topColors = Array.from(colorCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([color, weight]) => ({ color, weight })); + + const topFonts = Array.from(fontCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([font, weight]) => ({ font, weight })); + + return { + topColors, + topFonts, + contrastRisks, + colorCount: colorCounts.size, + fontCount: fontCounts.size, + }; + }); +} + +module.exports = { + sampleStyles, +}; diff --git a/src/cli/index.js b/src/cli/index.js new file mode 100644 index 0000000..532d615 --- /dev/null +++ b/src/cli/index.js @@ -0,0 +1,105 @@ +function parseArgs(argv) { + const args = { + url: null, + mobile: false, + width: 1280, + height: 720, + wait: 1500, + waitUntil: 'load', + readySelector: null, + steps: 4, + out: null, + html: null, + screenshots: null, + targetPolicy: 'wcag21-aaa', + axeTags: null, + trace: false, + budgetA11y: null, + budgetTap: null, + budgetOverflow: null, + budgetConsole: null, + budgetHttpErrors: null, + }; + + for (let i = 0; i < argv.length; i += 1) { + const val = argv[i]; + if (val === '--url' && argv[i + 1]) { + args.url = argv[i + 1]; + i += 1; + } else if (!val.startsWith('--') && !args.url) { + args.url = val; + } else if (val === '--mobile') { + args.mobile = true; + } else if (val === '--viewport' && argv[i + 1]) { + const [w, h] = argv[i + 1].split('x'); + const width = Number.parseInt(w, 10); + const height = Number.parseInt(h, 10); + if (Number.isFinite(width) && width > 0) args.width = width; + if (Number.isFinite(height) && height > 0) args.height = height; + i += 1; + } else if (val === '--wait' && argv[i + 1]) { + const wait = Number.parseInt(argv[i + 1], 10); + if (Number.isFinite(wait) && wait >= 0) args.wait = wait; + i += 1; + } else if (val === '--wait-until' && argv[i + 1]) { + args.waitUntil = argv[i + 1]; + i += 1; + } else if (val === '--ready-selector' && argv[i + 1]) { + args.readySelector = argv[i + 1]; + i += 1; + } else if (val === '--steps' && argv[i + 1]) { + const steps = Number.parseInt(argv[i + 1], 10); + if (Number.isFinite(steps) && steps >= 1) args.steps = steps; + i += 1; + } else if (val === '--out' && argv[i + 1]) { + args.out = argv[i + 1]; + i += 1; + } else if ((val === '--shots' || val === '--screenshots') && argv[i + 1]) { + args.screenshots = argv[i + 1]; + i += 1; + } else if (val === '--html') { + if (argv[i + 1] && !argv[i + 1].startsWith('--')) { + args.html = argv[i + 1]; + i += 1; + } else { + args.html = true; + } + } else if (val === '--target-policy' && argv[i + 1]) { + args.targetPolicy = argv[i + 1]; + i += 1; + } else if (val === '--axe-tags' && argv[i + 1]) { + args.axeTags = argv[i + 1]; + i += 1; + } else if (val === '--trace') { + args.trace = true; + } else if (val === '--max-a11y' && argv[i + 1]) { + const value = Number.parseInt(argv[i + 1], 10); + if (Number.isFinite(value) && value >= 0) args.budgetA11y = value; + i += 1; + } else if (val === '--max-small-targets' && argv[i + 1]) { + const value = Number.parseInt(argv[i + 1], 10); + if (Number.isFinite(value) && value >= 0) args.budgetTap = value; + i += 1; + } else if (val === '--max-overflow' && argv[i + 1]) { + const value = Number.parseInt(argv[i + 1], 10); + if (Number.isFinite(value) && value >= 0) args.budgetOverflow = value; + i += 1; + } else if (val === '--max-console' && argv[i + 1]) { + const value = Number.parseInt(argv[i + 1], 10); + if (Number.isFinite(value) && value >= 0) args.budgetConsole = value; + i += 1; + } else if (val === '--max-http-errors' && argv[i + 1]) { + const value = Number.parseInt(argv[i + 1], 10); + if (Number.isFinite(value) && value >= 0) args.budgetHttpErrors = value; + i += 1; + } + } + return args; +} + +const usage = 'Usage: node ui-review.js --url [--mobile] [--viewport 1280x720] [--out report.json] [--html [path]] [--shots ./reports] [--wait-until load|domcontentloaded|networkidle] [--ready-selector ] [--target-policy wcag22-aa|wcag21-aaa|lighthouse] [--axe-tags wcag21aa,wcag2aa] [--trace] [--max-a11y N] [--max-small-targets N] [--max-overflow N] [--max-console N] [--max-http-errors N]'; + +module.exports = { + parseArgs, + usage, +}; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..7bedf3f --- /dev/null +++ b/src/config.js @@ -0,0 +1,12 @@ +const TARGET_POLICIES = { + 'wcag22-aa': { id: 'wcag22-aa', label: 'WCAG 2.2 AA Target Size Minimum', minSize: 24, spacing: 8 }, + 'wcag21-aaa': { id: 'wcag21-aaa', label: 'WCAG 2.1 AAA Target Size', minSize: 44, spacing: 0 }, + lighthouse: { id: 'lighthouse', label: 'Lighthouse recommended tap target size', minSize: 48, spacing: 0 }, +}; + +const WAIT_UNTIL_OPTIONS = new Set(['load', 'domcontentloaded', 'networkidle', 'commit']); + +module.exports = { + TARGET_POLICIES, + WAIT_UNTIL_OPTIONS, +}; diff --git a/src/core/audit-runner.js b/src/core/audit-runner.js new file mode 100644 index 0000000..4bc168a --- /dev/null +++ b/src/core/audit-runner.js @@ -0,0 +1,75 @@ +const path = require('path'); +const { detectOverflow, detectTapTargets, checkReflow } = require('../audits/layout'); +const { sampleStyles } = require('../audits/style'); +const { captureScrollShots, captureCrops } = require('../evidence'); +const { collectDomSignals, checkFocusVisibility } = require('../audits/dom'); +const { collectNavigationPerf, collectPerfSignals } = require('../perf'); +const { runAxe } = require('../audits/a11y'); +const { buildEvaluation } = require('./evaluation'); + +async function collectMetrics(page, opts) { + const perf = await collectNavigationPerf(page); + const overflow = await detectOverflow(page); + const tapTargets = await detectTapTargets(page, opts.targetPolicy); + const styles = await sampleStyles(page); + const axe = await runAxe(page, { axeTags: opts.axeTags }); + const domSignals = await collectDomSignals(page); + const perfSignals = await collectPerfSignals(page); + const viewportMeta = await page.evaluate(() => Boolean(document.querySelector('meta[name="viewport"]'))); + + return { + perf, + overflow, + tapTargets, + styles, + axe, + domSignals, + perfSignals, + viewportMeta, + }; +} + +async function collectEvidence(page, opts, metrics) { + const overflowCrops = await captureCrops(page, metrics.overflow.offenders, path.join(opts.screenshotsDir, 'crops'), 'overflow'); + const tapCrops = await captureCrops(page, metrics.tapTargets.samples, path.join(opts.screenshotsDir, 'crops'), 'tap'); + const shots = await captureScrollShots(page, opts.screenshotsDir, opts.emulateMobile ? 'mobile' : 'desktop', opts.steps); + const focusSignals = await checkFocusVisibility(page); + const reflow = opts.emulateMobile ? null : await checkReflow(page); + + return { + overflowCrops, + tapCrops, + shots, + focusSignals, + reflow, + }; +} + +async function runAuditPipeline(page, opts) { + const metrics = await collectMetrics(page, opts); + const evidence = await collectEvidence(page, opts, metrics); + const evaluation = buildEvaluation({ + overflow: metrics.overflow, + tapTargets: metrics.tapTargets, + styles: metrics.styles, + axe: metrics.axe, + domSignals: metrics.domSignals, + perfSignals: metrics.perfSignals, + viewportMeta: metrics.viewportMeta, + reflow: evidence.reflow, + consoleIssues: opts.consoleIssues, + networkIssues: opts.networkIssues, + focusSignals: evidence.focusSignals, + perf: metrics.perf, + }); + + return { + ...metrics, + ...evidence, + evaluation, + }; +} + +module.exports = { + runAuditPipeline, +}; diff --git a/src/core/evaluation.js b/src/core/evaluation.js new file mode 100644 index 0000000..ee14ea0 --- /dev/null +++ b/src/core/evaluation.js @@ -0,0 +1,174 @@ +const { clampScore } = require('../utils'); +const { DEFAULT_POLICY } = require('./scoring-policy'); + +function buildEvaluation(input, policy = DEFAULT_POLICY) { + const { + overflow, + tapTargets, + styles, + axe, + domSignals, + perfSignals, + viewportMeta, + reflow, + consoleIssues, + networkIssues, + focusSignals, + perf, + } = input; + + const visualPolicy = policy.visual || {}; + const functionalPolicy = policy.functional || {}; + const a11yPolicy = policy.accessibility || {}; + const responsivePolicy = policy.responsive || {}; + const perfPolicy = policy.performance || {}; + const designPolicy = policy.designConsistency || {}; + const contentPolicy = policy.contentQuality || {}; + const statePolicy = policy.stateCoverage || {}; + const trustPolicy = policy.trustPolish || {}; + const regressionPolicy = policy.regressionRisk || {}; + + const contrastCount = styles?.contrastRisks?.length || 0; + const clippedCount = domSignals?.textClips?.length || 0; + const overlapCount = domSignals?.coveredElements?.length || 0; + const missingImages = domSignals?.missingImages?.length || 0; + + const visualScore = clampScore(100 + - (overflow?.offenders?.length || 0) * (visualPolicy.overflowPenalty ?? 2) + - clippedCount * (visualPolicy.clippedPenalty ?? 2) + - overlapCount * (visualPolicy.overlapPenalty ?? 1) + - missingImages * (visualPolicy.missingImagePenalty ?? 5) + - contrastCount * (visualPolicy.contrastPenalty ?? 1)); + const visualIssues = []; + if (overflow?.hasOverflowX) visualIssues.push(`horizontal overflow (${overflow.offenders.length})`); + if (clippedCount) visualIssues.push(`clipped text (${clippedCount})`); + if (overlapCount) visualIssues.push(`overlapping/covered elements (${overlapCount})`); + if (missingImages) visualIssues.push(`missing/broken images (${missingImages})`); + if (contrastCount) visualIssues.push(`contrast risks (${contrastCount})`); + + const deadLinks = domSignals?.deadLinks?.length || 0; + const buttonsNoText = domSignals?.buttonsNoText?.length || 0; + const inputsMissingLabels = domSignals?.inputsMissingLabels?.length || 0; + const formsWithoutSubmit = domSignals?.formsWithoutSubmit?.length || 0; + const functionalScore = clampScore(100 + - deadLinks * (functionalPolicy.deadLinkPenalty ?? 2) + - buttonsNoText * (functionalPolicy.buttonsNoTextPenalty ?? 2) + - inputsMissingLabels * (functionalPolicy.inputsMissingLabelsPenalty ?? 2) + - formsWithoutSubmit * (functionalPolicy.formsWithoutSubmitPenalty ?? 5)); + const functionalIssues = []; + if (deadLinks) functionalIssues.push(`dead/placeholder links (${deadLinks})`); + if (buttonsNoText) functionalIssues.push(`buttons without labels (${buttonsNoText})`); + if (inputsMissingLabels) functionalIssues.push(`inputs missing labels (${inputsMissingLabels})`); + if (formsWithoutSubmit) functionalIssues.push(`forms without submit (${formsWithoutSubmit})`); + + const axeViolations = axe?.summary?.violations || 0; + const headingIssues = domSignals?.headingOrderIssues?.length || 0; + const focusMissing = focusSignals?.missing?.length || 0; + const smallTargets = tapTargets?.smallTargetCount || 0; + const a11yScore = clampScore(100 + - axeViolations * (a11yPolicy.axePenalty ?? 5) + - headingIssues * (a11yPolicy.headingPenalty ?? 5) + - focusMissing * (a11yPolicy.focusPenalty ?? 2) + - inputsMissingLabels * (a11yPolicy.unlabeledInputPenalty ?? 2) + - smallTargets * (a11yPolicy.smallTargetPenalty ?? 1)); + const a11yIssues = []; + if (axeViolations) a11yIssues.push(`axe violations (${axeViolations})`); + if (headingIssues) a11yIssues.push(`heading order issues (${headingIssues})`); + if (focusMissing) a11yIssues.push(`focus visibility missing (${focusMissing})`); + if (inputsMissingLabels) a11yIssues.push(`unlabeled inputs (${inputsMissingLabels})`); + if (smallTargets) a11yIssues.push(`small tap targets (${smallTargets})`); + + const reflowOverflow = reflow?.overflowX === true; + const responsiveScore = clampScore(100 + - (viewportMeta ? 0 : (responsivePolicy.missingViewportPenalty ?? 20)) + - (reflowOverflow ? (responsivePolicy.reflowPenalty ?? 30) : 0) + - smallTargets * (responsivePolicy.smallTargetPenalty ?? 1)); + const responsiveIssues = []; + if (!viewportMeta) responsiveIssues.push('missing viewport meta'); + if (reflowOverflow) responsiveIssues.push('reflow overflow at 320px'); + if (smallTargets) responsiveIssues.push(`small tap targets (${smallTargets})`); + + const loadMs = perf?.load || 0; + const dclMs = perf?.domContentLoaded || 0; + const totalTransfer = perfSignals?.totalTransfer || 0; + const largeImages = perfSignals?.largeImages || 0; + const perfScore = clampScore(100 + - (loadMs > (perfPolicy.loadBadMs ?? 6000) + ? (perfPolicy.loadBadPenalty ?? 20) + : loadMs > (perfPolicy.loadWarnMs ?? 3000) + ? (perfPolicy.loadWarnPenalty ?? 10) + : 0) + - (dclMs > (perfPolicy.dclWarnMs ?? 3000) ? (perfPolicy.dclWarnPenalty ?? 10) : 0) + - (totalTransfer > (perfPolicy.transferBadBytes ?? 8_000_000) + ? (perfPolicy.transferBadPenalty ?? 20) + : totalTransfer > (perfPolicy.transferWarnBytes ?? 4_000_000) + ? (perfPolicy.transferWarnPenalty ?? 10) + : 0) + - largeImages * (perfPolicy.largeImagePenalty ?? 2)); + const perfIssues = []; + if (loadMs > 3000) perfIssues.push(`slow load (${Math.round(loadMs)}ms)`); + if (dclMs > 3000) perfIssues.push(`slow DOMContentLoaded (${Math.round(dclMs)}ms)`); + if (totalTransfer > 4_000_000) perfIssues.push(`large transfer ${(totalTransfer / 1_000_000).toFixed(1)}MB`); + if (largeImages) perfIssues.push(`large images (${largeImages})`); + + const fontCount = styles?.fontCount || 0; + const colorCount = styles?.colorCount || 0; + const designScore = clampScore(100 + - Math.max(0, fontCount - (designPolicy.maxFonts ?? 3)) * (designPolicy.fontPenalty ?? 5) + - Math.max(0, colorCount - (designPolicy.maxColors ?? 12)) * (designPolicy.colorPenalty ?? 1)); + const designIssues = []; + if (fontCount > 3) designIssues.push(`many fonts (${fontCount})`); + if (colorCount > 12) designIssues.push(`many colors (${colorCount})`); + + const loremCount = domSignals?.loremText?.length || 0; + const genericCtas = domSignals?.genericCtas?.length || 0; + const contentScore = clampScore(100 + - loremCount * (contentPolicy.loremPenalty ?? 5) + - genericCtas * (contentPolicy.genericCtaPenalty ?? 2)); + const contentIssues = []; + if (loremCount) contentIssues.push(`placeholder copy (${loremCount})`); + if (genericCtas) contentIssues.push(`generic CTAs (${genericCtas})`); + + const stateSignals = domSignals?.stateSignals || {}; + const stateScore = clampScore((statePolicy.baseScore ?? 60) + + Math.min(statePolicy.loadingCap ?? 20, (stateSignals.loadingHints || 0) * (statePolicy.loadingWeight ?? 2)) + + Math.min(statePolicy.errorCap ?? 10, (stateSignals.errorHints || 0) * (statePolicy.errorWeight ?? 2)) + + Math.min(statePolicy.ariaLiveCap ?? 10, (stateSignals.ariaLive || 0) * (statePolicy.ariaLiveWeight ?? 2))); + const stateIssues = []; + if ((stateSignals.loadingHints || 0) === 0) stateIssues.push('no loading indicators detected'); + if ((stateSignals.errorHints || 0) === 0) stateIssues.push('no error/alert indicators detected'); + + const externalBlankNoRel = domSignals?.externalBlankNoRel?.length || 0; + const trustScore = clampScore(100 - externalBlankNoRel * (trustPolicy.externalBlankPenalty ?? 2)); + const trustIssues = []; + if (externalBlankNoRel) trustIssues.push(`external links missing rel=noopener (${externalBlankNoRel})`); + + const consoleCount = consoleIssues?.length || 0; + const failedReqs = networkIssues?.failedRequests?.length || 0; + const httpErrors = networkIssues?.httpErrors?.length || 0; + const regressionScore = clampScore(100 + - consoleCount * (regressionPolicy.consolePenalty ?? 2) + - failedReqs * (regressionPolicy.failedRequestPenalty ?? 2) + - httpErrors * (regressionPolicy.httpErrorPenalty ?? 1)); + const regressionIssues = []; + if (consoleCount) regressionIssues.push(`console issues (${consoleCount})`); + if (failedReqs) regressionIssues.push(`failed requests (${failedReqs})`); + if (httpErrors) regressionIssues.push(`http errors (${httpErrors})`); + + return { + visual: { score: visualScore, issues: visualIssues }, + functional: { score: functionalScore, issues: functionalIssues }, + accessibility: { score: a11yScore, issues: a11yIssues }, + responsive: { score: responsiveScore, issues: responsiveIssues }, + performance: { score: perfScore, issues: perfIssues }, + designConsistency: { score: designScore, issues: designIssues }, + contentQuality: { score: contentScore, issues: contentIssues }, + stateCoverage: { score: stateScore, issues: stateIssues, confidence: 'low' }, + trustPolish: { score: trustScore, issues: trustIssues }, + regressionRisk: { score: regressionScore, issues: regressionIssues }, + }; +} + +module.exports = { + buildEvaluation, +}; diff --git a/src/core/scoring-policy.js b/src/core/scoring-policy.js new file mode 100644 index 0000000..e7fe7dd --- /dev/null +++ b/src/core/scoring-policy.js @@ -0,0 +1,71 @@ +const DEFAULT_POLICY = { + visual: { + overflowPenalty: 2, + clippedPenalty: 2, + overlapPenalty: 1, + missingImagePenalty: 5, + contrastPenalty: 1, + }, + functional: { + deadLinkPenalty: 2, + buttonsNoTextPenalty: 2, + inputsMissingLabelsPenalty: 2, + formsWithoutSubmitPenalty: 5, + }, + accessibility: { + axePenalty: 5, + headingPenalty: 5, + focusPenalty: 2, + unlabeledInputPenalty: 2, + smallTargetPenalty: 1, + }, + responsive: { + missingViewportPenalty: 20, + reflowPenalty: 30, + smallTargetPenalty: 1, + }, + performance: { + loadWarnMs: 3000, + loadBadMs: 6000, + loadWarnPenalty: 10, + loadBadPenalty: 20, + dclWarnMs: 3000, + dclWarnPenalty: 10, + transferWarnBytes: 4_000_000, + transferBadBytes: 8_000_000, + transferWarnPenalty: 10, + transferBadPenalty: 20, + largeImagePenalty: 2, + }, + designConsistency: { + maxFonts: 3, + fontPenalty: 5, + maxColors: 12, + colorPenalty: 1, + }, + contentQuality: { + loremPenalty: 5, + genericCtaPenalty: 2, + }, + stateCoverage: { + baseScore: 60, + loadingWeight: 2, + errorWeight: 2, + ariaLiveWeight: 2, + loadingCap: 20, + errorCap: 10, + ariaLiveCap: 10, + }, + trustPolish: { + externalBlankPenalty: 2, + }, + regressionRisk: { + consolePenalty: 2, + failedRequestPenalty: 2, + httpErrorPenalty: 1, + }, +}; + +module.exports = { + DEFAULT_POLICY, +}; diff --git a/src/evidence/index.js b/src/evidence/index.js new file mode 100644 index 0000000..9d24b65 --- /dev/null +++ b/src/evidence/index.js @@ -0,0 +1,74 @@ +const path = require('path'); +const { ensureDir } = require('../utils'); + +async function captureScrollShots(page, dir, prefix, steps) { + const shotPaths = []; + ensureDir(dir); + + const fullPath = path.join(dir, `${prefix}-full.png`); + await page.screenshot({ path: fullPath, fullPage: true }); + shotPaths.push(fullPath); + + for (let i = 0; i < steps; i += 1) { + const shotPath = path.join(dir, `${prefix}-v${i + 1}.png`); + await page.screenshot({ path: shotPath, fullPage: false }); + shotPaths.push(shotPath); + + const didReachEnd = await page.evaluate(() => { + const viewportHeight = window.innerHeight; + const before = window.scrollY; + window.scrollBy({ top: viewportHeight * 0.85, left: 0, behavior: 'instant' }); + return { before, after: window.scrollY, max: document.documentElement.scrollHeight - viewportHeight }; + }); + + if (didReachEnd.after >= didReachEnd.max) break; + await page.waitForTimeout(500); + } + + return shotPaths.map((p) => path.relative(process.cwd(), p)); +} + +async function captureCrops(page, items, dir, prefix, limit = 8) { + if (!items || !items.length) return []; + ensureDir(dir); + const viewport = page.viewportSize && page.viewportSize(); + const docSize = await page.evaluate(() => ({ + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + })); + + const crops = []; + const targets = items.slice(0, limit); + for (let i = 0; i < targets.length; i += 1) { + const rect = targets[i].rect; + if (!rect || !rect.width || !rect.height) continue; + if (viewport) { + const outOfView = rect.x > viewport.width || rect.y > viewport.height || rect.x + rect.width < 0 || rect.y + rect.height < 0; + if (outOfView) continue; + } + const clip = { + x: Math.max(0, rect.x - 4), + y: Math.max(0, rect.y - 4), + width: rect.width + 8, + height: rect.height + 8, + }; + const maxWidth = Math.max(0, docSize.width - clip.x); + const maxHeight = Math.max(0, docSize.height - clip.y); + clip.width = Math.min(clip.width, maxWidth); + clip.height = Math.min(clip.height, maxHeight); + if (clip.width <= 1 || clip.height <= 1) continue; + const cropPath = path.join(dir, `${prefix}-${i + 1}.png`); + try { + await page.screenshot({ path: cropPath, clip }); + crops.push(path.relative(process.cwd(), cropPath)); + } catch (err) { + console.warn(`crop failed for ${prefix} index ${i}: ${err.message}`); + } + } + return crops; +} + +module.exports = { + captureScrollShots, + captureCrops, +}; diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..523c0ca --- /dev/null +++ b/src/main.js @@ -0,0 +1,338 @@ +const path = require('path'); +const fs = require('fs'); +const { chromium, devices } = require('playwright'); + +const { parseArgs, usage } = require('./cli'); +const { TARGET_POLICIES, WAIT_UNTIL_OPTIONS } = require('./config'); +const { nowTag, ensureDir } = require('./utils'); +const { runAuditPipeline } = require('./core/audit-runner'); +const { aggregateCounts, evaluateBudgets, generateHtml } = require('./report'); + +async function createContext(browser, { emulateMobile, width, height }) { + return emulateMobile + ? browser.newContext({ ...devices['iPhone 12'], viewport: devices['iPhone 12'].viewport }) + : browser.newContext({ viewport: { width, height } }); +} + +function attachPageListeners(page, networkIssues, consoleIssues) { + page.on('requestfailed', (req) => { + networkIssues.failedRequests.push({ + url: req.url(), + method: req.method(), + error: req.failure()?.errorText, + resourceType: req.resourceType(), + }); + }); + + page.on('response', (res) => { + const status = res.status(); + if (status >= 400) { + networkIssues.httpErrors.push({ + url: res.url(), + status, + statusText: res.statusText(), + method: res.request().method(), + resourceType: res.request().resourceType(), + }); + } + }); + + page.on('console', (msg) => { + if (['error', 'warning'].includes(msg.type())) { + consoleIssues.push({ type: msg.type(), text: msg.text() }); + } + }); + + page.on('pageerror', (err) => { + consoleIssues.push({ type: 'pageerror', text: err.message }); + }); +} + +async function stopTracing(context, traceLabel, screenshotsDir) { + const tracePath = path.join(screenshotsDir, `${traceLabel}-trace.zip`); + await context.tracing.stop({ path: tracePath }); + return tracePath; +} + +async function auditViewport(url, opts) { + const { + width, + height, + wait, + waitUntil, + readySelector, + steps, + screenshotsDir, + emulateMobile, + targetPolicy, + axeTags, + trace, + } = opts; + + let browser = null; + let context = null; + let tracePath = null; + const traceLabel = emulateMobile ? 'mobile' : 'desktop'; + let tracingStarted = false; + let navStart = null; + let perf = null; + let overflow = null; + let overflowCrops = []; + let tapTargets = null; + let tapCrops = []; + let styles = null; + let domSignals = null; + let focusSignals = null; + let perfSignals = null; + let viewportMeta = false; + let shots = []; + let reflow = null; + let axe = null; + let evaluation = null; + + const networkIssues = { failedRequests: [], httpErrors: [] }; + const consoleIssues = []; + + try { + browser = await chromium.launch({ headless: true }); + context = await createContext(browser, { emulateMobile, width, height }); + + if (trace) { + await context.tracing.start({ screenshots: true, snapshots: true }); + tracingStarted = true; + } + + const page = await context.newPage(); + attachPageListeners(page, networkIssues, consoleIssues); + + navStart = Date.now(); + await page.goto(url, { waitUntil: WAIT_UNTIL_OPTIONS.has(waitUntil) ? waitUntil : 'load', timeout: 45000 }); + if (readySelector) { + try { + await page.waitForSelector(readySelector, { timeout: Math.max(wait, 5000) }); + } catch (err) { + console.warn(`ready-selector '${readySelector}' not found before timeout`); + } + } + await page.waitForTimeout(wait); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(100); + + const audit = await runAuditPipeline(page, { + targetPolicy, + axeTags, + steps, + screenshotsDir, + emulateMobile, + consoleIssues, + networkIssues, + }); + + ({ + perf, + overflow, + tapTargets, + styles, + axe, + domSignals, + perfSignals, + viewportMeta, + overflowCrops, + tapCrops, + shots, + focusSignals, + reflow, + evaluation, + } = audit); + + if (context && trace && tracingStarted) { + try { + tracePath = await stopTracing(context, traceLabel, screenshotsDir); + } catch (_) { + tracePath = null; + } finally { + tracingStarted = false; + } + } + + return { + viewport: emulateMobile ? 'mobile iPhone 12' : `${width}x${height}`, + navTimeMs: Date.now() - navStart, + perf, + overflow: { ...overflow, crops: overflowCrops }, + tapTargets: { ...tapTargets, crops: tapCrops }, + viewportMeta, + styles: { ...styles }, + domSignals, + focusSignals, + perfSignals, + reflow, + evaluation, + axe, + screenshots: shots, + networkIssues, + consoleIssues, + trace: tracePath ? path.relative(process.cwd(), tracePath) : null, + }; + } finally { + if (context && trace && tracingStarted) { + try { + await stopTracing(context, traceLabel, screenshotsDir); + } catch (_) { + // ignore trace stop failures + } + } + if (context) { + try { + await context.close(); + } catch (_) { + // ignore close failures + } + } + if (browser) { + try { + await browser.close(); + } catch (_) { + // ignore close failures + } + } + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.url) { + console.error(usage); + process.exit(1); + } + + if (!WAIT_UNTIL_OPTIONS.has(args.waitUntil)) { + console.warn(`Unknown wait-until '${args.waitUntil}', defaulting to 'load'`); + args.waitUntil = 'load'; + } + + if (!TARGET_POLICIES[args.targetPolicy]) { + console.warn(`Unknown target policy '${args.targetPolicy}', defaulting to wcag21-aaa (44px).`); + args.targetPolicy = 'wcag21-aaa'; + } + + const axeTags = args.axeTags + ? args.axeTags.split(',').map((t) => t.trim()).filter(Boolean) + : null; + + const tag = nowTag(); + const shotsDir = args.screenshots || path.join(process.cwd(), 'reports', `shots-${tag}`); + const outFile = args.out || path.join(process.cwd(), 'reports', `ui-report-${tag}.json`); + const htmlFile = args.html ? (args.html === true ? path.join(process.cwd(), 'reports', `ui-report-${tag}.html`) : args.html) : null; + ensureDir(path.dirname(outFile)); + ensureDir(shotsDir); + if (htmlFile) ensureDir(path.dirname(htmlFile)); + + const desktop = await auditViewport(args.url, { + width: args.width, + height: args.height, + wait: args.wait, + waitUntil: args.waitUntil, + readySelector: args.readySelector, + steps: args.steps, + screenshotsDir: path.join(shotsDir, 'desktop'), + emulateMobile: false, + targetPolicy: args.targetPolicy, + axeTags, + trace: args.trace, + }); + + let mobile = null; + if (args.mobile) { + mobile = await auditViewport(args.url, { + width: 390, + height: 844, + wait: args.wait, + waitUntil: args.waitUntil, + readySelector: args.readySelector, + steps: Math.max(2, args.steps - 1), + screenshotsDir: path.join(shotsDir, 'mobile'), + emulateMobile: true, + targetPolicy: args.targetPolicy, + axeTags, + trace: args.trace, + }); + } + + const pkg = require('../package.json'); + const playwrightPkg = require('playwright/package.json'); + let axeVersion = null; + try { + axeVersion = require('@axe-core/playwright/package.json').version; + } catch (err) { + axeVersion = 'unknown'; + } + + const report = { + tool: { name: pkg.name || 'uxray', version: pkg.version || '0.0.0' }, + url: args.url, + runAt: new Date().toISOString(), + config: { + wait: args.wait, + waitUntil: args.waitUntil, + readySelector: args.readySelector, + viewport: `${args.width}x${args.height}`, + steps: args.steps, + mobile: args.mobile, + targetPolicy: args.targetPolicy, + axeTags, + trace: args.trace, + }, + environment: { + node: process.version, + platform: process.platform, + arch: process.arch, + }, + dependencies: { + playwrightVersion: playwrightPkg.version, + axeVersion, + }, + desktop, + mobile, + notes: { + guidance: 'Focus on overflows, small tap targets (policy-driven), viewport meta (mobile), Axe violations, network/console errors. Automated scan; manual a11y review still required.', + assets: 'See screenshot paths under screenshots[]. Trace zip present when --trace is used.', + }, + }; + + const budgets = { + a11yViolations: Number.isFinite(args.budgetA11y) ? args.budgetA11y : null, + smallTargets: Number.isFinite(args.budgetTap) ? args.budgetTap : null, + overflowOffenders: Number.isFinite(args.budgetOverflow) ? args.budgetOverflow : null, + consoleIssues: Number.isFinite(args.budgetConsole) ? args.budgetConsole : null, + httpErrors: Number.isFinite(args.budgetHttpErrors) ? args.budgetHttpErrors : null, + }; + + const counts = aggregateCounts(desktop, mobile); + const budgetResults = evaluateBudgets(counts, budgets); + report.budgets = budgetResults; + report.counts = counts; + + fs.writeFileSync(outFile, JSON.stringify(report, null, 2)); + if (htmlFile) { + generateHtml(report, htmlFile); + } + + console.log(`UXRay complete for ${args.url}`); + console.log(`Desktop: axe violations ${desktop.axe.summary.violations}, small tap targets ${desktop.tapTargets.smallTargetCount}, overflow ${desktop.overflow.hasOverflowX}`); + if (mobile) { + console.log(`Mobile: axe violations ${mobile.axe.summary.violations}, small tap targets ${mobile.tapTargets.smallTargetCount}, overflow ${mobile.overflow.hasOverflowX}`); + } + console.log(`Budgets: ${budgetResults.entries.length ? (budgetResults.ok ? 'pass' : 'fail') : 'none set'}`); + console.log(`Report: ${path.relative(process.cwd(), outFile)}`); + console.log(`Screenshots folder: ${path.relative(process.cwd(), shotsDir)}`); + if (htmlFile) console.log(`HTML report: ${path.relative(process.cwd(), htmlFile)}`); + + if (!budgetResults.ok) { + process.exitCode = 1; + } +} + +module.exports = { + main, +}; diff --git a/src/perf/index.js b/src/perf/index.js new file mode 100644 index 0000000..76cd0a9 --- /dev/null +++ b/src/perf/index.js @@ -0,0 +1,59 @@ +async function collectNavigationPerf(page) { + return page.evaluate(() => { + const nav = performance.getEntriesByType('navigation')[0]; + if (!nav) return null; + return { + domContentLoaded: nav.domContentLoadedEventEnd, + load: nav.loadEventEnd, + renderBlocking: nav.responseEnd, + }; + }); +} + +async function collectPerfSignals(page) { + return page.evaluate(() => { + const resources = performance.getEntriesByType('resource'); + const paints = performance.getEntriesByType('paint'); + const toNum = (v) => (Number.isFinite(v) ? v : 0); + let totalTransfer = 0; + let totalEncoded = 0; + let imgBytes = 0; + let jsBytes = 0; + let cssBytes = 0; + let largeImages = 0; + let largeScripts = 0; + resources.forEach((r) => { + totalTransfer += toNum(r.transferSize); + totalEncoded += toNum(r.encodedBodySize); + if (r.initiatorType === 'img') { + imgBytes += toNum(r.transferSize); + if (toNum(r.transferSize) > 1_000_000) largeImages += 1; + } + if (r.initiatorType === 'script') { + jsBytes += toNum(r.transferSize); + if (toNum(r.transferSize) > 500_000) largeScripts += 1; + } + if (r.initiatorType === 'link' || r.initiatorType === 'css') { + cssBytes += toNum(r.transferSize); + } + }); + const paintMap = {}; + paints.forEach((p) => { paintMap[p.name] = p.startTime; }); + return { + resourceCount: resources.length, + totalTransfer, + totalEncoded, + imgBytes, + jsBytes, + cssBytes, + largeImages, + largeScripts, + paint: paintMap, + }; + }); +} + +module.exports = { + collectNavigationPerf, + collectPerfSignals, +}; diff --git a/src/report/index.js b/src/report/index.js new file mode 100644 index 0000000..072dc3a --- /dev/null +++ b/src/report/index.js @@ -0,0 +1,147 @@ +const fs = require('fs'); +const path = require('path'); +const { ensureDir } = require('../utils'); + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\"/g, '"') + .replace(/'/g, ''') + .replace(/`/g, '`'); +} + +function renderList(items) { + return (items || []).map((p) => `
  • ${escapeHtml(p)}
  • `).join(''); +} + +function aggregateCounts(desktop, mobile) { + const sum = (getter) => { + const d = desktop ? getter(desktop) : 0; + const m = mobile ? getter(mobile) : 0; + return d + m; + }; + + return { + a11yViolations: sum((r) => r.axe?.summary?.violations || 0), + smallTargets: sum((r) => r.tapTargets?.smallTargetCount || 0), + overflowOffenders: sum((r) => (r.overflow?.offenders || []).length), + consoleIssues: sum((r) => (r.consoleIssues || []).length), + httpErrors: sum((r) => (r.networkIssues?.httpErrors || []).length), + }; +} + +function evaluateBudgets(counts, budgets) { + const entries = []; + const check = (key, label) => { + const limit = budgets[key]; + if (limit === null || limit === undefined || Number.isNaN(limit)) return; + const value = counts[key] || 0; + const pass = value <= limit; + entries.push({ key, label, value, limit, pass }); + }; + + check('a11yViolations', 'Accessibility violations'); + check('smallTargets', 'Small tap targets'); + check('overflowOffenders', 'Overflow offenders'); + check('consoleIssues', 'Console issues'); + check('httpErrors', 'HTTP errors (4xx/5xx)'); + + const ok = entries.every((e) => e.pass); + return { ok, entries }; +} + +function generateHtml(report, outputPath) { + const safeUrl = escapeHtml(report.url); + const safeRunAt = escapeHtml(report.runAt); + const safeViewport = escapeHtml(report.config?.viewport ?? ''); + const safeMobile = escapeHtml(String(report.config?.mobile ?? '')); + const safeWaitUntil = escapeHtml(report.config?.waitUntil ?? ''); + + const html = ` + + + + UXRay report - ${safeUrl} + + + +

    UXRay Report

    +
    URL: ${safeUrl}
    Run at: ${safeRunAt}
    Config: ${safeViewport}, mobile: ${safeMobile}, waitUntil: ${safeWaitUntil}
    + +
    +
    +

    Desktop

    + + + + + + +
    A11y violations${escapeHtml(report.desktop?.axe?.summary?.violations ?? '-')}
    Small targets${escapeHtml(report.desktop?.tapTargets?.smallTargetCount ?? '-')}
    Overflow offenders${escapeHtml(report.desktop?.overflow?.offenders?.length ?? '-')}
    HTTP errors${escapeHtml(report.desktop?.networkIssues?.httpErrors?.length ?? '-')}
    Console issues${escapeHtml(report.desktop?.consoleIssues?.length ?? '-')}
    +
    + ${report.mobile ? `
    +

    Mobile

    + + + + + + +
    A11y violations${escapeHtml(report.mobile?.axe?.summary?.violations ?? '-')}
    Small targets${escapeHtml(report.mobile?.tapTargets?.smallTargetCount ?? '-')}
    Overflow offenders${escapeHtml(report.mobile?.overflow?.offenders?.length ?? '-')}
    HTTP errors${escapeHtml(report.mobile?.networkIssues?.httpErrors?.length ?? '-')}
    Console issues${escapeHtml(report.mobile?.consoleIssues?.length ?? '-')}
    +
    ` : ''} +
    + +
    +

    Budgets

    + + + ${(report.budgets?.entries || []).map((e) => ``).join('')} +
    ItemValueLimitStatus
    ${escapeHtml(e.label)}${escapeHtml(e.value)}${escapeHtml(e.limit ?? '—')}${e.pass ? 'pass' : 'fail'}
    +
    + +
    +
    +

    Evidence

    +
    Desktop shots:
    +
      ${renderList((report.desktop?.screenshots || []).slice(0, 4))}
    + ${report.desktop?.overflow?.crops?.length ? `
    Overflow crops:
      ${renderList(report.desktop.overflow.crops)}
    ` : ''} + ${report.desktop?.tapTargets?.crops?.length ? `
    Tap target crops:
      ${renderList(report.desktop.tapTargets.crops)}
    ` : ''} + ${report.desktop?.trace ? `
    Trace: ${escapeHtml(report.desktop.trace)}
    ` : ''} +
    + ${report.mobile ? `
    +

    Evidence (Mobile)

    +
    Shots:
    +
      ${renderList((report.mobile?.screenshots || []).slice(0, 4))}
    + ${report.mobile?.overflow?.crops?.length ? `
    Overflow crops:
      ${renderList(report.mobile.overflow.crops)}
    ` : ''} + ${report.mobile?.tapTargets?.crops?.length ? `
    Tap target crops:
      ${renderList(report.mobile.tapTargets.crops)}
    ` : ''} + ${report.mobile?.trace ? `
    Trace: ${escapeHtml(report.mobile.trace)}
    ` : ''} +
    ` : ''} +
    + +`; + + ensureDir(path.dirname(outputPath)); + fs.writeFileSync(outputPath, html, 'utf8'); +} + +module.exports = { + aggregateCounts, + evaluateBudgets, + generateHtml, +}; diff --git a/src/utils/index.js b/src/utils/index.js new file mode 100644 index 0000000..5e9577d --- /dev/null +++ b/src/utils/index.js @@ -0,0 +1,20 @@ +const fs = require('fs'); + +function nowTag() { + return new Date().toISOString().replace(/[:.]/g, '-'); +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function clampScore(value) { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, Math.round(value))); +} + +module.exports = { + nowTag, + ensureDir, + clampScore, +}; diff --git a/ui-review.js b/ui-review.js index b3ab974..cb13056 100755 --- a/ui-review.js +++ b/ui-review.js @@ -1,332 +1,5 @@ #!/usr/bin/env node -const fs = require('fs'); -const path = require('path'); -const { chromium, devices } = require('playwright'); -const { AxeBuilder } = require('@axe-core/playwright'); - -// Basic CLI arg parsing to keep dependencies light. -function parseArgs(argv) { - const args = { - url: null, - mobile: false, - width: 1280, - height: 720, - wait: 1500, - steps: 4, - out: null, - screenshots: null, - }; - - for (let i = 0; i < argv.length; i += 1) { - const val = argv[i]; - if (val === '--url' && argv[i + 1]) { - args.url = argv[i + 1]; - i += 1; - } else if (!val.startsWith('--') && !args.url) { - args.url = val; - } else if (val === '--mobile') { - args.mobile = true; - } else if (val === '--viewport' && argv[i + 1]) { - const [w, h] = argv[i + 1].split('x'); - args.width = Number.parseInt(w, 10) || args.width; - args.height = Number.parseInt(h, 10) || args.height; - i += 1; - } else if (val === '--wait' && argv[i + 1]) { - args.wait = Number.parseInt(argv[i + 1], 10) || args.wait; - i += 1; - } else if (val === '--steps' && argv[i + 1]) { - args.steps = Number.parseInt(argv[i + 1], 10) || args.steps; - i += 1; - } else if (val === '--out' && argv[i + 1]) { - args.out = argv[i + 1]; - i += 1; - } else if ((val === '--shots' || val === '--screenshots') && argv[i + 1]) { - args.screenshots = argv[i + 1]; - i += 1; - } - } - return args; -} - -function nowTag() { - return new Date().toISOString().replace(/[:.]/g, '-'); -} - -function ensureDir(dirPath) { - fs.mkdirSync(dirPath, { recursive: true }); -} - -async function detectOverflow(page) { - return page.evaluate(() => { - const doc = document.documentElement; - const hasOverflowX = doc.scrollWidth - doc.clientWidth > 2; - const offenders = []; - - if (hasOverflowX) { - const nodes = Array.from(document.querySelectorAll('body *')); - const format = (el) => { - const cls = typeof el.className === 'string' && el.className.trim() - ? `.${el.className.trim().split(/\s+/).slice(0, 3).join('.')}` - : ''; - return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${cls}`; - }; - - for (const el of nodes) { - const rect = el.getBoundingClientRect(); - if (!rect || !rect.width || !rect.height) continue; - if (rect.right > doc.clientWidth + 1 || rect.left < -1) { - offenders.push({ - selector: format(el), - rect: { - x: Math.round(rect.x), - y: Math.round(rect.y), - width: Math.round(rect.width), - height: Math.round(rect.height), - right: Math.round(rect.right), - }, - }); - if (offenders.length >= 40) break; - } - } - } - - return { hasOverflowX, offenders }; - }); -} - -async function detectTapTargets(page) { - return page.evaluate(() => { - const MIN_SIZE = 44; // WCAG touch target size guideline in px - const nodes = Array.from( - document.querySelectorAll('button, a, input, select, textarea, [role="button"], [role="link"], [role="menuitem"]'), - ); - const tiny = []; - const format = (el) => { - const cls = typeof el.className === 'string' && el.className.trim() - ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` - : ''; - return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${cls}`; - }; - - nodes.forEach((el) => { - const rect = el.getBoundingClientRect(); - if (!rect.width || !rect.height) return; - if (rect.width < MIN_SIZE || rect.height < MIN_SIZE) { - tiny.push({ - selector: format(el), - size: { width: Math.round(rect.width), height: Math.round(rect.height) }, - text: (el.innerText || '').trim().slice(0, 80), - }); - } - }); - - return { smallTargetCount: tiny.length, samples: tiny.slice(0, 30) }; - }); -} - -async function sampleStyles(page) { - return page.evaluate(() => { - const toHex = (rgb) => { - const parts = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); - if (!parts) return null; - return `#${[1, 2, 3] - .map((i) => Number(parts[i]).toString(16).padStart(2, '0')) - .join('')}`; - }; - - const colorCounts = new Map(); - const fontCounts = new Map(); - const elements = Array.from(document.querySelectorAll('body *')).slice(0, 400); - - elements.forEach((el) => { - const style = getComputedStyle(el); - const fg = toHex(style.color); - const bg = toHex(style.backgroundColor); - const font = style.fontFamily.split(',')[0].replace(/['"]/g, '').trim(); - - if (fg) colorCounts.set(fg, (colorCounts.get(fg) || 0) + 1); - if (bg && bg !== '#000000' && bg !== '#00000000') colorCounts.set(bg, (colorCounts.get(bg) || 0) + 1); - if (font) fontCounts.set(font, (fontCounts.get(font) || 0) + 1); - }); - - const topColors = Array.from(colorCounts.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 6) - .map(([color, weight]) => ({ color, weight })); - - const topFonts = Array.from(fontCounts.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) - .map(([font, weight]) => ({ font, weight })); - - return { topColors, topFonts }; - }); -} - -async function captureScrollShots(page, dir, prefix, steps) { - const shotPaths = []; - ensureDir(dir); - - const fullPath = path.join(dir, `${prefix}-full.png`); - await page.screenshot({ path: fullPath, fullPage: true }); - shotPaths.push(fullPath); - - for (let i = 0; i < steps; i += 1) { - const shotPath = path.join(dir, `${prefix}-v${i + 1}.png`); - await page.screenshot({ path: shotPath, fullPage: false }); - shotPaths.push(shotPath); - - const didReachEnd = await page.evaluate((step) => { - const viewportHeight = window.innerHeight; - const before = window.scrollY; - window.scrollBy({ top: viewportHeight * 0.85, left: 0, behavior: 'instant' }); - return { before, after: window.scrollY, max: document.documentElement.scrollHeight - viewportHeight }; - }, i); - - if (didReachEnd.after >= didReachEnd.max) break; - await page.waitForTimeout(500); - } - - // Return relative paths so the report stays portable. - return shotPaths.map((p) => path.relative(process.cwd(), p)); -} - -async function runAxe(page) { - const results = await new AxeBuilder({ page }).analyze(); - return { - summary: { - violations: results.violations.length, - passes: results.passes.length, - incomplete: results.incomplete.length, - }, - topViolations: results.violations.slice(0, 10).map((v) => ({ - id: v.id, - description: v.description, - impact: v.impact, - help: v.help, - nodes: v.nodes.slice(0, 4).map((n) => ({ target: n.target, summary: n.failureSummary })), - })), - }; -} - -async function auditViewport(url, opts) { - const { width, height, wait, steps, screenshotsDir, emulateMobile } = opts; - const browser = await chromium.launch({ headless: true }); - const context = emulateMobile - ? await browser.newContext({ ...devices['iPhone 12'], viewport: devices['iPhone 12'].viewport }) - : await browser.newContext({ viewport: { width, height } }); - - const page = await context.newPage(); - const networkIssues = []; - const consoleIssues = []; - - page.on('requestfailed', (req) => { - networkIssues.push({ url: req.url(), method: req.method(), error: req.failure()?.errorText }); - }); - - page.on('console', (msg) => { - if (['error', 'warning'].includes(msg.type())) { - consoleIssues.push({ type: msg.type(), text: msg.text() }); - } - }); - - page.on('pageerror', (err) => { - consoleIssues.push({ type: 'pageerror', text: err.message }); - }); - - const navStart = Date.now(); - await page.goto(url, { waitUntil: 'networkidle', timeout: 45000 }); - await page.waitForTimeout(wait); - - const perf = await page.evaluate(() => { - const nav = performance.getEntriesByType('navigation')[0]; - if (!nav) return null; - return { - domContentLoaded: nav.domContentLoadedEventEnd, - load: nav.loadEventEnd, - renderBlocking: nav.responseEnd, - }; - }); - - const overflow = await detectOverflow(page); - const tapTargets = await detectTapTargets(page); - const styles = await sampleStyles(page); - const axe = await runAxe(page); - const viewportMeta = await page.evaluate(() => Boolean(document.querySelector('meta[name="viewport"]'))); - const shots = await captureScrollShots(page, screenshotsDir, emulateMobile ? 'mobile' : 'desktop', steps); - - await browser.close(); - - return { - viewport: emulateMobile ? 'mobile iPhone 12' : `${width}x${height}`, - navTimeMs: Date.now() - navStart, - perf, - overflow, - tapTargets, - viewportMeta, - styles, - axe, - screenshots: shots, - networkIssues, - consoleIssues, - }; -} - -async function main() { - const args = parseArgs(process.argv.slice(2)); - if (!args.url) { - console.error('Usage: node ui-review.js --url [--mobile] [--viewport 1280x720] [--out report.json] [--shots ./reports]'); - process.exit(1); - } - - const tag = nowTag(); - const shotsDir = args.screenshots || path.join(process.cwd(), 'reports', `shots-${tag}`); - const outFile = args.out || path.join(process.cwd(), 'reports', `ui-report-${tag}.json`); - ensureDir(path.dirname(outFile)); - ensureDir(shotsDir); - - const desktop = await auditViewport(args.url, { - width: args.width, - height: args.height, - wait: args.wait, - steps: args.steps, - screenshotsDir: path.join(shotsDir, 'desktop'), - emulateMobile: false, - }); - - let mobile = null; - if (args.mobile) { - mobile = await auditViewport(args.url, { - width: 390, - height: 844, - wait: args.wait, - steps: Math.max(2, args.steps - 1), - screenshotsDir: path.join(shotsDir, 'mobile'), - emulateMobile: true, - }); - } - - const report = { - url: args.url, - runAt: new Date().toISOString(), - desktop, - mobile, - notes: { - guidance: 'Focus on horizontal overflows, small tap targets (<44px), missing viewport meta (mobile), Axe violations, and network/console errors.', - assets: 'See screenshot paths under screenshots[].', - }, - }; - - fs.writeFileSync(outFile, JSON.stringify(report, null, 2)); - - console.log(`UXRay complete for ${args.url}`); - console.log(`Desktop: axe violations ${desktop.axe.summary.violations}, small tap targets ${desktop.tapTargets.smallTargetCount}, overflow ${desktop.overflow.hasOverflowX}`); - if (mobile) { - console.log(`Mobile: axe violations ${mobile.axe.summary.violations}, small tap targets ${mobile.tapTargets.smallTargetCount}, overflow ${mobile.overflow.hasOverflowX}`); - } - console.log(`Report: ${path.relative(process.cwd(), outFile)}`); - console.log(`Screenshots folder: ${path.relative(process.cwd(), shotsDir)}`); -} +const { main } = require('./src/main'); main().catch((err) => { console.error('UXRay failed:', err);