// @vitest-environment happy-dom
// Exercise the real socket callbacks with the chat renderer. No server, agent,
// authentication request or live owner data is used.
import { beforeEach, afterEach, expect, it, vi } from 'vitest';
let sockets;
beforeEach(() => {
vi.resetModules();
vi.useFakeTimers();
sockets = [];
vi.stubGlobal('WebSocket', class {
constructor() { sockets.push(this); }
});
vi.stubGlobal('location', { protocol: 'http:', host: 'localhost', reload: vi.fn() });
document.body.innerHTML = '
';
localStorage.clear();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
});
function setup(connect, state, renderWaiting, renderBillionStatus) {
state.setWaitingActive(true);
const receive = vi.fn(msg => {
if (msg.type === 'billion-status') state.setBillionStatus(msg);
if (msg.type === 'chat-list') state.setChatMessages(msg.messages);
renderWaiting();
renderBillionStatus();
});
connect(receive);
return receive;
}
const emit = (socket, data) => socket.onmessage({ data: JSON.stringify(data) });
const pendingMessage = () => [{ id: 'm1', from: 'owner', text: 'Check the run', at: new Date().toISOString(), awaitsReply: true, workDetails: ['Checking evidence'] }];
const workingOn = (lines) => ({ type: 'billion-status', running: true, working: true, pending: ['m1'], currentRequest: 'm1', progress: { m1: lines } });
it('keeps a pending owner message and its progress box through a short drop, shows nothing as active, and reconnects without a reload', async () => {
const { connect } = await import('../public/modules/ws.js');
const state = await import('../public/modules/state.js');
const { renderWaiting } = await import('../public/modules/waiting.js');
const { renderBillionStatus } = await import('../public/modules/round.js');
const receive = setup(connect, state, renderWaiting, renderBillionStatus);
sockets[0].onopen();
emit(sockets[0], { type: 'welcome', bootId: 'boot-1', sessionIds: [] });
const messages = pendingMessage();
emit(sockets[0], { type: 'chat-list', messages });
emit(sockets[0], workingOn(['Checking evidence']));
expect(document.querySelector('.chat-progress.active')).not.toBeNull();
receive.mockClear();
sockets[0].onclose({ code: 1006 });
expect(receive).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ type: 'billion-status', disconnected: true, working: false }));
// The message and its box stay, with the progress last heard; nothing is
// shown as active while what Billion is doing is unknown.
const row = document.querySelector('.chat-msg.mine.pending');
expect(row?.dataset.id).toBe('m1');
expect(row.querySelector('.chat-progress.active')).toBeNull();
expect(row.querySelector('.chat-progress-head').textContent).toBe('Reconnecting… your message is kept');
expect(row.querySelector('.chat-progress li').textContent).toBe('Checking evidence');
expect(document.getElementById('billion-status').textContent).toBe('Reconnecting…');
expect(state.chatMessages).toEqual(messages);
expect(document.querySelector('.chat-work-details')).toBeNull();
vi.advanceTimersByTime(1000);
expect(sockets).toHaveLength(2);
sockets[1].onopen();
// The same server: the page stays, and the handshake's replay carries
// persisted chat before current status, with progress made meanwhile.
emit(sockets[1], { type: 'welcome', bootId: 'boot-1', sessionIds: [] });
expect(location.reload).not.toHaveBeenCalled();
emit(sockets[1], { type: 'chat-list', messages });
emit(sockets[1], workingOn(['Checking evidence', 'Reading the log']));
expect([...document.querySelectorAll('.chat-progress.active li')].map(li => li.textContent)).toEqual(['Checking evidence', 'Reading the log']);
expect(document.querySelector('.chat-work-details')).toBeNull();
});
it('the keep-alive: an unanswered ping drops a half-dead socket and reconnects in place, keeping the pending message', async () => {
const { connect, PING_MS, DEAD_MS } = await import('../public/modules/ws.js');
const state = await import('../public/modules/state.js');
const { renderWaiting } = await import('../public/modules/waiting.js');
const { renderBillionStatus } = await import('../public/modules/round.js');
setup(connect, state, renderWaiting, renderBillionStatus);
const sent = [];
sockets[0].readyState = 1;
sockets[0].send = (data) => sent.push(JSON.parse(data));
sockets[0].close = vi.fn();
sockets[0].onopen();
emit(sockets[0], { type: 'welcome', bootId: 'boot-1', sessionIds: [] });
emit(sockets[0], { type: 'chat-list', messages: pendingMessage() });
emit(sockets[0], workingOn(['Checking evidence']));
// Answered pings keep the socket.
vi.advanceTimersByTime(PING_MS);
expect(sent).toEqual([{ type: 'ping' }]);
emit(sockets[0], { type: 'pong' });
vi.advanceTimersByTime(PING_MS);
expect(sockets).toHaveLength(1);
// The link goes quiet: no pong. Within one ping past DEAD_MS it is dropped.
vi.advanceTimersByTime(DEAD_MS + PING_MS);
expect(sockets[0].close).toHaveBeenCalled();
expect(document.querySelector('.chat-msg.mine.pending .chat-progress li')?.textContent).toBe('Checking evidence');
vi.advanceTimersByTime(1000);
expect(sockets.length).toBeGreaterThanOrEqual(2);
const next = sockets.at(-1);
next.onopen();
emit(next, { type: 'welcome', bootId: 'boot-1', sessionIds: [] });
expect(location.reload).not.toHaveBeenCalled();
});
it('a restarted server (a new boot) reloads the page once', async () => {
const { connect } = await import('../public/modules/ws.js');
const state = await import('../public/modules/state.js');
const { renderWaiting } = await import('../public/modules/waiting.js');
const { renderBillionStatus } = await import('../public/modules/round.js');
setup(connect, state, renderWaiting, renderBillionStatus);
sockets[0].onopen();
emit(sockets[0], { type: 'welcome', bootId: 'boot-1', sessionIds: [] });
sockets[0].onclose({ code: 1006 });
vi.advanceTimersByTime(1000);
sockets[1].onopen();
emit(sockets[1], { type: 'welcome', bootId: 'boot-2', sessionIds: [] });
expect(location.reload).toHaveBeenCalledTimes(1);
});