diff --git a/TODO.md b/TODO.md index 572d83a..bcca353 100644 --- a/TODO.md +++ b/TODO.md @@ -96,6 +96,17 @@ - Fix: reorder must also update turnOrderIds to match new participant order (within same-initiative tie). +### BUG-7: reorderParticipants has no undo +- Test: `shared/tests/turn.undo.test.js` 'reorderParticipants has no undo' (GREEN doc). +- `reorderParticipants` returns `log: null`. Other ops return `log.undo`. +- Cannot undo drag-drop. Candidate for undo system (M6). + +### BUG-8: ws adapter has no reconnect +- Test: `server/tests/ws-reconnect.test.js` (RED). +- WS dies (idle/error/close) → `wsReady=null`, subscribers dead forever. +- Display frozen until full reload. +- Fix: `onclose` → reconnect + re-subscribe existing paths. + ## Pipeline - [ ] Red test: dead participant still in turnOrderIds, turn still advances to them - [ ] Fix `shared/turn.js`: don't drop dead from turn order diff --git a/server/tests/ws-reconnect.test.js b/server/tests/ws-reconnect.test.js new file mode 100644 index 0000000..db49669 --- /dev/null +++ b/server/tests/ws-reconnect.test.js @@ -0,0 +1,58 @@ +// BUG-8: ws adapter has NO reconnect. WS dies (idle/error/close) → wsReady=null, +// subscribers dead forever, no re-subscribe. Display frozen until full reload. +// Test: subscribe, write (cb fires), force-drop WS, write again (must still fire). +// RED on current. + +'use strict'; + +const path = require('path'); +const os = require('os'); +const { createServer } = require('../index'); +const { createWsStorage } = require('../../src/storage/ws'); + +const flush = (ms = 150) => new Promise(r => setTimeout(r, ms)); + +let nextPort = 5000 + Math.floor(Math.random() * 999); + +async function makeStorage() { + const port = nextPort++; + const dbPath = path.join(os.tmpdir(), `ws-recon-${port}-${Date.now()}.sqlite`); + const handle = createServer({ dbPath, port }); + await new Promise((resolve, reject) => { + handle.server.on('error', reject); + handle.server.listen(port, resolve); + }); + const baseUrl = `http://127.0.0.1:${port}`; + const wsUrl = `ws://127.0.0.1:${port}/ws`; + const storage = createWsStorage({ baseUrl, wsUrl }); + storage.dispose = (done) => handle.close(done); + return storage; +} + +describe('BUG-8: ws adapter reconnect after drop', () => { + test('subscribe fires cb after WS dropped + restored', async () => { + const storage = await makeStorage(); + try { + await storage.setDoc('campaigns/a', { name: 'V1' }); + const calls = []; + storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc)); + await flush(); + expect(calls.length).toBeGreaterThanOrEqual(1); + + // force-drop WS (simulates idle timeout / network blip) + storage._test.forceDrop(); + await flush(300); + // wsReady should be null now + expect(storage._test.getReady()).toBeNull(); + + // write again — subscriber must re-fire after reconnect + await storage.setDoc('campaigns/a', { name: 'V2' }); + await flush(1000); + + const last = calls[calls.length - 1]; + expect(last).toEqual({ name: 'V2' }); + } finally { + await new Promise(r => storage.dispose(r)); + } + }, 15000); +}); diff --git a/src/storage/ws.js b/src/storage/ws.js index 712e741..ba00423 100644 --- a/src/storage/ws.js +++ b/src/storage/ws.js @@ -160,6 +160,12 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { dispose() { if (ws) ws.close(); docSubs.clear(); collSubs.clear(); }, _api: api, + _test: { + getWs: () => ws, + forceDrop: () => { if (ws) ws.close(); }, + getReady: () => wsReady, + docSubs, collSubs, + }, }; return storage;