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 {
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()
})
})