panel v0.9.2 — public release

Self-hostable game server control panel: Go controller + agent, 26 game
modules, embedded web UI. One-line install via install.sh / install.ps1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 00:43:35 -07:00
commit 4ccccc6fe2
2164 changed files with 301480 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
# controller/.test — UI regression gate
Two layers, run them in this order.
## 1. `gate-parse.mjs` — static, zero dependencies, run ALWAYS
```bash
node controller/.test/gate-parse.mjs
```
Extracts every inline `<script>` block from `controller/cmd/controller/static/{new,index,login}.html`
and parses each one (`acorn` if installed locally, else `node --check` on
extracted temp files). Exit 0 = all blocks parse; non-zero prints the file,
block index, and the parser error.
This is the codified **2026-05-08 blank-page deploy gate**: the panel HTML is
embedded via `//go:embed`, so `go build` succeeds even when a JS syntax error
would render the entire panel blank. Run this before every controller build
that touched `static/*.html`. Needs nothing but Node — no npm install, no
controller, no browser.
## 2. `ui.spec.mjs` — Playwright browser suite, needs a RUNNING controller
```bash
cd controller/.test
npm install && npx playwright install chromium # one-time
BASE_URL=http://127.0.0.1:8080 \
TEST_EMAIL=you@example.com TEST_PASSWORD=... \
npx playwright test ui.spec.mjs
```
Prereqs (see `memory/build.md`): Postgres dev container up
(`docker compose -f docker-compose.dev.yml up -d`), controller built and
running on `BASE_URL` (default `http://127.0.0.1:8080`).
| Check | Needs |
|---|---|
| `/login` renders form, 0 console errors | controller only |
| `/` without session redirects to `/login` | controller only |
| `/` serves new.html by default, 0 hard console errors | controller + `TEST_EMAIL`/`TEST_PASSWORD` |
| `panel_ui=classic` cookie serves index.html, **no redirect loop** (the FileServer 301 gotcha, see `memory/gotcha-fileserver-index-html-redirect-loop.md`) | controller + creds |
| Command palette opens (Ctrl+K and `#topbar-cmdk`) | controller + creds |
| Hero-stat IDs (`#stat-running/players/agents/cpu` + hints, `#status`, `#status-dot`) attached | controller + creds |
Without `TEST_EMAIL`/`TEST_PASSWORD` the authenticated tests **self-skip**
(Playwright annotation, not failure). Console errors that are clearly
missing-live-agent-shaped (websocket/agent connection noise) are annotated
`needs-live-agent` rather than failed — a live agent is never required.
## Status honesty
`gate-parse.mjs` has been run for real on this repo. `ui.spec.mjs` was
authored against the real DOM IDs in `static/new.html` / `static/login.html`
and the root-route logic in `httpapi.go`, but a full run requires a local
Postgres + controller — if your box doesn't have the dev Postgres container,
the browser suite is unverified there.
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
// gate-parse.mjs — static JS parse gate for the panel front-ends.
//
// This codifies the 2026-05-08 blank-page deploy gate: a single syntax error
// anywhere in the giant inline <script> block of new.html/index.html renders
// the whole panel as a blank page, and `go build` cannot catch it because the
// HTML is embedded as opaque bytes (//go:embed). This script extracts every
// <script> block from the served HTML files and parses each one.
//
// Zero dependencies: each block is written to a temp file and checked with
// `node --check` (falls back to acorn if a local install of it exists).
//
// Usage: node gate-parse.mjs
// Exit 0 = every script block in every file parses. Non-zero = at least one
// syntax error (printed with file, block index, and node's error output).
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const staticDir = resolve(here, '../cmd/controller/static');
// new.html + index.html are the two panel UIs the root route can serve;
// login.html is the unauthenticated landing page. All three brick on a
// syntax error, so all three are gated.
const files = ['new.html', 'index.html', 'login.html'];
// Extract inline <script> blocks. Blocks with a src= attribute have no inline
// body worth parsing; blocks with a non-JS type (application/json templates
// etc.) are skipped.
function extractScripts(html) {
const out = [];
const re = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
let m;
while ((m = re.exec(html)) !== null) {
const attrs = m[1];
const body = m[2];
// External local bundles (e.g. /rcc.js?v=hash) are part of the deploy:
// resolve them from static/ and gate their contents too.
const srcMatch = attrs.match(/\bsrc\s*=\s*["']?([^"'\s>]+)/i);
if (srcMatch) {
const src = srcMatch[1];
if (/^(https?:)?\/\//i.test(src)) continue; // never hotlink; skip if ever present
const local = src.replace(/^\//, '').split('?')[0];
try {
const extBody = readFileSync(join(staticDir, local), 'utf8');
const line = html.slice(0, m.index).split('\n').length;
out.push({ body: extBody, line, module: /\btype\s*=\s*["']?module/i.test(attrs), external: local });
} catch (e) {
out.push({ body: `throw new SyntaxError(${JSON.stringify(`missing external script ${local}: ${e.message}`)});(`, line: 0, module: false, external: local });
}
continue;
}
const typeMatch = attrs.match(/\btype\s*=\s*["']?([^"'\s>]+)/i);
const type = typeMatch ? typeMatch[1].toLowerCase() : 'text/javascript';
if (!['text/javascript', 'application/javascript', 'module'].includes(type)) continue;
// Line offset of the block start, for error reporting.
const line = html.slice(0, m.index).split('\n').length;
out.push({ body, line, module: type === 'module' });
}
return out;
}
// Prefer acorn if it happens to be installed next to this suite; otherwise
// use `node --check` on a temp file (always available, needs no npm install).
let acorn = null;
try { acorn = await import('acorn'); } catch { /* fine — node --check path */ }
const tmp = mkdtempSync(join(tmpdir(), 'panel-gate-'));
let failures = 0;
let checkedFiles = 0;
for (const name of files) {
let html;
try {
html = readFileSync(join(staticDir, name), 'utf8');
} catch (e) {
console.error(`FAIL ${name}: cannot read (${e.message})`);
failures++;
continue;
}
checkedFiles++;
const blocks = extractScripts(html);
let fileOk = true;
blocks.forEach((b, i) => {
const label = `${name} script-block ${i + 1}/${blocks.length}${b.external ? ` [external ${b.external}]` : ''} (starts html line ${b.line}, ${b.body.length} chars)`;
try {
if (acorn) {
acorn.parse(b.body, {
ecmaVersion: 'latest',
sourceType: b.module ? 'module' : 'script',
});
} else {
const tf = join(tmp, `${name.replace(/\W/g, '_')}_${i}${b.module ? '.mjs' : '.js'}`);
writeFileSync(tf, b.body);
execFileSync(process.execPath, ['--check', tf], { stdio: ['ignore', 'pipe', 'pipe'] });
}
console.log(`PASS ${label}`);
} catch (e) {
fileOk = false;
failures++;
const detail = (e.stderr ? e.stderr.toString() : e.message).trim();
console.error(`FAIL ${label}\n${detail.split('\n').map(l => ' ' + l).join('\n')}`);
}
});
if (blocks.length === 0) {
// A panel HTML with zero script blocks is itself a broken deploy.
console.error(`FAIL ${name}: 0 inline script blocks found — extraction or file is broken`);
failures++;
fileOk = false;
}
console.log(`${fileOk ? 'OK ' : 'BAD '} ${name}: ${blocks.length} inline script block(s)`);
}
rmSync(tmp, { recursive: true, force: true });
if (failures > 0) {
console.error(`\ngate-parse: ${failures} failure(s) across ${checkedFiles} file(s) — DO NOT DEPLOY`);
process.exit(1);
}
console.log(`\ngate-parse: all script blocks parse (${checkedFiles} files, parser: ${acorn ? 'acorn' : 'node --check'})`);
+12
View File
@@ -0,0 +1,12 @@
{
"name": "panel-controller-ui-gate",
"private": true,
"type": "module",
"scripts": {
"gate": "node gate-parse.mjs",
"test": "playwright test ui.spec.mjs"
},
"devDependencies": {
"@playwright/test": "^1.59.1"
}
}
+122
View File
@@ -0,0 +1,122 @@
// ui.spec.mjs — browser regression gate for the panel controller UI.
// Runs with @playwright/test: npx playwright test ui.spec.mjs
//
// Requires a LOCALLY RUNNING controller (see ../../memory/build.md).
// BASE_URL controller origin (default http://127.0.0.1:8080)
// TEST_EMAIL panel account email \ needed for the authenticated
// TEST_PASSWORD panel account password / tests; without them those
// tests self-skip (annotated).
//
// Anything that would need a live agent (server cards with real data,
// consoles, etc.) is deliberately NOT asserted — those checks self-skip.
// The point of this suite is the 2026-05-08 class of regression: the page
// serves, the inline JS boots with zero console errors, the root route's
// panel_ui cookie logic doesn't loop, and the command-center chrome
// (palette, hero stats) exists.
import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080';
const EMAIL = process.env.TEST_EMAIL || '';
const PASSWORD = process.env.TEST_PASSWORD || '';
// Collect console errors + page errors on a page. Filters nothing: the gate
// is "0 console errors" on first paint, matching the redroid-console house
// boilerplate (page.on('console'/'pageerror') capture).
function captureErrors(page) {
const errs = [];
page.on('console', m => { if (m.type() === 'error') errs.push(`[console.error] ${m.text().slice(0, 300)}`); });
page.on('pageerror', e => errs.push(`[pageerror] ${e.message.slice(0, 300)}`));
return errs;
}
async function login(page) {
await page.goto(BASE + '/login');
await page.fill('#email', EMAIL);
await page.fill('#password', PASSWORD);
await page.click('#submit');
await page.waitForURL(u => !u.pathname.startsWith('/login'), { timeout: 10_000 });
}
test.describe('unauthenticated', () => {
test('login page renders with its form and 0 console errors', async ({ page }) => {
const errs = captureErrors(page);
const resp = await page.goto(BASE + '/login');
expect(resp.ok()).toBeTruthy();
await expect(page.locator('#email')).toBeVisible();
await expect(page.locator('#password')).toBeVisible();
await expect(page.locator('#submit')).toBeVisible();
await page.waitForTimeout(500);
expect(errs, errs.join('\n')).toHaveLength(0);
});
test('root without session 302s to /login (no loop)', async ({ page }) => {
await page.goto(BASE + '/');
await expect(page).toHaveURL(/\/login/);
});
});
test.describe('authenticated', () => {
test.skip(!EMAIL || !PASSWORD,
'TEST_EMAIL / TEST_PASSWORD not set — authenticated UI checks skipped');
test('root serves new.html by default, boots with 0 console errors', async ({ page }, testInfo) => {
const errs = captureErrors(page);
await login(page);
await page.goto(BASE + '/');
// new.html markers: hero-stat tiles + command-palette hint in the topbar.
await expect(page.locator('#stat-running')).toBeAttached();
await expect(page.locator('#topbar-cmdk')).toBeAttached();
await page.waitForTimeout(1500); // let the boot JS run
// WebSocket/agent-dependent noise would need a live agent — annotate
// rather than fail on errors that are clearly missing-agent-shaped.
const agentish = errs.filter(e => /websocket|ws:|agent|ECONNREFUSED/i.test(e));
const hard = errs.filter(e => !agentish.includes(e));
if (agentish.length) {
testInfo.annotations.push({ type: 'needs-live-agent', description: agentish.join(' | ') });
}
expect(hard, hard.join('\n')).toHaveLength(0);
});
test('panel_ui=classic cookie serves index.html with no redirect loop', async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page);
await ctx.addCookies([{
name: 'panel_ui', value: 'classic',
url: BASE,
}]);
// The 2026-05-31 gotcha: FileServer canonicalization made "/" 301-loop
// for authenticated classic users (ERR_TOO_MANY_REDIRECTS). A successful
// goto with a 200 final response proves no loop.
const resp = await page.goto(BASE + '/');
expect(resp.status()).toBe(200);
// Classic UI must NOT show the new-brand hero-stat tiles.
await expect(page.locator('#stat-running')).toHaveCount(0);
await ctx.close();
});
test('command palette opens on Ctrl+K and via the topbar hint', async ({ page }) => {
await login(page);
await page.goto(BASE + '/');
const modal = page.locator('#cmdk-modal');
await expect(modal).toBeHidden();
await page.keyboard.press('Control+KeyK');
await expect(modal).toBeVisible();
await expect(page.locator('#cmdk-input')).toBeFocused();
await page.keyboard.press('Escape');
await expect(modal).toBeHidden();
await page.click('#topbar-cmdk');
await expect(modal).toBeVisible();
});
test('hero-stat element IDs present', async ({ page }) => {
await login(page);
await page.goto(BASE + '/');
for (const id of ['stat-running', 'stat-players', 'stat-agents', 'stat-cpu',
'stat-running-hint', 'stat-players-hint', 'stat-agents-hint', 'stat-cpu-hint',
'status-dot', 'status']) {
await expect(page.locator('#' + id), `#${id} missing from new.html DOM`).toBeAttached();
}
});
});
+106
View File
@@ -0,0 +1,106 @@
// WI-07 verification: diff the dashboard's old inline per-module maps
// (READY_PATTERNS / moduleAppearance / moduleBrowseableRoots /
// guessPortsForModule — kept in rcc.js as fallbacks) against the values
// the manifests now serve via GET /api/modules (as loaded by
// tools/module-meta-dump, which uses the controller's own LoadDir).
//
// ready_pattern + appearance were MOVED (must be value-identical → any
// mismatch exits 1). ports + browseable_roots were already declared in
// module.yaml; drift there is reported informationally (manifest wins).
//
// Run from repo root: node controller/.test/wi07-diff-maps.mjs
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const rcc = fs.readFileSync(path.join(root, "controller/cmd/controller/static/rcc.js"), "utf8");
// Extract a brace-balanced object literal following `marker`.
function extractObject(marker, from = 0) {
const i = rcc.indexOf(marker, from);
if (i < 0) throw new Error("marker not found: " + marker);
const start = rcc.indexOf("{", i);
let depth = 0, j = start;
for (; j < rcc.length; j++) {
const c = rcc[j];
if (c === "{") depth++;
else if (c === "}") { depth--; if (depth === 0) break; }
}
return eval("(" + rcc.slice(start, j + 1) + ")");
}
const READY = extractObject("const READY_PATTERNS = ");
const APPEAR = extractObject("const moduleAppearance = ");
const ROOTS = extractObject("const moduleBrowseableRoots = ");
// ports map lives inside guessPortsForModule()
const fnIdx = rcc.indexOf("function guessPortsForModule(id)");
if (fnIdx < 0) throw new Error("guessPortsForModule not found");
const PORTS = extractObject("const m = {", fnIdx);
// Manifest side.
const goExe = process.env.GO_EXE || "C:\\Program Files\\Go\\bin\\go.exe";
const manifest = JSON.parse(execFileSync(goExe, ["run", "./tools/module-meta-dump"], { cwd: root, encoding: "utf8" }));
// Same compile rule the dashboard uses for manifest ready_pattern strings.
function compileReady(s) {
if (!s) return null;
const m = /^\/([\s\S]+)\/([a-z]*)$/.exec(s);
return m ? new RegExp(m[1], m[2]) : new RegExp(s);
}
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
let hardFail = 0;
const ids = [...new Set([...Object.keys(manifest), ...Object.keys(READY), ...Object.keys(APPEAR), ...Object.keys(ROOTS), ...Object.keys(PORTS)])].sort();
for (const id of ids) {
const man = manifest[id];
const lines = [];
// ready_pattern — moved: must be identical where both sides have it.
const oldRe = READY[id];
const newRe = man && compileReady(man.ready_pattern);
if (oldRe && !man) lines.push(" ready: JS-only (no manifest dir) — fallback covers it");
else if (oldRe && !newRe) { lines.push(" ready: MISSING in manifest"); hardFail++; }
else if (oldRe && newRe && (oldRe.source !== newRe.source || oldRe.flags !== newRe.flags)) {
lines.push(` ready: MISMATCH\n old ${oldRe}\n new ${newRe}`); hardFail++;
}
// appearance — moved: compare emoji/grad/art/steam.
const oa = APPEAR[id], na = man && man.appearance;
if (oa && !man) lines.push(" appearance: JS-only (no manifest dir) — fallback covers it");
else if (oa && !na) { lines.push(" appearance: MISSING in manifest"); hardFail++; }
else if (oa && na) {
for (const k of ["emoji", "grad", "art", "steam"]) {
if ((oa[k] || "") !== (na[k] || "")) { lines.push(` appearance.${k}: MISMATCH old=${JSON.stringify(oa[k] || "")} new=${JSON.stringify(na[k] || "")}`); hardFail++; }
}
}
// browseable_roots — pre-existing in manifests; report drift.
const orr = ROOTS[id], nrr = man && man.browseable_roots;
if (orr && man && !eq(orr, nrr)) lines.push(` roots drift (manifest wins):\n old ${JSON.stringify(orr)}\n new ${JSON.stringify(nrr)}`);
if (orr && !man) lines.push(" roots: JS-only (no manifest dir) — fallback covers it");
// ports — pre-existing in manifests; compare ignoring the additive env field.
const op = PORTS[id];
const np = man && man.ports && man.ports.map(p => {
const o = { name: p.name, proto: p.proto, default: p.default };
if (p.required) o.required = true;
if (p.internal) o.internal = true;
return o;
});
const opNorm = op && op.map(p => {
const o = { name: p.name, proto: p.proto, default: p.default };
if (p.required) o.required = true;
if (p.internal) o.internal = true;
return o;
});
if (op && man && !eq(opNorm, np)) lines.push(` ports drift (manifest wins):\n old ${JSON.stringify(opNorm)}\n new ${JSON.stringify(np)}`);
if (op && !man) lines.push(" ports: JS-only (no manifest dir) — fallback covers it");
if (lines.length) console.log(id + "\n" + lines.join("\n"));
else console.log(id + "\n OK (identical)");
}
console.log(hardFail ? `\nFAIL: ${hardFail} moved-field mismatch(es)` : "\nPASS: all moved fields (ready_pattern, appearance) value-identical");
process.exit(hardFail ? 1 : 0);