import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useWebSocket } from '@/composables/useWebSocket.js'
import { useChatStore } from '@/stores/chat.js'
const toastMock = vi.hoisted(() => ({
show: vi.fn(), info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn(),
}))
vi.mock('gnexus-ui-kit/vue', () => ({ useToast: () => toastMock }))
class MockWebSocket extends EventTarget {
// The composable compares readyState against WebSocket.OPEN/CLOSED and the
// watchdog against WebSocket.CONNECTING. Stubbing the global without these
// makes every constant undefined, so any readyState matches any constant.
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
constructor(url) {
super()
this.url = url
this.readyState = WebSocket.CONNECTING
this.sent = []
MockWebSocket.last = this
}
send(data) { this.sent.push(data) }
close() {
this.readyState = WebSocket.CLOSED
this.dispatchEvent(new Event('close'))
}
_open() {
this.readyState = WebSocket.OPEN
this.dispatchEvent(new Event('open'))
}
_msg(data) {
this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(data) }))
}
}
// Stub global WebSocket before each test
beforeEach(() => {
vi.stubGlobal('WebSocket', MockWebSocket)
// happy-dom does not provide localStorage — getWsUrl reads it for the API token
vi.stubGlobal('localStorage', {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
})
setActivePinia(createPinia())
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('useWebSocket', () => {
it('connect opens socket', () => {
const ws = useWebSocket()
ws.connect('s1')
expect(MockWebSocket.last).toBeTruthy()
expect(MockWebSocket.last.url).toContain('/ws/sessions/s1')
})
it('connected flag after open', () => {
const ws = useWebSocket()
ws.connect('s1')
MockWebSocket.last._open()
expect(ws.connected.value).toBe(true)
expect(ws.reconnecting.value).toBe(false)
})
it('send delivers JSON when open', () => {
const ws = useWebSocket()
ws.connect('s1')
MockWebSocket.last._open()
ws.send({ type: 'message', content: 'hi' })
expect(MockWebSocket.last.sent).toHaveLength(1)
expect(JSON.parse(MockWebSocket.last.sent[0])).toEqual({ type: 'message', content: 'hi' })
})
it('disconnect sets destroyed and clears state', () => {
const ws = useWebSocket()
ws.connect('s1')
MockWebSocket.last._open()
ws.disconnect()
expect(ws.connected.value).toBe(false)
})
it('dispatches stream_start to chat store', () => {
const ws = useWebSocket()
ws.connect('s1')
MockWebSocket.last._open()
MockWebSocket.last._msg({ type: 'stream_start' })
// No crash = dispatched; deeper assertions need chat store spy
expect(ws.connected.value).toBe(true)
})
it('dispatches session_sync', () => {
const ws = useWebSocket()
ws.connect('s1')
MockWebSocket.last._open()
MockWebSocket.last._msg({ type: 'session_sync' })
expect(ws.connected.value).toBe(true)
})
it('dispatches task_update and message_queued to the chat store', () => {
const ws = useWebSocket()
const chat = useChatStore()
ws.connect('s1')
MockWebSocket.last._open()
MockWebSocket.last._msg({ type: 'task_update', task_id: 'bt-7', tool: 'peer', status: 'running' })
MockWebSocket.last._msg({ type: 'message_queued', position: 1, queue_len: 1, max: 5 })
expect(chat.backgroundTasks['bt-7'].tool).toBe('peer')
expect(chat.queuedMessages.count).toBe(1)
})
it('dispatches terminal_opened to the chat store', () => {
const ws = useWebSocket()
const chat = useChatStore()
ws.connect('s1')
MockWebSocket.last._open()
MockWebSocket.last._msg({ type: 'terminal_opened', terminal_name: 'ops', description: '', pid: 3, background: true })
expect(chat.terminals['ops']).toMatchObject({ name: 'ops', closed: false })
})
it('reconnect schedules on close', () => {
vi.useFakeTimers()
const ws = useWebSocket()
ws.connect('s1')
MockWebSocket.last._open()
MockWebSocket.last.close()
expect(ws.reconnecting.value).toBe(true)
vi.advanceTimersByTime(3000)
// Should have created a new MockWebSocket instance
expect(MockWebSocket.last.readyState).toBe(WebSocket.CONNECTING)
vi.useRealTimers()
})
})
describe('useWebSocket — task_update toasts', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('toasts start for a new task and finish on terminal status', () => {
const ws = useWebSocket()
const chat = useChatStore()
chat.currentId = 's1'
ws.connect('s1')
MockWebSocket.last._open()
MockWebSocket.last._msg({ type: 'task_update', task_id: 'bt-9', tool: 'terminal', status: 'running', session_id: 's1' })
expect(toastMock.info).toHaveBeenCalledTimes(1)
expect(toastMock.info.mock.calls[0][0]).toMatchObject({ title: 'Background task started' })
// repeated running publishes must not re-toast
MockWebSocket.last._msg({ type: 'task_update', task_id: 'bt-9', tool: 'terminal', status: 'running', session_id: 's1' })
expect(toastMock.info).toHaveBeenCalledTimes(1)
MockWebSocket.last._msg({ type: 'task_update', task_id: 'bt-9', tool: 'terminal', status: 'completed', result_preview: '', session_id: 's1' })
expect(toastMock.success).toHaveBeenCalledTimes(1)
})
it('does not toast a late running update for a finished task', () => {
const ws = useWebSocket()
const chat = useChatStore()
chat.currentId = 's1'
ws.connect('s1')
MockWebSocket.last._open()
MockWebSocket.last._msg({ type: 'task_update', task_id: 'bt-8', tool: 'peer', status: 'running', session_id: 's1' })
toastMock.info.mockClear()
MockWebSocket.last._msg({ type: 'task_update', task_id: 'bt-8', tool: 'peer', status: 'completed', session_id: 's1' })
expect(toastMock.success).toHaveBeenCalledTimes(1)
toastMock.success.mockClear()
MockWebSocket.last._msg({ type: 'task_update', task_id: 'bt-8', tool: 'peer', status: 'running', session_id: 's1' })
expect(toastMock.info).not.toHaveBeenCalled()
})
it('skips toasts for other sessions', () => {
const ws = useWebSocket()
const chat = useChatStore()
chat.currentId = 's1'
ws.connect('s1')
MockWebSocket.last._open()
MockWebSocket.last._msg({ type: 'task_update', task_id: 'bt-o', tool: 'terminal', status: 'running', session_id: 's-other' })
expect(toastMock.info).not.toHaveBeenCalled()
})
})
describe('useWebSocket — liveness and recovery', () => {
it('pings the server while the connection sits idle', () => {
vi.useFakeTimers()
const ws = useWebSocket()
ws.connect('s1')
const sock = MockWebSocket.last
sock._open()
vi.advanceTimersByTime(20000)
expect(sock.sent.map(d => JSON.parse(d).type)).toContain('ping')
ws.disconnect()
vi.useRealTimers()
})
it('answers to a server heartbeat prove the socket is alive', () => {
vi.useFakeTimers()
const ws = useWebSocket()
ws.connect('s1')
const sock = MockWebSocket.last
sock._open()
// Heartbeats arrive every 20 s — nothing should be considered stale.
for (let i = 0; i < 4; i++) {
vi.advanceTimersByTime(20000)
sock._msg({ type: 'heartbeat' })
}
expect(ws.connected.value).toBe(true)
expect(MockWebSocket.last).toBe(sock)
ws.disconnect()
vi.useRealTimers()
})
it('queues a message instead of writing to a silent socket', () => {
vi.useFakeTimers()
const ws = useWebSocket()
ws.connect('s1')
const first = MockWebSocket.last
first._open()
// No frames for two heartbeat windows: half-open, writes would vanish.
vi.advanceTimersByTime(46000)
expect(ws.send({ type: 'message', content: 'hi' })).toBe(false)
expect(first.sent.some(d => d.includes('"message"'))).toBe(false)
expect(MockWebSocket.last).not.toBe(first)
ws.disconnect()
vi.useRealTimers()
})
it('drops the stream that died with the socket and syncs history', () => {
vi.useFakeTimers()
const chat = useChatStore()
const ws = useWebSocket()
ws.connect('s1')
const first = MockWebSocket.last
first._open()
chat.onStreamStart()
chat.onStreamDelta('half an answer')
expect(chat.streaming).toBe(true)
first.close()
vi.advanceTimersByTime(1000)
MockWebSocket.last._open()
expect(chat.streaming).toBe(false)
expect(chat.messages).toHaveLength(0)
// The run finished while we were away — session_sync must get through now.
const reload = vi.spyOn(chat, 'reloadSession').mockResolvedValue()
MockWebSocket.last._msg({ type: 'session_sync' })
expect(reload).toHaveBeenCalledWith('s1')
ws.disconnect()
vi.useRealTimers()
})
it('reconnects immediately when the network comes back', () => {
const ws = useWebSocket()
ws.connect('s1')
const first = MockWebSocket.last
first._open()
window.dispatchEvent(new Event('online'))
expect(MockWebSocket.last).not.toBe(first)
ws.disconnect()
})
})