diff --git a/web/src/composables/useAcpStream.test.ts b/web/src/composables/useAcpStream.test.ts new file mode 100644 index 0000000..03b28bb --- /dev/null +++ b/web/src/composables/useAcpStream.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => ({ token: 'jwt-token' }), +})) + +import { useAcpStream } from './useAcpStream' + +class MockWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + static instances: MockWebSocket[] = [] + static last(): MockWebSocket { + return this.instances[this.instances.length - 1] + } + + url: string + readyState = 0 + onopen: ((ev: unknown) => void) | null = null + onmessage: ((ev: { data: string }) => void) | null = null + onerror: ((ev: unknown) => void) | null = null + onclose: ((ev: unknown) => void) | null = null + sent: string[] = [] + + constructor(url: string) { + this.url = url + MockWebSocket.instances.push(this) + queueMicrotask(() => { + this.readyState = 1 + this.onopen?.({}) + }) + } + + send(data: string) { + this.sent.push(data) + } + + close() { + this.readyState = 3 + this.onclose?.({}) + } + + emit(data: unknown) { + this.onmessage?.({ data: JSON.stringify(data) }) + } +} + +beforeEach(() => { + MockWebSocket.instances = [] + vi.stubGlobal('WebSocket', MockWebSocket) + vi.stubGlobal('crypto', { randomUUID: () => 'fake-uuid' }) + vi.stubGlobal('window', { location: { protocol: 'http:', host: 'test.local' } }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('useAcpStream', () => { + it('connects with ?since=0 on mount', async () => { + const stream = useAcpStream('sess-1') + await new Promise((r) => setTimeout(r, 10)) + const ws = MockWebSocket.last() + expect(ws.url).toContain('/sessions/sess-1/ws') + expect(ws.url).toContain('since=0') + expect(ws.url).toContain('token=jwt-token') + expect(stream.status.value).toBe('streaming') + stream.close() + }) + + it('updates sessionStatus from state event', async () => { + const stream = useAcpStream('s1') + await new Promise((r) => setTimeout(r, 10)) + MockWebSocket.last().emit({ kind: 'state', status: 'running' }) + expect(stream.sessionStatus.value).toBe('running') + stream.close() + }) + + it('accumulates events and tracks lastEventID', async () => { + const stream = useAcpStream('s2') + await new Promise((r) => setTimeout(r, 10)) + MockWebSocket.last().emit({ + id: 1, + direction: 'out', + kind: 'notification', + method: 'session/update', + payload: {}, + }) + MockWebSocket.last().emit({ + id: 2, + direction: 'out', + kind: 'notification', + method: 'session/update', + payload: {}, + }) + expect(stream.events.value).toHaveLength(2) + stream.close() + }) + + it('reconnects with ?since= after close', async () => { + const stream = useAcpStream('s3') + await new Promise((r) => setTimeout(r, 10)) + MockWebSocket.last().emit({ id: 5, direction: 'out', kind: 'notification' }) + + MockWebSocket.last().close() + await new Promise((r) => setTimeout(r, 1100)) + const ws2 = MockWebSocket.last() + expect(ws2.url).toContain('since=5') + stream.close() + }) + + it('sends session/prompt with client- ID', async () => { + const stream = useAcpStream('s4') + await new Promise((r) => setTimeout(r, 10)) + stream.prompt('hello', 'agent-sid') + const sent = MockWebSocket.last().sent + expect(sent).toHaveLength(1) + const parsed = JSON.parse(sent[0]) as { + method: string + id: string + params: { sessionId: string; prompt: { text: string }[] } + } + expect(parsed.method).toBe('session/prompt') + expect(parsed.id).toMatch(/^client-/) + expect(parsed.params.sessionId).toBe('agent-sid') + expect(parsed.params.prompt[0].text).toBe('hello') + stream.close() + }) + + it('manual close prevents reconnect', async () => { + const stream = useAcpStream('s5') + await new Promise((r) => setTimeout(r, 10)) + stream.close() + expect(stream.status.value).toBe('closed') + await new Promise((r) => setTimeout(r, 1500)) + expect(MockWebSocket.instances).toHaveLength(1) + }) +})