fix(frontend): harden the streaming ui and extract copy and constants
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run

This commit is contained in:
Justin Visser 2026-08-10 15:42:18 +02:00
parent 036ef3cc6f
commit a3af8f45cc
36 changed files with 1662 additions and 226 deletions

View 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',
})
})
})

View file

@ -0,0 +1,79 @@
import { createApp, h } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import PlaylistSaved from '../src/components/PlaylistSaved.vue'
import { useApi } from '../src/composables/useApi'
import { parseSpotifyUrl } from '../src/lib/spotifyUrl'
import { parseStreamEvent } from '../src/lib/streamParser'
afterEach(() => vi.unstubAllGlobals())
describe('parseSpotifyUrl', () => {
it.each([
['https://open.spotify.com/track/123', 'https://open.spotify.com/track/123'],
[
'https://open.spotify.com/playlist/123?si=abc',
'https://open.spotify.com/playlist/123?si=abc',
],
])('accepts %s', (value, expected) => {
expect(parseSpotifyUrl(value)).toBe(expected)
})
it.each([
'http://open.spotify.com/track/123',
'https://embed.spotify.com/track/123',
'https://open.spotify.com:8443/track/123',
'https://open.spotify.com.evil.example/track/123',
'https://user@open.spotify.com/track/123',
'javascript:alert(1)',
'/track/123',
'not a url',
])('rejects %s', (value) => {
expect(parseSpotifyUrl(value)).toBeNull()
})
it('removes an unsafe track link at the event parsing boundary', () => {
const event = parseStreamEvent({
type: 'track',
rank: 1,
track: {
id: 'track-1',
uri: 'spotify:track:1',
title: 'Track',
artists: ['Artist'],
album_name: 'Album',
album_art_url: null,
external_url: 'https://evil.example/track/1',
},
justification: 'It fits.',
})
expect(event).toMatchObject({ type: 'track', track: { external_url: null } })
})
it('keeps playlist confirmation but removes an unsafe API link', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(JSON.stringify({ url: 'https://evil.example/playlist/1' }), {
headers: { 'Content-Type': 'application/json' },
}),
),
)
const { createPlaylist } = useApi()
const result = await createPlaylist({
schema_version: 1,
name: 'Playlist',
track_uris: ['spotify:track:1'],
})
const root = document.createElement('div')
const app = createApp({
render: () => h(PlaylistSaved, { name: 'Playlist', url: result.url, trackCount: 1 }),
})
app.mount(root)
expect(root.textContent).toContain('Saved Playlist')
expect(root.querySelector('a')).toBeNull()
app.unmount()
})
})

View file

@ -0,0 +1,242 @@
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)
})
})