discovery-by-llm/frontend/tests/recommendationStream.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

202 lines
6.4 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { streamRecommendations, StreamTransportError } from '../src/lib/recommendationStream'
import type { StreamEvent } from '../src/lib/types'
const encoder = new TextEncoder()
function streamResponse(
chunks: Uint8Array[],
options: { close?: boolean; cancel?: () => void } = {},
): Response {
let index = 0
const body = new ReadableStream<Uint8Array>({
pull(controller) {
const chunk = chunks[index]
if (chunk) {
controller.enqueue(chunk)
index += 1
}
if (options.close !== false && index === chunks.length) controller.close()
},
cancel() {
options.cancel?.()
},
})
return new Response(body, {
headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' },
})
}
function stubResponse(response: Response): void {
vi.stubGlobal(
'fetch',
vi.fn(async () => response),
)
}
afterEach(() => vi.unstubAllGlobals())
describe('streamRecommendations', () => {
it('decodes fragmented NDJSON across JSON and multi-byte boundaries', async () => {
const symbol = String.fromCodePoint(0x1f3b5)
const records = [
{
type: 'metadata',
request_id: 'request-1',
intent_summary: `Focused ${symbol} listening`,
candidate_count: 4,
},
{
type: 'track',
rank: 1,
track: {
id: 'track-1',
uri: 'spotify:track:1',
title: `Signal ${symbol}`,
artists: ['Artist'],
album_name: 'Album',
album_art_url: null,
external_url: 'https://open.spotify.com/track/1',
},
justification: 'It fits.',
},
{ type: 'warning', code: 'limited_pool', message: 'The pool was limited.' },
{ type: 'warning', code: 'limited_pool', message: 'The pool stayed limited.' },
{ type: 'done', track_count: 1, total_ms: 12 },
]
const payload = `${records.map((record) => JSON.stringify(record)).join('\n')}\n`
const bytes = encoder.encode(payload)
const jsonCut = encoder.encode(payload.slice(0, payload.indexOf('candidate_count') + 5)).length
const symbolIndex = payload.indexOf(symbol)
const symbolCut = encoder.encode(payload.slice(0, symbolIndex)).length + 2
const cuts = [jsonCut, symbolCut, bytes.length - 8].sort((left, right) => left - right)
const chunks = [
bytes.slice(0, cuts[0]),
bytes.slice(cuts[0], cuts[1]),
bytes.slice(cuts[1], cuts[2]),
bytes.slice(cuts[2]),
]
stubResponse(streamResponse(chunks))
const events: StreamEvent[] = []
await streamRecommendations(
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
new AbortController().signal,
(event) => events.push(event),
)
expect(events).toHaveLength(5)
expect(events[0]).toMatchObject({
type: 'metadata',
intent_summary: `Focused ${symbol} listening`,
})
expect(events[1]).toMatchObject({ type: 'track', track: { title: `Signal ${symbol}` } })
expect(events.filter((event) => event.type === 'warning')).toHaveLength(2)
expect(events.at(-1)).toMatchObject({ type: 'done', track_count: 1 })
})
it('treats an error as terminal without waiting for done', async () => {
let wasCancelled = false
const payload = [
JSON.stringify({
type: 'metadata',
request_id: 'request-1',
intent_summary: 'Intent',
candidate_count: 0,
}),
JSON.stringify({ type: 'error', code: 'upstream', message: 'Upstream failed.' }),
JSON.stringify({ type: 'done', track_count: 0, total_ms: 4 }),
].join('\n')
stubResponse(
streamResponse([encoder.encode(payload)], {
close: false,
cancel: () => {
wasCancelled = true
throw new Error('Cleanup failed.')
},
}),
)
const events: StreamEvent[] = []
await streamRecommendations(
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
new AbortController().signal,
(event) => events.push(event),
)
expect(events.map((event) => event.type)).toEqual(['metadata', 'error'])
expect(wasCancelled).toBe(true)
})
it.each([
['an event before metadata', [{ type: 'warning', code: 'early', message: 'No metadata.' }]],
[
'duplicate metadata',
[
{ type: 'metadata', request_id: 'one', intent_summary: 'First', candidate_count: 1 },
{ type: 'metadata', request_id: 'two', intent_summary: 'Second', candidate_count: 1 },
],
],
])('rejects %s and cancels the reader', async (_label, records) => {
let wasCancelled = false
const payload = `${records.map((record) => JSON.stringify(record)).join('\n')}\n`
stubResponse(
streamResponse([encoder.encode(payload)], {
close: false,
cancel: () => {
wasCancelled = true
},
}),
)
const result = streamRecommendations(
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
new AbortController().signal,
() => undefined,
)
await expect(result).rejects.toMatchObject<Partial<StreamTransportError>>({ kind: 'protocol' })
expect(wasCancelled).toBe(true)
})
it('cancels the reader after a parse failure', async () => {
let wasCancelled = false
stubResponse(
streamResponse([encoder.encode('{not-json}\n')], {
close: false,
cancel: () => {
wasCancelled = true
},
}),
)
const result = streamRecommendations(
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
new AbortController().signal,
() => undefined,
)
await expect(result).rejects.toMatchObject<Partial<StreamTransportError>>({ kind: 'parse' })
expect(wasCancelled).toBe(true)
})
it('reports a transport failure when the body ends before a terminal event', async () => {
const payload = `${JSON.stringify({
type: 'metadata',
request_id: 'request-1',
intent_summary: 'Intent',
candidate_count: 0,
})}\n`
stubResponse(streamResponse([encoder.encode(payload)]))
const result = streamRecommendations(
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
new AbortController().signal,
() => undefined,
)
await expect(result).rejects.toMatchObject<Partial<StreamTransportError>>({
kind: 'unexpected_eof',
})
})
})