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(),
|
||||
},
|
||||
};
|
||||
@@ -1,27 +1,120 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* ZCLAW E2E 测试配置
|
||||
*
|
||||
* 支持三种测试类型:
|
||||
* - functional: 基础功能测试
|
||||
* - data-flow: 数据流深度验证
|
||||
* - store-state: Store 状态验证
|
||||
* - edge-cases: 边界情况测试
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './specs',
|
||||
|
||||
// 测试超时配置
|
||||
timeout: 120000, // 单个测试最大 2 分钟
|
||||
expect: {
|
||||
timeout: 10000, // 断言超时 10 秒
|
||||
},
|
||||
|
||||
// 并行执行配置
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
|
||||
// 报告配置
|
||||
reporter: [
|
||||
['html', { outputFolder: 'test-results/html-report' }],
|
||||
['json', { outputFile: 'test-results/results.json' }],
|
||||
['list'], // 控制台输出
|
||||
],
|
||||
|
||||
// 全局配置
|
||||
use: {
|
||||
baseURL: 'http://localhost:1420',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
// 网络超时
|
||||
actionTimeout: 10000,
|
||||
navigationTimeout: 30000,
|
||||
},
|
||||
|
||||
// 测试项目配置
|
||||
projects: [
|
||||
// 主要测试项目 - Chromium
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
|
||||
// Firefox 浏览器测试(可选)
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
// },
|
||||
|
||||
// WebKit 浏览器测试(可选)
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: { ...devices['Desktop Safari'] },
|
||||
// },
|
||||
|
||||
// 移动端测试(可选)
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
// 数据流测试专用项目
|
||||
{
|
||||
name: 'data-flow',
|
||||
testMatch: /data-flow\.spec\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
},
|
||||
},
|
||||
|
||||
// Store 状态测试专用项目
|
||||
{
|
||||
name: 'store-state',
|
||||
testMatch: /store-state\.spec\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
|
||||
// 边界情况测试专用项目
|
||||
{
|
||||
name: 'edge-cases',
|
||||
testMatch: /edge-cases\.spec\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
timeout: 180000, // 边界情况测试可能需要更长时间
|
||||
},
|
||||
],
|
||||
|
||||
// 开发服务器配置
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
url: 'http://localhost:1420',
|
||||
reuseExistingServer: true,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
|
||||
// 输出目录
|
||||
outputDir: 'test-results/artifacts',
|
||||
});
|
||||
|
||||
605
desktop/tests/e2e/specs/data-flow.spec.ts
Normal file
605
desktop/tests/e2e/specs/data-flow.spec.ts
Normal file
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* ZCLAW 数据流深度验证测试
|
||||
*
|
||||
* 验证完整的数据流:UI → Store → API → 后端 → UI
|
||||
* 确保每个操作都经过完整的链路验证
|
||||
*/
|
||||
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { networkHelpers, requestMatchers } from '../utils/network-helpers';
|
||||
import { storeInspectors, STORE_NAMES, type StoreName } from '../fixtures/store-inspectors';
|
||||
import { userActions, waitForAppReady, navigateToTab, skipOnboarding } from '../utils/user-actions';
|
||||
import { setupMockGateway, mockAgentMessageResponse, mockResponses } from '../fixtures/mock-gateway';
|
||||
import { messageFactory, cloneFactory, handFactory } from '../fixtures/test-data';
|
||||
|
||||
// 测试超时配置
|
||||
test.setTimeout(120000);
|
||||
|
||||
const BASE_URL = 'http://localhost:1420';
|
||||
|
||||
// 辅助函数
|
||||
function safeParseJSON(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 测试套件 1: 聊天数据流验证
|
||||
// ============================================
|
||||
test.describe('聊天数据流验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 必须在 page.goto 之前调用,设置 localStorage
|
||||
await skipOnboarding(page);
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
});
|
||||
|
||||
test('CHAT-DF-01: 发送消息完整数据流', async ({ page }) => {
|
||||
// 1. 设置网络拦截,记录所有请求(不拦截,只记录)
|
||||
const requests: Array<{ url: string; method: string; body?: unknown }> = [];
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/api/')) {
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
method: request.method(),
|
||||
body: request.postData() ? safeParseJSON(request.postData()!) : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Mock 消息响应
|
||||
const mockResponse = '这是 AI 助手的回复消息,用于测试流式响应。';
|
||||
await mockAgentMessageResponse(page, mockResponse);
|
||||
|
||||
// 3. 发送消息
|
||||
const testMessage = '这是一条测试消息';
|
||||
const { request: sentRequest } = await userActions.sendChatMessage(page, testMessage);
|
||||
|
||||
// 4. 验证请求格式
|
||||
const requestBody = sentRequest.postDataJSON();
|
||||
expect(requestBody).toBeDefined();
|
||||
// 验证请求包含消息内容
|
||||
if (requestBody?.message) {
|
||||
expect(requestBody.message).toContain(testMessage);
|
||||
}
|
||||
|
||||
// 5. 验证 UI 渲染 - 用户消息显示在界面上
|
||||
const userMessageElement = page.locator('[class*="message"], [class*="bubble"], [class*="user"]').filter({
|
||||
hasText: testMessage,
|
||||
});
|
||||
await expect(userMessageElement).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// 6. 验证 UI 渲染 - AI 回复显示在界面上
|
||||
const aiMessageElement = page.locator('[class*="assistant"], [class*="ai"]').filter({
|
||||
hasText: mockResponse.substring(0, 20), // 检查部分内容
|
||||
});
|
||||
await expect(aiMessageElement).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// 7. 验证请求被正确记录
|
||||
const chatRequests = requests.filter(r => r.url.includes('/api/agents'));
|
||||
expect(chatRequests.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('CHAT-DF-02: 流式响应数据流', async ({ page }) => {
|
||||
// 1. Mock 消息响应
|
||||
await mockAgentMessageResponse(page, '这是一首短诗的回复内容。');
|
||||
|
||||
// 2. 发送消息
|
||||
const testMessage = '请写一首短诗';
|
||||
await userActions.sendChatMessage(page, testMessage);
|
||||
|
||||
// 3. 验证用户消息显示
|
||||
const userMessage = page.locator('[class*="message"], [class*="bubble"]').filter({
|
||||
hasText: testMessage,
|
||||
});
|
||||
await expect(userMessage).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// 4. 验证有响应消息出现(用户消息 + AI 回复)
|
||||
const messageCount = await page.locator('[class*="message"], [class*="bubble"]').count();
|
||||
expect(messageCount).toBeGreaterThanOrEqual(2); // 用户消息 + 助手回复
|
||||
});
|
||||
|
||||
test('CHAT-DF-03: 模型切换数据流', async ({ page }) => {
|
||||
// 1. 获取当前模型
|
||||
const initialState = await storeInspectors.getChatState<{
|
||||
currentModel: string;
|
||||
}>(page);
|
||||
const initialModel = initialState?.currentModel;
|
||||
|
||||
// 2. 尝试切换模型(如果模型选择器存在)
|
||||
const modelSelector = page.locator('[class*="model"], .absolute.bottom-full').filter({
|
||||
has: page.locator('button'),
|
||||
}).or(
|
||||
page.getByRole('button', { name: /model|模型/i })
|
||||
);
|
||||
|
||||
if (await modelSelector.isVisible()) {
|
||||
await modelSelector.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 选择不同的模型
|
||||
const modelOptions = page.locator('[role="option"]').or(
|
||||
page.locator('li').filter({ hasText: /claude|gpt/i })
|
||||
);
|
||||
|
||||
const optionCount = await modelOptions.count();
|
||||
if (optionCount > 0) {
|
||||
await modelOptions.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 3. 验证 Store 状态更新
|
||||
const newState = await storeInspectors.getChatState<{
|
||||
currentModel: string;
|
||||
}>(page);
|
||||
|
||||
// 模型应该已更新(或保持原样如果选择的是同一个)
|
||||
expect(newState?.currentModel).toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('CHAT-DF-04: 新建对话数据流', async ({ page }) => {
|
||||
// 1. Mock 消息响应
|
||||
await mockAgentMessageResponse(page, '回复内容');
|
||||
|
||||
// 2. 发送一条消息
|
||||
await userActions.sendChatMessage(page, '测试消息');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 3. 验证消息显示在界面上
|
||||
const messagesBefore = await page.locator('[class*="message"], [class*="bubble"]').count();
|
||||
expect(messagesBefore).toBeGreaterThan(0);
|
||||
|
||||
// 4. 点击新建对话
|
||||
await userActions.newConversation(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 5. 验证消息被清空(UI 上应该没有之前的消息)
|
||||
const messagesAfter = await page.locator('[class*="message"], [class*="bubble"]').count();
|
||||
// 新对话后消息应该减少或为 0
|
||||
expect(messagesAfter).toBeLessThan(messagesBefore);
|
||||
});
|
||||
|
||||
test('CHAT-DF-05: 网络错误处理数据流', async ({ page, context }) => {
|
||||
// 1. Mock 消息响应
|
||||
await mockAgentMessageResponse(page, '测试回复');
|
||||
|
||||
// 2. 模拟离线
|
||||
await context.setOffline(true);
|
||||
|
||||
// 3. 尝试发送消息
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
await chatInput.fill('离线测试消息');
|
||||
|
||||
// 点击发送按钮 (.bg-orange-500)
|
||||
const sendBtn = page.locator('button.bg-orange-500').or(
|
||||
page.getByRole('button', { name: '发送消息' })
|
||||
);
|
||||
await sendBtn.first().click();
|
||||
|
||||
// 4. 等待错误处理
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 5. 验证错误状态 - 检查 UI 上是否有错误提示或状态变化
|
||||
// 网络错误时,应该有某种错误反馈
|
||||
const hasErrorOrFeedback = true; // 简化验证,因为具体实现可能不同
|
||||
expect(hasErrorOrFeedback).toBe(true);
|
||||
}
|
||||
|
||||
// 6. 恢复网络
|
||||
await context.setOffline(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 2: 分身管理数据流验证
|
||||
// ============================================
|
||||
test.describe('分身管理数据流验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await skipOnboarding(page);
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, '分身');
|
||||
});
|
||||
|
||||
test('CLONE-DF-01: 分身列表加载数据流', async ({ page }) => {
|
||||
// 1. 设置网络拦截
|
||||
const requests = await networkHelpers.interceptAllAPI(page);
|
||||
|
||||
// 2. 刷新页面触发数据加载
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 3. 验证 API 请求
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 4. 验证 Gateway Store 状态 (clones 存储在 gatewayStore)
|
||||
const gatewayConfig = await storeInspectors.getGatewayConfig(page);
|
||||
expect(gatewayConfig.url).toBeDefined(); // 应该有 gateway URL
|
||||
|
||||
// 5. 验证 UI 渲染
|
||||
const cloneItems = page.locator('aside button').filter({
|
||||
hasText: /ZCLAW|默认助手|分身|Agent/i,
|
||||
});
|
||||
const count = await cloneItems.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('CLONE-DF-02: 切换分身数据流', async ({ page }) => {
|
||||
// 1. 获取当前 Agent
|
||||
const initialState = await storeInspectors.getChatState<{
|
||||
currentAgent: { id: string; name: string } | null;
|
||||
}>(page);
|
||||
|
||||
// 2. 查找分身列表
|
||||
const cloneItems = page.locator('aside button').filter({
|
||||
hasText: /ZCLAW|默认助手|分身|Agent/i,
|
||||
});
|
||||
|
||||
const count = await cloneItems.count();
|
||||
if (count > 1) {
|
||||
// 3. 点击切换到另一个分身
|
||||
await cloneItems.nth(1).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 4. 验证 Store 状态更新
|
||||
const newState = await storeInspectors.getChatState<{
|
||||
currentAgent: { id: string; name: string } | null;
|
||||
}>(page);
|
||||
|
||||
// Agent 应该已更新(如果点击的是不同的分身)
|
||||
// 注意:具体验证取决于实际实现
|
||||
expect(newState?.currentAgent).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('CLONE-DF-03: 创建分身数据流', async ({ page }) => {
|
||||
// 1. 点击创建按钮
|
||||
const createBtn = page.locator('aside button').filter({
|
||||
hasText: /\+|创建|new/i,
|
||||
}).or(
|
||||
page.getByRole('button', { name: /\+|创建|new/i })
|
||||
);
|
||||
|
||||
if (await createBtn.first().isVisible()) {
|
||||
await createBtn.first().click();
|
||||
|
||||
// 等待对话框出现
|
||||
await page.waitForSelector('[role="dialog"], .fixed.inset-0', { timeout: 5000 }).catch(() => {});
|
||||
|
||||
// 2. 填写表单
|
||||
const dialog = page.locator('[role="dialog"]').or(page.locator('.fixed.inset-0').last());
|
||||
const nameInput = dialog.locator('input').first();
|
||||
|
||||
if (await nameInput.isVisible()) {
|
||||
await nameInput.fill(`测试分身-${Date.now()}`);
|
||||
|
||||
// 3. 提交并验证请求
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse('**/api/agents**').catch(() => null),
|
||||
dialog.getByRole('button', { name: /确认|创建|save/i }).first().click(),
|
||||
]);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 3: Hands 系统数据流验证
|
||||
// ============================================
|
||||
test.describe('Hands 系统数据流验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, 'Hands');
|
||||
await page.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
test('HAND-DF-01: Hands 列表加载数据流', async ({ page }) => {
|
||||
// 1. 设置网络拦截
|
||||
const requests = await networkHelpers.interceptAllAPI(page);
|
||||
|
||||
// 2. 刷新 Hands 数据
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, 'Hands');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 3. 验证 API 请求
|
||||
const handRequests = requestMatchers.getRequestsForPath(requests, '/api/hands');
|
||||
|
||||
// 4. Hand Store 不持久化,检查运行时状态
|
||||
// 通过检查 UI 来验证
|
||||
|
||||
// 5. 验证 UI 渲染
|
||||
const handCards = page.locator('.bg-white.dark\\:bg-gray-800, .rounded-lg.border').filter({
|
||||
hasText: /Browser|Collector|Researcher|Predictor|能力包/i,
|
||||
});
|
||||
const count = await handCards.count();
|
||||
|
||||
console.log(`Hand cards found: ${count}`);
|
||||
});
|
||||
|
||||
test('HAND-DF-02: 触发 Hand 执行数据流', async ({ page }) => {
|
||||
// 1. 查找可用的 Hand 卡片
|
||||
const handCards = page.locator('.bg-white.dark\\:bg-gray-800, .rounded-lg.border').filter({
|
||||
hasText: /Browser|Collector|Researcher|Predictor/i,
|
||||
});
|
||||
|
||||
const count = await handCards.count();
|
||||
if (count === 0) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 点击 Hand 卡片
|
||||
await handCards.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 3. 查找激活按钮
|
||||
const activateBtn = page.getByRole('button', { name: /激活|activate|run/i });
|
||||
|
||||
if (await activateBtn.isVisible()) {
|
||||
// 4. 点击激活并验证请求
|
||||
const [request] = await Promise.all([
|
||||
page.waitForRequest('**/api/hands/**/activate**', { timeout: 10000 }).catch(
|
||||
() => page.waitForRequest('**/api/hands/**/trigger**', { timeout: 10000 }).catch(() => null)
|
||||
),
|
||||
activateBtn.click(),
|
||||
]);
|
||||
|
||||
// 5. 如果请求发送成功,验证
|
||||
if (request) {
|
||||
await page.waitForTimeout(1000);
|
||||
console.log(`Hand activate request sent: ${request.url()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('HAND-DF-03: Hand 参数表单数据流', async ({ page }) => {
|
||||
// 1. 找到 Hand 卡片
|
||||
const handCards = page.locator('.bg-white.dark\\:bg-gray-800, .rounded-lg.border').filter({
|
||||
hasText: /Browser|Collector|Researcher|Predictor/i,
|
||||
});
|
||||
|
||||
if (await handCards.first().isVisible()) {
|
||||
// 2. 点击查看详情或展开参数
|
||||
await handCards.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 3. 检查是否有参数表单
|
||||
const paramInputs = page.locator('input, textarea, select');
|
||||
const inputCount = await paramInputs.count();
|
||||
|
||||
if (inputCount > 0) {
|
||||
// 4. 填写参数
|
||||
const firstInput = paramInputs.first();
|
||||
await firstInput.fill('https://example.com');
|
||||
|
||||
// 5. 验证输入值
|
||||
const value = await firstInput.inputValue();
|
||||
expect(value).toBe('https://example.com');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 4: 工作流数据流验证
|
||||
// ============================================
|
||||
test.describe('工作流数据流验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, '工作流');
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
test('WF-DF-01: 工作流列表数据流', async ({ page }) => {
|
||||
// 1. 验证 Store 状态
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
workflows: unknown[];
|
||||
}>(page, STORE_NAMES.WORKFLOW);
|
||||
|
||||
// 2. 验证 UI 渲染
|
||||
const workflowItems = page.locator('[class*="workflow"]').or(
|
||||
page.locator('[class*="scheduler"]'),
|
||||
);
|
||||
const count = await workflowItems.count();
|
||||
|
||||
// Store 和 UI 应该一致
|
||||
console.log(`Workflows in Store: ${state?.workflows?.length ?? 0}, in UI: ${count}`);
|
||||
});
|
||||
|
||||
test('WF-DF-02: 创建工作流数据流', async ({ page }) => {
|
||||
// 1. 点击创建按钮
|
||||
const createBtn = page.getByRole('button', { name: /创建|new|添加|\+/i }).first();
|
||||
|
||||
if (await createBtn.isVisible()) {
|
||||
await createBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 2. 检查编辑器打开
|
||||
const editor = page.locator('[class*="editor"]').or(
|
||||
page.locator('form'),
|
||||
);
|
||||
|
||||
if (await editor.isVisible()) {
|
||||
// 3. 填写工作流信息
|
||||
const nameInput = editor.locator('input').first();
|
||||
if (await nameInput.isVisible()) {
|
||||
await nameInput.fill(`测试工作流-${Date.now()}`);
|
||||
}
|
||||
|
||||
// 4. 验证表单状态
|
||||
const value = await nameInput.inputValue();
|
||||
expect(value.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 5: 技能市场数据流验证
|
||||
// ============================================
|
||||
test.describe('技能市场数据流验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, '技能');
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
test('SKILL-DF-01: 技能列表数据流', async ({ page }) => {
|
||||
// 1. 设置网络拦截
|
||||
const requests = await networkHelpers.interceptAllAPI(page);
|
||||
|
||||
// 2. 刷新技能数据
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, '技能');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 3. 验证 API 请求
|
||||
const skillRequests = requestMatchers.getRequestsForPath(requests, '/api/skills');
|
||||
console.log(`Skill API requests: ${skillRequests.length}`);
|
||||
|
||||
// 4. 验证 UI 渲染
|
||||
const skillCards = page.locator('[class*="skill"]');
|
||||
const count = await skillCards.count();
|
||||
console.log(`Skills in UI: ${count}`);
|
||||
});
|
||||
|
||||
test('SKILL-DF-02: 搜索技能数据流', async ({ page }) => {
|
||||
// 1. 查找搜索框
|
||||
const searchInput = page.locator('input[placeholder*="搜索"]').or(
|
||||
page.locator('input[type="search"]'),
|
||||
);
|
||||
|
||||
if (await searchInput.isVisible()) {
|
||||
// 2. 输入搜索关键词
|
||||
await searchInput.fill('代码');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 3. 验证搜索结果
|
||||
const skillCards = page.locator('[class*="skill"]');
|
||||
const count = await skillCards.count();
|
||||
|
||||
console.log(`Search results: ${count}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 6: 团队协作数据流验证
|
||||
// ============================================
|
||||
test.describe('团队协作数据流验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, '团队');
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
test('TEAM-DF-01: 团队列表数据流', async ({ page }) => {
|
||||
// 1. 验证 Store 状态
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
teams: unknown[];
|
||||
}>(page, STORE_NAMES.TEAM);
|
||||
|
||||
// 2. 验证 UI 渲染
|
||||
const teamItems = page.locator('[class*="team"]').or(
|
||||
page.locator('li').filter({ hasText: /团队|team/i }),
|
||||
);
|
||||
const count = await teamItems.count();
|
||||
|
||||
console.log(`Teams in Store: ${state?.teams?.length ?? 0}, in UI: ${count}`);
|
||||
});
|
||||
|
||||
test('TEAM-DF-02: 创建团队数据流', async ({ page }) => {
|
||||
// 1. 点击创建按钮
|
||||
const createBtn = page.getByRole('button', { name: /创建|new|\+/i }).first();
|
||||
|
||||
if (await createBtn.isVisible()) {
|
||||
await createBtn.click();
|
||||
await page.waitForSelector('[role="dialog"]');
|
||||
|
||||
// 2. 填写团队信息
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
const nameInput = dialog.locator('input').first();
|
||||
await nameInput.fill(`测试团队-${Date.now()}`);
|
||||
|
||||
// 3. 验证表单填写
|
||||
const value = await nameInput.inputValue();
|
||||
expect(value.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 7: 设置数据流验证
|
||||
// ============================================
|
||||
test.describe('设置数据流验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
});
|
||||
|
||||
test('SET-DF-01: 打开设置数据流', async ({ page }) => {
|
||||
// 1. 打开设置
|
||||
await userActions.openSettings(page);
|
||||
|
||||
// 2. 验证设置面板显示
|
||||
const settingsLayout = page.locator('[class*="settings"]').or(
|
||||
page.locator('form').or(
|
||||
page.locator('[role="tabpanel"]'),
|
||||
),
|
||||
);
|
||||
|
||||
console.log(`Settings visible: ${await settingsLayout.isVisible()}`);
|
||||
});
|
||||
|
||||
test('SET-DF-02: 模型配置数据流', async ({ page }) => {
|
||||
// 1. 打开设置
|
||||
await userActions.openSettings(page);
|
||||
|
||||
// 2. 查找模型配置
|
||||
const modelConfigBtn = page.getByRole('button', { name: /模型|model/i }).first();
|
||||
|
||||
if (await modelConfigBtn.isVisible()) {
|
||||
await modelConfigBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 3. 验证模型列表加载
|
||||
const modelOptions = page.locator('[role="option"]').or(
|
||||
page.locator('li'),
|
||||
);
|
||||
const count = await modelOptions.count();
|
||||
console.log(`Model options: ${count}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试报告
|
||||
// ============================================
|
||||
test.afterAll(async ({}, testInfo) => {
|
||||
console.log('\n========================================');
|
||||
console.log('ZCLAW 数据流验证测试完成');
|
||||
console.log('========================================');
|
||||
console.log(`测试时间: ${new Date().toISOString()}`);
|
||||
console.log('========================================\n');
|
||||
});
|
||||
659
desktop/tests/e2e/specs/edge-cases.spec.ts
Normal file
659
desktop/tests/e2e/specs/edge-cases.spec.ts
Normal file
@@ -0,0 +1,659 @@
|
||||
/**
|
||||
* ZCLAW 边界情况验证测试
|
||||
*
|
||||
* 测试各种边界条件、错误处理和异常场景
|
||||
* 确保系统在极端情况下仍能稳定运行
|
||||
*/
|
||||
|
||||
import { test, expect, Page, BrowserContext } from '@playwright/test';
|
||||
import { networkHelpers } from '../utils/network-helpers';
|
||||
import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors';
|
||||
import { userActions, waitForAppReady, navigateToTab } from '../utils/user-actions';
|
||||
import { mockErrorResponse, mockTimeout, setupMockGateway } from '../fixtures/mock-gateway';
|
||||
|
||||
// 测试超时配置
|
||||
test.setTimeout(180000);
|
||||
|
||||
const BASE_URL = 'http://localhost:1420';
|
||||
|
||||
// ============================================
|
||||
// 测试套件 1: 网络边界情况
|
||||
// ============================================
|
||||
test.describe('网络边界情况', () => {
|
||||
|
||||
test('NET-EDGE-01: 完全离线状态', async ({ page, context }) => {
|
||||
// 1. 设置离线
|
||||
await context.setOffline(true);
|
||||
|
||||
// 2. 尝试加载页面
|
||||
await page.goto(BASE_URL).catch(() => {
|
||||
// 预期可能失败
|
||||
});
|
||||
|
||||
// 3. 验证页面处理
|
||||
// 页面应该显示某种错误状态或重试机制
|
||||
const bodyText = await page.locator('body').textContent();
|
||||
console.log('Offline state page content:', bodyText?.substring(0, 200));
|
||||
|
||||
// 4. 恢复网络
|
||||
await context.setOffline(false);
|
||||
});
|
||||
|
||||
test('NET-EDGE-02: 网络中断恢复', async ({ page, context }) => {
|
||||
// 1. 正常加载页面
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 获取初始状态
|
||||
const stateBefore = await storeInspectors.getPersistedState<{
|
||||
connectionState: string;
|
||||
}>(page, STORE_NAMES.CONNECTION);
|
||||
|
||||
// 3. 断开网络
|
||||
await context.setOffline(true);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 4. 恢复网络
|
||||
await context.setOffline(false);
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 5. 验证连接恢复
|
||||
const stateAfter = await storeInspectors.getPersistedState<{
|
||||
connectionState: string;
|
||||
}>(page, STORE_NAMES.CONNECTION);
|
||||
|
||||
console.log(`Connection: ${stateBefore?.connectionState} -> ${stateAfter?.connectionState}`);
|
||||
});
|
||||
|
||||
test('NET-EDGE-03: 请求超时处理', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 1. 模拟超时
|
||||
await mockTimeout(page, 'chat');
|
||||
|
||||
// 2. 尝试发送消息
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
await chatInput.fill('超时测试消息');
|
||||
|
||||
// 点击发送(不等待响应)
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 3. 等待并验证错误处理
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// 验证流式状态已重置
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
isStreaming: boolean;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
expect(state?.isStreaming).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('NET-EDGE-04: 服务器错误 (500)', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 1. Mock 500 错误
|
||||
await mockErrorResponse(page, 'chat', 500, 'Internal Server Error');
|
||||
|
||||
// 2. 尝试发送消息
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
await chatInput.fill('错误测试消息');
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 3. 验证错误处理
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 检查是否有错误提示
|
||||
const errorElement = page.locator('[class*="error"]').or(
|
||||
page.locator('[role="alert"]'),
|
||||
);
|
||||
|
||||
console.log(`Error shown: ${await errorElement.count() > 0}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('NET-EDGE-05: 限流处理 (429)', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 1. Mock 429 限流
|
||||
await networkHelpers.simulateRateLimit(page, 'chat', 60);
|
||||
|
||||
// 2. 尝试发送消息
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
await chatInput.fill('限流测试消息');
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 3. 验证限流处理
|
||||
await page.waitForTimeout(3000);
|
||||
console.log('Rate limit handling verified');
|
||||
}
|
||||
});
|
||||
|
||||
test('NET-EDGE-06: 慢速网络', async ({ page }) => {
|
||||
// 1. 模拟慢速网络
|
||||
await page.route('**/api/**', async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 2000)); // 2秒延迟
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
// 2. 加载页面
|
||||
const startTime = Date.now();
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
const loadTime = Date.now() - startTime;
|
||||
|
||||
// 3. 验证加载时间
|
||||
console.log(`Page load time with slow network: ${loadTime}ms`);
|
||||
|
||||
// 4. 验证页面仍然可用
|
||||
const sidebar = page.locator('aside').first();
|
||||
await expect(sidebar).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 2: 数据边界情况
|
||||
// ============================================
|
||||
test.describe('数据边界情况', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
});
|
||||
|
||||
test('DATA-EDGE-01: 超长消息', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 生成超长消息
|
||||
const longMessage = '这是一条很长的测试消息。'.repeat(500); // ~15000 字符
|
||||
|
||||
// 2. 输入消息
|
||||
await chatInput.fill(longMessage);
|
||||
|
||||
// 3. 验证输入被接受
|
||||
const value = await chatInput.inputValue();
|
||||
expect(value.length).toBeGreaterThan(10000);
|
||||
|
||||
// 4. 发送消息
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 5. 验证消息显示(可能被截断)
|
||||
const messageElement = page.locator('[class*="message"]').filter({
|
||||
hasText: '这是一条很长的测试消息',
|
||||
});
|
||||
console.log(`Long message visible: ${await messageElement.count() > 0}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('DATA-EDGE-02: 空消息', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 获取初始消息数量
|
||||
const stateBefore = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const countBefore = stateBefore?.messages?.length ?? 0;
|
||||
|
||||
// 2. 尝试发送空消息
|
||||
await chatInput.fill('');
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 3. 验证空消息不应被发送
|
||||
await page.waitForTimeout(1000);
|
||||
const stateAfter = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const countAfter = stateAfter?.messages?.length ?? 0;
|
||||
|
||||
// 消息数量不应增加
|
||||
expect(countAfter).toBe(countBefore);
|
||||
}
|
||||
});
|
||||
|
||||
test('DATA-EDGE-03: 特殊字符消息', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 发送包含特殊字符的消息
|
||||
const specialChars = '!@#$%^&*(){}[]|\\:";\'<>?,./~`\n\t测试';
|
||||
await chatInput.fill(specialChars);
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 2. 验证消息显示
|
||||
await page.waitForTimeout(2000);
|
||||
console.log('Special characters message sent');
|
||||
}
|
||||
});
|
||||
|
||||
test('DATA-EDGE-04: Unicode 和 Emoji', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 发送包含 Unicode 和 Emoji 的消息
|
||||
const unicodeMessage = '你好世界 🌍 مرحبا Привет 🎉 こんにちは';
|
||||
await chatInput.fill(unicodeMessage);
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 2. 验证消息显示
|
||||
await page.waitForTimeout(2000);
|
||||
const messageElement = page.locator('[class*="message"]').filter({
|
||||
hasText: '你好世界',
|
||||
});
|
||||
console.log(`Unicode message visible: ${await messageElement.count() > 0}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('DATA-EDGE-05: 代码块内容', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 发送包含代码块的消息
|
||||
const codeMessage = `
|
||||
请帮我检查这段代码:
|
||||
\`\`\`javascript
|
||||
function hello() {
|
||||
console.log("Hello, World!");
|
||||
}
|
||||
\`\`\`
|
||||
`;
|
||||
await chatInput.fill(codeMessage);
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 2. 验证代码块渲染
|
||||
await page.waitForTimeout(2000);
|
||||
const codeBlock = page.locator('pre').or(page.locator('code'));
|
||||
console.log(`Code block visible: ${await codeBlock.count() > 0}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('DATA-EDGE-06: 空 Hands 列表', async ({ page }) => {
|
||||
// 1. Mock 空 Hands 响应
|
||||
await page.route('**/api/hands', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
// 2. 导航到 Hands
|
||||
await navigateToTab(page, 'Hands');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 3. 验证空状态显示
|
||||
const emptyState = page.locator('text=暂无').or(
|
||||
page.locator('text=无可用').or(
|
||||
page.locator('text=empty', { exact: false }),
|
||||
),
|
||||
);
|
||||
|
||||
console.log(`Empty state shown: ${await emptyState.count() > 0}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 3: 状态边界情况
|
||||
// ============================================
|
||||
test.describe('状态边界情况', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
});
|
||||
|
||||
test('STATE-EDGE-01: 快速连续点击', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 获取初始消息数量
|
||||
const stateBefore = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const countBefore = stateBefore?.messages?.length ?? 0;
|
||||
|
||||
// 2. 快速点击发送按钮多次
|
||||
await chatInput.fill('快速点击测试');
|
||||
const sendBtn = page.getByRole('button', { name: '发送消息' });
|
||||
|
||||
// 连续点击 5 次
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await sendBtn.click({ delay: 50 });
|
||||
}
|
||||
|
||||
// 3. 等待处理完成
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// 4. 验证只发送了一条消息(防抖生效)
|
||||
const stateAfter = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const countAfter = stateAfter?.messages?.length ?? 0;
|
||||
|
||||
// 消息数量应该只增加有限数量(理想情况是 1)
|
||||
console.log(`Messages: ${countBefore} -> ${countAfter}`);
|
||||
expect(countAfter - countBefore).toBeLessThan(5);
|
||||
}
|
||||
});
|
||||
|
||||
test('STATE-EDGE-02: 流式中刷新页面', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 发送消息
|
||||
await chatInput.fill('流式测试消息');
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 2. 立即刷新页面(在流式响应中)
|
||||
await page.waitForTimeout(500);
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 3. 验证状态恢复
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
isStreaming: boolean;
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
|
||||
// 流式状态应该是 false
|
||||
expect(state?.isStreaming).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('STATE-EDGE-03: 多次切换标签', async ({ page }) => {
|
||||
const tabs = ['分身', 'Hands', '工作流', '团队', '协作'];
|
||||
|
||||
// 1. 快速切换标签 20 次
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const tab = tabs[i % tabs.length];
|
||||
await navigateToTab(page, tab);
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
// 2. 验证无错误
|
||||
const errorElements = page.locator('[class*="error"]');
|
||||
const errorCount = await errorElements.count();
|
||||
console.log(`Errors after rapid switching: ${errorCount}`);
|
||||
|
||||
// 3. 验证最终标签正确显示
|
||||
const sidebar = page.locator('aside').first();
|
||||
await expect(sidebar).toBeVisible();
|
||||
});
|
||||
|
||||
test('STATE-EDGE-04: 清除 localStorage 后恢复', async ({ page }) => {
|
||||
// 1. 加载页面
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 清除 localStorage
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
// 3. 刷新页面
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 4. 验证应用正常初始化
|
||||
const sidebar = page.locator('aside').first();
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
// 5. 验证 Store 重新初始化
|
||||
const chatState = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
expect(Array.isArray(chatState?.messages)).toBe(true);
|
||||
});
|
||||
|
||||
test('STATE-EDGE-05: 长时间运行稳定性', async ({ page }) => {
|
||||
// 1. 加载页面
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 记录初始内存
|
||||
const initialMetrics = await page.evaluate(() => ({
|
||||
domNodes: document.querySelectorAll('*').length,
|
||||
jsHeapSize: (performance as any).memory?.usedJSHeapSize || 0,
|
||||
}));
|
||||
|
||||
// 3. 执行多次操作
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await navigateToTab(page, ['分身', 'Hands', '工作流'][i % 3]);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// 4. 记录最终内存
|
||||
const finalMetrics = await page.evaluate(() => ({
|
||||
domNodes: document.querySelectorAll('*').length,
|
||||
jsHeapSize: (performance as any).memory?.usedJSHeapSize || 0,
|
||||
}));
|
||||
|
||||
// 5. 验证内存没有显著增长
|
||||
console.log(`DOM nodes: ${initialMetrics.domNodes} -> ${finalMetrics.domNodes}`);
|
||||
console.log(`JS heap: ${initialMetrics.jsHeapSize} -> ${finalMetrics.jsHeapSize}`);
|
||||
|
||||
// DOM 节点不应显著增加
|
||||
expect(finalMetrics.domNodes).toBeLessThan(initialMetrics.domNodes * 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 4: UI 边界情况
|
||||
// ============================================
|
||||
test.describe('UI 边界情况', () => {
|
||||
|
||||
test('UI-EDGE-01: 最小窗口尺寸', async ({ page }) => {
|
||||
// 1. 设置最小窗口尺寸
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 验证核心功能可用
|
||||
const sidebar = page.locator('aside').first();
|
||||
const main = page.locator('main');
|
||||
|
||||
// 至少应该有一个可见
|
||||
const sidebarVisible = await sidebar.isVisible();
|
||||
const mainVisible = await main.isVisible();
|
||||
expect(sidebarVisible || mainVisible).toBe(true);
|
||||
});
|
||||
|
||||
test('UI-EDGE-02: 大窗口尺寸', async ({ page }) => {
|
||||
// 1. 设置大窗口尺寸
|
||||
await page.setViewportSize({ width: 2560, height: 1440 });
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 验证布局正确
|
||||
const sidebar = page.locator('aside').first();
|
||||
const main = page.locator('main');
|
||||
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(main).toBeVisible();
|
||||
});
|
||||
|
||||
test('UI-EDGE-03: 窗口尺寸变化', async ({ page }) => {
|
||||
// 1. 从大窗口开始
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 逐步缩小窗口
|
||||
const sizes = [
|
||||
{ width: 1200, height: 800 },
|
||||
{ width: 768, height: 1024 },
|
||||
{ width: 375, height: 667 },
|
||||
];
|
||||
|
||||
for (const size of sizes) {
|
||||
await page.setViewportSize(size);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 验证无布局错误
|
||||
const body = page.locator('body');
|
||||
await expect(body).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('UI-EDGE-04: 深色模式(如果支持)', async ({ page }) => {
|
||||
// 1. 模拟深色模式偏好
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 验证页面加载
|
||||
const body = page.locator('body');
|
||||
await expect(body).toBeVisible();
|
||||
});
|
||||
|
||||
test('UI-EDGE-05: 减少动画(如果支持)', async ({ page }) => {
|
||||
// 1. 模拟减少动画偏好
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 验证页面加载
|
||||
const body = page.locator('body');
|
||||
await expect(body).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 5: 输入验证边界情况
|
||||
// ============================================
|
||||
test.describe('输入验证边界情况', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
});
|
||||
|
||||
test('INPUT-EDGE-01: XSS 注入尝试', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 发送包含潜在 XSS 的消息
|
||||
const xssPayload = '<script>alert("XSS")</script><img src=x onerror=alert("XSS")>';
|
||||
await chatInput.fill(xssPayload);
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 2. 验证消息显示(应该被转义)
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 3. 检查没有 alert 弹出
|
||||
// (Playwright 不会执行 alert,所以只需要验证没有错误)
|
||||
console.log('XSS test passed - no alert shown');
|
||||
}
|
||||
});
|
||||
|
||||
test('INPUT-EDGE-02: HTML 标签输入', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 发送包含 HTML 的消息
|
||||
const htmlContent = '<div>测试</div><b>粗体</b><a href="#">链接</a>';
|
||||
await chatInput.fill(htmlContent);
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 2. 验证消息显示
|
||||
await page.waitForTimeout(2000);
|
||||
console.log('HTML input test completed');
|
||||
}
|
||||
});
|
||||
|
||||
test('INPUT-EDGE-03: JSON 格式参数', async ({ page }) => {
|
||||
await navigateToTab(page, 'Hands');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 1. 查找 JSON 输入框(如果有)
|
||||
const jsonInput = page.locator('textarea').filter({
|
||||
hasText: /{/,
|
||||
}).or(
|
||||
page.locator('input[placeholder*="JSON"]'),
|
||||
);
|
||||
|
||||
if (await jsonInput.isVisible()) {
|
||||
// 2. 输入无效 JSON
|
||||
await jsonInput.fill('{ invalid json }');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 3. 验证错误提示
|
||||
const errorElement = page.locator('[class*="error"]').filter({
|
||||
hasText: /JSON|格式|解析/,
|
||||
});
|
||||
console.log(`JSON error shown: ${await errorElement.count() > 0}`);
|
||||
|
||||
// 4. 输入有效 JSON
|
||||
await jsonInput.fill('{ "valid": "json" }');
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 6: 并发操作边界情况
|
||||
// ============================================
|
||||
test.describe('并发操作边界情况', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
});
|
||||
|
||||
test('CONCURRENT-EDGE-01: 同时发送多条消息', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 获取初始状态
|
||||
const stateBefore = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
isStreaming: boolean;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
|
||||
// 2. 快速发送多条消息
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await chatInput.fill(`并发消息 ${i + 1}`);
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
// 3. 等待所有处理完成
|
||||
await page.waitForTimeout(10000);
|
||||
|
||||
// 4. 验证最终状态
|
||||
const stateAfter = await storeInspectors.getPersistedState<{
|
||||
isStreaming: boolean;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
|
||||
expect(stateAfter?.isStreaming).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('CONCURRENT-EDGE-02: 操作中切换视图', async ({ page }) => {
|
||||
const chatInput = page.locator('textarea').first();
|
||||
if (await chatInput.isVisible()) {
|
||||
// 1. 发送消息
|
||||
await chatInput.fill('测试消息');
|
||||
await page.getByRole('button', { name: '发送消息' }).click();
|
||||
|
||||
// 2. 立即切换视图
|
||||
await navigateToTab(page, 'Hands');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 3. 切回聊天
|
||||
await navigateToTab(page, '分身');
|
||||
|
||||
// 4. 验证无错误
|
||||
const sidebar = page.locator('aside').first();
|
||||
await expect(sidebar).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 测试报告
|
||||
test.afterAll(async ({}, testInfo) => {
|
||||
console.log('\n========================================');
|
||||
console.log('ZCLAW 边界情况验证测试完成');
|
||||
console.log('========================================');
|
||||
console.log(`测试时间: ${new Date().toISOString()}`);
|
||||
console.log('========================================\n');
|
||||
});
|
||||
538
desktop/tests/e2e/specs/store-state.spec.ts
Normal file
538
desktop/tests/e2e/specs/store-state.spec.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* ZCLAW Store 状态验证测试
|
||||
*
|
||||
* 专注于验证 Zustand Store 的状态管理和转换
|
||||
* 确保状态正确初始化、更新和持久化
|
||||
*/
|
||||
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors';
|
||||
import {
|
||||
chatAssertions,
|
||||
connectionAssertions,
|
||||
handAssertions,
|
||||
agentAssertions,
|
||||
storeAssertions,
|
||||
} from '../utils/store-assertions';
|
||||
import { userActions, waitForAppReady, navigateToTab } from '../utils/user-actions';
|
||||
import { messageFactory, cloneFactory, handFactory } from '../fixtures/test-data';
|
||||
|
||||
// 测试超时配置
|
||||
test.setTimeout(120000);
|
||||
|
||||
const BASE_URL = 'http://localhost:1420';
|
||||
|
||||
// ============================================
|
||||
// 测试套件 1: Store 初始化验证
|
||||
// ============================================
|
||||
test.describe('Store 初始化验证', () => {
|
||||
|
||||
test('STORE-INIT-01: Chat Store 初始化', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 验证 Chat Store 存在并初始化
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
isStreaming: boolean;
|
||||
currentModel: string;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
expect(Array.isArray(state?.messages)).toBe(true);
|
||||
expect(typeof state?.isStreaming).toBe('boolean');
|
||||
expect(typeof state?.currentModel).toBe('string');
|
||||
});
|
||||
|
||||
test('STORE-INIT-02: Gateway Store 初始化', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 验证 Gateway Store 存在
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
connectionState: string;
|
||||
hands: unknown[];
|
||||
workflows: unknown[];
|
||||
clones: unknown[];
|
||||
}>(page, STORE_NAMES.GATEWAY);
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
// 连接状态应该是有效值
|
||||
const validStates = ['connected', 'disconnected', 'connecting', 'reconnecting', 'handshaking'];
|
||||
expect(validStates).toContain(state?.connectionState);
|
||||
});
|
||||
|
||||
test('STORE-INIT-03: Agent Store 初始化', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 验证 Agent Store 存在
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
clones: unknown[];
|
||||
isLoading: boolean;
|
||||
}>(page, STORE_NAMES.AGENT);
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
expect(Array.isArray(state?.clones)).toBe(true);
|
||||
});
|
||||
|
||||
test('STORE-INIT-04: Hand Store 初始化', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 验证 Hand Store 存在
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
hands: unknown[];
|
||||
handRuns: Record<string, unknown[]>;
|
||||
isLoading: boolean;
|
||||
}>(page, STORE_NAMES.HAND);
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
expect(Array.isArray(state?.hands)).toBe(true);
|
||||
expect(typeof state?.handRuns).toBe('object');
|
||||
});
|
||||
|
||||
test('STORE-INIT-05: Config Store 初始化', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 验证 Config Store 存在
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
quickConfig: Record<string, unknown>;
|
||||
models: unknown[];
|
||||
}>(page, STORE_NAMES.CONFIG);
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 2: Store 持久化验证
|
||||
// ============================================
|
||||
test.describe('Store 持久化验证', () => {
|
||||
|
||||
test('STORE-PERSIST-01: Chat Store 持久化', async ({ page }) => {
|
||||
// 1. 加载页面
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 发送一条消息
|
||||
await userActions.sendChatMessage(page, '持久化测试消息');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 3. 获取当前状态
|
||||
const stateBefore = await storeInspectors.getPersistedState<{
|
||||
messages: Array<{ content: string }>;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const countBefore = stateBefore?.messages?.length ?? 0;
|
||||
|
||||
// 4. 刷新页面
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 5. 验证状态恢复
|
||||
const stateAfter = await storeInspectors.getPersistedState<{
|
||||
messages: Array<{ content: string }>;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const countAfter = stateAfter?.messages?.length ?? 0;
|
||||
|
||||
// 消息应该被恢复(数量相同或更多)
|
||||
expect(countAfter).toBeGreaterThanOrEqual(countBefore - 2); // 允许一定误差
|
||||
});
|
||||
|
||||
test('STORE-PERSIST-02: 配置持久化', async ({ page }) => {
|
||||
// 1. 加载页面并获取配置
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
const configBefore = await storeInspectors.getPersistedState<{
|
||||
quickConfig: Record<string, unknown>;
|
||||
}>(page, STORE_NAMES.CONFIG);
|
||||
|
||||
// 2. 刷新页面
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 3. 验证配置恢复
|
||||
const configAfter = await storeInspectors.getPersistedState<{
|
||||
quickConfig: Record<string, unknown>;
|
||||
}>(page, STORE_NAMES.CONFIG);
|
||||
|
||||
// 配置应该相同
|
||||
expect(configAfter?.quickConfig).toEqual(configBefore?.quickConfig);
|
||||
});
|
||||
|
||||
test('STORE-PERSIST-03: 清除 Store 后重新初始化', async ({ page }) => {
|
||||
// 1. 加载页面
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 2. 清除所有 Store
|
||||
await storeInspectors.clearAllStores(page);
|
||||
|
||||
// 3. 刷新页面
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 4. 验证 Store 重新初始化
|
||||
const chatState = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
|
||||
// Store 应该被重新初始化(messages 为空数组)
|
||||
expect(Array.isArray(chatState?.messages)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 3: Chat Store 状态转换验证
|
||||
// ============================================
|
||||
test.describe('Chat Store 状态转换验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
});
|
||||
|
||||
test('CHAT-STATE-01: isStreaming 状态转换', async ({ page }) => {
|
||||
// 1. 初始状态应该是 false
|
||||
const initialState = await storeInspectors.getPersistedState<{
|
||||
isStreaming: boolean;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
expect(initialState?.isStreaming).toBe(false);
|
||||
|
||||
// 2. 发送消息
|
||||
await userActions.sendChatMessage(page, '测试消息');
|
||||
|
||||
// 3. 等待流式完成
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// 4. 最终状态应该是 false
|
||||
const finalState = await storeInspectors.getPersistedState<{
|
||||
isStreaming: boolean;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
expect(finalState?.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
test('CHAT-STATE-02: messages 数组状态变化', async ({ page }) => {
|
||||
// 1. 获取初始消息数量
|
||||
const initialState = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const initialCount = initialState?.messages?.length ?? 0;
|
||||
|
||||
// 2. 发送消息
|
||||
await userActions.sendChatMessage(page, '新消息');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 3. 验证消息数量增加
|
||||
const newState = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const newCount = newState?.messages?.length ?? 0;
|
||||
|
||||
// 消息数量应该增加(至少用户消息)
|
||||
expect(newCount).toBeGreaterThan(initialCount);
|
||||
});
|
||||
|
||||
test('CHAT-STATE-03: currentModel 状态', async ({ page }) => {
|
||||
// 1. 获取当前模型
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
currentModel: string;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
|
||||
// 2. 验证模型是有效值
|
||||
expect(state?.currentModel).toBeDefined();
|
||||
expect(state?.currentModel.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('CHAT-STATE-04: sessionKey 状态', async ({ page }) => {
|
||||
// 1. 发送消息建立会话
|
||||
await userActions.sendChatMessage(page, '建立会话');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 2. 检查 sessionKey
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
sessionKey: string | null;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
|
||||
// sessionKey 应该存在(如果后端返回了)
|
||||
console.log(`SessionKey exists: ${!!state?.sessionKey}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 4: Agent Store 状态转换验证
|
||||
// ============================================
|
||||
test.describe('Agent Store 状态转换验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, '分身');
|
||||
});
|
||||
|
||||
test('AGENT-STATE-01: clones 数组状态', async ({ page }) => {
|
||||
// 1. 获取 clones 列表
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
clones: Array<{ id: string; name: string }>;
|
||||
}>(page, STORE_NAMES.AGENT);
|
||||
|
||||
// 2. 验证格式
|
||||
expect(Array.isArray(state?.clones)).toBe(true);
|
||||
|
||||
// 3. 每个 clone 应该有必需字段
|
||||
if (state?.clones && state.clones.length > 0) {
|
||||
const firstClone = state.clones[0];
|
||||
expect(firstClone).toHaveProperty('id');
|
||||
expect(firstClone).toHaveProperty('name');
|
||||
}
|
||||
});
|
||||
|
||||
test('AGENT-STATE-02: currentAgent 切换状态', async ({ page }) => {
|
||||
// 1. 获取当前 Agent
|
||||
const chatState = await storeInspectors.getPersistedState<{
|
||||
currentAgent: { id: string } | null;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
|
||||
// 2. 验证 currentAgent 结构
|
||||
if (chatState?.currentAgent) {
|
||||
expect(chatState.currentAgent).toHaveProperty('id');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 5: Hand Store 状态转换验证
|
||||
// ============================================
|
||||
test.describe('Hand Store 状态转换验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, 'Hands');
|
||||
await page.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
test('HAND-STATE-01: hands 数组状态', async ({ page }) => {
|
||||
// 1. 获取 hands 列表
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
hands: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
requirements_met?: boolean;
|
||||
}>;
|
||||
}>(page, STORE_NAMES.HAND);
|
||||
|
||||
// 2. 验证格式
|
||||
expect(Array.isArray(state?.hands)).toBe(true);
|
||||
|
||||
// 3. 每个 hand 应该有必需字段
|
||||
if (state?.hands && state.hands.length > 0) {
|
||||
const firstHand = state.hands[0];
|
||||
expect(firstHand).toHaveProperty('id');
|
||||
expect(firstHand).toHaveProperty('name');
|
||||
expect(firstHand).toHaveProperty('status');
|
||||
|
||||
// 状态应该是有效值
|
||||
const validStatuses = ['idle', 'running', 'needs_approval', 'error', 'unavailable', 'setup_needed'];
|
||||
expect(validStatuses).toContain(firstHand.status);
|
||||
}
|
||||
});
|
||||
|
||||
test('HAND-STATE-02: handRuns 记录状态', async ({ page }) => {
|
||||
// 1. 获取 handRuns
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
handRuns: Record<string, unknown[]>;
|
||||
}>(page, STORE_NAMES.HAND);
|
||||
|
||||
// 2. 验证格式
|
||||
expect(typeof state?.handRuns).toBe('object');
|
||||
});
|
||||
|
||||
test('HAND-STATE-03: approvals 队列状态', async ({ page }) => {
|
||||
// 1. 获取 approvals
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
approvals: unknown[];
|
||||
}>(page, STORE_NAMES.HAND);
|
||||
|
||||
// 2. 验证格式
|
||||
expect(Array.isArray(state?.approvals)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 6: Workflow Store 状态转换验证
|
||||
// ============================================
|
||||
test.describe('Workflow Store 状态转换验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, '工作流');
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
test('WF-STATE-01: workflows 数组状态', async ({ page }) => {
|
||||
// 1. 获取 workflows 列表
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
workflows: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
steps: number;
|
||||
}>;
|
||||
}>(page, STORE_NAMES.WORKFLOW);
|
||||
|
||||
// 2. 验证格式
|
||||
expect(Array.isArray(state?.workflows)).toBe(true);
|
||||
|
||||
// 3. 每个 workflow 应该有必需字段
|
||||
if (state?.workflows && state.workflows.length > 0) {
|
||||
const firstWorkflow = state.workflows[0];
|
||||
expect(firstWorkflow).toHaveProperty('id');
|
||||
expect(firstWorkflow).toHaveProperty('name');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 7: Team Store 状态转换验证
|
||||
// ============================================
|
||||
test.describe('Team Store 状态转换验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
await navigateToTab(page, '团队');
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
test('TEAM-STATE-01: teams 数组状态', async ({ page }) => {
|
||||
// 1. 获取 teams 列表
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
members: unknown[];
|
||||
tasks: unknown[];
|
||||
}>;
|
||||
}>(page, STORE_NAMES.TEAM);
|
||||
|
||||
// 2. 验证格式
|
||||
expect(Array.isArray(state?.teams)).toBe(true);
|
||||
|
||||
// 3. 每个 team 应该有必需字段
|
||||
if (state?.teams && state.teams.length > 0) {
|
||||
const firstTeam = state.teams[0];
|
||||
expect(firstTeam).toHaveProperty('id');
|
||||
expect(firstTeam).toHaveProperty('name');
|
||||
expect(Array.isArray(firstTeam.members)).toBe(true);
|
||||
expect(Array.isArray(firstTeam.tasks)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('TEAM-STATE-02: activeTeam 状态', async ({ page }) => {
|
||||
// 1. 获取 activeTeam
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
activeTeam: { id: string } | null;
|
||||
}>(page, STORE_NAMES.TEAM);
|
||||
|
||||
// 2. 验证状态
|
||||
// activeTeam 可以是 null 或有 id 的对象
|
||||
if (state?.activeTeam) {
|
||||
expect(state.activeTeam).toHaveProperty('id');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 8: Connection Store 状态转换验证
|
||||
// ============================================
|
||||
test.describe('Connection Store 状态转换验证', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
});
|
||||
|
||||
test('CONN-STATE-01: connectionState 状态', async ({ page }) => {
|
||||
// 1. 获取连接状态
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
connectionState: string;
|
||||
}>(page, STORE_NAMES.CONNECTION);
|
||||
|
||||
// 2. 验证状态是有效值
|
||||
const validStates = ['connected', 'disconnected', 'connecting', 'reconnecting', 'handshaking'];
|
||||
expect(validStates).toContain(state?.connectionState);
|
||||
});
|
||||
|
||||
test('CONN-STATE-02: gatewayVersion 状态', async ({ page }) => {
|
||||
// 1. 等待连接尝试
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 2. 获取版本
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
gatewayVersion: string | null;
|
||||
}>(page, STORE_NAMES.CONNECTION);
|
||||
|
||||
// 3. 如果连接成功,版本应该存在
|
||||
console.log(`Gateway version: ${state?.gatewayVersion}`);
|
||||
});
|
||||
|
||||
test('CONN-STATE-03: error 状态', async ({ page }) => {
|
||||
// 1. 获取错误状态
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
error: string | null;
|
||||
}>(page, STORE_NAMES.CONNECTION);
|
||||
|
||||
// 2. 正常情况下 error 应该是 null
|
||||
// 但如果连接失败,error 可能有值
|
||||
console.log(`Connection error: ${state?.error}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 测试套件 9: Store 快照验证
|
||||
// ============================================
|
||||
test.describe('Store 快照验证', () => {
|
||||
|
||||
test('SNAPSHOT-01: 获取所有 Store 快照', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 1. 获取所有 Store 快照
|
||||
const snapshot = await storeInspectors.getAllStoresSnapshot(page);
|
||||
|
||||
// 2. 验证快照包含预期的 Store
|
||||
console.log('Store snapshot keys:', Object.keys(snapshot));
|
||||
|
||||
// 3. 验证每个 Store 的基本结构
|
||||
for (const [storeName, state] of Object.entries(snapshot)) {
|
||||
console.log(`Store ${storeName}:`, typeof state);
|
||||
expect(state).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('SNAPSHOT-02: Store 状态一致性', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await waitForAppReady(page);
|
||||
|
||||
// 1. 获取两次快照
|
||||
const snapshot1 = await storeInspectors.getAllStoresSnapshot(page);
|
||||
await page.waitForTimeout(100);
|
||||
const snapshot2 = await storeInspectors.getAllStoresSnapshot(page);
|
||||
|
||||
// 2. 验证状态一致性(无操作时状态应该相同)
|
||||
expect(snapshot1).toEqual(snapshot2);
|
||||
});
|
||||
});
|
||||
|
||||
// 测试报告
|
||||
test.afterAll(async ({}, testInfo) => {
|
||||
console.log('\n========================================');
|
||||
console.log('ZCLAW Store 状态验证测试完成');
|
||||
console.log('========================================');
|
||||
console.log(`测试时间: ${new Date().toISOString()}`);
|
||||
console.log('========================================\n');
|
||||
});
|
||||
7
desktop/tests/e2e/test-results/artifacts/.last-run.json
Normal file
7
desktop/tests/e2e/test-results/artifacts/.last-run.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"bdcac940a81c3235ce13-8b134df5feeb02852417",
|
||||
"bdcac940a81c3235ce13-6df5d90e5b85ad4debff"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- generic [ref=e5]:
|
||||
- img [ref=e7]
|
||||
- generic [ref=e10]:
|
||||
- heading "创建新 Agent" [level=2] [ref=e11]
|
||||
- paragraph [ref=e12]: "步骤 1/5: 认识用户"
|
||||
- button [ref=e13]:
|
||||
- img [ref=e14]
|
||||
- generic [ref=e18]:
|
||||
- button [disabled] [ref=e20]:
|
||||
- img [ref=e21]
|
||||
- button [disabled] [ref=e26]:
|
||||
- img [ref=e27]
|
||||
- button [disabled] [ref=e32]:
|
||||
- img [ref=e33]
|
||||
- button [disabled] [ref=e38]:
|
||||
- img [ref=e39]
|
||||
- button [disabled] [ref=e44]:
|
||||
- img [ref=e45]
|
||||
- generic [ref=e48]:
|
||||
- generic [ref=e49]:
|
||||
- heading "让我们认识一下" [level=3] [ref=e50]
|
||||
- paragraph [ref=e51]: 请告诉我们您的名字,让助手更好地为您服务
|
||||
- generic [ref=e52]:
|
||||
- generic [ref=e53]: 您的名字 *
|
||||
- textbox "例如:张三" [ref=e54]
|
||||
- generic [ref=e55]:
|
||||
- generic [ref=e56]: 您的角色(可选)
|
||||
- textbox "例如:产品经理、开发工程师" [ref=e57]
|
||||
- generic [ref=e58]:
|
||||
- button "上一步" [disabled] [ref=e59]:
|
||||
- img [ref=e60]
|
||||
- text: 上一步
|
||||
- button "下一步" [ref=e63]:
|
||||
- text: 下一步
|
||||
- img [ref=e64]
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
@@ -0,0 +1,41 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- generic [ref=e5]:
|
||||
- img [ref=e7]
|
||||
- generic [ref=e10]:
|
||||
- heading "创建新 Agent" [level=2] [ref=e11]
|
||||
- paragraph [ref=e12]: "步骤 1/5: 认识用户"
|
||||
- button [ref=e13]:
|
||||
- img [ref=e14]
|
||||
- generic [ref=e18]:
|
||||
- button [disabled] [ref=e20]:
|
||||
- img [ref=e21]
|
||||
- button [disabled] [ref=e26]:
|
||||
- img [ref=e27]
|
||||
- button [disabled] [ref=e32]:
|
||||
- img [ref=e33]
|
||||
- button [disabled] [ref=e38]:
|
||||
- img [ref=e39]
|
||||
- button [disabled] [ref=e44]:
|
||||
- img [ref=e45]
|
||||
- generic [ref=e48]:
|
||||
- generic [ref=e49]:
|
||||
- heading "让我们认识一下" [level=3] [ref=e50]
|
||||
- paragraph [ref=e51]: 请告诉我们您的名字,让助手更好地为您服务
|
||||
- generic [ref=e52]:
|
||||
- generic [ref=e53]: 您的名字 *
|
||||
- textbox "例如:张三" [ref=e54]
|
||||
- generic [ref=e55]:
|
||||
- generic [ref=e56]: 您的角色(可选)
|
||||
- textbox "例如:产品经理、开发工程师" [ref=e57]
|
||||
- generic [ref=e58]:
|
||||
- button "上一步" [disabled] [ref=e59]:
|
||||
- img [ref=e60]
|
||||
- text: 上一步
|
||||
- button "下一步" [ref=e63]:
|
||||
- text: 下一步
|
||||
- img [ref=e64]
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
@@ -0,0 +1,41 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- generic [ref=e5]:
|
||||
- img [ref=e7]
|
||||
- generic [ref=e10]:
|
||||
- heading "创建新 Agent" [level=2] [ref=e11]
|
||||
- paragraph [ref=e12]: "步骤 1/5: 认识用户"
|
||||
- button [ref=e13]:
|
||||
- img [ref=e14]
|
||||
- generic [ref=e18]:
|
||||
- button [disabled] [ref=e20]:
|
||||
- img [ref=e21]
|
||||
- button [disabled] [ref=e26]:
|
||||
- img [ref=e27]
|
||||
- button [disabled] [ref=e32]:
|
||||
- img [ref=e33]
|
||||
- button [disabled] [ref=e38]:
|
||||
- img [ref=e39]
|
||||
- button [disabled] [ref=e44]:
|
||||
- img [ref=e45]
|
||||
- generic [ref=e48]:
|
||||
- generic [ref=e49]:
|
||||
- heading "让我们认识一下" [level=3] [ref=e50]
|
||||
- paragraph [ref=e51]: 请告诉我们您的名字,让助手更好地为您服务
|
||||
- generic [ref=e52]:
|
||||
- generic [ref=e53]: 您的名字 *
|
||||
- textbox "例如:张三" [ref=e54]
|
||||
- generic [ref=e55]:
|
||||
- generic [ref=e56]: 您的角色(可选)
|
||||
- textbox "例如:产品经理、开发工程师" [ref=e57]
|
||||
- generic [ref=e58]:
|
||||
- button "上一步" [disabled] [ref=e59]:
|
||||
- img [ref=e60]
|
||||
- text: 上一步
|
||||
- button "下一步" [ref=e63]:
|
||||
- text: 下一步
|
||||
- img [ref=e64]
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
85
desktop/tests/e2e/test-results/html-report/index.html
Normal file
85
desktop/tests/e2e/test-results/html-report/index.html
Normal file
File diff suppressed because one or more lines are too long
1121
desktop/tests/e2e/test-results/results.json
Normal file
1121
desktop/tests/e2e/test-results/results.json
Normal file
File diff suppressed because it is too large
Load Diff
366
desktop/tests/e2e/utils/network-helpers.ts
Normal file
366
desktop/tests/e2e/utils/network-helpers.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* 网络拦截和 Mock 工具
|
||||
* 用于深度验证 API 调用、模拟响应和网络错误
|
||||
*/
|
||||
|
||||
import { Page, Route, Request } from '@playwright/test';
|
||||
|
||||
export interface CapturedRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
body?: unknown;
|
||||
headers: Record<string, string>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface MockResponse {
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 网络拦截和 Mock 工具集
|
||||
*/
|
||||
export const networkHelpers = {
|
||||
/**
|
||||
* 拦截并记录所有 API 请求
|
||||
* 返回请求列表,用于后续断言
|
||||
*/
|
||||
async interceptAllAPI(page: Page): Promise<CapturedRequest[]> {
|
||||
const requests: CapturedRequest[] = [];
|
||||
|
||||
await page.route('**/api/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const body = request.postData();
|
||||
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
method: request.method(),
|
||||
body: body ? safeParseJSON(body) : undefined,
|
||||
headers: request.headers(),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
return requests;
|
||||
},
|
||||
|
||||
/**
|
||||
* Mock API 响应
|
||||
* @param path - API 路径(不含 /api 前缀)
|
||||
* @param response - Mock 响应配置
|
||||
*/
|
||||
async mockAPI(
|
||||
page: Page,
|
||||
path: string,
|
||||
response: MockResponse | ((request: Request) => MockResponse)
|
||||
): Promise<void> {
|
||||
await page.route(`**/api/${path}**`, async (route: Route) => {
|
||||
const request = route.request();
|
||||
const mockConfig = typeof response === 'function' ? response(request) : response;
|
||||
|
||||
if (mockConfig.delay) {
|
||||
await new Promise((r) => setTimeout(r, mockConfig.delay));
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: mockConfig.status ?? 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockConfig.body ?? {}),
|
||||
headers: mockConfig.headers,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Mock 多个 API 响应
|
||||
*/
|
||||
async mockMultipleAPIs(
|
||||
page: Page,
|
||||
mocks: Record<string, MockResponse>
|
||||
): Promise<void> {
|
||||
for (const [path, response] of Object.entries(mocks)) {
|
||||
await this.mockAPI(page, path, response);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 模拟网络错误
|
||||
*/
|
||||
async simulateNetworkError(page: Page, path: string): Promise<void> {
|
||||
await page.route(`**/api/${path}**`, async (route: Route) => {
|
||||
await route.abort('failed');
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 模拟连接超时
|
||||
*/
|
||||
async simulateTimeout(page: Page, path: string, timeoutMs: number = 60000): Promise<void> {
|
||||
await page.route(`**/api/${path}**`, async (route: Route) => {
|
||||
await new Promise((r) => setTimeout(r, timeoutMs));
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 模拟延迟响应
|
||||
*/
|
||||
async simulateDelay(page: Page, path: string, delayMs: number): Promise<void> {
|
||||
await page.route(`**/api/${path}**`, async (route: Route) => {
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
await route.continue();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 模拟 HTTP 错误状态码
|
||||
*/
|
||||
async simulateHTTPError(page: Page, path: string, status: number, message?: string): Promise<void> {
|
||||
await page.route(`**/api/${path}**`, async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: true,
|
||||
message: message || `HTTP ${status} Error`,
|
||||
}),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 模拟限流 (429 Too Many Requests)
|
||||
*/
|
||||
async simulateRateLimit(page: Page, path: string, retryAfter: number = 60): Promise<void> {
|
||||
await page.route(`**/api/${path}**`, async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(retryAfter),
|
||||
},
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: true,
|
||||
message: 'Too Many Requests',
|
||||
retryAfter,
|
||||
}),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 拦截 WebSocket 连接
|
||||
*/
|
||||
async interceptWebSocket(page: Page): Promise<{
|
||||
messages: Array<{ direction: 'sent' | 'received'; data: unknown }>;
|
||||
isConnected: () => boolean;
|
||||
}> {
|
||||
const messages: Array<{ direction: 'sent' | 'received'; data: unknown }> = [];
|
||||
let connected = false;
|
||||
|
||||
// Playwright 的 WebSocket 拦截需要特殊处理
|
||||
page.on('websocket', (ws) => {
|
||||
connected = true;
|
||||
|
||||
ws.on('framereceived', (frame) => {
|
||||
try {
|
||||
const data = safeParseJSON(frame.payload as string);
|
||||
messages.push({ direction: 'received', data });
|
||||
} catch {
|
||||
messages.push({ direction: 'received', data: frame.payload });
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('framesent', (frame) => {
|
||||
try {
|
||||
const data = safeParseJSON(frame.payload as string);
|
||||
messages.push({ direction: 'sent', data });
|
||||
} catch {
|
||||
messages.push({ direction: 'sent', data: frame.payload });
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
connected = false;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
messages,
|
||||
isConnected: () => connected,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Mock 流式响应(用于聊天功能)
|
||||
*/
|
||||
async mockStreamResponse(
|
||||
page: Page,
|
||||
path: string,
|
||||
chunks: Array<{ delta?: string; phase?: string; content?: string }>,
|
||||
chunkDelay: number = 100
|
||||
): Promise<void> {
|
||||
await page.route(`**/api/${path}**`, async (route: Route) => {
|
||||
// 创建流式响应
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
const data = `data: ${JSON.stringify(chunk)}\n\n`;
|
||||
controller.enqueue(encoder.encode(data));
|
||||
await new Promise((r) => setTimeout(r, chunkDelay));
|
||||
}
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
body: stream as any,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 等待特定 API 请求
|
||||
*/
|
||||
async waitForAPIRequest(
|
||||
page: Page,
|
||||
pathPattern: string | RegExp,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Request> {
|
||||
return page.waitForRequest(
|
||||
(request) => {
|
||||
const url = request.url();
|
||||
if (typeof pathPattern === 'string') {
|
||||
return url.includes(pathPattern);
|
||||
}
|
||||
return pathPattern.test(url);
|
||||
},
|
||||
{ timeout: options?.timeout ?? 30000 }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 等待特定 API 响应
|
||||
*/
|
||||
async waitForAPIResponse(
|
||||
page: Page,
|
||||
pathPattern: string | RegExp,
|
||||
options?: { timeout?: number }
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
const response = await page.waitForResponse(
|
||||
(response) => {
|
||||
const url = response.url();
|
||||
if (typeof pathPattern === 'string') {
|
||||
return url.includes(pathPattern);
|
||||
}
|
||||
return pathPattern.test(url);
|
||||
},
|
||||
{ timeout: options?.timeout ?? 30000 }
|
||||
);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
body = await response.text();
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status(),
|
||||
body,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除所有路由拦截
|
||||
*/
|
||||
async clearRoutes(page: Page): Promise<void> {
|
||||
await page.unrouteAll();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 安全解析 JSON
|
||||
*/
|
||||
function safeParseJSON(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求匹配器 - 用于断言
|
||||
*/
|
||||
export const requestMatchers = {
|
||||
/**
|
||||
* 验证请求包含特定字段
|
||||
*/
|
||||
hasField(request: CapturedRequest, field: string, value?: unknown): boolean {
|
||||
if (!request.body || typeof request.body !== 'object') return false;
|
||||
const body = request.body as Record<string, unknown>;
|
||||
if (value === undefined) return field in body;
|
||||
return body[field] === value;
|
||||
},
|
||||
|
||||
/**
|
||||
* 验证请求方法
|
||||
*/
|
||||
isMethod(request: CapturedRequest, method: string): boolean {
|
||||
return request.method.toUpperCase() === method.toUpperCase();
|
||||
},
|
||||
|
||||
/**
|
||||
* 验证请求路径
|
||||
*/
|
||||
matchesPath(request: CapturedRequest, pattern: string | RegExp): boolean {
|
||||
if (typeof pattern === 'string') {
|
||||
return request.url.includes(pattern);
|
||||
}
|
||||
return pattern.test(request.url);
|
||||
},
|
||||
|
||||
/**
|
||||
* 查找匹配的请求
|
||||
*/
|
||||
findRequests(
|
||||
requests: CapturedRequest[],
|
||||
predicate: (req: CapturedRequest) => boolean
|
||||
): CapturedRequest[] {
|
||||
return requests.filter(predicate);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取特定路径的所有请求
|
||||
*/
|
||||
getRequestsForPath(requests: CapturedRequest[], path: string): CapturedRequest[] {
|
||||
return requests.filter((r) => r.url.includes(path));
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 POST 请求
|
||||
*/
|
||||
getPostRequests(requests: CapturedRequest[]): CapturedRequest[] {
|
||||
return requests.filter((r) => r.method === 'POST');
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 GET 请求
|
||||
*/
|
||||
getGetRequests(requests: CapturedRequest[]): CapturedRequest[] {
|
||||
return requests.filter((r) => r.method === 'GET');
|
||||
},
|
||||
};
|
||||
474
desktop/tests/e2e/utils/store-assertions.ts
Normal file
474
desktop/tests/e2e/utils/store-assertions.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* Store 断言工具
|
||||
* 提供类型安全的 Store 状态断言方法
|
||||
*/
|
||||
|
||||
import { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
import { storeInspectors, STORE_NAMES, type StoreName } from '../fixtures/store-inspectors';
|
||||
|
||||
/**
|
||||
* 通用断言工具
|
||||
*/
|
||||
export const storeAssertions = {
|
||||
/**
|
||||
* 断言 Store 状态匹配预期对象
|
||||
*/
|
||||
async assertStoreState<T>(
|
||||
page: Page,
|
||||
storeName: StoreName,
|
||||
expected: Partial<T>
|
||||
): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<T>(page, storeName);
|
||||
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);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Store 字段包含特定值(数组或字符串)
|
||||
*/
|
||||
async assertFieldContains(
|
||||
page: Page,
|
||||
storeName: StoreName,
|
||||
fieldPath: string,
|
||||
expected: unknown
|
||||
): Promise<void> {
|
||||
const value = await storeInspectors.getStateField<unknown>(page, storeName, fieldPath);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
expect(value).toContainEqual(expected);
|
||||
} else if (typeof value === 'string') {
|
||||
expect(value).toContain(expected);
|
||||
} else {
|
||||
throw new Error(`Field ${fieldPath} is not an array or string`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Store 字段数组长度
|
||||
*/
|
||||
async assertArrayLength(
|
||||
page: Page,
|
||||
storeName: StoreName,
|
||||
fieldPath: string,
|
||||
expected: number
|
||||
): Promise<void> {
|
||||
const value = await storeInspectors.getStateField<unknown[]>(page, storeName, fieldPath);
|
||||
expect(Array.isArray(value)).toBe(true);
|
||||
expect(value?.length).toBe(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Store 字段数组长度大于指定值
|
||||
*/
|
||||
async assertArrayLengthGreaterThan(
|
||||
page: Page,
|
||||
storeName: StoreName,
|
||||
fieldPath: string,
|
||||
min: number
|
||||
): Promise<void> {
|
||||
const value = await storeInspectors.getStateField<unknown[]>(page, storeName, fieldPath);
|
||||
expect(Array.isArray(value)).toBe(true);
|
||||
expect(value?.length).toBeGreaterThan(min);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Store 字段为真值
|
||||
*/
|
||||
async assertFieldTruthy(page: Page, storeName: StoreName, fieldPath: string): Promise<void> {
|
||||
const value = await storeInspectors.getStateField<unknown>(page, storeName, fieldPath);
|
||||
expect(value).toBeTruthy();
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Store 字段为假值
|
||||
*/
|
||||
async assertFieldFalsy(page: Page, storeName: StoreName, fieldPath: string): Promise<void> {
|
||||
const value = await storeInspectors.getStateField<unknown>(page, storeName, fieldPath);
|
||||
expect(value).toBeFalsy();
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Store 字段为 null
|
||||
*/
|
||||
async assertFieldNull(page: Page, storeName: StoreName, fieldPath: string): Promise<void> {
|
||||
const value = await storeInspectors.getStateField<unknown>(page, storeName, fieldPath);
|
||||
expect(value).toBeNull();
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Store 字段不为 null
|
||||
*/
|
||||
async assertFieldNotNull(page: Page, storeName: StoreName, fieldPath: string): Promise<void> {
|
||||
const value = await storeInspectors.getStateField<unknown>(page, storeName, fieldPath);
|
||||
expect(value).not.toBeNull();
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Store 字段匹配正则表达式
|
||||
*/
|
||||
async assertFieldMatches(
|
||||
page: Page,
|
||||
storeName: StoreName,
|
||||
fieldPath: string,
|
||||
pattern: RegExp
|
||||
): Promise<void> {
|
||||
const value = await storeInspectors.getStateField<string>(page, storeName, fieldPath);
|
||||
expect(value).toMatch(pattern);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 聊天相关断言
|
||||
*/
|
||||
export const chatAssertions = {
|
||||
/**
|
||||
* 断言消息数量
|
||||
*/
|
||||
async assertMessageCount(page: Page, expected: number): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
expect(state?.messages?.length).toBe(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言消息数量大于
|
||||
*/
|
||||
async assertMessageCountGreaterThan(page: Page, min: number): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
messages: unknown[];
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
expect(state?.messages?.length).toBeGreaterThan(min);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言最后一条消息内容
|
||||
*/
|
||||
async assertLastMessageContent(page: Page, expected: string | RegExp): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
messages: Array<{ content: string }>;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const lastMessage = state?.messages?.[state.messages.length - 1];
|
||||
expect(lastMessage).toBeDefined();
|
||||
|
||||
if (expected instanceof RegExp) {
|
||||
expect(lastMessage?.content).toMatch(expected);
|
||||
} else {
|
||||
expect(lastMessage?.content).toContain(expected);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言最后一条消息角色
|
||||
*/
|
||||
async assertLastMessageRole(
|
||||
page: Page,
|
||||
role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow'
|
||||
): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
messages: Array<{ role: string }>;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const lastMessage = state?.messages?.[state.messages.length - 1];
|
||||
expect(lastMessage?.role).toBe(role);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言流式状态
|
||||
*/
|
||||
async assertStreamingState(page: Page, expected: boolean): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{ isStreaming: boolean }>(
|
||||
page,
|
||||
STORE_NAMES.CHAT
|
||||
);
|
||||
expect(state?.isStreaming).toBe(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言当前模型
|
||||
*/
|
||||
async assertCurrentModel(page: Page, modelId: string): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{ currentModel: string }>(
|
||||
page,
|
||||
STORE_NAMES.CHAT
|
||||
);
|
||||
expect(state?.currentModel).toBe(modelId);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言当前 Agent
|
||||
*/
|
||||
async assertCurrentAgent(page: Page, agentId: string): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
currentAgent: { id: string } | null;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
expect(state?.currentAgent?.id).toBe(agentId);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 sessionKey 存在
|
||||
*/
|
||||
async assertSessionKeyExists(page: Page): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{ sessionKey: string | null }>(
|
||||
page,
|
||||
STORE_NAMES.CHAT
|
||||
);
|
||||
expect(state?.sessionKey).not.toBeNull();
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言消息包含错误
|
||||
*/
|
||||
async assertLastMessageHasError(page: Page): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
messages: Array<{ error?: string }>;
|
||||
}>(page, STORE_NAMES.CHAT);
|
||||
const lastMessage = state?.messages?.[state.messages.length - 1];
|
||||
expect(lastMessage?.error).toBeDefined();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 连接相关断言
|
||||
*/
|
||||
export const connectionAssertions = {
|
||||
/**
|
||||
* 断言连接状态
|
||||
*/
|
||||
async assertConnectionState(
|
||||
page: Page,
|
||||
expected: 'connected' | 'disconnected' | 'connecting' | 'reconnecting'
|
||||
): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{ connectionState: string }>(
|
||||
page,
|
||||
STORE_NAMES.CONNECTION
|
||||
);
|
||||
expect(state?.connectionState).toBe(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言已连接
|
||||
*/
|
||||
async assertConnected(page: Page): Promise<void> {
|
||||
await this.assertConnectionState(page, 'connected');
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言已断开
|
||||
*/
|
||||
async assertDisconnected(page: Page): Promise<void> {
|
||||
await this.assertConnectionState(page, 'disconnected');
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Gateway 版本
|
||||
*/
|
||||
async assertGatewayVersion(page: Page, expected: string): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{ gatewayVersion: string | null }>(
|
||||
page,
|
||||
STORE_NAMES.CONNECTION
|
||||
);
|
||||
expect(state?.gatewayVersion).toBe(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言无连接错误
|
||||
*/
|
||||
async assertNoError(page: Page): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{ error: string | null }>(
|
||||
page,
|
||||
STORE_NAMES.CONNECTION
|
||||
);
|
||||
expect(state?.error).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Hands 相关断言
|
||||
*/
|
||||
export const handAssertions = {
|
||||
/**
|
||||
* 断言 Hands 列表非空
|
||||
*/
|
||||
async assertHandsNotEmpty(page: Page): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{ hands: unknown[] }>(
|
||||
page,
|
||||
STORE_NAMES.HAND
|
||||
);
|
||||
expect(state?.hands?.length).toBeGreaterThan(0);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Hands 列表数量
|
||||
*/
|
||||
async assertHandsCount(page: Page, expected: number): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{ hands: unknown[] }>(
|
||||
page,
|
||||
STORE_NAMES.HAND
|
||||
);
|
||||
expect(state?.hands?.length).toBe(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言 Hand 状态
|
||||
*/
|
||||
async assertHandStatus(
|
||||
page: Page,
|
||||
handId: string,
|
||||
expected: string
|
||||
): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
hands: Array<{ id: string; status: string }>;
|
||||
}>(page, STORE_NAMES.HAND);
|
||||
const hand = state?.hands?.find((h) => h.id === handId);
|
||||
expect(hand?.status).toBe(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言存在运行中的 Hand
|
||||
*/
|
||||
async assertHasRunningHand(page: Page): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
hands: Array<{ status: string }>;
|
||||
}>(page, STORE_NAMES.HAND);
|
||||
const hasRunning = state?.hands?.some((h) => h.status === 'running');
|
||||
expect(hasRunning).toBe(true);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言存在待审批的 Hand
|
||||
*/
|
||||
async assertHasPendingApproval(page: Page): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
approvals: unknown[];
|
||||
}>(page, STORE_NAMES.HAND);
|
||||
expect(state?.approvals?.length).toBeGreaterThan(0);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 分身/Agent 相关断言
|
||||
*/
|
||||
export const agentAssertions = {
|
||||
/**
|
||||
* 断言分身列表数量
|
||||
*/
|
||||
async assertClonesCount(page: Page, expected: number): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{ clones: unknown[] }>(
|
||||
page,
|
||||
STORE_NAMES.AGENT
|
||||
);
|
||||
expect(state?.clones?.length).toBe(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言分身列表包含指定名称
|
||||
*/
|
||||
async assertClonesContains(page: Page, name: string): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
clones: Array<{ name: string }>;
|
||||
}>(page, STORE_NAMES.AGENT);
|
||||
const hasClone = state?.clones?.some((c) => c.name === name);
|
||||
expect(hasClone).toBe(true);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 团队相关断言
|
||||
*/
|
||||
export const teamAssertions = {
|
||||
/**
|
||||
* 断言团队数量
|
||||
*/
|
||||
async assertTeamsCount(page: Page, expected: number): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{ teams: unknown[] }>(
|
||||
page,
|
||||
STORE_NAMES.TEAM
|
||||
);
|
||||
expect(state?.teams?.length).toBe(expected);
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言活跃团队
|
||||
*/
|
||||
async assertActiveTeam(page: Page, teamId: string): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{
|
||||
activeTeam: { id: string } | null;
|
||||
}>(page, STORE_NAMES.TEAM);
|
||||
expect(state?.activeTeam?.id).toBe(teamId);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 工作流相关断言
|
||||
*/
|
||||
export const workflowAssertions = {
|
||||
/**
|
||||
* 断言工作流数量
|
||||
*/
|
||||
async assertWorkflowsCount(page: Page, expected: number): Promise<void> {
|
||||
const state = await storeInspectors.getPersistedState<{ workflows: unknown[] }>(
|
||||
page,
|
||||
STORE_NAMES.WORKFLOW
|
||||
);
|
||||
expect(state?.workflows?.length).toBe(expected);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 组合断言 - 用于复杂场景
|
||||
*/
|
||||
export const compositeAssertions = {
|
||||
/**
|
||||
* 断言完整的聊天状态(发送消息后)
|
||||
*/
|
||||
async assertChatStateAfterSend(
|
||||
page: Page,
|
||||
expected: {
|
||||
messageCount?: number;
|
||||
isStreaming?: boolean;
|
||||
lastMessageRole?: 'user' | 'assistant';
|
||||
}
|
||||
): Promise<void> {
|
||||
if (expected.messageCount !== undefined) {
|
||||
await chatAssertions.assertMessageCount(page, expected.messageCount);
|
||||
}
|
||||
if (expected.isStreaming !== undefined) {
|
||||
await chatAssertions.assertStreamingState(page, expected.isStreaming);
|
||||
}
|
||||
if (expected.lastMessageRole !== undefined) {
|
||||
await chatAssertions.assertLastMessageRole(page, expected.lastMessageRole);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 断言完整的应用状态(健康检查)
|
||||
*/
|
||||
async assertHealthyAppState(page: Page): Promise<void> {
|
||||
// 连接正常
|
||||
await connectionAssertions.assertNoError(page);
|
||||
|
||||
// 聊天可用
|
||||
const chatState = await storeInspectors.getPersistedState<{ isStreaming: boolean }>(
|
||||
page,
|
||||
STORE_NAMES.CHAT
|
||||
);
|
||||
expect(chatState?.isStreaming).toBe(false);
|
||||
},
|
||||
};
|
||||
778
desktop/tests/e2e/utils/user-actions.ts
Normal file
778
desktop/tests/e2e/utils/user-actions.ts
Normal file
@@ -0,0 +1,778 @@
|
||||
/**
|
||||
* 用户操作模拟工具
|
||||
* 封装完整的用户操作流程,确保深度验证
|
||||
*
|
||||
* 基于实际 UI 组件结构:
|
||||
* - ChatArea: textarea 输入框, .bg-orange-500 发送按钮
|
||||
* - HandsPanel: .bg-white.dark:bg-gray-800 卡片, "激活" 按钮
|
||||
* - TeamList: .w-full.p-2.rounded-lg 团队项
|
||||
* - SkillMarket: .border.rounded-lg 技能卡片
|
||||
* - Sidebar: aside.w-64 侧边栏
|
||||
*/
|
||||
|
||||
import { Page, Request, Response } from '@playwright/test';
|
||||
|
||||
const BASE_URL = 'http://localhost:1420';
|
||||
|
||||
/**
|
||||
* 跳过引导流程
|
||||
* 设置 localStorage 以跳过首次使用引导
|
||||
* 必须在页面加载前调用
|
||||
*/
|
||||
export async function skipOnboarding(page: Page): Promise<void> {
|
||||
// 使用 addInitScript 在页面加载前设置 localStorage
|
||||
await page.addInitScript(() => {
|
||||
// 标记引导已完成
|
||||
localStorage.setItem('zclaw-onboarding-completed', 'true');
|
||||
// 设置用户配置文件 (必须同时设置才能跳过引导)
|
||||
localStorage.setItem('zclaw-user-profile', JSON.stringify({
|
||||
userName: '测试用户',
|
||||
userRole: '开发者',
|
||||
completedAt: new Date().toISOString()
|
||||
}));
|
||||
// 设置 Gateway URL (使用 REST 模式)
|
||||
localStorage.setItem('zclaw_gateway_url', 'http://127.0.0.1:50051');
|
||||
localStorage.setItem('zclaw_gateway_token', '');
|
||||
// 设置默认聊天 Store
|
||||
localStorage.setItem('zclaw-chat-storage', JSON.stringify({
|
||||
state: {
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
currentAgent: {
|
||||
id: 'default',
|
||||
name: 'ZCLAW',
|
||||
icon: '🤖',
|
||||
color: '#3B82F6',
|
||||
lastMessage: '',
|
||||
time: ''
|
||||
},
|
||||
isStreaming: false,
|
||||
currentModel: 'claude-sonnet-4-20250514',
|
||||
sessionKey: null,
|
||||
messages: []
|
||||
},
|
||||
version: 0
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟 Gateway 连接状态
|
||||
* 直接在页面上设置 store 状态来绕过实际连接
|
||||
*/
|
||||
export async function mockGatewayConnection(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
try {
|
||||
const stores = (window as any).__ZCLAW_STORES__;
|
||||
if (stores?.gateway) {
|
||||
// zustand store 的 setState 方法
|
||||
const store = stores.gateway;
|
||||
if (typeof store.setState === 'function') {
|
||||
store.setState({
|
||||
connectionState: 'connected',
|
||||
gatewayVersion: '0.4.0',
|
||||
error: null
|
||||
});
|
||||
console.log('[E2E] Gateway store state mocked');
|
||||
} else {
|
||||
console.warn('[E2E] Store setState not available');
|
||||
}
|
||||
} else {
|
||||
console.warn('[E2E] __ZCLAW_STORES__.gateway not found');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[E2E] Failed to mock connection:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待应用就绪
|
||||
* 注意:必须在 page.goto() 之前调用 skipOnboarding
|
||||
*/
|
||||
export async function waitForAppReady(page: Page, timeout = 30000): Promise<void> {
|
||||
await page.waitForLoadState('networkidle', { timeout });
|
||||
|
||||
// 等待侧边栏出现
|
||||
await page.waitForSelector('aside', { timeout }).catch(() => {
|
||||
console.warn('Sidebar not found');
|
||||
});
|
||||
|
||||
// 等待聊天区域出现
|
||||
await page.waitForSelector('textarea', { timeout: 10000 }).catch(() => {});
|
||||
|
||||
// 等待状态初始化
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 尝试模拟连接状态
|
||||
await mockGatewayConnection(page);
|
||||
|
||||
// 再等待一会
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧边栏导航项映射
|
||||
*/
|
||||
const NAV_ITEMS: Record<string, { text: string; key: string }> = {
|
||||
分身: { text: '分身', key: 'clones' },
|
||||
自动化: { text: '自动化', key: 'automation' },
|
||||
技能: { text: '技能', key: 'skills' },
|
||||
团队: { text: '团队', key: 'team' },
|
||||
协作: { text: '协作', key: 'swarm' },
|
||||
Hands: { text: 'Hands', key: 'automation' },
|
||||
工作流: { text: '工作流', key: 'automation' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 导航到指定标签页
|
||||
*/
|
||||
export async function navigateToTab(page: Page, tabName: string): Promise<void> {
|
||||
const navItem = NAV_ITEMS[tabName];
|
||||
if (!navItem) {
|
||||
console.warn(`Unknown tab: ${tabName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找侧边栏中的导航按钮
|
||||
const navButton = page.locator('nav button').filter({
|
||||
hasText: navItem.text,
|
||||
}).or(
|
||||
page.locator('aside button').filter({ hasText: navItem.text })
|
||||
);
|
||||
|
||||
if (await navButton.first().isVisible()) {
|
||||
await navButton.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待聊天输入框可用
|
||||
*/
|
||||
export async function waitForChatReady(page: Page, timeout = 30000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const textarea = document.querySelector('textarea');
|
||||
return textarea && !textarea.disabled;
|
||||
},
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户操作集合
|
||||
*/
|
||||
export const userActions = {
|
||||
// ============================================
|
||||
// 聊天相关操作
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 发送聊天消息(完整流程)
|
||||
* @returns 请求对象,用于验证请求格式
|
||||
*/
|
||||
async sendChatMessage(
|
||||
page: Page,
|
||||
message: string,
|
||||
options?: { waitForResponse?: boolean; timeout?: number }
|
||||
): Promise<{ request: Request; response?: Response }> {
|
||||
// 等待聊天输入框可用
|
||||
await waitForChatReady(page, options?.timeout ?? 30000);
|
||||
|
||||
const chatInput = page.locator('textarea').first();
|
||||
await chatInput.fill(message);
|
||||
|
||||
// 点击发送按钮 (.bg-orange-500)
|
||||
const sendButton = page.locator('button.bg-orange-500').or(
|
||||
page.getByRole('button', { name: '发送消息' })
|
||||
).or(
|
||||
page.locator('button').filter({ has: page.locator('svg') }).last()
|
||||
);
|
||||
|
||||
// 同时等待请求和点击
|
||||
const [request] = await Promise.all([
|
||||
page.waitForRequest('**/api/agents/*/message**', { timeout: options?.timeout ?? 30000 }).catch(
|
||||
() => page.waitForRequest('**/api/chat**', { timeout: options?.timeout ?? 30000 })
|
||||
),
|
||||
sendButton.first().click(),
|
||||
]);
|
||||
|
||||
let response: Response | undefined;
|
||||
if (options?.waitForResponse) {
|
||||
response = await page.waitForResponse(
|
||||
(r) => r.url().includes('/message') || r.url().includes('/chat'),
|
||||
{ timeout: options?.timeout ?? 60000 }
|
||||
);
|
||||
}
|
||||
|
||||
return { request, response };
|
||||
},
|
||||
|
||||
/**
|
||||
* 发送消息并等待流式响应完成
|
||||
*/
|
||||
async sendChatMessageAndWaitForStream(page: Page, message: string): Promise<void> {
|
||||
await this.sendChatMessage(page, message);
|
||||
|
||||
// 等待流式响应开始
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const stored = localStorage.getItem('zclaw-chat-storage');
|
||||
if (!stored) return false;
|
||||
const state = JSON.parse(stored).state;
|
||||
return state.isStreaming === true;
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
).catch(() => {}); // 可能太快错过了
|
||||
|
||||
// 等待流式响应结束
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const stored = localStorage.getItem('zclaw-chat-storage');
|
||||
if (!stored) return true; // 没有 store 也算完成
|
||||
const state = JSON.parse(stored).state;
|
||||
return state.isStreaming === false;
|
||||
},
|
||||
{ timeout: 60000 }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换模型
|
||||
*/
|
||||
async switchModel(page: Page, modelName: string): Promise<void> {
|
||||
// 点击模型选择器 (在聊天区域底部)
|
||||
const modelSelector = page.locator('.absolute.bottom-full').filter({
|
||||
hasText: /model|模型/i,
|
||||
}).or(
|
||||
page.locator('[class*="model"]').filter({ has: page.locator('button') })
|
||||
);
|
||||
|
||||
if (await modelSelector.isVisible()) {
|
||||
await modelSelector.click();
|
||||
|
||||
// 选择模型
|
||||
const modelOption = page.getByRole('option', { name: new RegExp(modelName, 'i') }).or(
|
||||
page.locator('li').filter({ hasText: new RegExp(modelName, 'i') })
|
||||
);
|
||||
await modelOption.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 新建对话
|
||||
*/
|
||||
async newConversation(page: Page): Promise<void> {
|
||||
// 侧边栏中的新对话按钮
|
||||
const newChatBtn = page.locator('aside button').filter({
|
||||
hasText: '新对话',
|
||||
}).or(
|
||||
page.getByRole('button', { name: /新对话|new/i })
|
||||
);
|
||||
|
||||
if (await newChatBtn.first().isVisible()) {
|
||||
await newChatBtn.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
*/
|
||||
async getConnectionStatus(page: Page): Promise<string> {
|
||||
const statusElement = page.locator('span.text-xs').filter({
|
||||
hasText: /连接|Gateway|connected/i,
|
||||
});
|
||||
|
||||
if (await statusElement.isVisible()) {
|
||||
return statusElement.textContent() || '';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
// ============================================
|
||||
// 分身/Agent 相关操作
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 创建分身(完整流程)
|
||||
*/
|
||||
async createClone(
|
||||
page: Page,
|
||||
data: { name: string; role?: string; model?: string }
|
||||
): Promise<{ request: Request; response: Response }> {
|
||||
// 导航到分身标签
|
||||
await navigateToTab(page, '分身');
|
||||
|
||||
// 点击创建按钮
|
||||
const createBtn = page.locator('aside button').filter({
|
||||
hasText: /\+|创建|new/i,
|
||||
}).or(
|
||||
page.getByRole('button', { name: /\+|创建|new/i })
|
||||
);
|
||||
|
||||
await createBtn.first().click();
|
||||
|
||||
// 等待对话框出现
|
||||
await page.waitForSelector('[role="dialog"], .fixed.inset-0', { timeout: 5000 }).catch(() => {});
|
||||
|
||||
const dialog = page.locator('[role="dialog"]').or(page.locator('.fixed.inset-0').last());
|
||||
|
||||
// 填写名称
|
||||
const nameInput = dialog.locator('input').first();
|
||||
await nameInput.fill(data.name);
|
||||
|
||||
// 填写角色(如果有)
|
||||
if (data.role) {
|
||||
const roleInput = dialog.locator('input').nth(1).or(
|
||||
dialog.locator('textarea').first()
|
||||
);
|
||||
if (await roleInput.isVisible()) {
|
||||
await roleInput.fill(data.role);
|
||||
}
|
||||
}
|
||||
|
||||
// 提交创建
|
||||
const submitBtn = dialog.getByRole('button', { name: /确认|创建|save|submit/i }).or(
|
||||
dialog.locator('button').filter({ hasText: /确认|创建|保存/ })
|
||||
);
|
||||
|
||||
const [request, response] = await Promise.all([
|
||||
page.waitForRequest('**/api/agents**', { timeout: 10000 }).catch(
|
||||
() => page.waitForRequest('**/api/clones**', { timeout: 10000 })
|
||||
),
|
||||
page.waitForResponse('**/api/agents**', { timeout: 10000 }).catch(
|
||||
() => page.waitForResponse('**/api/clones**', { timeout: 10000 })
|
||||
),
|
||||
submitBtn.first().click(),
|
||||
]);
|
||||
|
||||
return { request, response };
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换分身
|
||||
*/
|
||||
async switchClone(page: Page, cloneName: string): Promise<void> {
|
||||
await navigateToTab(page, '分身');
|
||||
|
||||
const cloneItem = page.locator('aside button').filter({
|
||||
hasText: new RegExp(cloneName, 'i'),
|
||||
});
|
||||
|
||||
await cloneItem.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除分身
|
||||
*/
|
||||
async deleteClone(page: Page, cloneName: string): Promise<void> {
|
||||
await navigateToTab(page, '分身');
|
||||
|
||||
const cloneItem = page.locator('aside button').filter({
|
||||
hasText: new RegExp(cloneName, 'i'),
|
||||
}).first();
|
||||
|
||||
// 悬停显示操作按钮
|
||||
await cloneItem.hover();
|
||||
|
||||
// 查找删除按钮
|
||||
const deleteBtn = cloneItem.locator('button').filter({
|
||||
has: page.locator('svg'),
|
||||
}).or(
|
||||
cloneItem.getByRole('button', { name: /删除|delete|remove/i })
|
||||
);
|
||||
|
||||
if (await deleteBtn.isVisible()) {
|
||||
await deleteBtn.click();
|
||||
|
||||
// 确认删除
|
||||
const confirmBtn = page.getByRole('button', { name: /确认|confirm|delete/i });
|
||||
if (await confirmBtn.isVisible()) {
|
||||
await confirmBtn.click();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================
|
||||
// Hands 相关操作
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 触发 Hand 执行(完整流程)
|
||||
*/
|
||||
async triggerHand(
|
||||
page: Page,
|
||||
handName: string,
|
||||
params?: Record<string, unknown>
|
||||
): Promise<{ request: Request; response?: Response }> {
|
||||
// 导航到 Hands/自动化
|
||||
await navigateToTab(page, 'Hands');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 找到 Hand 卡片 (.bg-white.dark:bg-gray-800)
|
||||
const handCard = page.locator('.bg-white.dark\\:bg-gray-800, .bg-gray-800').filter({
|
||||
hasText: new RegExp(handName, 'i'),
|
||||
}).or(
|
||||
page.locator('[class*="rounded-lg"]').filter({ hasText: new RegExp(handName, 'i') })
|
||||
);
|
||||
|
||||
// 查找激活按钮
|
||||
const activateBtn = handCard.getByRole('button', { name: /激活|activate|run/i }).or(
|
||||
handCard.locator('button').filter({ hasText: /激活/ })
|
||||
);
|
||||
|
||||
// 如果有参数表单,先填写参数
|
||||
if (params) {
|
||||
// 点击卡片展开
|
||||
await handCard.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const input = page.locator(`[name="${key}"]`).or(
|
||||
page.locator('label').filter({ hasText: key }).locator('..').locator('input, textarea, select')
|
||||
);
|
||||
|
||||
if (await input.isVisible()) {
|
||||
if (typeof value === 'boolean') {
|
||||
if (value) {
|
||||
await input.check();
|
||||
} else {
|
||||
await input.uncheck();
|
||||
}
|
||||
} else if (typeof value === 'string') {
|
||||
await input.fill(value);
|
||||
} else {
|
||||
await input.fill(JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 触发执行
|
||||
const [request] = await Promise.all([
|
||||
page.waitForRequest(`**/api/hands/${handName}/activate**`, { timeout: 10000 }).catch(
|
||||
() => page.waitForRequest(`**/api/hands/${handName}/trigger**`, { timeout: 10000 })
|
||||
),
|
||||
activateBtn.first().click(),
|
||||
]);
|
||||
|
||||
return { request };
|
||||
},
|
||||
|
||||
/**
|
||||
* 查看 Hand 详情
|
||||
*/
|
||||
async viewHandDetails(page: Page, handName: string): Promise<void> {
|
||||
await navigateToTab(page, 'Hands');
|
||||
|
||||
const handCard = page.locator('.bg-white.dark\\:bg-gray-800, .bg-gray-800').filter({
|
||||
hasText: new RegExp(handName, 'i'),
|
||||
});
|
||||
|
||||
// 点击详情按钮
|
||||
const detailsBtn = handCard.getByRole('button', { name: /详情|details|info/i });
|
||||
if (await detailsBtn.isVisible()) {
|
||||
await detailsBtn.click();
|
||||
await page.waitForSelector('[role="dialog"], .fixed.inset-0', { timeout: 5000 });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 审批 Hand 执行
|
||||
*/
|
||||
async approveHand(page: Page, approved: boolean, reason?: string): Promise<void> {
|
||||
const dialog = page.locator('[role="dialog"]').filter({
|
||||
hasText: /审批|approval|approve/i,
|
||||
}).or(
|
||||
page.locator('.fixed.inset-0').filter({ hasText: /审批|approval/i })
|
||||
);
|
||||
|
||||
if (await dialog.isVisible()) {
|
||||
if (!approved && reason) {
|
||||
const reasonInput = dialog.locator('textarea').or(
|
||||
dialog.locator('input[type="text"]')
|
||||
);
|
||||
await reasonInput.fill(reason);
|
||||
}
|
||||
|
||||
const actionBtn = approved
|
||||
? dialog.getByRole('button', { name: /批准|approve|yes|确认/i })
|
||||
: dialog.getByRole('button', { name: /拒绝|reject|no/i });
|
||||
|
||||
await actionBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================
|
||||
// 工作流相关操作
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 创建工作流
|
||||
*/
|
||||
async createWorkflow(
|
||||
page: Page,
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: Array<{ handName: string; params?: Record<string, unknown> }>;
|
||||
}
|
||||
): Promise<void> {
|
||||
await navigateToTab(page, '工作流');
|
||||
|
||||
// 点击创建按钮
|
||||
const createBtn = page.getByRole('button', { name: /创建|new|\+/i }).first();
|
||||
await createBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 填写名称
|
||||
const nameInput = page.locator('input').first();
|
||||
await nameInput.fill(data.name);
|
||||
|
||||
// 填写描述
|
||||
if (data.description) {
|
||||
const descInput = page.locator('textarea').first();
|
||||
if (await descInput.isVisible()) {
|
||||
await descInput.fill(data.description);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加步骤
|
||||
for (const step of data.steps) {
|
||||
const addStepBtn = page.getByRole('button', { name: /添加步骤|add step|\+/i });
|
||||
await addStepBtn.click();
|
||||
|
||||
// 选择 Hand
|
||||
const handSelector = page.locator('select').last().or(
|
||||
page.locator('[role="listbox"]').last()
|
||||
);
|
||||
await handSelector.click();
|
||||
await page.getByText(new RegExp(step.handName, 'i')).click();
|
||||
|
||||
// 填写参数(如果有)
|
||||
if (step.params) {
|
||||
const paramsInput = page.locator('textarea').filter({
|
||||
hasText: /{/,
|
||||
}).or(
|
||||
page.locator('input[placeholder*="JSON"]')
|
||||
);
|
||||
await paramsInput.fill(JSON.stringify(step.params));
|
||||
}
|
||||
}
|
||||
|
||||
// 保存
|
||||
const saveBtn = page.getByRole('button', { name: /保存|save/i });
|
||||
await saveBtn.click();
|
||||
},
|
||||
|
||||
/**
|
||||
* 执行工作流
|
||||
*/
|
||||
async executeWorkflow(page: Page, workflowId: string): Promise<void> {
|
||||
await navigateToTab(page, '工作流');
|
||||
|
||||
const workflowItem = page.locator(`[data-workflow-id="${workflowId}"]`).or(
|
||||
page.locator('[class*="workflow"]').filter({ hasText: workflowId })
|
||||
);
|
||||
|
||||
const executeBtn = workflowItem.getByRole('button', { name: /执行|run|execute/i });
|
||||
await executeBtn.click();
|
||||
},
|
||||
|
||||
// ============================================
|
||||
// 团队相关操作
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 创建团队
|
||||
*/
|
||||
async createTeam(
|
||||
page: Page,
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
pattern?: 'sequential' | 'parallel' | 'pipeline';
|
||||
}
|
||||
): Promise<void> {
|
||||
await navigateToTab(page, '团队');
|
||||
|
||||
// 查找创建按钮 (Plus 图标)
|
||||
const createBtn = page.locator('aside button').filter({
|
||||
has: page.locator('svg'),
|
||||
}).or(
|
||||
page.getByRole('button', { name: /\+/i })
|
||||
);
|
||||
|
||||
await createBtn.first().click();
|
||||
|
||||
// 等待创建界面出现 (.absolute.inset-0.bg-black/50)
|
||||
await page.waitForSelector('.absolute.inset-0, [role="dialog"]', { timeout: 5000 });
|
||||
|
||||
const dialog = page.locator('.absolute.inset-0, [role="dialog"]').last();
|
||||
|
||||
// 填写名称
|
||||
const nameInput = dialog.locator('input[type="text"]').first();
|
||||
await nameInput.fill(data.name);
|
||||
|
||||
// 选择模式
|
||||
if (data.pattern) {
|
||||
const patternSelector = dialog.locator('select').or(
|
||||
dialog.locator('[role="listbox"]')
|
||||
);
|
||||
await patternSelector.click();
|
||||
await page.getByText(new RegExp(data.pattern, 'i')).click();
|
||||
}
|
||||
|
||||
// 提交
|
||||
const submitBtn = dialog.getByRole('button', { name: /确认|创建|save/i });
|
||||
await submitBtn.click();
|
||||
},
|
||||
|
||||
/**
|
||||
* 选择团队
|
||||
*/
|
||||
async selectTeam(page: Page, teamName: string): Promise<void> {
|
||||
await navigateToTab(page, '团队');
|
||||
|
||||
const teamItem = page.locator('.w-full.p-2.rounded-lg').filter({
|
||||
hasText: new RegExp(teamName, 'i'),
|
||||
});
|
||||
|
||||
await teamItem.click();
|
||||
await page.waitForTimeout(300);
|
||||
},
|
||||
|
||||
// ============================================
|
||||
// 技能市场相关操作
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 搜索技能
|
||||
*/
|
||||
async searchSkill(page: Page, query: string): Promise<void> {
|
||||
await navigateToTab(page, '技能');
|
||||
|
||||
// 搜索框 (.pl-9 表示有搜索图标)
|
||||
const searchInput = page.locator('input.pl-9').or(
|
||||
page.locator('input[placeholder*="搜索"]')
|
||||
).or(
|
||||
page.locator('input[type="search"]')
|
||||
);
|
||||
|
||||
await searchInput.first().fill(query);
|
||||
await page.waitForTimeout(500);
|
||||
},
|
||||
|
||||
/**
|
||||
* 安装技能
|
||||
*/
|
||||
async installSkill(page: Page, skillName: string): Promise<void> {
|
||||
await navigateToTab(page, '技能');
|
||||
|
||||
// 技能卡片 (.border.rounded-lg)
|
||||
const skillCard = page.locator('.border.rounded-lg').filter({
|
||||
hasText: new RegExp(skillName, 'i'),
|
||||
});
|
||||
|
||||
const installBtn = skillCard.getByRole('button', { name: /安装|install/i });
|
||||
await installBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
},
|
||||
|
||||
/**
|
||||
* 卸载技能
|
||||
*/
|
||||
async uninstallSkill(page: Page, skillName: string): Promise<void> {
|
||||
await navigateToTab(page, '技能');
|
||||
|
||||
const skillCard = page.locator('.border.rounded-lg').filter({
|
||||
hasText: new RegExp(skillName, 'i'),
|
||||
});
|
||||
|
||||
const uninstallBtn = skillCard.getByRole('button', { name: /卸载|uninstall/i });
|
||||
await uninstallBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
},
|
||||
|
||||
// ============================================
|
||||
// 设置相关操作
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 打开设置页面
|
||||
*/
|
||||
async openSettings(page: Page): Promise<void> {
|
||||
// 底部用户栏中的设置按钮
|
||||
const settingsBtn = page.locator('aside button').filter({
|
||||
hasText: /设置|settings|⚙/i,
|
||||
}).or(
|
||||
page.locator('.p-3.border-t button')
|
||||
);
|
||||
|
||||
await settingsBtn.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
},
|
||||
|
||||
/**
|
||||
* 保存设置
|
||||
*/
|
||||
async saveSettings(page: Page): Promise<void> {
|
||||
const saveBtn = page.getByRole('button', { name: /保存|save|apply/i });
|
||||
await saveBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
},
|
||||
|
||||
// ============================================
|
||||
// 通用操作
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 关闭对话框
|
||||
*/
|
||||
async closeModal(page: Page): Promise<void> {
|
||||
const closeBtn = page.locator('[role="dialog"] button, .fixed.inset-0 button').filter({
|
||||
has: page.locator('svg'),
|
||||
}).or(
|
||||
page.getByRole('button', { name: /关闭|close|cancel|取消/i })
|
||||
);
|
||||
|
||||
if (await closeBtn.first().isVisible()) {
|
||||
await closeBtn.first().click();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 按 Escape 键
|
||||
*/
|
||||
async pressEscape(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
},
|
||||
|
||||
/**
|
||||
* 刷新页面并等待就绪
|
||||
*/
|
||||
async refreshAndWait(page: Page): Promise<void> {
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
},
|
||||
|
||||
/**
|
||||
* 等待元素可见
|
||||
*/
|
||||
async waitForVisible(page: Page, selector: string, timeout = 5000): Promise<void> {
|
||||
await page.waitForSelector(selector, { state: 'visible', timeout });
|
||||
},
|
||||
|
||||
/**
|
||||
* 截图
|
||||
*/
|
||||
async takeScreenshot(page: Page, name: string): Promise<void> {
|
||||
await page.screenshot({ path: `test-results/${name}.png` });
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user