242 lines
8.0 KiB
JavaScript
242 lines
8.0 KiB
JavaScript
#!/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');
|
|
}
|