panel — open-source game server manager (public release)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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'})`);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user