Compare commits
1 Commits
queue/492-
...
queue/490-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e394c85c0b |
@@ -1,150 +0,0 @@
|
||||
# Visual Accessibility Audit — Foundation Web Properties
|
||||
|
||||
**Issue:** timmy-config #492
|
||||
**Date:** 2026-04-13
|
||||
**Label:** gemma-4-multimodal
|
||||
**Scope:** forge.alexanderwhitestone.com (Gitea 1.25.4)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Foundation's primary accessible web property is the Gitea forge. The Matrix homeserver (matrix.timmy.foundation) is currently unreachable (DNS/SSL issues). This audit covers the forge across three page types: Homepage, Login, and Explore/Repositories.
|
||||
|
||||
**Overall: 6 WCAG 2.1 AA violations found, 4 best-practice recommendations.**
|
||||
|
||||
---
|
||||
|
||||
## Pages Audited
|
||||
|
||||
| Page | URL | Status |
|
||||
|------|-----|--------|
|
||||
| Homepage | forge.alexanderwhitestone.com | Live |
|
||||
| Sign In | forge.alexanderwhitestone.com/user/login | Live |
|
||||
| Explore Repos | forge.alexanderwhitestone.com/explore/repos | Live |
|
||||
| Matrix/Element | matrix.timmy.foundation | DOWN (DNS/SSL) |
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### P1 — Violations (WCAG 2.1 AA)
|
||||
|
||||
#### V1: No Skip Navigation Link (2.4.1)
|
||||
- **Pages:** All
|
||||
- **Severity:** Medium
|
||||
- **Description:** No "Skip to content" link exists. Keyboard users must tab through the full navigation on every page load.
|
||||
- **Evidence:** Programmatic check returned `skipNav: false`
|
||||
- **Fix:** Add `<a href="#main" class="skip-link">Skip to content</a>` visually hidden until focused.
|
||||
|
||||
#### V2: 25 Form Inputs Without Labels (1.3.1, 3.3.2)
|
||||
- **Pages:** Explore/Repositories (filter dropdowns)
|
||||
- **Severity:** High
|
||||
- **Description:** The search input and all radio buttons in the Filter/Sort dropdowns lack programmatic label associations.
|
||||
- **Evidence:** Programmatic check found 25 inputs without `label[for=]`, `aria-label`, or `aria-labelledby`
|
||||
- **Affected inputs:** `q` (search), `archived` (x2), `fork` (x2), `mirror` (x2), `template` (x2), `private` (x2), `sort` (x12), `clear-filter` (x1)
|
||||
- **Fix:** Add `aria-label="Search repositories"` to search input. Add `aria-label` to each radio button group and individual options.
|
||||
|
||||
#### V3: Low-Contrast Footer Text (1.4.3)
|
||||
- **Pages:** All
|
||||
- **Severity:** Medium
|
||||
- **Description:** Footer text (version, page render time) appears light gray on white, likely failing the 4.5:1 contrast ratio.
|
||||
- **Evidence:** 30 elements flagged as potential low-contrast suspects.
|
||||
- **Fix:** Darken footer text to at least `#767676` on white (4.54:1 ratio).
|
||||
|
||||
#### V4: Green Link Color Fails Contrast (1.4.3)
|
||||
- **Pages:** Homepage
|
||||
- **Severity:** Medium
|
||||
- **Description:** Inline links use medium-green (~#609926) on white. This shade typically fails 4.5:1 for normal body text.
|
||||
- **Evidence:** Visual analysis identified green links ("run the binary", "Docker", "contributing") as potentially failing.
|
||||
- **Fix:** Darken link color to at least `#507020` or add an underline for non-color differentiation (SC 1.4.1).
|
||||
|
||||
#### V5: Missing Header/Banner Landmark (1.3.1)
|
||||
- **Pages:** All
|
||||
- **Severity:** Low
|
||||
- **Description:** No `<header>` or `role="banner"` element found. The navigation bar is a `<nav>` but not wrapped in a banner landmark.
|
||||
- **Evidence:** `landmarks.banner: 0`
|
||||
- **Fix:** Wrap the top navigation in `<header>` or add `role="banner"`.
|
||||
|
||||
#### V6: Heading Hierarchy Issue (1.3.1)
|
||||
- **Pages:** Login
|
||||
- **Severity:** Low
|
||||
- **Description:** The Sign In heading is `<h4>` rather than `<h1>`, breaking the heading hierarchy. The page has no `<h1>`.
|
||||
- **Evidence:** Accessibility tree shows `heading "Sign In" [level=4]`
|
||||
- **Fix:** Use `<h1>` for "Sign In" on the login page.
|
||||
|
||||
---
|
||||
|
||||
### P2 — Best Practice Recommendations
|
||||
|
||||
#### R1: Add Password Visibility Toggle
|
||||
- **Page:** Login
|
||||
- **Description:** No show/hide toggle on the password field. This helps users with cognitive or motor impairments verify input.
|
||||
|
||||
#### R2: Add `aria-required` to Required Fields
|
||||
- **Page:** Login
|
||||
- **Evidence:** `inputsWithAriaRequired: 0` (no inputs marked as required)
|
||||
- **Description:** The username field shows a red asterisk but has no `required` or `aria-required="true"` attribute.
|
||||
|
||||
#### R3: Improve Star/Fork Link Labels
|
||||
- **Page:** Explore Repos
|
||||
- **Description:** Star and fork counts are bare numbers (e.g., "0", "2"). Screen readers announce these without context.
|
||||
- **Fix:** Add `aria-label="2 stars"` / `aria-label="0 forks"` to count links.
|
||||
|
||||
#### R4: Use `<time>` Elements for Timestamps
|
||||
- **Page:** Explore Repos
|
||||
- **Description:** Relative timestamps ("2 minutes ago") are human-readable but lack machine-readable fallbacks.
|
||||
- **Fix:** Wrap in `<time datetime="2026-04-13T17:00:00Z">2 minutes ago</time>`.
|
||||
|
||||
---
|
||||
|
||||
## What's Working Well
|
||||
|
||||
- **Color contrast (primary):** Black text on white backgrounds — excellent 21:1 ratio.
|
||||
- **Heading structure (homepage):** Clean h1 > h2 > h3 hierarchy.
|
||||
- **Landmark regions:** `<main>` and `<nav>` landmarks present.
|
||||
- **Language attribute:** `lang="en-US"` set on `<html>`.
|
||||
- **Link text:** Descriptive — no "click here" or "read more" patterns found.
|
||||
- **Form layout:** Login form uses clean single-column with good spacing.
|
||||
- **Submit button:** Full-width, good contrast, large touch target.
|
||||
- **Navigation:** Simple, consistent across pages.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- **matrix.timmy.foundation:** Unreachable (DNS resolution failure / SSL cert mismatch). Should be re-audited when operational.
|
||||
- **Evennia web client (localhost:4001):** Local-only, not publicly accessible.
|
||||
- **WCAG AAA criteria:** This audit covers AA only.
|
||||
|
||||
---
|
||||
|
||||
## Remediation Priority
|
||||
|
||||
| Priority | Issue | Effort |
|
||||
|----------|-------|--------|
|
||||
| P1 | V2: 25 unlabeled inputs | Medium |
|
||||
| P1 | V1: Skip nav link | Small |
|
||||
| P1 | V4: Green link contrast | Small |
|
||||
| P1 | V3: Footer text contrast | Small |
|
||||
| P2 | V6: Heading hierarchy | Small |
|
||||
| P2 | V5: Banner landmark | Small |
|
||||
| P2 | R1-R4: Best practices | Small |
|
||||
|
||||
---
|
||||
|
||||
## Automated Check Results
|
||||
|
||||
```
|
||||
skipNav: false
|
||||
headings: h1(3), h4(1)
|
||||
imgsNoAlt: 0 / 1
|
||||
inputsNoLabel: 25
|
||||
genericLinks: 0
|
||||
lowContrastSuspects: 30
|
||||
inputsWithAriaRequired: 0
|
||||
landmarks: main=1, nav=2, banner=0, contentinfo=2
|
||||
hasLang: true (en-US)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Generated via visual + programmatic analysis of forge.alexanderwhitestone.com*
|
||||
@@ -1,151 +0,0 @@
|
||||
// a11y-check.js — Automated accessibility audit script for Foundation web properties
|
||||
// Run in browser console or via Playwright/Puppeteer
|
||||
//
|
||||
// Usage: Paste into DevTools console, or include in automated test suite.
|
||||
// Returns a JSON object with pass/fail for WCAG 2.1 AA checks.
|
||||
|
||||
(function a11yAudit() {
|
||||
const results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
violations: [],
|
||||
passes: [],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
// --- 2.4.1 Skip Navigation ---
|
||||
const skipLink = document.querySelector('a[href="#main"], a[href="#content"], .skip-nav, .skip-link');
|
||||
if (skipLink) {
|
||||
results.passes.push({ rule: '2.4.1', name: 'Skip Navigation', detail: 'Skip link found' });
|
||||
} else {
|
||||
results.violations.push({ rule: '2.4.1', name: 'Skip Navigation', severity: 'medium', detail: 'No skip-to-content link found' });
|
||||
}
|
||||
|
||||
// --- 1.3.1 / 3.3.2 Form Labels ---
|
||||
const unlabeledInputs = Array.from(document.querySelectorAll('input, select, textarea')).filter(el => {
|
||||
if (el.type === 'hidden') return false;
|
||||
const id = el.id;
|
||||
const hasLabel = id && document.querySelector(`label[for="${id}"]`);
|
||||
const hasAriaLabel = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby');
|
||||
const hasTitle = el.getAttribute('title');
|
||||
const hasPlaceholder = el.getAttribute('placeholder'); // placeholder alone is NOT sufficient
|
||||
return !hasLabel && !hasAriaLabel && !hasTitle;
|
||||
});
|
||||
if (unlabeledInputs.length === 0) {
|
||||
results.passes.push({ rule: '3.3.2', name: 'Form Labels', detail: 'All inputs have labels' });
|
||||
} else {
|
||||
results.violations.push({
|
||||
rule: '3.3.2',
|
||||
name: 'Form Labels',
|
||||
severity: 'high',
|
||||
detail: `${unlabeledInputs.length} inputs without programmatic labels`,
|
||||
elements: unlabeledInputs.map(el => ({ tag: el.tagName, type: el.type, name: el.name, id: el.id }))
|
||||
});
|
||||
}
|
||||
|
||||
// --- 1.4.3 Contrast (heuristic: very light text colors) ---
|
||||
const lowContrast = Array.from(document.querySelectorAll('p, span, a, li, td, th, label, small, footer *')).filter(el => {
|
||||
const style = getComputedStyle(el);
|
||||
const color = style.color;
|
||||
// Check for very light RGB values (r/g/b < 120)
|
||||
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
if (!match) return false;
|
||||
const [, r, g, b] = match.map(Number);
|
||||
return r < 120 && g < 120 && b < 120 && (r + g + b) < 200;
|
||||
});
|
||||
if (lowContrast.length === 0) {
|
||||
results.passes.push({ rule: '1.4.3', name: 'Contrast', detail: 'No obviously low-contrast text found' });
|
||||
} else {
|
||||
results.warnings.push({ rule: '1.4.3', name: 'Contrast', detail: `${lowContrast.length} elements with potentially low contrast (manual verification needed)` });
|
||||
}
|
||||
|
||||
// --- 1.3.1 Heading Hierarchy ---
|
||||
const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')).map(h => ({
|
||||
level: parseInt(h.tagName[1]),
|
||||
text: h.textContent.trim().substring(0, 80)
|
||||
}));
|
||||
let headingIssues = [];
|
||||
let lastLevel = 0;
|
||||
for (const h of headings) {
|
||||
if (h.level > lastLevel + 1 && lastLevel > 0) {
|
||||
headingIssues.push(`Skipped h${lastLevel} to h${h.level}: "${h.text}"`);
|
||||
}
|
||||
lastLevel = h.level;
|
||||
}
|
||||
if (headingIssues.length === 0 && headings.length > 0) {
|
||||
results.passes.push({ rule: '1.3.1', name: 'Heading Hierarchy', detail: `${headings.length} headings, proper nesting` });
|
||||
} else if (headingIssues.length > 0) {
|
||||
results.violations.push({ rule: '1.3.1', name: 'Heading Hierarchy', severity: 'low', detail: headingIssues.join('; ') });
|
||||
}
|
||||
|
||||
// --- 1.3.1 Landmarks ---
|
||||
const landmarks = {
|
||||
main: document.querySelectorAll('main, [role="main"]').length,
|
||||
nav: document.querySelectorAll('nav, [role="navigation"]').length,
|
||||
banner: document.querySelectorAll('header, [role="banner"]').length,
|
||||
contentinfo: document.querySelectorAll('footer, [role="contentinfo"]').length
|
||||
};
|
||||
if (landmarks.main > 0) {
|
||||
results.passes.push({ rule: '1.3.1', name: 'Main Landmark', detail: 'Found' });
|
||||
} else {
|
||||
results.violations.push({ rule: '1.3.1', name: 'Main Landmark', severity: 'medium', detail: 'No <main> or role="main" found' });
|
||||
}
|
||||
if (landmarks.banner === 0) {
|
||||
results.violations.push({ rule: '1.3.1', name: 'Banner Landmark', severity: 'low', detail: 'No <header> or role="banner" found' });
|
||||
}
|
||||
|
||||
// --- 3.3.1 Required Fields ---
|
||||
const requiredInputs = document.querySelectorAll('input[required], input[aria-required="true"]');
|
||||
if (requiredInputs.length > 0) {
|
||||
results.passes.push({ rule: '3.3.1', name: 'Required Fields', detail: `${requiredInputs.length} inputs marked as required` });
|
||||
} else {
|
||||
const visualRequired = document.querySelector('.required, [class*="required"], label .text-danger');
|
||||
if (visualRequired) {
|
||||
results.warnings.push({ rule: '3.3.1', name: 'Required Fields', detail: 'Visual indicators found but no aria-required attributes' });
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2.4.2 Page Title ---
|
||||
if (document.title && document.title.trim().length > 0) {
|
||||
results.passes.push({ rule: '2.4.2', name: 'Page Title', detail: document.title });
|
||||
} else {
|
||||
results.violations.push({ rule: '2.4.2', name: 'Page Title', severity: 'medium', detail: 'Page has no title' });
|
||||
}
|
||||
|
||||
// --- 3.1.1 Language ---
|
||||
const lang = document.documentElement.lang;
|
||||
if (lang) {
|
||||
results.passes.push({ rule: '3.1.1', name: 'Language', detail: lang });
|
||||
} else {
|
||||
results.violations.push({ rule: '3.1.1', name: 'Language', severity: 'medium', detail: 'No lang attribute on <html>' });
|
||||
}
|
||||
|
||||
// --- Images without alt ---
|
||||
const imgsNoAlt = Array.from(document.querySelectorAll('img:not([alt])'));
|
||||
if (imgsNoAlt.length === 0) {
|
||||
results.passes.push({ rule: '1.1.1', name: 'Image Alt Text', detail: 'All images have alt attributes' });
|
||||
} else {
|
||||
results.violations.push({ rule: '1.1.1', name: 'Image Alt Text', severity: 'high', detail: `${imgsNoAlt.length} images without alt attributes` });
|
||||
}
|
||||
|
||||
// --- Buttons without accessible names ---
|
||||
const emptyButtons = Array.from(document.querySelectorAll('button')).filter(b => {
|
||||
return !b.textContent.trim() && !b.getAttribute('aria-label') && !b.getAttribute('aria-labelledby') && !b.getAttribute('title');
|
||||
});
|
||||
if (emptyButtons.length === 0) {
|
||||
results.passes.push({ rule: '4.1.2', name: 'Button Names', detail: 'All buttons have accessible names' });
|
||||
} else {
|
||||
results.violations.push({ rule: '4.1.2', name: 'Button Names', severity: 'medium', detail: `${emptyButtons.length} buttons without accessible names` });
|
||||
}
|
||||
|
||||
// Summary
|
||||
results.summary = {
|
||||
violations: results.violations.length,
|
||||
passes: results.passes.length,
|
||||
warnings: results.warnings.length
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
return results;
|
||||
})();
|
||||
@@ -1,20 +1,582 @@
|
||||
import json
|
||||
from hermes_tools import browser_navigate, browser_vision
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
nexus_smoke_test.py — Visual Smoke Test for The Nexus.
|
||||
|
||||
def run_smoke_test():
|
||||
print("Navigating to The Nexus...")
|
||||
browser_navigate(url="https://nexus.alexanderwhitestone.com")
|
||||
|
||||
print("Performing visual verification...")
|
||||
analysis = browser_vision(
|
||||
question="Is the Nexus landing page rendered correctly? Check for: 1) The Tower logo, 2) The main entry portal, 3) Absence of 404/Error messages. Provide a clear PASS or FAIL."
|
||||
Takes screenshots of The Nexus landing page, verifies layout consistency
|
||||
using both programmatic checks (DOM structure, element presence) and
|
||||
optional vision model analysis (visual regression detection).
|
||||
|
||||
The Nexus is the Three.js 3D world frontend at nexus.alexanderwhitestone.com.
|
||||
This test ensures the landing page renders correctly on every push.
|
||||
|
||||
Usage:
|
||||
# Full smoke test (programmatic + optional vision)
|
||||
python scripts/nexus_smoke_test.py
|
||||
|
||||
# Programmatic only (no vision model needed, CI-safe)
|
||||
python scripts/nexus_smoke_test.py --programmatic
|
||||
|
||||
# With vision model regression check
|
||||
python scripts/nexus_smoke_test.py --vision
|
||||
|
||||
# Against a specific URL
|
||||
python scripts/nexus_smoke_test.py --url https://nexus.alexanderwhitestone.com
|
||||
|
||||
# With baseline comparison
|
||||
python scripts/nexus_smoke_test.py --baseline screenshots/nexus-baseline.png
|
||||
|
||||
Checks:
|
||||
1. Page loads without errors (HTTP 200, no console errors)
|
||||
2. Key elements present (Three.js canvas, title, navigation)
|
||||
3. No 404/error messages visible
|
||||
4. JavaScript bundle loaded (window.__nexus or scene exists)
|
||||
5. Screenshot captured successfully
|
||||
6. Vision model layout verification (optional)
|
||||
7. Baseline comparison for visual regression (optional)
|
||||
|
||||
Refs: timmy-config#490
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# === Configuration ===
|
||||
|
||||
DEFAULT_URL = os.environ.get("NEXUS_URL", "https://nexus.alexanderwhitestone.com")
|
||||
OLLAMA_BASE = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
VISION_MODEL = os.environ.get("VISUAL_REVIEW_MODEL", "gemma3:12b")
|
||||
|
||||
|
||||
class Severity(str, Enum):
|
||||
PASS = "pass"
|
||||
WARN = "warn"
|
||||
FAIL = "fail"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmokeCheck:
|
||||
"""A single smoke test check."""
|
||||
name: str
|
||||
status: Severity = Severity.PASS
|
||||
message: str = ""
|
||||
details: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmokeResult:
|
||||
"""Complete smoke test result."""
|
||||
url: str = ""
|
||||
status: Severity = Severity.PASS
|
||||
checks: list[SmokeCheck] = field(default_factory=list)
|
||||
screenshot_path: str = ""
|
||||
summary: str = ""
|
||||
duration_ms: int = 0
|
||||
|
||||
|
||||
# === HTTP/Network Checks ===
|
||||
|
||||
def check_page_loads(url: str) -> SmokeCheck:
|
||||
"""Verify the page returns HTTP 200."""
|
||||
check = SmokeCheck(name="Page Loads")
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "NexusSmokeTest/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
if resp.status == 200:
|
||||
check.status = Severity.PASS
|
||||
check.message = f"HTTP {resp.status}"
|
||||
else:
|
||||
check.status = Severity.WARN
|
||||
check.message = f"HTTP {resp.status} (expected 200)"
|
||||
except urllib.error.HTTPError as e:
|
||||
check.status = Severity.FAIL
|
||||
check.message = f"HTTP {e.code}: {e.reason}"
|
||||
except Exception as e:
|
||||
check.status = Severity.FAIL
|
||||
check.message = f"Connection failed: {e}"
|
||||
return check
|
||||
|
||||
|
||||
def check_html_content(url: str) -> tuple[SmokeCheck, str]:
|
||||
"""Fetch HTML and check for key content."""
|
||||
check = SmokeCheck(name="HTML Content")
|
||||
html = ""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "NexusSmokeTest/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
html = resp.read().decode("utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
check.status = Severity.FAIL
|
||||
check.message = f"Failed to fetch: {e}"
|
||||
return check, html
|
||||
|
||||
issues = []
|
||||
|
||||
# Check for Three.js
|
||||
if "three" not in html.lower() and "THREE" not in html and "threejs" not in html.lower():
|
||||
issues.append("No Three.js reference found")
|
||||
|
||||
# Check for canvas element
|
||||
if "<canvas" not in html.lower():
|
||||
issues.append("No <canvas> element found")
|
||||
|
||||
# Check title
|
||||
title_match = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
|
||||
if title_match:
|
||||
title = title_match.group(1).strip()
|
||||
check.details = f"Title: {title}"
|
||||
if "nexus" not in title.lower() and "tower" not in title.lower():
|
||||
issues.append(f"Title doesn't reference Nexus: '{title}'")
|
||||
else:
|
||||
issues.append("No <title> element")
|
||||
|
||||
# Check for error messages
|
||||
error_patterns = ["404", "not found", "error", "500 internal", "connection refused"]
|
||||
html_lower = html.lower()
|
||||
for pattern in error_patterns:
|
||||
if pattern in html_lower[:500] or pattern in html_lower[-500:]:
|
||||
issues.append(f"Possible error message in HTML: '{pattern}'")
|
||||
|
||||
# Check for script tags (app loaded)
|
||||
script_count = html.lower().count("<script")
|
||||
if script_count == 0:
|
||||
issues.append("No <script> tags found")
|
||||
else:
|
||||
check.details += f" | Scripts: {script_count}"
|
||||
|
||||
if issues:
|
||||
check.status = Severity.FAIL if len(issues) > 2 else Severity.WARN
|
||||
check.message = "; ".join(issues)
|
||||
else:
|
||||
check.status = Severity.PASS
|
||||
check.message = "HTML structure looks correct"
|
||||
|
||||
return check, html
|
||||
|
||||
|
||||
# === Screenshot Capture ===
|
||||
|
||||
def take_screenshot(url: str, output_path: str, width: int = 1280, height: int = 720) -> SmokeCheck:
|
||||
"""Take a screenshot of the page."""
|
||||
check = SmokeCheck(name="Screenshot Capture")
|
||||
|
||||
# Try Playwright
|
||||
try:
|
||||
script = f"""
|
||||
import sys
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
sys.exit(2)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={{"width": {width}, "height": {height}}})
|
||||
|
||||
errors = []
|
||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||
page.on("console", lambda m: errors.append(f"console.{{m.type}}: {{m.text}}") if m.type == "error" else None)
|
||||
|
||||
page.goto("{url}", wait_until="networkidle", timeout=30000)
|
||||
page.wait_for_timeout(3000) # Wait for Three.js to render
|
||||
page.screenshot(path="{output_path}", full_page=False)
|
||||
|
||||
# Check for Three.js scene
|
||||
has_canvas = page.evaluate("() => !!document.querySelector('canvas')")
|
||||
has_three = page.evaluate("() => typeof THREE !== 'undefined' || !!document.querySelector('canvas')")
|
||||
title = page.title()
|
||||
|
||||
browser.close()
|
||||
|
||||
import json
|
||||
print(json.dumps({{"has_canvas": has_canvas, "has_three": has_three, "title": title, "errors": errors[:5]}}))
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["python3", "-c", script],
|
||||
capture_output=True, text=True, timeout=60
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Parse Playwright output
|
||||
try:
|
||||
# Find JSON in output
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line.startswith("{"):
|
||||
info = json.loads(line)
|
||||
extras = []
|
||||
if info.get("has_canvas"):
|
||||
extras.append("canvas present")
|
||||
if info.get("errors"):
|
||||
extras.append(f"{len(info['errors'])} JS errors")
|
||||
check.details = "; ".join(extras) if extras else "Playwright capture"
|
||||
if info.get("errors"):
|
||||
check.status = Severity.WARN
|
||||
check.message = f"JS errors detected: {info['errors'][0][:100]}"
|
||||
else:
|
||||
check.message = "Screenshot captured via Playwright"
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if Path(output_path).exists() and Path(output_path).stat().st_size > 1000:
|
||||
return check
|
||||
elif result.returncode == 2:
|
||||
check.details = "Playwright not installed"
|
||||
else:
|
||||
check.details = f"Playwright failed: {result.stderr[:200]}"
|
||||
except Exception as e:
|
||||
check.details = f"Playwright error: {e}"
|
||||
|
||||
# Try wkhtmltoimage
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["wkhtmltoimage", "--width", str(width), "--quality", "90", url, output_path],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if result.returncode == 0 and Path(output_path).exists() and Path(output_path).stat().st_size > 1000:
|
||||
check.status = Severity.PASS
|
||||
check.message = "Screenshot captured via wkhtmltoimage"
|
||||
check.details = ""
|
||||
return check
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try curl + browserless (if available)
|
||||
browserless = os.environ.get("BROWSERLESS_URL")
|
||||
if browserless:
|
||||
try:
|
||||
payload = json.dumps({
|
||||
"url": url,
|
||||
"options": {"type": "png", "fullPage": False}
|
||||
})
|
||||
req = urllib.request.Request(
|
||||
f"{browserless}/screenshot",
|
||||
data=payload.encode(),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
img_data = resp.read()
|
||||
Path(output_path).write_bytes(img_data)
|
||||
if Path(output_path).stat().st_size > 1000:
|
||||
check.status = Severity.PASS
|
||||
check.message = "Screenshot captured via browserless"
|
||||
check.details = ""
|
||||
return check
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
check.status = Severity.WARN
|
||||
check.message = "No screenshot backend available"
|
||||
check.details = "Install Playwright: pip install playwright && playwright install chromium"
|
||||
return check
|
||||
|
||||
|
||||
# === Vision Analysis ===
|
||||
|
||||
VISION_PROMPT = """You are a web QA engineer. Analyze this screenshot of The Nexus (a Three.js 3D world).
|
||||
|
||||
Check for:
|
||||
1. LAYOUT: Is the page layout correct? Is content centered, not broken or overlapping?
|
||||
2. THREE.JS RENDER: Is there a visible 3D canvas/scene? Any black/blank areas where rendering failed?
|
||||
3. NAVIGATION: Are navigation elements (buttons, links, menu) visible and properly placed?
|
||||
4. TEXT: Is text readable? Any missing text, garbled characters, or font issues?
|
||||
5. ERRORS: Any visible error messages, 404 pages, or broken images?
|
||||
6. TOWER: Is the Tower or entry portal visible in the scene?
|
||||
|
||||
Respond as JSON:
|
||||
{
|
||||
"status": "PASS|FAIL|WARN",
|
||||
"checks": [
|
||||
{"name": "Layout", "status": "pass|fail|warn", "message": "..."},
|
||||
{"name": "Three.js Render", "status": "pass|fail|warn", "message": "..."},
|
||||
{"name": "Navigation", "status": "pass|fail|warn", "message": "..."},
|
||||
{"name": "Text Readability", "status": "pass|fail|warn", "message": "..."},
|
||||
{"name": "Error Messages", "status": "pass|fail|warn", "message": "..."}
|
||||
],
|
||||
"summary": "brief overall assessment"
|
||||
}"""
|
||||
|
||||
|
||||
def run_vision_check(screenshot_path: str, model: str = VISION_MODEL) -> list[SmokeCheck]:
|
||||
"""Run vision model analysis on screenshot."""
|
||||
checks = []
|
||||
try:
|
||||
b64 = base64.b64encode(Path(screenshot_path).read_bytes()).decode()
|
||||
payload = json.dumps({
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": VISION_PROMPT},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
||||
]}],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.1}
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{OLLAMA_BASE}/api/chat",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
result = json.loads(resp.read())
|
||||
content = result.get("message", {}).get("content", "")
|
||||
|
||||
parsed = _parse_json_response(content)
|
||||
for c in parsed.get("checks", []):
|
||||
status = Severity(c.get("status", "warn"))
|
||||
checks.append(SmokeCheck(
|
||||
name=f"Vision: {c.get('name', 'Unknown')}",
|
||||
status=status,
|
||||
message=c.get("message", "")
|
||||
))
|
||||
|
||||
if not checks:
|
||||
checks.append(SmokeCheck(
|
||||
name="Vision Analysis",
|
||||
status=Severity.WARN,
|
||||
message="Vision model returned no structured checks"
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
checks.append(SmokeCheck(
|
||||
name="Vision Analysis",
|
||||
status=Severity.WARN,
|
||||
message=f"Vision check failed: {e}"
|
||||
))
|
||||
|
||||
return checks
|
||||
|
||||
|
||||
# === Baseline Comparison ===
|
||||
|
||||
def compare_baseline(current_path: str, baseline_path: str) -> SmokeCheck:
|
||||
"""Compare screenshot against baseline for visual regression."""
|
||||
check = SmokeCheck(name="Baseline Comparison")
|
||||
|
||||
if not Path(baseline_path).exists():
|
||||
check.status = Severity.WARN
|
||||
check.message = f"Baseline not found: {baseline_path}"
|
||||
return check
|
||||
|
||||
if not Path(current_path).exists():
|
||||
check.status = Severity.FAIL
|
||||
check.message = "No current screenshot to compare"
|
||||
return check
|
||||
|
||||
# Simple file size comparison (rough regression indicator)
|
||||
baseline_size = Path(baseline_path).stat().st_size
|
||||
current_size = Path(current_path).stat().st_size
|
||||
|
||||
if baseline_size == 0:
|
||||
check.status = Severity.WARN
|
||||
check.message = "Baseline is empty"
|
||||
return check
|
||||
|
||||
diff_pct = abs(current_size - baseline_size) / baseline_size * 100
|
||||
|
||||
if diff_pct > 50:
|
||||
check.status = Severity.FAIL
|
||||
check.message = f"Major visual change: {diff_pct:.0f}% file size difference"
|
||||
elif diff_pct > 20:
|
||||
check.status = Severity.WARN
|
||||
check.message = f"Significant visual change: {diff_pct:.0f}% file size difference"
|
||||
else:
|
||||
check.status = Severity.PASS
|
||||
check.message = f"Visual consistency: {diff_pct:.1f}% difference"
|
||||
|
||||
check.details = f"Baseline: {baseline_size}B, Current: {current_size}B"
|
||||
|
||||
# Pixel-level diff using ImageMagick (if available)
|
||||
try:
|
||||
diff_output = current_path.replace(".png", "-diff.png")
|
||||
result = subprocess.run(
|
||||
["compare", "-metric", "AE", current_path, baseline_path, diff_output],
|
||||
capture_output=True, text=True, timeout=15
|
||||
)
|
||||
if result.returncode < 2:
|
||||
pixels_diff = int(result.stderr) if result.stderr.strip().isdigit() else 0
|
||||
check.details += f" | Pixel diff: {pixels_diff}"
|
||||
if pixels_diff > 10000:
|
||||
check.status = Severity.FAIL
|
||||
check.message = f"Major visual regression: {pixels_diff} pixels changed"
|
||||
elif pixels_diff > 1000:
|
||||
check.status = Severity.WARN
|
||||
check.message = f"Visual change detected: {pixels_diff} pixels changed"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return check
|
||||
|
||||
|
||||
# === Helpers ===
|
||||
|
||||
def _parse_json_response(text: str) -> dict:
|
||||
cleaned = text.strip()
|
||||
if cleaned.startswith("```"):
|
||||
lines = cleaned.split("\n")[1:]
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
cleaned = "\n".join(lines)
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except json.JSONDecodeError:
|
||||
start = cleaned.find("{")
|
||||
end = cleaned.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
return json.loads(cleaned[start:end + 1])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
# === Main Smoke Test ===
|
||||
|
||||
def run_smoke_test(url: str, vision: bool = False, baseline: Optional[str] = None,
|
||||
model: str = VISION_MODEL) -> SmokeResult:
|
||||
"""Run the full visual smoke test suite."""
|
||||
import time
|
||||
start = time.time()
|
||||
|
||||
result = SmokeResult(url=url)
|
||||
screenshot_path = ""
|
||||
|
||||
# 1. Page loads
|
||||
print(f" [1/5] Checking page loads...", file=sys.stderr)
|
||||
result.checks.append(check_page_loads(url))
|
||||
|
||||
# 2. HTML content
|
||||
print(f" [2/5] Checking HTML content...", file=sys.stderr)
|
||||
html_check, html = check_html_content(url)
|
||||
result.checks.append(html_check)
|
||||
|
||||
# 3. Screenshot
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
screenshot_path = tmp.name
|
||||
print(f" [3/5] Taking screenshot...", file=sys.stderr)
|
||||
screenshot_check = take_screenshot(url, screenshot_path)
|
||||
result.checks.append(screenshot_check)
|
||||
result.screenshot_path = screenshot_path
|
||||
|
||||
# 4. Vision analysis (optional)
|
||||
if vision and Path(screenshot_path).exists():
|
||||
print(f" [4/5] Running vision analysis...", file=sys.stderr)
|
||||
result.checks.extend(run_vision_check(screenshot_path, model))
|
||||
else:
|
||||
print(f" [4/5] Vision analysis skipped", file=sys.stderr)
|
||||
|
||||
# 5. Baseline comparison (optional)
|
||||
if baseline:
|
||||
print(f" [5/5] Comparing against baseline...", file=sys.stderr)
|
||||
result.checks.append(compare_baseline(screenshot_path, baseline))
|
||||
else:
|
||||
print(f" [5/5] Baseline comparison skipped", file=sys.stderr)
|
||||
|
||||
# Determine overall status
|
||||
fails = sum(1 for c in result.checks if c.status == Severity.FAIL)
|
||||
warns = sum(1 for c in result.checks if c.status == Severity.WARN)
|
||||
|
||||
if fails > 0:
|
||||
result.status = Severity.FAIL
|
||||
elif warns > 0:
|
||||
result.status = Severity.WARN
|
||||
else:
|
||||
result.status = Severity.PASS
|
||||
|
||||
result.summary = (
|
||||
f"{result.status.value.upper()}: {len(result.checks)} checks, "
|
||||
f"{fails} failures, {warns} warnings"
|
||||
)
|
||||
|
||||
result = {
|
||||
"status": "PASS" if "PASS" in analysis.upper() else "FAIL",
|
||||
"analysis": analysis
|
||||
}
|
||||
result.duration_ms = int((time.time() - start) * 1000)
|
||||
|
||||
return result
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps(run_smoke_test(), indent=2))
|
||||
|
||||
# === Output ===
|
||||
|
||||
def format_result(result: SmokeResult, fmt: str = "json") -> str:
|
||||
if fmt == "json":
|
||||
data = {
|
||||
"url": result.url,
|
||||
"status": result.status.value,
|
||||
"summary": result.summary,
|
||||
"duration_ms": result.duration_ms,
|
||||
"screenshot": result.screenshot_path,
|
||||
"checks": [asdict(c) for c in result.checks],
|
||||
}
|
||||
for c in data["checks"]:
|
||||
if hasattr(c["status"], "value"):
|
||||
c["status"] = c["status"].value
|
||||
return json.dumps(data, indent=2)
|
||||
|
||||
elif fmt == "text":
|
||||
lines = [
|
||||
"=" * 50,
|
||||
" NEXUS VISUAL SMOKE TEST",
|
||||
"=" * 50,
|
||||
f" URL: {result.url}",
|
||||
f" Status: {result.status.value.upper()}",
|
||||
f" Duration: {result.duration_ms}ms",
|
||||
"",
|
||||
]
|
||||
icons = {"pass": "✅", "warn": "⚠️", "fail": "❌"}
|
||||
for c in result.checks:
|
||||
icon = icons.get(c.status.value if hasattr(c.status, "value") else str(c.status), "?")
|
||||
lines.append(f" {icon} {c.name}: {c.message}")
|
||||
if c.details:
|
||||
lines.append(f" {c.details}")
|
||||
lines.append("")
|
||||
lines.append(f" {result.summary}")
|
||||
lines.append("=" * 50)
|
||||
return "\n".join(lines)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
# === CLI ===
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Visual Smoke Test for The Nexus — layout + regression verification"
|
||||
)
|
||||
parser.add_argument("--url", default=DEFAULT_URL, help=f"Nexus URL (default: {DEFAULT_URL})")
|
||||
parser.add_argument("--vision", action="store_true", help="Include vision model analysis")
|
||||
parser.add_argument("--baseline", help="Baseline screenshot for regression comparison")
|
||||
parser.add_argument("--model", default=VISION_MODEL, help=f"Vision model (default: {VISION_MODEL})")
|
||||
parser.add_argument("--format", choices=["json", "text"], default="json")
|
||||
parser.add_argument("--output", "-o", help="Output file (default: stdout)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Running smoke test on {args.url}...", file=sys.stderr)
|
||||
result = run_smoke_test(args.url, vision=args.vision, baseline=args.baseline, model=args.model)
|
||||
output = format_result(result, args.format)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output)
|
||||
print(f"Results written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
if result.status == Severity.FAIL:
|
||||
sys.exit(1)
|
||||
elif result.status == Severity.WARN:
|
||||
sys.exit(0) # Warnings don't fail CI
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
123
tests/test_nexus_smoke_test.py
Normal file
123
tests/test_nexus_smoke_test.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for nexus_smoke_test.py — verifies smoke test logic."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
|
||||
from nexus_smoke_test import (
|
||||
Severity, SmokeCheck, SmokeResult,
|
||||
format_result, _parse_json_response,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_json_clean():
|
||||
result = _parse_json_response('{"status": "PASS", "summary": "ok"}')
|
||||
assert result["status"] == "PASS"
|
||||
print(" PASS: test_parse_json_clean")
|
||||
|
||||
|
||||
def test_parse_json_fenced():
|
||||
result = _parse_json_response('```json\n{"status": "FAIL"}\n```')
|
||||
assert result["status"] == "FAIL"
|
||||
print(" PASS: test_parse_json_fenced")
|
||||
|
||||
|
||||
def test_parse_json_garbage():
|
||||
result = _parse_json_response("no json here")
|
||||
assert result == {}
|
||||
print(" PASS: test_parse_json_garbage")
|
||||
|
||||
|
||||
def test_smoke_check_dataclass():
|
||||
c = SmokeCheck(name="Test", status=Severity.PASS, message="All good")
|
||||
assert c.name == "Test"
|
||||
assert c.status == Severity.PASS
|
||||
print(" PASS: test_smoke_check_dataclass")
|
||||
|
||||
|
||||
def test_smoke_result_dataclass():
|
||||
r = SmokeResult(url="https://example.com", status=Severity.PASS)
|
||||
r.checks.append(SmokeCheck(name="Page Loads", status=Severity.PASS))
|
||||
assert len(r.checks) == 1
|
||||
assert r.url == "https://example.com"
|
||||
print(" PASS: test_smoke_result_dataclass")
|
||||
|
||||
|
||||
def test_format_json():
|
||||
r = SmokeResult(url="https://test.com", status=Severity.PASS, summary="All good", duration_ms=100)
|
||||
r.checks.append(SmokeCheck(name="Test", status=Severity.PASS, message="OK"))
|
||||
output = format_result(r, "json")
|
||||
parsed = json.loads(output)
|
||||
assert parsed["status"] == "pass"
|
||||
assert parsed["url"] == "https://test.com"
|
||||
assert len(parsed["checks"]) == 1
|
||||
print(" PASS: test_format_json")
|
||||
|
||||
|
||||
def test_format_text():
|
||||
r = SmokeResult(url="https://test.com", status=Severity.WARN, summary="1 warning", duration_ms=200)
|
||||
r.checks.append(SmokeCheck(name="Screenshot", status=Severity.WARN, message="No backend"))
|
||||
output = format_result(r, "text")
|
||||
assert "NEXUS VISUAL SMOKE TEST" in output
|
||||
assert "https://test.com" in output
|
||||
assert "WARN" in output
|
||||
print(" PASS: test_format_text")
|
||||
|
||||
|
||||
def test_format_text_pass():
|
||||
r = SmokeResult(url="https://test.com", status=Severity.PASS, summary="All clear")
|
||||
r.checks.append(SmokeCheck(name="Page Loads", status=Severity.PASS, message="HTTP 200"))
|
||||
r.checks.append(SmokeCheck(name="HTML Content", status=Severity.PASS, message="Valid"))
|
||||
output = format_result(r, "text")
|
||||
assert "✅" in output
|
||||
assert "Page Loads" in output
|
||||
print(" PASS: test_format_text")
|
||||
|
||||
|
||||
def test_severity_enum():
|
||||
assert Severity.PASS.value == "pass"
|
||||
assert Severity.FAIL.value == "fail"
|
||||
assert Severity.WARN.value == "warn"
|
||||
print(" PASS: test_severity_enum")
|
||||
|
||||
|
||||
def test_overall_status_logic():
|
||||
# All pass
|
||||
r = SmokeResult()
|
||||
r.checks = [SmokeCheck(name="a", status=Severity.PASS), SmokeCheck(name="b", status=Severity.PASS)]
|
||||
fails = sum(1 for c in r.checks if c.status == Severity.FAIL)
|
||||
warns = sum(1 for c in r.checks if c.status == Severity.WARN)
|
||||
assert fails == 0 and warns == 0
|
||||
|
||||
# One fail
|
||||
r.checks.append(SmokeCheck(name="c", status=Severity.FAIL))
|
||||
fails = sum(1 for c in r.checks if c.status == Severity.FAIL)
|
||||
assert fails == 1
|
||||
print(" PASS: test_overall_status_logic")
|
||||
|
||||
|
||||
def run_all():
|
||||
print("=== nexus_smoke_test tests ===")
|
||||
tests = [
|
||||
test_parse_json_clean, test_parse_json_fenced, test_parse_json_garbage,
|
||||
test_smoke_check_dataclass, test_smoke_result_dataclass,
|
||||
test_format_json, test_format_text, test_format_text_pass,
|
||||
test_severity_enum, test_overall_status_logic,
|
||||
]
|
||||
passed = failed = 0
|
||||
for t in tests:
|
||||
try:
|
||||
t()
|
||||
passed += 1
|
||||
except Exception as e:
|
||||
print(f" FAIL: {t.__name__} — {e}")
|
||||
failed += 1
|
||||
print(f"\n{'ALL PASSED' if failed == 0 else f'{failed} FAILED'}: {passed}/{len(tests)}")
|
||||
return failed == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(0 if run_all() else 1)
|
||||
Reference in New Issue
Block a user