discovery-by-llm/frontend/tests/useChatStream.test.ts
Justin Visser a3af8f45cc
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run
fix(frontend): harden the streaming ui and extract copy and constants
2026-08-10 15:42:18 +02:00

242 lines
7.7 KiB
TypeScript

import { createApp, defineComponent, h } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useChatStream } from '../src/composables/useChatStream'
import { streamRecommendations, StreamTransportError } from '../src/lib/recommendationStream'
import type { AssistantTurn } from '../src/lib/models'
import type { RecommendationRequest, StreamEvent, TrackEvent } from '../src/lib/types'
vi.mock('../src/lib/recommendationStream', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/lib/recommendationStream')>()
return { ...actual, streamRecommendations: vi.fn() }
})
const streamMock = vi.mocked(streamRecommendations)
const unmountCallbacks: Array<() => void> = []
function mountChat() {
let chat: ReturnType<typeof useChatStream> | undefined
const root = document.createElement('div')
document.body.append(root)
const app = createApp(
defineComponent({
setup() {
chat = useChatStream(vi.fn(async () => ({ url: null })))
return () => h('div')
},
}),
)
app.mount(root)
unmountCallbacks.push(() => {
app.unmount()
root.remove()
})
if (!chat) throw new Error('Chat composable did not mount.')
return chat
}
function metadata(requestId: string): StreamEvent {
return {
type: 'metadata',
request_id: requestId,
intent_summary: `Intent for ${requestId}`,
candidate_count: 100,
}
}
function track(rank: number): TrackEvent {
return {
type: 'track',
rank,
track: {
id: `track-${rank}`,
uri: `spotify:track:${rank}`,
title: `Track ${rank}`,
artists: ['Artist'],
album_name: 'Album',
album_art_url: null,
external_url: null,
},
justification: 'It fits.',
}
}
function assistantTurns(chat: ReturnType<typeof useChatStream>): AssistantTurn[] {
return chat.turns.value.filter((turn): turn is AssistantTurn => turn.role === 'assistant')
}
beforeEach(() => {
streamMock.mockReset()
})
afterEach(() => {
while (unmountCallbacks.length) unmountCallbacks.pop()?.()
})
describe('useChatStream', () => {
it('retains repeated warnings that share a code', async () => {
streamMock.mockImplementation(async (_request, _signal, onEvent) => {
onEvent(metadata('warnings'))
onEvent({ type: 'warning', code: 'limited_pool', message: 'First warning.' })
onEvent({ type: 'warning', code: 'limited_pool', message: 'Second warning.' })
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
})
const chat = mountChat()
await chat.send('warnings')
expect(assistantTurns(chat)[0]?.warnings).toEqual([
{ type: 'warning', code: 'limited_pool', message: 'First warning.' },
{ type: 'warning', code: 'limited_pool', message: 'Second warning.' },
])
})
it('keeps error terminal when a late done callback arrives', async () => {
streamMock.mockImplementation(async (_request, _signal, onEvent) => {
onEvent(metadata('error'))
onEvent({ type: 'error', code: 'upstream', message: 'Upstream failed.' })
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
})
const chat = mountChat()
await chat.send('error')
const turn = assistantTurns(chat)[0]
expect(turn).toMatchObject({ status: 'error', error: { code: 'upstream' } })
expect(turn?.completion).toBeNull()
expect(turn?.transportFailure).toBeNull()
})
it('stores an incomplete stream as a transport failure', async () => {
streamMock.mockRejectedValue(
new StreamTransportError('unexpected_eof', 'The stream ended early.'),
)
const chat = mountChat()
await chat.send('failure')
expect(assistantTurns(chat)[0]).toMatchObject({
status: 'error',
transportFailure: { kind: 'unexpected_eof' },
})
})
it('caps request history at 12 turns', async () => {
const requests: RecommendationRequest[] = []
streamMock.mockImplementation(async (request, _signal, onEvent) => {
requests.push(request)
onEvent(metadata(request.query))
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
})
const chat = mountChat()
for (let index = 0; index < 8; index += 1) {
await chat.send(`query ${index}`)
}
expect(requests.at(-1)?.history).toHaveLength(12)
expect(requests.at(-1)?.history[0]?.role).toBe('user')
})
it('caps prior recommendations at 50 tracks', async () => {
const requests: RecommendationRequest[] = []
streamMock.mockImplementation(async (request, _signal, onEvent) => {
requests.push(request)
onEvent(metadata(request.query))
if (requests.length === 1) {
for (let rank = 1; rank <= 60; rank += 1) onEvent(track(rank))
onEvent({ type: 'done', track_count: 60, total_ms: 5 })
} else {
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
}
})
const chat = mountChat()
await chat.send('first')
await chat.send('second')
expect(requests[1]?.prior_recommendations).toHaveLength(50)
expect(requests[1]?.prior_recommendations.at(-1)?.rank).toBe(50)
})
it('aborts a replaced stream and ignores its late callbacks and failure', async () => {
interface PendingStream {
signal: AbortSignal
onEvent: (event: StreamEvent) => void
resolve: () => void
reject: (error: unknown) => void
}
const pending: PendingStream[] = []
streamMock.mockImplementation(
(_request, signal, onEvent) =>
new Promise<void>((resolve, reject) => {
pending.push({ signal, onEvent, resolve, reject })
}),
)
const chat = mountChat()
const firstSend = chat.send('first')
const secondSend = chat.send('second')
expect(pending[0]?.signal.aborted).toBe(true)
pending[0]?.onEvent(metadata('late-first'))
pending[0]?.onEvent(track(1))
pending[0]?.reject(new DOMException('Aborted', 'AbortError'))
pending[1]?.onEvent(metadata('second'))
pending[1]?.onEvent({ type: 'done', track_count: 0, total_ms: 5 })
pending[1]?.resolve()
await Promise.all([firstSend, secondSend])
const [firstTurn, secondTurn] = assistantTurns(chat)
expect(firstTurn).toMatchObject({
status: 'error',
requestId: null,
tracks: [],
transportFailure: { kind: 'cancelled' },
})
expect(secondTurn).toMatchObject({ status: 'done', requestId: 'second' })
})
it('does not relabel a terminal turn while its reader is cleaning up', async () => {
interface PendingStream {
signal: AbortSignal
onEvent: (event: StreamEvent) => void
resolve: () => void
}
const pending: PendingStream[] = []
streamMock.mockImplementation(
(_request, signal, onEvent) =>
new Promise<void>((resolve) => {
pending.push({ signal, onEvent, resolve })
}),
)
const chat = mountChat()
const firstSend = chat.send('first')
pending[0]?.onEvent(metadata('first'))
pending[0]?.onEvent({ type: 'done', track_count: 0, total_ms: 5 })
const secondSend = chat.send('second')
expect(pending[0]?.signal.aborted).toBe(true)
pending[0]?.resolve()
pending[1]?.onEvent(metadata('second'))
pending[1]?.onEvent({ type: 'done', track_count: 0, total_ms: 5 })
pending[1]?.resolve()
await Promise.all([firstSend, secondSend])
expect(assistantTurns(chat).map((turn) => turn.status)).toEqual(['done', 'done'])
})
it('caps a programmatic query at 1000 characters', async () => {
let receivedQuery = ''
streamMock.mockImplementation(async (request, _signal, onEvent) => {
receivedQuery = request.query
onEvent(metadata('query-limit'))
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
})
const chat = mountChat()
await chat.send('x'.repeat(1200))
expect(receivedQuery).toHaveLength(1000)
})
})