fix(web): harden typed SPA chat lifecycle

Co-Authored-By: Claude Haiku 4.5 <[email protected]>
This commit is contained in:
shaggy (mosaic-dev box)
2026-08-10 00:24:20 -05:00
co-authored by Claude Haiku 4.5
parent b2e005f2b4
commit caebf9ef70
20 changed files with 1778 additions and 125 deletions
+38 -17
View File
@@ -10,30 +10,32 @@ vi.mock('socket.io-client', () => ({
import { destroySocket, getSocket } from './socket';
function createMockSocket(): {
on: ReturnType<typeof vi.fn>;
offAny: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
} {
const mockSocket = {
on: vi.fn(() => mockSocket),
offAny: vi.fn(() => mockSocket),
disconnect: vi.fn(() => mockSocket),
};
return mockSocket;
}
describe('chat socket', () => {
let disconnectHandler: (() => void) | undefined;
beforeEach(() => {
disconnectHandler = undefined;
ioMock.mockReset();
const mockSocket = {
on: vi.fn((event: string, handler: () => void) => {
if (event === 'disconnect') disconnectHandler = handler;
return mockSocket;
}),
offAny: vi.fn(() => mockSocket),
disconnect: vi.fn(() => mockSocket),
};
ioMock.mockReturnValue(mockSocket);
// A fresh object per io() call so identity assertions (same singleton vs.
// a genuinely new instance) are meaningful.
ioMock.mockImplementation(() => createMockSocket());
});
afterEach(() => {
destroySocket();
});
it('creates one same-origin /chat namespace socket until it disconnects', () => {
it('creates one same-origin /chat namespace socket', () => {
const first = getSocket();
const second = getSocket();
@@ -44,9 +46,28 @@ describe('chat socket', () => {
autoConnect: false,
transports: ['websocket', 'polling'],
});
});
disconnectHandler?.();
getSocket();
it('keeps the same singleton instance across a transient disconnect', () => {
const first = getSocket();
// socket.ts must not react to a real socket's `disconnect` event by
// nulling the singleton — it registers no such handler at all now, so
// simply calling getSocket() again after a "disconnect" must still
// return the same instance.
const second = getSocket();
expect(second).toBe(first);
expect(ioMock).toHaveBeenCalledOnce();
});
it('only creates a new singleton after an explicit destroySocket()', () => {
const first = getSocket();
destroySocket();
const second = getSocket();
expect(second).not.toBe(first);
expect(ioMock).toHaveBeenCalledTimes(2);
});
});