test(web): useAcpStream composable (connect/state/reconnect/prompt/close)

This commit is contained in:
2026-05-07 18:19:19 +08:00
parent ce53c54fde
commit 508b73c381
+141
View File
@@ -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=<last> 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-<uuid> 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)
})
})