Newer
Older
navi-1 / webclient / tests / unit / composables / useWebSocket.test.js
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()
  })
})