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:
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user