294 lines
11 KiB
Python
294 lines
11 KiB
Python
"""
|
|
Browser smoke tests for the Nexus 3D world.
|
|
Uses Playwright to verify the DOM contract, Three.js initialization,
|
|
portal loading, and loading screen flow.
|
|
|
|
Refs: #686
|
|
"""
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from playwright.sync_api import sync_playwright, expect
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
SCREENSHOT_DIR = REPO_ROOT / "test-screenshots"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture(scope="module")
|
|
def http_server():
|
|
"""Start a simple HTTP server for the Nexus static files."""
|
|
import http.server
|
|
import threading
|
|
|
|
port = int(os.environ.get("NEXUS_TEST_PORT", "9876"))
|
|
handler = http.server.SimpleHTTPRequestHandler
|
|
server = http.server.HTTPServer(("127.0.0.1", port), handler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
time.sleep(0.3)
|
|
yield f"http://127.0.0.1:{port}"
|
|
server.shutdown()
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def browser_page(http_server):
|
|
"""Launch a headless browser and navigate to the Nexus."""
|
|
SCREENSHOT_DIR.mkdir(exist_ok=True)
|
|
with sync_playwright() as pw:
|
|
browser = pw.chromium.launch(
|
|
headless=True,
|
|
args=["--no-sandbox", "--disable-gpu"],
|
|
)
|
|
context = browser.new_context(
|
|
viewport={"width": 1280, "height": 720},
|
|
ignore_https_errors=True,
|
|
)
|
|
page = context.new_page()
|
|
# Collect console errors
|
|
console_errors = []
|
|
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
|
|
page.goto(http_server, wait_until="domcontentloaded", timeout=30000)
|
|
page._console_errors = console_errors
|
|
yield page
|
|
browser.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Static asset tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestStaticAssets:
|
|
"""Verify all contract files are serveable."""
|
|
|
|
REQUIRED_FILES = [
|
|
"index.html",
|
|
"app.js",
|
|
"style.css",
|
|
"portals.json",
|
|
"vision.json",
|
|
"manifest.json",
|
|
"gofai_worker.js",
|
|
]
|
|
|
|
def test_index_html_served(self, http_server):
|
|
"""index.html must return 200."""
|
|
import urllib.request
|
|
resp = urllib.request.urlopen(f"{http_server}/index.html")
|
|
assert resp.status == 200
|
|
|
|
@pytest.mark.parametrize("filename", REQUIRED_FILES)
|
|
def test_contract_file_served(self, http_server, filename):
|
|
"""Each contract file must return 200."""
|
|
import urllib.request
|
|
try:
|
|
resp = urllib.request.urlopen(f"{http_server}/{filename}")
|
|
assert resp.status == 200
|
|
except Exception as e:
|
|
pytest.fail(f"{filename} not serveable: {e}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DOM contract tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDOMContract:
|
|
"""Verify required DOM elements exist after page load."""
|
|
|
|
REQUIRED_ELEMENTS = {
|
|
"nexus-canvas": "canvas",
|
|
"hud": "div",
|
|
"chat-panel": "div",
|
|
"chat-input": "input",
|
|
"chat-messages": "div",
|
|
"chat-send": "button",
|
|
"chat-toggle": "button",
|
|
"debug-overlay": "div",
|
|
"nav-mode-label": "span",
|
|
"ws-status-dot": "span",
|
|
"hud-location-text": "span",
|
|
"portal-hint": "div",
|
|
"spatial-search": "div",
|
|
}
|
|
|
|
@pytest.mark.parametrize("element_id,tag", list(REQUIRED_ELEMENTS.items()))
|
|
def test_element_exists(self, browser_page, element_id, tag):
|
|
"""Element with given ID must exist in the DOM."""
|
|
el = browser_page.query_selector(f"#{element_id}")
|
|
assert el is not None, f"#{element_id} ({tag}) missing from DOM"
|
|
|
|
def test_canvas_has_webgl(self, browser_page):
|
|
"""The nexus-canvas must have a WebGL rendering context."""
|
|
has_webgl = browser_page.evaluate("""
|
|
() => {
|
|
const c = document.getElementById('nexus-canvas');
|
|
if (!c) return false;
|
|
const ctx = c.getContext('webgl2') || c.getContext('webgl');
|
|
return ctx !== null;
|
|
}
|
|
""")
|
|
assert has_webgl, "nexus-canvas has no WebGL context"
|
|
|
|
def test_title_contains_nexus(self, browser_page):
|
|
"""Page title should reference The Nexus."""
|
|
title = browser_page.title()
|
|
assert "nexus" in title.lower() or "timmy" in title.lower(), f"Unexpected title: {title}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Loading flow tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLoadingFlow:
|
|
"""Verify the loading screen → enter prompt → HUD flow."""
|
|
|
|
def test_loading_screen_transitions(self, browser_page):
|
|
"""Loading screen should fade out and HUD should become visible."""
|
|
# Wait for loading to complete and enter prompt to appear
|
|
try:
|
|
browser_page.wait_for_selector("#enter-prompt", state="visible", timeout=15000)
|
|
except Exception:
|
|
# Enter prompt may have already appeared and been clicked
|
|
pass
|
|
|
|
# Try clicking the enter prompt if it exists
|
|
enter = browser_page.query_selector("#enter-prompt")
|
|
if enter and enter.is_visible():
|
|
enter.click()
|
|
time.sleep(1)
|
|
|
|
# HUD should now be visible
|
|
hud = browser_page.query_selector("#hud")
|
|
assert hud is not None, "HUD element missing"
|
|
# After enter, HUD display should not be 'none'
|
|
display = browser_page.evaluate("() => document.getElementById('hud').style.display")
|
|
assert display != "none", "HUD should be visible after entering"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Three.js initialization tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestThreeJSInit:
|
|
"""Verify Three.js initialized properly."""
|
|
|
|
def test_three_loaded(self, browser_page):
|
|
"""THREE namespace should be available (via import map)."""
|
|
# Three.js is loaded as ES module, check for canvas context instead
|
|
has_canvas = browser_page.evaluate("""
|
|
() => {
|
|
const c = document.getElementById('nexus-canvas');
|
|
return c && c.width > 0 && c.height > 0;
|
|
}
|
|
""")
|
|
assert has_canvas, "Canvas not properly initialized"
|
|
|
|
def test_canvas_dimensions(self, browser_page):
|
|
"""Canvas should fill the viewport."""
|
|
dims = browser_page.evaluate("""
|
|
() => {
|
|
const c = document.getElementById('nexus-canvas');
|
|
return { width: c.width, height: c.height, ww: window.innerWidth, wh: window.innerHeight };
|
|
}
|
|
""")
|
|
assert dims["width"] > 0, "Canvas width is 0"
|
|
assert dims["height"] > 0, "Canvas height is 0"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data contract tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDataContract:
|
|
"""Verify JSON data files are valid and well-formed."""
|
|
|
|
def test_portals_json_valid(self):
|
|
"""portals.json must parse as a non-empty JSON array."""
|
|
data = json.loads((REPO_ROOT / "portals.json").read_text())
|
|
assert isinstance(data, list), "portals.json must be an array"
|
|
assert len(data) > 0, "portals.json must have at least one portal"
|
|
|
|
def test_portals_have_required_fields(self):
|
|
"""Each portal must have id, name, status, destination."""
|
|
data = json.loads((REPO_ROOT / "portals.json").read_text())
|
|
required = {"id", "name", "status", "destination"}
|
|
for i, portal in enumerate(data):
|
|
missing = required - set(portal.keys())
|
|
assert not missing, f"Portal {i} missing fields: {missing}"
|
|
|
|
def test_vision_json_valid(self):
|
|
"""vision.json must parse as valid JSON."""
|
|
data = json.loads((REPO_ROOT / "vision.json").read_text())
|
|
assert data is not None
|
|
|
|
def test_manifest_json_valid(self):
|
|
"""manifest.json must have required PWA fields."""
|
|
data = json.loads((REPO_ROOT / "manifest.json").read_text())
|
|
for key in ["name", "start_url", "theme_color"]:
|
|
assert key in data, f"manifest.json missing '{key}'"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Screenshot / visual proof
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestVisualProof:
|
|
"""Capture screenshots as visual validation evidence."""
|
|
|
|
def test_screenshot_initial_state(self, browser_page):
|
|
"""Take a screenshot of the initial page state."""
|
|
path = SCREENSHOT_DIR / "smoke-initial.png"
|
|
browser_page.screenshot(path=str(path))
|
|
assert path.exists(), "Screenshot was not saved"
|
|
assert path.stat().st_size > 1000, "Screenshot seems empty"
|
|
|
|
def test_screenshot_after_enter(self, browser_page):
|
|
"""Take a screenshot after clicking through the enter prompt."""
|
|
enter = browser_page.query_selector("#enter-prompt")
|
|
if enter and enter.is_visible():
|
|
enter.click()
|
|
time.sleep(2)
|
|
else:
|
|
time.sleep(1)
|
|
|
|
path = SCREENSHOT_DIR / "smoke-post-enter.png"
|
|
browser_page.screenshot(path=str(path))
|
|
assert path.exists()
|
|
|
|
def test_screenshot_fullscreen(self, browser_page):
|
|
"""Full-page screenshot for visual regression baseline."""
|
|
path = SCREENSHOT_DIR / "smoke-fullscreen.png"
|
|
browser_page.screenshot(path=str(path), full_page=True)
|
|
assert path.exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Provenance in browser context
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBrowserProvenance:
|
|
"""Verify provenance from within the browser context."""
|
|
|
|
def test_page_served_from_correct_origin(self, http_server):
|
|
"""The page must be served from localhost, not a stale remote."""
|
|
import urllib.request
|
|
resp = urllib.request.urlopen(f"{http_server}/index.html")
|
|
content = resp.read().decode("utf-8", errors="replace")
|
|
# Must not contain references to legacy matrix path
|
|
assert "/Users/apayne/the-matrix" not in content, \
|
|
"index.html references legacy matrix path — provenance violation"
|
|
|
|
def test_index_html_has_nexus_title(self, http_server):
|
|
"""index.html title must reference The Nexus."""
|
|
import urllib.request
|
|
resp = urllib.request.urlopen(f"{http_server}/index.html")
|
|
content = resp.read().decode("utf-8", errors="replace")
|
|
assert "<title>The Nexus" in content or "Timmy" in content, \
|
|
"index.html title does not reference The Nexus"
|