## Error Handling - Add GlobalErrorBoundary with error classification and recovery - Add custom error types (SecurityError, ConnectionError, TimeoutError) - Fix ErrorAlert component syntax errors ## Offline Mode - Add offlineStore for offline state management - Implement message queue with localStorage persistence - Add exponential backoff reconnection (1s→60s) - Add OfflineIndicator component with status display - Queue messages when offline, auto-retry on reconnect ## Security Hardening - Add AES-256-GCM encryption for chat history storage - Add secure API key storage with OS keychain integration - Add security audit logging system - Add XSS prevention and input validation utilities - Add rate limiting and token generation helpers ## CI/CD (Gitea Actions) - Add .gitea/workflows/ci.yml for continuous integration - Add .gitea/workflows/release.yml for release automation - Support Windows Tauri build and release ## UI Components - Add LoadingSpinner, LoadingOverlay, LoadingDots components - Add MessageSkeleton, ConversationListSkeleton skeletons - Add EmptyMessages, EmptyConversations empty states - Integrate loading states in ChatArea and ConversationList ## E2E Tests - Fix WebSocket mock for streaming response tests - Fix approval endpoint route matching - Add store state exposure for testing - All 19 core-features tests now passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
963 lines
26 KiB
TypeScript
963 lines
26 KiB
TypeScript
/**
|
|
* Gateway Mock 工具
|
|
* 模拟后端 Gateway 服务,用于独立测试前端功能
|
|
* 基于实际 API 端点: http://127.0.0.1:50051
|
|
*/
|
|
|
|
import { Page, WebSocketRoute } from '@playwright/test';
|
|
|
|
/**
|
|
* Mock 响应数据模板 - 基于实际 API 响应格式
|
|
*/
|
|
export const mockResponses = {
|
|
// 健康检查
|
|
health: {
|
|
status: 'ok',
|
|
version: '0.4.0-mock',
|
|
},
|
|
|
|
// 模型列表 - 来自 /api/models
|
|
models: [
|
|
{ id: 'claude-3-opus-20240229', name: 'Claude 3 Opus', provider: 'anthropic' },
|
|
{ id: 'claude-3-sonnet-20240229', name: 'Claude 3 Sonnet', provider: 'anthropic' },
|
|
{ id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku', provider: 'anthropic' },
|
|
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'anthropic' },
|
|
{ id: 'gpt-4o', name: 'GPT-4o', provider: 'openai' },
|
|
],
|
|
|
|
// Agent/分身列表 - 来自 /api/agents
|
|
agents: [
|
|
{
|
|
id: 'default-agent',
|
|
name: 'ZCLAW',
|
|
role: 'AI Assistant',
|
|
nickname: 'ZCLAW',
|
|
model: 'claude-sonnet-4-20250514',
|
|
createdAt: new Date().toISOString(),
|
|
bootstrapReady: true,
|
|
onboardingCompleted: true,
|
|
},
|
|
],
|
|
|
|
// Hands 列表 - 来自 /api/hands
|
|
hands: [
|
|
{
|
|
id: 'browser',
|
|
name: 'Browser',
|
|
description: '浏览器自动化能力包',
|
|
status: 'idle',
|
|
requirements_met: true,
|
|
category: 'automation',
|
|
icon: '🌐',
|
|
tool_count: 4,
|
|
metric_count: 0,
|
|
},
|
|
{
|
|
id: 'collector',
|
|
name: 'Collector',
|
|
description: '数据收集聚合能力包',
|
|
status: 'idle',
|
|
requirements_met: true,
|
|
category: 'data',
|
|
icon: '📊',
|
|
tool_count: 3,
|
|
metric_count: 2,
|
|
},
|
|
{
|
|
id: 'researcher',
|
|
name: 'Researcher',
|
|
description: '深度研究能力包',
|
|
status: 'idle',
|
|
requirements_met: true,
|
|
category: 'research',
|
|
icon: '🔍',
|
|
tool_count: 5,
|
|
metric_count: 1,
|
|
},
|
|
{
|
|
id: 'predictor',
|
|
name: 'Predictor',
|
|
description: '预测分析能力包',
|
|
status: 'setup_needed',
|
|
requirements_met: false,
|
|
category: 'analytics',
|
|
icon: '📈',
|
|
tool_count: 2,
|
|
metric_count: 3,
|
|
},
|
|
],
|
|
|
|
// 工作流列表 - 来自 /api/workflows
|
|
workflows: [
|
|
{
|
|
id: 'wf-default',
|
|
name: '示例工作流',
|
|
description: '演示用工作流',
|
|
steps: [],
|
|
status: 'idle',
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
],
|
|
|
|
// 触发器列表 - 来自 /api/triggers
|
|
triggers: [
|
|
{
|
|
id: 'trigger-1',
|
|
type: 'webhook',
|
|
name: '示例触发器',
|
|
enabled: true,
|
|
},
|
|
],
|
|
|
|
// 技能列表 - 来自 /api/skills
|
|
skills: [
|
|
{
|
|
id: 'skill-code-review',
|
|
name: '代码审查',
|
|
description: '自动审查代码质量',
|
|
category: 'development',
|
|
triggers: ['review', 'audit'],
|
|
installed: true,
|
|
},
|
|
{
|
|
id: 'skill-doc-gen',
|
|
name: '文档生成',
|
|
description: '自动生成代码文档',
|
|
category: 'development',
|
|
triggers: ['doc', 'document'],
|
|
installed: false,
|
|
},
|
|
],
|
|
|
|
// 审批列表 - 来自 /api/approvals
|
|
approvals: [],
|
|
|
|
// 会话列表 - 来自 /api/sessions
|
|
sessions: [],
|
|
|
|
// 用量统计 - 来自 /api/stats/usage
|
|
usageStats: {
|
|
totalSessions: 10,
|
|
totalMessages: 100,
|
|
totalTokens: 50000,
|
|
byModel: {
|
|
'claude-sonnet-4-20250514': { messages: 60, inputTokens: 20000, outputTokens: 15000 },
|
|
'claude-3-haiku-20240307': { messages: 40, inputTokens: 10000, outputTokens: 5000 },
|
|
},
|
|
},
|
|
|
|
// 插件状态 - 来自 /api/plugins/status
|
|
pluginStatus: [
|
|
{ id: 'mcp-filesystem', name: 'Filesystem MCP', status: 'active', version: '1.0.0' },
|
|
{ id: 'mcp-github', name: 'GitHub MCP', status: 'inactive' },
|
|
],
|
|
|
|
// 快速配置 - 来自 /api/config
|
|
quickConfig: {
|
|
userName: 'User',
|
|
userRole: 'Developer',
|
|
defaultModel: 'claude-sonnet-4-20250514',
|
|
dataDir: './data',
|
|
workspaceDir: './workspace',
|
|
},
|
|
|
|
// 工作区信息 - 来自 /api/workspace
|
|
workspace: {
|
|
path: './workspace',
|
|
exists: true,
|
|
},
|
|
|
|
// 安全状态 - 来自 /api/security/status
|
|
securityStatus: {
|
|
status: 'secure',
|
|
lastAudit: new Date().toISOString(),
|
|
},
|
|
|
|
// 调度任务 - 来自 /api/scheduler/tasks
|
|
scheduledTasks: [],
|
|
|
|
// 能力列表 - 来自 /api/capabilities
|
|
capabilities: {
|
|
hands: ['browser', 'collector', 'researcher', 'predictor'],
|
|
models: ['claude-sonnet-4-20250514', 'claude-3-haiku-20240307'],
|
|
plugins: ['mcp-filesystem', 'mcp-github'],
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 创建 Agent 消息响应 - 来自 POST /api/agents/{id}/message
|
|
*/
|
|
export function createAgentMessageResponse(content: string): object {
|
|
return {
|
|
response: content,
|
|
input_tokens: 100,
|
|
output_tokens: 50,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 创建流式响应数据块 - 用于 WebSocket
|
|
*/
|
|
export function createStreamChunks(text: string, chunkSize = 10): Array<{ delta: string; phase: string }> {
|
|
const chunks: Array<{ delta: string; phase: string }> = [];
|
|
const words = text.split(' ');
|
|
|
|
// 开始标记
|
|
chunks.push({ delta: '', phase: 'start' });
|
|
|
|
// 内容块
|
|
let current = '';
|
|
for (const word of words) {
|
|
current += (current ? ' ' : '') + word;
|
|
if (current.length >= chunkSize) {
|
|
chunks.push({ delta: current, phase: 'delta' });
|
|
current = '';
|
|
}
|
|
}
|
|
if (current) {
|
|
chunks.push({ delta: current, phase: 'delta' });
|
|
}
|
|
|
|
// 结束标记
|
|
chunks.push({ delta: '', phase: 'end' });
|
|
|
|
return chunks;
|
|
}
|
|
|
|
/**
|
|
* Gateway Mock 配置
|
|
*/
|
|
export interface MockGatewayConfig {
|
|
/** 是否模拟延迟 */
|
|
simulateDelay?: boolean;
|
|
/** 延迟时间 (ms) */
|
|
delayMs?: number;
|
|
/** 是否模拟错误 */
|
|
simulateError?: boolean;
|
|
/** 错误率 (0-1) */
|
|
errorRate?: number;
|
|
/** 自定义响应覆盖 */
|
|
customResponses?: Partial<typeof mockResponses>;
|
|
}
|
|
|
|
/**
|
|
* 设置完整的 Gateway Mock
|
|
* 基于实际 API 端点: http://127.0.0.1:50051/api/*
|
|
*/
|
|
export async function setupMockGateway(
|
|
page: Page,
|
|
config: MockGatewayConfig = {}
|
|
): Promise<void> {
|
|
const {
|
|
simulateDelay = false,
|
|
delayMs = 100,
|
|
simulateError = false,
|
|
errorRate = 0,
|
|
customResponses = {},
|
|
} = config;
|
|
|
|
// 合并默认响应和自定义响应
|
|
const responses = { ...mockResponses, ...customResponses };
|
|
|
|
// ========================================
|
|
// 系统端点
|
|
// ========================================
|
|
|
|
// Mock 健康检查 - GET /api/health
|
|
await page.route('**/api/health', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
if (shouldError(simulateError, errorRate)) {
|
|
await route.fulfill({ status: 500, body: 'Internal Server Error' });
|
|
return;
|
|
}
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(responses.health),
|
|
});
|
|
});
|
|
|
|
// Mock 模型列表 - GET /api/models
|
|
await page.route('**/api/models', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(responses.models),
|
|
});
|
|
});
|
|
|
|
// Mock 能力列表 - GET /api/capabilities
|
|
await page.route('**/api/capabilities', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(responses.capabilities),
|
|
});
|
|
});
|
|
|
|
// Mock 工作区 - GET /api/workspace
|
|
await page.route('**/api/workspace', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(responses.workspace),
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// Agent 端点 (分身管理)
|
|
// ========================================
|
|
|
|
// Mock Agent 列表 - GET /api/agents
|
|
await page.route('**/api/agents', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
const method = route.request().method();
|
|
|
|
if (method === 'GET') {
|
|
// 返回格式: { agents: [...] } 或 { clones: [...] }
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ agents: responses.agents }),
|
|
});
|
|
} else if (method === 'POST') {
|
|
// 创建 Agent
|
|
const body = route.request().postDataJSON();
|
|
const newAgent = {
|
|
id: `agent-${Date.now()}`,
|
|
name: body.name || body.manifest_toml?.match(/name\s*=\s*"([^"]+)"/)?.[1] || 'New Agent',
|
|
role: body.role || 'Assistant',
|
|
createdAt: new Date().toISOString(),
|
|
bootstrapReady: false,
|
|
};
|
|
responses.agents.push(newAgent as any);
|
|
await route.fulfill({
|
|
status: 201,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(newAgent),
|
|
});
|
|
}
|
|
});
|
|
|
|
// Mock Agent 详情/更新/删除 - /api/agents/{id}
|
|
await page.route('**/api/agents/*', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
const method = route.request().method();
|
|
const url = route.request().url();
|
|
|
|
// 排除 message 和 ws 端点
|
|
if (url.includes('/message') || url.includes('/ws')) {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
|
|
if (method === 'PUT') {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ success: true }),
|
|
});
|
|
} else if (method === 'DELETE') {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ success: true }),
|
|
});
|
|
} else {
|
|
await route.continue();
|
|
}
|
|
});
|
|
|
|
// Mock Agent 消息 - POST /api/agents/{id}/message
|
|
await page.route('**/api/agents/*/message', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
if (route.request().method() === 'POST') {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(createAgentMessageResponse('这是一个模拟响应。')),
|
|
});
|
|
}
|
|
});
|
|
|
|
// ========================================
|
|
// Hands 端点
|
|
// ========================================
|
|
|
|
// Mock Hands 列表 - GET /api/hands
|
|
await page.route('**/api/hands', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ hands: responses.hands }),
|
|
});
|
|
});
|
|
|
|
// Mock Hand 详情 - GET /api/hands/{name}
|
|
await page.route('**/api/hands/*', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
const url = route.request().url();
|
|
|
|
// 排除 activate, runs 等子端点
|
|
if (url.includes('/activate') || url.includes('/runs')) {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
|
|
const handName = url.split('/hands/')[1]?.split('?')[0].split('/')[0];
|
|
const hand = responses.hands.find(h => h.name.toLowerCase() === handName?.toLowerCase());
|
|
|
|
if (hand) {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(hand),
|
|
});
|
|
} else {
|
|
await route.fulfill({
|
|
status: 404,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ error: 'Hand not found' }),
|
|
});
|
|
}
|
|
});
|
|
|
|
// Mock Hand 激活/触发 - POST /api/hands/{name}/activate
|
|
await page.route('**/api/hands/*/activate', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
runId: `run-${Date.now()}`,
|
|
status: 'running',
|
|
}),
|
|
});
|
|
});
|
|
|
|
// Mock Hand 运行状态 - GET /api/hands/{name}/runs/{runId}
|
|
await page.route('**/api/hands/*/runs/**', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
const method = route.request().method();
|
|
const url = route.request().url();
|
|
|
|
if (url.includes('/approve') && method === 'POST') {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ status: 'approved' }),
|
|
});
|
|
} else if (url.includes('/cancel') && method === 'POST') {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ status: 'cancelled' }),
|
|
});
|
|
} else if (method === 'GET') {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
runId: `run-${Date.now()}`,
|
|
status: 'completed',
|
|
startedAt: new Date().toISOString(),
|
|
completedAt: new Date().toISOString(),
|
|
}),
|
|
});
|
|
} else {
|
|
// Fallback for any other requests
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ status: 'ok' }),
|
|
});
|
|
}
|
|
});
|
|
|
|
// Mock Hand 运行历史 - GET /api/hands/{name}/runs
|
|
await page.route('**/api/hands/*/runs', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ runs: [] }),
|
|
});
|
|
});
|
|
|
|
// Mock Hand 审批 - POST /api/hands/{name}/runs/{runId}/approve
|
|
await page.route('**/api/hands/*/runs/*/approve', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ status: 'approved' }),
|
|
});
|
|
});
|
|
|
|
// Mock Hand 取消 - POST /api/hands/{name}/runs/{runId}/cancel
|
|
await page.route('**/api/hands/*/runs/*/cancel', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ status: 'cancelled' }),
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// Workflow 端点
|
|
// ========================================
|
|
|
|
// Mock 工作流列表 - GET /api/workflows
|
|
await page.route('**/api/workflows', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
const method = route.request().method();
|
|
|
|
if (method === 'GET') {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ workflows: responses.workflows }),
|
|
});
|
|
} else if (method === 'POST') {
|
|
const body = route.request().postDataJSON();
|
|
const newWorkflow = {
|
|
id: `wf-${Date.now()}`,
|
|
...body,
|
|
status: 'idle',
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
responses.workflows.push(newWorkflow as any);
|
|
await route.fulfill({
|
|
status: 201,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(newWorkflow),
|
|
});
|
|
}
|
|
});
|
|
|
|
// Mock 工作流执行 - POST /api/workflows/{id}/execute
|
|
await page.route('**/api/workflows/*/execute', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
runId: `wf-run-${Date.now()}`,
|
|
status: 'running',
|
|
}),
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// Trigger 端点
|
|
// ========================================
|
|
|
|
// Mock 触发器列表 - GET /api/triggers
|
|
await page.route('**/api/triggers', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
const method = route.request().method();
|
|
|
|
if (method === 'GET') {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ triggers: responses.triggers }),
|
|
});
|
|
} else if (method === 'POST') {
|
|
const body = route.request().postDataJSON();
|
|
const newTrigger = {
|
|
id: `trigger-${Date.now()}`,
|
|
...body,
|
|
};
|
|
responses.triggers.push(newTrigger as any);
|
|
await route.fulfill({
|
|
status: 201,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(newTrigger),
|
|
});
|
|
}
|
|
});
|
|
|
|
// ========================================
|
|
// Approval 端点
|
|
// ========================================
|
|
|
|
// Mock 审批列表 - GET /api/approvals
|
|
await page.route('**/api/approvals', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ approvals: responses.approvals }),
|
|
});
|
|
});
|
|
|
|
// Mock 审批响应 - POST /api/approvals/{id}/respond
|
|
await page.route('**/api/approvals/*/respond', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ status: 'responded' }),
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// Session 端点
|
|
// ========================================
|
|
|
|
// Mock 会话列表 - GET /api/sessions
|
|
await page.route('**/api/sessions', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ sessions: responses.sessions }),
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// Skill 端点
|
|
// ========================================
|
|
|
|
// Mock 技能列表 - GET /api/skills
|
|
await page.route('**/api/skills', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ skills: responses.skills }),
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// Stats 端点
|
|
// ========================================
|
|
|
|
// Mock 用量统计 - GET /api/stats/usage
|
|
await page.route('**/api/stats/usage', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(responses.usageStats),
|
|
});
|
|
});
|
|
|
|
// Mock 会话统计 - GET /api/stats/sessions
|
|
await page.route('**/api/stats/sessions', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ total: 10, active: 2 }),
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// Config 端点
|
|
// ========================================
|
|
|
|
// Mock 配置 - GET/PUT /api/config
|
|
await page.route('**/api/config', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
const method = route.request().method();
|
|
|
|
if (method === 'GET') {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(responses.quickConfig),
|
|
});
|
|
} else if (method === 'PUT') {
|
|
const body = route.request().postDataJSON();
|
|
Object.assign(responses.quickConfig, body);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(responses.quickConfig),
|
|
});
|
|
}
|
|
});
|
|
|
|
// ========================================
|
|
// Plugin 端点
|
|
// ========================================
|
|
|
|
// Mock 插件状态 - GET /api/plugins/status
|
|
await page.route('**/api/plugins/status', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(responses.pluginStatus),
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// Security 端点
|
|
// ========================================
|
|
|
|
// Mock 安全状态 - GET /api/security/status
|
|
await page.route('**/api/security/status', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(responses.securityStatus),
|
|
});
|
|
});
|
|
|
|
// ========================================
|
|
// Scheduler 端点
|
|
// ========================================
|
|
|
|
// Mock 调度任务 - GET /api/scheduler/tasks
|
|
await page.route('**/api/scheduler/tasks', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
const method = route.request().method();
|
|
|
|
if (method === 'GET') {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ tasks: responses.scheduledTasks }),
|
|
});
|
|
} else if (method === 'POST') {
|
|
const body = route.request().postDataJSON();
|
|
const newTask = {
|
|
id: `task-${Date.now()}`,
|
|
...body,
|
|
};
|
|
responses.scheduledTasks.push(newTask as any);
|
|
await route.fulfill({
|
|
status: 201,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(newTask),
|
|
});
|
|
}
|
|
});
|
|
|
|
// ========================================
|
|
// Audit 端点
|
|
// ========================================
|
|
|
|
// Mock 审计日志 - GET /api/audit/logs
|
|
await page.route('**/api/audit/logs', async (route) => {
|
|
if (simulateDelay) await delay(delayMs);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ logs: [], total: 0 }),
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mock Agent 消息响应(非流式)
|
|
*/
|
|
export async function mockAgentMessageResponse(page: Page, response: string): Promise<void> {
|
|
await page.route('**/api/agents/*/message', async (route) => {
|
|
if (route.request().method() === 'POST') {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(createAgentMessageResponse(response)),
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mock 错误响应
|
|
*/
|
|
export async function mockErrorResponse(
|
|
page: Page,
|
|
path: string,
|
|
status: number,
|
|
message: string
|
|
): Promise<void> {
|
|
await page.route(`**/api/${path}**`, async (route) => {
|
|
await route.fulfill({
|
|
status,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
error: true,
|
|
message,
|
|
}),
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mock 网络超时
|
|
*/
|
|
export async function mockTimeout(page: Page, path: string): Promise<void> {
|
|
await page.route(`**/api/${path}**`, async () => {
|
|
// 永不响应,模拟超时
|
|
await new Promise(() => {});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* WebSocket Mock 配置
|
|
*/
|
|
export interface MockWebSocketConfig {
|
|
/** 模拟响应内容 */
|
|
responseContent?: string;
|
|
/** 是否模拟流式响应 */
|
|
streaming?: boolean;
|
|
/** 流式响应的块延迟 (ms) */
|
|
chunkDelay?: number;
|
|
/** 是否模拟错误 */
|
|
simulateError?: boolean;
|
|
/** 错误消息 */
|
|
errorMessage?: string;
|
|
}
|
|
|
|
/**
|
|
* 存储 WebSocket Mock 配置
|
|
*/
|
|
let wsConfig: MockWebSocketConfig = {
|
|
responseContent: 'This is a mock streaming response from the WebSocket server.',
|
|
streaming: true,
|
|
chunkDelay: 50,
|
|
};
|
|
|
|
/**
|
|
* 设置 WebSocket Mock 配置
|
|
*/
|
|
export function setWebSocketConfig(config: Partial<MockWebSocketConfig>): void {
|
|
wsConfig = { ...wsConfig, ...config };
|
|
}
|
|
|
|
/**
|
|
* Mock Agent WebSocket 流式响应
|
|
* 使用 Playwright 的 routeWebSocket API 拦截 WebSocket 连接
|
|
*/
|
|
export async function mockAgentWebSocket(
|
|
page: Page,
|
|
config: Partial<MockWebSocketConfig> = {}
|
|
): Promise<void> {
|
|
const finalConfig = { ...wsConfig, ...config };
|
|
|
|
await page.routeWebSocket('**/api/agents/*/ws', async (ws: WebSocketRoute) => {
|
|
// Handle incoming messages from the page
|
|
ws.onMessage(async (message) => {
|
|
try {
|
|
const data = JSON.parse(message);
|
|
|
|
// Handle chat message
|
|
if (data.type === 'message' || data.content) {
|
|
// Send connected event first
|
|
ws.send(JSON.stringify({
|
|
type: 'connected',
|
|
agent_id: 'default-agent',
|
|
}));
|
|
|
|
// Simulate error if configured
|
|
if (finalConfig.simulateError) {
|
|
ws.send(JSON.stringify({
|
|
type: 'error',
|
|
message: finalConfig.errorMessage || 'Mock WebSocket error',
|
|
}));
|
|
ws.close({ code: 1011, reason: 'Error' });
|
|
return;
|
|
}
|
|
|
|
const responseText = finalConfig.responseContent || 'Mock response';
|
|
|
|
if (finalConfig.streaming) {
|
|
// Send typing indicator
|
|
ws.send(JSON.stringify({
|
|
type: 'typing',
|
|
state: 'start',
|
|
}));
|
|
|
|
// Stream response in chunks
|
|
const words = responseText.split(' ');
|
|
let current = '';
|
|
|
|
for (let i = 0; i < words.length; i++) {
|
|
current += (current ? ' ' : '') + words[i];
|
|
|
|
// Send text delta every few words
|
|
if (current.length >= 10 || i === words.length - 1) {
|
|
await new Promise(resolve => setTimeout(resolve, finalConfig.chunkDelay || 50));
|
|
ws.send(JSON.stringify({
|
|
type: 'text_delta',
|
|
content: current,
|
|
}));
|
|
current = '';
|
|
}
|
|
}
|
|
|
|
// Send typing stop
|
|
ws.send(JSON.stringify({
|
|
type: 'typing',
|
|
state: 'stop',
|
|
}));
|
|
|
|
// Send phase done
|
|
ws.send(JSON.stringify({
|
|
type: 'phase',
|
|
phase: 'done',
|
|
}));
|
|
} else {
|
|
// Non-streaming response
|
|
ws.send(JSON.stringify({
|
|
type: 'response',
|
|
content: responseText,
|
|
input_tokens: 100,
|
|
output_tokens: responseText.split(' ').length,
|
|
}));
|
|
}
|
|
|
|
// Close connection after response
|
|
ws.close({ code: 1000, reason: 'Stream complete' });
|
|
}
|
|
} catch (err) {
|
|
console.error('WebSocket mock error:', err);
|
|
ws.send(JSON.stringify({
|
|
type: 'error',
|
|
message: 'Failed to parse message',
|
|
}));
|
|
}
|
|
});
|
|
|
|
// Handle connection close from page
|
|
ws.onClose(() => {
|
|
// Clean up
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 设置完整的 Gateway Mock (包括 WebSocket)
|
|
*/
|
|
export async function setupMockGatewayWithWebSocket(
|
|
page: Page,
|
|
config: MockGatewayConfig & { wsConfig?: Partial<MockWebSocketConfig> } = {}
|
|
): Promise<void> {
|
|
// Setup HTTP mocks
|
|
await setupMockGateway(page, config);
|
|
|
|
// Setup WebSocket mock
|
|
await mockAgentWebSocket(page, config.wsConfig || {});
|
|
}
|
|
|
|
// 辅助函数
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function shouldError(simulate: boolean, rate: number): boolean {
|
|
if (!simulate) return false;
|
|
return Math.random() < rate;
|
|
}
|