diff --git a/test-hermes-session.js b/test-hermes-session.js new file mode 100644 index 0000000..7fadac3 --- /dev/null +++ b/test-hermes-session.js @@ -0,0 +1,241 @@ +#!/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'); +} diff --git a/ws-client.js b/ws-client.js index 7ae5fb0..6f80e63 100644 --- a/ws-client.js +++ b/ws-client.js @@ -1,4 +1,5 @@ const HERMES_WS_URL = 'ws://143.198.27.163/api/world/ws'; +const SESSION_STORAGE_KEY = 'hermes-session'; export class WebSocketClient { constructor(url = HERMES_WS_URL) { @@ -11,6 +12,52 @@ export class WebSocketClient { this.connected = false; this.reconnectTimeout = null; this.messageQueue = []; + this.session = null; + } + + /** + * Persist session data to localStorage so it survives page reloads. + * @param {Object} data Arbitrary session payload (token, id, etc.) + */ + saveSession(data) { + try { + localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({ ...data, savedAt: Date.now() })); + this.session = data; + console.log('[hermes] Session saved'); + } catch (err) { + console.warn('[hermes] Could not save session:', err); + } + } + + /** + * Restore session data from localStorage. + * @returns {Object|null} Previously saved session, or null if none. + */ + loadSession() { + try { + const raw = localStorage.getItem(SESSION_STORAGE_KEY); + if (!raw) return null; + const data = JSON.parse(raw); + this.session = data; + console.log('[hermes] Session loaded (savedAt:', new Date(data.savedAt).toISOString(), ')'); + return data; + } catch (err) { + console.warn('[hermes] Could not load session:', err); + return null; + } + } + + /** + * Remove any persisted session from localStorage. + */ + clearSession() { + try { + localStorage.removeItem(SESSION_STORAGE_KEY); + this.session = null; + console.log('[hermes] Session cleared'); + } catch (err) { + console.warn('[hermes] Could not clear session:', err); + } } connect() { @@ -30,6 +77,12 @@ export class WebSocketClient { console.log('[hermes] Connected to Hermes gateway'); this.connected = true; this.reconnectAttempts = 0; + // Restore session if available; send it as the first frame so the server + // can resume the previous session rather than creating a new one. + const existing = this.loadSession(); + if (existing?.token) { + this._send({ type: 'session-resume', token: existing.token }); + } this.messageQueue.forEach(msg => this._send(msg)); this.messageQueue = []; window.dispatchEvent(new CustomEvent('ws-connected', { detail: { url: this.url } })); @@ -62,6 +115,14 @@ export class WebSocketClient { _route(data) { switch (data.type) { + case 'session-init': + // Server issued a new session token — persist it for future reconnects. + if (data.token) { + this.saveSession({ token: data.token, clientId: data.clientId }); + } + window.dispatchEvent(new CustomEvent('session-init', { detail: data })); + break; + case 'chat': case 'chat-message': window.dispatchEvent(new CustomEvent('chat-message', { detail: data }));