feat: API client and hooks for auth, sessions, chat SSE, artifacts

This commit is contained in:
Ken
2026-05-26 19:13:46 +00:00
parent 5069676914
commit c24fd035eb
14 changed files with 2267 additions and 3 deletions
+1214 -1
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@copilotkit/react-core": "^1.58.0",
@@ -20,16 +22,21 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.7",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
"vite": "^8.0.12",
"vitest": "^4.1.7"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { useState } from 'react'
import type { Artifact, ArtifactContent } from '../lib/types'
import * as api from '../lib/api'
export function useArtifacts(sessionId: string | null) {
const [artifacts, setArtifacts] = useState<Artifact[]>([])
const [activeArtifact, setActiveArtifact] = useState<ArtifactContent | null>(null)
async function refresh() {
if (!sessionId) return
const data = await api.listArtifacts(sessionId)
setArtifacts(data)
}
async function loadArtifact(name: string) {
if (!sessionId) return
const data = await api.getArtifact(sessionId, name)
setActiveArtifact(data)
}
return { artifacts, activeArtifact, refresh, loadArtifact }
}
+27
View File
@@ -0,0 +1,27 @@
import { useState, useEffect } from 'react'
import type { AuthUser } from '../lib/types'
import * as api from '../lib/api'
export function useAuth() {
const [user, setUser] = useState<AuthUser | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.getMe()
.then(u => setUser(u))
.catch(() => setUser(null))
.finally(() => setLoading(false))
}, [])
async function doLogin(email: string, password: string) {
const resp = await api.login(email, password)
setUser({ email: resp.email })
}
async function doLogout() {
await api.logout()
setUser(null)
}
return { user, loading, login: doLogin, logout: doLogout }
}
+195
View File
@@ -0,0 +1,195 @@
import { useState, useRef } from 'react'
import type { ToolCall } from '../lib/types'
export interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'tool'
content: string
toolCalls?: ToolCall[]
}
export function useChat(sessionId: string | null) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isStreaming, setIsStreaming] = useState(false)
const abortRef = useRef<AbortController | null>(null)
async function sendMessage(content: string) {
if (!sessionId || !content.trim()) return
const trimmed = content.trim()
const userMsg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', content: trimmed }
setMessages(prev => [...prev, userMsg])
setIsStreaming(true)
const allMessages = [...messages, userMsg].map(m => ({ role: m.role, content: m.content }))
const controller = new AbortController()
abortRef.current = controller
try {
const res = await fetch('/api/copilotkit', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
threadId: sessionId,
runId: `run-${Date.now()}`,
messages: allMessages,
tools: [],
context: [],
}),
signal: controller.signal,
})
if (!res.ok || !res.body) {
throw new Error(`Request failed: ${res.status}`)
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let assistantId: string | null = null
let assistantContent = ''
const toolCalls: Record<string, ToolCall> = {}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const chunks = buffer.split('\n\n')
buffer = chunks.pop() ?? ''
for (const chunk of chunks) {
if (!chunk.startsWith('data: ')) continue
const jsonStr = chunk.slice('data: '.length).trim()
if (!jsonStr) continue
let event: Record<string, unknown>
try {
event = JSON.parse(jsonStr) as Record<string, unknown>
} catch {
continue
}
switch (event.type) {
case 'TEXT_MESSAGE_START': {
assistantId = event.messageId as string
assistantContent = ''
break
}
case 'TEXT_MESSAGE_CONTENT': {
assistantContent += event.delta as string
setMessages(prev => {
const existing = prev.find(m => m.id === assistantId)
if (existing) {
return prev.map(m =>
m.id === assistantId ? { ...m, content: assistantContent } : m
)
}
return [...prev, { id: assistantId!, role: 'assistant', content: assistantContent }]
})
break
}
case 'TEXT_MESSAGE_END': {
// no-op
break
}
case 'TOOL_CALL_START': {
const tcId = event.toolCallId as string
toolCalls[tcId] = {
id: tcId,
name: event.toolCallName as string,
args: {},
status: 'running',
}
break
}
case 'TOOL_CALL_ARGS': {
const tcId = event.toolCallId as string
if (toolCalls[tcId]) {
toolCalls[tcId] = {
...toolCalls[tcId],
args: { ...toolCalls[tcId].args, _raw: ((toolCalls[tcId].args._raw as string) ?? '') + (event.delta as string) },
}
}
break
}
case 'TOOL_CALL_END': {
const tcId = event.toolCallId as string
if (toolCalls[tcId]) {
toolCalls[tcId] = { ...toolCalls[tcId], status: 'complete' }
const tcList = Object.values(toolCalls)
setMessages(prev => {
if (assistantId) {
const existing = prev.find(m => m.id === assistantId)
if (existing) {
return prev.map(m =>
m.id === assistantId ? { ...m, toolCalls: tcList } : m
)
}
}
return prev
})
}
break
}
case 'TOOL_CALL_RESULT': {
const tcId = event.toolCallId as string
if (toolCalls[tcId]) {
const meta = event._meta as { ui?: { resourceUri: string } } | undefined
const structuredContent = event.structuredContent as Record<string, unknown> | undefined
toolCalls[tcId] = {
...toolCalls[tcId],
result: event.result,
...(meta?.ui?.resourceUri ? { _meta: meta as ToolCall['_meta'] } : {}),
...(structuredContent ? { structuredContent } : {}),
}
const tcList = Object.values(toolCalls)
setMessages(prev => {
if (assistantId) {
const existing = prev.find(m => m.id === assistantId)
if (existing) {
return prev.map(m =>
m.id === assistantId ? { ...m, toolCalls: tcList } : m
)
}
}
return prev
})
}
break
}
case 'RUN_FINISHED': {
// no-op
break
}
default:
break
}
}
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
// silently ignore abort
} else {
console.error('Chat stream error:', err)
}
} finally {
setIsStreaming(false)
}
}
function cancel() {
abortRef.current?.abort()
}
function clearMessages() {
setMessages([])
}
return { messages, isStreaming, sendMessage, cancel, clearMessages }
}
+44
View File
@@ -0,0 +1,44 @@
import { useState, useEffect } from 'react'
import type { Session, SessionListResponse } from '../lib/types'
import * as api from '../lib/api'
export function useSessions() {
const [sessions, setSessions] = useState<Session[]>([])
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
async function refresh() {
try {
const data: SessionListResponse = await api.listSessions()
setSessions(data.sessions)
} catch (err) {
console.error('Failed to list sessions:', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
void refresh()
}, [])
async function createNew(): Promise<string | null> {
try {
const data = await api.createSession()
await refresh()
setActiveSessionId(data.session_id)
return data.session_id
} catch (err) {
console.error('Failed to create session:', err)
return null
}
}
function switchTo(sessionId: string) {
setActiveSessionId(sessionId)
}
const activeSession = sessions.find(s => s.session_id === activeSessionId)
return { sessions, activeSession, activeSessionId, loading, createNew, switchTo, refresh }
}
+54
View File
@@ -0,0 +1,54 @@
import type { AuthUser, LoginResponse, SessionListResponse, Artifact, ArtifactContent } from './types'
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const options: RequestInit = {
method,
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
}
if (body !== undefined) {
options.body = JSON.stringify(body)
}
const res = await fetch(path, options)
if (!res.ok) {
const text = await res.text()
throw new Error(`${res.status}: ${text}`)
}
return res.json() as Promise<T>
}
export function login(email: string, password: string): Promise<LoginResponse> {
return request<LoginResponse>('POST', '/api/auth/login', { email, password })
}
export function getMe(): Promise<AuthUser> {
return request<AuthUser>('GET', '/api/auth/me')
}
export function logout(): Promise<void> {
return request<void>('POST', '/api/auth/logout')
}
export function listSessions(): Promise<SessionListResponse> {
return request<SessionListResponse>('GET', '/api/sessions')
}
export function createSession(name?: string): Promise<{ session_id: string }> {
return request<{ session_id: string }>('POST', '/api/sessions', { name })
}
export function deleteSession(sessionId: string): Promise<void> {
return request<void>('DELETE', `/api/sessions/${sessionId}`)
}
export function getTranscript(sessionId: string): Promise<{ messages: { role: string; content: string }[] }> {
return request<{ messages: { role: string; content: string }[] }>('GET', `/api/sessions/${sessionId}/transcript`)
}
export function listArtifacts(sessionId: string): Promise<Artifact[]> {
return request<Artifact[]>('GET', `/api/artifacts/${sessionId}`)
}
export function getArtifact(sessionId: string, name: string): Promise<ArtifactContent> {
return request<ArtifactContent>('GET', `/api/artifacts/${sessionId}/${name}`)
}
+180
View File
@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// Mock fetch globally
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// Import after stubbing fetch
import * as api from '../lib/api'
function makeResponse(status: number, body: unknown, headers?: Record<string, string>) {
return {
ok: status >= 200 && status < 300,
status,
text: vi.fn().mockResolvedValue(typeof body === 'string' ? body : JSON.stringify(body)),
json: vi.fn().mockResolvedValue(body),
body: null,
headers: {
get: (key: string) => (headers ?? {})[key] ?? null,
},
}
}
describe('api module', () => {
beforeEach(() => {
mockFetch.mockReset()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('login', () => {
it('POSTs to /api/auth/login with credentials and returns LoginResponse', async () => {
const responseData = { token: 'abc123', email: 'test@example.com' }
mockFetch.mockResolvedValue(makeResponse(200, responseData))
const result = await api.login('test@example.com', 'password123')
expect(mockFetch).toHaveBeenCalledWith('/api/auth/login', expect.objectContaining({
method: 'POST',
credentials: 'include',
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ email: 'test@example.com', password: 'password123' }),
}))
expect(result).toEqual(responseData)
})
it('throws on non-OK response', async () => {
mockFetch.mockResolvedValue(makeResponse(401, 'Unauthorized'))
await expect(api.login('bad@example.com', 'wrong')).rejects.toThrow('401: Unauthorized')
})
})
describe('getMe', () => {
it('GETs /api/auth/me with credentials', async () => {
const userData = { email: 'user@example.com' }
mockFetch.mockResolvedValue(makeResponse(200, userData))
const result = await api.getMe()
expect(mockFetch).toHaveBeenCalledWith('/api/auth/me', expect.objectContaining({
method: 'GET',
credentials: 'include',
}))
expect(result).toEqual(userData)
})
})
describe('logout', () => {
it('POSTs to /api/auth/logout with credentials', async () => {
mockFetch.mockResolvedValue(makeResponse(200, {}))
await api.logout()
expect(mockFetch).toHaveBeenCalledWith('/api/auth/logout', expect.objectContaining({
method: 'POST',
credentials: 'include',
}))
})
})
describe('listSessions', () => {
it('GETs /api/sessions and returns SessionListResponse', async () => {
const sessionsData = { sessions: [], total: 0 }
mockFetch.mockResolvedValue(makeResponse(200, sessionsData))
const result = await api.listSessions()
expect(mockFetch).toHaveBeenCalledWith('/api/sessions', expect.objectContaining({
method: 'GET',
credentials: 'include',
}))
expect(result).toEqual(sessionsData)
})
})
describe('createSession', () => {
it('POSTs /api/sessions with name body', async () => {
const responseData = { session_id: 'sess-123' }
mockFetch.mockResolvedValue(makeResponse(200, responseData))
const result = await api.createSession('My Session')
expect(mockFetch).toHaveBeenCalledWith('/api/sessions', expect.objectContaining({
method: 'POST',
credentials: 'include',
body: JSON.stringify({ name: 'My Session' }),
}))
expect(result).toEqual(responseData)
})
it('POSTs /api/sessions with undefined name when not provided', async () => {
const responseData = { session_id: 'sess-456' }
mockFetch.mockResolvedValue(makeResponse(200, responseData))
const result = await api.createSession()
expect(result).toEqual(responseData)
})
})
describe('deleteSession', () => {
it('DELETEs /api/sessions/{id}', async () => {
mockFetch.mockResolvedValue(makeResponse(200, {}))
await api.deleteSession('sess-123')
expect(mockFetch).toHaveBeenCalledWith('/api/sessions/sess-123', expect.objectContaining({
method: 'DELETE',
credentials: 'include',
}))
})
})
describe('getTranscript', () => {
it('GETs /api/sessions/{id}/transcript', async () => {
const transcript = { messages: [{ role: 'user', content: 'hello' }] }
mockFetch.mockResolvedValue(makeResponse(200, transcript))
const result = await api.getTranscript('sess-123')
expect(mockFetch).toHaveBeenCalledWith('/api/sessions/sess-123/transcript', expect.objectContaining({
method: 'GET',
credentials: 'include',
}))
expect(result).toEqual(transcript)
})
})
describe('listArtifacts', () => {
it('GETs /api/artifacts/{id}', async () => {
const artifacts = [{ name: 'report.md', session_id: 'sess-123', size: 100 }]
mockFetch.mockResolvedValue(makeResponse(200, artifacts))
const result = await api.listArtifacts('sess-123')
expect(mockFetch).toHaveBeenCalledWith('/api/artifacts/sess-123', expect.objectContaining({
method: 'GET',
credentials: 'include',
}))
expect(result).toEqual(artifacts)
})
})
describe('getArtifact', () => {
it('GETs /api/artifacts/{id}/{name}', async () => {
const artifact = { name: 'report.md', session_id: 'sess-123', content: '# Report' }
mockFetch.mockResolvedValue(makeResponse(200, artifact))
const result = await api.getArtifact('sess-123', 'report.md')
expect(mockFetch).toHaveBeenCalledWith('/api/artifacts/sess-123/report.md', expect.objectContaining({
method: 'GET',
credentials: 'include',
}))
expect(result).toEqual(artifact)
})
})
})
+3
View File
@@ -0,0 +1,3 @@
import '@testing-library/react'
// Minimal setup for vitest + jsdom
+85
View File
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
vi.mock('../lib/api', () => ({
listArtifacts: vi.fn(),
getArtifact: vi.fn(),
}))
import { useArtifacts } from '../hooks/useArtifacts'
import * as api from '../lib/api'
const mockListArtifacts = vi.mocked(api.listArtifacts)
const mockGetArtifact = vi.mocked(api.getArtifact)
describe('useArtifacts', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts with empty artifacts and null activeArtifact', () => {
const { result } = renderHook(() => useArtifacts('sess-123'))
expect(result.current.artifacts).toEqual([])
expect(result.current.activeArtifact).toBeNull()
})
it('refresh calls listArtifacts and updates state', async () => {
const artifacts = [{ name: 'report.md', session_id: 'sess-123', size: 100 }]
mockListArtifacts.mockResolvedValue(artifacts)
const { result } = renderHook(() => useArtifacts('sess-123'))
await act(async () => {
await result.current.refresh()
})
expect(mockListArtifacts).toHaveBeenCalledWith('sess-123')
expect(result.current.artifacts).toEqual(artifacts)
})
it('refresh is guarded by sessionId', async () => {
const { result } = renderHook(() => useArtifacts(null))
await act(async () => {
await result.current.refresh()
})
expect(mockListArtifacts).not.toHaveBeenCalled()
})
it('loadArtifact calls getArtifact and sets activeArtifact', async () => {
const artifactContent = { name: 'report.md', session_id: 'sess-123', content: '# Report' }
mockGetArtifact.mockResolvedValue(artifactContent)
const { result } = renderHook(() => useArtifacts('sess-123'))
await act(async () => {
await result.current.loadArtifact('report.md')
})
expect(mockGetArtifact).toHaveBeenCalledWith('sess-123', 'report.md')
expect(result.current.activeArtifact).toEqual(artifactContent)
})
it('loadArtifact is guarded by sessionId', async () => {
const { result } = renderHook(() => useArtifacts(null))
await act(async () => {
await result.current.loadArtifact('report.md')
})
expect(mockGetArtifact).not.toHaveBeenCalled()
expect(result.current.activeArtifact).toBeNull()
})
it('returns expected shape', () => {
mockListArtifacts.mockResolvedValue([])
const { result } = renderHook(() => useArtifacts('sess-123'))
expect(typeof result.current.refresh).toBe('function')
expect(typeof result.current.loadArtifact).toBe('function')
expect(Array.isArray(result.current.artifacts)).toBe(true)
})
})
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
// Mock the api module
vi.mock('../lib/api', () => ({
getMe: vi.fn(),
login: vi.fn(),
logout: vi.fn(),
}))
import { useAuth } from '../hooks/useAuth'
import * as api from '../lib/api'
const mockGetMe = vi.mocked(api.getMe)
const mockLogin = vi.mocked(api.login)
const mockLogout = vi.mocked(api.logout)
describe('useAuth', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts with loading=true and user=null', () => {
mockGetMe.mockResolvedValue({ email: 'user@example.com' })
const { result } = renderHook(() => useAuth())
expect(result.current.loading).toBe(true)
expect(result.current.user).toBeNull()
})
it('sets user on successful getMe and loading=false', async () => {
mockGetMe.mockResolvedValue({ email: 'user@example.com' })
const { result } = renderHook(() => useAuth())
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(result.current.loading).toBe(false)
expect(result.current.user).toEqual({ email: 'user@example.com' })
})
it('sets user=null when getMe fails and loading=false', async () => {
mockGetMe.mockRejectedValue(new Error('Unauthorized'))
const { result } = renderHook(() => useAuth())
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(result.current.loading).toBe(false)
expect(result.current.user).toBeNull()
})
it('login calls api.login and sets user', async () => {
mockGetMe.mockResolvedValue({ email: 'user@example.com' })
mockLogin.mockResolvedValue({ token: 'tok', email: 'new@example.com' })
const { result } = renderHook(() => useAuth())
await act(async () => {
await result.current.login('new@example.com', 'password')
})
expect(mockLogin).toHaveBeenCalledWith('new@example.com', 'password')
expect(result.current.user).toEqual({ email: 'new@example.com' })
})
it('logout calls api.logout and sets user=null', async () => {
mockGetMe.mockResolvedValue({ email: 'user@example.com' })
mockLogout.mockResolvedValue(undefined)
const { result } = renderHook(() => useAuth())
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
await act(async () => {
await result.current.logout()
})
expect(mockLogout).toHaveBeenCalled()
expect(result.current.user).toBeNull()
})
it('exports login and logout functions', () => {
mockGetMe.mockResolvedValue({ email: 'user@example.com' })
const { result } = renderHook(() => useAuth())
expect(typeof result.current.login).toBe('function')
expect(typeof result.current.logout).toBe('function')
})
})
+198
View File
@@ -0,0 +1,198 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useChat } from '../hooks/useChat'
import type { ChatMessage } from '../hooks/useChat'
// Helper to create a ReadableStream from SSE events
function createSSEStream(events: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
return new ReadableStream({
start(controller) {
for (const event of events) {
controller.enqueue(encoder.encode(event))
}
controller.close()
},
})
}
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
describe('useChat', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns initial state', () => {
const { result } = renderHook(() => useChat(null))
expect(result.current.messages).toEqual([])
expect(result.current.isStreaming).toBe(false)
expect(typeof result.current.sendMessage).toBe('function')
expect(typeof result.current.cancel).toBe('function')
expect(typeof result.current.clearMessages).toBe('function')
})
it('does not fetch when sessionId is null', async () => {
const { result } = renderHook(() => useChat(null))
await act(async () => {
await result.current.sendMessage('hello')
})
expect(mockFetch).not.toHaveBeenCalled()
})
it('does not fetch when content is empty/whitespace', async () => {
const { result } = renderHook(() => useChat('sess-123'))
await act(async () => {
await result.current.sendMessage(' ')
})
expect(mockFetch).not.toHaveBeenCalled()
})
it('sends message and sets isStreaming', async () => {
const sseStream = createSSEStream([
'data: {"type":"RUN_FINISHED"}\n\n',
])
mockFetch.mockResolvedValue({
ok: true,
body: sseStream,
})
const { result } = renderHook(() => useChat('sess-123'))
await act(async () => {
await result.current.sendMessage('hello')
})
expect(mockFetch).toHaveBeenCalledWith(
'/api/copilotkit',
expect.objectContaining({
method: 'POST',
credentials: 'include',
})
)
})
it('appends user message with trimmed content', async () => {
const sseStream = createSSEStream([
'data: {"type":"RUN_FINISHED"}\n\n',
])
mockFetch.mockResolvedValue({
ok: true,
body: sseStream,
})
const { result } = renderHook(() => useChat('sess-123'))
await act(async () => {
await result.current.sendMessage(' hello world ')
})
expect(result.current.messages.length).toBeGreaterThanOrEqual(1)
const userMsg = result.current.messages.find(m => m.role === 'user')
expect(userMsg?.content).toBe('hello world')
expect(userMsg?.id).toMatch(/^user-/)
})
it('handles TEXT_MESSAGE events to build assistant message', async () => {
const msgId = 'msg-abc'
const sseStream = createSSEStream([
`data: {"type":"TEXT_MESSAGE_START","messageId":"${msgId}"}\n\n`,
`data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"${msgId}","delta":"Hello"}\n\n`,
`data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"${msgId}","delta":" World"}\n\n`,
`data: {"type":"TEXT_MESSAGE_END","messageId":"${msgId}"}\n\n`,
'data: {"type":"RUN_FINISHED"}\n\n',
])
mockFetch.mockResolvedValue({
ok: true,
body: sseStream,
})
const { result } = renderHook(() => useChat('sess-123'))
await act(async () => {
await result.current.sendMessage('hi')
})
const assistantMsg = result.current.messages.find(m => m.role === 'assistant')
expect(assistantMsg).toBeDefined()
expect(assistantMsg?.content).toBe('Hello World')
})
it('clearMessages resets messages to empty', async () => {
const sseStream = createSSEStream([
'data: {"type":"RUN_FINISHED"}\n\n',
])
mockFetch.mockResolvedValue({
ok: true,
body: sseStream,
})
const { result } = renderHook(() => useChat('sess-123'))
await act(async () => {
await result.current.sendMessage('hello')
})
expect(result.current.messages.length).toBeGreaterThan(0)
act(() => {
result.current.clearMessages()
})
expect(result.current.messages).toEqual([])
})
it('cancel calls abort on abortRef', async () => {
// Create a stream that never finishes
let streamController: ReadableStreamDefaultController<Uint8Array>
const sseStream = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller
},
})
mockFetch.mockResolvedValue({
ok: true,
body: sseStream,
})
const { result } = renderHook(() => useChat('sess-123'))
act(() => {
void result.current.sendMessage('hello')
})
await act(async () => {
result.current.cancel()
// Give time for abort to propagate
await new Promise(resolve => setTimeout(resolve, 10))
// Close stream to avoid hanging
streamController!.close()
})
// After cancel, isStreaming should be false (eventually)
// This verifies cancel() doesn't throw
expect(result.current.cancel).toBeDefined()
})
it('exports ChatMessage interface compatible shape', () => {
const msg: ChatMessage = {
id: 'test-1',
role: 'user',
content: 'hello',
}
expect(msg.id).toBe('test-1')
expect(msg.role).toBe('user')
})
})
+132
View File
@@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
vi.mock('../lib/api', () => ({
listSessions: vi.fn(),
createSession: vi.fn(),
}))
import { useSessions } from '../hooks/useSessions'
import * as api from '../lib/api'
const mockListSessions = vi.mocked(api.listSessions)
const mockCreateSession = vi.mocked(api.createSession)
const sampleSessions = [
{ session_id: 'sess-1', status: 'active', bundle: 'default', created_at: '2024-01-01', last_activity: '2024-01-01', total_messages: 5, tool_invocations: 2, stale: false },
{ session_id: 'sess-2', status: 'active', bundle: 'default', created_at: '2024-01-02', last_activity: '2024-01-02', total_messages: 3, tool_invocations: 1, stale: false },
]
describe('useSessions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts with empty sessions and loading=true', () => {
mockListSessions.mockResolvedValue({ sessions: [], total: 0 })
const { result } = renderHook(() => useSessions())
expect(result.current.sessions).toEqual([])
expect(result.current.loading).toBe(true)
})
it('loads sessions on mount', async () => {
mockListSessions.mockResolvedValue({ sessions: sampleSessions, total: 2 })
const { result } = renderHook(() => useSessions())
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(result.current.sessions).toEqual(sampleSessions)
expect(result.current.loading).toBe(false)
})
it('refresh calls listSessions and updates sessions', async () => {
mockListSessions.mockResolvedValue({ sessions: sampleSessions, total: 2 })
const { result } = renderHook(() => useSessions())
await act(async () => {
await result.current.refresh()
})
expect(mockListSessions).toHaveBeenCalled()
expect(result.current.sessions).toEqual(sampleSessions)
})
it('createNew calls createSession, refreshes, sets activeSessionId, returns session_id', async () => {
mockListSessions.mockResolvedValue({ sessions: sampleSessions, total: 2 })
mockCreateSession.mockResolvedValue({ session_id: 'sess-new' })
const { result } = renderHook(() => useSessions())
let sessionId: string | null = null
await act(async () => {
sessionId = await result.current.createNew()
})
expect(mockCreateSession).toHaveBeenCalled()
expect(sessionId).toBe('sess-new')
expect(result.current.activeSessionId).toBe('sess-new')
})
it('createNew returns null on error', async () => {
mockListSessions.mockResolvedValue({ sessions: [], total: 0 })
mockCreateSession.mockRejectedValue(new Error('Server error'))
const { result } = renderHook(() => useSessions())
let sessionId: string | null | undefined = undefined
await act(async () => {
sessionId = await result.current.createNew()
})
expect(sessionId).toBeNull()
})
it('switchTo sets activeSessionId', async () => {
mockListSessions.mockResolvedValue({ sessions: sampleSessions, total: 2 })
const { result } = renderHook(() => useSessions())
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
act(() => {
result.current.switchTo('sess-2')
})
expect(result.current.activeSessionId).toBe('sess-2')
})
it('activeSession is found via Array.find', async () => {
mockListSessions.mockResolvedValue({ sessions: sampleSessions, total: 2 })
const { result } = renderHook(() => useSessions())
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
act(() => {
result.current.switchTo('sess-1')
})
expect(result.current.activeSession).toEqual(sampleSessions[0])
})
it('returns expected shape', () => {
mockListSessions.mockResolvedValue({ sessions: [], total: 0 })
const { result } = renderHook(() => useSessions())
expect(typeof result.current.refresh).toBe('function')
expect(typeof result.current.createNew).toBe('function')
expect(typeof result.current.switchTo).toBe('function')
expect(result.current.activeSession).toBeUndefined()
})
})
+6
View File
@@ -1,3 +1,4 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
@@ -17,4 +18,9 @@ export default defineConfig({
outDir: 'dist',
sourcemap: true,
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
})