From b69980dba254126e907c5f8a9dc838a179a55f33 Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Mon, 13 Apr 2026 12:48:19 +0530 Subject: [PATCH 1/8] Add budgets, HTML report, and CI workflow --- .github/workflows/ci.yml | 52 +++++ README.md | 47 +++- package.json | 6 +- ui-review.js | 467 ++++++++++++++++++++++++++++++++++++--- 4 files changed, 534 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dade466 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +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') + 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: Install Playwright browsers (chromium only) + run: npx playwright install chromium --with-deps + - 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..35944a8 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,14 @@ 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. +- Touch ergonomics: configurable policies (24px/44px/48px) with spacing-aware detection. - 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. +- Theme diagnostics: top colors/fonts plus low-contrast samples. +- Stability: logs console errors/warnings, failed network requests, and HTTP 4xx/5xx responses. +- Evidence: full-page + stepped viewport screenshots, issue crops, optional trace zips, optional HTML report. ## Prereqs -- Node.js 18+ (Playwright uses modern APIs). +- Node.js 20+ (aligned with current Playwright requirements). - One-time: download bundled browsers ```bash @@ -26,7 +26,7 @@ npm install Or run directly after cloning with `npx` (no global install): ```bash -npx ./ui-review.js --url https://example.com +npx uxray --url https://example.com ``` ## Usage @@ -37,6 +37,17 @@ 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 ``` @@ -47,12 +58,36 @@ Short flags: - `--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). +- `--wait-until` Playwright navigation readiness (`load` default, `domcontentloaded`, `networkidle`, `commit`). +- `--ready-selector` wait for a CSS selector after navigation (useful for SPAs). - `--out` output report path (JSON). +- `--html` also emit a shareable HTML report (optional path argument). - `--shots` screenshots root folder. +- `--target-policy` tap target preset: `wcag22-aa` (24px + spacing), `wcag21-aaa` (44px), `lighthouse` (48px recommendation). +- `--axe-tags` comma-separated axe rule tags (e.g., `wcag21aa,wcag2aa`). +- `--trace` also capture a Playwright trace zip per run for deep debugging. +- Budgets / CI gates (exit non-zero on fail): + - `--max-a11y ` + - `--max-small-targets ` + - `--max-overflow ` + - `--max-console ` + - `--max-http-errors ` After a run you’ll see: - `reports/ui-report-.json` with counts and offenders. - `reports/shots-/desktop|mobile` PNGs. +- If `--trace` is used: `reports/shots-/desktop|mobile-trace.zip` for replay in Playwright Trace Viewer. +- If `--html` is used: `reports/ui-report-.html` with a quick summary and evidence links. +- Crops for top overflow/tap issues live under `reports/shots-/desktop|mobile/crops/`. + +## CI & release +- GitHub Actions workflow (`.github/workflows/ci.yml`) runs a Playwright-backed smoke against `https://example.com` and publishes smoke artifacts. +- To publish on tag `v*.*.*`, add repo secret `NPM_TOKEN` with publish rights; tagging triggers `npm publish --provenance`. + +Tap target policy notes: +- `wcag22-aa`: flags targets smaller than 24×24px unless they have generous spacing from neighbors (approximate spacing check). +- `wcag21-aaa`: classic 44×44px minimum. +- `lighthouse`: 48×48px guidance. ## JSON report shape (excerpt) diff --git a/package.json b/package.json index 892b58d..28067a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "uxray", - "version": "0.1.0", + "version": "0.2.0", "description": "CLI to audit a live web app UI for layout, accessibility, and UX issues", "main": "ui-review.js", "type": "commonjs", @@ -8,7 +8,9 @@ "uxray": "./ui-review.js" }, "scripts": { - "review": "node ui-review.js" + "review": "node ui-review.js", + "smoke": "node ui-review.js --url https://example.com --steps 1 --wait 800 --wait-until load --target-policy wcag21-aaa --out /tmp/uxray-ci.json --shots /tmp/uxray-shots", + "ci": "npm run smoke" }, "repository": { "type": "git", diff --git a/ui-review.js b/ui-review.js index b3ab974..c3b3df7 100755 --- a/ui-review.js +++ b/ui-review.js @@ -4,6 +4,14 @@ const path = require('path'); const { chromium, devices } = require('playwright'); const { AxeBuilder } = require('@axe-core/playwright'); +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']); + // Basic CLI arg parsing to keep dependencies light. function parseArgs(argv) { const args = { @@ -12,9 +20,20 @@ function parseArgs(argv) { 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) { @@ -34,6 +53,12 @@ function parseArgs(argv) { } else if (val === '--wait' && argv[i + 1]) { args.wait = Number.parseInt(argv[i + 1], 10) || args.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]) { args.steps = Number.parseInt(argv[i + 1], 10) || args.steps; i += 1; @@ -43,6 +68,36 @@ function parseArgs(argv) { } 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; // will resolve to default path later + } + } 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]) { + args.budgetA11y = Number.parseInt(argv[i + 1], 10); + i += 1; + } else if (val === '--max-small-targets' && argv[i + 1]) { + args.budgetTap = Number.parseInt(argv[i + 1], 10); + i += 1; + } else if (val === '--max-overflow' && argv[i + 1]) { + args.budgetOverflow = Number.parseInt(argv[i + 1], 10); + i += 1; + } else if (val === '--max-console' && argv[i + 1]) { + args.budgetConsole = Number.parseInt(argv[i + 1], 10); + i += 1; + } else if (val === '--max-http-errors' && argv[i + 1]) { + args.budgetHttpErrors = Number.parseInt(argv[i + 1], 10); + i += 1; } } return args; @@ -94,12 +149,13 @@ async function detectOverflow(page) { }); } -async function detectTapTargets(page) { - return page.evaluate(() => { - const MIN_SIZE = 44; // WCAG touch target size guideline in px +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() @@ -108,45 +164,121 @@ async function detectTapTargets(page) { 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) { + 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) }, + spacingOk: spacedEnough, text: (el.innerText || '').trim().slice(0, 80), }); } }); - return { smallTargetCount: tiny.length, samples: tiny.slice(0, 30) }; - }); + return { + policy: policyDef, + smallTargetCount: tiny.length, + samples: tiny.slice(0, 30), + }; + }, effectivePolicy); } async function sampleStyles(page) { return page.evaluate(() => { const toHex = (rgb) => { - const parts = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); + 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) => { + elements.forEach((el, idx) => { const style = getComputedStyle(el); - const fg = toHex(style.color); - const bg = toHex(style.backgroundColor); + const fgHex = toHex(style.color); + const bgHex = 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 (fgHex) colorCounts.set(fgHex, (colorCounts.get(fgHex) || 0) + 1); + if (bgHex && bgHex !== '#000000' && bgHex !== '#00000000') 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()) @@ -159,7 +291,7 @@ async function sampleStyles(page) { .slice(0, 5) .map(([font, weight]) => ({ font, weight })); - return { topColors, topFonts }; + return { topColors, topFonts, contrastRisks }; }); } @@ -191,8 +323,39 @@ async function captureScrollShots(page, dir, prefix, steps) { return shotPaths.map((p) => path.relative(process.cwd(), p)); } -async function runAxe(page) { - const results = await new AxeBuilder({ page }).analyze(); +async function captureCrops(page, items, dir, prefix, limit = 8) { + if (!items || !items.length) return []; + ensureDir(dir); + 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; + const clip = { + x: Math.max(0, rect.x - 4), + y: Math.max(0, rect.y - 4), + width: Math.min(rect.width + 8, docSize.width - rect.x), + height: Math.min(rect.height + 8, docSize.height - rect.y), + }; + if (clip.width <= 0 || clip.height <= 0) continue; + const cropPath = path.join(dir, `${prefix}-${i + 1}.png`); + await page.screenshot({ path: cropPath, clip }); + crops.push(path.relative(process.cwd(), cropPath)); + } + return crops; +} + +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, @@ -210,18 +373,53 @@ async function runAxe(page) { } async function auditViewport(url, opts) { - const { width, height, wait, steps, screenshotsDir, emulateMobile } = opts; + const { + width, + height, + wait, + waitUntil, + readySelector, + steps, + screenshotsDir, + emulateMobile, + targetPolicy, + axeTags, + trace, + } = 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 } }); + if (trace) { + await context.tracing.start({ screenshots: true, snapshots: true }); + } + const page = await context.newPage(); - const networkIssues = []; + const networkIssues = { failedRequests: [], httpErrors: [] }; const consoleIssues = []; page.on('requestfailed', (req) => { - networkIssues.push({ url: req.url(), method: req.method(), error: req.failure()?.errorText }); + 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) => { @@ -235,7 +433,14 @@ async function auditViewport(url, opts) { }); const navStart = Date.now(); - await page.goto(url, { waitUntil: 'networkidle', timeout: 45000 }); + 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); const perf = await page.evaluate(() => { @@ -249,11 +454,20 @@ async function auditViewport(url, opts) { }); const overflow = await detectOverflow(page); - const tapTargets = await detectTapTargets(page); + const tapTargets = await detectTapTargets(page, targetPolicy); const styles = await sampleStyles(page); - const axe = await runAxe(page); + const axe = await runAxe(page, { axeTags }); const viewportMeta = await page.evaluate(() => Boolean(document.querySelector('meta[name="viewport"]'))); const shots = await captureScrollShots(page, screenshotsDir, emulateMobile ? 'mobile' : 'desktop', steps); + const overflowCrops = await captureCrops(page, overflow.offenders, path.join(screenshotsDir, 'crops'), 'overflow'); + const tapCrops = await captureCrops(page, tapTargets.samples, path.join(screenshotsDir, 'crops'), 'tap'); + + let tracePath = null; + if (trace) { + tracePath = path.join(screenshotsDir, `${emulateMobile ? 'mobile' : 'desktop'}-trace.zip`); + await context.tracing.stop({ path: tracePath }); + tracePath = path.relative(process.cwd(), tracePath); + } await browser.close(); @@ -261,37 +475,178 @@ async function auditViewport(url, opts) { viewport: emulateMobile ? 'mobile iPhone 12' : `${width}x${height}`, navTimeMs: Date.now() - navStart, perf, - overflow, - tapTargets, + overflow: { ...overflow, crops: overflowCrops }, + tapTargets: { ...tapTargets, crops: tapCrops }, viewportMeta, - styles, + styles: { ...styles }, axe, screenshots: shots, + overflowCrops, networkIssues, consoleIssues, + trace: tracePath, + }; +} + +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 html = ` + + + + UXRay report - ${report.url} + + + +

