#!/usr/bin/env node /** * Integration test — Hermes session save and load * * Tests the session persistence layer of WebSocketClient in isolation. * Runs with Node.js built-ins only — no browser, no real WebSocket. * * Run: node test-hermes-session.js */ import { readFileSync } from 'fs'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); let passed = 0; let failed = 0; function pass(name) { console.log(` ✓ ${name}`); passed++; } function fail(name, reason) { console.log(` ✗ ${name}`); if (reason) console.log(` → ${reason}`); failed++; } function section(name) { console.log(`\n${name}`); } // ── In-memory localStorage mock ───────────────────────────────────────────── class MockStorage { constructor() { this._store = new Map(); } getItem(key) { return this._store.has(key) ? this._store.get(key) : null; } setItem(key, value) { this._store.set(key, String(value)); } removeItem(key) { this._store.delete(key); } clear() { this._store.clear(); } } // ── Minimal WebSocketClient extracted from ws-client.js ─────────────────── // We re-implement only the session methods so the test has no browser deps. const SESSION_STORAGE_KEY = 'hermes-session'; class SessionClient { constructor(storage) { this._storage = storage; this.session = null; } saveSession(data) { const payload = { ...data, savedAt: Date.now() }; this._storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(payload)); this.session = data; } loadSession() { const raw = this._storage.getItem(SESSION_STORAGE_KEY); if (!raw) return null; const data = JSON.parse(raw); this.session = data; return data; } clearSession() { this._storage.removeItem(SESSION_STORAGE_KEY); this.session = null; } } // ── Tests ──────────────────────────────────────────────────────────────────── section('Session Save'); const store1 = new MockStorage(); const client1 = new SessionClient(store1); // saveSession persists to storage client1.saveSession({ token: 'abc-123', clientId: 'nexus-visitor' }); const raw = store1.getItem(SESSION_STORAGE_KEY); if (raw) { pass('saveSession writes to storage'); } else { fail('saveSession writes to storage', 'storage item is null after save'); } // Persisted JSON is parseable try { const parsed = JSON.parse(raw); pass('stored value is valid JSON'); if (parsed.token === 'abc-123') { pass('token field preserved'); } else { fail('token field preserved', `expected "abc-123", got "${parsed.token}"`); } if (parsed.clientId === 'nexus-visitor') { pass('clientId field preserved'); } else { fail('clientId field preserved', `expected "nexus-visitor", got "${parsed.clientId}"`); } if (typeof parsed.savedAt === 'number' && parsed.savedAt > 0) { pass('savedAt timestamp present'); } else { fail('savedAt timestamp present', `got: ${parsed.savedAt}`); } } catch (e) { fail('stored value is valid JSON', e.message); } // in-memory session property updated if (client1.session && client1.session.token === 'abc-123') { pass('this.session updated after saveSession'); } else { fail('this.session updated after saveSession', JSON.stringify(client1.session)); } // ── Session Load ───────────────────────────────────────────────────────────── section('Session Load'); const store2 = new MockStorage(); const client2 = new SessionClient(store2); // loadSession on empty storage returns null const empty = client2.loadSession(); if (empty === null) { pass('loadSession returns null when no session stored'); } else { fail('loadSession returns null when no session stored', `got: ${JSON.stringify(empty)}`); } // Seed the storage and load store2.setItem(SESSION_STORAGE_KEY, JSON.stringify({ token: 'xyz-789', clientId: 'timmy', savedAt: 1700000000000 })); const loaded = client2.loadSession(); if (loaded && loaded.token === 'xyz-789') { pass('loadSession returns stored token'); } else { fail('loadSession returns stored token', `got: ${JSON.stringify(loaded)}`); } if (loaded && loaded.clientId === 'timmy') { pass('loadSession returns stored clientId'); } else { fail('loadSession returns stored clientId', `got: ${JSON.stringify(loaded)}`); } if (client2.session && client2.session.token === 'xyz-789') { pass('this.session updated after loadSession'); } else { fail('this.session updated after loadSession', JSON.stringify(client2.session)); } // ── Full save → reload cycle ───────────────────────────────────────────────── section('Save → Load Round-trip'); const store3 = new MockStorage(); const writer = new SessionClient(store3); const reader = new SessionClient(store3); // simulates a page reload (new instance, same storage) writer.saveSession({ token: 'round-trip-token', role: 'visitor' }); const reloaded = reader.loadSession(); if (reloaded && reloaded.token === 'round-trip-token') { pass('round-trip: token survives save → load'); } else { fail('round-trip: token survives save → load', JSON.stringify(reloaded)); } if (reloaded && reloaded.role === 'visitor') { pass('round-trip: extra fields survive save → load'); } else { fail('round-trip: extra fields survive save → load', JSON.stringify(reloaded)); } // ── clearSession ───────────────────────────────────────────────────────────── section('Session Clear'); const store4 = new MockStorage(); const client4 = new SessionClient(store4); client4.saveSession({ token: 'to-be-cleared' }); client4.clearSession(); const afterClear = client4.loadSession(); if (afterClear === null) { pass('clearSession removes stored session'); } else { fail('clearSession removes stored session', `still got: ${JSON.stringify(afterClear)}`); } if (client4.session === null) { pass('this.session is null after clearSession'); } else { fail('this.session is null after clearSession', JSON.stringify(client4.session)); } // ── ws-client.js static check ──────────────────────────────────────────────── section('ws-client.js Session Methods (static analysis)'); const wsClientSrc = (() => { try { return readFileSync(resolve(__dirname, 'ws-client.js'), 'utf8'); } catch (e) { fail('ws-client.js readable', e.message); return ''; } })(); if (wsClientSrc) { const checks = [ ['saveSession method defined', /saveSession\s*\(/], ['loadSession method defined', /loadSession\s*\(/], ['clearSession method defined', /clearSession\s*\(/], ['SESSION_STORAGE_KEY constant', /SESSION_STORAGE_KEY/], ['session-init message handled', /'session-init'/], ['session-resume sent on open', /session-resume/], ['this.session property set', /this\.session\s*=/], ]; for (const [name, re] of checks) { if (re.test(wsClientSrc)) { pass(name); } else { fail(name, `pattern not found: ${re}`); } } } // ── Summary ────────────────────────────────────────────────────────────────── console.log(`\n${'─'.repeat(50)}`); console.log(`Results: ${passed} passed, ${failed} failed`); if (failed > 0) { console.log('\nSome tests failed. Fix the issues above before committing.\n'); process.exit(1); } else { console.log('\nAll session tests passed.\n'); }