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;

View File

@@ -15,6 +15,7 @@ import { setupMockGateway, mockAgentMessageResponse, mockResponses, mockErrorRes
import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors';
import { userActions, waitForAppReady, skipOnboarding, navigateToTab } from '../utils/user-actions';
import { networkHelpers } from '../utils/network-helpers';
import { setupMockGatewayWithWebSocket, setWebSocketConfig } from '../fixtures/mock-gateway';
// Test configuration
test.setTimeout(120000);
@@ -168,16 +169,15 @@ test.describe('Chat Message Tests', () => {
test.describe.configure({ mode: 'parallel' }); // Parallel for isolation
test('CHAT-MSG-01: Send message and receive response', async ({ page }) => {
// Setup mock gateway
await setupMockGateway(page);
// Setup mock gateway with WebSocket support
const mockResponse = 'This is a mock AI response for testing purposes.';
await setupMockGatewayWithWebSocket(page, {
wsConfig: { responseContent: mockResponse, streaming: true }
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
// Mock agent message response
const mockResponse = 'This is a mock AI response for testing purposes.';
await mockAgentMessageResponse(page, mockResponse);
// Find chat input
const chatInput = page.locator('textarea').first();
await expect(chatInput).toBeVisible({ timeout: 10000 });
@@ -209,12 +209,13 @@ test.describe('Chat Message Tests', () => {
});
test('CHAT-MSG-02: Message updates store state', async ({ page }) => {
// Setup fresh page
await setupMockGateway(page);
// Setup fresh page with WebSocket support
await setupMockGatewayWithWebSocket(page, {
wsConfig: { responseContent: 'Store state test response', streaming: true }
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
await mockAgentMessageResponse(page, 'Store state test response');
// Clear any existing messages first
await storeInspectors.clearStore(page, STORE_NAMES.CHAT);
@@ -243,12 +244,13 @@ test.describe('Chat Message Tests', () => {
});
test('CHAT-MSG-03: Streaming response indicator', async ({ page }) => {
// Setup fresh page
await setupMockGateway(page);
// Setup fresh page with WebSocket support
await setupMockGatewayWithWebSocket(page, {
wsConfig: { responseContent: 'Streaming test response with longer content', streaming: true, chunkDelay: 100 }
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
await mockAgentMessageResponse(page, 'Streaming test response with longer content');
const chatInput = page.locator('textarea').first();
await chatInput.fill('Write a short poem');
@@ -276,8 +278,10 @@ test.describe('Chat Message Tests', () => {
});
test('CHAT-MSG-04: Error handling for failed message', async ({ page }) => {
// Setup fresh page with error mock
await mockErrorResponse(page, 'health', 500, 'Internal Server Error');
// Setup fresh page with error mock - WebSocket will simulate error
await setupMockGatewayWithWebSocket(page, {
wsConfig: { simulateError: true, errorMessage: 'WebSocket connection failed' }
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
@@ -294,12 +298,13 @@ test.describe('Chat Message Tests', () => {
});
test('CHAT-MSG-05: Multiple messages in sequence', async ({ page }) => {
// Setup fresh page
await setupMockGateway(page);
// Setup fresh page with WebSocket support
await setupMockGatewayWithWebSocket(page, {
wsConfig: { responseContent: 'Response to sequential message', streaming: true }
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
await mockAgentMessageResponse(page, 'Response to sequential message');
// Clear existing messages
await storeInspectors.clearStore(page, STORE_NAMES.CHAT);