docs(guide): rewrite CLAUDE.md with ZCLAW-first perspective
Major changes: - Shift from "OpenFang desktop client" to "independent AI Agent desktop app" - Add decision principle: "Is this useful for ZCLAW? Does it affect ZCLAW?" - Simplify project structure and tech stack sections - Replace OpenClaw vs OpenFang comparison with unified backend approach - Consolidate troubleshooting from scattered sections into organized FAQ - Update Hands system documentation with 8 capabilities and status - Stream
This commit is contained in:
788
desktop/tests/e2e/fixtures/mock-gateway.ts
Normal file
788
desktop/tests/e2e/fixtures/mock-gateway.ts
Normal file
@@ -0,0 +1,788 @@
|
||||
/**
|
||||
* Gateway Mock 工具
|
||||
* 模拟后端 Gateway 服务,用于独立测试前端功能
|
||||
* 基于实际 API 端点: http://127.0.0.1:50051
|
||||
*/
|
||||
|
||||
import { Page } 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(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 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: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// 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(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
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;
|
||||
}
|
||||
635
desktop/tests/e2e/fixtures/store-inspectors.ts
Normal file
635
desktop/tests/e2e/fixtures/store-inspectors.ts
Normal file
@@ -0,0 +1,635 @@
|
||||
/**
|
||||
* Store 状态检查工具
|
||||
* 用于验证 Zustand Store 的状态变化
|
||||
*
|
||||
* 实际 localStorage keys:
|
||||
* - chatStore: zclaw-chat-storage (持久化)
|
||||
* - teamStore: zclaw-teams (持久化)
|
||||
* - gatewayStore: zclaw-gateway-url, zclaw-gateway-token, zclaw-device-id (单独键)
|
||||
* - handStore: 不持久化
|
||||
* - agentStore: 不持久化
|
||||
*/
|
||||
|
||||
import { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* localStorage key 映射
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
// 标准持久化 Store (使用 zustand persist)
|
||||
CHAT: 'zclaw-chat-storage',
|
||||
TEAMS: 'zclaw-teams',
|
||||
|
||||
// Gateway Store 使用单独的键
|
||||
GATEWAY: 'zclaw-gateway-url', // 主键
|
||||
GATEWAY_URL: 'zclaw-gateway-url',
|
||||
GATEWAY_TOKEN: 'zclaw-gateway-token',
|
||||
DEVICE_ID: 'zclaw-device-id',
|
||||
|
||||
// 非持久化 Store (运行时状态,不在 localStorage)
|
||||
HAND: null,
|
||||
AGENT: null,
|
||||
WORKFLOW: null,
|
||||
CONNECTION: null,
|
||||
CONFIG: null,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Store 名称类型
|
||||
*/
|
||||
export type StoreName = keyof typeof STORAGE_KEYS;
|
||||
|
||||
/**
|
||||
* 向后兼容: STORE_NAMES 枚举
|
||||
* @deprecated 使用 STORAGE_KEYS 代替
|
||||
*/
|
||||
export const STORE_NAMES = {
|
||||
CHAT: 'CHAT' as StoreName,
|
||||
GATEWAY: 'GATEWAY' as StoreName,
|
||||
AGENT: 'AGENT' as StoreName,
|
||||
HAND: 'HAND' as StoreName,
|
||||
WORKFLOW: 'WORKFLOW' as StoreName,
|
||||
CONFIG: 'CONFIG' as StoreName,
|
||||
TEAM: 'TEAMS' as StoreName,
|
||||
CONNECTION: 'CONNECTION' as StoreName,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Store 状态检查工具
|
||||
*/
|
||||
export const storeInspectors = {
|
||||
/**
|
||||
* 获取 localStorage key
|
||||
*/
|
||||
getStorageKey(storeName: StoreName): string | null {
|
||||
return STORAGE_KEYS[storeName];
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取持久化的 Chat Store 状态
|
||||
*/
|
||||
async getChatState<T = unknown>(page: Page): Promise<T | null> {
|
||||
return page.evaluate((key) => {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed.state as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, STORAGE_KEYS.CHAT);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取持久化的 Teams Store 状态
|
||||
*/
|
||||
async getTeamsState<T = unknown>(page: Page): Promise<T | null> {
|
||||
return page.evaluate((key) => {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return null;
|
||||
try {
|
||||
// teams store 可能直接存储数组或对象
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed.state ? parsed.state : parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, STORAGE_KEYS.TEAMS);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 Gateway 配置
|
||||
*/
|
||||
async getGatewayConfig(page: Page): Promise<{
|
||||
url: string | null;
|
||||
token: string | null;
|
||||
deviceId: string | null;
|
||||
}> {
|
||||
return page.evaluate((keys) => {
|
||||
return {
|
||||
url: localStorage.getItem(keys.url),
|
||||
token: localStorage.getItem(keys.token),
|
||||
deviceId: localStorage.getItem(keys.deviceId),
|
||||
};
|
||||
}, {
|
||||
url: STORAGE_KEYS.GATEWAY_URL,
|
||||
token: STORAGE_KEYS.GATEWAY_TOKEN,
|
||||
deviceId: STORAGE_KEYS.DEVICE_ID,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取持久化的 Store 状态 (通用方法)
|
||||
*/
|
||||
async getPersistedState<T = unknown>(page: Page, storeName: StoreName): Promise<T | null> {
|
||||
const key = STORAGE_KEYS[storeName];
|
||||
|
||||
if (!key) {
|
||||
// 非持久化 Store,尝试从运行时获取
|
||||
return this.getRuntimeState<T>(page, storeName);
|
||||
}
|
||||
|
||||
return page.evaluate((storageKey) => {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (!stored) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed.state ? (parsed.state as T) : (parsed as T);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, key);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取运行时 Store 状态 (通过 window 全局变量)
|
||||
* 注意:需要在应用中暴露 store 到 window 对象
|
||||
*/
|
||||
async getRuntimeState<T = unknown>(page: Page, storeName: string): Promise<T | null> {
|
||||
return page.evaluate((name) => {
|
||||
// 尝试从 window.__ZCLAW_STORES__ 获取
|
||||
const stores = (window as any).__ZCLAW_STORES__;
|
||||
if (stores && stores[name]) {
|
||||
return stores[name].getState() as T;
|
||||
}
|
||||
return null;
|
||||
}, storeName);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取整个持久化对象(包含 state 和 version)
|
||||
*/
|
||||
async getFullStorage<T = unknown>(
|
||||
page: Page,
|
||||
storeName: StoreName
|
||||
): Promise<{ state: T; version: number } | null> {
|
||||
const key = STORAGE_KEYS[storeName];
|
||||
if (!key) return null;
|
||||
|
||||
return page.evaluate((storageKey) => {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (!stored) return null;
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, key);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 Store 中的特定字段
|
||||
*/
|
||||
async getStateField<T = unknown>(
|
||||
page: Page,
|
||||
storeName: StoreName,
|
||||
fieldPath: string
|
||||
): Promise<T | null> {
|
||||
const key = STORAGE_KEYS[storeName];
|
||||
if (!key) return null;
|
||||
|
||||
return page.evaluate(
|
||||
({ storageKey, path }) => {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (!stored) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
const state = parsed.state ? parsed.state : parsed;
|
||||
const value = path.split('.').reduce((obj: any, key) => obj?.[key], state);
|
||||
return value as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{ storageKey: key, path: fieldPath }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 等待 Store 状态变化
|
||||
*/
|
||||
async waitForStateChange<T = unknown>(
|
||||
page: Page,
|
||||
storeName: StoreName,
|
||||
fieldPath: string,
|
||||
expectedValue: T,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
const key = STORAGE_KEYS[storeName];
|
||||
if (!key) {
|
||||
throw new Error(`Store ${storeName} is not persisted`);
|
||||
}
|
||||
|
||||
await page.waitForFunction(
|
||||
({ storageKey, path, expected }) => {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (!stored) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
const state = parsed.state ? parsed.state : parsed;
|
||||
const value = path.split('.').reduce((obj: any, key) => obj?.[key], state);
|
||||
return JSON.stringify(value) === JSON.stringify(expected);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
storageKey: key,
|
||||
path: fieldPath,
|
||||
expected: expectedValue,
|
||||
},
|
||||
{ timeout: options?.timeout ?? 5000 }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 等待 Store 中某个字段存在
|
||||
*/
|
||||
async waitForFieldExists(
|
||||
page: Page,
|
||||
storeName: StoreName,
|
||||
fieldPath: string,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
const key = STORAGE_KEYS[storeName];
|
||||
if (!key) return;
|
||||
|
||||
await page.waitForFunction(
|
||||
({ storageKey, path }) => {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (!stored) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
const state = parsed.state ? parsed.state : parsed;
|
||||
const value = path.split('.').reduce((obj: any, key) => obj?.[key], state);
|
||||
return value !== undefined && value !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ storageKey: key, path: fieldPath },
|
||||
{ timeout: options?.timeout ?? 5000 }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 等待消息数量变化
|
||||
*/
|
||||
async waitForMessageCount(
|
||||
page: Page,
|
||||
expectedCount: number,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
({ expected, key }) => {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed.state?.messages?.length === expected;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ expected: expectedCount, key: STORAGE_KEYS.CHAT },
|
||||
{ timeout: options?.timeout ?? 10000 }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除特定 Store 的持久化数据
|
||||
*/
|
||||
async clearStore(page: Page, storeName: StoreName): Promise<void> {
|
||||
const key = STORAGE_KEYS[storeName];
|
||||
if (key) {
|
||||
await page.evaluate((storageKey) => {
|
||||
localStorage.removeItem(storageKey);
|
||||
}, key);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除所有 Store 数据
|
||||
*/
|
||||
async clearAllStores(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const keys = Object.keys(localStorage);
|
||||
keys.forEach((key) => {
|
||||
if (key.startsWith('zclaw-')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置 Store 状态(用于测试初始化)
|
||||
*/
|
||||
async setStoreState<T>(page: Page, storeName: StoreName, state: T): Promise<void> {
|
||||
const key = STORAGE_KEYS[storeName];
|
||||
if (!key) return;
|
||||
|
||||
await page.evaluate(
|
||||
({ storageKey, stateObj }) => {
|
||||
const data = {
|
||||
state: stateObj,
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem(storageKey, JSON.stringify(data));
|
||||
},
|
||||
{ storageKey: key, stateObj: state }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置 Chat Store 状态
|
||||
*/
|
||||
async setChatState<T>(page: Page, state: T): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ key, stateObj }) => {
|
||||
const data = {
|
||||
state: stateObj,
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
},
|
||||
{ key: STORAGE_KEYS.CHAT, stateObj: state }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取所有 Store 状态快照
|
||||
*/
|
||||
async getAllStoresSnapshot(page: Page): Promise<Record<string, unknown>> {
|
||||
return page.evaluate(() => {
|
||||
const snapshot: Record<string, unknown> = {};
|
||||
const keys = Object.keys(localStorage);
|
||||
|
||||
keys.forEach((key) => {
|
||||
if (key.startsWith('zclaw-')) {
|
||||
const storeName = key.replace('zclaw-', '').replace('-storage', '');
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
snapshot[storeName] = parsed.state ? parsed.state : parsed;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return snapshot;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查 Store 是否持久化
|
||||
*/
|
||||
isPersistedStore(storeName: StoreName): boolean {
|
||||
return STORAGE_KEYS[storeName] !== null;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Store 断言工具
|
||||
*/
|
||||
export const storeAssertions = {
|
||||
/**
|
||||
* 断言 Chat Store 状态匹配预期
|
||||
*/
|
||||
async assertChatState<T>(
|
||||
page: Page,
|
||||
expected: Partial<T>
|
||||
): Promise<void> {
|
||||
const state = await storeInspectors.getChatState<T>(page);
|
||||
expect(state).not.toBeNull();
|
||||
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
expect(state).toHaveProperty(key, value);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Teams Store 状态匹配预期
|
||||
*/
|
||||
async assertTeamsState<T>(
|
||||
page: Page,
|
||||
expected: Partial<T>
|
||||
): Promise<void> {
|
||||
const state = await storeInspectors.getTeamsState<T>(page);
|
||||
expect(state).not.toBeNull();
|
||||
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
expect(state).toHaveProperty(key, value);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Store 字段值
|
||||
*/
|
||||
async assertFieldEquals<T>(
|
||||
page: Page,
|
||||
storeName: StoreName,
|
||||
fieldPath: string,
|
||||
expected: T
|
||||
): Promise<void> {
|
||||
const value = await storeInspectors.getStateField<T>(page, storeName, fieldPath);
|
||||
expect(value).toEqual(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言消息数量
|
||||
*/
|
||||
async assertMessageCount(page: Page, expected: number): Promise<void> {
|
||||
const state = await storeInspectors.getChatState<{ messages: unknown[] }>(page);
|
||||
expect(state?.messages?.length).toBe(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言最后一条消息内容
|
||||
*/
|
||||
async assertLastMessageContent(page: Page, expected: string): Promise<void> {
|
||||
const state = await storeInspectors.getChatState<{
|
||||
messages: Array<{ content: string }>;
|
||||
}>(page);
|
||||
const lastMessage = state?.messages?.[state.messages.length - 1];
|
||||
expect(lastMessage?.content).toContain(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Gateway 配置
|
||||
*/
|
||||
async assertGatewayConfig(
|
||||
page: Page,
|
||||
expected: { url?: string; token?: string; deviceId?: string }
|
||||
): Promise<void> {
|
||||
const config = await storeInspectors.getGatewayConfig(page);
|
||||
|
||||
if (expected.url !== undefined) {
|
||||
expect(config.url).toBe(expected.url);
|
||||
}
|
||||
if (expected.token !== undefined) {
|
||||
expect(config.token).toBe(expected.token);
|
||||
}
|
||||
if (expected.deviceId !== undefined) {
|
||||
expect(config.deviceId).toBe(expected.deviceId);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Teams 列表非空
|
||||
*/
|
||||
async assertTeamsNotEmpty(page: Page): Promise<void> {
|
||||
const state = await storeInspectors.getTeamsState<{ teams: unknown[] }>(page);
|
||||
expect(state?.teams?.length).toBeGreaterThan(0);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言当前 Agent
|
||||
*/
|
||||
async assertCurrentAgent(page: Page, agentId: string): Promise<void> {
|
||||
const state = await storeInspectors.getChatState<{
|
||||
currentAgent: { id: string };
|
||||
}>(page);
|
||||
expect(state?.currentAgent?.id).toBe(agentId);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 isStreaming 状态
|
||||
*/
|
||||
async assertStreamingState(page: Page, expected: boolean): Promise<void> {
|
||||
const state = await storeInspectors.getChatState<{ isStreaming: boolean }>(page);
|
||||
expect(state?.isStreaming).toBe(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言当前模型
|
||||
*/
|
||||
async assertCurrentModel(page: Page, expectedModel: string): Promise<void> {
|
||||
const state = await storeInspectors.getChatState<{ currentModel: string }>(page);
|
||||
expect(state?.currentModel).toBe(expectedModel);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言会话存在
|
||||
*/
|
||||
async assertConversationExists(page: Page, conversationId: string): Promise<void> {
|
||||
const state = await storeInspectors.getChatState<{
|
||||
conversations: Array<{ id: string }>;
|
||||
}>(page);
|
||||
const exists = state?.conversations?.some(c => c.id === conversationId);
|
||||
expect(exists).toBe(true);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 类型定义 - Chat Store 状态
|
||||
*/
|
||||
export interface ChatStoreState {
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
streaming?: boolean;
|
||||
error?: string;
|
||||
runId?: string;
|
||||
toolName?: string;
|
||||
toolInput?: string;
|
||||
toolOutput?: string;
|
||||
handName?: string;
|
||||
handStatus?: string;
|
||||
handResult?: unknown;
|
||||
workflowId?: string;
|
||||
workflowStep?: string;
|
||||
workflowStatus?: string;
|
||||
workflowResult?: unknown;
|
||||
}>;
|
||||
conversations: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
messages: string[];
|
||||
sessionKey: string | null;
|
||||
agentId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
currentConversationId: string | null;
|
||||
currentAgent: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
lastMessage: string;
|
||||
time: string;
|
||||
} | null;
|
||||
isStreaming: boolean;
|
||||
currentModel: string;
|
||||
sessionKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型定义 - Team Store 状态
|
||||
*/
|
||||
export interface TeamStoreState {
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
members: Array<{
|
||||
id: string;
|
||||
agentId: string;
|
||||
name: string;
|
||||
role: 'orchestrator' | 'reviewer' | 'worker';
|
||||
skills: string[];
|
||||
workload: number;
|
||||
status: 'idle' | 'working' | 'offline';
|
||||
maxConcurrentTasks: number;
|
||||
currentTasks: string[];
|
||||
}>;
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'pending' | 'assigned' | 'in_progress' | 'review' | 'completed';
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
assigneeId?: string;
|
||||
dependencies: string[];
|
||||
type: string;
|
||||
estimate?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
pattern: 'sequential' | 'parallel' | 'pipeline';
|
||||
activeLoops: Array<{
|
||||
id: string;
|
||||
developerId: string;
|
||||
reviewerId: string;
|
||||
taskId: string;
|
||||
state: 'developing' | 'revising' | 'reviewing' | 'approved' | 'escalated';
|
||||
iterationCount: number;
|
||||
maxIterations: number;
|
||||
}>;
|
||||
status: 'active' | 'paused' | 'completed';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
activeTeam: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
metrics: {
|
||||
tasksCompleted: number;
|
||||
avgCompletionTime: number;
|
||||
passRate: number;
|
||||
avgIterations: number;
|
||||
escalations: number;
|
||||
efficiency: number;
|
||||
} | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
459
desktop/tests/e2e/fixtures/test-data.ts
Normal file
459
desktop/tests/e2e/fixtures/test-data.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* 测试数据工厂
|
||||
* 生成一致的测试数据,确保测试可重复性
|
||||
*/
|
||||
|
||||
/**
|
||||
* 生成唯一 ID
|
||||
*/
|
||||
export function generateId(prefix = 'id'): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成时间戳
|
||||
*/
|
||||
export function generateTimestamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试消息工厂
|
||||
*/
|
||||
export const messageFactory = {
|
||||
/**
|
||||
* 创建用户消息
|
||||
*/
|
||||
createUser(content: string, options?: { id?: string; timestamp?: string }) {
|
||||
return {
|
||||
id: options?.id ?? generateId('msg'),
|
||||
role: 'user' as const,
|
||||
content,
|
||||
timestamp: options?.timestamp ?? generateTimestamp(),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建助手消息
|
||||
*/
|
||||
createAssistant(content: string, options?: {
|
||||
id?: string;
|
||||
timestamp?: string;
|
||||
streaming?: boolean;
|
||||
model?: string;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('msg'),
|
||||
role: 'assistant' as const,
|
||||
content,
|
||||
timestamp: options?.timestamp ?? generateTimestamp(),
|
||||
streaming: options?.streaming ?? false,
|
||||
model: options?.model ?? 'claude-3-sonnet',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建工具消息
|
||||
*/
|
||||
createTool(toolName: string, input: unknown, output: unknown) {
|
||||
return {
|
||||
id: generateId('msg'),
|
||||
role: 'tool' as const,
|
||||
content: '',
|
||||
timestamp: generateTimestamp(),
|
||||
toolName,
|
||||
toolInput: JSON.stringify(input),
|
||||
toolOutput: JSON.stringify(output),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建 Hand 消息
|
||||
*/
|
||||
createHand(handName: string, status: string, result?: unknown) {
|
||||
return {
|
||||
id: generateId('msg'),
|
||||
role: 'hand' as const,
|
||||
content: '',
|
||||
timestamp: generateTimestamp(),
|
||||
handName,
|
||||
handStatus: status,
|
||||
handResult: result,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建 Workflow 消息
|
||||
*/
|
||||
createWorkflow(workflowId: string, step: string, status: string) {
|
||||
return {
|
||||
id: generateId('msg'),
|
||||
role: 'workflow' as const,
|
||||
content: '',
|
||||
timestamp: generateTimestamp(),
|
||||
workflowId,
|
||||
workflowStep: step,
|
||||
workflowStatus: status,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建消息列表
|
||||
*/
|
||||
createConversation(messages: Array<{ role: string; content: string }>) {
|
||||
return messages.map((m) => {
|
||||
if (m.role === 'user') {
|
||||
return this.createUser(m.content);
|
||||
}
|
||||
return this.createAssistant(m.content);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试分身工厂
|
||||
*/
|
||||
export const cloneFactory = {
|
||||
/**
|
||||
* 创建分身
|
||||
*/
|
||||
create(options?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
model?: string;
|
||||
workspaceDir?: string;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('clone'),
|
||||
name: options?.name ?? `测试分身-${Date.now()}`,
|
||||
role: options?.role ?? 'AI Assistant',
|
||||
model: options?.model ?? 'claude-3-sonnet',
|
||||
workspaceDir: options?.workspaceDir ?? '/tmp/workspace',
|
||||
createdAt: generateTimestamp(),
|
||||
onboardingCompleted: true,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建多个分身
|
||||
*/
|
||||
createMany(count: number) {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
this.create({
|
||||
name: `分身-${i + 1}`,
|
||||
role: i === 0 ? 'Main Assistant' : `Specialist ${i + 1}`,
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试 Hand 工厂
|
||||
*/
|
||||
export const handFactory = {
|
||||
/**
|
||||
* 创建 Hand
|
||||
*/
|
||||
create(options?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
category?: string;
|
||||
requirementsMet?: boolean;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('hand'),
|
||||
name: options?.name ?? 'TestHand',
|
||||
description: `Test Hand: ${options?.name ?? 'TestHand'}`,
|
||||
status: options?.status ?? 'idle',
|
||||
category: options?.category ?? 'automation',
|
||||
requirements_met: options?.requirementsMet ?? true,
|
||||
tools: ['tool1', 'tool2'],
|
||||
metrics: ['metric1'],
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建 Browser Hand
|
||||
*/
|
||||
createBrowser(status = 'idle') {
|
||||
return this.create({
|
||||
id: 'browser',
|
||||
name: 'Browser',
|
||||
status,
|
||||
category: 'automation',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建 Collector Hand
|
||||
*/
|
||||
createCollector(status = 'idle') {
|
||||
return this.create({
|
||||
id: 'collector',
|
||||
name: 'Collector',
|
||||
status,
|
||||
category: 'data',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建需要审批的 Hand
|
||||
*/
|
||||
createNeedsApproval() {
|
||||
return this.create({
|
||||
status: 'needs_approval',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建多个 Hands
|
||||
*/
|
||||
createMany(count: number) {
|
||||
const categories = ['automation', 'data', 'research', 'analytics'];
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
this.create({
|
||||
id: `hand-${i + 1}`,
|
||||
name: `Hand${i + 1}`,
|
||||
category: categories[i % categories.length],
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试工作流工厂
|
||||
*/
|
||||
export const workflowFactory = {
|
||||
/**
|
||||
* 创建工作流
|
||||
*/
|
||||
create(options?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
steps?: number;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('wf'),
|
||||
name: options?.name ?? `工作流-${Date.now()}`,
|
||||
description: '测试工作流',
|
||||
steps: options?.steps ?? 1,
|
||||
status: 'idle',
|
||||
createdAt: generateTimestamp(),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建工作流步骤
|
||||
*/
|
||||
createStep(options?: {
|
||||
id?: string;
|
||||
handName?: string;
|
||||
params?: Record<string, unknown>;
|
||||
condition?: string;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('step'),
|
||||
handName: options?.handName ?? 'Browser',
|
||||
params: options?.params ?? {},
|
||||
condition: options?.condition,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建完整工作流(含步骤)
|
||||
*/
|
||||
createWithSteps(stepCount: number) {
|
||||
const steps = Array.from({ length: stepCount }, (_, i) =>
|
||||
this.createStep({
|
||||
handName: ['Browser', 'Collector', 'Researcher'][i % 3],
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
...this.create({ steps: stepCount }),
|
||||
steps,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试技能工厂
|
||||
*/
|
||||
export const skillFactory = {
|
||||
/**
|
||||
* 创建技能
|
||||
*/
|
||||
create(options?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
category?: string;
|
||||
installed?: boolean;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('skill'),
|
||||
name: options?.name ?? `技能-${Date.now()}`,
|
||||
description: '测试技能描述',
|
||||
category: options?.category ?? 'development',
|
||||
triggers: ['trigger1', 'trigger2'],
|
||||
capabilities: ['capability1'],
|
||||
installed: options?.installed ?? false,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建已安装的技能
|
||||
*/
|
||||
createInstalled(name?: string) {
|
||||
return this.create({
|
||||
name: name ?? '已安装技能',
|
||||
installed: true,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建多个技能
|
||||
*/
|
||||
createMany(count: number) {
|
||||
const categories = ['development', 'security', 'analytics', 'productivity'];
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
this.create({
|
||||
id: `skill-${i + 1}`,
|
||||
name: `技能 ${i + 1}`,
|
||||
category: categories[i % categories.length],
|
||||
installed: i < 2, // 前两个已安装
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试团队工厂
|
||||
*/
|
||||
export const teamFactory = {
|
||||
/**
|
||||
* 创建团队
|
||||
*/
|
||||
create(options?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
pattern?: string;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('team'),
|
||||
name: options?.name ?? `团队-${Date.now()}`,
|
||||
description: '测试团队',
|
||||
members: [],
|
||||
tasks: [],
|
||||
pattern: options?.pattern ?? 'sequential',
|
||||
status: 'active',
|
||||
createdAt: generateTimestamp(),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建团队成员
|
||||
*/
|
||||
createMember(options?: {
|
||||
id?: string;
|
||||
role?: string;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('member'),
|
||||
agentId: generateId('agent'),
|
||||
role: options?.role ?? 'member',
|
||||
joinedAt: generateTimestamp(),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建团队任务
|
||||
*/
|
||||
createTask(options?: {
|
||||
id?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('task'),
|
||||
title: '测试任务',
|
||||
description: '任务描述',
|
||||
status: options?.status ?? 'pending',
|
||||
assigneeId: null,
|
||||
createdAt: generateTimestamp(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试审批工厂
|
||||
*/
|
||||
export const approvalFactory = {
|
||||
/**
|
||||
* 创建审批请求
|
||||
*/
|
||||
create(options?: {
|
||||
id?: string;
|
||||
handName?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
return {
|
||||
id: options?.id ?? generateId('approval'),
|
||||
handName: options?.handName ?? 'Browser',
|
||||
reason: '需要用户批准执行',
|
||||
params: {},
|
||||
status: options?.status ?? 'pending',
|
||||
createdAt: generateTimestamp(),
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 预设测试场景数据
|
||||
*/
|
||||
export const testScenarios = {
|
||||
/**
|
||||
* 完整的聊天场景
|
||||
*/
|
||||
chatConversation: [
|
||||
{ role: 'user', content: '你好' },
|
||||
{ role: 'assistant', content: '你好!我是 AI 助手,有什么可以帮助你的吗?' },
|
||||
{ role: 'user', content: '请写一个简单的函数' },
|
||||
{ role: 'assistant', content: '好的,这是一个简单的函数:\n```python\ndef hello():\n print("Hello, World!")\n```' },
|
||||
],
|
||||
|
||||
/**
|
||||
* Hand 执行场景
|
||||
*/
|
||||
handExecution: {
|
||||
hand: handFactory.createBrowser(),
|
||||
params: { url: 'https://example.com' },
|
||||
expectedStatus: ['idle', 'running', 'completed'],
|
||||
},
|
||||
|
||||
/**
|
||||
* 工作流场景
|
||||
*/
|
||||
workflowExecution: {
|
||||
workflow: workflowFactory.createWithSteps(3),
|
||||
expectedSteps: ['step-1', 'step-2', 'step-3'],
|
||||
},
|
||||
|
||||
/**
|
||||
* 审批场景
|
||||
*/
|
||||
approvalFlow: {
|
||||
approval: approvalFactory.create({ handName: 'Browser' }),
|
||||
actions: ['approve', 'reject'] as const,
|
||||
},
|
||||
|
||||
/**
|
||||
* 团队协作场景
|
||||
*/
|
||||
teamCollaboration: {
|
||||
team: teamFactory.create({ pattern: 'review_loop' }),
|
||||
members: [teamFactory.createMember({ role: 'developer' }), teamFactory.createMember({ role: 'reviewer' })],
|
||||
task: teamFactory.createTask(),
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user