Compare commits
2 Commits
improvemen
...
tests/smok
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a21afda8b9 | ||
|
|
7ef1c55bc0 |
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
test-results/
|
||||
test-screenshots/
|
||||
75
package-lock.json
generated
Normal file
75
package-lock.json
generated
Normal 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
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2"
|
||||
}
|
||||
}
|
||||
43
tests/playwright.config.js
Normal file
43
tests/playwright.config.js
Normal 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
43
tests/run-smoke.sh
Executable 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
338
tests/smoke.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user