cc-haha/src/server/__tests__/e2e/full-flow.test.ts
程序员阿江(Relakkes) 5688fe10a7 test(e2e): isolate ANTHROPIC_*MODEL env from the models-API fixtures
The models API derives its model list from ANTHROPIC_MODEL and the
ANTHROPIC_DEFAULT_{HAIKU,SONNET,OPUS}_MODEL env vars. A developer who exports
these for a custom provider (e.g. MiniMax) leaked them into the no-provider
fixture: the four collapsed to one model, the default model became the
custom one, and switched-model names fell back to the raw id — failing five
"available models" / "default model" assertions on that machine while passing
on clean CI.

Clear those four vars in the e2e setup and restore them in teardown, matching
the existing CLAUDE_CONFIG_DIR / CLAUDE_CLI_PATH isolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:12:54 +08:00

404 lines
13 KiB
TypeScript

/**
* E2E Test — 完整流程测试
*
* 启动真实服务器,模拟 UI 前端的完整操作流程。
*/
import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { fileURLToPath } from 'node:url'
let server: ReturnType<typeof Bun.serve>
let baseUrl: string
let tmpDir: string
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
const originalCliPath = process.env.CLAUDE_CLI_PATH
const originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
const mockSdkCliPath = fileURLToPath(new URL('../fixtures/mock-sdk-cli.ts', import.meta.url))
// The models API derives its model list from these env vars (see
// src/server/api/models.ts getEnvConfiguredAnthropicModels). A developer who
// exports them for a custom provider would otherwise leak them into the
// no-provider fixture and break the default-model assertions. Isolate them.
const MODEL_ENV_KEYS = [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
] as const
const originalModelEnv = Object.fromEntries(
MODEL_ENV_KEYS.map((key) => [key, process.env[key]]),
) as Record<(typeof MODEL_ENV_KEYS)[number], string | undefined>
function restoreEnv() {
for (const key of MODEL_ENV_KEYS) {
if (originalModelEnv[key] !== undefined) process.env[key] = originalModelEnv[key]
else delete process.env[key]
}
if (originalConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
if (originalCliPath !== undefined) {
process.env.CLAUDE_CLI_PATH = originalCliPath
} else {
delete process.env.CLAUDE_CLI_PATH
}
if (originalDisableTerminalShellEnv !== undefined) {
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = originalDisableTerminalShellEnv
} else {
delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
}
}
afterAll(() => {
restoreEnv()
})
// Use dynamic import to avoid bundling issues
async function startTestServer() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-e2e-'))
process.env.CLAUDE_CONFIG_DIR = tmpDir
process.env.CLAUDE_CLI_PATH = mockSdkCliPath
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1'
for (const key of MODEL_ENV_KEYS) delete process.env[key]
// Create required directories
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
const { startServer } = await import('../../index.js')
server = startServer(0, '127.0.0.1')
baseUrl = `http://127.0.0.1:${server.port}`
}
async function api(method: string, path: string, body?: unknown): Promise<{ status: number; data: any }> {
const res = await fetch(`${baseUrl}${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
})
const data = await res.json().catch(() => null)
return { status: res.status, data }
}
describe('E2E: Full Flow', () => {
beforeAll(async () => {
await startTestServer()
})
afterAll(async () => {
server?.stop()
await fs.rm(tmpDir, { recursive: true, force: true })
})
// =============================================
// 1. Health & Status
// =============================================
it('should return healthy status', async () => {
const res = await fetch(`${baseUrl}/health`)
const data = await res.json()
expect(data.status).toBe('ok')
})
it('should return server status', async () => {
const { data } = await api('GET', '/api/status')
expect(data.status).toBe('ok')
expect(data.version).toBeDefined()
})
it('should return diagnostics', async () => {
const { data } = await api('GET', '/api/status/diagnostics')
expect(data.platform).toBe('darwin')
expect(data.configDir).toBe(tmpDir)
})
// =============================================
// 2. Sessions CRUD
// =============================================
let sessionId: string
it('should start with empty session list', async () => {
const { data } = await api('GET', '/api/sessions')
expect(data.sessions).toEqual([])
expect(data.total).toBe(0)
})
it('should create a new session', async () => {
const { status, data } = await api('POST', '/api/sessions', { workDir: tmpDir })
expect(status).toBe(201)
expect(data.sessionId).toBeDefined()
expect(data.sessionId).toMatch(/^[0-9a-f-]{36}$/)
sessionId = data.sessionId
})
it('should list the created session', async () => {
const { data } = await api('GET', '/api/sessions')
expect(data.sessions.length).toBe(1)
expect(data.sessions[0].id).toBe(sessionId)
})
it('should get session detail', async () => {
const { status, data } = await api('GET', `/api/sessions/${sessionId}`)
expect(status).toBe(200)
expect(data.id).toBe(sessionId)
})
it('should rename session', async () => {
const { status } = await api('PATCH', `/api/sessions/${sessionId}`, { title: 'My Test Session' })
expect(status).toBe(200)
const { data } = await api('GET', `/api/sessions/${sessionId}`)
expect(data.title).toBe('My Test Session')
})
it('should get session messages', async () => {
const { status, data } = await api('GET', `/api/sessions/${sessionId}/messages`)
expect(status).toBe(200)
expect(Array.isArray(data.messages)).toBe(true)
})
it('should delete session', async () => {
const { status } = await api('DELETE', `/api/sessions/${sessionId}`)
expect(status).toBe(200)
const { data } = await api('GET', '/api/sessions')
expect(data.sessions.length).toBe(0)
})
// =============================================
// 3. Settings
// =============================================
it('should get empty settings initially', async () => {
const { data } = await api('GET', '/api/settings/user')
expect(data).toEqual({})
})
it('should update and read user settings', async () => {
await api('PUT', '/api/settings/user', { theme: 'dark', model: 'claude-sonnet-4-6' })
const { data } = await api('GET', '/api/settings/user')
expect(data.theme).toBe('dark')
expect(data.model).toBe('claude-sonnet-4-6')
})
it('should get and set permission mode', async () => {
await api('PUT', '/api/permissions/mode', { mode: 'plan' })
const { data } = await api('GET', '/api/permissions/mode')
expect(data.mode).toBe('plan')
})
it('should reject invalid permission mode', async () => {
const { status } = await api('PUT', '/api/permissions/mode', { mode: 'invalid' })
expect(status).toBe(400)
})
// =============================================
// 4. Models
// =============================================
it('should list available models', async () => {
const { data } = await api('GET', '/api/models')
expect(data.models.length).toBe(3)
expect(data.models[0].name).toBe('Opus 4.7')
})
it('should switch model', async () => {
await api('PUT', '/api/models/current', { modelId: 'claude-haiku-4-5' })
const { data } = await api('GET', '/api/models/current')
expect(data.model.id).toBe('claude-haiku-4-5')
})
it('should get and set effort level', async () => {
await api('PUT', '/api/effort', { level: 'high' })
const { data } = await api('GET', '/api/effort')
expect(data.level).toBe('high')
})
// =============================================
// 5. Scheduled Tasks
// =============================================
let taskId: string
it('should start with empty task list', async () => {
const { data } = await api('GET', '/api/scheduled-tasks')
expect(data.tasks).toEqual([])
})
it('should create a scheduled task', async () => {
const { status, data } = await api('POST', '/api/scheduled-tasks', {
cron: '0 9 * * *',
prompt: 'Review commits from last 24h',
recurring: true,
name: 'daily-review',
description: 'Daily code review',
})
expect(status).toBe(201)
expect(data.task.id).toBeDefined()
expect(data.task.cron).toBe('0 9 * * *')
taskId = data.task.id
})
it('should list the created task', async () => {
const { data } = await api('GET', '/api/scheduled-tasks')
expect(data.tasks.length).toBe(1)
expect(data.tasks[0].id).toBe(taskId)
})
it('should update a task', async () => {
const { status, data } = await api('PUT', `/api/scheduled-tasks/${taskId}`, {
cron: '0 10 * * 1-5',
})
expect(status).toBe(200)
expect(data.task.cron).toBe('0 10 * * 1-5')
})
it('should delete a task', async () => {
const { status } = await api('DELETE', `/api/scheduled-tasks/${taskId}`)
expect([200, 204]).toContain(status)
const { data } = await api('GET', '/api/scheduled-tasks')
expect(data.tasks).toEqual([])
})
// =============================================
// 6. Search
// =============================================
it('should search workspace', async () => {
// Create a test file to search
await fs.writeFile(path.join(tmpDir, 'test-search.txt'), 'Hello World\nFoo Bar Baz\n')
const { status, data } = await api('POST', '/api/search', {
query: 'Hello',
cwd: tmpDir,
})
expect(status).toBe(200)
expect(data.results.length).toBeGreaterThan(0)
expect(data.results[0].text).toContain('Hello')
})
// =============================================
// 7. Agents
// =============================================
it('should start with shared active/all agent payload', async () => {
const { data } = await api('GET', '/api/agents')
expect(Array.isArray(data.activeAgents)).toBe(true)
expect(Array.isArray(data.allAgents)).toBe(true)
expect(data.activeAgents.length).toBeGreaterThan(0)
expect(data.activeAgents.some((agent: any) => agent.source === 'built-in')).toBe(true)
})
it('should create an agent', async () => {
const { status } = await api('POST', '/api/agents', {
name: 'test-agent',
description: 'A test agent',
model: 'claude-sonnet-4-6',
})
expect(status).toBe(201)
})
it('should expose shared active/all agent payload independent of CRUD storage', async () => {
const { data } = await api('GET', '/api/agents')
expect(Array.isArray(data.activeAgents)).toBe(true)
expect(Array.isArray(data.allAgents)).toBe(true)
expect(data.activeAgents.length).toBeGreaterThan(0)
expect(data.activeAgents.some((agent: any) => agent.source === 'built-in')).toBe(true)
expect(data.activeAgents.some((agent: any) => agent.agentType === 'test-agent')).toBe(false)
})
it('should delete an agent', async () => {
const { status } = await api('DELETE', '/api/agents/test-agent')
expect([200, 204]).toContain(status)
})
// =============================================
// 8. WebSocket Chat
// =============================================
it('should connect via WebSocket', async () => {
const wsUrl = baseUrl.replace('http://', 'ws://') + '/ws/test-ws-session'
const messages: any[] = []
const ws = new WebSocket(wsUrl)
await new Promise<void>((resolve, reject) => {
ws.onopen = () => {
// Should receive connected message
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data as string)
messages.push(msg)
if (msg.type === 'connected') {
// Send a test message
ws.send(JSON.stringify({ type: 'user_message', content: 'Hello' }))
}
if (msg.type === 'status' && msg.state === 'idle' && messages.length > 2) {
ws.close()
resolve()
}
}
ws.onerror = reject
setTimeout(() => {
ws.close()
resolve()
}, 3000)
})
expect(messages[0].type).toBe('connected')
expect(messages[0].sessionId).toBe('test-ws-session')
})
// =============================================
// 9. Conversation Status
// =============================================
it('should get chat status', async () => {
// Create a session first
const { data: created } = await api('POST', '/api/sessions', { workDir: tmpDir })
const { status, data } = await api('GET', `/api/sessions/${created.sessionId}/chat/status`)
expect(status).toBe(200)
expect(data.state).toBe('idle')
// Cleanup
await api('DELETE', `/api/sessions/${created.sessionId}`)
})
// =============================================
// 10. CORS
// =============================================
it('should block browser CORS preflight while H5 access is disabled', async () => {
const res = await fetch(`${baseUrl}/api/status`, {
method: 'OPTIONS',
headers: { 'Origin': 'http://localhost:3000' },
})
expect(res.status).toBe(403)
})
// =============================================
// 11. Error Handling
// =============================================
it('should return 404 for unknown API', async () => {
const { status } = await api('GET', '/api/nonexistent')
expect(status).toBe(404)
})
it('should return 404 for unknown session', async () => {
const { status } = await api('GET', '/api/sessions/00000000-0000-0000-0000-000000000000')
expect(status).toBe(404)
})
})