mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-12 19:23:07 +08:00
feat: add ECC2 local control pane (#2131)
* feat: add ECC2 local control pane * fix: refresh control pane package locks * test: harden control pane coverage * test: allow portable control pane shutdown * test: retry local control pane fetches * fix: harden control pane error handling * fix: wrap control pane metadata
This commit is contained in:
100
tests/lib/control-pane-actions.test.js
Normal file
100
tests/lib/control-pane-actions.test.js
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tests for allowlisted ECC2 control-pane actions.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
buildControlPaneActions,
|
||||
buildControlPaneAction,
|
||||
shellQuote,
|
||||
} = require('../../scripts/lib/control-pane/actions');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing control-pane actions ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('builds copyable and executable allowlisted ECC2 actions', () => {
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const actions = buildControlPaneActions({
|
||||
repoRoot,
|
||||
query: 'Hermes Desktop Zellij',
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
assert.ok(actions.some(action => action.id === 'sync-knowledge'));
|
||||
assert.ok(actions.some(action => action.id === 'recall-knowledge'));
|
||||
assert.ok(actions.some(action => action.id === 'open-dashboard'));
|
||||
|
||||
const sync = actions.find(action => action.id === 'sync-knowledge');
|
||||
assert.strictEqual(sync.executable, true);
|
||||
assert.strictEqual(sync.command, 'cargo');
|
||||
assert.deepStrictEqual(sync.args, [
|
||||
'run',
|
||||
'--quiet',
|
||||
'--',
|
||||
'graph',
|
||||
'connector-sync',
|
||||
'--all',
|
||||
'--json',
|
||||
'--limit',
|
||||
'25',
|
||||
]);
|
||||
assert.strictEqual(sync.cwd, path.join(repoRoot, 'ecc2'));
|
||||
assert.ok(sync.commandLine.includes('connector-sync'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('preserves recall query as a single argument instead of shell text', () => {
|
||||
const action = buildControlPaneAction('recall-knowledge', {
|
||||
repoRoot: '/repo/ecc',
|
||||
query: 'Hermes "Desktop"; rm -rf ~',
|
||||
limit: 7,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(action.args, [
|
||||
'run',
|
||||
'--quiet',
|
||||
'--',
|
||||
'graph',
|
||||
'recall',
|
||||
'Hermes "Desktop"; rm -rf ~',
|
||||
'--json',
|
||||
'--limit',
|
||||
'7',
|
||||
]);
|
||||
assert.ok(action.commandLine.includes("'Hermes \"Desktop\"; rm -rf ~'"));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects unknown action identifiers', () => {
|
||||
assert.throws(
|
||||
() => buildControlPaneAction('rm -rf', { repoRoot: '/repo/ecc' }),
|
||||
/Unknown control-pane action/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shellQuote handles empty strings and single quotes', () => {
|
||||
assert.strictEqual(shellQuote(''), "''");
|
||||
assert.strictEqual(shellQuote("can't"), "'can'\\''t'");
|
||||
assert.strictEqual(shellQuote('simple'), 'simple');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
663
tests/lib/control-pane-state.test.js
Normal file
663
tests/lib/control-pane-state.test.js
Normal file
@@ -0,0 +1,663 @@
|
||||
/**
|
||||
* Tests for the local ECC2 control-pane state projection.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const initSqlJs = require('sql.js');
|
||||
|
||||
const {
|
||||
buildControlPaneSnapshot,
|
||||
recallKnowledgeEntries,
|
||||
resolveControlPaneConfig,
|
||||
} = require('../../scripts/lib/control-pane/state');
|
||||
|
||||
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 writeSampleEcc2Database(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
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_session TEXT NOT NULL,
|
||||
to_session TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
msg_type TEXT NOT NULL DEFAULT 'info',
|
||||
read INTEGER DEFAULT 0,
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE context_graph_entities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
entity_key TEXT NOT NULL UNIQUE,
|
||||
entity_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE context_graph_observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
entity_id INTEGER NOT NULL,
|
||||
observation_type TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 1,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
summary TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE context_graph_relations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
from_entity_id INTEGER NOT NULL,
|
||||
to_entity_id INTEGER NOT NULL,
|
||||
relation_type TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE context_graph_connector_checkpoints (
|
||||
connector_name TEXT NOT NULL,
|
||||
source_path TEXT NOT NULL,
|
||||
source_signature TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (connector_name, source_path)
|
||||
);
|
||||
`);
|
||||
|
||||
const insertSession = db.prepare(`
|
||||
INSERT INTO sessions (
|
||||
id, task, project, task_group, agent_type, harness, detected_harnesses_json,
|
||||
working_dir, state, pid, worktree_path, worktree_branch, worktree_base,
|
||||
input_tokens, output_tokens, tokens_used, tool_calls, files_changed,
|
||||
duration_secs, cost_usd, created_at, updated_at, last_heartbeat_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insertSession.run([
|
||||
'lead-hermes',
|
||||
'Coordinate Hermes desktop and ECC release work',
|
||||
'ECC',
|
||||
'2.0-control-pane',
|
||||
'claude',
|
||||
'claude',
|
||||
JSON.stringify(['claude', 'codex']),
|
||||
'/repo/ecc',
|
||||
'running',
|
||||
4242,
|
||||
'/tmp/ecc-worktrees/hermes',
|
||||
'ecc/hermes-control-pane',
|
||||
'main',
|
||||
1200,
|
||||
800,
|
||||
2000,
|
||||
19,
|
||||
6,
|
||||
540,
|
||||
0.42,
|
||||
'2026-06-03T10:00:00Z',
|
||||
'2026-06-03T10:15:00Z',
|
||||
'2026-06-03T10:15:00Z',
|
||||
]);
|
||||
insertSession.run([
|
||||
'worker-kb',
|
||||
'Index operator memory',
|
||||
'ECC',
|
||||
'knowledge',
|
||||
'codex',
|
||||
'codex',
|
||||
JSON.stringify(['codex']),
|
||||
'/repo/ecc',
|
||||
'idle',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
300,
|
||||
200,
|
||||
500,
|
||||
4,
|
||||
2,
|
||||
120,
|
||||
0.07,
|
||||
'2026-06-03T10:05:00Z',
|
||||
'2026-06-03T10:14:00Z',
|
||||
'2026-06-03T10:14:00Z',
|
||||
]);
|
||||
insertSession.free();
|
||||
|
||||
db.run(
|
||||
'INSERT INTO messages (from_session, to_session, content, msg_type, read, timestamp) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
['worker-kb', 'lead-hermes', 'Need approval for connector sync', 'approval_request', 0, '2026-06-03T10:16:00Z']
|
||||
);
|
||||
|
||||
const insertEntity = db.prepare(`
|
||||
INSERT INTO context_graph_entities (
|
||||
session_id, entity_key, entity_type, name, path, summary, metadata_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insertEntity.run([
|
||||
'lead-hermes',
|
||||
'runbook:Hermes revenue runbook:/notes/hermes.md',
|
||||
'runbook',
|
||||
'Hermes revenue runbook',
|
||||
'/notes/hermes.md',
|
||||
'How Affaan routes Hermes Desktop, Zellij panes, Devin-style delegation, and ECC release control work.',
|
||||
JSON.stringify({ source: 'hermes_workspace', platform: 'desktop' }),
|
||||
'2026-06-03T10:10:00Z',
|
||||
'2026-06-03T10:10:00Z',
|
||||
]);
|
||||
insertEntity.run([
|
||||
null,
|
||||
'concept:gbrain memory:/notes/gbrain.md',
|
||||
'concept',
|
||||
'gbrain memory',
|
||||
'/notes/gbrain.md',
|
||||
'Operator knowledge base pattern for cross-platform agent memory.',
|
||||
JSON.stringify({ source: 'workspace_notes' }),
|
||||
'2026-06-03T10:11:00Z',
|
||||
'2026-06-03T10:11:00Z',
|
||||
]);
|
||||
insertEntity.free();
|
||||
|
||||
db.run(
|
||||
'INSERT INTO context_graph_observations (session_id, entity_id, observation_type, priority, pinned, summary, details_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
'lead-hermes',
|
||||
1,
|
||||
'operator_memory',
|
||||
3,
|
||||
1,
|
||||
'Hermes Desktop and ECC should share recall before dispatching work.',
|
||||
JSON.stringify({ note: 'safe public summary only' }),
|
||||
'2026-06-03T10:12:00Z',
|
||||
]
|
||||
);
|
||||
db.run(
|
||||
'INSERT INTO context_graph_relations (session_id, from_entity_id, to_entity_id, relation_type, summary, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
['lead-hermes', 1, 2, 'depends_on', 'Runbook uses durable memory concepts.', '2026-06-03T10:13:00Z']
|
||||
);
|
||||
db.run(
|
||||
'INSERT INTO context_graph_connector_checkpoints (connector_name, source_path, source_signature, updated_at) VALUES (?, ?, ?, ?)',
|
||||
['hermes_workspace', '/notes/hermes.md', 'sig-1', '2026-06-03T10:12:00Z']
|
||||
);
|
||||
|
||||
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function mutateSqlDatabase(dbPath, mutator) {
|
||||
const SQL = await initSqlJs();
|
||||
const buffer = fs.readFileSync(dbPath);
|
||||
const db = new SQL.Database(buffer);
|
||||
try {
|
||||
await mutator(db);
|
||||
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('\n=== Testing control-pane state ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (await test('builds an operator snapshot from ECC2 SQLite and configured connectors', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-state-'));
|
||||
const dbPath = path.join(tempDir, 'ecc2.db');
|
||||
|
||||
try {
|
||||
await writeSampleEcc2Database(dbPath);
|
||||
const snapshot = await buildControlPaneSnapshot({
|
||||
dbPath,
|
||||
repoRoot: path.join(__dirname, '..', '..'),
|
||||
query: 'Hermes Desktop Zellij gbrain',
|
||||
config: {
|
||||
memoryConnectors: {
|
||||
hermes_workspace: {
|
||||
kind: 'markdown_directory',
|
||||
path: '/notes',
|
||||
recurse: true,
|
||||
},
|
||||
safe_env: {
|
||||
kind: 'dotenv_file',
|
||||
path: '/notes/.env',
|
||||
includeSafeValues: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(snapshot.schemaVersion, 'ecc.control-pane.snapshot.v1');
|
||||
assert.strictEqual(snapshot.summary.totalSessions, 2);
|
||||
assert.strictEqual(snapshot.summary.runningSessions, 1);
|
||||
assert.strictEqual(snapshot.summary.unreadMessages, 1);
|
||||
assert.strictEqual(snapshot.sessions[0].id, 'lead-hermes');
|
||||
assert.deepStrictEqual(snapshot.sessions[0].detectedHarnesses, ['claude', 'codex']);
|
||||
assert.strictEqual(snapshot.knowledge.query, 'Hermes Desktop Zellij gbrain');
|
||||
assert.strictEqual(snapshot.knowledge.results[0].entity.name, 'Hermes revenue runbook');
|
||||
assert.ok(snapshot.knowledge.results[0].matchedTerms.includes('hermes'));
|
||||
assert.strictEqual(snapshot.knowledge.results[0].hasPinnedObservation, true);
|
||||
assert.strictEqual(snapshot.connectors.length, 2);
|
||||
assert.strictEqual(snapshot.connectors[0].name, 'hermes_workspace');
|
||||
assert.strictEqual(snapshot.connectors[0].syncedSources, 1);
|
||||
assert.strictEqual(snapshot.connectors[1].syncedSources, 0);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('resolves config from explicit db path and TOML connector file', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-config-'));
|
||||
const dbPath = path.join(tempDir, 'state.db');
|
||||
const configPath = path.join(tempDir, 'ecc2.toml');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
`db_path = "${dbPath.replace(/\\/g, '\\\\')}"`,
|
||||
'',
|
||||
'[memory_connectors.hermes_workspace]',
|
||||
'kind = "markdown_directory"',
|
||||
'path = "/tmp/hermes"',
|
||||
'recurse = true',
|
||||
'default_entity_type = "operator_note"',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const config = resolveControlPaneConfig({
|
||||
cwd: tempDir,
|
||||
configPath,
|
||||
});
|
||||
|
||||
assert.strictEqual(config.dbPath, dbPath);
|
||||
assert.strictEqual(config.memoryConnectors.hermes_workspace.kind, 'markdown_directory');
|
||||
assert.strictEqual(config.memoryConnectors.hermes_workspace.path, '/tmp/hermes');
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('prefers the operator home config over stale app-support config', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-precedence-'));
|
||||
const homeDir = path.join(tempDir, 'home');
|
||||
const homeConfigDir = path.join(homeDir, '.claude');
|
||||
const appConfigDir = path.join(homeDir, 'Library', 'Application Support', 'ecc2');
|
||||
const homeDbPath = path.join(tempDir, 'operator.db');
|
||||
const staleDbPath = path.join(tempDir, 'stale-smoke.db');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(homeConfigDir, { recursive: true });
|
||||
fs.mkdirSync(appConfigDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(appConfigDir, 'config.toml'),
|
||||
`db_path = "${staleDbPath.replace(/\\/g, '\\\\')}"\n`,
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(homeConfigDir, 'ecc2.toml'),
|
||||
`db_path = "${homeDbPath.replace(/\\/g, '\\\\')}"\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const config = resolveControlPaneConfig({
|
||||
cwd: tempDir,
|
||||
env: { HOME: homeDir },
|
||||
});
|
||||
|
||||
assert.strictEqual(config.dbPath, homeDbPath);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('shows configured connectors even when the SQLite database is missing', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-missing-db-'));
|
||||
|
||||
try {
|
||||
const snapshot = await buildControlPaneSnapshot({
|
||||
repoRoot: path.join(__dirname, '..', '..'),
|
||||
dbPath: path.join(tempDir, 'missing.db'),
|
||||
config: {
|
||||
memoryConnectors: {
|
||||
hermes_workspace: {
|
||||
kind: 'markdown_directory',
|
||||
path: '/notes/hermes',
|
||||
recurse: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(snapshot.database.exists, false);
|
||||
assert.strictEqual(snapshot.connectors.length, 1);
|
||||
assert.strictEqual(snapshot.connectors[0].name, 'hermes_workspace');
|
||||
assert.strictEqual(snapshot.connectors[0].syncedSources, 0);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('handles an existing SQLite database before ECC2 tables are created', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-empty-db-'));
|
||||
const dbPath = path.join(tempDir, 'empty.db');
|
||||
|
||||
try {
|
||||
const SQL = await initSqlJs();
|
||||
const db = new SQL.Database();
|
||||
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
||||
db.close();
|
||||
|
||||
const snapshot = await buildControlPaneSnapshot({
|
||||
repoRoot: path.join(__dirname, '..', '..'),
|
||||
dbPath,
|
||||
config: {
|
||||
memoryConnectors: {
|
||||
workspace_notes: {
|
||||
kind: 'markdown_directory',
|
||||
path: '/notes',
|
||||
includeSafeValues: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(snapshot.database.exists, true);
|
||||
assert.strictEqual(snapshot.summary.totalSessions, 0);
|
||||
assert.strictEqual(snapshot.knowledge.entityCount, 0);
|
||||
assert.strictEqual(snapshot.knowledge.observationCount, 0);
|
||||
assert.strictEqual(snapshot.connectors[0].name, 'workspace_notes');
|
||||
assert.strictEqual(snapshot.connectors[0].lastSyncedAt, null);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('recalls pinned knowledge when no query is provided', async () => {
|
||||
const results = recallKnowledgeEntries({
|
||||
entities: [
|
||||
{
|
||||
id: 1,
|
||||
entityType: 'runbook',
|
||||
name: 'Pinned runbook',
|
||||
path: '/notes/pinned.md',
|
||||
summary: 'Pinned operator context',
|
||||
metadata: {},
|
||||
updatedAt: '2026-06-03T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
entityType: 'concept',
|
||||
name: 'Unpinned concept',
|
||||
path: null,
|
||||
summary: 'Secondary context',
|
||||
metadata: {},
|
||||
updatedAt: '2026-06-03T11:00:00Z',
|
||||
},
|
||||
],
|
||||
observations: [
|
||||
{
|
||||
entityId: 1,
|
||||
priority: 4,
|
||||
pinned: true,
|
||||
summary: 'Pinned detail',
|
||||
},
|
||||
{
|
||||
entityId: 2,
|
||||
priority: 2,
|
||||
pinned: false,
|
||||
summary: 'Other detail',
|
||||
},
|
||||
],
|
||||
relationCounts: new Map([[1, 3]]),
|
||||
query: '',
|
||||
limit: 0,
|
||||
});
|
||||
|
||||
assert.strictEqual(results.length, 2);
|
||||
assert.strictEqual(results[0].entity.name, 'Pinned runbook');
|
||||
assert.strictEqual(results[0].hasPinnedObservation, true);
|
||||
assert.strictEqual(results[0].relationCount, 3);
|
||||
assert.strictEqual(results[1].entity.name, 'Unpinned concept');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('handles malformed JSON rows and all session state counters', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-edge-db-'));
|
||||
const dbPath = path.join(tempDir, 'ecc2.db');
|
||||
|
||||
try {
|
||||
await writeSampleEcc2Database(dbPath);
|
||||
await mutateSqlDatabase(dbPath, db => {
|
||||
const insertSession = db.prepare(`
|
||||
INSERT INTO sessions (
|
||||
id, task, project, task_group, agent_type, harness, detected_harnesses_json,
|
||||
working_dir, state, pid, worktree_path, worktree_branch, worktree_base,
|
||||
input_tokens, output_tokens, tokens_used, tool_calls, files_changed,
|
||||
duration_secs, cost_usd, created_at, updated_at, last_heartbeat_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const state of ['pending', 'failed', 'stopped', 'completed']) {
|
||||
insertSession.run([
|
||||
`session-${state}`,
|
||||
`Exercise ${state}`,
|
||||
'ECC',
|
||||
'coverage',
|
||||
'codex',
|
||||
'',
|
||||
state === 'failed' ? '{bad json' : '[]',
|
||||
'',
|
||||
state,
|
||||
state === 'pending' ? 'not-a-pid' : null,
|
||||
state === 'completed' ? '/tmp/worktree' : null,
|
||||
null,
|
||||
null,
|
||||
'not-input-tokens',
|
||||
null,
|
||||
state === 'pending' ? 'not-tokens' : 10,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
state === 'failed' ? 'not-cost' : 0.1,
|
||||
'2026-06-03T11:00:00Z',
|
||||
`2026-06-03T11:0${state.length % 10}:00Z`,
|
||||
'',
|
||||
]);
|
||||
}
|
||||
insertSession.free();
|
||||
|
||||
db.run(
|
||||
`INSERT INTO context_graph_entities (
|
||||
session_id, entity_key, entity_type, name, path, summary, metadata_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
'session-failed',
|
||||
'bad:json',
|
||||
'note',
|
||||
'Malformed JSON knowledge',
|
||||
'/notes/malformed.md',
|
||||
'This record should still be searchable.',
|
||||
'{bad json',
|
||||
'2026-06-03T11:20:00Z',
|
||||
'2026-06-03T11:20:00Z',
|
||||
]
|
||||
);
|
||||
db.run(
|
||||
'INSERT INTO context_graph_observations (session_id, entity_id, observation_type, priority, pinned, summary, details_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
'session-failed',
|
||||
3,
|
||||
'',
|
||||
'not-a-priority',
|
||||
0,
|
||||
'Malformed details should fall back safely.',
|
||||
'{bad json',
|
||||
'2026-06-03T11:21:00Z',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
const snapshot = await buildControlPaneSnapshot({
|
||||
repoRoot: path.join(__dirname, '..', '..'),
|
||||
dbPath,
|
||||
query: 'Malformed',
|
||||
config: {
|
||||
memoryConnectors: {
|
||||
malformed_notes: {
|
||||
kind: 'markdown_directory',
|
||||
path: '/notes/malformed',
|
||||
recurse: false,
|
||||
defaultEntityType: 'note',
|
||||
defaultObservationType: 'operator_memory',
|
||||
includeSafeValues: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(snapshot.summary.pendingSessions, 1);
|
||||
assert.strictEqual(snapshot.summary.failedSessions, 1);
|
||||
assert.strictEqual(snapshot.summary.stoppedSessions, 1);
|
||||
assert.strictEqual(snapshot.summary.completedSessions, 1);
|
||||
assert.strictEqual(snapshot.summary.runningSessions, 1);
|
||||
assert.strictEqual(snapshot.summary.idleSessions, 1);
|
||||
assert.strictEqual(snapshot.summary.totalSessions, 6);
|
||||
|
||||
const failedSession = snapshot.sessions.find(session => session.id === 'session-failed');
|
||||
assert.deepStrictEqual(failedSession.detectedHarnesses, []);
|
||||
assert.strictEqual(failedSession.metrics.costUsd, 0);
|
||||
|
||||
const pendingSession = snapshot.sessions.find(session => session.id === 'session-pending');
|
||||
assert.strictEqual(pendingSession.pid, 0);
|
||||
assert.strictEqual(pendingSession.metrics.tokensUsed, 0);
|
||||
|
||||
assert.strictEqual(snapshot.knowledge.results[0].entity.name, 'Malformed JSON knowledge');
|
||||
assert.deepStrictEqual(snapshot.knowledge.results[0].entity.metadata, {});
|
||||
assert.deepStrictEqual(snapshot.knowledge.results[0].latestObservation.details, {});
|
||||
assert.strictEqual(snapshot.connectors[0].defaultEntityType, 'note');
|
||||
assert.strictEqual(snapshot.connectors[0].defaultObservationType, 'operator_memory');
|
||||
assert.strictEqual(snapshot.connectors[0].includeSafeValues, true);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('recall search covers metadata, relation caps, no matches, and tie ordering', async () => {
|
||||
const baseEntities = [
|
||||
{
|
||||
id: 1,
|
||||
entityType: 'note',
|
||||
name: 'First shared memory',
|
||||
path: '/notes/shared-a.md',
|
||||
summary: 'Platform context',
|
||||
metadata: { source: 'workspace' },
|
||||
updatedAt: '2026-06-03T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
entityType: 'note',
|
||||
name: 'Second shared memory',
|
||||
path: '/notes/shared-b.md',
|
||||
summary: 'Platform context',
|
||||
metadata: { source: 'workspace' },
|
||||
updatedAt: '2026-06-03T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
entityType: 'concept',
|
||||
name: 'Markets graph',
|
||||
path: null,
|
||||
summary: 'Correlation graph visualization',
|
||||
metadata: { flow: 'friction-flow' },
|
||||
updatedAt: '2026-06-03T09:00:00Z',
|
||||
},
|
||||
];
|
||||
const observations = [
|
||||
{
|
||||
entityId: 3,
|
||||
priority: 1,
|
||||
pinned: false,
|
||||
summary: 'Ito should expose market backtesting through ECC tools.',
|
||||
},
|
||||
];
|
||||
|
||||
const tied = recallKnowledgeEntries({
|
||||
entities: baseEntities,
|
||||
observations: [],
|
||||
relationCounts: new Map(),
|
||||
query: 'shared',
|
||||
limit: 50,
|
||||
});
|
||||
assert.deepStrictEqual(tied.map(entry => entry.entity.id), [2, 1]);
|
||||
|
||||
const metadataHit = recallKnowledgeEntries({
|
||||
entities: baseEntities,
|
||||
observations,
|
||||
relationCounts: new Map([[3, 20]]),
|
||||
query: 'friction-flow backtesting',
|
||||
limit: -5,
|
||||
});
|
||||
assert.strictEqual(metadataHit.length, 1);
|
||||
assert.strictEqual(metadataHit[0].entity.id, 3);
|
||||
assert.strictEqual(metadataHit[0].relationCount, 20);
|
||||
assert.ok(metadataHit[0].score >= 18);
|
||||
|
||||
const noHits = recallKnowledgeEntries({
|
||||
entities: baseEntities,
|
||||
observations,
|
||||
relationCounts: new Map(),
|
||||
query: 'unmatched',
|
||||
limit: 'wat',
|
||||
});
|
||||
assert.deepStrictEqual(noHits, []);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
423
tests/scripts/control-pane.test.js
Normal file
423
tests/scripts/control-pane.test.js
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* 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',
|
||||
'--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.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('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('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.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(), /<svg/);
|
||||
} finally {
|
||||
await realAssetApp.close();
|
||||
}
|
||||
|
||||
const missingAsset = await fetchLocal(`${app.url}/assets/ecc-icon.svg`);
|
||||
assert.strictEqual(missingAsset.status, 404);
|
||||
assert.strictEqual(await missingAsset.text(), 'not found');
|
||||
|
||||
const missing = await fetchLocal(`${app.url}/not-here`).then(response => 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();
|
||||
@@ -70,6 +70,7 @@ function main() {
|
||||
assert.match(result.stdout, /doctor/);
|
||||
assert.match(result.stdout, /auto-update/);
|
||||
assert.match(result.stdout, /consult/);
|
||||
assert.match(result.stdout, /control-pane/);
|
||||
assert.match(result.stdout, /loop-status/);
|
||||
assert.match(result.stdout, /work-items/);
|
||||
assert.match(result.stdout, /platform-audit/);
|
||||
@@ -114,6 +115,12 @@ function main() {
|
||||
assert.strictEqual(payload.schemaVersion, 'ecc.consult.v1');
|
||||
assert.strictEqual(payload.matches[0].componentId, 'capability:security');
|
||||
}],
|
||||
['supports help for the control-pane subcommand', () => {
|
||||
const result = runCli(['help', 'control-pane']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Usage:/);
|
||||
assert.match(result.stdout, /control-pane/);
|
||||
}],
|
||||
['delegates lifecycle commands', () => {
|
||||
const homeDir = createTempDir('ecc-cli-home-');
|
||||
const projectRoot = createTempDir('ecc-cli-project-');
|
||||
|
||||
@@ -46,6 +46,7 @@ function buildExpectedPublishPaths(repoRoot) {
|
||||
"scripts/ci/scan-supply-chain-iocs.js",
|
||||
"scripts/ci/supply-chain-advisory-sources.js",
|
||||
"scripts/consult.js",
|
||||
"scripts/control-pane.js",
|
||||
"scripts/claw.js",
|
||||
"scripts/discussion-audit.js",
|
||||
"scripts/doctor.js",
|
||||
@@ -132,6 +133,7 @@ function main() {
|
||||
"scripts/ci/scan-supply-chain-iocs.js",
|
||||
"scripts/ci/supply-chain-advisory-sources.js",
|
||||
"scripts/consult.js",
|
||||
"scripts/control-pane.js",
|
||||
"scripts/discussion-audit.js",
|
||||
"scripts/operator-readiness-dashboard.js",
|
||||
"scripts/preview-pack-smoke.js",
|
||||
|
||||
Reference in New Issue
Block a user