UXRay Report

+
URL: ${report.url}
Run at: ${report.runAt}
Config: ${report.config.viewport}, mobile: ${report.config.mobile}, waitUntil: ${report.config.waitUntil}
+ +
+
+

Desktop

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

Mobile

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

Budgets

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

Evidence

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

Evidence (Mobile)

+
Shots:
+
    ${(report.mobile?.screenshots || []).slice(0, 4).map((p) => `
  • ${p}
  • `).join('')}
+ ${report.mobile?.overflow?.crops?.length ? `
Overflow crops:
    ${report.mobile.overflow.crops.map((p) => `
  • ${p}
  • `).join('')}
` : ''} + ${report.mobile?.tapTargets?.crops?.length ? `
Tap target crops:
    ${report.mobile.tapTargets.crops.map((p) => `
  • ${p}
  • `).join('')}
` : ''} + ${report.mobile?.trace ? `
Trace: ${report.mobile.trace}
` : ''} +
` : ''} +
+ +`; + + ensureDir(path.dirname(outputPath)); + fs.writeFileSync(outputPath, html, 'utf8'); +} + 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]'); + console.error('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]'); 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; @@ -300,32 +655,84 @@ async function main() { 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'); + const axePkg = require('@axe-core/playwright/package.json'); + 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: axePkg.version, + }, 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[].', + 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; + } } main().catch((err) => { From d2037ca839e6c6f0206f578b1b95e1983d54646d Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Mon, 13 Apr 2026 12:55:37 +0530 Subject: [PATCH 2/8] Fix regex parsing and crop robustness --- ui-review.js | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/ui-review.js b/ui-review.js index c3b3df7..ab1f842 100755 --- a/ui-review.js +++ b/ui-review.js @@ -216,7 +216,7 @@ async function detectTapTargets(page, policy) { async function sampleStyles(page) { return page.evaluate(() => { const toHex = (rgb) => { - const parts = rgb.match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/i); + 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')) @@ -224,7 +224,7 @@ async function sampleStyles(page) { }; const parseRgb = (rgb) => { - const parts = rgb.match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)(?:,\\s*([\\d.]+))?/i); + const parts = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/i); if (!parts) return null; return { r: Number(parts[1]), @@ -339,13 +339,23 @@ async function captureCrops(page, items, dir, prefix, limit = 8) { const clip = { x: Math.max(0, rect.x - 4), y: Math.max(0, rect.y - 4), - width: Math.min(rect.width + 8, docSize.width - rect.x), - height: Math.min(rect.height + 8, docSize.height - rect.y), + width: rect.width + 8, + height: rect.height + 8, }; - if (clip.width <= 0 || clip.height <= 0) continue; + 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`); - await page.screenshot({ path: cropPath, clip }); - crops.push(path.relative(process.cwd(), cropPath)); + try { + await page.screenshot({ path: cropPath, clip }); + crops.push(path.relative(process.cwd(), cropPath)); + } catch (err) { + // Skip invalid clip regions (outside viewport) to keep the run resilient. + // eslint-disable-next-line no-console + console.warn(`crop failed for ${prefix} index ${i}: ${err.message}`); + } } return crops; } @@ -668,7 +678,14 @@ async function main() { const pkg = require('./package.json'); const playwrightPkg = require('playwright/package.json'); - const axePkg = require('@axe-core/playwright/package.json'); + let axeVersion = null; + try { + // Some packages don't export package.json via exports; guard to keep runs resilient. + // eslint-disable-next-line import/no-dynamic-require, global-require + 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' }, @@ -692,7 +709,7 @@ async function main() { }, dependencies: { playwrightVersion: playwrightPkg.version, - axeVersion: axePkg.version, + axeVersion, }, desktop, mobile, From cc2a7fd79e1ac02722444147b1db203cca78e24f Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Mon, 13 Apr 2026 13:45:18 +0530 Subject: [PATCH 3/8] Modularize audits and evaluation --- src/audits.js | 545 +++++++++++++++++++++++++++++++++ src/cli.js | 96 ++++++ src/config.js | 12 + src/evaluation.js | 140 +++++++++ src/main.js | 300 ++++++++++++++++++ src/report.js | 127 ++++++++ src/utils.js | 20 ++ ui-review.js | 753 +--------------------------------------------- 8 files changed, 1241 insertions(+), 752 deletions(-) create mode 100644 src/audits.js create mode 100644 src/cli.js create mode 100644 src/config.js create mode 100644 src/evaluation.js create mode 100644 src/main.js create mode 100644 src/report.js create mode 100644 src/utils.js diff --git a/src/audits.js b/src/audits.js new file mode 100644 index 0000000..59442f5 --- /dev/null +++ b/src/audits.js @@ -0,0 +1,545 @@ +const path = require('path'); +const { AxeBuilder } = require('@axe-core/playwright'); +const { TARGET_POLICIES } = require('./config'); +const { ensureDir } = require('./utils'); + +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) }, + spacingOk: spacedEnough, + text: (el.innerText || '').trim().slice(0, 80), + }); + } + }); + + return { + policy: policyDef, + smallTargetCount: tiny.length, + samples: tiny.slice(0, 30), + }; + }, effectivePolicy); +} + +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 font = style.fontFamily.split(',')[0].replace(/['"]/g, '').trim(); + + if (fgHex) colorCounts.set(fgHex, (colorCounts.get(fgHex) || 0) + 1); + if (bgHex && bgHex !== '#000000' && bgHex !== '#00000000') 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, + }; + }); +} + +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 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; + 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; +} + +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) => { + 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:'); + if (hasDeadHref && 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) => { + if (el.type === 'hidden') 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"], input[type="submit"], [role="button"]'); + 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.hasAttribute('disabled')) + .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); +} + +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, + }; + }); +} + +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 }; + } +} + +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 = { + detectOverflow, + detectTapTargets, + sampleStyles, + captureScrollShots, + captureCrops, + collectDomSignals, + checkFocusVisibility, + collectPerfSignals, + checkReflow, + runAxe, +}; diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..2701079 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,96 @@ +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'); + 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 === '--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]) { + 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; + } 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]) { + args.budgetA11y = Number.parseInt(argv[i + 1], 10); + i += 1; + } else if (val === '--max-small-targets' && argv[i + 1]) { + args.budgetTap = Number.parseInt(argv[i + 1], 10); + i += 1; + } else if (val === '--max-overflow' && argv[i + 1]) { + args.budgetOverflow = Number.parseInt(argv[i + 1], 10); + i += 1; + } else if (val === '--max-console' && argv[i + 1]) { + args.budgetConsole = Number.parseInt(argv[i + 1], 10); + i += 1; + } else if (val === '--max-http-errors' && argv[i + 1]) { + args.budgetHttpErrors = Number.parseInt(argv[i + 1], 10); + 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/evaluation.js b/src/evaluation.js new file mode 100644 index 0000000..4a6a991 --- /dev/null +++ b/src/evaluation.js @@ -0,0 +1,140 @@ +const { clampScore } = require('./utils'); + +function buildEvaluation(input) { + const { + overflow, + tapTargets, + styles, + axe, + domSignals, + perfSignals, + viewportMeta, + reflow, + consoleIssues, + networkIssues, + focusSignals, + perf, + } = input; + + 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) * 2 + - clippedCount * 2 + - overlapCount * 1 + - missingImages * 5 + - contrastCount * 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 * 2 - buttonsNoText * 2 - inputsMissingLabels * 2 - formsWithoutSubmit * 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 * 5 - headingIssues * 5 - focusMissing * 2 - inputsMissingLabels * 2 - smallTargets); + 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 : 20) + - (reflowOverflow ? 30 : 0) + - smallTargets * 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 > 6000 ? 20 : loadMs > 3000 ? 10 : 0) + - (dclMs > 3000 ? 10 : 0) + - (totalTransfer > 8_000_000 ? 20 : totalTransfer > 4_000_000 ? 10 : 0) + - largeImages * 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 - 3) * 5 + - Math.max(0, colorCount - 12) * 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 * 5 - genericCtas * 2); + const contentIssues = []; + if (loremCount) contentIssues.push(`placeholder copy (${loremCount})`); + if (genericCtas) contentIssues.push(`generic CTAs (${genericCtas})`); + + const stateSignals = domSignals?.stateSignals || {}; + const stateScore = clampScore(60 + + Math.min(20, (stateSignals.loadingHints || 0) * 2) + + Math.min(10, (stateSignals.errorHints || 0) * 2) + + Math.min(10, (stateSignals.ariaLive || 0) * 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 * 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 * 2 - failedReqs * 2 - httpErrors * 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/main.js b/src/main.js new file mode 100644 index 0000000..6b48aa2 --- /dev/null +++ b/src/main.js @@ -0,0 +1,300 @@ +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 { + detectOverflow, + detectTapTargets, + sampleStyles, + captureScrollShots, + captureCrops, + collectDomSignals, + checkFocusVisibility, + collectPerfSignals, + checkReflow, + runAxe, +} = require('./audits'); +const { buildEvaluation } = require('./evaluation'); +const { aggregateCounts, evaluateBudgets, generateHtml } = require('./report'); + +async function auditViewport(url, opts) { + const { + width, + height, + wait, + waitUntil, + readySelector, + steps, + screenshotsDir, + emulateMobile, + targetPolicy, + axeTags, + trace, + } = 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 } }); + + if (trace) { + await context.tracing.start({ screenshots: true, snapshots: true }); + } + + const page = await context.newPage(); + const networkIssues = { failedRequests: [], httpErrors: [] }; + const 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 }); + }); + + const 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); + + 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, targetPolicy); + const styles = await sampleStyles(page); + const axe = await runAxe(page, { axeTags }); + const domSignals = await collectDomSignals(page); + const perfSignals = await collectPerfSignals(page); + const viewportMeta = await page.evaluate(() => Boolean(document.querySelector('meta[name="viewport"]'))); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(100); + const shots = await captureScrollShots(page, screenshotsDir, emulateMobile ? 'mobile' : 'desktop', steps); + const overflowCrops = await captureCrops(page, overflow.offenders, path.join(screenshotsDir, 'crops'), 'overflow'); + const tapCrops = await captureCrops(page, tapTargets.samples, path.join(screenshotsDir, 'crops'), 'tap'); + const focusSignals = await checkFocusVisibility(page); + const reflow = emulateMobile ? null : await checkReflow(page); + const evaluation = buildEvaluation({ + overflow, + tapTargets, + styles, + axe, + domSignals, + perfSignals, + viewportMeta, + reflow, + consoleIssues, + networkIssues, + focusSignals, + perf, + }); + + let tracePath = null; + if (trace) { + tracePath = path.join(screenshotsDir, `${emulateMobile ? 'mobile' : 'desktop'}-trace.zip`); + await context.tracing.stop({ path: tracePath }); + tracePath = path.relative(process.cwd(), tracePath); + } + + await browser.close(); + + 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, + overflowCrops, + networkIssues, + consoleIssues, + trace: tracePath, + }; +} + +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/report.js b/src/report.js new file mode 100644 index 0000000..0f6f058 --- /dev/null +++ b/src/report.js @@ -0,0 +1,127 @@ +const fs = require('fs'); +const path = require('path'); +const { ensureDir } = require('./utils'); + +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 html = ` + + + + UXRay report - ${report.url} + + + +

UXRay Report

+
URL: ${report.url}
Run at: ${report.runAt}
Config: ${report.config.viewport}, mobile: ${report.config.mobile}, waitUntil: ${report.config.waitUntil}
+ +
+
+

Desktop

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

Mobile

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

Budgets

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

Evidence

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

Evidence (Mobile)

+
Shots:
+
    ${(report.mobile?.screenshots || []).slice(0, 4).map((p) => `
  • ${p}
  • `).join('')}
+ ${report.mobile?.overflow?.crops?.length ? `
Overflow crops:
    ${report.mobile.overflow.crops.map((p) => `
  • ${p}
  • `).join('')}
` : ''} + ${report.mobile?.tapTargets?.crops?.length ? `
Tap target crops:
    ${report.mobile.tapTargets.crops.map((p) => `
  • ${p}
  • `).join('')}
` : ''} + ${report.mobile?.trace ? `
Trace: ${report.mobile.trace}
` : ''} +
` : ''} +
+ +`; + + ensureDir(path.dirname(outputPath)); + fs.writeFileSync(outputPath, html, 'utf8'); +} + +module.exports = { + aggregateCounts, + evaluateBudgets, + generateHtml, +}; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..5e9577d --- /dev/null +++ b/src/utils.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 ab1f842..cb13056 100755 --- a/ui-review.js +++ b/ui-review.js @@ -1,756 +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'); - -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']); - -// Basic CLI arg parsing to keep dependencies light. -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'); - 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 === '--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]) { - 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; - } else if (val === '--html') { - if (argv[i + 1] && !argv[i + 1].startsWith('--')) { - args.html = argv[i + 1]; - i += 1; - } else { - args.html = true; // will resolve to default path later - } - } 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]) { - args.budgetA11y = Number.parseInt(argv[i + 1], 10); - i += 1; - } else if (val === '--max-small-targets' && argv[i + 1]) { - args.budgetTap = Number.parseInt(argv[i + 1], 10); - i += 1; - } else if (val === '--max-overflow' && argv[i + 1]) { - args.budgetOverflow = Number.parseInt(argv[i + 1], 10); - i += 1; - } else if (val === '--max-console' && argv[i + 1]) { - args.budgetConsole = Number.parseInt(argv[i + 1], 10); - i += 1; - } else if (val === '--max-http-errors' && argv[i + 1]) { - args.budgetHttpErrors = Number.parseInt(argv[i + 1], 10); - 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, 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) }, - spacingOk: spacedEnough, - text: (el.innerText || '').trim().slice(0, 80), - }); - } - }); - - return { - policy: policyDef, - smallTargetCount: tiny.length, - samples: tiny.slice(0, 30), - }; - }, effectivePolicy); -} - -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, idx) => { - const style = getComputedStyle(el); - const fgHex = toHex(style.color); - const bgHex = toHex(style.backgroundColor); - const font = style.fontFamily.split(',')[0].replace(/['"]/g, '').trim(); - - if (fgHex) colorCounts.set(fgHex, (colorCounts.get(fgHex) || 0) + 1); - if (bgHex && bgHex !== '#000000' && bgHex !== '#00000000') 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 }; - }); -} - -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 captureCrops(page, items, dir, prefix, limit = 8) { - if (!items || !items.length) return []; - ensureDir(dir); - 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; - 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) { - // Skip invalid clip regions (outside viewport) to keep the run resilient. - // eslint-disable-next-line no-console - console.warn(`crop failed for ${prefix} index ${i}: ${err.message}`); - } - } - return crops; -} - -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 })), - })), - }; -} - -async function auditViewport(url, opts) { - const { - width, - height, - wait, - waitUntil, - readySelector, - steps, - screenshotsDir, - emulateMobile, - targetPolicy, - axeTags, - trace, - } = 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 } }); - - if (trace) { - await context.tracing.start({ screenshots: true, snapshots: true }); - } - - const page = await context.newPage(); - const networkIssues = { failedRequests: [], httpErrors: [] }; - const 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 }); - }); - - const 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); - - 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, targetPolicy); - const styles = await sampleStyles(page); - const axe = await runAxe(page, { axeTags }); - const viewportMeta = await page.evaluate(() => Boolean(document.querySelector('meta[name="viewport"]'))); - const shots = await captureScrollShots(page, screenshotsDir, emulateMobile ? 'mobile' : 'desktop', steps); - const overflowCrops = await captureCrops(page, overflow.offenders, path.join(screenshotsDir, 'crops'), 'overflow'); - const tapCrops = await captureCrops(page, tapTargets.samples, path.join(screenshotsDir, 'crops'), 'tap'); - - let tracePath = null; - if (trace) { - tracePath = path.join(screenshotsDir, `${emulateMobile ? 'mobile' : 'desktop'}-trace.zip`); - await context.tracing.stop({ path: tracePath }); - tracePath = path.relative(process.cwd(), tracePath); - } - - await browser.close(); - - 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 }, - axe, - screenshots: shots, - overflowCrops, - networkIssues, - consoleIssues, - trace: tracePath, - }; -} - -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 html = ` - - - - UXRay report - ${report.url} - - - -

