Some checks failed
Architecture Lint / Linter Tests (push) Successful in 15s
Smoke Test / smoke (push) Failing after 14s
Validate Config / YAML Lint (push) Failing after 13s
Validate Config / JSON Validate (push) Successful in 13s
Validate Config / Shell Script Lint (push) Failing after 40s
Validate Config / Python Syntax & Import Check (push) Failing after 58s
Validate Config / Python Test Suite (push) Has been skipped
Validate Config / Cron Syntax Check (push) Successful in 11s
Validate Config / Deploy Script Dry Run (push) Successful in 11s
Validate Config / Playbook Schema Validation (push) Successful in 18s
Architecture Lint / Lint Repository (push) Failing after 13s
Architecture Lint / Linter Tests (pull_request) Successful in 26s
Smoke Test / smoke (pull_request) Failing after 17s
Validate Config / YAML Lint (pull_request) Failing after 12s
Validate Config / JSON Validate (pull_request) Successful in 12s
PR Checklist / pr-checklist (pull_request) Failing after 3m36s
Validate Config / Shell Script Lint (pull_request) Failing after 40s
Validate Config / Python Syntax & Import Check (pull_request) Failing after 1m4s
Validate Config / Python Test Suite (pull_request) Has been skipped
Validate Config / Cron Syntax Check (pull_request) Successful in 9s
Validate Config / Deploy Script Dry Run (pull_request) Successful in 9s
Validate Config / Playbook Schema Validation (pull_request) Successful in 20s
Architecture Lint / Lint Repository (pull_request) Failing after 16s
Merge PR #558
583 lines
20 KiB
Python
583 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
nexus_smoke_test.py — Visual Smoke Test for The Nexus.
|
|
|
|
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.duration_ms = int((time.time() - start) * 1000)
|
|
|
|
return result
|
|
|
|
|
|
# === 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()
|