summaryrefslogtreecommitdiff
path: root/scripts/test-agents-lifecycle.js
blob: 4f011a1ca4d16edf7c218cc8aadda918b6db71ca (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#!/usr/bin/env node
/**
 * scripts/test-agents-lifecycle.js — regression test for the /agents/ VFS leak.
 *
 * Bug history: killAgent() / AgentRegistry.unregisterAgent() called
 * fs.deleteFile('agents/<id>') on a DIRECTORY. VirtualFileSystem.deleteFile()
 * is file-only (returns false for dirs), so the call silently no-op'd and
 * /agents/<id>/ (status.json, inbox.json, outbox.json) leaked forever.
 * Fixed by using fs.rm() (dispatches file|dir → trash) + bounded .trash.
 *
 * Loads the PRODUCTION artifact (modules/core/core-bundle.js), not the sources,
 * because main.html serves the bundle — a source-only fix is invisible in prod.
 *
 * Run: node scripts/test-agents-lifecycle.js     (exit 0 = pass)
 */
const fs = require('fs');
const path = require('path');

const CORE = path.join(__dirname, '..', 'modules', 'core');

// ── browser shims ─────────────────────────────────────────────────────
const store = new Map();
global.localStorage = {
  getItem: k => (store.has(k) ? store.get(k) : null),
  setItem: (k, v) => store.set(k, String(v)),
  removeItem: k => store.delete(k),
  clear: () => store.clear(),
};
global.indexedDB = undefined;
global.fetch = async () => ({ ok: false, json: async () => ({}) });
const el = { style: {}, classList: { add() {}, remove() {} }, addEventListener() {}, appendChild() {}, textContent: '', innerHTML: '' };
global.document = {
  readyState: 'complete', addEventListener() {}, getElementById: () => null,
  querySelector: () => null, querySelectorAll: () => [], createElement: () => ({ ...el }),
  body: { ...el, appendChild() {} }, head: { ...el, appendChild() {} },
};
global.window = global;
global.navigator = { userAgent: 'node' };
global.performance = { now: () => Date.now() };
global.WebSocket = class { constructor() {} close() {} send() {} };

const src = fs.readFileSync(path.join(CORE, 'core-bundle.js'), 'utf8');
const { OrchestratorModule, VirtualFileSystem } = new Function('window', src +
  '\n;return { OrchestratorModule: (typeof OrchestratorModule!=="undefined"?OrchestratorModule:null), VirtualFileSystem: (typeof VirtualFileSystem!=="undefined"?VirtualFileSystem:window.VirtualFileSystem) };'
)(global.window);

let failures = 0;
const check = (label, cond, detail = '') => {
  console.log(`${cond ? '✅ PASS' : '❌ FAIL'} ${label}${detail ? ' — ' + detail : ''}`);
  if (!cond) failures++;
};

const log = [];
const vfs = new VirtualFileSystem();
const terminal = { fs: vfs, print: m => log.push(String(m)), moduleManager: { modules: {}, _setInterval: () => null, _clearInterval: () => {} } };
const orch = new OrchestratorModule();
orch.terminal = terminal;
const dirs = () => (vfs.readDir('/agents/') || []).filter(e => e.type === 'dir').map(e => e.name);

// ── 1. spawn/kill cycle leaves nothing behind ─────────────────────────
const ids = [];
for (let i = 0; i < 5; i++) ids.push(orch.spawnAgent('worker', ['read_file']).id);
check('spawn creates 5 agent dirs', dirs().length === 5, `/agents/=${dirs().length}`);
ids.forEach(id => orch.killAgent(id));
check('kill frees all in-memory agents', Object.keys(orch._agents).length === 0);
check('kill removes all /agents/ dirs (the leak)', dirs().length === 0, dirs().length ? 'LEAKED: ' + dirs().join(',') : 'clean');
check('no VFS cleanup warnings', !log.some(l => l.includes('VFS cleanup failed')));

// ── 2. registry unregister path (second call site) ───────────────────
const AgentRegistryModule = new Function('window', fs.readFileSync(path.join(CORE, 'module-agent-registry.js'), 'utf8') +
  '\n;return AgentRegistryModule;')(global.window);
const registry = Object.create(AgentRegistryModule.prototype);
registry.terminal = terminal;
registry.a2aUnregister = () => {};
const a2 = orch.spawnAgent('searcher', ['read_file']);
registry.unregisterAgent(a2.id);
check('registry.unregisterAgent removes the dir', !dirs().includes(a2.id));

// ── 3. .trash stays bounded ──────────────────────────────────────────
orch._SPAWN_LIMIT = 200;
for (let i = 0; i < 60; i++) { const a = orch.spawnAgent('w', ['read_file']); if (a) orch.killAgent(a.id); }
const trash = vfs.readDir('/.trash/') || [];
check('/agents/ still empty after 60 cycles', dirs().length === 0, `${dirs().length} dirs`);
check('.trash capped at 50 entries', trash.length <= 50, `${trash.length} entries`);

console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`);
process.exit(failures === 0 ? 0 : 1);