feat: production readiness improvements

## Error Handling
- Add GlobalErrorBoundary with error classification and recovery
- Add custom error types (SecurityError, ConnectionError, TimeoutError)
- Fix ErrorAlert component syntax errors

## Offline Mode
- Add offlineStore for offline state management
- Implement message queue with localStorage persistence
- Add exponential backoff reconnection (1s→60s)
- Add OfflineIndicator component with status display
- Queue messages when offline, auto-retry on reconnect

## Security Hardening
- Add AES-256-GCM encryption for chat history storage
- Add secure API key storage with OS keychain integration
- Add security audit logging system
- Add XSS prevention and input validation utilities
- Add rate limiting and token generation helpers

## CI/CD (Gitea Actions)
- Add .gitea/workflows/ci.yml for continuous integration
- Add .gitea/workflows/release.yml for release automation
- Support Windows Tauri build and release

## UI Components
- Add LoadingSpinner, LoadingOverlay, LoadingDots components
- Add MessageSkeleton, ConversationListSkeleton skeletons
- Add EmptyMessages, EmptyConversations empty states
- Integrate loading states in ChatArea and ConversationList

## E2E Tests
- Fix WebSocket mock for streaming response tests
- Fix approval endpoint route matching
- Add store state exposure for testing
- All 19 core-features tests now passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-22 00:03:22 +08:00
parent ce562e8bfc
commit 185763868a
27 changed files with 5725 additions and 268 deletions

View File

@@ -4,7 +4,7 @@
* 基于实际 API 端点: http://127.0.0.1:50051
*/
import { Page } from '@playwright/test';
import { Page, WebSocketRoute } from '@playwright/test';
/**
* Mock 响应数据模板 - 基于实际 API 响应格式
@@ -440,7 +440,7 @@ export async function setupMockGateway(
});
// Mock Hand 运行状态 - GET /api/hands/{name}/runs/{runId}
await page.route('**/api/hands/*/runs/*', async (route) => {
await page.route('**/api/hands/*/runs/**', async (route) => {
if (simulateDelay) await delay(delayMs);
const method = route.request().method();
const url = route.request().url();
@@ -468,6 +468,13 @@ export async function setupMockGateway(
completedAt: new Date().toISOString(),
}),
});
} else {
// Fallback for any other requests
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'ok' }),
});
}
});
@@ -481,6 +488,26 @@ export async function setupMockGateway(
});
});
// Mock Hand 审批 - POST /api/hands/{name}/runs/{runId}/approve
await page.route('**/api/hands/*/runs/*/approve', async (route) => {
if (simulateDelay) await delay(delayMs);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'approved' }),
});
});
// Mock Hand 取消 - POST /api/hands/{name}/runs/{runId}/cancel
await page.route('**/api/hands/*/runs/*/cancel', async (route) => {
if (simulateDelay) await delay(delayMs);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'cancelled' }),
});
});
// ========================================
// Workflow 端点
// ========================================
@@ -777,6 +804,153 @@ export async function mockTimeout(page: Page, path: string): Promise<void> {
});
}
/**
* WebSocket Mock 配置
*/
export interface MockWebSocketConfig {
/** 模拟响应内容 */
responseContent?: string;
/** 是否模拟流式响应 */
streaming?: boolean;
/** 流式响应的块延迟 (ms) */
chunkDelay?: number;
/** 是否模拟错误 */
simulateError?: boolean;
/** 错误消息 */
errorMessage?: string;
}
/**
* 存储 WebSocket Mock 配置
*/
let wsConfig: MockWebSocketConfig = {
responseContent: 'This is a mock streaming response from the WebSocket server.',
streaming: true,
chunkDelay: 50,
};
/**
* 设置 WebSocket Mock 配置
*/
export function setWebSocketConfig(config: Partial<MockWebSocketConfig>): void {
wsConfig = { ...wsConfig, ...config };
}
/**
* Mock Agent WebSocket 流式响应
* 使用 Playwright 的 routeWebSocket API 拦截 WebSocket 连接
*/
export async function mockAgentWebSocket(
page: Page,
config: Partial<MockWebSocketConfig> = {}
): Promise<void> {
const finalConfig = { ...wsConfig, ...config };
await page.routeWebSocket('**/api/agents/*/ws', async (ws: WebSocketRoute) => {
// Handle incoming messages from the page
ws.onMessage(async (message) => {
try {
const data = JSON.parse(message);
// Handle chat message
if (data.type === 'message' || data.content) {
// Send connected event first
ws.send(JSON.stringify({
type: 'connected',
agent_id: 'default-agent',
}));
// Simulate error if configured
if (finalConfig.simulateError) {
ws.send(JSON.stringify({
type: 'error',
message: finalConfig.errorMessage || 'Mock WebSocket error',
}));
ws.close({ code: 1011, reason: 'Error' });
return;
}
const responseText = finalConfig.responseContent || 'Mock response';
if (finalConfig.streaming) {
// Send typing indicator
ws.send(JSON.stringify({
type: 'typing',
state: 'start',
}));
// Stream response in chunks
const words = responseText.split(' ');
let current = '';
for (let i = 0; i < words.length; i++) {
current += (current ? ' ' : '') + words[i];
// Send text delta every few words
if (current.length >= 10 || i === words.length - 1) {
await new Promise(resolve => setTimeout(resolve, finalConfig.chunkDelay || 50));
ws.send(JSON.stringify({
type: 'text_delta',
content: current,
}));
current = '';
}
}
// Send typing stop
ws.send(JSON.stringify({
type: 'typing',
state: 'stop',
}));
// Send phase done
ws.send(JSON.stringify({
type: 'phase',
phase: 'done',
}));
} else {
// Non-streaming response
ws.send(JSON.stringify({
type: 'response',
content: responseText,
input_tokens: 100,
output_tokens: responseText.split(' ').length,
}));
}
// Close connection after response
ws.close({ code: 1000, reason: 'Stream complete' });
}
} catch (err) {
console.error('WebSocket mock error:', err);
ws.send(JSON.stringify({
type: 'error',
message: 'Failed to parse message',
}));
}
});
// Handle connection close from page
ws.onClose(() => {
// Clean up
});
});
}
/**
* 设置完整的 Gateway Mock (包括 WebSocket)
*/
export async function setupMockGatewayWithWebSocket(
page: Page,
config: MockGatewayConfig & { wsConfig?: Partial<MockWebSocketConfig> } = {}
): Promise<void> {
// Setup HTTP mocks
await setupMockGateway(page, config);
// Setup WebSocket mock
await mockAgentWebSocket(page, config.wsConfig || {});
}
// 辅助函数
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));

View File

@@ -68,8 +68,23 @@ export const storeInspectors = {
/**
* 获取持久化的 Chat Store 状态
* 优先从运行时获取,如果不可用则从 localStorage 获取
*/
async getChatState<T = unknown>(page: Page): Promise<T | null> {
// First try to get runtime state (more reliable for E2E tests)
const runtimeState = await page.evaluate(() => {
const stores = (window as any).__ZCLAW_STORES__;
if (stores && stores.chat) {
return stores.chat.getState() as T;
}
return null;
});
if (runtimeState) {
return runtimeState;
}
// Fallback to localStorage
return page.evaluate((key) => {
const stored = localStorage.getItem(key);
if (!stored) return null;