From 6f1264f6c6d2bc2db8f8a1c48a66d79f523d84d7 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Fri, 10 Apr 2026 21:17:44 -0400 Subject: [PATCH] WIP: Browser smoke tests (issue #686) --- .gitignore | 1 + BROWSER_CONTRACT.md | 83 +++++ .../generate_provenance.cpython-312.pyc | Bin 0 -> 5704 bytes bin/browser_smoke.sh | 69 +++++ bin/generate_provenance.py | 131 ++++++++ provenance.json | 62 ++++ tests/test_browser_smoke.py | 293 ++++++++++++++++++ tests/test_provenance.py | 73 +++++ 8 files changed, 712 insertions(+) create mode 100644 BROWSER_CONTRACT.md create mode 100644 bin/__pycache__/generate_provenance.cpython-312.pyc create mode 100755 bin/browser_smoke.sh create mode 100755 bin/generate_provenance.py create mode 100644 provenance.json create mode 100644 tests/test_browser_smoke.py create mode 100644 tests/test_provenance.py diff --git a/.gitignore b/.gitignore index 79e3e5bf..94182016 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ mempalace/__pycache__/ # Prevent agents from writing to wrong path (see issue #1145) public/nexus/ +test-screenshots/ diff --git a/BROWSER_CONTRACT.md b/BROWSER_CONTRACT.md new file mode 100644 index 00000000..ecc18e64 --- /dev/null +++ b/BROWSER_CONTRACT.md @@ -0,0 +1,83 @@ +# Browser Contract — The Nexus + +The minimal set of guarantees a working Nexus browser surface must satisfy. +This is the target the smoke suite validates against. + +## 1. Static Assets + +The following files MUST exist at the repo root and be serveable: + +| File | Purpose | +|-------------------|----------------------------------| +| `index.html` | Entry point HTML shell | +| `app.js` | Main Three.js application | +| `style.css` | Visual styling | +| `portals.json` | Portal registry data | +| `vision.json` | Vision points data | +| `manifest.json` | PWA manifest | +| `gofai_worker.js` | GOFAI web worker | +| `server.py` | WebSocket bridge | + +## 2. DOM Contract + +The following elements MUST exist after the page loads: + +| ID | Type | Purpose | +|-----------------------|----------|------------------------------------| +| `nexus-canvas` | canvas | Three.js render target | +| `loading-screen` | div | Initial loading overlay | +| `hud` | div | Main HUD container | +| `chat-panel` | div | Chat interface panel | +| `chat-input` | input | Chat text input | +| `chat-messages` | div | Chat message history | +| `chat-send` | button | Send message button | +| `chat-toggle` | button | Collapse/expand chat | +| `debug-overlay` | div | Debug info overlay | +| `nav-mode-label` | span | Current navigation mode display | +| `ws-status-dot` | span | Hermes WS connection indicator | +| `hud-location-text` | span | Current location label | +| `portal-hint` | div | Portal proximity hint | +| `spatial-search` | div | Spatial memory search overlay | +| `enter-prompt` | div | Click-to-enter overlay (transient) | + +## 3. Three.js Contract + +After initialization completes: + +- `window` has a THREE renderer created from `#nexus-canvas` +- The canvas has a WebGL rendering context +- `scene` is a `THREE.Scene` with fog +- `camera` is a `THREE.PerspectiveCamera` +- `portals` array is populated from `portals.json` +- At least one portal mesh exists in the scene +- The render loop is running (`requestAnimationFrame` active) + +## 4. Loading Contract + +1. Page loads → loading screen visible +2. Progress bar fills to 100% +3. Loading screen fades out +4. Enter prompt appears +5. User clicks → enter prompt fades → HUD appears + +## 5. Provenance Contract + +A validation run MUST prove: + +- The served files match a known hash manifest from `Timmy_Foundation/the-nexus` main +- No file is served from `/Users/apayne/the-matrix` or other stale source +- The hash manifest is generated from a clean git checkout +- Screenshot evidence is captured and timestamped + +## 6. Data Contract + +- `portals.json` MUST parse as valid JSON array +- Each portal MUST have: `id`, `name`, `status`, `destination` +- `vision.json` MUST parse as valid JSON +- `manifest.json` MUST have `name`, `start_url`, `theme_color` + +## 7. WebSocket Contract + +- `server.py` starts without error on port 8765 +- A browser client can connect to `ws://localhost:8765` +- The connection status indicator reflects connected state diff --git a/bin/__pycache__/generate_provenance.cpython-312.pyc b/bin/__pycache__/generate_provenance.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f06cdcea45e395127109b2cd93e3f19e8e4b194a GIT binary patch literal 5704 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 literal 0 HcmV?d00001 diff --git a/bin/browser_smoke.sh b/bin/browser_smoke.sh new file mode 100755 index 00000000..434d3352 --- /dev/null +++ b/bin/browser_smoke.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Browser smoke validation runner for The Nexus. +# Runs provenance checks + Playwright browser tests + screenshot capture. +# +# Usage: bash bin/browser_smoke.sh +# Env: NEXUS_TEST_PORT=9876 (default) +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +PORT="${NEXUS_TEST_PORT:-9876}" +SCREENSHOT_DIR="$REPO_ROOT/test-screenshots" +mkdir -p "$SCREENSHOT_DIR" + +echo "═══════════════════════════════════════════" +echo " Nexus Browser Smoke Validation" +echo "═══════════════════════════════════════════" + +# Step 1: Provenance check +echo "" +echo "[1/4] Provenance check..." +if python3 bin/generate_provenance.py --check; then + echo " ✓ Provenance verified" +else + echo " ✗ Provenance mismatch — files have changed since manifest was generated" + echo " Run: python3 bin/generate_provenance.py to regenerate" + exit 1 +fi + +# Step 2: Static file contract +echo "" +echo "[2/4] Static file contract..." +MISSING=0 +for f in index.html app.js style.css portals.json vision.json manifest.json gofai_worker.js; do + if [ -f "$f" ]; then + echo " ✓ $f" + else + echo " ✗ $f MISSING" + MISSING=1 + fi +done +if [ "$MISSING" -eq 1 ]; then + echo " Static file contract FAILED" + exit 1 +fi + +# Step 3: Browser tests via pytest + Playwright +echo "" +echo "[3/4] Browser tests (Playwright)..." +NEXUS_TEST_PORT=$PORT python3 -m pytest tests/test_browser_smoke.py \ + -v --tb=short -x \ + -k "not test_screenshot" \ + 2>&1 | tail -30 + +# Step 4: Screenshot capture +echo "" +echo "[4/4] Screenshot capture..." +NEXUS_TEST_PORT=$PORT python3 -m pytest tests/test_browser_smoke.py \ + -v --tb=short \ + -k "test_screenshot" \ + 2>&1 | tail -15 + +echo "" +echo "═══════════════════════════════════════════" +echo " Screenshots saved to: $SCREENSHOT_DIR/" +ls -la "$SCREENSHOT_DIR/" 2>/dev/null || echo " (none captured)" +echo "═══════════════════════════════════════════" +echo " Smoke validation complete." diff --git a/bin/generate_provenance.py b/bin/generate_provenance.py new file mode 100755 index 00000000..87aad49b --- /dev/null +++ b/bin/generate_provenance.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +Generate a provenance manifest for the Nexus browser surface. +Hashes all frontend files so smoke tests can verify the app comes +from a clean Timmy_Foundation/the-nexus checkout, not stale sources. + +Usage: + python bin/generate_provenance.py # writes provenance.json + python bin/generate_provenance.py --check # verify existing manifest matches +""" +import hashlib +import json +import subprocess +import sys +import os +from datetime import datetime, timezone +from pathlib import Path + +# Files that constitute the browser-facing contract +CONTRACT_FILES = [ + "index.html", + "app.js", + "style.css", + "gofai_worker.js", + "server.py", + "portals.json", + "vision.json", + "manifest.json", +] + +# Component files imported by app.js +COMPONENT_FILES = [ + "nexus/components/spatial-memory.js", + "nexus/components/session-rooms.js", + "nexus/components/timeline-scrubber.js", + "nexus/components/memory-particles.js", +] + +ALL_FILES = CONTRACT_FILES + COMPONENT_FILES + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + h.update(path.read_bytes()) + return h.hexdigest() + + +def get_git_info(repo_root: Path) -> dict: + """Capture git state for provenance.""" + def git(*args): + try: + r = subprocess.run( + ["git", *args], + cwd=repo_root, + capture_output=True, text=True, timeout=10, + ) + return r.stdout.strip() if r.returncode == 0 else None + except Exception: + return None + + return { + "commit": git("rev-parse", "HEAD"), + "branch": git("rev-parse", "--abbrev-ref", "HEAD"), + "remote": git("remote", "get-url", "origin"), + "dirty": git("status", "--porcelain") != "", + } + + +def generate_manifest(repo_root: Path) -> dict: + files = {} + missing = [] + for rel in ALL_FILES: + p = repo_root / rel + if p.exists(): + files[rel] = { + "sha256": sha256_file(p), + "size": p.stat().st_size, + } + else: + missing.append(rel) + + return { + "generated_at": datetime.now(timezone.utc).isoformat(), + "repo": "Timmy_Foundation/the-nexus", + "git": get_git_info(repo_root), + "files": files, + "missing": missing, + "file_count": len(files), + } + + +def check_manifest(repo_root: Path, existing: dict) -> tuple[bool, list[str]]: + """Check if current files match the stored manifest. Returns (ok, mismatches).""" + mismatches = [] + for rel, expected in existing.get("files", {}).items(): + p = repo_root / rel + if not p.exists(): + mismatches.append(f"MISSING: {rel}") + elif sha256_file(p) != expected["sha256"]: + mismatches.append(f"CHANGED: {rel}") + return (len(mismatches) == 0, mismatches) + + +def main(): + repo_root = Path(__file__).resolve().parent.parent + manifest_path = repo_root / "provenance.json" + + if "--check" in sys.argv: + if not manifest_path.exists(): + print("FAIL: provenance.json does not exist") + sys.exit(1) + existing = json.loads(manifest_path.read_text()) + ok, mismatches = check_manifest(repo_root, existing) + if ok: + print(f"OK: All {len(existing['files'])} files match provenance manifest") + sys.exit(0) + else: + print(f"FAIL: {len(mismatches)} file(s) differ:") + for m in mismatches: + print(f" {m}") + sys.exit(1) + + manifest = generate_manifest(repo_root) + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n") + print(f"Wrote provenance.json: {manifest['file_count']} files hashed") + if manifest["missing"]: + print(f" Missing (not yet created): {', '.join(manifest['missing'])}") + + +if __name__ == "__main__": + main() diff --git a/provenance.json b/provenance.json new file mode 100644 index 00000000..3db69be4 --- /dev/null +++ b/provenance.json @@ -0,0 +1,62 @@ +{ + "generated_at": "2026-04-11T01:14:54.632326+00:00", + "repo": "Timmy_Foundation/the-nexus", + "git": { + "commit": "d408d2c365a9efc0c1e3a9b38b9cc4eed75695c5", + "branch": "mimo/build/issue-686", + "remote": "https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus.git", + "dirty": true + }, + "files": { + "index.html": { + "sha256": "71ba27afe8b6b42a09efe09d2b3017599392ddc3bc02543b31c2277dfb0b82cc", + "size": 25933 + }, + "app.js": { + "sha256": "2b765a724a0fcda29abd40ba921bc621d2699f11d0ba14cf1579cbbdafdc5cd5", + "size": 132902 + }, + "style.css": { + "sha256": "cd3068d03eed6f52a00bbc32cfae8fba4739b8b3cb194b3ec09fd747a075056d", + "size": 44198 + }, + "gofai_worker.js": { + "sha256": "d292f110aa12a8aa2b16b0c2d48e5b4ce24ee15b1cffb409ab846b1a05a91de2", + "size": 969 + }, + "server.py": { + "sha256": "e963cc9715accfc8814e3fe5c44af836185d66740d5a65fd0365e9c629d38e05", + "size": 4185 + }, + "portals.json": { + "sha256": "889a5e0f724eb73a95f960bca44bca232150bddff7c1b11f253bd056f3683a08", + "size": 3442 + }, + "vision.json": { + "sha256": "0e3b5c06af98486bbcb2fc2dc627dc8b7b08aed4c3a4f9e10b57f91e1e8ca6ad", + "size": 1658 + }, + "manifest.json": { + "sha256": "352304c4f7746f5d31cbc223636769969dd263c52800645c01024a3a8489d8c9", + "size": 495 + }, + "nexus/components/spatial-memory.js": { + "sha256": "60170f6490ddd743acd6d285d3a1af6cad61fbf8aaef3f679ff4049108eac160", + "size": 32782 + }, + "nexus/components/session-rooms.js": { + "sha256": "9997a60dda256e38cb4645508bf9e98c15c3d963b696e0080e3170a9a7fa7cf1", + "size": 15113 + }, + "nexus/components/timeline-scrubber.js": { + "sha256": "f8a17762c2735be283dc5074b13eb00e1e3b2b04feb15996c2cf0323b46b6014", + "size": 7177 + }, + "nexus/components/memory-particles.js": { + "sha256": "1be5567a3ebb229f9e1a072c08a25387ade87cb4a1df6a624e5c5254d3bef8fa", + "size": 14216 + } + }, + "missing": [], + "file_count": 12 +} diff --git a/tests/test_browser_smoke.py b/tests/test_browser_smoke.py new file mode 100644 index 00000000..642675c3 --- /dev/null +++ b/tests/test_browser_smoke.py @@ -0,0 +1,293 @@ +""" +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 "The Nexus" in content or "Timmy" in content, \ + "index.html title does not reference The Nexus" diff --git a/tests/test_provenance.py b/tests/test_provenance.py new file mode 100644 index 00000000..c666e1ea --- /dev/null +++ b/tests/test_provenance.py @@ -0,0 +1,73 @@ +""" +Provenance tests — verify the Nexus browser surface comes from +a clean Timmy_Foundation/the-nexus checkout, not stale sources. + +Refs: #686 +""" +import json +import hashlib +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def test_provenance_manifest_exists() -> None: + """provenance.json must exist and be valid JSON.""" + p = REPO_ROOT / "provenance.json" + assert p.exists(), "provenance.json missing — run bin/generate_provenance.py" + data = json.loads(p.read_text()) + assert "files" in data + assert "repo" in data + + +def test_provenance_repo_identity() -> None: + """Manifest must claim Timmy_Foundation/the-nexus.""" + data = json.loads((REPO_ROOT / "provenance.json").read_text()) + assert data["repo"] == "Timmy_Foundation/the-nexus" + + +def test_provenance_all_contract_files_present() -> None: + """Every file listed in the provenance manifest must exist on disk.""" + data = json.loads((REPO_ROOT / "provenance.json").read_text()) + missing = [] + for rel in data["files"]: + if not (REPO_ROOT / rel).exists(): + missing.append(rel) + assert not missing, f"Contract files missing: {missing}" + + +def test_provenance_hashes_match() -> None: + """File hashes must match the stored manifest (no stale/modified files).""" + data = json.loads((REPO_ROOT / "provenance.json").read_text()) + mismatches = [] + for rel, meta in data["files"].items(): + p = REPO_ROOT / rel + if not p.exists(): + mismatches.append(f"MISSING: {rel}") + continue + actual = hashlib.sha256(p.read_bytes()).hexdigest() + if actual != meta["sha256"]: + mismatches.append(f"CHANGED: {rel}") + assert not mismatches, f"Provenance mismatch:\n" + "\n".join(mismatches) + + +def test_no_legacy_matrix_references_in_frontend() -> None: + """Frontend files must not reference /Users/apayne/the-matrix as a source.""" + forbidden_paths = ["/Users/apayne/the-matrix"] + offenders = [] + for rel in ["index.html", "app.js", "style.css"]: + p = REPO_ROOT / rel + if p.exists(): + content = p.read_text() + for bad in forbidden_paths: + if bad in content: + offenders.append(f"{rel} references {bad}") + assert not offenders, f"Legacy matrix references found: {offenders}" + + +def test_no_stale_perplexity_computer_references_in_critical_files() -> None: + """Verify the provenance generator script itself is canonical.""" + script = REPO_ROOT / "bin" / "generate_provenance.py" + assert script.exists(), "bin/generate_provenance.py must exist" + content = script.read_text() + assert "Timmy_Foundation/the-nexus" in content