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',
|
||||
})
|
||||
})
|
||||
})
|
||||
79
frontend/tests/spotifyUrl.test.ts
Normal file
79
frontend/tests/spotifyUrl.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
242
frontend/tests/useChatStream.test.ts
Normal file
242
frontend/tests/useChatStream.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue