
Qa Browser Automation
- 75 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
QA Browser Automation is a Claude skill that combines Chrome MCP browser control with Python tools for health scoring, WCAG accessibility auditing, visual regression tracking and QA reporting.
About
QA Browser Automation drives Chrome MCP for live browser testing and adds four Python tools for health scoring, accessibility auditing, visual regression tracking and report generation. A developer uses it for systematic, repeatable QA sweeps of web apps covering functional, accessibility, performance and security-header checks. It defines four testing tiers and gates on a health score of at least 85, zero P0 findings and WCAG AA at 95%.
- 11-phase full-app QA sweep with 0-100 health scoring across 10 categories
- WCAG 2.1 A/AA/AAA accessibility auditing with remediation guidance
- Visual regression tracking via baseline vs current screenshot comparison
Qa Browser Automation by the numbers
- 75 all-time installs (skills.sh)
- Ranked #1,076 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
qa-browser-automation capabilities & compatibility
- Capabilities
- accessibility audit · visual regression · pr review expert
- Works with
- chrome · playwright
- Use cases
- testing · code review · web design
- Pricing
- Free
What qa-browser-automation says it does
Use when performing browser-based QA testing, visual regression tracking, WCAG accessibility auditing, performance profiling, or health scoring web applications.
**Validation checkpoint:** Health score >= 85. Zero P0 findings. WCAG AA >= 95%.
The agent drives Chrome MCP for live browser testing and uses four Python tools for deterministic health scoring, accessibility auditing, visual regression tracking, and report generation.
npx skills add https://github.com/borghei/claude-skills --skill qa-browser-automationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 75 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Run repeatable browser QA sweeps with health scoring, WCAG accessibility audits, visual regression tracking and performance checks.
Who is it for?
Repeatable browser QA sweeps with accessibility, visual-regression and performance checks.
Skip if: Static code review of a PR diff (use pr-review-expert).
When should I use this skill?
Performing browser QA, visual regression tracking, WCAG auditing, or performance/health scoring a web app.
What you get
A scored, evidence-backed QA report with WCAG findings and flagged visual regressions.
- 0-100 health score
- WCAG violation report
- visual regression results
By the numbers
- 11-phase QA sweep
- 4 Python tools
- 10 weighted health-score categories
Files
QA Browser Automation
The agent drives Chrome MCP for live browser testing and uses four Python tools for deterministic health scoring, accessibility auditing, visual regression tracking, and report generation.
---
Quick Start
# Score QA findings (0-100 weighted across 10 categories)
python scripts/qa_health_scorer.py findings.json --threshold 85 --baseline .qa-baselines/latest.json --save-baseline --json
# Audit HTML for WCAG 2.1 violations
python scripts/accessibility_auditor.py page.html --level AA --json
# Track visual regressions
python scripts/visual_regression_tracker.py --init --baseline-dir ./baselines
python scripts/visual_regression_tracker.py --register ./baselines
python scripts/visual_regression_tracker.py --baseline ./baselines --current ./screenshots --threshold 5
# Generate full QA report
python scripts/test_report_generator.py session_data.json --format markdown -o report.mdTools Overview
| Tool | Input | Output |
|---|---|---|
qa_health_scorer.py | Findings JSON | Score 0-100, grade A-F, category breakdown, trend data |
accessibility_auditor.py | HTML file (or stdin) | WCAG violations by level with remediation guidance |
visual_regression_tracker.py | Baseline + current screenshot dirs | Pass/fail per page, change percentages |
test_report_generator.py | Session data JSON | Markdown or JSON report with recommendations |
All tools support --json for machine output. Health scorer and regression tracker return exit code 1 on failure (CI-friendly).
---
Workflow 1: Full Application QA Sweep (11 Phases)
Phase 1-2: Pre-flight and authentication.
- Verify
git statusis clean. Abort if dirty. - Create session directory:
.qa-sessions/{timestamp}/ - Authenticate via Chrome MCP if needed.
Phase 3-4: Orient and explore.
- Use
mcp__claude-in-chrome__read_pageto build sitemap/page map. - Navigate each route. Check
read_console_messagesfor errors,read_network_requestsfor 4xx/5xx. - Test all forms with valid data, empty submissions, and boundary values.
Phase 5: State testing.
- Verify loading states (skeleton screens, not blank), empty states (guides to first action), error states, success states, partial states.
- Four shadow paths per interaction: happy path, nil input, empty input, error upstream.
Phase 6: Cross-device and security.
- Resize to 320px, 768px, 1024px, 1440px, 1920px.
- Check touch targets (44x44px min), layout shifts.
- Verify security headers (CSP, HSTS, X-Frame-Options), cookie flags.
Phase 7-8: Document and score.
- Record every finding with screenshot evidence. No finding without evidence.
- Classify by severity (P0-P4) and category (10 categories).
- Run:
python scripts/qa_health_scorer.py findings.json --baseline .qa-baselines/latest.json
Phase 9: Triage and fix loop.
- P3/P4: AUTO-FIX, commit atomically, verify.
- P0/P1/P2: ASK, present evidence, propose fix, wait for approval.
- After each fix: re-run check. If fail:
git revert. - Hard stop at 50 fixes.
Phase 10-11: Regression check and report.
- Re-visit fixed pages. Verify no new errors.
- Generate report:
python scripts/test_report_generator.py session.json --save-baseline
Validation checkpoint: Health score >= 85. Zero P0 findings. WCAG AA >= 95%.
---
Workflow 2: Visual Regression Testing
# Set up baseline
python scripts/visual_regression_tracker.py --init --baseline-dir ./baselines
# Capture and register screenshots
python scripts/visual_regression_tracker.py --register ./baselines
# After changes, compare
python scripts/visual_regression_tracker.py --baseline ./baselines --current ./screenshots --threshold 5 --json
# Accept intentional changes
python scripts/visual_regression_tracker.py --update-baseline --baseline ./baselines --current ./screenshotsPages exceeding the threshold (default 5%) are flagged as regressions. Uses SHA-256 hashing and byte-level comparison.
---
Workflow 3: Accessibility Audit
python scripts/accessibility_auditor.py page.html --level AA --json
curl -s https://example.com | python scripts/accessibility_auditor.py - --level AAAWhat gets checked by level:
- A (Must Fix): Alt text, page language, form labels, headings, duplicate IDs, autoplay media
- AA (Should Fix): Color contrast (4.5:1 text, 3:1 large), heading hierarchy, focus visible, error identification
- AAA (Nice to Have): Enhanced contrast (7:1), extended audio, reading level
Each violation includes: WCAG criterion, severity, element selector, and remediation guidance.
---
Testing Tiers
| Tier | Duration | Scope |
|---|---|---|
| Quick | 30s | Console errors, broken links, basic a11y, mobile resize |
| Standard | 2-5 min | + Top 10 routes, forms, contrast, Core Web Vitals |
| Deep | 10-20 min | + Full sitemap, state testing, WCAG AA, performance, visual regression, security headers |
| Exhaustive | 30+ min | + Every element, WCAG AAA, all pages performance, 5 breakpoints, auth edge cases, memory leaks |
---
Health Scoring System
10 weighted categories, score 0-100:
| Category | Weight | Measures |
|---|---|---|
| Functional | 18% | Forms, CRUD, navigation flows |
| Accessibility | 13% | WCAG compliance, keyboard nav |
| Console Errors | 12% | JS errors, unhandled rejections |
| UX Flow | 12% | Logical navigation, clear feedback |
| Performance | 12% | Core Web Vitals within thresholds |
| Visual Consistency | 10% | Layout shifts, alignment, z-index |
| Broken Links | 8% | HTTP 4xx/5xx, dead anchors |
| Content Quality | 5% | Spelling, placeholder text, truncation |
| Security Headers | 5% | CSP, HSTS, cookie flags |
| Mobile Responsive | 5% | Breakpoints, touch targets, no h-scroll |
Severity deductions: P0: -30, P1: -18, P2: -10, P3: -4, P4: -1.
Grades: A (90-100), B (80-89), C (70-79), D (60-69), F (0-59).
---
Safety Controls
- Clean working tree required -- abort if
git statusdirty. - Max 50 fixes per session -- hard stop.
- Risk accumulator -- component (+5), style (+2), config (+8), revert (+15). Stop at 25% of budget.
- WTF heuristic -- 3 consecutive fix verification failures = stop entirely.
- Atomic commits -- one fix = one commit:
fix(qa): [P{severity}] {description}
---
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Scorer exits code 1 with no errors | Score below --threshold (default 70) | Check score in output; raise threshold or fix findings |
Auditor reports parse-error | Malformed HTML | Verify file is complete; check curl is not returning redirect |
| Regression tracker 100% change on all pages | Baseline manifest empty | Run --init then --register before comparing |
| Findings default to P3/functional | Missing severity or category keys | Include both keys in each finding dict |
| Chrome MCP returns stale content after SPA nav | DOM updated without full page load | Wait for transition, call read_page again |
---
References
| Guide | Path |
|---|---|
| Browser Testing Methodology | references/browser_testing_methodology.md |
| WCAG Compliance Guide | references/wcag_compliance_guide.md |
| Performance Benchmarks | references/performance_benchmarks.md |
---
Integration Points
| Skill | Integration |
|---|---|
code-reviewer | Health score and findings in PR review context |
senior-frontend | Visual regression baselines align with component library |
senior-devops | Health score gates CI/CD via exit code |
senior-secops | Security header findings escalate to security review |
incident-commander | P0 findings trigger incident response |
---
Last Updated: April 2026 Version: 2.1.0
QA Report: [PROJECT NAME]
Date: [YYYY-MM-DD] URL: [Application URL] Tester: [Name or "QA Automation"] Tier: [Quick / Standard / Deep / Exhaustive] Branch/Version: [Git branch or release version]
---
Executive Summary
[1-3 sentences summarizing overall quality, key risks, and release readiness.]
Health Score: [XX]/100 (Grade: [A-F]) — [PASS/FAIL]
---
Health Score Dashboard
| Category | Weight | Score | Findings |
|---|---|---|---|
| Console Errors | 12% | --% | -- |
| Broken Links | 8% | --% | -- |
| Visual Consistency | 10% | --% | -- |
| Functional | 18% | --% | -- |
| UX Flow | 12% | --% | -- |
| Performance | 12% | --% | -- |
| Content Quality | 5% | --% | -- |
| Accessibility | 13% | --% | -- |
| Security Headers | 5% | --% | -- |
| Mobile Responsive | 5% | --% | -- |
---
Findings
P0 — Critical
[List P0 findings or "None"]
P1 — High
[List P1 findings or "None"]
P2 — Medium
[List P2 findings or "None"]
P3 — Low
[List P3 findings or "None"]
P4 — Cosmetic
[List P4 findings or "None"]
---
Accessibility Summary
- Level Checked: WCAG 2.1 [A/AA/AAA]
- Violations: [count]
- Compliance: [XX]%
[Top violations if any]
---
Performance Summary
| Metric | Value | Status |
|---|---|---|
| LCP | -- | -- |
| INP | -- | -- |
| CLS | -- | -- |
| TTFB | -- | -- |
---
Visual Regression
- Pages Compared: [count]
- Regressions: [count]
[List regressions if any]
---
Recommendations
1. [Priority recommendation] 2. [Secondary recommendation] 3. [Additional recommendation]
---
Notes
[Additional context, environment details, known limitations, etc.]
---
Generated by QA Browser Automation skill
{
"project": "Example Web App",
"url": "https://app.example.com",
"tester": "QA Automation",
"tier": "standard",
"timestamp": "2026-03-18T14:30:00Z",
"findings": [
{
"severity": "P0",
"category": "functional",
"title": "Checkout form silently drops payment data on submit",
"description": "Submitting the checkout form with valid credit card data returns success but no charge is created. Order appears in dashboard with $0.00 total.",
"location": "/checkout",
"steps_to_reproduce": "1. Add item to cart. 2. Proceed to checkout. 3. Enter valid payment info. 4. Click Submit.",
"expected": "Payment is processed and order total matches cart",
"actual": "Order created with $0.00 total, no payment charge"
},
{
"severity": "P1",
"category": "accessibility",
"title": "Login form inputs have no associated labels",
"description": "Email and password fields use placeholder text only, with no <label> elements or aria-label attributes. Screen readers cannot identify the fields.",
"location": "/login",
"steps_to_reproduce": "Navigate to /login and inspect form elements",
"expected": "Each input has an associated label element",
"actual": "Inputs have placeholder only, no labels"
},
{
"severity": "P1",
"category": "console_errors",
"title": "Unhandled promise rejection on dashboard load",
"description": "TypeError: Cannot read properties of undefined (reading 'map') appears in console when loading the dashboard with no data.",
"location": "/dashboard",
"steps_to_reproduce": "Log in as new user with no data, navigate to /dashboard",
"expected": "Empty state message displayed",
"actual": "Blank page with console error"
},
{
"severity": "P2",
"category": "performance",
"title": "LCP exceeds 4 seconds on product listing page",
"description": "Largest Contentful Paint is 4.2 seconds due to unoptimized hero image (2.8 MB PNG).",
"location": "/products",
"steps_to_reproduce": "Navigate to /products on standard 4G connection",
"expected": "LCP <= 2.5 seconds",
"actual": "LCP = 4.2 seconds"
},
{
"severity": "P2",
"category": "visual_consistency",
"title": "Modal content overflows on mobile viewport",
"description": "Settings modal extends beyond viewport at 320px width, with no scroll and a clipped close button.",
"location": "/settings",
"steps_to_reproduce": "Open settings modal at 320px viewport width",
"expected": "Modal fits within viewport or scrolls internally",
"actual": "Content overflows, close button inaccessible"
},
{
"severity": "P3",
"category": "ux_flow",
"title": "No loading indicator during search",
"description": "Performing a search shows no loading state. Results appear after 1-2 seconds with no feedback.",
"location": "/search",
"steps_to_reproduce": "Enter a search query and submit",
"expected": "Loading spinner or skeleton while results load",
"actual": "No visual feedback during loading"
},
{
"severity": "P3",
"category": "security_headers",
"title": "Missing Content-Security-Policy header",
"description": "The application does not send a Content-Security-Policy header, increasing XSS risk.",
"location": "All pages",
"steps_to_reproduce": "Inspect response headers on any page",
"expected": "Content-Security-Policy header present",
"actual": "Header missing"
},
{
"severity": "P4",
"category": "content_quality",
"title": "Lorem ipsum placeholder text in footer",
"description": "The footer 'About Us' section contains 'Lorem ipsum dolor sit amet...' placeholder text.",
"location": "/ (footer)",
"steps_to_reproduce": "Scroll to footer on any page",
"expected": "Real content",
"actual": "Lorem ipsum placeholder"
},
{
"severity": "P4",
"category": "visual_consistency",
"title": "Inconsistent button border-radius across pages",
"description": "Buttons on /settings use 4px border-radius while buttons on /profile use 8px.",
"location": "/settings, /profile",
"steps_to_reproduce": "Compare button styles between pages",
"expected": "Consistent border-radius across all buttons",
"actual": "4px on /settings, 8px on /profile"
}
]
}
Browser Testing Methodology
Expert knowledge base for systematic browser-based quality assurance.
---
Systematic Page Exploration
Route Discovery Strategy
1. Navigation-first: Inspect the main navigation (header, sidebar, footer) to enumerate all top-level routes. 2. Sitemap check: Look for /sitemap.xml or /robots.txt for a machine-readable route list. 3. Dynamic routes: Identify parameterized URLs (e.g., /users/:id, /products/:slug) and test with valid, invalid, and boundary IDs. 4. Hidden routes: Check for admin panels (/admin, /dashboard), API docs (/docs, /swagger), and debug pages (/debug, /health). 5. Hash routes: SPAs often use hash-based routing (/#/page). Inspect the router configuration or observe URL changes during navigation.
Exploration Order
- Start from the landing page and follow natural user flows.
- Test the primary conversion path first (signup, purchase, key action).
- Then test secondary paths (settings, profile, help).
- Finally test edge-case paths (error pages, 404, maintenance mode).
---
Element Interaction Patterns
Forms
| Element | Test Actions |
|---|---|
| Text input | Valid data, empty, max length, special chars (<script>, '; DROP), unicode, whitespace-only |
| Email input | Valid format, missing @, missing domain, IDN domains |
| Password | Min length, max length, special chars, paste behavior, show/hide toggle |
| Select/dropdown | First option, last option, disabled options, keyboard selection |
| Checkbox | Check, uncheck, indeterminate state, required validation |
| Radio buttons | Each option, default selection, required validation |
| File upload | Valid file, oversized file, wrong type, no file, multiple files |
| Date picker | Valid date, past date, future date, boundary dates, manual entry |
| Textarea | Empty, max length, line breaks, paste large content |
Interactive Elements
- Modals/dialogs: Open, close (X, overlay click, Escape key), scroll within, nested modals.
- Tabs: Each tab, keyboard navigation, deep-linking to specific tab.
- Accordions: Expand, collapse, expand-all, multiple open simultaneously.
- Tooltips: Hover trigger, focus trigger, dismiss, positioning at edges.
- Drag and drop: Start, move, drop, cancel, keyboard alternative.
- Infinite scroll: Initial load, subsequent loads, scroll-to-top, empty state.
- Search: Empty query, partial match, no results, special characters, debounce behavior.
---
State Testing
Five Critical States
1. Loading state: Verify skeleton screens, spinners, or progress indicators appear during data fetching. Check that interactive elements are disabled or hidden until ready.
2. Empty state: Test with zero data. Verify helpful messaging and a clear call-to-action (e.g., "Create your first item"). No broken layouts from missing data.
3. Error state: Trigger errors (invalid input, network failure, server error). Verify user-friendly error messages, retry options, and no raw error dumps.
4. Success state: Complete actions successfully. Verify confirmation messages, redirects, data persistence, and UI updates.
5. Partial state: Test with incomplete data (some fields filled, partial API responses). Verify graceful degradation without crashes.
Additional States
- Offline: Disconnect network and verify offline messaging or cached behavior.
- Slow network: Throttle to 3G and verify timeouts are handled, loading indicators appear.
- Stale data: Open in two tabs, modify in one, verify the other handles stale state.
- Session expired: Let the session timeout and verify redirect to login with appropriate messaging.
---
Cross-Browser Considerations
Testing Priority Matrix
| Browser | Priority | Key Concerns |
|---|---|---|
| Chrome (latest) | P0 | Baseline — most users |
| Safari (latest) | P1 | Date inputs, flexbox gaps, WebKit quirks |
| Firefox (latest) | P1 | Form styling, scrollbar behavior |
| Edge (latest) | P2 | Chromium-based, minimal delta from Chrome |
| Mobile Safari | P1 | Touch events, viewport units, safe area insets |
| Mobile Chrome | P1 | Touch targets, viewport behavior |
Common Cross-Browser Issues
- CSS Grid/Flexbox: Gap property support, subgrid availability.
- Date/time inputs: Native picker differences, format localization.
- Scroll behavior:
scroll-behavior: smoothinconsistencies. - Font rendering: Anti-aliasing differences, font-weight rendering.
- Focus styles:
:focus-visiblesupport, default outline styles. - Clipboard API: Permissions model differs across browsers.
---
Network Condition Simulation
Throttling Profiles
| Profile | Download | Upload | Latency | Use Case |
|---|---|---|---|---|
| Fast 3G | 1.5 Mbps | 750 Kbps | 563ms | Mobile baseline |
| Slow 3G | 500 Kbps | 500 Kbps | 2000ms | Worst-case mobile |
| Offline | 0 | 0 | N/A | Service worker/cache testing |
| High latency | Normal | Normal | 2000ms | Distant server simulation |
What to Verify Under Throttling
- Loading indicators appear promptly (not after content loads).
- Images use lazy loading and responsive sizes.
- Critical CSS is inlined or loaded first.
- API calls have appropriate timeouts.
- Retry logic works for transient failures.
- No duplicate submissions from impatient clicks.
---
Authentication Flow Testing
Login Flows
- Valid credentials (happy path).
- Invalid password (error message, no password leak in URL/logs).
- Non-existent user (same error as invalid password to prevent enumeration).
- Account locked after N attempts (verify lockout and messaging).
- Remember me / persistent session.
- OAuth/SSO redirect and callback.
- Multi-factor authentication (MFA) — code entry, backup codes, timeout.
Session Management
- Session timeout behavior (redirect to login, preserve intended URL).
- Concurrent sessions (login from second device).
- Logout (session invalidation, redirect, cached page behavior).
- CSRF token rotation on sensitive actions.
- Cookie security flags (Secure, HttpOnly, SameSite).
Authorization
- Role-based access (admin vs user vs guest).
- Direct URL access to unauthorized pages (proper 403, not broken UI).
- API authorization (token in header, not URL params).
- Privilege escalation attempts (modify user ID in request).
---
Mobile-Specific Testing
Touch Interaction
- Touch targets minimum 44x44px (WCAG) or 48x48px (Material Design).
- Swipe gestures (carousels, dismissals) work correctly.
- Long press does not trigger unintended context menus.
- Pinch-to-zoom does not break layout.
Viewport Considerations
- Test at 320px (iPhone SE), 375px (iPhone), 390px (iPhone 14), 428px (iPhone 14 Pro Max).
- Verify no horizontal scrollbar at any supported width.
- Check that fixed elements (headers, footers, FABs) do not overlap content.
- Test with on-screen keyboard visible (input fields not obscured).
---
Reference for the QA Browser Automation skill. Use alongside Chrome MCP tools for live browser testing.
Performance Benchmarks
Reference thresholds for Core Web Vitals, network analysis, and application performance profiling.
---
Core Web Vitals Thresholds
Google's Core Web Vitals are the primary performance metrics that affect user experience and search ranking.
| Metric | Good | Needs Improvement | Poor | What It Measures |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | <= 2.5s | 2.5s - 4.0s | > 4.0s | Loading — time to render largest visible element |
| FID (First Input Delay) | <= 100ms | 100ms - 300ms | > 300ms | Interactivity — delay before first input response |
| CLS (Cumulative Layout Shift) | <= 0.1 | 0.1 - 0.25 | > 0.25 | Visual stability — unexpected layout movement |
| INP (Interaction to Next Paint) | <= 200ms | 200ms - 500ms | > 500ms | Responsiveness — latency of all interactions (replaces FID) |
| TTFB (Time to First Byte) | <= 800ms | 800ms - 1800ms | > 1800ms | Server — time to receive first byte |
Additional Timing Metrics
| Metric | Target | Description |
|---|---|---|
| FCP (First Contentful Paint) | <= 1.8s | Time to first text/image render |
| TTI (Time to Interactive) | <= 3.8s | Time until page is fully interactive |
| TBT (Total Blocking Time) | <= 200ms | Sum of long task blocking time between FCP and TTI |
| Speed Index | <= 3.4s | How quickly visible content populates |
---
Network Waterfall Analysis
Resource Budget Guidelines
| Resource Type | Budget (compressed) | Notes |
|---|---|---|
| HTML document | < 50 KB | Includes inline critical CSS |
| CSS (total) | < 100 KB | Combined, minified, compressed |
| JavaScript (total) | < 300 KB | Combined initial bundle |
| Images (above fold) | < 200 KB | Use WebP/AVIF, lazy load below fold |
| Fonts | < 100 KB | Subset, use font-display: swap |
| Total page weight | < 1 MB | First load, compressed |
Request Count Targets
| Metric | Target | Concern |
|---|---|---|
| Total requests | < 50 | Connection overhead |
| Third-party requests | < 15 | Latency, privacy, reliability |
| Blocking requests | < 5 | Render delay |
| Redirects | 0-1 | Each adds 100-300ms |
Waterfall Red Flags
- Long TTFB: Server processing or DNS issues. Check CDN, caching, database queries.
- Render-blocking CSS/JS: Move to async/defer, inline critical CSS.
- Sequential resource loading: Resources loading one-after-another instead of parallel. Check for unnecessary dependency chains.
- Large uncompressed assets: Enable gzip/brotli compression. Check
Content-Encodingheader. - No caching headers: Static assets should have
Cache-Control: max-age=31536000with hashed filenames. - Unused resources: JavaScript or CSS loaded but not used on current page. Code-split by route.
---
JavaScript Execution Profiling
Long Task Thresholds
| Duration | Classification | Impact |
|---|---|---|
| < 50ms | Normal | No perceptible delay |
| 50-100ms | Long task | Slight jank possible |
| 100-300ms | Very long task | Noticeable lag |
| > 300ms | Blocking task | User perceives freeze |
Common Causes of Long Tasks
1. Synchronous layout (forced reflow): Reading layout properties after DOM changes. 2. Large DOM manipulation: Batch DOM updates, use requestAnimationFrame. 3. Heavy computation: Move to Web Worker or break into smaller chunks with setTimeout. 4. Third-party scripts: Analytics, ads, chat widgets blocking main thread. 5. JSON parsing: Large API responses parsed on main thread.
JavaScript Bundle Analysis
| Bundle Size (uncompressed) | Assessment |
|---|---|
| < 100 KB | Excellent |
| 100-250 KB | Good |
| 250-500 KB | Review for optimization |
| > 500 KB | Needs code splitting |
---
Memory Leak Detection Patterns
Symptoms
- Heap size grows continuously over time without stabilizing.
- Performance degrades with prolonged use.
- Page becomes unresponsive after extended sessions.
- Browser tab crash on low-memory devices.
Common Leak Sources
1. Detached DOM nodes: Elements removed from DOM but still referenced in JavaScript. 2. Event listeners: Listeners not removed on component unmount. 3. Closures: Functions retaining references to large objects. 4. Timers: setInterval not cleared on cleanup. 5. Global state accumulation: Growing arrays/objects never trimmed.
Detection Approach
1. Take heap snapshot (baseline). 2. Perform the suspected leaking action 5-10 times. 3. Force garbage collection. 4. Take second heap snapshot. 5. Compare: if retained size grew significantly, investigate the delta.
---
Mobile Performance Considerations
Mobile-Specific Budgets
Mobile devices have less CPU, memory, and often slower network connections.
| Metric | Mobile Target | Desktop Target |
|---|---|---|
| LCP | <= 2.5s | <= 2.0s |
| TTI | <= 5.0s | <= 3.8s |
| TBT | <= 300ms | <= 200ms |
| JS bundle | < 200 KB | < 300 KB |
| Total weight | < 500 KB | < 1 MB |
Mobile Optimization Checklist
- Images use
srcsetandsizesfor responsive loading. - Below-fold images use
loading="lazy". - Critical CSS is inlined in
<head>. - JavaScript is deferred (
deferortype="module"). - Fonts use
font-display: swapto prevent FOIT. - Touch interactions have no delay (no 300ms click delay).
- Viewport meta tag is set:
<meta name="viewport" content="width=device-width, initial-scale=1">. - Service worker caches critical resources for repeat visits.
---
Caching Strategy
Cache-Control Headers
| Resource | Cache-Control | Rationale |
|---|---|---|
| HTML pages | no-cache or max-age=0, must-revalidate | Always fresh |
| Hashed static assets | max-age=31536000, immutable | Content-addressed, safe to cache forever |
| API responses | no-store or max-age=60 | Data freshness depends on use case |
| Images (CDN) | max-age=86400 | Daily revalidation |
| Fonts | max-age=31536000 | Rarely change |
Performance Headers to Verify
| Header | Purpose |
|---|---|
Content-Encoding: br or gzip | Compression enabled |
Cache-Control | Caching policy set |
ETag / Last-Modified | Conditional request support |
Vary: Accept-Encoding | Correct cache variants |
Connection: keep-alive | Connection reuse |
---
Reference for the QA Browser Automation skill. Use alongside Chrome MCP network inspection tools.
WCAG 2.1 Compliance Guide
Quick reference for Web Content Accessibility Guidelines 2.1 conformance levels, common violations, and testing techniques.
---
Level A Requirements (Must Have)
These are the minimum accessibility requirements. Failure to meet Level A means the site has critical barriers for users with disabilities.
Perceivable
| Criterion | Requirement | Common Violation |
|---|---|---|
| 1.1.1 Non-text Content | All images, icons, and media have text alternatives | <img> without alt attribute |
| 1.2.1 Audio/Video (Prerecorded) | Provide alternatives for time-based media | Video without transcript |
| 1.2.2 Captions (Prerecorded) | Synchronized captions for video content | Missing <track kind="captions"> |
| 1.2.3 Audio Description | Audio description or text alternative for video | No description of visual-only content |
| 1.3.1 Info and Relationships | Structure conveyed through markup, not just visually | Using <b> instead of <strong>, missing <label> |
| 1.3.2 Meaningful Sequence | Reading order matches visual order | CSS reordering breaks logical flow |
| 1.3.3 Sensory Characteristics | Instructions don't rely solely on shape, size, or location | "Click the round button on the left" |
| 1.4.1 Use of Color | Color is not the only means of conveying information | Red text for errors with no icon or text label |
| 1.4.2 Audio Control | Auto-playing audio can be paused or stopped | Background music with no controls |
Operable
| Criterion | Requirement | Common Violation |
|---|---|---|
| 2.1.1 Keyboard | All functionality available via keyboard | Custom dropdown only works with mouse |
| 2.1.2 No Keyboard Trap | Users can navigate away from all components | Modal with no keyboard escape |
| 2.2.1 Timing Adjustable | Users can extend time limits | Session timeout with no warning |
| 2.2.2 Pause, Stop, Hide | Moving content can be paused | Auto-scrolling carousel with no pause |
| 2.3.1 Three Flashes | No content flashes more than 3 times per second | Animated banner exceeds flash threshold |
| 2.4.1 Bypass Blocks | Skip navigation mechanism available | No "skip to content" link |
| 2.4.2 Page Titled | Pages have descriptive titles | Generic <title>Untitled</title> |
| 2.4.3 Focus Order | Focus order matches logical reading order | tabindex values disrupt natural order |
| 2.4.4 Link Purpose | Link purpose determinable from text or context | "Click here" as link text |
Understandable
| Criterion | Requirement | Common Violation |
|---|---|---|
| 3.1.1 Language of Page | lang attribute on <html> | Missing <html lang="en"> |
| 3.2.1 On Focus | No unexpected context change on focus | Page navigates when element receives focus |
| 3.2.2 On Input | No unexpected context change on input | Form submits on dropdown change |
| 3.3.1 Error Identification | Errors are identified and described | Form shows red border but no text |
| 3.3.2 Labels or Instructions | Inputs have labels or instructions | Placeholder-only inputs |
Robust
| Criterion | Requirement | Common Violation |
|---|---|---|
| 4.1.1 Parsing | Valid HTML with unique IDs | Duplicate id attributes |
| 4.1.2 Name, Role, Value | Custom components expose name, role, value | Custom checkbox without ARIA attributes |
---
Level AA Requirements (Should Have)
Level AA is the standard target for most organizations and is required by many accessibility laws (ADA, Section 508, EN 301 549).
| Criterion | Requirement | Common Violation |
|---|---|---|
| 1.3.4 Orientation | Content not restricted to single display orientation | Landscape-only app |
| 1.3.5 Identify Input Purpose | Input purpose identifiable via autocomplete | Missing autocomplete="email" on email fields |
| 1.4.3 Contrast (Minimum) | 4.5:1 ratio for normal text, 3:1 for large text (18pt+) | Light gray text on white background |
| 1.4.4 Resize Text | Text resizable to 200% without loss of function | Fixed-size containers clip enlarged text |
| 1.4.5 Images of Text | Use real text instead of images of text | Logo text as image without alt |
| 1.4.10 Reflow | Content reflows at 320px width without horizontal scroll | Fixed-width layouts |
| 1.4.11 Non-text Contrast | 3:1 ratio for UI components and graphics | Low-contrast form borders |
| 1.4.12 Text Spacing | Content usable with increased text spacing | Overflow hidden clips spaced text |
| 1.4.13 Content on Hover/Focus | Hover/focus content dismissible and persistent | Tooltip disappears when hovering over it |
| 2.4.5 Multiple Ways | Multiple ways to find pages (search, sitemap, nav) | Single navigation method only |
| 2.4.6 Headings and Labels | Headings and labels describe topic or purpose | Vague headings like "Section 1" |
| 2.4.7 Focus Visible | Keyboard focus indicator is visible | outline: none with no replacement |
| 3.1.2 Language of Parts | Language changes marked in HTML | French quote in English page without lang="fr" |
| 3.2.3 Consistent Navigation | Navigation consistent across pages | Nav items reorder between pages |
| 3.2.4 Consistent Identification | Same function has same label everywhere | "Search" on one page, "Find" on another |
| 3.3.3 Error Suggestion | Error messages suggest corrections | "Invalid input" with no hint |
| 3.3.4 Error Prevention (Legal) | Reversible/confirmable for legal/financial | One-click purchase with no confirmation |
| 4.1.3 Status Messages | Status messages announced to assistive tech | Toast notification not in ARIA live region |
---
Level AAA Requirements (Nice to Have)
Level AAA conformance is aspirational. Full AAA compliance is not typically feasible for entire sites, but individual criteria can be targeted.
| Criterion | Requirement |
|---|---|
| 1.2.6 Sign Language | Sign language interpretation for audio |
| 1.2.7 Extended Audio Description | Extended audio description for video |
| 1.2.8 Media Alternative | Full text alternative for synchronized media |
| 1.2.9 Audio-only (Live) | Alternative for live audio |
| 1.4.6 Contrast (Enhanced) | 7:1 ratio for normal text, 4.5:1 for large text |
| 1.4.7 Low or No Background Audio | Speech audio has minimal background noise |
| 1.4.8 Visual Presentation | Customizable text presentation (width, spacing, alignment) |
| 1.4.9 Images of Text (No Exception) | Images of text only for pure decoration |
| 2.1.3 Keyboard (No Exception) | All functionality keyboard-operable, no exceptions |
| 2.2.3 No Timing | No time limits at all |
| 2.2.4 Interruptions | User can postpone/suppress interruptions |
| 2.2.5 Re-authenticating | Data preserved after re-authentication |
| 2.3.2 Three Flashes | No content flashes at all |
| 2.4.8 Location | Breadcrumb or indication of location within site |
| 2.4.9 Link Purpose (Link Only) | Link purpose determinable from link text alone |
| 2.4.10 Section Headings | Content organized with headings |
| 3.1.3-3.1.6 | Unusual words, abbreviations, pronunciation, reading level |
| 3.2.5 Change on Request | Context changes only on user request |
| 3.3.5 Help | Context-sensitive help available |
| 3.3.6 Error Prevention (All) | Reversible/confirmable for all user input |
---
Common Violations and Quick Fixes
Missing Alt Text
<!-- Violation -->
<img src="hero.jpg">
<!-- Fix: descriptive alt -->
<img src="hero.jpg" alt="Team collaborating around a whiteboard">
<!-- Fix: decorative image -->
<img src="divider.png" alt="" role="presentation">Missing Form Labels
<!-- Violation -->
<input type="email" placeholder="Email">
<!-- Fix -->
<label for="email">Email address</label>
<input type="email" id="email" placeholder="user@example.com">Low Color Contrast
/* Violation: 2.5:1 ratio */
color: #999999;
background: #ffffff;
/* Fix: 4.6:1 ratio (meets AA) */
color: #767676;
background: #ffffff;
/* Fix: 7.1:1 ratio (meets AAA) */
color: #595959;
background: #ffffff;Missing Focus Indicators
/* Violation */
*:focus { outline: none; }
/* Fix */
*:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}Missing Skip Navigation
<!-- Add as first element in body -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- ... header/nav ... -->
<main id="main-content">---
Testing Techniques
Keyboard Navigation Test
1. Start at the top of the page, press Tab repeatedly. 2. Verify every interactive element receives focus in a logical order. 3. Verify focus is visible on every focused element. 4. Test Enter/Space to activate buttons and links. 5. Test Escape to close modals and popups. 6. Verify no keyboard traps (can always Tab away).
Screen Reader Quick Check
1. Navigate by headings (H key in NVDA/VoiceOver) — verify logical hierarchy. 2. Navigate by landmarks (D key) — verify main, nav, banner regions exist. 3. Navigate by form elements — verify all inputs are labeled. 4. Navigate by links — verify link text makes sense out of context.
Contrast Ratio Check
- Normal text (<18pt / <14pt bold): minimum 4.5:1 (AA), 7:1 (AAA)
- Large text (>=18pt / >=14pt bold): minimum 3:1 (AA), 4.5:1 (AAA)
- UI components and graphics: minimum 3:1 (AA)
Zoom/Reflow Test
1. Zoom browser to 200% — verify no content loss or horizontal scroll. 2. Set viewport to 320px width — verify content reflows properly. 3. Increase text spacing (letter-spacing: 0.12em, word-spacing: 0.16em, line-height: 1.5) — verify no clipping.
---
Reference for the QA Browser Automation skill. Use with accessibility_auditor.py for automated checks.
#!/usr/bin/env python3
"""Accessibility Auditor — Analyzes HTML for WCAG 2.1 violations.
Checks HTML content against WCAG 2.1 conformance levels A, AA, and AAA.
Detects missing alt text, heading hierarchy issues, color contrast problems,
form label associations, ARIA attribute usage, link text quality, and more.
Usage:
python accessibility_auditor.py page.html
python accessibility_auditor.py page.html --level AAA --json
curl -s https://example.com | python accessibility_auditor.py - --level A
"""
from __future__ import annotations
import argparse
import html.parser
import json
import math
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# Data Structures
# ---------------------------------------------------------------------------
WCAG_LEVELS = ("A", "AA", "AAA")
@dataclass
class Violation:
"""A single WCAG violation."""
rule_id: str
wcag_criterion: str
level: str # A, AA, AAA
severity: str # must-fix, should-fix, nice-to-have
message: str
element: str
selector_hint: str
remediation: str
def to_dict(self) -> dict[str, str]:
return {
"rule_id": self.rule_id,
"wcag_criterion": self.wcag_criterion,
"level": self.level,
"severity": self.severity,
"message": self.message,
"element": self.element,
"selector_hint": self.selector_hint,
"remediation": self.remediation,
}
@dataclass
class AuditResult:
"""Complete audit result."""
total_elements_checked: int = 0
violations: list[Violation] = field(default_factory=list)
level_checked: str = "AA"
summary: dict[str, int] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
level_counts = {"A": 0, "AA": 0, "AAA": 0}
severity_counts = {"must-fix": 0, "should-fix": 0, "nice-to-have": 0}
for v in self.violations:
level_counts[v.level] = level_counts.get(v.level, 0) + 1
severity_counts[v.severity] = severity_counts.get(v.severity, 0) + 1
total = self.total_elements_checked
violation_count = len(self.violations)
compliance_pct = round((1 - violation_count / max(total, 1)) * 100, 1)
return {
"level_checked": self.level_checked,
"total_elements_checked": total,
"total_violations": violation_count,
"compliance_percentage": compliance_pct,
"by_level": level_counts,
"by_severity": severity_counts,
"violations": [v.to_dict() for v in self.violations],
}
# ---------------------------------------------------------------------------
# HTML Parser
# ---------------------------------------------------------------------------
@dataclass
class ParsedElement:
"""A parsed HTML element with its attributes."""
tag: str
attrs: dict[str, str]
text_content: str = ""
line: int = 0
children_tags: list[str] = field(default_factory=list)
class HTMLStructureParser(html.parser.HTMLParser):
"""Lightweight HTML parser that extracts elements for accessibility checks."""
def __init__(self) -> None:
super().__init__()
self.elements: list[ParsedElement] = []
self._stack: list[ParsedElement] = []
self._current_text: list[str] = []
self.has_lang: bool = False
self.has_title: bool = False
self.has_doctype: bool = False
self.heading_order: list[tuple[int, int]] = [] # (level, line)
self.id_counts: dict[str, int] = {}
self.label_for_ids: set[str] = set()
self.input_ids: set[str] = set()
self.form_inputs: list[ParsedElement] = []
self.links: list[ParsedElement] = []
self.images: list[ParsedElement] = []
self.media_elements: list[ParsedElement] = []
self.landmark_roles: list[str] = []
self.all_tags: list[str] = []
def handle_decl(self, decl: str) -> None:
if decl.lower().startswith("doctype"):
self.has_doctype = True
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
attr_dict = {k: (v or "") for k, v in attrs}
line = self.getpos()[0]
elem = ParsedElement(tag=tag, attrs=attr_dict, line=line)
self.all_tags.append(tag)
# Track parent-child
if self._stack:
self._stack[-1].children_tags.append(tag)
self._stack.append(elem)
self._current_text = []
# Track html lang
if tag == "html" and "lang" in attr_dict and attr_dict["lang"].strip():
self.has_lang = True
# Track title
if tag == "title":
self.has_title = True
# Track headings
if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
level = int(tag[1])
self.heading_order.append((level, line))
# Track IDs
elem_id = attr_dict.get("id", "").strip()
if elem_id:
self.id_counts[elem_id] = self.id_counts.get(elem_id, 0) + 1
# Track labels
if tag == "label" and "for" in attr_dict:
self.label_for_ids.add(attr_dict["for"])
# Track form inputs
if tag in ("input", "textarea", "select"):
self.form_inputs.append(elem)
if elem_id:
self.input_ids.add(elem_id)
# Track links
if tag == "a":
self.links.append(elem)
# Track images
if tag == "img":
self.images.append(elem)
# Track media
if tag in ("video", "audio"):
self.media_elements.append(elem)
# Track landmarks
role = attr_dict.get("role", "")
if role in ("banner", "navigation", "main", "contentinfo", "complementary", "search"):
self.landmark_roles.append(role)
if tag in ("header", "nav", "main", "footer", "aside"):
self.landmark_roles.append(tag)
def handle_data(self, data: str) -> None:
self._current_text.append(data)
def handle_endtag(self, tag: str) -> None:
if self._stack and self._stack[-1].tag == tag:
elem = self._stack.pop()
elem.text_content = " ".join(self._current_text).strip()
self.elements.append(elem)
self._current_text = []
# Update link/image text
if tag == "a" and self.links and self.links[-1] is elem:
pass # text_content already set
else:
self._current_text = []
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self.handle_starttag(tag, attrs)
self.handle_endtag(tag)
# ---------------------------------------------------------------------------
# WCAG Checks
# ---------------------------------------------------------------------------
def check_images_alt_text(parser: HTMLStructureParser) -> list[Violation]:
"""WCAG 1.1.1 (A): All images must have alt text."""
violations: list[Violation] = []
for img in parser.images:
alt = img.attrs.get("alt")
role = img.attrs.get("role", "")
if alt is None and role != "presentation":
src = img.attrs.get("src", "unknown")
violations.append(Violation(
rule_id="img-alt",
wcag_criterion="1.1.1 Non-text Content",
level="A",
severity="must-fix",
message="Image missing alt attribute",
element=f'<img src="{src}">',
selector_hint=f'img[src="{src}"]' if len(src) < 80 else f"img (line {img.line})",
remediation="Add alt attribute describing the image content, or alt=\"\" for decorative images",
))
elif alt is not None and alt.strip() == "" and role != "presentation":
# Empty alt is valid for decorative images, but flag if no role=presentation
aria_hidden = img.attrs.get("aria-hidden", "")
if aria_hidden != "true":
src = img.attrs.get("src", "unknown")
# This is actually acceptable per spec, so we don't flag it
return violations
def check_page_language(parser: HTMLStructureParser) -> list[Violation]:
"""WCAG 3.1.1 (A): Page must have a lang attribute."""
if not parser.has_lang:
return [Violation(
rule_id="html-lang",
wcag_criterion="3.1.1 Language of Page",
level="A",
severity="must-fix",
message="HTML element missing lang attribute",
element="<html>",
selector_hint="html",
remediation='Add lang attribute to html element, e.g., <html lang="en">',
)]
return []
def check_page_title(parser: HTMLStructureParser) -> list[Violation]:
"""WCAG 2.4.2 (A): Page must have a title."""
if not parser.has_title:
return [Violation(
rule_id="page-title",
wcag_criterion="2.4.2 Page Titled",
level="A",
severity="must-fix",
message="Page missing <title> element",
element="<head>",
selector_hint="head",
remediation="Add a descriptive <title> element inside <head>",
)]
return []
def check_heading_hierarchy(parser: HTMLStructureParser) -> list[Violation]:
"""WCAG 1.3.1 (A) + 2.4.6 (AA): Headings should follow logical order."""
violations: list[Violation] = []
if not parser.heading_order:
return violations
# Check for skipped levels
prev_level = 0
for level, line in parser.heading_order:
if prev_level > 0 and level > prev_level + 1:
violations.append(Violation(
rule_id="heading-order",
wcag_criterion="1.3.1 Info and Relationships",
level="A",
severity="must-fix",
message=f"Heading level skipped: h{prev_level} to h{level}",
element=f"<h{level}>",
selector_hint=f"h{level} (line {line})",
remediation=f"Use h{prev_level + 1} instead of h{level}, or add missing intermediate headings",
))
prev_level = level
# Check first heading is h1
if parser.heading_order and parser.heading_order[0][0] != 1:
first_level = parser.heading_order[0][0]
violations.append(Violation(
rule_id="heading-first-h1",
wcag_criterion="2.4.6 Headings and Labels",
level="AA",
severity="should-fix",
message=f"First heading is h{first_level}, expected h1",
element=f"<h{first_level}>",
selector_hint=f"h{first_level} (line {parser.heading_order[0][1]})",
remediation="Start the page heading hierarchy with an h1 element",
))
return violations
def check_duplicate_ids(parser: HTMLStructureParser) -> list[Violation]:
"""WCAG 4.1.1 (A): IDs must be unique."""
violations: list[Violation] = []
for id_val, count in parser.id_counts.items():
if count > 1:
violations.append(Violation(
rule_id="duplicate-id",
wcag_criterion="4.1.1 Parsing",
level="A",
severity="must-fix",
message=f'Duplicate id="{id_val}" found {count} times',
element=f'id="{id_val}"',
selector_hint=f'[id="{id_val}"]',
remediation="Ensure all id attribute values are unique within the page",
))
return violations
def check_form_labels(parser: HTMLStructureParser) -> list[Violation]:
"""WCAG 1.3.1 + 4.1.2 (A): Form inputs must have labels."""
violations: list[Violation] = []
for inp in parser.form_inputs:
input_type = inp.attrs.get("type", "text")
if input_type in ("hidden", "submit", "button", "reset", "image"):
continue
has_label = False
input_id = inp.attrs.get("id", "")
if input_id and input_id in parser.label_for_ids:
has_label = True
if inp.attrs.get("aria-label", "").strip():
has_label = True
if inp.attrs.get("aria-labelledby", "").strip():
has_label = True
if inp.attrs.get("title", "").strip():
has_label = True
if inp.attrs.get("placeholder", "").strip():
# Placeholder alone is not sufficient per WCAG, but we soften severity
pass
if not has_label:
name = inp.attrs.get("name", inp.attrs.get("id", "unknown"))
violations.append(Violation(
rule_id="form-label",
wcag_criterion="4.1.2 Name, Role, Value",
level="A",
severity="must-fix",
message=f"Form input missing accessible label: {inp.tag}[name={name}]",
element=f"<{inp.tag} name=\"{name}\">",
selector_hint=f'{inp.tag}[name="{name}"]' if name != "unknown" else f"{inp.tag} (line {inp.line})",
remediation="Add a <label for=\"...\"> element, aria-label, or aria-labelledby attribute",
))
return violations
def check_link_text_quality(parser: HTMLStructureParser) -> list[Violation]:
"""WCAG 2.4.4 (A) + 2.4.9 (AAA): Links must have descriptive text."""
violations: list[Violation] = []
generic_texts = {"click here", "here", "read more", "more", "link", "this"}
for link in parser.links:
href = link.attrs.get("href", "")
text = link.text_content.strip().lower()
aria_label = link.attrs.get("aria-label", "").strip()
effective_text = aria_label or text
if not effective_text:
# Check for child images with alt
if not link.children_tags or "img" not in link.children_tags:
violations.append(Violation(
rule_id="link-name",
wcag_criterion="2.4.4 Link Purpose (In Context)",
level="A",
severity="must-fix",
message="Link has no accessible text",
element=f'<a href="{href[:60]}">',
selector_hint=f'a[href="{href[:60]}"]',
remediation="Add descriptive link text or aria-label attribute",
))
elif effective_text in generic_texts:
violations.append(Violation(
rule_id="link-text-generic",
wcag_criterion="2.4.9 Link Purpose (Link Only)",
level="AAA",
severity="nice-to-have",
message=f'Link text "{effective_text}" is too generic',
element=f'<a href="{href[:60]}">{effective_text}</a>',
selector_hint=f'a[href="{href[:60]}"]',
remediation="Use descriptive link text that makes sense out of context",
))
return violations
def check_color_contrast_hints(parser: HTMLStructureParser) -> list[Violation]:
"""WCAG 1.4.3 (AA) + 1.4.6 (AAA): Flag elements that commonly have contrast issues.
Note: Full contrast checking requires computed styles which we cannot get
from static HTML alone. This check flags patterns that commonly indicate
contrast problems and recommends manual verification.
"""
violations: list[Violation] = []
contrast_risk_patterns = []
for elem in parser.elements:
style = elem.attrs.get("style", "")
if not style:
continue
# Check for inline color declarations that may have low contrast
has_color = "color:" in style.lower() and "background" not in style.lower()
has_bg = "background" in style.lower()
if has_color and not has_bg:
contrast_risk_patterns.append(elem)
elif has_bg and not has_color:
contrast_risk_patterns.append(elem)
if contrast_risk_patterns:
violations.append(Violation(
rule_id="color-contrast-review",
wcag_criterion="1.4.3 Contrast (Minimum)",
level="AA",
severity="should-fix",
message=f"{len(contrast_risk_patterns)} element(s) have inline color styles — verify contrast ratio >= 4.5:1 for text, >= 3:1 for large text",
element="(multiple elements with inline styles)",
selector_hint="[style*='color']",
remediation="Verify color contrast ratios meet WCAG AA (4.5:1 normal, 3:1 large) or AAA (7:1 normal, 4.5:1 large)",
))
return violations
def check_media_alternatives(parser: HTMLStructureParser) -> list[Violation]:
"""WCAG 1.2.1-1.2.5 (A/AA): Media must have alternatives."""
violations: list[Violation] = []
for media in parser.media_elements:
has_track = "track" in [c for c in media.children_tags]
autoplay = "autoplay" in media.attrs
if not has_track:
violations.append(Violation(
rule_id="media-captions",
wcag_criterion="1.2.2 Captions (Prerecorded)",
level="A",
severity="must-fix",
message=f"<{media.tag}> element missing captions/subtitles track",
element=f"<{media.tag}> (line {media.line})",
selector_hint=f"{media.tag} (line {media.line})",
remediation="Add a <track kind=\"captions\"> element for synchronized captions",
))
if autoplay:
violations.append(Violation(
rule_id="media-autoplay",
wcag_criterion="1.4.2 Audio Control",
level="A",
severity="must-fix",
message=f"<{media.tag}> has autoplay attribute",
element=f"<{media.tag} autoplay> (line {media.line})",
selector_hint=f"{media.tag}[autoplay]",
remediation="Remove autoplay or ensure the media is muted and user can pause/stop it",
))
return violations
def check_landmark_regions(parser: HTMLStructureParser) -> list[Violation]:
"""WCAG 1.3.1 (A): Page should use landmark regions."""
violations: list[Violation] = []
has_main = "main" in parser.landmark_roles
has_nav = "nav" in parser.landmark_roles or "navigation" in parser.landmark_roles
if not has_main and len(parser.all_tags) > 10:
violations.append(Violation(
rule_id="landmark-main",
wcag_criterion="1.3.1 Info and Relationships",
level="A",
severity="must-fix",
message="Page missing <main> landmark region",
element="<body>",
selector_hint="body",
remediation="Wrap the primary content in a <main> element or add role=\"main\"",
))
if not has_nav and len(parser.links) > 5:
violations.append(Violation(
rule_id="landmark-nav",
wcag_criterion="1.3.1 Info and Relationships",
level="A",
severity="should-fix",
message="Page has multiple links but no <nav> landmark",
element="<body>",
selector_hint="body",
remediation="Wrap navigation links in a <nav> element",
))
return violations
def check_focus_indicators(parser: HTMLStructureParser) -> list[Violation]:
"""WCAG 2.4.7 (AA): Check for outline:none or outline:0 that removes focus indicators."""
violations: list[Violation] = []
for elem in parser.elements:
style = elem.attrs.get("style", "")
if re.search(r"outline\s*:\s*(none|0)\b", style, re.IGNORECASE):
violations.append(Violation(
rule_id="focus-visible",
wcag_criterion="2.4.7 Focus Visible",
level="AA",
severity="should-fix",
message="Element removes focus indicator with outline:none",
element=f"<{elem.tag}> (line {elem.line})",
selector_hint=f"{elem.tag} (line {elem.line})",
remediation="Remove outline:none or provide a custom focus indicator with :focus-visible",
))
return violations
def check_tabindex_misuse(parser: HTMLStructureParser) -> list[Violation]:
"""WCAG 2.4.3 (A): tabindex > 0 disrupts natural tab order."""
violations: list[Violation] = []
for elem in parser.elements:
tabindex = elem.attrs.get("tabindex", "")
if tabindex:
try:
val = int(tabindex)
if val > 0:
violations.append(Violation(
rule_id="tabindex-positive",
wcag_criterion="2.4.3 Focus Order",
level="A",
severity="must-fix",
message=f"Element has tabindex={val} which disrupts natural focus order",
element=f"<{elem.tag} tabindex=\"{val}\"> (line {elem.line})",
selector_hint=f"[tabindex=\"{val}\"]",
remediation="Use tabindex=\"0\" or tabindex=\"-1\" instead; restructure DOM for desired tab order",
))
except ValueError:
pass
return violations
# ---------------------------------------------------------------------------
# Audit Runner
# ---------------------------------------------------------------------------
ALL_CHECKS_A = [
check_images_alt_text,
check_page_language,
check_page_title,
check_heading_hierarchy,
check_duplicate_ids,
check_form_labels,
check_link_text_quality,
check_media_alternatives,
check_landmark_regions,
check_tabindex_misuse,
]
ALL_CHECKS_AA = [
check_color_contrast_hints,
check_focus_indicators,
]
ALL_CHECKS_AAA = [
# Link text quality (AAA subset) is already included in the A check
# with level=AAA on certain violations.
]
def run_audit(html_content: str, level: str = "AA") -> AuditResult:
"""Run all applicable checks against the HTML content."""
parser = HTMLStructureParser()
try:
parser.feed(html_content)
except Exception as exc:
return AuditResult(violations=[Violation(
rule_id="parse-error",
wcag_criterion="N/A",
level="A",
severity="must-fix",
message=f"Failed to parse HTML: {exc}",
element="(document)",
selector_hint="(document)",
remediation="Ensure the HTML is well-formed",
)])
checks = list(ALL_CHECKS_A)
if level in ("AA", "AAA"):
checks.extend(ALL_CHECKS_AA)
if level == "AAA":
checks.extend(ALL_CHECKS_AAA)
all_violations: list[Violation] = []
for check_fn in checks:
violations = check_fn(parser)
all_violations.extend(violations)
# Filter violations by level
level_index = WCAG_LEVELS.index(level)
filtered = [
v for v in all_violations
if WCAG_LEVELS.index(v.level) <= level_index
]
total_elements = (
len(parser.images) + len(parser.links) + len(parser.form_inputs)
+ len(parser.media_elements) + len(parser.heading_order)
+ len(parser.elements) + 3 # +3 for page-level checks
)
result = AuditResult(
total_elements_checked=total_elements,
violations=filtered,
level_checked=level,
)
return result
# ---------------------------------------------------------------------------
# Output Formatting
# ---------------------------------------------------------------------------
def format_human_readable(result: AuditResult) -> str:
"""Format audit result as human-readable text."""
lines: list[str] = []
data = result.to_dict()
lines.append("=" * 60)
lines.append(" ACCESSIBILITY AUDIT REPORT")
lines.append("=" * 60)
lines.append("")
lines.append(f" Level checked: WCAG 2.1 {data['level_checked']}")
lines.append(f" Elements checked: {data['total_elements_checked']}")
lines.append(f" Total violations: {data['total_violations']}")
lines.append(f" Compliance: {data['compliance_percentage']}%")
lines.append("")
# By level
lines.append("-" * 60)
lines.append(" VIOLATIONS BY LEVEL")
lines.append("-" * 60)
for lvl in WCAG_LEVELS:
count = data["by_level"].get(lvl, 0)
lines.append(f" Level {lvl}: {count}")
lines.append("")
# By severity
lines.append("-" * 60)
lines.append(" VIOLATIONS BY SEVERITY")
lines.append("-" * 60)
for sev in ("must-fix", "should-fix", "nice-to-have"):
count = data["by_severity"].get(sev, 0)
lines.append(f" {sev}: {count}")
lines.append("")
# Individual violations
if result.violations:
lines.append("-" * 60)
lines.append(" VIOLATIONS")
lines.append("-" * 60)
for i, v in enumerate(result.violations, 1):
lines.append(f" [{i}] {v.rule_id} ({v.level} / {v.severity})")
lines.append(f" WCAG: {v.wcag_criterion}")
lines.append(f" Issue: {v.message}")
lines.append(f" Element: {v.element}")
lines.append(f" Fix: {v.remediation}")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)
def format_json_output(result: AuditResult) -> str:
"""Format audit result as JSON."""
return json.dumps(result.to_dict(), indent=2)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
"""Build the argument parser."""
parser = argparse.ArgumentParser(
prog="accessibility_auditor",
description="Audit HTML for WCAG 2.1 accessibility violations.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Examples:\n"
" python accessibility_auditor.py page.html\n"
" python accessibility_auditor.py page.html --level AAA --json\n"
" curl -s https://example.com | python accessibility_auditor.py -\n"
),
)
parser.add_argument(
"html_file",
help="Path to HTML file (use '-' for stdin)",
)
parser.add_argument(
"--level",
choices=WCAG_LEVELS,
default="AA",
help="WCAG conformance level to check (default: AA)",
)
parser.add_argument(
"--json",
action="store_true",
dest="json_output",
help="Output results as JSON",
)
return parser
def main() -> None:
"""Entry point."""
parser = build_parser()
args = parser.parse_args()
# Read HTML
if args.html_file == "-":
html_content = sys.stdin.read()
else:
path = Path(args.html_file)
if not path.exists():
print(f"Error: File not found: {args.html_file}", file=sys.stderr)
sys.exit(1)
html_content = path.read_text(encoding="utf-8")
# Run audit
result = run_audit(html_content, level=args.level)
# Output
if args.json_output:
print(format_json_output(result))
else:
print(format_human_readable(result))
# Exit code: non-zero if must-fix violations exist
must_fix_count = sum(1 for v in result.violations if v.severity == "must-fix")
if must_fix_count > 0:
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""QA Health Scorer — Computes a weighted health score (0-100) from QA findings.
Uses a 10-category weighted system with severity-based deductions to produce
an overall quality grade (A-F). Supports trend tracking against previous
baselines and machine-readable JSON output for CI integration.
Usage:
python qa_health_scorer.py findings.json
python qa_health_scorer.py findings.json --json
python qa_health_scorer.py findings.json --baseline previous.json
python qa_health_scorer.py findings.json --threshold 80
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
CATEGORY_WEIGHTS: dict[str, float] = {
"console_errors": 0.12,
"broken_links": 0.08,
"visual_consistency": 0.10,
"functional": 0.18,
"ux_flow": 0.12,
"performance": 0.12,
"content_quality": 0.05,
"accessibility": 0.13,
"security_headers": 0.05,
"mobile_responsive": 0.05,
}
SEVERITY_DEDUCTIONS: dict[str, int] = {
"P0": 30,
"P1": 18,
"P2": 10,
"P3": 4,
"P4": 1,
}
GRADE_THRESHOLDS: list[tuple[int, str]] = [
(90, "A"),
(80, "B"),
(70, "C"),
(60, "D"),
(0, "F"),
]
CATEGORY_DISPLAY_NAMES: dict[str, str] = {
"console_errors": "Console Errors",
"broken_links": "Broken Links",
"visual_consistency": "Visual Consistency",
"functional": "Functional",
"ux_flow": "UX Flow",
"performance": "Performance",
"content_quality": "Content Quality",
"accessibility": "Accessibility",
"security_headers": "Security Headers",
"mobile_responsive": "Mobile Responsive",
}
# ---------------------------------------------------------------------------
# Scoring Logic
# ---------------------------------------------------------------------------
def compute_category_scores(findings: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Compute per-category scores from a list of findings.
Each finding must have at minimum:
- severity: P0-P4
- category: one of the 10 category keys
Returns a dict keyed by category with score, deductions, and finding counts.
"""
category_deductions: dict[str, float] = {cat: 0.0 for cat in CATEGORY_WEIGHTS}
category_counts: dict[str, dict[str, int]] = {
cat: {sev: 0 for sev in SEVERITY_DEDUCTIONS} for cat in CATEGORY_WEIGHTS
}
for finding in findings:
severity = finding.get("severity", "P3")
category = finding.get("category", "functional")
if severity not in SEVERITY_DEDUCTIONS:
severity = "P3"
if category not in CATEGORY_WEIGHTS:
category = "functional"
deduction = SEVERITY_DEDUCTIONS[severity]
weight = CATEGORY_WEIGHTS[category]
# Scale deduction by category weight so a P0 in a low-weight category
# doesn't disproportionately crush the overall score.
weighted_deduction = deduction * weight
category_deductions[category] += weighted_deduction
category_counts[category][severity] += 1
results: dict[str, dict[str, Any]] = {}
for cat, weight in CATEGORY_WEIGHTS.items():
max_points = weight * 100
raw_score = max(0.0, max_points - category_deductions[cat])
pct = (raw_score / max_points * 100) if max_points > 0 else 100.0
results[cat] = {
"weight": weight,
"max_points": round(max_points, 2),
"deductions": round(category_deductions[cat], 2),
"score_points": round(raw_score, 2),
"score_pct": round(pct, 1),
"finding_counts": category_counts[cat],
"total_findings": sum(category_counts[cat].values()),
}
return results
def compute_overall_score(category_scores: dict[str, dict[str, Any]]) -> float:
"""Sum weighted category scores into an overall 0-100 score."""
total = sum(cs["score_points"] for cs in category_scores.values())
return round(max(0.0, min(100.0, total)), 1)
def score_to_grade(score: float) -> str:
"""Convert a numeric score to a letter grade."""
for threshold, grade in GRADE_THRESHOLDS:
if score >= threshold:
return grade
return "F"
def summarize_findings(findings: list[dict[str, Any]]) -> dict[str, int]:
"""Count findings by severity."""
counts: dict[str, int] = {sev: 0 for sev in SEVERITY_DEDUCTIONS}
for f in findings:
sev = f.get("severity", "P3")
if sev in counts:
counts[sev] += 1
else:
counts["P3"] += 1
return counts
# ---------------------------------------------------------------------------
# Trend Tracking
# ---------------------------------------------------------------------------
def compute_trend(current_score: float, baseline_path: str | None) -> dict[str, Any] | None:
"""Compare current score against a previous baseline if provided."""
if baseline_path is None:
return None
path = Path(baseline_path)
if not path.exists():
return {"error": f"Baseline file not found: {baseline_path}"}
try:
with open(path, "r", encoding="utf-8") as f:
baseline = json.load(f)
except (json.JSONDecodeError, OSError) as exc:
return {"error": f"Failed to read baseline: {exc}"}
prev_score = baseline.get("overall_score", baseline.get("score", 0))
delta = round(current_score - prev_score, 1)
direction = "improved" if delta > 0 else "declined" if delta < 0 else "unchanged"
return {
"previous_score": prev_score,
"current_score": current_score,
"delta": delta,
"direction": direction,
"baseline_file": str(path),
}
# ---------------------------------------------------------------------------
# Output Formatting
# ---------------------------------------------------------------------------
def format_human_readable(
overall_score: float,
grade: str,
category_scores: dict[str, dict[str, Any]],
severity_summary: dict[str, int],
trend: dict[str, Any] | None,
threshold: int,
) -> str:
"""Format results as a human-readable text report."""
lines: list[str] = []
lines.append("=" * 60)
lines.append(" QA HEALTH SCORE REPORT")
lines.append("=" * 60)
lines.append("")
# Overall score
pass_fail = "PASS" if overall_score >= threshold else "FAIL"
lines.append(f" Overall Score: {overall_score}/100 (Grade: {grade}) [{pass_fail}]")
lines.append(f" Threshold: {threshold}")
lines.append(f" Timestamp: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}")
lines.append("")
# Trend
if trend and "error" not in trend:
arrow = "^" if trend["delta"] > 0 else "v" if trend["delta"] < 0 else "="
lines.append(f" Trend: {trend['direction']} ({arrow} {abs(trend['delta'])} pts from {trend['previous_score']})")
lines.append("")
# Severity summary
lines.append("-" * 60)
lines.append(" FINDINGS BY SEVERITY")
lines.append("-" * 60)
total_findings = sum(severity_summary.values())
lines.append(f" Total findings: {total_findings}")
for sev in ["P0", "P1", "P2", "P3", "P4"]:
count = severity_summary.get(sev, 0)
deduction = SEVERITY_DEDUCTIONS[sev]
marker = " !!!" if sev == "P0" and count > 0 else ""
lines.append(f" {sev} ({'-' + str(deduction)} pts each): {count}{marker}")
lines.append("")
# Category breakdown
lines.append("-" * 60)
lines.append(" CATEGORY BREAKDOWN")
lines.append("-" * 60)
header = f" {'Category':<22} {'Weight':>6} {'Score':>7} {'Findings':>8}"
lines.append(header)
lines.append(" " + "-" * 46)
for cat, data in category_scores.items():
name = CATEGORY_DISPLAY_NAMES.get(cat, cat)
weight_str = f"{int(data['weight'] * 100)}%"
score_str = f"{data['score_pct']:.0f}%"
findings_str = str(data["total_findings"])
lines.append(f" {name:<22} {weight_str:>6} {score_str:>7} {findings_str:>8}")
lines.append("")
lines.append("=" * 60)
# Recommendations
critical_categories = [
(cat, data) for cat, data in category_scores.items()
if data["score_pct"] < 70
]
if critical_categories:
lines.append(" PRIORITY AREAS")
lines.append("-" * 60)
for cat, data in sorted(critical_categories, key=lambda x: x[1]["score_pct"]):
name = CATEGORY_DISPLAY_NAMES.get(cat, cat)
lines.append(f" - {name}: {data['score_pct']:.0f}% ({data['total_findings']} findings)")
lines.append("")
return "\n".join(lines)
def format_json_output(
overall_score: float,
grade: str,
category_scores: dict[str, dict[str, Any]],
severity_summary: dict[str, int],
trend: dict[str, Any] | None,
threshold: int,
) -> str:
"""Format results as JSON for machine consumption."""
result: dict[str, Any] = {
"overall_score": overall_score,
"grade": grade,
"passed": overall_score >= threshold,
"threshold": threshold,
"timestamp": datetime.now(timezone.utc).isoformat(),
"severity_summary": severity_summary,
"total_findings": sum(severity_summary.values()),
"categories": {},
}
for cat, data in category_scores.items():
result["categories"][cat] = {
"display_name": CATEGORY_DISPLAY_NAMES.get(cat, cat),
"weight": data["weight"],
"score_pct": data["score_pct"],
"deductions": data["deductions"],
"total_findings": data["total_findings"],
"finding_counts": data["finding_counts"],
}
if trend is not None:
result["trend"] = trend
return json.dumps(result, indent=2)
# ---------------------------------------------------------------------------
# Input Handling
# ---------------------------------------------------------------------------
def load_findings(filepath: str) -> list[dict[str, Any]]:
"""Load findings from a JSON file.
Accepts either a JSON array of findings directly, or an object with a
'findings' key containing the array.
"""
path = Path(filepath)
if not path.exists():
print(f"Error: File not found: {filepath}", file=sys.stderr)
sys.exit(1)
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError as exc:
print(f"Error: Invalid JSON in {filepath}: {exc}", file=sys.stderr)
sys.exit(1)
if isinstance(data, list):
return data
if isinstance(data, dict):
if "findings" in data:
return data["findings"]
# Single finding object
return [data]
print(f"Error: Unexpected JSON structure in {filepath}", file=sys.stderr)
sys.exit(1)
def validate_findings(findings: list[dict[str, Any]]) -> list[str]:
"""Validate findings and return a list of warnings."""
warnings: list[str] = []
valid_severities = set(SEVERITY_DEDUCTIONS.keys())
valid_categories = set(CATEGORY_WEIGHTS.keys())
for i, finding in enumerate(findings):
if not isinstance(finding, dict):
warnings.append(f"Finding #{i}: not a dict, skipping")
continue
sev = finding.get("severity")
if sev and sev not in valid_severities:
warnings.append(f"Finding #{i}: unknown severity '{sev}', defaulting to P3")
cat = finding.get("category")
if cat and cat not in valid_categories:
warnings.append(f"Finding #{i}: unknown category '{cat}', defaulting to 'functional'")
return warnings
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
"""Build the argument parser."""
parser = argparse.ArgumentParser(
prog="qa_health_scorer",
description="Compute a weighted QA health score (0-100) from findings.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Examples:\n"
" python qa_health_scorer.py findings.json\n"
" python qa_health_scorer.py findings.json --json\n"
" python qa_health_scorer.py findings.json --baseline prev.json --threshold 85\n"
),
)
parser.add_argument(
"findings_file",
help="Path to JSON file containing QA findings",
)
parser.add_argument(
"--json",
action="store_true",
dest="json_output",
help="Output results as JSON",
)
parser.add_argument(
"--baseline",
default=None,
help="Path to previous score JSON for trend comparison",
)
parser.add_argument(
"--threshold",
type=int,
default=70,
help="Minimum passing score (default: 70)",
)
parser.add_argument(
"--save-baseline",
action="store_true",
dest="save_baseline",
help="Save current score to .qa-baselines/{date}.json for future trend comparison",
)
return parser
def main() -> None:
"""Entry point."""
parser = build_parser()
args = parser.parse_args()
# Load and validate
findings = load_findings(args.findings_file)
warnings = validate_findings(findings)
if warnings and not args.json_output:
for w in warnings:
print(f"Warning: {w}", file=sys.stderr)
# Compute scores
category_scores = compute_category_scores(findings)
overall_score = compute_overall_score(category_scores)
grade = score_to_grade(overall_score)
severity_summary = summarize_findings(findings)
trend = compute_trend(overall_score, args.baseline)
# Output
if args.json_output:
print(format_json_output(
overall_score, grade, category_scores,
severity_summary, trend, args.threshold,
))
else:
print(format_human_readable(
overall_score, grade, category_scores,
severity_summary, trend, args.threshold,
))
# Save baseline if requested
if args.save_baseline:
baseline_dir = Path(".qa-baselines")
baseline_dir.mkdir(parents=True, exist_ok=True)
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
baseline_path = baseline_dir / f"{date_str}.json"
baseline_data = {
"score": overall_score,
"grade": grade,
"timestamp": datetime.now(timezone.utc).isoformat(),
"findings_count": sum(severity_summary.values()),
"category_scores": {
cat: {"score_pct": data["score_pct"], "findings": data["total_findings"]}
for cat, data in category_scores.items()
},
}
with open(baseline_path, "w", encoding="utf-8") as f:
json.dump(baseline_data, f, indent=2)
# Also save as "latest" for easy reference
latest_path = baseline_dir / "latest.json"
with open(latest_path, "w", encoding="utf-8") as f:
json.dump(baseline_data, f, indent=2)
if not args.json_output:
print(f"\nBaseline saved to {baseline_path} and {latest_path}")
# Exit code: non-zero if below threshold
if overall_score < args.threshold:
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Test Report Generator — Generates comprehensive QA reports from session data.
Takes QA session data (findings, scores, screenshots, accessibility results,
performance metrics) as JSON input and produces detailed reports in markdown
or JSON format. Includes executive summary, health score dashboard, findings
by severity, and actionable recommendations.
Usage:
python test_report_generator.py session_data.json
python test_report_generator.py session_data.json --format json
python test_report_generator.py session_data.json --format markdown -o report.md
python test_report_generator.py session_data.json --history scores_history.json
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SEVERITY_ORDER = ["P0", "P1", "P2", "P3", "P4"]
SEVERITY_LABELS = {
"P0": "Critical",
"P1": "High",
"P2": "Medium",
"P3": "Low",
"P4": "Cosmetic",
}
SEVERITY_EMOJI_TEXT = {
"P0": "[CRITICAL]",
"P1": "[HIGH]",
"P2": "[MEDIUM]",
"P3": "[LOW]",
"P4": "[COSMETIC]",
}
GRADE_THRESHOLDS = [
(90, "A"),
(80, "B"),
(70, "C"),
(60, "D"),
(0, "F"),
]
CATEGORY_DISPLAY_NAMES = {
"console_errors": "Console Errors",
"broken_links": "Broken Links",
"visual_consistency": "Visual Consistency",
"functional": "Functional",
"ux_flow": "UX Flow",
"performance": "Performance",
"content_quality": "Content Quality",
"accessibility": "Accessibility",
"security_headers": "Security Headers",
"mobile_responsive": "Mobile Responsive",
}
# ---------------------------------------------------------------------------
# Data Loading
# ---------------------------------------------------------------------------
def load_session_data(filepath: str) -> dict[str, Any]:
"""Load QA session data from a JSON file.
Expected structure:
{
"project": "My App",
"url": "https://example.com",
"tester": "QA Engineer",
"tier": "standard",
"timestamp": "2026-03-18T...",
"health_score": { ... }, // optional, from qa_health_scorer
"findings": [ ... ], // list of finding objects
"accessibility": { ... }, // optional, from accessibility_auditor
"performance": { ... }, // optional, performance metrics
"visual_regression": { ... }, // optional, from visual_regression_tracker
"screenshots": [ ... ], // optional, list of screenshot paths
"notes": "..." // optional, free text
}
"""
path = Path(filepath)
if not path.exists():
print(f"Error: File not found: {filepath}", file=sys.stderr)
sys.exit(1)
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError as exc:
print(f"Error: Invalid JSON in {filepath}: {exc}", file=sys.stderr)
sys.exit(1)
return data
def load_history(filepath: str | None) -> list[dict[str, Any]]:
"""Load score history for trend analysis."""
if not filepath:
return []
path = Path(filepath)
if not path.exists():
return []
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
return data
if isinstance(data, dict) and "history" in data:
return data["history"]
return []
except (json.JSONDecodeError, OSError):
return []
# ---------------------------------------------------------------------------
# Analysis Helpers
# ---------------------------------------------------------------------------
def score_to_grade(score: float) -> str:
"""Convert a numeric score to a letter grade."""
for threshold, grade in GRADE_THRESHOLDS:
if score >= threshold:
return grade
return "F"
def count_findings_by_severity(findings: list[dict[str, Any]]) -> dict[str, int]:
"""Count findings grouped by severity."""
counts: dict[str, int] = {sev: 0 for sev in SEVERITY_ORDER}
for f in findings:
sev = f.get("severity", "P3")
if sev in counts:
counts[sev] += 1
else:
counts["P3"] += 1
return counts
def count_findings_by_category(findings: list[dict[str, Any]]) -> dict[str, int]:
"""Count findings grouped by category."""
counts: dict[str, int] = {}
for f in findings:
cat = f.get("category", "functional")
counts[cat] = counts.get(cat, 0) + 1
return counts
def compute_trend(current_score: float, history: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Compute trend data from score history."""
if not history:
return None
scores = [h.get("score", h.get("overall_score", 0)) for h in history]
if not scores:
return None
previous = scores[-1]
delta = round(current_score - previous, 1)
avg = round(sum(scores) / len(scores), 1)
if len(scores) >= 2:
recent_trend = scores[-1] - scores[-2]
direction = "improving" if recent_trend > 0 else "declining" if recent_trend < 0 else "stable"
else:
direction = "improving" if delta > 0 else "declining" if delta < 0 else "stable"
return {
"previous_score": previous,
"delta": delta,
"direction": direction,
"average": avg,
"history_length": len(scores),
"best": max(scores),
"worst": min(scores),
}
def generate_recommendations(
findings: list[dict[str, Any]],
health_score: dict[str, Any] | None,
accessibility: dict[str, Any] | None,
) -> list[str]:
"""Generate prioritized recommendations based on findings."""
recommendations: list[str] = []
severity_counts = count_findings_by_severity(findings)
if severity_counts.get("P0", 0) > 0:
recommendations.append(
f"URGENT: {severity_counts['P0']} critical issue(s) must be resolved before any release. "
"These include application crashes, data loss risks, or security vulnerabilities."
)
if severity_counts.get("P1", 0) > 0:
recommendations.append(
f"HIGH PRIORITY: {severity_counts['P1']} high-severity issue(s) should be fixed within the current sprint."
)
if health_score:
categories = health_score.get("categories", {})
weak_categories = [
(cat, data.get("score_pct", 100))
for cat, data in categories.items()
if data.get("score_pct", 100) < 70
]
for cat, pct in sorted(weak_categories, key=lambda x: x[1]):
display = CATEGORY_DISPLAY_NAMES.get(cat, cat)
recommendations.append(
f"Improve {display} (currently {pct:.0f}%): Focus on resolving {cat} findings to raise the overall health score."
)
if accessibility:
must_fix = accessibility.get("by_severity", {}).get("must-fix", 0)
if must_fix > 0:
recommendations.append(
f"Accessibility: {must_fix} must-fix WCAG violation(s) detected. "
"Address these to meet minimum compliance requirements."
)
category_counts = count_findings_by_category(findings)
top_categories = sorted(category_counts.items(), key=lambda x: x[1], reverse=True)[:3]
if top_categories:
cats = ", ".join(CATEGORY_DISPLAY_NAMES.get(c, c) for c, _ in top_categories)
recommendations.append(
f"Most affected areas: {cats}. Consider dedicated review sessions for these categories."
)
if not recommendations:
recommendations.append("No critical issues found. Continue monitoring with regular QA sweeps.")
return recommendations
# ---------------------------------------------------------------------------
# Markdown Report Generation
# ---------------------------------------------------------------------------
def generate_markdown_report(
session: dict[str, Any],
history: list[dict[str, Any]],
) -> str:
"""Generate a comprehensive markdown QA report."""
lines: list[str] = []
now = session.get("timestamp", datetime.now(timezone.utc).isoformat())
project = session.get("project", "Unknown Project")
url = session.get("url", "N/A")
tester = session.get("tester", "QA Automation")
tier = session.get("tier", "standard")
findings = session.get("findings", [])
health_data = session.get("health_score", {})
accessibility = session.get("accessibility", {})
performance = session.get("performance", {})
visual_reg = session.get("visual_regression", {})
notes = session.get("notes", "")
overall_score = health_data.get("overall_score", health_data.get("score", 0))
grade = score_to_grade(overall_score)
severity_counts = count_findings_by_severity(findings)
total_findings = sum(severity_counts.values())
# --- Header ---
lines.append(f"# QA Report: {project}")
lines.append("")
lines.append(f"**Date:** {now}")
lines.append(f"**URL:** {url}")
lines.append(f"**Tester:** {tester}")
lines.append(f"**Tier:** {tier.capitalize()}")
lines.append(f"**Total Findings:** {total_findings}")
lines.append("")
# --- Executive Summary ---
lines.append("## Executive Summary")
lines.append("")
pass_fail = "PASS" if overall_score >= 70 else "FAIL"
lines.append(f"The application scored **{overall_score}/100 (Grade: {grade})** — **{pass_fail}**.")
lines.append("")
if severity_counts.get("P0", 0) > 0:
lines.append(f"**{severity_counts['P0']} critical issue(s) detected** requiring immediate attention before release.")
elif severity_counts.get("P1", 0) > 0:
lines.append(f"No critical issues, but **{severity_counts['P1']} high-severity issue(s)** should be addressed this sprint.")
elif total_findings > 0:
lines.append(f"No critical or high-severity issues. {total_findings} lower-priority finding(s) identified for improvement.")
else:
lines.append("No issues detected. The application is in excellent condition.")
lines.append("")
# --- Health Score Dashboard ---
lines.append("## Health Score Dashboard")
lines.append("")
lines.append(f"| Metric | Value |")
lines.append(f"|--------|-------|")
lines.append(f"| Overall Score | {overall_score}/100 |")
lines.append(f"| Grade | {grade} |")
lines.append(f"| Status | {pass_fail} |")
lines.append(f"| Total Findings | {total_findings} |")
lines.append("")
# Category breakdown
categories = health_data.get("categories", {})
if categories:
lines.append("### Category Breakdown")
lines.append("")
lines.append("| Category | Weight | Score | Findings |")
lines.append("|----------|--------|-------|----------|")
for cat, data in categories.items():
display = CATEGORY_DISPLAY_NAMES.get(cat, data.get("display_name", cat))
weight = f"{int(data.get('weight', 0) * 100)}%"
score_pct = f"{data.get('score_pct', 100):.0f}%"
count = data.get("total_findings", 0)
lines.append(f"| {display} | {weight} | {score_pct} | {count} |")
lines.append("")
# Trend
trend = compute_trend(overall_score, history)
if trend:
lines.append("### Trend")
lines.append("")
arrow = "+" if trend["delta"] > 0 else "" if trend["delta"] < 0 else ""
lines.append(f"- **Direction:** {trend['direction'].capitalize()}")
lines.append(f"- **Previous:** {trend['previous_score']}")
lines.append(f"- **Delta:** {arrow}{trend['delta']} pts")
lines.append(f"- **Average:** {trend['average']} (over {trend['history_length']} runs)")
lines.append(f"- **Best:** {trend['best']} / **Worst:** {trend['worst']}")
lines.append("")
# --- Findings by Severity ---
lines.append("## Findings by Severity")
lines.append("")
for sev in SEVERITY_ORDER:
sev_findings = [f for f in findings if f.get("severity", "P3") == sev]
if not sev_findings:
continue
label = SEVERITY_LABELS.get(sev, sev)
tag = SEVERITY_EMOJI_TEXT.get(sev, "")
lines.append(f"### {sev} — {label} ({len(sev_findings)} finding{'s' if len(sev_findings) != 1 else ''})")
lines.append("")
for i, finding in enumerate(sev_findings, 1):
title = finding.get("title", finding.get("message", "Untitled finding"))
category = finding.get("category", "functional")
display_cat = CATEGORY_DISPLAY_NAMES.get(category, category)
description = finding.get("description", "")
location = finding.get("location", finding.get("page", finding.get("url", "")))
steps = finding.get("steps_to_reproduce", "")
expected = finding.get("expected", "")
actual = finding.get("actual", "")
lines.append(f"**{i}. {title}** {tag}")
lines.append(f"- **Category:** {display_cat}")
if location:
lines.append(f"- **Location:** {location}")
if description:
lines.append(f"- **Description:** {description}")
if steps:
lines.append(f"- **Steps:** {steps}")
if expected:
lines.append(f"- **Expected:** {expected}")
if actual:
lines.append(f"- **Actual:** {actual}")
lines.append("")
if total_findings == 0:
lines.append("No findings recorded.")
lines.append("")
# --- Accessibility Results ---
if accessibility:
lines.append("## Accessibility Results")
lines.append("")
level = accessibility.get("level_checked", "AA")
total_violations = accessibility.get("total_violations", 0)
compliance = accessibility.get("compliance_percentage", 100)
lines.append(f"- **Level Checked:** WCAG 2.1 {level}")
lines.append(f"- **Violations:** {total_violations}")
lines.append(f"- **Compliance:** {compliance}%")
lines.append("")
by_severity = accessibility.get("by_severity", {})
if by_severity:
lines.append("| Severity | Count |")
lines.append("|----------|-------|")
for sev in ("must-fix", "should-fix", "nice-to-have"):
lines.append(f"| {sev} | {by_severity.get(sev, 0)} |")
lines.append("")
a11y_violations = accessibility.get("violations", [])
if a11y_violations:
lines.append("### Top Accessibility Violations")
lines.append("")
for v in a11y_violations[:10]:
rule = v.get("rule_id", "unknown")
msg = v.get("message", "")
criterion = v.get("wcag_criterion", "")
lines.append(f"- **{rule}** ({criterion}): {msg}")
if len(a11y_violations) > 10:
lines.append(f"- ... and {len(a11y_violations) - 10} more")
lines.append("")
# --- Performance Metrics ---
if performance:
lines.append("## Performance Metrics")
lines.append("")
metrics = performance.get("metrics", performance)
if isinstance(metrics, dict):
lines.append("| Metric | Value | Threshold | Status |")
lines.append("|--------|-------|-----------|--------|")
metric_defs = {
"lcp": ("Largest Contentful Paint", "2.5s", 2500),
"fid": ("First Input Delay", "100ms", 100),
"cls": ("Cumulative Layout Shift", "0.1", 0.1),
"inp": ("Interaction to Next Paint", "200ms", 200),
"ttfb": ("Time to First Byte", "800ms", 800),
"fcp": ("First Contentful Paint", "1.8s", 1800),
"tti": ("Time to Interactive", "3.8s", 3800),
"tbt": ("Total Blocking Time", "200ms", 200),
}
for key, (label, threshold_str, threshold_val) in metric_defs.items():
value = metrics.get(key)
if value is not None:
if key == "cls":
val_str = f"{value:.3f}"
status = "Pass" if value <= threshold_val else "Fail"
else:
val_str = f"{value}ms"
status = "Pass" if value <= threshold_val else "Fail"
lines.append(f"| {label} | {val_str} | {threshold_str} | {status} |")
lines.append("")
# Resource summary
resources = performance.get("resources", {})
if resources:
lines.append("### Resource Summary")
lines.append("")
total_size = resources.get("total_size_kb", 0)
request_count = resources.get("request_count", 0)
lines.append(f"- **Total Transfer Size:** {total_size} KB")
lines.append(f"- **Request Count:** {request_count}")
for rtype, size in resources.get("by_type", {}).items():
lines.append(f"- **{rtype}:** {size} KB")
lines.append("")
# --- Visual Regression ---
if visual_reg:
lines.append("## Visual Regression Results")
lines.append("")
summary = visual_reg.get("summary", {})
lines.append(f"- **Pages Compared:** {summary.get('total_compared', 0)}")
lines.append(f"- **Passed:** {summary.get('passed', 0)}")
lines.append(f"- **Failed:** {summary.get('failed', 0)}")
lines.append(f"- **New Pages:** {summary.get('new_pages', 0)}")
lines.append("")
pages = visual_reg.get("pages", {})
failed_pages = {k: v for k, v in pages.items() if v.get("status") == "fail"}
if failed_pages:
lines.append("### Regressions Detected")
lines.append("")
lines.append("| Page | Change % |")
lines.append("|------|----------|")
for page, data in sorted(failed_pages.items(), key=lambda x: x[1].get("change_pct", 0), reverse=True):
lines.append(f"| {page} | {data.get('change_pct', 0)}% |")
lines.append("")
# --- Recommendations ---
recommendations = generate_recommendations(findings, health_data, accessibility)
lines.append("## Recommendations")
lines.append("")
for i, rec in enumerate(recommendations, 1):
lines.append(f"{i}. {rec}")
lines.append("")
# --- Notes ---
if notes:
lines.append("## Notes")
lines.append("")
lines.append(notes)
lines.append("")
# --- Footer ---
lines.append("---")
lines.append("")
lines.append(f"*Generated by QA Browser Automation skill — {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}*")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# JSON Summary Generation
# ---------------------------------------------------------------------------
def generate_json_summary(
session: dict[str, Any],
history: list[dict[str, Any]],
) -> str:
"""Generate a JSON summary of the QA session."""
findings = session.get("findings", [])
health_data = session.get("health_score", {})
accessibility = session.get("accessibility", {})
performance = session.get("performance", {})
visual_reg = session.get("visual_regression", {})
overall_score = health_data.get("overall_score", health_data.get("score", 0))
severity_counts = count_findings_by_severity(findings)
category_counts = count_findings_by_category(findings)
trend = compute_trend(overall_score, history)
recommendations = generate_recommendations(findings, health_data, accessibility)
summary: dict[str, Any] = {
"report_type": "qa_session_summary",
"generated": datetime.now(timezone.utc).isoformat(),
"project": session.get("project", "Unknown"),
"url": session.get("url", ""),
"tier": session.get("tier", "standard"),
"health_score": overall_score,
"grade": score_to_grade(overall_score),
"passed": overall_score >= 70,
"total_findings": sum(severity_counts.values()),
"findings_by_severity": severity_counts,
"findings_by_category": category_counts,
"accessibility_violations": accessibility.get("total_violations", 0),
"accessibility_compliance_pct": accessibility.get("compliance_percentage"),
"visual_regressions": visual_reg.get("summary", {}).get("failed", 0),
"performance_metrics": performance.get("metrics", {}),
"trend": trend,
"recommendations": recommendations,
}
return json.dumps(summary, indent=2)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
"""Build the argument parser."""
parser = argparse.ArgumentParser(
prog="test_report_generator",
description="Generate comprehensive QA reports from session data.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Examples:\n"
" python test_report_generator.py session_data.json\n"
" python test_report_generator.py session_data.json --format json\n"
" python test_report_generator.py session_data.json -o report.md\n"
" python test_report_generator.py session_data.json --history history.json\n"
),
)
parser.add_argument(
"session_file",
help="Path to QA session data JSON file",
)
parser.add_argument(
"--format",
choices=["markdown", "json"],
default="markdown",
dest="output_format",
help="Output format (default: markdown)",
)
parser.add_argument(
"-o", "--output",
default=None,
help="Write report to file instead of stdout",
)
parser.add_argument(
"--history",
default=None,
help="Path to score history JSON for trend analysis",
)
return parser
def main() -> None:
"""Entry point."""
parser = build_parser()
args = parser.parse_args()
session = load_session_data(args.session_file)
history = load_history(args.history)
if args.output_format == "json":
output = generate_json_summary(session, history)
else:
output = generate_markdown_report(session, history)
if args.output:
out_path = Path(args.output)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(output, encoding="utf-8")
print(f"Report written to: {args.output}")
else:
print(output)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Visual Regression Tracker — Manages screenshot baselines and detects regressions.
Tracks visual changes between test runs by maintaining a JSON manifest of
baseline screenshots with file hashes. Computes change metrics per page and
flags significant regressions exceeding a configurable threshold.
Usage:
python visual_regression_tracker.py --init --baseline-dir ./baselines
python visual_regression_tracker.py --register ./baselines
python visual_regression_tracker.py --baseline ./baselines --current ./screenshots
python visual_regression_tracker.py --baseline ./baselines --current ./screenshots --threshold 3
python visual_regression_tracker.py --update-baseline --baseline ./baselines --current ./screenshots
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import struct
import sys
import zlib
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
MANIFEST_FILENAME = "visual_baseline_manifest.json"
SUPPORTED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp"}
DEFAULT_THRESHOLD = 5.0 # percent
# ---------------------------------------------------------------------------
# File Hashing
# ---------------------------------------------------------------------------
def compute_file_hash(filepath: Path) -> str:
"""Compute SHA-256 hash of a file."""
h = hashlib.sha256()
with open(filepath, "rb") as f:
while True:
chunk = f.read(8192)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
def get_file_size(filepath: Path) -> int:
"""Get file size in bytes."""
return filepath.stat().st_size
# ---------------------------------------------------------------------------
# PNG Dimension Reader (standard library only)
# ---------------------------------------------------------------------------
def read_png_dimensions(filepath: Path) -> tuple[int, int] | None:
"""Read width and height from a PNG file header."""
try:
with open(filepath, "rb") as f:
header = f.read(24)
if len(header) < 24:
return None
if header[:8] != b"\x89PNG\r\n\x1a\n":
return None
width = struct.unpack(">I", header[16:20])[0]
height = struct.unpack(">I", header[20:24])[0]
return (width, height)
except (OSError, struct.error):
return None
def get_image_info(filepath: Path) -> dict[str, Any]:
"""Get basic image metadata."""
info: dict[str, Any] = {
"size_bytes": get_file_size(filepath),
"extension": filepath.suffix.lower(),
}
if filepath.suffix.lower() == ".png":
dims = read_png_dimensions(filepath)
if dims:
info["width"] = dims[0]
info["height"] = dims[1]
return info
# ---------------------------------------------------------------------------
# Manifest Management
# ---------------------------------------------------------------------------
def load_manifest(baseline_dir: Path) -> dict[str, Any]:
"""Load the baseline manifest from disk."""
manifest_path = baseline_dir / MANIFEST_FILENAME
if not manifest_path.exists():
return {"version": 1, "created": "", "updated": "", "baselines": {}}
with open(manifest_path, "r", encoding="utf-8") as f:
return json.load(f)
def save_manifest(baseline_dir: Path, manifest: dict[str, Any]) -> None:
"""Save the baseline manifest to disk."""
manifest_path = baseline_dir / MANIFEST_FILENAME
manifest["updated"] = datetime.now(timezone.utc).isoformat()
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2)
def init_baseline_dir(baseline_dir: Path) -> dict[str, Any]:
"""Initialize a baseline directory with an empty manifest."""
baseline_dir.mkdir(parents=True, exist_ok=True)
manifest: dict[str, Any] = {
"version": 1,
"created": datetime.now(timezone.utc).isoformat(),
"updated": datetime.now(timezone.utc).isoformat(),
"baselines": {},
}
save_manifest(baseline_dir, manifest)
return manifest
def register_baselines(baseline_dir: Path) -> dict[str, Any]:
"""Scan baseline directory and register all image files in the manifest."""
manifest = load_manifest(baseline_dir)
registered = 0
for filepath in sorted(baseline_dir.iterdir()):
if filepath.suffix.lower() not in SUPPORTED_EXTENSIONS:
continue
if filepath.name == MANIFEST_FILENAME:
continue
page_name = filepath.stem
file_hash = compute_file_hash(filepath)
image_info = get_image_info(filepath)
manifest["baselines"][page_name] = {
"filename": filepath.name,
"hash": file_hash,
"registered": datetime.now(timezone.utc).isoformat(),
**image_info,
}
registered += 1
save_manifest(baseline_dir, manifest)
return {"registered": registered, "total": len(manifest["baselines"])}
# ---------------------------------------------------------------------------
# Comparison Logic
# ---------------------------------------------------------------------------
def compare_file_bytes(file_a: Path, file_b: Path) -> dict[str, Any]:
"""Compare two files byte-by-byte and compute a change metric.
Since we use only the standard library (no PIL/OpenCV), we compare:
1. File hash equality (fast path)
2. File size difference as a proxy for change magnitude
3. Byte-level difference ratio for same-size files
Returns a dict with comparison metrics.
"""
hash_a = compute_file_hash(file_a)
hash_b = compute_file_hash(file_b)
if hash_a == hash_b:
return {
"identical": True,
"change_pct": 0.0,
"hash_a": hash_a,
"hash_b": hash_b,
"size_a": get_file_size(file_a),
"size_b": get_file_size(file_b),
}
size_a = get_file_size(file_a)
size_b = get_file_size(file_b)
# If sizes differ significantly, estimate change from size delta
if size_a > 0 and size_b > 0:
size_ratio = abs(size_a - size_b) / max(size_a, size_b) * 100
# For same-ish sizes, do byte comparison on compressed content
# to estimate structural differences
if abs(size_a - size_b) / max(size_a, size_b) < 0.5:
change_pct = _byte_diff_ratio(file_a, file_b)
else:
change_pct = min(size_ratio * 2, 100.0) # Scale up size diff
else:
change_pct = 100.0
return {
"identical": False,
"change_pct": round(change_pct, 2),
"hash_a": hash_a,
"hash_b": hash_b,
"size_a": size_a,
"size_b": size_b,
}
def _byte_diff_ratio(file_a: Path, file_b: Path) -> float:
"""Compute byte-level difference ratio between two files.
Reads both files and compares byte-by-byte up to the shorter length,
plus any extra bytes in the longer file count as differences.
"""
with open(file_a, "rb") as fa, open(file_b, "rb") as fb:
data_a = fa.read()
data_b = fb.read()
min_len = min(len(data_a), len(data_b))
max_len = max(len(data_a), len(data_b))
if max_len == 0:
return 0.0
diff_count = abs(len(data_a) - len(data_b)) # Extra bytes
# Sample comparison for performance (compare every Nth byte for large files)
step = max(1, min_len // 100000)
sampled = 0
sampled_diff = 0
for i in range(0, min_len, step):
sampled += 1
if data_a[i] != data_b[i]:
sampled_diff += 1
if sampled > 0:
byte_diff_ratio = sampled_diff / sampled
diff_count += int(byte_diff_ratio * min_len)
return min((diff_count / max_len) * 100, 100.0)
def run_comparison(
baseline_dir: Path,
current_dir: Path,
threshold: float,
) -> dict[str, Any]:
"""Compare current screenshots against baselines.
Returns a comprehensive comparison report.
"""
manifest = load_manifest(baseline_dir)
baselines = manifest.get("baselines", {})
results: dict[str, Any] = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"threshold": threshold,
"baseline_dir": str(baseline_dir),
"current_dir": str(current_dir),
"pages": {},
"summary": {
"total_compared": 0,
"passed": 0,
"failed": 0,
"new_pages": 0,
"missing_pages": 0,
},
}
# Find current screenshots
current_files: dict[str, Path] = {}
if current_dir.exists():
for filepath in current_dir.iterdir():
if filepath.suffix.lower() in SUPPORTED_EXTENSIONS:
current_files[filepath.stem] = filepath
# Compare each baseline
for page_name, baseline_info in baselines.items():
baseline_file = baseline_dir / baseline_info["filename"]
if not baseline_file.exists():
results["pages"][page_name] = {
"status": "baseline_missing",
"message": f"Baseline file missing: {baseline_info['filename']}",
}
results["summary"]["missing_pages"] += 1
continue
if page_name not in current_files:
results["pages"][page_name] = {
"status": "current_missing",
"message": "No current screenshot found for this page",
}
results["summary"]["missing_pages"] += 1
continue
comparison = compare_file_bytes(baseline_file, current_files[page_name])
passed = comparison["change_pct"] <= threshold
results["pages"][page_name] = {
"status": "pass" if passed else "fail",
"change_pct": comparison["change_pct"],
"identical": comparison["identical"],
"baseline_hash": comparison["hash_a"],
"current_hash": comparison["hash_b"],
"baseline_size": comparison["size_a"],
"current_size": comparison["size_b"],
}
results["summary"]["total_compared"] += 1
if passed:
results["summary"]["passed"] += 1
else:
results["summary"]["failed"] += 1
# Detect new pages (in current but not in baseline)
for page_name, filepath in current_files.items():
if page_name not in baselines:
results["pages"][page_name] = {
"status": "new",
"message": "New page not in baseline",
"current_hash": compute_file_hash(filepath),
"current_size": get_file_size(filepath),
}
results["summary"]["new_pages"] += 1
return results
def update_baselines(baseline_dir: Path, current_dir: Path) -> dict[str, Any]:
"""Update baseline screenshots with current ones."""
import shutil
manifest = load_manifest(baseline_dir)
updated = 0
added = 0
if not current_dir.exists():
return {"error": f"Current directory not found: {current_dir}"}
for filepath in sorted(current_dir.iterdir()):
if filepath.suffix.lower() not in SUPPORTED_EXTENSIONS:
continue
dest = baseline_dir / filepath.name
shutil.copy2(filepath, dest)
page_name = filepath.stem
file_hash = compute_file_hash(dest)
image_info = get_image_info(dest)
if page_name in manifest.get("baselines", {}):
updated += 1
else:
added += 1
manifest.setdefault("baselines", {})[page_name] = {
"filename": filepath.name,
"hash": file_hash,
"registered": datetime.now(timezone.utc).isoformat(),
**image_info,
}
save_manifest(baseline_dir, manifest)
return {"updated": updated, "added": added, "total": len(manifest["baselines"])}
# ---------------------------------------------------------------------------
# Output Formatting
# ---------------------------------------------------------------------------
def format_human_readable(results: dict[str, Any]) -> str:
"""Format comparison results as human-readable text."""
lines: list[str] = []
summary = results["summary"]
lines.append("=" * 60)
lines.append(" VISUAL REGRESSION REPORT")
lines.append("=" * 60)
lines.append("")
lines.append(f" Timestamp: {results['timestamp']}")
lines.append(f" Threshold: {results['threshold']}%")
lines.append(f" Compared: {summary['total_compared']} pages")
lines.append(f" Passed: {summary['passed']}")
lines.append(f" Failed: {summary['failed']}")
lines.append(f" New pages: {summary['new_pages']}")
lines.append(f" Missing: {summary['missing_pages']}")
lines.append("")
overall = "PASS" if summary["failed"] == 0 else "FAIL"
lines.append(f" Result: {overall}")
lines.append("")
# Page details
lines.append("-" * 60)
lines.append(" PAGE RESULTS")
lines.append("-" * 60)
for page_name, data in sorted(results["pages"].items()):
status = data["status"].upper()
if data["status"] == "pass":
change = data.get("change_pct", 0)
marker = " [OK]" if data.get("identical") else f" [{change}% change]"
lines.append(f" {page_name:<30} PASS{marker}")
elif data["status"] == "fail":
change = data.get("change_pct", 0)
lines.append(f" {page_name:<30} FAIL [{change}% change] !!!")
elif data["status"] == "new":
lines.append(f" {page_name:<30} NEW (not in baseline)")
else:
msg = data.get("message", "")
lines.append(f" {page_name:<30} {status} {msg}")
lines.append("")
lines.append("=" * 60)
if summary["failed"] > 0:
lines.append("")
lines.append(" REGRESSIONS DETECTED — Review failed pages above.")
lines.append(" To accept changes: --update-baseline")
lines.append("")
return "\n".join(lines)
def format_json_output(results: dict[str, Any]) -> str:
"""Format comparison results as JSON."""
return json.dumps(results, indent=2)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
"""Build the argument parser."""
parser = argparse.ArgumentParser(
prog="visual_regression_tracker",
description="Track visual regressions between screenshot baselines and current captures.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Examples:\n"
" python visual_regression_tracker.py --init --baseline-dir ./baselines\n"
" python visual_regression_tracker.py --register ./baselines\n"
" python visual_regression_tracker.py --baseline ./baselines --current ./screenshots\n"
" python visual_regression_tracker.py --baseline ./baselines --current ./screenshots --threshold 3\n"
" python visual_regression_tracker.py --update-baseline --baseline ./baselines --current ./screenshots\n"
),
)
# Actions
parser.add_argument(
"--init",
action="store_true",
help="Initialize a new baseline directory",
)
parser.add_argument(
"--register",
metavar="DIR",
help="Scan directory and register all images as baselines",
)
parser.add_argument(
"--update-baseline",
action="store_true",
help="Update baselines with current screenshots",
)
# Directories
parser.add_argument(
"--baseline-dir", "--baseline",
dest="baseline_dir",
help="Path to baseline screenshot directory",
)
parser.add_argument(
"--current",
help="Path to current screenshot directory (for comparison)",
)
# Options
parser.add_argument(
"--threshold",
type=float,
default=DEFAULT_THRESHOLD,
help=f"Change percentage threshold for regression (default: {DEFAULT_THRESHOLD}%%)",
)
parser.add_argument(
"--json",
action="store_true",
dest="json_output",
help="Output results as JSON",
)
return parser
def main() -> None:
"""Entry point."""
parser = build_parser()
args = parser.parse_args()
# --- Init mode ---
if args.init:
if not args.baseline_dir:
print("Error: --baseline-dir required with --init", file=sys.stderr)
sys.exit(1)
baseline_dir = Path(args.baseline_dir)
manifest = init_baseline_dir(baseline_dir)
if args.json_output:
print(json.dumps({"action": "init", "directory": str(baseline_dir), "manifest": manifest}, indent=2))
else:
print(f"Initialized baseline directory: {baseline_dir}")
print(f"Manifest created: {baseline_dir / MANIFEST_FILENAME}")
return
# --- Register mode ---
if args.register:
register_dir = Path(args.register)
if not register_dir.exists():
print(f"Error: Directory not found: {args.register}", file=sys.stderr)
sys.exit(1)
result = register_baselines(register_dir)
if args.json_output:
print(json.dumps({"action": "register", **result}, indent=2))
else:
print(f"Registered {result['registered']} baseline(s) ({result['total']} total)")
return
# --- Update baseline mode ---
if args.update_baseline:
if not args.baseline_dir or not args.current:
print("Error: --baseline-dir and --current required with --update-baseline", file=sys.stderr)
sys.exit(1)
baseline_dir = Path(args.baseline_dir)
current_dir = Path(args.current)
result = update_baselines(baseline_dir, current_dir)
if "error" in result:
print(f"Error: {result['error']}", file=sys.stderr)
sys.exit(1)
if args.json_output:
print(json.dumps({"action": "update_baseline", **result}, indent=2))
else:
print(f"Updated {result['updated']} baseline(s), added {result['added']} new ({result['total']} total)")
return
# --- Comparison mode (default) ---
if not args.baseline_dir or not args.current:
print("Error: --baseline-dir and --current required for comparison", file=sys.stderr)
print("Use --help for usage information", file=sys.stderr)
sys.exit(1)
baseline_dir = Path(args.baseline_dir)
current_dir = Path(args.current)
if not baseline_dir.exists():
print(f"Error: Baseline directory not found: {baseline_dir}", file=sys.stderr)
sys.exit(1)
if not current_dir.exists():
print(f"Error: Current directory not found: {current_dir}", file=sys.stderr)
sys.exit(1)
results = run_comparison(baseline_dir, current_dir, args.threshold)
if args.json_output:
print(format_json_output(results))
else:
print(format_human_readable(results))
# Exit code: non-zero if regressions found
if results["summary"]["failed"] > 0:
sys.exit(1)
if __name__ == "__main__":
main()
Related skills
FAQ
What does the QA sweep gate on?
A health score of at least 85, zero P0 findings, and WCAG AA at or above 95%.
Which WCAG levels are audited?
Level A (must fix), AA (should fix), and AAA (nice to have), each violation including the WCAG criterion, severity, element selector and remediation guidance.