fix(frontend): harden the streaming ui and extract copy and constants
This commit is contained in:
parent
036ef3cc6f
commit
a3af8f45cc
36 changed files with 1662 additions and 226 deletions
202
frontend/tests/recommendationStream.test.ts
Normal file
202
frontend/tests/recommendationStream.test.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
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',
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue