Compare commits

...

2 Commits

Author SHA1 Message Date
Timmy
a21afda8b9 fix: add GPU screenshot tests, improve render wait times
- 3 visual screenshot tests: default view, overview mode, mouse look
- Screenshots saved to test-screenshots/ for human review
- GPU project config for headed real-render tests
- Pixel stats logging for automated content verification
- Increased render wait to 6s for full scene initialization

Refs #445
2026-03-25 09:48:28 -04:00
Timmy
7ef1c55bc0 feat: add headless smoke tests for Nexus rendering and interaction
Some checks failed
CI / validate (pull_request) Failing after 5s
- 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
2026-03-25 09:18:13 -04:00
6 changed files with 507 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
test-results/
test-screenshots/

75
package-lock.json generated Normal file
View File

@@ -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"
}
}
}
}

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"devDependencies": {
"@playwright/test": "^1.58.2"
}
}

View File

@@ -0,0 +1,43 @@
// @ts-check
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: '.',
timeout: 30000,
retries: 1,
use: {
headless: true,
viewport: { width: 1280, height: 720 },
launchOptions: {
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-webgl',
],
},
},
projects: [
// Headless — fast, for CI. Software WebGL (limited shader support).
{ name: 'chromium', use: { browserName: 'chromium' } },
// Headed — real GPU render. Use for visual screenshot tests.
// Run with: --project=gpu
{
name: 'gpu',
use: {
browserName: 'chromium',
headless: false,
viewport: { width: 1280, height: 720 },
launchOptions: {
args: ['--enable-webgl', '--enable-gpu'],
},
},
},
],
// Local server
webServer: {
command: 'python3 -m http.server 8888',
port: 8888,
cwd: '..',
reuseExistingServer: true,
},
});

43
tests/run-smoke.sh Executable file
View File

@@ -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

338
tests/smoke.spec.js Normal file
View File

@@ -0,0 +1,338 @@
// @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 path = require('path');
const fs = require('fs');
const BASE_URL = process.env.NEXUS_URL || 'http://localhost:8888';
const SCREENSHOT_DIR = path.join(__dirname, '..', 'test-screenshots');
// Ensure screenshot directory exists
if (!fs.existsSync(SCREENSHOT_DIR)) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}
// --- 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 <canvas> 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('full render screenshot — default view', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
// Wait for scene to fully render — crystals, portals, sigil, earth
await page.waitForTimeout(6000);
const screenshotPath = path.join(SCREENSHOT_DIR, 'render-default.png');
await page.screenshot({ path: screenshotPath, fullPage: false });
console.log(`Screenshot saved: ${screenshotPath}`);
// Verify the screenshot has actual content (not blank)
const stats = fs.statSync(screenshotPath);
expect(stats.size).toBeGreaterThan(10000); // A real render is >10KB
});
test('full render screenshot — overview mode', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(4000);
// Toggle overview mode
await page.keyboard.press('Tab');
await page.waitForTimeout(2000);
const screenshotPath = path.join(SCREENSHOT_DIR, 'render-overview.png');
await page.screenshot({ path: screenshotPath, fullPage: false });
console.log(`Screenshot saved: ${screenshotPath}`);
const stats = fs.statSync(screenshotPath);
expect(stats.size).toBeGreaterThan(10000);
});
test('full render screenshot — after mouse look', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(4000);
// Move the camera by moving mouse to corner
const viewport = page.viewportSize();
await page.mouse.move(viewport.width * 0.9, viewport.height * 0.2);
await page.waitForTimeout(2000);
const screenshotPath = path.join(SCREENSHOT_DIR, 'render-mouse-look.png');
await page.screenshot({ path: screenshotPath, fullPage: false });
console.log(`Screenshot saved: ${screenshotPath}`);
const stats = fs.statSync(screenshotPath);
expect(stats.size).toBeGreaterThan(10000);
});
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 pixelStats = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
if (!canvas) return { error: 'no canvas' };
const ctx = canvas.getContext('webgl2') || canvas.getContext('webgl');
if (!ctx) return { error: 'no webgl context' };
// 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
);
// Count non-black pixels and compute average brightness
let nonBlack = 0;
let totalBrightness = 0;
for (let i = 0; i < pixels.length; i += 4) {
const brightness = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3;
totalBrightness += brightness;
if (brightness > 5) nonBlack++;
}
return {
nonBlackPixels: nonBlack,
totalSampled: 25,
avgBrightness: Math.round(totalBrightness / 25),
};
});
console.log(`Pixel stats: ${JSON.stringify(pixelStats)}`);
expect(pixelStats.error).toBeUndefined();
expect(pixelStats.nonBlackPixels).toBeGreaterThan(3);
});
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),
version: gl.getParameter(gl.VERSION),
isLost: gl.isContextLost(),
};
});
console.log(`WebGL: ${glInfo.renderer} (${glInfo.vendor}) ${glInfo.version}`);
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();
});
});