UXRay Report

-
URL: ${report.url}
Run at: ${report.runAt}
Config: ${report.config.viewport}, mobile: ${report.config.mobile}, waitUntil: ${report.config.waitUntil}
- -
-
-

Desktop

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

Mobile

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

Budgets

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

Evidence

-
Desktop shots:
-
    ${(report.desktop?.screenshots || []).slice(0, 4).map((p) => `
  • ${p}
  • `).join('')}
- ${report.desktop?.overflow?.crops?.length ? `
Overflow crops:
    ${report.desktop.overflow.crops.map((p) => `
  • ${p}
  • `).join('')}
` : ''} - ${report.desktop?.tapTargets?.crops?.length ? `
Tap target crops:
    ${report.desktop.tapTargets.crops.map((p) => `
  • ${p}
  • `).join('')}
` : ''} - ${report.desktop?.trace ? `
Trace: ${report.desktop.trace}
` : ''} -
- ${report.mobile ? `
-

Evidence (Mobile)

-
Shots:
-
    ${(report.mobile?.screenshots || []).slice(0, 4).map((p) => `
  • ${p}
  • `).join('')}
- ${report.mobile?.overflow?.crops?.length ? `
Overflow crops:
    ${report.mobile.overflow.crops.map((p) => `
  • ${p}
  • `).join('')}
` : ''} - ${report.mobile?.tapTargets?.crops?.length ? `
Tap target crops:
    ${report.mobile.tapTargets.crops.map((p) => `
  • ${p}
  • `).join('')}
` : ''} - ${report.mobile?.trace ? `
Trace: ${report.mobile.trace}
` : ''} -
` : ''} -
- -`; - - ensureDir(path.dirname(outputPath)); - fs.writeFileSync(outputPath, html, 'utf8'); -} - -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] [--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]'); - 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 { - // Some packages don't export package.json via exports; guard to keep runs resilient. - // eslint-disable-next-line import/no-dynamic-require, global-require - 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; - } -} +const { main } = require('./src/main'); main().catch((err) => { console.error('UXRay failed:', err); From e54739838143ccc7fc665316ed11946883069708 Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Mon, 13 Apr 2026 14:00:38 +0530 Subject: [PATCH 4/8] Reduce dead-link and lazy image false positives --- src/audits.js | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/audits.js b/src/audits.js index 59442f5..3e0cfb4 100644 --- a/src/audits.js +++ b/src/audits.js @@ -306,6 +306,11 @@ async function collectDomSignals(page) { 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 }); } @@ -325,7 +330,24 @@ async function collectDomSignals(page) { links.forEach((a) => { const href = (a.getAttribute('href') || '').trim(); const hasDeadHref = !href || href === '#' || href.toLowerCase().startsWith('javascript:'); - if (hasDeadHref && deadLinks.length < 20) deadLinks.push({ selector: format(a), href }); + 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) { From 53a395089e8e54a072fe130a9e1dca085cb417e0 Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Mon, 13 Apr 2026 14:12:29 +0530 Subject: [PATCH 5/8] Reduce crop failures and stabilize scroll state --- src/audits.js | 5 +++++ src/main.js | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/audits.js b/src/audits.js index 3e0cfb4..6a8746a 100644 --- a/src/audits.js +++ b/src/audits.js @@ -223,6 +223,7 @@ async function captureScrollShots(page, dir, prefix, steps) { 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, @@ -233,6 +234,10 @@ async function captureCrops(page, items, dir, prefix, limit = 8) { 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), diff --git a/src/main.js b/src/main.js index 6b48aa2..ff72225 100644 --- a/src/main.js +++ b/src/main.js @@ -90,6 +90,8 @@ async function auditViewport(url, opts) { } } await page.waitForTimeout(wait); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(100); const perf = await page.evaluate(() => { const nav = performance.getEntriesByType('navigation')[0]; @@ -108,11 +110,9 @@ async function auditViewport(url, opts) { const domSignals = await collectDomSignals(page); const perfSignals = await collectPerfSignals(page); const viewportMeta = await page.evaluate(() => Boolean(document.querySelector('meta[name="viewport"]'))); - await page.evaluate(() => window.scrollTo(0, 0)); - await page.waitForTimeout(100); - const shots = await captureScrollShots(page, screenshotsDir, emulateMobile ? 'mobile' : 'desktop', steps); const overflowCrops = await captureCrops(page, overflow.offenders, path.join(screenshotsDir, 'crops'), 'overflow'); const tapCrops = await captureCrops(page, tapTargets.samples, path.join(screenshotsDir, 'crops'), 'tap'); + const shots = await captureScrollShots(page, screenshotsDir, emulateMobile ? 'mobile' : 'desktop', steps); const focusSignals = await checkFocusVisibility(page); const reflow = emulateMobile ? null : await checkReflow(page); const evaluation = buildEvaluation({ From 8681e09be6dc61fc72672c1018a5d22221de04c3 Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Mon, 13 Apr 2026 21:37:16 +0530 Subject: [PATCH 6/8] Resolve review comments and harden CI --- .github/workflows/ci.yml | 5 +- README.md | 2 +- package.json | 2 +- scripts/smoke.js | 21 ++++ src/audits.js | 17 ++- src/cli.js | 27 ++-- src/main.js | 266 +++++++++++++++++++++++---------------- src/report.js | 62 +++++---- 8 files changed, 255 insertions(+), 147 deletions(-) create mode 100644 scripts/smoke.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dade466..f46a8bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: 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 @@ -44,8 +47,6 @@ jobs: registry-url: https://registry.npmjs.org - name: Install dependencies run: npm ci - - name: Install Playwright browsers (chromium only) - run: npx playwright install chromium --with-deps - name: Publish to npm env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index 35944a8..d0de8e6 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ After a run you’ll see: - Crops for top overflow/tap issues live under `reports/shots-/desktop|mobile/crops/`. ## CI & release -- GitHub Actions workflow (`.github/workflows/ci.yml`) runs a Playwright-backed smoke against `https://example.com` and publishes smoke artifacts. +- GitHub Actions workflow (`.github/workflows/ci.yml`) runs a Playwright-backed smoke against `https://example.com` and publishes smoke artifacts (uses OS temp dir for outputs). - To publish on tag `v*.*.*`, add repo secret `NPM_TOKEN` with publish rights; tagging triggers `npm publish --provenance`. Tap target policy notes: diff --git a/package.json b/package.json index 28067a9..3b65b40 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "review": "node ui-review.js", - "smoke": "node ui-review.js --url https://example.com --steps 1 --wait 800 --wait-until load --target-policy wcag21-aaa --out /tmp/uxray-ci.json --shots /tmp/uxray-shots", + "smoke": "node scripts/smoke.js", "ci": "npm run smoke" }, "repository": { 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.js b/src/audits.js index 6a8746a..33c26bc 100644 --- a/src/audits.js +++ b/src/audits.js @@ -91,7 +91,6 @@ async function detectTapTargets(page, policy) { height: Math.round(rect.height), }, size: { width: Math.round(rect.width), height: Math.round(rect.height) }, - spacingOk: spacedEnough, text: (el.innerText || '').trim().slice(0, 80), }); } @@ -150,10 +149,11 @@ async function sampleStyles(page) { 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 && bgHex !== '#000000' && bgHex !== '#00000000') colorCounts.set(bgHex, (colorCounts.get(bgHex) || 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; @@ -375,7 +375,8 @@ async function collectDomSignals(page) { const inputs = Array.from(document.querySelectorAll('input, select, textarea')).slice(0, 300); inputs.forEach((el) => { - if (el.type === 'hidden') return; + 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'); @@ -390,7 +391,7 @@ async function collectDomSignals(page) { const forms = Array.from(document.querySelectorAll('form')).slice(0, 80); forms.forEach((form) => { - const hasSubmit = form.querySelector('button[type="submit"], input[type="submit"], [role="button"]'); + const hasSubmit = form.querySelector('button[type="submit"], button:not([type]), input[type="submit"]'); if (!hasSubmit && formsWithoutSubmit.length < 20) formsWithoutSubmit.push({ selector: format(form) }); }); @@ -455,7 +456,15 @@ async function checkFocusVisibility(page, limit = 20) { 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; diff --git a/src/cli.js b/src/cli.js index 2701079..532d615 100644 --- a/src/cli.js +++ b/src/cli.js @@ -32,11 +32,14 @@ function parseArgs(argv) { 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; + 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]) { - args.wait = Number.parseInt(argv[i + 1], 10) || args.wait; + 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]; @@ -45,7 +48,8 @@ function parseArgs(argv) { args.readySelector = argv[i + 1]; i += 1; } else if (val === '--steps' && argv[i + 1]) { - args.steps = Number.parseInt(argv[i + 1], 10) || args.steps; + 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]; @@ -69,19 +73,24 @@ function parseArgs(argv) { } else if (val === '--trace') { args.trace = true; } else if (val === '--max-a11y' && argv[i + 1]) { - args.budgetA11y = Number.parseInt(argv[i + 1], 10); + 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]) { - args.budgetTap = Number.parseInt(argv[i + 1], 10); + 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]) { - args.budgetOverflow = Number.parseInt(argv[i + 1], 10); + 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]) { - args.budgetConsole = Number.parseInt(argv[i + 1], 10); + 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]) { - args.budgetHttpErrors = Number.parseInt(argv[i + 1], 10); + const value = Number.parseInt(argv[i + 1], 10); + if (Number.isFinite(value) && value >= 0) args.budgetHttpErrors = value; i += 1; } } diff --git a/src/main.js b/src/main.js index ff72225..e7686f7 100644 --- a/src/main.js +++ b/src/main.js @@ -35,130 +35,178 @@ async function auditViewport(url, opts) { trace, } = 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 } }); - - if (trace) { - await context.tracing.start({ screenshots: true, snapshots: true }); - } + 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 page = await context.newPage(); const networkIssues = { failedRequests: [], httpErrors: [] }; const consoleIssues = []; - page.on('requestfailed', (req) => { - networkIssues.failedRequests.push({ - url: req.url(), - method: req.method(), - error: req.failure()?.errorText, - resourceType: req.resourceType(), - }); - }); + try { + browser = await chromium.launch({ headless: true }); + context = emulateMobile + ? await browser.newContext({ ...devices['iPhone 12'], viewport: devices['iPhone 12'].viewport }) + : await browser.newContext({ viewport: { width, height } }); - 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(), - }); + if (trace) { + await context.tracing.start({ screenshots: true, snapshots: true }); + tracingStarted = true; } - }); - page.on('console', (msg) => { - if (['error', 'warning'].includes(msg.type())) { - consoleIssues.push({ type: msg.type(), text: msg.text() }); + const page = await context.newPage(); + 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 }); + }); + + 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); - page.on('pageerror', (err) => { - consoleIssues.push({ type: 'pageerror', text: err.message }); - }); + perf = await page.evaluate(() => { + const nav = performance.getEntriesByType('navigation')[0]; + if (!nav) return null; + return { + domContentLoaded: nav.domContentLoadedEventEnd, + load: nav.loadEventEnd, + renderBlocking: nav.responseEnd, + }; + }); + + overflow = await detectOverflow(page); + tapTargets = await detectTapTargets(page, targetPolicy); + styles = await sampleStyles(page); + axe = await runAxe(page, { axeTags }); + domSignals = await collectDomSignals(page); + perfSignals = await collectPerfSignals(page); + viewportMeta = await page.evaluate(() => Boolean(document.querySelector('meta[name="viewport"]'))); + overflowCrops = await captureCrops(page, overflow.offenders, path.join(screenshotsDir, 'crops'), 'overflow'); + tapCrops = await captureCrops(page, tapTargets.samples, path.join(screenshotsDir, 'crops'), 'tap'); + shots = await captureScrollShots(page, screenshotsDir, emulateMobile ? 'mobile' : 'desktop', steps); + focusSignals = await checkFocusVisibility(page); + reflow = emulateMobile ? null : await checkReflow(page); + evaluation = buildEvaluation({ + overflow, + tapTargets, + styles, + axe, + domSignals, + perfSignals, + viewportMeta, + reflow, + consoleIssues, + networkIssues, + focusSignals, + perf, + }); - const 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`); + if (context && trace && tracingStarted) { + try { + tracePath = path.join(screenshotsDir, `${traceLabel}-trace.zip`); + await context.tracing.stop({ path: tracePath }); + } catch (_) { + tracePath = null; + } finally { + tracingStarted = false; + } } - } - await page.waitForTimeout(wait); - await page.evaluate(() => window.scrollTo(0, 0)); - await page.waitForTimeout(100); - 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, + 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, }; - }); - - const overflow = await detectOverflow(page); - const tapTargets = await detectTapTargets(page, targetPolicy); - const styles = await sampleStyles(page); - const axe = await runAxe(page, { axeTags }); - const domSignals = await collectDomSignals(page); - const perfSignals = await collectPerfSignals(page); - const viewportMeta = await page.evaluate(() => Boolean(document.querySelector('meta[name="viewport"]'))); - const overflowCrops = await captureCrops(page, overflow.offenders, path.join(screenshotsDir, 'crops'), 'overflow'); - const tapCrops = await captureCrops(page, tapTargets.samples, path.join(screenshotsDir, 'crops'), 'tap'); - const shots = await captureScrollShots(page, screenshotsDir, emulateMobile ? 'mobile' : 'desktop', steps); - const focusSignals = await checkFocusVisibility(page); - const reflow = emulateMobile ? null : await checkReflow(page); - const evaluation = buildEvaluation({ - overflow, - tapTargets, - styles, - axe, - domSignals, - perfSignals, - viewportMeta, - reflow, - consoleIssues, - networkIssues, - focusSignals, - perf, - }); - - let tracePath = null; - if (trace) { - tracePath = path.join(screenshotsDir, `${emulateMobile ? 'mobile' : 'desktop'}-trace.zip`); - await context.tracing.stop({ path: tracePath }); - tracePath = path.relative(process.cwd(), tracePath); + } finally { + if (context && trace && tracingStarted) { + try { + tracePath = path.join(screenshotsDir, `${traceLabel}-trace.zip`); + await context.tracing.stop({ path: tracePath }); + } 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 + } + } } - - await browser.close(); - - 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, - overflowCrops, - networkIssues, - consoleIssues, - trace: tracePath, - }; } async function main() { diff --git a/src/report.js b/src/report.js index 0f6f058..05162e2 100644 --- a/src/report.js +++ b/src/report.js @@ -2,6 +2,20 @@ 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; @@ -39,11 +53,17 @@ function evaluateBudgets(counts, budgets) { } 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 - ${report.url} + UXRay report - ${safeUrl}