From 7ef1c55bc07582ed7ab2524b94ade67ca3307567 Mon Sep 17 00:00:00 2001 From: Timmy Date: Wed, 25 Mar 2026 09:18:13 -0400 Subject: [PATCH] feat: add headless smoke tests for Nexus rendering and interaction - Playwright-based, zero LLM dependency - Tests: world renders, canvas exists, WebGL healthy, HUD elements, mouse interaction, Tab overview toggle, animation loop, data files - run-smoke.sh for local execution - Configurable NEXUS_URL for CI or local Refs #445 --- .gitignore | 2 + package-lock.json | 75 ++++++++++ package.json | 5 + tests/playwright.config.js | 30 ++++ tests/run-smoke.sh | 43 ++++++ tests/smoke.spec.js | 274 +++++++++++++++++++++++++++++++++++++ 6 files changed, 429 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tests/playwright.config.js create mode 100755 tests/run-smoke.sh create mode 100644 tests/smoke.spec.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae2f532 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +test-results/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6051fd4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,75 @@ +{ + "name": "nexus-check", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@playwright/test": "^1.58.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..35c1d3b --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "@playwright/test": "^1.58.2" + } +} diff --git a/tests/playwright.config.js b/tests/playwright.config.js new file mode 100644 index 0000000..8922d64 --- /dev/null +++ b/tests/playwright.config.js @@ -0,0 +1,30 @@ +// @ts-check +const { defineConfig } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: '.', + timeout: 30000, + retries: 1, + use: { + headless: true, + viewport: { width: 1280, height: 720 }, + // WebGL needs a real GPU context — use chromium with GPU + launchOptions: { + args: [ + '--use-gl=angle', + '--use-angle=swiftshader', // Software WebGL for CI + '--enable-webgl', + ], + }, + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + ], + // Local server + webServer: { + command: 'python3 -m http.server 8888', + port: 8888, + cwd: '..', + reuseExistingServer: true, + }, +}); diff --git a/tests/run-smoke.sh b/tests/run-smoke.sh new file mode 100755 index 0000000..3180ce1 --- /dev/null +++ b/tests/run-smoke.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# run-smoke.sh — Run Nexus smoke tests locally. No LLM. No cloud. +# +# Usage: +# ./tests/run-smoke.sh # Run all smoke tests +# ./tests/run-smoke.sh --headed # Run with visible browser (debug) +# ./tests/run-smoke.sh --grep "HUD" # Run specific test group +# +# Requirements: playwright installed (npm i -D @playwright/test) +# First run: npx playwright install chromium + +set -euo pipefail +cd "$(dirname "$0")/.." + +# Ensure playwright is available +if ! command -v npx &>/dev/null; then + echo "ERROR: npx not found. Install Node.js." + exit 1 +fi + +# Install playwright test if needed +if [ ! -d node_modules/@playwright ]; then + echo "Installing playwright test runner..." + npm install --save-dev @playwright/test 2>&1 | tail -3 +fi + +# Ensure browser is installed +npx playwright install chromium --with-deps 2>/dev/null || true + +# Run tests +echo "" +echo "=== NEXUS SMOKE TESTS ===" +echo "" +npx playwright test tests/smoke.spec.js -c tests/playwright.config.js "$@" +EXIT=$? + +echo "" +if [ $EXIT -eq 0 ]; then + echo "✅ ALL SMOKE TESTS PASSED" +else + echo "❌ SOME TESTS FAILED (exit $EXIT)" +fi +exit $EXIT diff --git a/tests/smoke.spec.js b/tests/smoke.spec.js new file mode 100644 index 0000000..cb521a2 --- /dev/null +++ b/tests/smoke.spec.js @@ -0,0 +1,274 @@ +// @ts-check +/** + * Nexus Smoke Tests — Zero LLM, pure headless browser. + * + * Tests that the 3D world renders, modules load, and basic interaction works. + * Run: npx playwright test tests/smoke.spec.js + * Requires: a local server serving the nexus (e.g., python3 -m http.server 8888) + */ + +const { test, expect } = require('@playwright/test'); + +const BASE_URL = process.env.NEXUS_URL || 'http://localhost:8888'; + +// --- RENDERING TESTS --- + +test.describe('World Renders', () => { + + test('index.html loads without errors', async ({ page }) => { + const errors = []; + page.on('pageerror', err => errors.push(err.message)); + + const response = await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + expect(response.status()).toBe(200); + + // Give Three.js a moment to initialize + await page.waitForTimeout(2000); + + // No fatal JS errors + const fatalErrors = errors.filter(e => + !e.includes('ambient.mp3') && // missing audio file is OK + !e.includes('favicon') && + !e.includes('serviceWorker') + ); + expect(fatalErrors).toEqual([]); + }); + + test('canvas element exists (Three.js rendered)', async ({ page }) => { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(3000); + + // Three.js creates a element + const canvas = await page.locator('canvas'); + await expect(canvas).toBeVisible(); + + // Canvas should have non-zero dimensions + const box = await canvas.boundingBox(); + expect(box.width).toBeGreaterThan(100); + expect(box.height).toBeGreaterThan(100); + }); + + test('canvas is not all black (scene has content)', async ({ page }) => { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(4000); + + // Sample pixels from the canvas + const hasContent = await page.evaluate(() => { + const canvas = document.querySelector('canvas'); + if (!canvas) return false; + const ctx = canvas.getContext('webgl2') || canvas.getContext('webgl'); + if (!ctx) return false; + + // Read a block of pixels from center + const w = canvas.width; + const h = canvas.height; + const pixels = new Uint8Array(4 * 100); + ctx.readPixels( + Math.floor(w / 2) - 5, Math.floor(h / 2) - 5, + 10, 10, ctx.RGBA, ctx.UNSIGNED_BYTE, pixels + ); + + // Check if at least some pixels are non-black + let nonBlack = 0; + for (let i = 0; i < pixels.length; i += 4) { + if (pixels[i] > 5 || pixels[i + 1] > 5 || pixels[i + 2] > 5) { + nonBlack++; + } + } + return nonBlack > 5; // At least 5 non-black pixels in the sample + }); + + expect(hasContent).toBe(true); + }); + + test('WebGL context is healthy', async ({ page }) => { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + + const glInfo = await page.evaluate(() => { + const canvas = document.querySelector('canvas'); + if (!canvas) return { error: 'no canvas' }; + const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + if (!gl) return { error: 'no webgl context' }; + return { + renderer: gl.getParameter(gl.RENDERER), + vendor: gl.getParameter(gl.VENDOR), + isLost: gl.isContextLost(), + }; + }); + + expect(glInfo.error).toBeUndefined(); + expect(glInfo.isLost).toBe(false); + }); +}); + +// --- MODULE LOADING TESTS --- + +test.describe('Modules Load', () => { + + test('all ES modules resolve (no import errors)', async ({ page }) => { + const moduleErrors = []; + page.on('pageerror', err => { + if (err.message.includes('import') || err.message.includes('module') || + err.message.includes('export') || err.message.includes('Cannot find')) { + moduleErrors.push(err.message); + } + }); + + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(3000); + + expect(moduleErrors).toEqual([]); + }); + + test('Three.js loaded from CDN', async ({ page }) => { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + + const hasThree = await page.evaluate(() => { + // Check if THREE is accessible (it's imported as ES module, so check via scene) + const canvas = document.querySelector('canvas'); + return !!canvas; + }); + + expect(hasThree).toBe(true); + }); +}); + +// --- HUD ELEMENTS --- + +test.describe('HUD Elements', () => { + + test('block height display exists', async ({ page }) => { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + const blockDisplay = page.locator('#block-height-display'); + await expect(blockDisplay).toBeAttached(); + }); + + test('weather HUD exists', async ({ page }) => { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + const weather = page.locator('#weather-hud'); + await expect(weather).toBeAttached(); + }); + + test('audio toggle exists', async ({ page }) => { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + const btn = page.locator('#audio-toggle'); + await expect(btn).toBeAttached(); + }); + + test('sovereignty message exists', async ({ page }) => { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + const msg = page.locator('#sovereignty-msg'); + await expect(msg).toBeAttached(); + }); + + test('oath overlay exists', async ({ page }) => { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + const oath = page.locator('#oath-overlay'); + await expect(oath).toBeAttached(); + }); +}); + +// --- INTERACTION TESTS --- + +test.describe('Interactions', () => { + + test('mouse movement updates camera (parallax)', async ({ page }) => { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(3000); + + // Get initial canvas snapshot + const canvas = page.locator('canvas'); + const box = await canvas.boundingBox(); + + // Take screenshot before mouse move + const before = await page.screenshot({ clip: { x: box.x, y: box.y, width: 100, height: 100 } }); + + // Move mouse significantly + await page.mouse.move(box.x + box.width * 0.8, box.y + box.height * 0.2); + await page.waitForTimeout(1500); + + // Take screenshot after + const after = await page.screenshot({ clip: { x: box.x, y: box.y, width: 100, height: 100 } }); + + // Screenshots should differ (camera moved) + expect(Buffer.compare(before, after)).not.toBe(0); + }); + + test('Tab key toggles overview mode', async ({ page }) => { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(3000); + + // Press Tab for overview + await page.keyboard.press('Tab'); + await page.waitForTimeout(1000); + + // Overview indicator should be visible + const indicator = page.locator('#overview-indicator'); + // It should have some visibility (either display or opacity) + const isVisible = await indicator.evaluate(el => { + const style = window.getComputedStyle(el); + return style.display !== 'none' && parseFloat(style.opacity) > 0; + }); + + // Press Tab again to exit + await page.keyboard.press('Tab'); + + expect(isVisible).toBe(true); + }); + + test('animation loop is running (requestAnimationFrame)', async ({ page }) => { + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + + // Check that frames are being rendered by watching a timestamp + const frameCount = await page.evaluate(() => { + return new Promise(resolve => { + let count = 0; + const start = performance.now(); + function tick() { + count++; + if (performance.now() - start > 500) { + resolve(count); + } else { + requestAnimationFrame(tick); + } + } + requestAnimationFrame(tick); + }); + }); + + // Should get at least 10 frames in 500ms (20+ FPS) + expect(frameCount).toBeGreaterThan(10); + }); +}); + +// --- DATA / API TESTS --- + +test.describe('Data Loading', () => { + + test('portals.json loads', async ({ page }) => { + const response = await page.goto(`${BASE_URL}/portals.json`); + expect(response.status()).toBe(200); + const data = await response.json(); + expect(Array.isArray(data) || typeof data === 'object').toBe(true); + }); + + test('sovereignty-status.json loads', async ({ page }) => { + const response = await page.goto(`${BASE_URL}/sovereignty-status.json`); + expect(response.status()).toBe(200); + }); + + test('style.css loads', async ({ page }) => { + const response = await page.goto(`${BASE_URL}/style.css`); + expect(response.status()).toBe(200); + }); + + test('manifest.json is valid', async ({ page }) => { + const response = await page.goto(`${BASE_URL}/manifest.json`); + expect(response.status()).toBe(200); + const data = await response.json(); + expect(data.name || data.short_name).toBeTruthy(); + }); +}); -- 2.43.0