Co-authored-by: Alexander Whitestone <alexander@alexanderwhitestone.com> Co-committed-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
284 lines
10 KiB
Python
284 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Capture automated screenshots of all primary Nexus zones.
|
|
|
|
Part of Epic 1: Visual QA for Nexus World.
|
|
Uses Selenium + Chrome headless to navigate each dashboard zone and
|
|
save full-page screenshots for visual audit.
|
|
|
|
Usage:
|
|
# Start the dashboard first (in another terminal):
|
|
PYTHONPATH=src python3 -m uvicorn dashboard.app:app --host 127.0.0.1 --port 8000
|
|
|
|
# Then run this script:
|
|
python3 scripts/capture_nexus_screenshots.py [--base-url http://127.0.0.1:8000] [--output-dir data/nexus_screenshots]
|
|
|
|
Requirements:
|
|
pip install selenium Pillow
|
|
Chrome/Chromium browser installed
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from selenium import webdriver
|
|
from selenium.webdriver.chrome.options import Options
|
|
from selenium.webdriver.chrome.service import Service
|
|
from selenium.webdriver.support.ui import WebDriverWait
|
|
from selenium.webdriver.support import expected_conditions as EC
|
|
from selenium.webdriver.common.by import By
|
|
from selenium.common.exceptions import (
|
|
TimeoutException,
|
|
WebDriverException,
|
|
)
|
|
|
|
# ── Primary Nexus Zones ──────────────────────────────────────────────────────
|
|
# These are the main HTML page routes of the Timmy dashboard.
|
|
# API endpoints, HTMX partials, and WebSocket routes are excluded.
|
|
|
|
PRIMARY_ZONES: list[dict] = [
|
|
{"path": "/", "name": "landing", "description": "Public landing page"},
|
|
{"path": "/dashboard", "name": "dashboard", "description": "Main mission control dashboard"},
|
|
{"path": "/nexus", "name": "nexus", "description": "Nexus conversational awareness space"},
|
|
{"path": "/agents", "name": "agents", "description": "Agent management panel"},
|
|
{"path": "/briefing", "name": "briefing", "description": "Daily briefing view"},
|
|
{"path": "/calm", "name": "calm", "description": "Calm ritual space"},
|
|
{"path": "/thinking", "name": "thinking", "description": "Thinking engine visualization"},
|
|
{"path": "/memory", "name": "memory", "description": "Memory system explorer"},
|
|
{"path": "/tasks", "name": "tasks", "description": "Task management"},
|
|
{"path": "/experiments", "name": "experiments", "description": "Experiments dashboard"},
|
|
{"path": "/monitoring", "name": "monitoring", "description": "System monitoring"},
|
|
{"path": "/tower", "name": "tower", "description": "Tower world view"},
|
|
{"path": "/tools", "name": "tools", "description": "Tools overview"},
|
|
{"path": "/voice/settings", "name": "voice-settings", "description": "Voice/TTS settings"},
|
|
{"path": "/scorecards", "name": "scorecards", "description": "Agent scorecards"},
|
|
{"path": "/quests", "name": "quests", "description": "Quest tracking"},
|
|
{"path": "/spark", "name": "spark", "description": "Spark intelligence UI"},
|
|
{"path": "/self-correction/ui", "name": "self-correction", "description": "Self-correction interface"},
|
|
{"path": "/energy/report", "name": "energy", "description": "Energy management report"},
|
|
{"path": "/creative/ui", "name": "creative", "description": "Creative generation UI"},
|
|
{"path": "/mobile", "name": "mobile", "description": "Mobile companion view"},
|
|
{"path": "/db-explorer", "name": "db-explorer", "description": "Database explorer"},
|
|
{"path": "/bugs", "name": "bugs", "description": "Bug tracker"},
|
|
{"path": "/self-coding", "name": "self-coding", "description": "Self-coding interface"},
|
|
]
|
|
|
|
# ── Defaults ─────────────────────────────────────────────────────────────────
|
|
|
|
DEFAULT_BASE_URL = "http://127.0.0.1:8000"
|
|
DEFAULT_OUTPUT_DIR = "data/nexus_screenshots"
|
|
DEFAULT_WIDTH = 1920
|
|
DEFAULT_HEIGHT = 1080
|
|
PAGE_LOAD_TIMEOUT = 15 # seconds
|
|
|
|
|
|
def create_driver(width: int, height: int) -> webdriver.Chrome:
|
|
"""Create a headless Chrome driver with the given viewport size."""
|
|
options = Options()
|
|
options.add_argument("--headless=new")
|
|
options.add_argument("--no-sandbox")
|
|
options.add_argument("--disable-dev-shm-usage")
|
|
options.add_argument("--disable-gpu")
|
|
options.add_argument(f"--window-size={width},{height}")
|
|
options.add_argument("--hide-scrollbars")
|
|
options.add_argument("--force-device-scale-factor=1")
|
|
|
|
# Try common Chrome paths
|
|
chrome_paths = [
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
"/usr/bin/google-chrome",
|
|
"/usr/bin/chromium",
|
|
"/usr/bin/chromium-browser",
|
|
]
|
|
|
|
for path in chrome_paths:
|
|
if os.path.exists(path):
|
|
options.binary_location = path
|
|
break
|
|
|
|
driver = webdriver.Chrome(options=options)
|
|
driver.set_window_size(width, height)
|
|
return driver
|
|
|
|
|
|
def capture_zone(
|
|
driver: webdriver.Chrome,
|
|
base_url: str,
|
|
zone: dict,
|
|
output_dir: Path,
|
|
timeout: int = PAGE_LOAD_TIMEOUT,
|
|
) -> dict:
|
|
"""Capture a screenshot of a single Nexus zone.
|
|
|
|
Returns a result dict with status, file path, and metadata.
|
|
"""
|
|
url = base_url.rstrip("/") + zone["path"]
|
|
name = zone["name"]
|
|
screenshot_path = output_dir / f"{name}.png"
|
|
result = {
|
|
"zone": name,
|
|
"path": zone["path"],
|
|
"url": url,
|
|
"description": zone["description"],
|
|
"screenshot": str(screenshot_path),
|
|
"status": "pending",
|
|
"error": None,
|
|
"timestamp": None,
|
|
}
|
|
|
|
try:
|
|
print(f" Capturing {zone['path']:30s} → {name}...", end=" ", flush=True)
|
|
driver.get(url)
|
|
|
|
# Wait for body to be present (basic page load)
|
|
try:
|
|
WebDriverWait(driver, timeout).until(
|
|
EC.presence_of_element_located((By.TAG_NAME, "body"))
|
|
)
|
|
except TimeoutException:
|
|
result["status"] = "timeout"
|
|
result["error"] = f"Page load timed out after {timeout}s"
|
|
print(f"TIMEOUT ({timeout}s)")
|
|
return result
|
|
|
|
# Additional wait for JS frameworks to render
|
|
time.sleep(2)
|
|
|
|
# Capture full-page screenshot (scroll to capture all content)
|
|
total_height = driver.execute_script("return document.body.scrollHeight")
|
|
driver.set_window_size(DEFAULT_WIDTH, max(DEFAULT_HEIGHT, total_height))
|
|
time.sleep(0.5)
|
|
|
|
# Save screenshot
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
driver.save_screenshot(str(screenshot_path))
|
|
|
|
# Capture page title for metadata
|
|
title = driver.title or "(no title)"
|
|
|
|
result["status"] = "ok"
|
|
result["timestamp"] = datetime.now(timezone.utc).isoformat()
|
|
result["page_title"] = title
|
|
result["file_size"] = screenshot_path.stat().st_size if screenshot_path.exists() else 0
|
|
print(f"OK — {title} ({result['file_size']:,} bytes)")
|
|
|
|
except WebDriverException as exc:
|
|
result["status"] = "error"
|
|
result["error"] = str(exc)[:200]
|
|
print(f"ERROR — {str(exc)[:100]}")
|
|
|
|
return result
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Capture screenshots of all primary Nexus zones."
|
|
)
|
|
parser.add_argument(
|
|
"--base-url",
|
|
default=DEFAULT_BASE_URL,
|
|
help=f"Dashboard base URL (default: {DEFAULT_BASE_URL})",
|
|
)
|
|
parser.add_argument(
|
|
"--output-dir",
|
|
default=DEFAULT_OUTPUT_DIR,
|
|
help=f"Output directory for screenshots (default: {DEFAULT_OUTPUT_DIR})",
|
|
)
|
|
parser.add_argument(
|
|
"--width",
|
|
type=int,
|
|
default=DEFAULT_WIDTH,
|
|
help=f"Viewport width (default: {DEFAULT_WIDTH})",
|
|
)
|
|
parser.add_argument(
|
|
"--height",
|
|
type=int,
|
|
default=DEFAULT_HEIGHT,
|
|
help=f"Viewport height (default: {DEFAULT_HEIGHT})",
|
|
)
|
|
parser.add_argument(
|
|
"--timeout",
|
|
type=int,
|
|
default=PAGE_LOAD_TIMEOUT,
|
|
help=f"Page load timeout in seconds (default: {PAGE_LOAD_TIMEOUT})",
|
|
)
|
|
parser.add_argument(
|
|
"--zones",
|
|
nargs="*",
|
|
help="Specific zone names to capture (default: all)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
output_dir = Path(args.output_dir)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Filter zones if specific ones requested
|
|
zones = PRIMARY_ZONES
|
|
if args.zones:
|
|
zones = [z for z in PRIMARY_ZONES if z["name"] in args.zones]
|
|
if not zones:
|
|
print(f"Error: No matching zones found for: {args.zones}")
|
|
print(f"Available: {[z['name'] for z in PRIMARY_ZONES]}")
|
|
return 1
|
|
|
|
print(f"Nexus Screenshot Capture")
|
|
print(f" Base URL: {args.base_url}")
|
|
print(f" Output dir: {output_dir}")
|
|
print(f" Viewport: {args.width}x{args.height}")
|
|
print(f" Zones: {len(zones)}")
|
|
print()
|
|
|
|
# Create driver
|
|
try:
|
|
driver = create_driver(args.width, args.height)
|
|
except WebDriverException as exc:
|
|
print(f"Failed to create Chrome driver: {exc}")
|
|
return 1
|
|
|
|
results = []
|
|
try:
|
|
for zone in zones:
|
|
result = capture_zone(
|
|
driver, args.base_url, zone, output_dir, timeout=args.timeout
|
|
)
|
|
results.append(result)
|
|
finally:
|
|
driver.quit()
|
|
|
|
# Write manifest
|
|
manifest = {
|
|
"captured_at": datetime.now(timezone.utc).isoformat(),
|
|
"base_url": args.base_url,
|
|
"viewport": {"width": args.width, "height": args.height},
|
|
"total_zones": len(zones),
|
|
"ok": sum(1 for r in results if r["status"] == "ok"),
|
|
"errors": sum(1 for r in results if r["status"] != "ok"),
|
|
"zones": results,
|
|
}
|
|
|
|
manifest_path = output_dir / "manifest.json"
|
|
with open(manifest_path, "w") as f:
|
|
json.dump(manifest, f, indent=2)
|
|
|
|
print()
|
|
print(f"Done! {manifest['ok']}/{manifest['total_zones']} zones captured successfully.")
|
|
print(f"Manifest: {manifest_path}")
|
|
|
|
if manifest["errors"] > 0:
|
|
print(f"\nFailed zones:")
|
|
for r in results:
|
|
if r["status"] != "ok":
|
|
print(f" {r['zone']:20s} — {r['status']}: {r['error']}")
|
|
|
|
return 0 if manifest["errors"] == 0 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|