diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/test-agents-reload.js | 129 |
1 files changed, 129 insertions, 0 deletions
diff --git a/scripts/test-agents-reload.js b/scripts/test-agents-reload.js new file mode 100644 index 0000000..2441cf9 --- /dev/null +++ b/scripts/test-agents-reload.js @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/** + * scripts/test-agents-reload.js — regression test for agent accumulation on page reload. + * + * Bug history (two layers): + * 1. spawnAgent() minted `agent-<ts>-<rand>-<role>` on every boot, and + * _preSpawnDefaultAgents() deduped against in-RAM _agents — which is empty after a + * reload. Meanwhile _reanimateFromVFS() only restored dirs that had sleep.json. + * → +3 dirs per reload, forever. + * 2. VirtualFileSystem.loadFromStorage() is ASYNC. init() called _reanimateFromVFS() + * synchronously, so it ran against an empty root, silently did nothing, and 2 s later + * pre-spawn wrote brand-new dirs over the persisted tree. + * + * Fix: stable `agent-<role>` ids + `_bootAgents()` (await VFS hydration → reconcile → + * spawn only what is missing). + * + * A "reload" = a fresh OrchestratorModule instance sharing the same persisted VFS. + * Run: node scripts/test-agents-reload.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 mkTerminal = (vfs) => ({ fs: vfs, print: () => {}, moduleManager: { modules: {}, _setInterval: () => null, _clearInterval: () => {} } }); +const agentDirs = (vfs) => (vfs.readDir('/agents/') || []).filter(e => e.type === 'dir' && e.name.startsWith('agent-')); +const boot = async (vfs) => { + const orch = new OrchestratorModule(); + orch.terminal = mkTerminal(vfs); + orch._BOOT_DELAY = 0; + await orch._bootAgents(); // the real init() path + return orch; +}; + +(async () => { + const vfs = new VirtualFileSystem(); + + // ── 1. five consecutive "reloads" ─────────────────────────────────── + for (let n = 1; n <= 5; n++) { + const orch = await boot(vfs); + const dirs = agentDirs(vfs); + check(`boot ${n}: 3 agent dirs`, dirs.length === 3, `${dirs.length}: ${dirs.map(d => d.name).join(', ')}`); + check(`boot ${n}: 3 live agents`, Object.keys(orch._agents).length === 3, `${Object.keys(orch._agents).length}`); + check(`boot ${n}: one per role`, new Set(Object.values(orch._agents).map(a => a.role)).size === 3); + } + check('stable ids after reloads', agentDirs(vfs).every(d => d.name === 'agent-auto-learner' || d.name === 'agent-kernel-architect' || d.name === 'agent-searcher'), + agentDirs(vfs).map(d => d.name).join(', ')); + + // ── 2. pre-existing timestamped garbage is swept ──────────────────── + const legacy = [ + ['agent-aaa111-x1-auto-learner', 'auto-learner'], + ['agent-zzz999-x2-auto-learner', 'auto-learner'], + ['agent-mmm555-x3-searcher', 'searcher'], + ]; + for (const [id, role] of legacy) { + vfs.mkdir(`agents/${id}`); + vfs.writeFile(`agents/${id}/status.json`, JSON.stringify({ id, role, status: 'idle', restartPolicy: 'always' })); + } + check('legacy dirs staged', agentDirs(vfs).length === 6, `${agentDirs(vfs).length} dirs`); + const orch2 = await boot(vfs); + check('legacy duplicates purged', agentDirs(vfs).length === 3, `${agentDirs(vfs).length} dirs`); + check('no duplicate roles live', new Set(Object.values(orch2._agents).map(a => a.role)).size === Object.keys(orch2._agents).length); + + // ── 3. non-default (worker) sleeping agents are NOT collapsed ─────── + for (const w of ['agent-w1aaaaaaaa-aaaa-worker', 'agent-w2bbbbbbbb-bbbb-worker']) { + vfs.mkdir(`agents/${w}`); + vfs.writeFile(`agents/${w}/sleep.json`, JSON.stringify({ id: w, role: 'worker', status: 'sleeping', capabilities: ['read_file'] })); + } + const orch3 = await boot(vfs); + check('both sleeping workers reanimated', Object.values(orch3._agents).filter(a => a.role === 'worker').length === 2, + `${Object.values(orch3._agents).filter(a => a.role === 'worker').length} workers`); + check('no sleep.json left behind', !vfs.readFile('/agents/agent-w1aaaaaaaa-aaaa-worker/sleep.json')); + + // ── 4. /agents/register (registry dir) is never GC'd ──────────────── + vfs.mkdir('agents/register'); + await boot(vfs); + check('registry dir /agents/register/ survives GC', (vfs.readDir('/agents/') || []).some(e => e.name === 'register')); + + // ── 5. async hydration race (the layer-2 bug) ─────────────────────── + // Persist the current tree, then "reload" into a NEW VFS whose localStorage hydration is + // asynchronous — exactly what a browser does. A synchronous reconcile must not see it. + const before = agentDirs(vfs).map(d => d.name).sort(); + // persist exactly the way the app does (gzip+base64 via VirtualFileSystem._compress) + localStorage.setItem('webx11_filesystem_v2', await vfs._compress(JSON.stringify({ root: vfs.root, currentPath: [] }))); + const vfs2 = new VirtualFileSystem(); // hydration starts, not finished yet + const orchReload = new OrchestratorModule(); + orchReload.terminal = mkTerminal(vfs2); + orchReload._BOOT_DELAY = 0; + orchReload._reanimateFromVFS(); // old (buggy) ordering + check('sync reconcile before hydration sees an empty VFS', Object.keys(orchReload._agents).length === 0, + `${Object.keys(orchReload._agents).length} agents`); + await orchReload._bootAgents(); // fixed ordering + const after = agentDirs(vfs2).map(d => d.name).sort(); + check('post-hydration boot restores exactly the persisted set', JSON.stringify(after) === JSON.stringify(before), + `before=${before.join(',')} after=${after.join(',')}`); + + console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`); + process.exit(failures === 0 ? 0 : 1); +})(); |