feat: API client and hooks for auth, sessions, chat SSE, artifacts
This commit is contained in:
Generated
+1214
-1
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@copilotkit/react-core": "^1.58.0",
|
"@copilotkit/react-core": "^1.58.0",
|
||||||
@@ -20,16 +22,21 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.7",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
"vite": "^8.0.12"
|
"vite": "^8.0.12",
|
||||||
|
"vitest": "^4.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import '@testing-library/react'
|
||||||
|
|
||||||
|
// Minimal setup for vitest + jsdom
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
@@ -17,4 +18,9 @@ export default defineConfig({
|
|||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user