/** * Tests for scripts/control-pane.js and its local HTTP API. */ const assert = require('assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawn, spawnSync } = require('child_process'); const initSqlJs = require('sql.js'); const { createControlPaneServer, parseArgs, runAction, } = require('../../scripts/lib/control-pane/server'); const { main: runControlPaneCli, } = require('../../scripts/control-pane'); const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'control-pane.js'); const REPO_ROOT = path.join(__dirname, '..', '..'); async function test(name, fn) { try { await fn(); console.log(` PASS ${name}`); return true; } catch (error) { console.log(` FAIL ${name}`); console.log(` Error: ${error.message}`); return false; } } async function writeMinimalDatabase(dbPath) { const SQL = await initSqlJs(); const db = new SQL.Database(); db.run(` CREATE TABLE sessions ( id TEXT PRIMARY KEY, task TEXT NOT NULL, project TEXT NOT NULL DEFAULT '', task_group TEXT NOT NULL DEFAULT '', agent_type TEXT NOT NULL, harness TEXT NOT NULL DEFAULT 'unknown', detected_harnesses_json TEXT NOT NULL DEFAULT '[]', working_dir TEXT NOT NULL DEFAULT '.', state TEXT NOT NULL DEFAULT 'pending', pid INTEGER, worktree_path TEXT, worktree_branch TEXT, worktree_base TEXT, input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, tokens_used INTEGER DEFAULT 0, tool_calls INTEGER DEFAULT 0, files_changed INTEGER DEFAULT 0, duration_secs INTEGER DEFAULT 0, cost_usd REAL DEFAULT 0.0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_heartbeat_at TEXT NOT NULL ); INSERT INTO sessions ( id, task, agent_type, harness, detected_harnesses_json, working_dir, state, created_at, updated_at, last_heartbeat_at ) VALUES ( 'session-a', 'Build the control pane', 'codex', 'codex', '["codex"]', '/repo/ecc', 'running', '2026-06-03T10:00:00Z', '2026-06-03T10:05:00Z', '2026-06-03T10:05:00Z' ); `); fs.writeFileSync(dbPath, Buffer.from(db.export())); db.close(); } function waitForCliReady(child) { return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; let settled = false; const timer = setTimeout(() => { if (settled) return; settled = true; child.kill('SIGTERM'); reject(new Error(`Timed out waiting for control pane CLI.\nstdout:\n${stdout}\nstderr:\n${stderr}`)); }, 5000); child.stdout.on('data', chunk => { stdout += chunk.toString('utf8'); if (!settled && stdout.includes('ECC Control Pane:') && stdout.includes('Actions:')) { settled = true; clearTimeout(timer); resolve({ stdout, stderr }); } }); child.stderr.on('data', chunk => { stderr += chunk.toString('utf8'); }); child.on('error', error => { if (settled) return; settled = true; clearTimeout(timer); reject(error); }); child.on('exit', code => { if (settled) return; settled = true; clearTimeout(timer); reject(new Error(`control pane CLI exited early with ${code}.\nstdout:\n${stdout}\nstderr:\n${stderr}`)); }); }); } function waitForExit(child) { return new Promise(resolve => { child.once('exit', (code, signal) => resolve({ code, signal })); }); } async function fetchLocal(url, options) { let lastError; for (let attempt = 0; attempt < 5; attempt += 1) { try { return await fetch(url, options); } catch (error) { lastError = error; await new Promise(resolve => setTimeout(resolve, 25 * (attempt + 1))); } } throw lastError; } async function runTests() { console.log('\n=== Testing control-pane server ===\n'); let passed = 0; let failed = 0; if (await test('parses CLI arguments for local-only serving', async () => { const parsed = parseArgs([ 'node', 'scripts/control-pane.js', '--host', '127.0.0.1', '--port', '8788', '--db', '/tmp/ecc2.db', '--state-db', '/tmp/ecc-state.db', '--query', 'Hermes memory', '--no-open', ]); assert.strictEqual(parsed.host, '127.0.0.1'); assert.strictEqual(parsed.port, 8788); assert.strictEqual(parsed.dbPath, '/tmp/ecc2.db'); assert.strictEqual(parsed.stateDbPath, '/tmp/ecc-state.db'); assert.strictEqual(parsed.query, 'Hermes memory'); assert.strictEqual(parsed.openBrowser, false); })) passed++; else failed++; if (await test('rejects invalid CLI port values', async () => { assert.throws( () => parseArgs(['node', 'scripts/control-pane.js', '--port', '70000']), /Invalid --port value/ ); assert.throws( () => parseArgs(['node', 'scripts/control-pane.js', '--port', 'wat']), /Invalid --port value/ ); })) passed++; else failed++; if (await test('rejects missing state database path values', async () => { assert.throws( () => parseArgs(['node', 'scripts/control-pane.js', '--state-db']), /Invalid --state-db value/ ); assert.throws( () => parseArgs(['node', 'scripts/control-pane.js', '--state-db', '--query', 'Hermes']), /Invalid --state-db value/ ); })) passed++; else failed++; if (await test('serves HTML and snapshot JSON from a temp ECC2 database', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-server-')); const dbPath = path.join(tempDir, 'ecc2.db'); try { await writeMinimalDatabase(dbPath); const app = await createControlPaneServer({ host: '127.0.0.1', port: 0, dbPath, repoRoot: REPO_ROOT, query: 'control pane', allowActions: false, }); await app.listen(); try { const html = await fetchLocal(`${app.url}/`).then(response => response.text()); assert.ok(html.includes('ECC Control Pane')); assert.ok(html.includes('id="app"')); assert.ok(html.includes('id="work-items"')); assert.ok(html.includes('function renderWorkItems')); assert.ok(html.includes('function showError')); assert.ok(html.includes('response.ok')); const snapshot = await fetchLocal(`${app.url}/api/snapshot?query=control`).then(response => response.json()); assert.strictEqual(snapshot.schemaVersion, 'ecc.control-pane.snapshot.v1'); assert.strictEqual(snapshot.summary.totalSessions, 1); assert.strictEqual(snapshot.workItems.totalCount, 0); assert.strictEqual(snapshot.sessions[0].id, 'session-a'); } finally { await app.close(); } } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } })) passed++; else failed++; if (await test('serves health, asset, not-found, invalid body, and read-only action responses', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-routes-')); try { const app = await createControlPaneServer({ host: '127.0.0.1', port: 0, dbPath: path.join(tempDir, 'missing.db'), repoRoot: tempDir, allowActions: false, }); await app.listen(); try { const health = await fetchLocal(`${app.url}/api/health`).then(response => response.json()); assert.strictEqual(health.ok, true); assert.strictEqual(health.allowActions, false); const realAssetApp = await createControlPaneServer({ host: '127.0.0.1', port: 0, dbPath: path.join(tempDir, 'missing.db'), repoRoot: REPO_ROOT, allowActions: false, }); await realAssetApp.listen(); try { const realAsset = await fetchLocal(`${realAssetApp.url}/assets/ecc-icon.svg`); assert.strictEqual(realAsset.status, 200); assert.match(await realAsset.text(), / response.json()); assert.strictEqual(missing.ok, false); assert.strictEqual(missing.error, 'not found'); const blocked = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query: 'memory' }), }).then(async response => ({ status: response.status, body: await response.json() })); assert.strictEqual(blocked.status, 403); assert.match(blocked.body.error, /disabled/); const invalidBody = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{bad json', }).then(async response => ({ status: response.status, body: await response.json() })); assert.strictEqual(invalidBody.status, 403); } finally { await app.close(); } } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } })) passed++; else failed++; if (await test('guards copy-only and unknown action requests', async () => { const app = await createControlPaneServer({ host: '127.0.0.1', port: 0, repoRoot: REPO_ROOT, allowActions: true, }); await app.listen(); try { const copyOnly = await fetchLocal(`${app.url}/api/actions/open-dashboard`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', }).then(async response => ({ status: response.status, body: await response.json() })); assert.strictEqual(copyOnly.status, 400); assert.strictEqual(copyOnly.body.action, 'open-dashboard'); assert.match(copyOnly.body.error, /copy-only/); const unknown = await fetchLocal(`${app.url}/api/actions/nope`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', }).then(async response => ({ status: response.status, body: await response.json() })); assert.strictEqual(unknown.status, 500); assert.match(unknown.body.error, /Unknown control-pane action/); const invalidBody = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{bad json', }).then(async response => ({ status: response.status, body: await response.json() })); assert.strictEqual(invalidBody.status, 500); assert.match(invalidBody.body.error, /JSON/); } finally { await app.close(); } })) passed++; else failed++; if (await test('runAction captures success, failure, and bounded output', async () => { const repoRoot = REPO_ROOT; const success = await runAction({ id: 'node-success', command: process.execPath, args: ['-e', 'process.stdout.write("x".repeat(21010))'], cwd: repoRoot, }); assert.strictEqual(success.ok, true); assert.strictEqual(success.code, 0); assert.ok(success.stdout.includes('[truncated ')); const failure = await runAction({ id: 'node-failure', command: process.execPath, args: ['-e', 'process.stderr.write("bad"); process.exit(7)'], cwd: repoRoot, }); assert.strictEqual(failure.ok, false); assert.strictEqual(failure.code, 7); assert.strictEqual(failure.stderr, 'bad'); const spawnError = await runAction({ id: 'spawn-error', command: 'definitely-not-ecc-control-pane-command', args: [], cwd: repoRoot, }); assert.strictEqual(spawnError.ok, false); assert.strictEqual(spawnError.code, null); assert.match(spawnError.error, /ENOENT/); })) passed++; else failed++; if (await test('runAction terminates commands that exceed the local timeout', async () => { const timedOut = await runAction( { id: 'node-timeout', command: process.execPath, args: ['-e', 'setTimeout(() => {}, 5000)'], cwd: REPO_ROOT, }, { timeoutMs: 25 } ); assert.strictEqual(timedOut.ok, false); assert.strictEqual(timedOut.signal, 'SIGTERM'); })) passed++; else failed++; if (await test('CLI prints help', async () => { const result = spawnSync('node', [SCRIPT, '--help'], { encoding: 'utf8', cwd: REPO_ROOT, }); assert.strictEqual(result.status, 0, result.stderr); assert.ok(result.stdout.includes('Usage:')); assert.ok(result.stdout.includes('control-pane')); })) passed++; else failed++; if (await test('CLI browser opener handles spawn errors', async () => { const source = fs.readFileSync(SCRIPT, 'utf8'); assert.match(source, /child\.on\('error'/); assert.match(source, /child\.unref\(\)/); })) passed++; else failed++; if (await test('CLI main handles help without starting a server', async () => { const originalLog = console.log; const lines = []; console.log = line => { lines.push(String(line)); }; try { await runControlPaneCli(['node', 'scripts/control-pane.js', '--help']); } finally { console.log = originalLog; } assert.match(lines.join('\n'), /Usage:/); assert.match(lines.join('\n'), /--read-only/); })) passed++; else failed++; if (await test('CLI starts a read-only local server and shuts down on SIGTERM', async () => { const child = spawn(process.execPath, [SCRIPT, '--host', '127.0.0.1', '--port', '0', '--read-only', '--no-open'], { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ECC2_DB_PATH: path.join(os.tmpdir(), 'missing-ecc2-cli.db'), }, }); const exitPromise = waitForExit(child); try { const ready = await waitForCliReady(child); assert.match(ready.stdout, /ECC Control Pane: http:\/\/127\.0\.0\.1:\d+/); assert.match(ready.stdout, /Actions: read-only/); } finally { if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); const result = await exitPromise; assert.ok( result.code === 0 || result.signal === 'SIGTERM', `expected graceful shutdown or SIGTERM, got code=${result.code} signal=${result.signal}` ); } })) passed++; else failed++; console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } runTests();