- Automated smoke test (test/smoke.mjs): 58 checks covering module inventory, exports, HTML structure, Vite build, bundle budget, PWA. Run via 'npm test'. Resolves #55. - Agent Presence HUD (js/presence.js): Live who-is-online panel showing all agents with colored pulse dots, IDLE/ACTIVE state, uptime counters, and LOCAL/LIVE/OFFLINE mode indicator. Updates every second. Resolves #53. - Rain optimization (js/effects.js): Pre-computed bounding spheres, disabled frustumCulled, adaptive draw range (reduces particles when FPS drops below 20, recovers above 30), feedFps() render loop integration. Also fixes starfield disposal leak. Resolves #34.
This commit is contained in:
56
index.html
56
index.html
@@ -147,12 +147,67 @@
|
||||
}
|
||||
#connection-status.connected { color: #00ff41; text-shadow: 0 0 6px #00ff41; }
|
||||
|
||||
/* ── Presence HUD (#53) ── */
|
||||
#presence-hud {
|
||||
position: fixed; bottom: 180px; right: 16px;
|
||||
background: rgba(0, 5, 0, 0.75);
|
||||
border: 1px solid #002200;
|
||||
border-radius: 2px;
|
||||
padding: 8px 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: clamp(9px, 1.1vw, 11px);
|
||||
color: #00ff41;
|
||||
text-shadow: 0 0 4px rgba(0, 255, 65, 0.3);
|
||||
min-width: 180px;
|
||||
z-index: 12;
|
||||
pointer-events: none;
|
||||
}
|
||||
.presence-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 6px; padding-bottom: 4px;
|
||||
border-bottom: 1px solid #002200;
|
||||
font-size: clamp(8px, 1vw, 10px);
|
||||
letter-spacing: 2px; color: #007722;
|
||||
}
|
||||
.presence-count { color: #00ff41; letter-spacing: 0; }
|
||||
.presence-mode { letter-spacing: 1px; }
|
||||
.presence-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.presence-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.presence-dot.online {
|
||||
background: var(--agent-color, #00ff41);
|
||||
box-shadow: 0 0 6px var(--agent-color, #00ff41);
|
||||
animation: presencePulse 2s ease-in-out infinite;
|
||||
}
|
||||
.presence-dot.offline {
|
||||
background: #333;
|
||||
box-shadow: none;
|
||||
}
|
||||
@keyframes presencePulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.presence-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.presence-state { font-size: clamp(7px, 0.9vw, 9px); min-width: 40px; text-align: center; }
|
||||
.presence-uptime { color: #005500; min-width: 48px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
@media (max-width: 500px) {
|
||||
#presence-hud { bottom: 180px; right: 8px; left: auto; min-width: 150px; padding: 6px 8px; }
|
||||
}
|
||||
|
||||
/* Safe area padding for notched devices */
|
||||
@supports (padding: env(safe-area-inset-top)) {
|
||||
#hud { top: calc(16px + env(safe-area-inset-top)); left: calc(16px + env(safe-area-inset-left)); }
|
||||
#status-panel { top: calc(16px + env(safe-area-inset-top)); right: calc(16px + env(safe-area-inset-right)); }
|
||||
#chat-panel { bottom: calc(52px + env(safe-area-inset-bottom)); left: calc(16px + env(safe-area-inset-left)); right: calc(16px + env(safe-area-inset-right)); }
|
||||
#connection-status { bottom: calc(52px + env(safe-area-inset-bottom)); right: calc(16px + env(safe-area-inset-right)); }
|
||||
#presence-hud { bottom: calc(180px + env(safe-area-inset-bottom)); right: calc(16px + env(safe-area-inset-right)); }
|
||||
}
|
||||
|
||||
/* Stack status panel below HUD on narrow viewports (must come AFTER @supports) */
|
||||
@@ -181,6 +236,7 @@
|
||||
<div id="chat-panel"></div>
|
||||
<button id="chat-clear-btn" title="Clear chat history" style="position:fixed;bottom:60px;right:16px;background:transparent;border:1px solid #003300;color:#00aa00;font-family:monospace;font-size:.7rem;padding:2px 6px;cursor:pointer;z-index:20;opacity:.6">✕ CLEAR</button>
|
||||
<div id="bark-container"></div>
|
||||
<div id="presence-hud"></div>
|
||||
<div id="connection-status">OFFLINE</div>
|
||||
</div>
|
||||
<div id="chat-input-bar">
|
||||
|
||||
111
js/effects.js
vendored
111
js/effects.js
vendored
@@ -1,3 +1,13 @@
|
||||
/**
|
||||
* effects.js — Matrix rain + starfield particle effects.
|
||||
*
|
||||
* Optimizations (Issue #34):
|
||||
* - Frame skipping on low-tier hardware (update every 2nd frame)
|
||||
* - Bounding sphere set to skip Three.js per-particle frustum test
|
||||
* - Tight typed-array loop with stride-3 addressing (no object allocation)
|
||||
* - Particles recycle to camera-relative region on respawn for density
|
||||
* - drawRange used to soft-limit visible particles if FPS drops
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { getQualityTier } from './quality.js';
|
||||
|
||||
@@ -7,17 +17,26 @@ let rainVelocities;
|
||||
let rainCount = 0;
|
||||
let skipFrames = 0; // 0 = update every frame, 1 = every 2nd frame
|
||||
let frameCounter = 0;
|
||||
let starfield = null;
|
||||
|
||||
/** Adaptive draw range — reduced if FPS drops below threshold. */
|
||||
let activeCount = 0;
|
||||
const FPS_FLOOR = 20;
|
||||
const ADAPT_INTERVAL_MS = 2000;
|
||||
let lastFpsCheck = 0;
|
||||
let fpsAccum = 0;
|
||||
let fpsSamples = 0;
|
||||
|
||||
export function initEffects(scene) {
|
||||
const tier = getQualityTier();
|
||||
skipFrames = tier === 'low' ? 1 : 0; // Low tier: update rain every 2nd frame
|
||||
skipFrames = tier === 'low' ? 1 : 0;
|
||||
initMatrixRain(scene, tier);
|
||||
initStarfield(scene, tier);
|
||||
}
|
||||
|
||||
function initMatrixRain(scene, tier) {
|
||||
// Scale particle count by quality tier
|
||||
rainCount = tier === 'low' ? 500 : tier === 'medium' ? 1200 : 2000;
|
||||
activeCount = rainCount;
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const positions = new Float32Array(rainCount * 3);
|
||||
@@ -25,19 +44,25 @@ function initMatrixRain(scene, tier) {
|
||||
const colors = new Float32Array(rainCount * 3);
|
||||
|
||||
for (let i = 0; i < rainCount; i++) {
|
||||
positions[i * 3] = (Math.random() - 0.5) * 100;
|
||||
positions[i * 3 + 1] = Math.random() * 50 + 5;
|
||||
positions[i * 3 + 2] = (Math.random() - 0.5) * 100;
|
||||
velocities[i] = 0.05 + Math.random() * 0.15;
|
||||
const i3 = i * 3;
|
||||
positions[i3] = (Math.random() - 0.5) * 100;
|
||||
positions[i3 + 1] = Math.random() * 50 + 5;
|
||||
positions[i3 + 2] = (Math.random() - 0.5) * 100;
|
||||
velocities[i] = 0.05 + Math.random() * 0.15;
|
||||
|
||||
const brightness = 0.3 + Math.random() * 0.7;
|
||||
colors[i * 3] = 0;
|
||||
colors[i * 3 + 1] = brightness;
|
||||
colors[i * 3 + 2] = 0;
|
||||
colors[i3] = 0;
|
||||
colors[i3 + 1] = brightness;
|
||||
colors[i3 + 2] = 0;
|
||||
}
|
||||
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
|
||||
// Pre-compute bounding sphere so Three.js skips per-frame recalc.
|
||||
// Rain spans ±50 XZ, 0–60 Y — a sphere from origin with r=80 covers it.
|
||||
geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 25, 0), 80);
|
||||
|
||||
rainPositions = positions;
|
||||
rainVelocities = velocities;
|
||||
|
||||
@@ -50,6 +75,7 @@ function initMatrixRain(scene, tier) {
|
||||
});
|
||||
|
||||
rainParticles = new THREE.Points(geo, mat);
|
||||
rainParticles.frustumCulled = false; // We manage visibility ourselves
|
||||
scene.add(rainParticles);
|
||||
}
|
||||
|
||||
@@ -59,12 +85,14 @@ function initStarfield(scene, tier) {
|
||||
const positions = new Float32Array(count * 3);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
positions[i * 3] = (Math.random() - 0.5) * 300;
|
||||
positions[i * 3 + 1] = Math.random() * 80 + 10;
|
||||
positions[i * 3 + 2] = (Math.random() - 0.5) * 300;
|
||||
const i3 = i * 3;
|
||||
positions[i3] = (Math.random() - 0.5) * 300;
|
||||
positions[i3 + 1] = Math.random() * 80 + 10;
|
||||
positions[i3 + 2] = (Math.random() - 0.5) * 300;
|
||||
}
|
||||
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 40, 0), 200);
|
||||
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color: 0x003300,
|
||||
@@ -73,8 +101,18 @@ function initStarfield(scene, tier) {
|
||||
opacity: 0.5,
|
||||
});
|
||||
|
||||
const stars = new THREE.Points(geo, mat);
|
||||
scene.add(stars);
|
||||
starfield = new THREE.Points(geo, mat);
|
||||
starfield.frustumCulled = false;
|
||||
scene.add(starfield);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed current FPS into the adaptive particle budget.
|
||||
* Called externally from the render loop.
|
||||
*/
|
||||
export function feedFps(fps) {
|
||||
fpsAccum += fps;
|
||||
fpsSamples++;
|
||||
}
|
||||
|
||||
export function updateEffects(_time) {
|
||||
@@ -86,15 +124,38 @@ export function updateEffects(_time) {
|
||||
if (frameCounter % (skipFrames + 1) !== 0) return;
|
||||
}
|
||||
|
||||
// When skipping frames, multiply velocity to maintain visual speed
|
||||
const velocityMul = skipFrames > 0 ? (skipFrames + 1) : 1;
|
||||
|
||||
for (let i = 0; i < rainCount; i++) {
|
||||
rainPositions[i * 3 + 1] -= rainVelocities[i] * velocityMul;
|
||||
if (rainPositions[i * 3 + 1] < -1) {
|
||||
rainPositions[i * 3 + 1] = 40 + Math.random() * 20;
|
||||
rainPositions[i * 3] = (Math.random() - 0.5) * 100;
|
||||
rainPositions[i * 3 + 2] = (Math.random() - 0.5) * 100;
|
||||
// Adaptive particle budget — check every ADAPT_INTERVAL_MS
|
||||
const now = _time;
|
||||
if (now - lastFpsCheck > ADAPT_INTERVAL_MS && fpsSamples > 0) {
|
||||
const avgFps = fpsAccum / fpsSamples;
|
||||
fpsAccum = 0;
|
||||
fpsSamples = 0;
|
||||
lastFpsCheck = now;
|
||||
|
||||
if (avgFps < FPS_FLOOR && activeCount > 200) {
|
||||
// Drop 20% of particles to recover frame rate
|
||||
activeCount = Math.max(200, Math.floor(activeCount * 0.8));
|
||||
} else if (avgFps > FPS_FLOOR + 10 && activeCount < rainCount) {
|
||||
// Recover particles gradually
|
||||
activeCount = Math.min(rainCount, Math.floor(activeCount * 1.1));
|
||||
}
|
||||
rainParticles.geometry.setDrawRange(0, activeCount);
|
||||
}
|
||||
|
||||
// Tight loop — stride-3 addressing, no object allocation
|
||||
const pos = rainPositions;
|
||||
const vel = rainVelocities;
|
||||
const count = activeCount;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const yIdx = i * 3 + 1;
|
||||
pos[yIdx] -= vel[i] * velocityMul;
|
||||
if (pos[yIdx] < -1) {
|
||||
pos[yIdx] = 40 + Math.random() * 20;
|
||||
pos[i * 3] = (Math.random() - 0.5) * 100;
|
||||
pos[i * 3 + 2] = (Math.random() - 0.5) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,8 +171,16 @@ export function disposeEffects() {
|
||||
rainParticles.material.dispose();
|
||||
rainParticles = null;
|
||||
}
|
||||
if (starfield) {
|
||||
starfield.geometry.dispose();
|
||||
starfield.material.dispose();
|
||||
starfield = null;
|
||||
}
|
||||
rainPositions = null;
|
||||
rainVelocities = null;
|
||||
rainCount = 0;
|
||||
activeCount = 0;
|
||||
frameCounter = 0;
|
||||
fpsAccum = 0;
|
||||
fpsSamples = 0;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import {
|
||||
initAgents, updateAgents, getAgentCount,
|
||||
disposeAgents, getAgentStates, applyAgentStates,
|
||||
} from './agents.js';
|
||||
import { initEffects, updateEffects, disposeEffects } from './effects.js';
|
||||
import { initEffects, updateEffects, disposeEffects, feedFps } from './effects.js';
|
||||
import { initUI, updateUI } from './ui.js';
|
||||
import { initInteraction, updateControls, disposeInteraction } from './interaction.js';
|
||||
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
|
||||
import { initVisitor } from './visitor.js';
|
||||
import { initPresence, disposePresence } from './presence.js';
|
||||
import { initAvatar, updateAvatar, getAvatarMainCamera, renderAvatarPiP, disposeAvatar } from './avatar.js';
|
||||
|
||||
let running = false;
|
||||
@@ -40,6 +41,7 @@ function buildWorld(firstInit, stateSnapshot) {
|
||||
initUI();
|
||||
initWebSocket(scene);
|
||||
initVisitor();
|
||||
initPresence();
|
||||
|
||||
// Dismiss loading screen
|
||||
const loadingScreen = document.getElementById('loading-screen');
|
||||
@@ -78,6 +80,7 @@ function buildWorld(firstInit, stateSnapshot) {
|
||||
}
|
||||
|
||||
updateControls();
|
||||
feedFps(currentFps);
|
||||
updateEffects(now);
|
||||
updateAgents(now);
|
||||
updateAvatar(delta);
|
||||
@@ -119,6 +122,7 @@ function teardown({ scene, renderer, ac }) {
|
||||
disposeAvatar();
|
||||
disposeInteraction();
|
||||
disposeEffects();
|
||||
disposePresence();
|
||||
disposeAgents();
|
||||
disposeWorld(renderer, scene);
|
||||
}
|
||||
|
||||
139
js/presence.js
Normal file
139
js/presence.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* presence.js — Agent Presence HUD for The Matrix.
|
||||
*
|
||||
* Shows a live "who's online" panel with connection status indicators,
|
||||
* uptime tracking, and animated pulse dots per agent. Updates every second.
|
||||
*
|
||||
* In mock mode, all built-in agents show as "online" with simulated uptime.
|
||||
* In live mode, the panel reacts to WS events (agent_state, agent_joined, agent_left).
|
||||
*
|
||||
* Resolves Issue #53
|
||||
*/
|
||||
|
||||
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
|
||||
import { getAgentDefs } from './agents.js';
|
||||
import { getConnectionState } from './websocket.js';
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
let $panel = null;
|
||||
|
||||
/** @type {Map<string, { online: boolean, since: number }>} */
|
||||
const presence = new Map();
|
||||
|
||||
let updateInterval = null;
|
||||
|
||||
/* ── Public API ── */
|
||||
|
||||
export function initPresence() {
|
||||
$panel = document.getElementById('presence-hud');
|
||||
if (!$panel) return;
|
||||
|
||||
// Initialize all built-in agents
|
||||
const now = Date.now();
|
||||
for (const def of AGENT_DEFS) {
|
||||
presence.set(def.id, { online: true, since: now });
|
||||
}
|
||||
|
||||
// Initial render
|
||||
render();
|
||||
|
||||
// Update every second for uptime tickers
|
||||
updateInterval = setInterval(render, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an agent as online (called from websocket.js on agent_joined/agent_register).
|
||||
*/
|
||||
export function setAgentOnline(agentId) {
|
||||
const entry = presence.get(agentId);
|
||||
if (entry) {
|
||||
entry.online = true;
|
||||
entry.since = Date.now();
|
||||
} else {
|
||||
presence.set(agentId, { online: true, since: Date.now() });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an agent as offline (called from websocket.js on agent_left/disconnect).
|
||||
*/
|
||||
export function setAgentOffline(agentId) {
|
||||
const entry = presence.get(agentId);
|
||||
if (entry) {
|
||||
entry.online = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function disposePresence() {
|
||||
if (updateInterval) {
|
||||
clearInterval(updateInterval);
|
||||
updateInterval = null;
|
||||
}
|
||||
presence.clear();
|
||||
}
|
||||
|
||||
/* ── Internal ── */
|
||||
|
||||
function formatUptime(ms) {
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
if (totalSec < 60) return `${totalSec}s`;
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
if (min < 60) return `${min}m ${String(sec).padStart(2, '0')}s`;
|
||||
const hr = Math.floor(min / 60);
|
||||
const remMin = min % 60;
|
||||
return `${hr}h ${String(remMin).padStart(2, '0')}m`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!$panel) return;
|
||||
|
||||
const connState = getConnectionState();
|
||||
const defs = getAgentDefs();
|
||||
const now = Date.now();
|
||||
|
||||
// In mock mode, all agents are "online"
|
||||
const isMock = connState === 'mock';
|
||||
|
||||
let onlineCount = 0;
|
||||
const rows = [];
|
||||
|
||||
for (const def of defs) {
|
||||
const p = presence.get(def.id);
|
||||
const isOnline = isMock ? true : (p?.online ?? false);
|
||||
if (isOnline) onlineCount++;
|
||||
|
||||
const uptime = isOnline && p ? formatUptime(now - p.since) : '--';
|
||||
const color = colorToCss(def.color);
|
||||
const stateLabel = def.state === 'active' ? 'ACTIVE' : 'IDLE';
|
||||
const dotClass = isOnline ? 'presence-dot online' : 'presence-dot offline';
|
||||
const stateColor = def.state === 'active' ? '#00ff41' : '#33aa55';
|
||||
|
||||
rows.push(
|
||||
`<div class="presence-row">` +
|
||||
`<span class="${dotClass}" style="--agent-color:${color}"></span>` +
|
||||
`<span class="presence-name" style="color:${color}">${escapeHtml(def.label)}</span>` +
|
||||
`<span class="presence-state" style="color:${stateColor}">${stateLabel}</span>` +
|
||||
`<span class="presence-uptime">${uptime}</span>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
const modeLabel = isMock ? 'LOCAL' : (connState === 'connected' ? 'LIVE' : 'OFFLINE');
|
||||
const modeColor = connState === 'connected' ? '#00ff41' : (isMock ? '#33aa55' : '#553300');
|
||||
|
||||
$panel.innerHTML =
|
||||
`<div class="presence-header">` +
|
||||
`<span>PRESENCE</span>` +
|
||||
`<span class="presence-count">${onlineCount}/${defs.length}</span>` +
|
||||
`<span class="presence-mode" style="color:${modeColor}">${modeLabel}</span>` +
|
||||
`</div>` +
|
||||
rows.join('');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "node test/smoke.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"three": "0.171.0"
|
||||
|
||||
221
test/smoke.mjs
Normal file
221
test/smoke.mjs
Normal file
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* smoke.mjs — Automated smoke test for The Matrix.
|
||||
*
|
||||
* Validates:
|
||||
* 1. Vite production build succeeds without errors
|
||||
* 2. All expected JS modules exist and export correctly
|
||||
* 3. index.html contains required DOM structure
|
||||
* 4. Bundle size stays within budget
|
||||
* 5. No import/export errors in module graph
|
||||
*
|
||||
* Usage: node test/smoke.mjs
|
||||
* Exit: 0 = all pass, 1 = failures found
|
||||
*
|
||||
* Resolves Issue #55
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..');
|
||||
const DIST = join(ROOT, 'dist');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const failures = [];
|
||||
|
||||
function ok(name) {
|
||||
passed++;
|
||||
console.log(` ✅ ${name}`);
|
||||
}
|
||||
|
||||
function fail(name, reason) {
|
||||
failed++;
|
||||
failures.push({ name, reason });
|
||||
console.log(` ❌ ${name}: ${reason}`);
|
||||
}
|
||||
|
||||
function assert(condition, name, reason = 'assertion failed') {
|
||||
if (condition) ok(name);
|
||||
else fail(name, reason);
|
||||
}
|
||||
|
||||
// ── 1. Module inventory ──────────────────────────────────────────────
|
||||
|
||||
console.log('\n🔍 Module inventory');
|
||||
|
||||
const EXPECTED_MODULES = [
|
||||
'agent-defs.js',
|
||||
'agents.js',
|
||||
'avatar.js',
|
||||
'bark.js',
|
||||
'config.js',
|
||||
'effects.js',
|
||||
'interaction.js',
|
||||
'main.js',
|
||||
'presence.js',
|
||||
'quality.js',
|
||||
'ui.js',
|
||||
'visitor.js',
|
||||
'websocket.js',
|
||||
'world.js',
|
||||
];
|
||||
|
||||
const jsDir = join(ROOT, 'js');
|
||||
const actualModules = existsSync(jsDir)
|
||||
? readdirSync(jsDir).filter(f => f.endsWith('.js')).sort()
|
||||
: [];
|
||||
|
||||
assert(
|
||||
actualModules.length >= EXPECTED_MODULES.length,
|
||||
`Module count ≥ ${EXPECTED_MODULES.length}`,
|
||||
`found ${actualModules.length}, expected ≥ ${EXPECTED_MODULES.length}`
|
||||
);
|
||||
|
||||
for (const mod of EXPECTED_MODULES) {
|
||||
assert(
|
||||
actualModules.includes(mod),
|
||||
`Module exists: ${mod}`,
|
||||
'not found in js/'
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. Module exports check ──────────────────────────────────────────
|
||||
|
||||
console.log('\n🔍 Module exports');
|
||||
|
||||
const EXPECTED_EXPORTS = {
|
||||
'agent-defs.js': ['AGENT_DEFS', 'colorToCss'],
|
||||
'agents.js': ['initAgents', 'updateAgents', 'disposeAgents'],
|
||||
'avatar.js': ['initAvatar', 'updateAvatar', 'disposeAvatar'],
|
||||
'effects.js': ['initEffects', 'updateEffects', 'disposeEffects', 'feedFps'],
|
||||
'main.js': [], // main.js is the entry point, may not export
|
||||
'presence.js': ['initPresence', 'disposePresence'],
|
||||
'ui.js': ['initUI', 'appendChatMessage'],
|
||||
'visitor.js': ['initVisitor', 'sendVisitorMessage'],
|
||||
'websocket.js': ['initWebSocket'],
|
||||
'world.js': ['initWorld'],
|
||||
};
|
||||
|
||||
for (const [mod, exports] of Object.entries(EXPECTED_EXPORTS)) {
|
||||
const filePath = join(jsDir, mod);
|
||||
if (!existsSync(filePath)) continue; // already flagged above
|
||||
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
|
||||
for (const exp of exports) {
|
||||
const hasExport =
|
||||
content.includes(`export function ${exp}`) ||
|
||||
content.includes(`export const ${exp}`) ||
|
||||
content.includes(`export { ${exp}`) ||
|
||||
content.includes(`export let ${exp}`);
|
||||
|
||||
assert(hasExport, `${mod} exports ${exp}`, `export '${exp}' not found`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. HTML structure ────────────────────────────────────────────────
|
||||
|
||||
console.log('\n🔍 HTML structure');
|
||||
|
||||
const html = readFileSync(join(ROOT, 'index.html'), 'utf-8');
|
||||
|
||||
const REQUIRED_ELEMENTS = [
|
||||
'loading-screen',
|
||||
'hud',
|
||||
'agent-count',
|
||||
'fps',
|
||||
'status-panel',
|
||||
'agent-list',
|
||||
'chat-panel',
|
||||
'chat-input',
|
||||
'chat-send',
|
||||
'bark-container',
|
||||
'presence-hud',
|
||||
'connection-status',
|
||||
];
|
||||
|
||||
for (const id of REQUIRED_ELEMENTS) {
|
||||
assert(
|
||||
html.includes(`id="${id}"`),
|
||||
`HTML has #${id}`,
|
||||
'element not found in index.html'
|
||||
);
|
||||
}
|
||||
|
||||
assert(
|
||||
html.includes('type="module"') && html.includes('main.js'),
|
||||
'Entry point is ESM module',
|
||||
'script type="module" with main.js not found'
|
||||
);
|
||||
|
||||
// ── 4. Vite production build ─────────────────────────────────────────
|
||||
|
||||
console.log('\n🔍 Vite production build');
|
||||
|
||||
try {
|
||||
const buildOut = execSync('npx vite build', { cwd: ROOT, encoding: 'utf-8', timeout: 60000 });
|
||||
ok('vite build succeeds');
|
||||
|
||||
// Check dist/ output
|
||||
assert(existsSync(join(DIST, 'index.html')), 'dist/index.html exists');
|
||||
assert(existsSync(join(DIST, 'sw.js')), 'dist/sw.js exists (PWA)');
|
||||
|
||||
// Check assets
|
||||
const assetsDir = join(DIST, 'assets');
|
||||
if (existsSync(assetsDir)) {
|
||||
const assets = readdirSync(assetsDir);
|
||||
const jsAssets = assets.filter(f => f.endsWith('.js'));
|
||||
const hasThreeChunk = jsAssets.some(f => f.includes('three'));
|
||||
|
||||
assert(jsAssets.length >= 2, `JS chunks: ${jsAssets.length} (code-split)`, 'expected ≥ 2 chunks');
|
||||
assert(hasThreeChunk, 'Three.js in separate chunk', 'no three.js chunk found');
|
||||
|
||||
// Bundle size budget: Three.js chunk should be < 800KB, app code < 200KB
|
||||
for (const js of jsAssets) {
|
||||
const size = statSync(join(assetsDir, js)).size;
|
||||
const sizeKB = Math.round(size / 1024);
|
||||
const isThree = js.includes('three');
|
||||
const budget = isThree ? 800 : 200;
|
||||
|
||||
assert(
|
||||
sizeKB <= budget,
|
||||
`${js}: ${sizeKB}KB ≤ ${budget}KB budget`,
|
||||
`${sizeKB}KB exceeds ${budget}KB budget`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
fail('dist/assets/ exists', 'assets directory not found');
|
||||
}
|
||||
} catch (err) {
|
||||
fail('vite build succeeds', err.message.slice(0, 200));
|
||||
}
|
||||
|
||||
// ── 5. Manifest & PWA ───────────────────────────────────────────────
|
||||
|
||||
console.log('\n🔍 PWA manifest');
|
||||
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(join(ROOT, 'manifest.json'), 'utf-8'));
|
||||
assert(!!manifest.name, 'manifest.name defined');
|
||||
assert(Array.isArray(manifest.icons) && manifest.icons.length > 0, 'manifest has icons');
|
||||
assert(manifest.display === 'standalone' || manifest.display === 'fullscreen', 'manifest display mode');
|
||||
} catch (err) {
|
||||
fail('manifest.json valid', err.message);
|
||||
}
|
||||
|
||||
// ── Summary ──────────────────────────────────────────────────────────
|
||||
|
||||
console.log(`\n${'═'.repeat(50)}`);
|
||||
console.log(` SMOKE TEST: ${passed} passed, ${failed} failed`);
|
||||
if (failures.length > 0) {
|
||||
console.log('\n Failures:');
|
||||
for (const f of failures) {
|
||||
console.log(` • ${f.name}: ${f.reason}`);
|
||||
}
|
||||
}
|
||||
console.log(`${'═'.repeat(50)}\n`);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user