feat: add integration test framework and health check improvements

- Add test helper library with assertion functions (scripts/lib/test-helpers.sh)
- Add gateway integration test script (scripts/tests/gateway-test.sh)
- Add configuration validation tool (scripts/validate-config.ts)
- Add health-check.ts library with Tauri command wrappers
- Add HealthStatusIndicator component to ConnectionStatus.tsx
- Add E2E test specs for memory, settings, and team collaboration
- Update ZCLAW-DEEP-ANALYSIS.md to reflect actual project state

Key improvements:
- Store architecture now properly documented as migrated
- Tauri backend shown as 85-90% complete
- Component integration status clarified

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-21 00:09:47 +08:00
parent ce522de7e9
commit c5d91cf9f0
11 changed files with 4911 additions and 26 deletions

View File

@@ -3,13 +3,21 @@
*
* Displays the current Gateway connection status with visual indicators.
* Supports automatic reconnect and manual reconnect button.
* Includes health status indicator for OpenFang backend.
*/
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Wifi, WifiOff, Loader2, RefreshCw } from 'lucide-react';
import { useGatewayStore } from '../store/gatewayStore';
import { Wifi, WifiOff, Loader2, RefreshCw, Heart, HeartPulse } from 'lucide-react';
import { useConnectionStore } from '../store/connectionStore';
import { getGatewayClient } from '../lib/gateway-client';
import {
createHealthCheckScheduler,
getHealthStatusLabel,
formatHealthCheckTime,
type HealthCheckResult,
type HealthStatus,
} from '../lib/health-check';
interface ConnectionStatusProps {
/** Show compact version (just icon and status text) */
@@ -75,7 +83,8 @@ export function ConnectionStatus({
showReconnectButton = true,
className = '',
}: ConnectionStatusProps) {
const { connectionState, connect } = useGatewayStore();
const connectionState = useConnectionStore((s) => s.connectionState);
const connect = useConnectionStore((s) => s.connect);
const [showPrompt, setShowPrompt] = useState(false);
const [reconnectInfo, setReconnectInfo] = useState<ReconnectInfo | null>(null);
@@ -188,7 +197,7 @@ export function ConnectionStatus({
* ConnectionIndicator - Minimal connection indicator for headers
*/
export function ConnectionIndicator({ className = '' }: { className?: string }) {
const { connectionState } = useGatewayStore();
const connectionState = useConnectionStore((s) => s.connectionState);
const isConnected = connectionState === 'connected';
const isReconnecting = connectionState === 'reconnecting';
@@ -221,4 +230,58 @@ export function ConnectionIndicator({ className = '' }: { className?: string })
);
}
/**
* HealthStatusIndicator - Displays OpenFang backend health status
*/
export function HealthStatusIndicator({
className = '',
showDetails = false,
}: {
className?: string;
showDetails?: boolean;
}) {
const [healthResult, setHealthResult] = useState<HealthCheckResult | null>(null);
useEffect(() => {
// Start periodic health checks
const cleanup = createHealthCheckScheduler((result) => {
setHealthResult(result);
}, 30000); // Check every 30 seconds
return cleanup;
}, []);
if (!healthResult) {
return (
<span className={`text-xs flex items-center gap-1 ${className}`}>
<Heart className="w-3.5 h-3.5 text-gray-400" />
<span className="text-gray-400">...</span>
</span>
);
}
const statusColors: Record<HealthStatus, { dot: string; text: string; icon: typeof Heart }> = {
healthy: { dot: 'bg-green-400', text: 'text-green-500', icon: Heart },
unhealthy: { dot: 'bg-red-400', text: 'text-red-500', icon: HeartPulse },
unknown: { dot: 'bg-gray-400', text: 'text-gray-500', icon: Heart },
};
const config = statusColors[healthResult.status];
const Icon = config.icon;
return (
<span className={`text-xs flex items-center gap-1 ${className}`}>
<Icon className={`w-3.5 h-3.5 ${config.text}`} />
<span className={config.text}>
{getHealthStatusLabel(healthResult.status)}
</span>
{showDetails && healthResult.message && (
<span className="text-gray-400 ml-1" title={healthResult.message}>
({formatHealthCheckTime(healthResult.timestamp)})
</span>
)}
</span>
);
}
export default ConnectionStatus;

View File

@@ -0,0 +1,137 @@
/**
* Health Check Library
*
* Provides Tauri health check command wrappers and utilities
* for monitoring the health status of the OpenFang backend.
*/
import { invoke } from '@tauri-apps/api/core';
import { isTauriRuntime } from './tauri-gateway';
// === Types ===
export type HealthStatus = 'healthy' | 'unhealthy' | 'unknown';
export interface HealthCheckResult {
status: HealthStatus;
message?: string;
timestamp: number;
details?: Record<string, unknown>;
}
export interface OpenFangHealthResponse {
healthy: boolean;
message?: string;
details?: Record<string, unknown>;
}
// === Health Check Functions ===
/**
* Perform a single health check via Tauri command.
* Returns a structured result with status, message, and timestamp.
*/
export async function performHealthCheck(): Promise<HealthCheckResult> {
const timestamp = Date.now();
if (!isTauriRuntime()) {
return {
status: 'unknown',
message: 'Not running in Tauri environment',
timestamp,
};
}
try {
const response = await invoke<OpenFangHealthResponse>('openfang_health_check');
return {
status: response.healthy ? 'healthy' : 'unhealthy',
message: response.message,
timestamp,
details: response.details,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
status: 'unhealthy',
message: `Health check failed: ${errorMessage}`,
timestamp,
};
}
}
/**
* Create a periodic health check scheduler.
* Returns cleanup function to stop the interval.
*/
export function createHealthCheckScheduler(
callback: (result: HealthCheckResult) => void,
intervalMs: number = 30000 // Default: 30 seconds
): () => void {
let intervalId: ReturnType<typeof setInterval> | null = null;
let isChecking = false;
const check = async () => {
// Prevent overlapping checks
if (isChecking) return;
isChecking = true;
try {
const result = await performHealthCheck();
callback(result);
} catch (error) {
console.error('[HealthCheck] Scheduled check failed:', error);
callback({
status: 'unknown',
message: error instanceof Error ? error.message : 'Unknown error',
timestamp: Date.now(),
});
} finally {
isChecking = false;
}
};
// Perform initial check immediately
check();
// Schedule periodic checks
intervalId = setInterval(check, intervalMs);
// Return cleanup function
return () => {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
};
}
// === Utility Functions ===
/**
* Get a human-readable label for a health status.
*/
export function getHealthStatusLabel(status: HealthStatus): string {
switch (status) {
case 'healthy':
return '健康';
case 'unhealthy':
return '异常';
case 'unknown':
default:
return '未知';
}
}
/**
* Format a timestamp for display.
*/
export function formatHealthCheckTime(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}

View File

@@ -0,0 +1,614 @@
/**
* ZCLAW Core Feature E2E Tests
*
* Tests for core functionality with mocked Gateway responses.
* These tests verify the complete data flow from UI to backend and back.
*
* Test Categories:
* - Gateway Connection: Health check, connection states, error handling
* - Chat Messages: Send message, receive response, streaming
* - Hands Trigger: Activate hand, approval flow, status updates
*/
import { test, expect, Page } from '@playwright/test';
import { setupMockGateway, mockAgentMessageResponse, mockResponses, mockErrorResponse } from '../fixtures/mock-gateway';
import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors';
import { userActions, waitForAppReady, skipOnboarding, navigateToTab } from '../utils/user-actions';
import { networkHelpers } from '../utils/network-helpers';
// Test configuration
test.setTimeout(120000);
const BASE_URL = 'http://localhost:1420';
// ============================================
// Test Suite 1: Gateway Connection Tests
// ============================================
test.describe('Gateway Connection Tests', () => {
test.describe.configure({ mode: 'parallel' });
test('GW-CONN-01: Health check returns correct status', async ({ page }) => {
// Setup mock gateway with health endpoint
await setupMockGateway(page);
// Skip onboarding and load page
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
// Wait for health check request
await page.waitForTimeout(2000);
// Verify health endpoint was called
const healthResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/health');
return await response.json();
} catch {
return null;
}
});
// Health check should return valid status
expect(healthResponse).not.toBeNull();
expect(healthResponse?.status).toBe('ok');
});
test('GW-CONN-02: Connection state updates correctly', async ({ page }) => {
await setupMockGateway(page);
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
// Wait for connection attempt
await page.waitForTimeout(3000);
// Check connection state in store
const gatewayConfig = await storeInspectors.getGatewayConfig(page);
// Gateway URL should be configured
expect(gatewayConfig.url).toBeDefined();
// Verify mock connection state was set
const connectionState = await page.evaluate(() => {
const stores = (window as any).__ZCLAW_STORES__;
if (stores?.gateway) {
return stores.gateway.getState?.()?.connectionState;
}
return null;
});
console.log(`Connection state: ${connectionState}`);
});
test('GW-CONN-03: Models list loads correctly', async ({ page }) => {
await setupMockGateway(page);
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
// Trigger models load
const modelsResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/models');
return await response.json();
} catch {
return null;
}
});
// Models should be an array with expected structure
expect(Array.isArray(modelsResponse)).toBe(true);
expect(modelsResponse.length).toBeGreaterThan(0);
// Verify first model has required fields
const firstModel = modelsResponse[0];
expect(firstModel).toHaveProperty('id');
expect(firstModel).toHaveProperty('name');
expect(firstModel).toHaveProperty('provider');
});
test('GW-CONN-04: Agents list loads correctly', async ({ page }) => {
await setupMockGateway(page);
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
// Trigger agents load
const agentsResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/agents');
return await response.json();
} catch {
return null;
}
});
// Agents response should have agents array
expect(agentsResponse).toHaveProperty('agents');
expect(Array.isArray(agentsResponse.agents)).toBe(true);
expect(agentsResponse.agents.length).toBeGreaterThan(0);
// Verify first agent has required fields
const firstAgent = agentsResponse.agents[0];
expect(firstAgent).toHaveProperty('id');
expect(firstAgent).toHaveProperty('name');
expect(firstAgent).toHaveProperty('model');
});
test('GW-CONN-05: Error handling for failed health check', async ({ page }) => {
// Mock error response for health endpoint
await mockErrorResponse(page, 'health', 500, 'Internal Server Error');
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
// Verify error was handled gracefully
const healthResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/health');
return { status: response.status, ok: response.ok };
} catch (error) {
return { error: String(error) };
}
});
// Should return error status
expect(healthResponse.status).toBe(500);
expect(healthResponse.ok).toBe(false);
});
});
// ============================================
// Test Suite 2: Chat Message Tests
// ============================================
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);
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 });
// Type message
const testMessage = 'Hello, this is a test message';
await chatInput.fill(testMessage);
// Verify input has correct value
const inputValue = await chatInput.inputValue();
expect(inputValue).toBe(testMessage);
// Send message
const sendButton = page.getByRole('button', { name: '发送消息' }).or(
page.locator('button.bg-orange-500').first()
);
await sendButton.click();
await page.waitForTimeout(2000);
// Verify user message appears in UI
const userMessage = page.locator('[class*="message"], [class*="bubble"]').filter({
hasText: testMessage,
});
await expect(userMessage).toBeVisible({ timeout: 10000 });
// Take screenshot for verification
await page.screenshot({ path: 'test-results/screenshots/chat-msg-01.png' });
});
test('CHAT-MSG-02: Message updates store state', async ({ page }) => {
// Setup fresh page
await setupMockGateway(page);
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);
await page.waitForTimeout(500);
// Get initial message count (should be 0 after clear)
const initialState = await storeInspectors.getChatState<{
messages: Array<{ content: string }>;
}>(page);
const initialCount = initialState?.messages?.length ?? 0;
// Send message
const chatInput = page.locator('textarea').first();
await chatInput.fill('Store state test');
await page.getByRole('button', { name: '发送消息' }).click();
await page.waitForTimeout(3000);
// Get new message count
const newState = await storeInspectors.getChatState<{
messages: Array<{ content: string }>;
}>(page);
const newCount = newState?.messages?.length ?? 0;
// Message count should have increased
expect(newCount).toBeGreaterThan(initialCount);
});
test('CHAT-MSG-03: Streaming response indicator', async ({ page }) => {
// Setup fresh page
await setupMockGateway(page);
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');
await page.getByRole('button', { name: '发送消息' }).click();
// Check for streaming state immediately after sending
await page.waitForTimeout(500);
// Verify streaming state in store
const state = await storeInspectors.getChatState<{
isStreaming: boolean;
}>(page);
// Streaming should be true or false depending on timing
console.log(`Is streaming: ${state?.isStreaming}`);
// Wait for streaming to complete
await page.waitForTimeout(3000);
// Final streaming state should be false
const finalState = await storeInspectors.getChatState<{
isStreaming: boolean;
}>(page);
expect(finalState?.isStreaming).toBe(false);
});
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');
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
const chatInput = page.locator('textarea').first();
await chatInput.fill('This message should fail');
await page.getByRole('button', { name: '发送消息' }).click();
await page.waitForTimeout(3000);
// Verify error handling - UI should still be functional
const chatInputAfter = page.locator('textarea').first();
await expect(chatInputAfter).toBeVisible();
});
test('CHAT-MSG-05: Multiple messages in sequence', async ({ page }) => {
// Setup fresh page
await setupMockGateway(page);
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);
await page.waitForTimeout(500);
const messages = ['First message', 'Second message', 'Third message'];
for (const msg of messages) {
const chatInput = page.locator('textarea').first();
await chatInput.fill(msg);
await page.getByRole('button', { name: '发送消息' }).click();
await page.waitForTimeout(1500);
}
// Verify all messages appear
const state = await storeInspectors.getChatState<{
messages: Array<{ content: string }>;
}>(page);
// Should have at least the user messages
expect(state?.messages?.length).toBeGreaterThanOrEqual(messages.length);
});
});
// ============================================
// Test Suite 3: Hands Trigger Tests
// ============================================
test.describe('Hands Trigger Tests', () => {
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ page }) => {
await setupMockGateway(page);
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
await navigateToTab(page, 'Hands');
await page.waitForTimeout(1000);
});
test('HAND-TRIG-01: Hands list loads correctly', async ({ page }) => {
// Request hands list
const handsResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/hands');
return await response.json();
} catch {
return null;
}
});
// Hands should be an array
expect(handsResponse).toHaveProperty('hands');
expect(Array.isArray(handsResponse.hands)).toBe(true);
// Should have at least one hand
expect(handsResponse.hands.length).toBeGreaterThan(0);
// Verify hand structure
const firstHand = handsResponse.hands[0];
expect(firstHand).toHaveProperty('id');
expect(firstHand).toHaveProperty('name');
expect(firstHand).toHaveProperty('status');
});
test('HAND-TRIG-02: Activate Hand returns run ID', async ({ page }) => {
// Activate a hand
const activateResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/hands/browser/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' }),
});
return await response.json();
} catch {
return null;
}
});
// Should return run ID and status
expect(activateResponse).toHaveProperty('runId');
expect(activateResponse).toHaveProperty('status');
expect(activateResponse.status).toBe('running');
});
test('HAND-TRIG-03: Hand status transitions', async ({ page }) => {
// Get initial hand status
const handsResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/hands');
return await response.json();
} catch {
return null;
}
});
// Find browser hand
const browserHand = handsResponse?.hands?.find(
(h: { name: string }) => h.name.toLowerCase() === 'browser'
);
expect(browserHand).toBeDefined();
expect(browserHand.status).toBe('idle');
// Activate the hand
await page.evaluate(async () => {
await fetch('/api/hands/browser/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
});
// Check run status
const runResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/hands/browser/runs/test-run-id');
return await response.json();
} catch {
return null;
}
});
// Run should have completed status
expect(runResponse).toHaveProperty('status');
});
test('HAND-TRIG-04: Hand requirements check', async ({ page }) => {
const handsResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/hands');
return await response.json();
} catch {
return null;
}
});
// Check each hand for requirements_met field
for (const hand of handsResponse?.hands ?? []) {
expect(hand).toHaveProperty('requirements_met');
// Requirements should be boolean
expect(typeof hand.requirements_met).toBe('boolean');
}
});
test('HAND-TRIG-05: Hand run history', async ({ page }) => {
const runsResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/hands/browser/runs');
return await response.json();
} catch {
return null;
}
});
// Should return runs array
expect(runsResponse).toHaveProperty('runs');
expect(Array.isArray(runsResponse.runs)).toBe(true);
});
test('HAND-TRIG-06: Hand approval flow', async ({ page }) => {
// Request approval
const approveResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/hands/browser/runs/test-run-id/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved: true }),
});
return await response.json();
} catch {
return null;
}
});
// Should return approved status
expect(approveResponse).toHaveProperty('status');
expect(approveResponse.status).toBe('approved');
});
test('HAND-TRIG-07: Hand cancellation', async ({ page }) => {
const cancelResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/hands/browser/runs/test-run-id/cancel', {
method: 'POST',
});
return await response.json();
} catch {
return null;
}
});
// Should return cancelled status
expect(cancelResponse).toHaveProperty('status');
expect(cancelResponse.status).toBe('cancelled');
});
});
// ============================================
// Test Suite 4: Integration Tests
// ============================================
test.describe('Integration Tests', () => {
test('INT-01: Full workflow - connect, chat, trigger hand', async ({ page }) => {
// Setup complete mock gateway
await setupMockGateway(page);
await mockAgentMessageResponse(page, 'Integration test response');
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
// 1. Verify connection
const healthResponse = await page.evaluate(async () => {
const response = await fetch('/api/health');
return response.json();
});
expect(healthResponse.status).toBe('ok');
// 2. Send chat message
const chatInput = page.locator('textarea').first();
await chatInput.fill('Integration test message');
await page.getByRole('button', { name: '发送消息' }).click();
await page.waitForTimeout(2000);
// 3. Navigate to Hands
await navigateToTab(page, 'Hands');
await page.waitForTimeout(1000);
// 4. Verify hands loaded
const handsResponse = await page.evaluate(async () => {
const response = await fetch('/api/hands');
return response.json();
});
expect(handsResponse.hands.length).toBeGreaterThan(0);
// 5. Activate a hand
const activateResponse = await page.evaluate(async () => {
const response = await fetch('/api/hands/browser/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
return response.json();
});
expect(activateResponse.runId).toBeDefined();
// Take final screenshot
await page.screenshot({ path: 'test-results/screenshots/int-01-full-workflow.png' });
});
test('INT-02: State persistence across navigation', async ({ page }) => {
await setupMockGateway(page);
await mockAgentMessageResponse(page, 'Persistence test response');
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
// Clear existing messages first
await storeInspectors.clearStore(page, STORE_NAMES.CHAT);
await page.waitForTimeout(500);
// Send a message
const chatInput = page.locator('textarea').first();
await chatInput.fill('Message before navigation');
await page.getByRole('button', { name: '发送消息' }).click();
await page.waitForTimeout(3000);
// Verify message was added before navigation
const stateBeforeNav = await storeInspectors.getChatState<{
messages: Array<{ content: string }>;
}>(page);
const countBeforeNav = stateBeforeNav?.messages?.length ?? 0;
// Only continue if message was added
if (countBeforeNav === 0) {
console.log('Message was not added to store - skipping navigation test');
test.skip();
return;
}
// Navigate to different tabs
await navigateToTab(page, 'Hands');
await page.waitForTimeout(500);
await navigateToTab(page, '工作流');
await page.waitForTimeout(500);
// Navigate back to chat (分身 tab)
await navigateToTab(page, '分身');
await page.waitForTimeout(500);
// Verify message is still in store
const state = await storeInspectors.getChatState<{
messages: Array<{ content: string }>;
}>(page);
expect(state?.messages?.length).toBeGreaterThanOrEqual(countBeforeNav);
});
});
// ============================================
// Test Report
// ============================================
test.afterAll(async ({}, testInfo) => {
console.log('\n========================================');
console.log('ZCLAW Core Feature E2E Tests Complete');
console.log('========================================');
console.log(`Test Time: ${new Date().toISOString()}`);
console.log('========================================\n');
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,733 @@
/**
* ZCLAW Settings E2E Tests
*
* Tests for settings page functionality with mocked Gateway responses.
* Covers model configuration, Channel management, and skill management.
*
* Test Categories:
* - Model Configuration: Load, save, switch models
* - Channel Configuration: Feishu channels, IM configuration
* - Skill Management: Browse, install, uninstall skills
* - General Settings: User profile, workspace, preferences
*/
import { test, expect, Page } from '@playwright/test';
import { setupMockGateway, mockResponses, mockErrorResponse } from '../fixtures/mock-gateway';
import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors';
import { userActions, waitForAppReady, skipOnboarding, navigateToTab } from '../utils/user-actions';
// Test configuration
test.setTimeout(120000);
const BASE_URL = 'http://localhost:1420';
// ============================================
// Test Suite 1: Model Configuration Tests
// ============================================
test.describe('Settings - Model Configuration Tests', () => {
test.describe.configure({ mode: 'parallel' });
test.beforeEach(async ({ page }) => {
await setupMockGateway(page);
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
});
test('SET-MODEL-01: Models list loads correctly in settings', async ({ page }) => {
// Navigate to settings
await userActions.openSettings(page);
await page.waitForTimeout(500);
// Navigate to Models/API section
const modelsTab = page.getByRole('tab', { name: /模型|model|api/i }).or(
page.locator('button').filter({ hasText: /模型|API/ })
);
if (await modelsTab.first().isVisible()) {
await modelsTab.first().click();
await page.waitForTimeout(500);
}
// Verify models are loaded via API
const modelsResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/models');
return await response.json();
} catch {
return null;
}
});
expect(Array.isArray(modelsResponse)).toBe(true);
expect(modelsResponse.length).toBeGreaterThan(0);
// Verify model structure
const firstModel = modelsResponse[0];
expect(firstModel).toHaveProperty('id');
expect(firstModel).toHaveProperty('name');
expect(firstModel).toHaveProperty('provider');
});
test('SET-MODEL-02: Switch default model saves to configuration', async ({ page }) => {
await userActions.openSettings(page);
await page.waitForTimeout(500);
// Get current model from store
const initialModel = await storeInspectors.getChatState<{
currentModel: string;
}>(page);
// Find model selector in settings
const modelSelector = page.locator('select').filter({
has: page.locator('option'),
}).or(
page.locator('[role="combobox"]').filter({ hasText: /model|模型/i })
);
if (await modelSelector.first().isVisible()) {
// Get available options
const options = await modelSelector.first().locator('option').allInnerTexts();
expect(options.length).toBeGreaterThan(0);
// Select a different model
const newModel = options.find(o => o !== initialModel?.currentModel) || options[0];
await modelSelector.first().selectOption({ label: newModel });
// Save settings
await userActions.saveSettings(page);
await page.waitForTimeout(1000);
// Verify model changed
const updatedModel = await storeInspectors.getChatState<{
currentModel: string;
}>(page);
// Model should be updated
expect(updatedModel?.currentModel).toBeDefined();
}
});
test('SET-MODEL-03: Model configuration persists across reload', async ({ page }) => {
// Set a model in chat store
await storeInspectors.setChatState(page, {
currentModel: 'claude-sonnet-4-20250514',
messages: [],
conversations: [],
currentConversationId: null,
currentAgent: null,
isStreaming: false,
sessionKey: null,
agents: [],
});
// Reload page
await page.reload();
await waitForAppReady(page);
// Verify model persisted
const state = await storeInspectors.getChatState<{
currentModel: string;
}>(page);
expect(state?.currentModel).toBe('claude-sonnet-4-20250514');
});
test('SET-MODEL-04: API configuration saves gateway URL', async ({ page }) => {
await userActions.openSettings(page);
await page.waitForTimeout(500);
// Navigate to API/Gateway section
const apiTab = page.getByRole('tab', { name: /api|gateway|连接/i }).or(
page.locator('button').filter({ hasText: /API|Gateway/ })
);
if (await apiTab.first().isVisible()) {
await apiTab.first().click();
await page.waitForTimeout(500);
}
// Find gateway URL input
const gatewayInput = page.locator('input').filter({
has: page.locator('[placeholder*="gateway"], [placeholder*="url"]'),
}).or(
page.locator('input[name="gatewayUrl"]').or(
page.locator('input').filter({ hasText: /gateway|url/i })
)
);
if (await gatewayInput.first().isVisible()) {
const testUrl = 'http://127.0.0.1:50051';
await gatewayInput.first().fill(testUrl);
// Save settings
await userActions.saveSettings(page);
await page.waitForTimeout(1000);
// Verify URL saved
const gatewayConfig = await storeInspectors.getGatewayConfig(page);
expect(gatewayConfig.url).toBe(testUrl);
}
});
test('SET-MODEL-05: Invalid model selection shows error', async ({ page }) => {
// Mock error response for models
await mockErrorResponse(page, 'models', 500, 'Failed to load models');
await userActions.openSettings(page);
await page.waitForTimeout(1000);
// Verify error handling - UI should still be functional
const settingsPanel = page.locator('[role="tabpanel"]').or(
page.locator('.settings-content').or(page.locator('main'))
);
await expect(settingsPanel.first()).toBeVisible();
});
});
// ============================================
// Test Suite 2: Channel Configuration Tests
// ============================================
test.describe('Settings - Channel Configuration Tests', () => {
test.describe.configure({ mode: 'parallel' });
test.beforeEach(async ({ page }) => {
await setupMockGateway(page);
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
});
test('SET-CHAN-01: Channels list loads correctly', async ({ page }) => {
// Request channels list
const channelsResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/channels');
return await response.json();
} catch {
return null;
}
});
// Channels should be an array
if (channelsResponse) {
expect(channelsResponse).toHaveProperty('channels');
expect(Array.isArray(channelsResponse.channels)).toBe(true);
}
});
test('SET-CHAN-02: Feishu channel status check', async ({ page }) => {
// Check Feishu status
const feishuResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/channels/feishu');
return await response.json();
} catch {
return null;
}
});
if (feishuResponse?.channel) {
expect(feishuResponse.channel).toHaveProperty('id');
expect(feishuResponse.channel).toHaveProperty('type');
expect(feishuResponse.channel.type).toBe('feishu');
}
});
test('SET-CHAN-03: Create new IM channel', async ({ page }) => {
const newChannel = {
type: 'feishu',
name: 'Test Feishu Channel',
config: {
appId: 'test-app-id',
appSecret: 'test-secret',
},
enabled: true,
};
const createResponse = await page.evaluate(async (channel) => {
try {
const response = await fetch('/api/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(channel),
});
return await response.json();
} catch {
return null;
}
}, newChannel);
// Should return created channel
if (createResponse?.channel) {
expect(createResponse.channel).toHaveProperty('id');
expect(createResponse.channel.name).toBe(newChannel.name);
}
});
test('SET-CHAN-04: Update channel configuration', async ({ page }) => {
const updateData = {
name: 'Updated Channel Name',
enabled: false,
};
const updateResponse = await page.evaluate(async (data) => {
try {
const response = await fetch('/api/channels/feishu', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return await response.json();
} catch {
return null;
}
}, updateData);
// Should return updated channel
if (updateResponse?.channel) {
expect(updateResponse.channel.name).toBe(updateData.name);
}
});
test('SET-CHAN-05: Delete channel', async ({ page }) => {
const deleteResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/channels/test-channel-id', {
method: 'DELETE',
});
return { status: response.status, ok: response.ok };
} catch {
return null;
}
});
// Delete should succeed
if (deleteResponse) {
expect([200, 204, 404]).toContain(deleteResponse.status);
}
});
});
// ============================================
// Test Suite 3: Skill Management Tests
// ============================================
test.describe('Settings - Skill Management Tests', () => {
test.describe.configure({ mode: 'parallel' });
test.beforeEach(async ({ page }) => {
await setupMockGateway(page);
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
});
test('SET-SKILL-01: Skills catalog loads correctly', async ({ page }) => {
// Request skills list
const skillsResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/skills');
return await response.json();
} catch {
return null;
}
});
// Skills should be an array
expect(skillsResponse).toHaveProperty('skills');
expect(Array.isArray(skillsResponse.skills)).toBe(true);
expect(skillsResponse.skills.length).toBeGreaterThan(0);
// Verify skill structure
const firstSkill = skillsResponse.skills[0];
expect(firstSkill).toHaveProperty('id');
expect(firstSkill).toHaveProperty('name');
});
test('SET-SKILL-02: Get skill details', async ({ page }) => {
const skillResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/skills/skill-code-review');
return await response.json();
} catch {
return null;
}
});
if (skillResponse?.skill) {
expect(skillResponse.skill).toHaveProperty('id');
expect(skillResponse.skill).toHaveProperty('name');
expect(skillResponse.skill).toHaveProperty('description');
}
});
test('SET-SKILL-03: Create new skill', async ({ page }) => {
const newSkill = {
name: 'Test Skill',
description: 'A test skill for E2E testing',
triggers: [{ type: 'keyword', pattern: 'test' }],
actions: [{ type: 'respond', params: { message: 'Test response' } }],
enabled: true,
};
const createResponse = await page.evaluate(async (skill) => {
try {
const response = await fetch('/api/skills', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(skill),
});
return await response.json();
} catch {
return null;
}
}, newSkill);
// Should return created skill
if (createResponse?.skill) {
expect(createResponse.skill).toHaveProperty('id');
expect(createResponse.skill.name).toBe(newSkill.name);
}
});
test('SET-SKILL-04: Update skill configuration', async ({ page }) => {
const updateData = {
name: 'Updated Skill Name',
enabled: false,
};
const updateResponse = await page.evaluate(async (data) => {
try {
const response = await fetch('/api/skills/skill-code-review', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return await response.json();
} catch {
return null;
}
}, updateData);
// Should return updated skill
if (updateResponse?.skill) {
expect(updateResponse.skill.name).toBe(updateData.name);
}
});
test('SET-SKILL-05: Delete skill', async ({ page }) => {
const deleteResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/skills/test-skill-id', {
method: 'DELETE',
});
return { status: response.status, ok: response.ok };
} catch {
return null;
}
});
// Delete should succeed
if (deleteResponse) {
expect([200, 204, 404]).toContain(deleteResponse.status);
}
});
test('SET-SKILL-06: Skill triggers configuration', async ({ page }) => {
// Navigate to Skills tab
await navigateToTab(page, '技能');
await page.waitForTimeout(500);
// Check if skill cards are visible
const skillCards = page.locator('.border.rounded-lg').filter({
hasText: /技能|skill/i,
});
// At minimum, the API should respond
const skillsResponse = await page.evaluate(async () => {
const response = await fetch('/api/skills');
return response.json();
});
expect(skillsResponse.skills).toBeDefined();
});
});
// ============================================
// Test Suite 4: General Settings Tests
// ============================================
test.describe('Settings - General Settings Tests', () => {
test.describe.configure({ mode: 'parallel' });
test.beforeEach(async ({ page }) => {
await setupMockGateway(page);
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
});
test('SET-GEN-01: Quick config loads correctly', async ({ page }) => {
const configResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/config');
return await response.json();
} catch {
return null;
}
});
// Config should have expected fields
expect(configResponse).not.toBeNull();
expect(configResponse).toHaveProperty('userName');
expect(configResponse).toHaveProperty('userRole');
expect(configResponse).toHaveProperty('defaultModel');
});
test('SET-GEN-02: Save user profile settings', async ({ page }) => {
const newConfig = {
userName: 'Test User',
userRole: 'Developer',
};
const saveResponse = await page.evaluate(async (config) => {
try {
const response = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
return await response.json();
} catch {
return null;
}
}, newConfig);
// Should return updated config
if (saveResponse) {
expect(saveResponse.userName).toBe(newConfig.userName);
expect(saveResponse.userRole).toBe(newConfig.userRole);
}
});
test('SET-GEN-03: Workspace info loads correctly', async ({ page }) => {
const workspaceResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/workspace');
return await response.json();
} catch {
return null;
}
});
// Workspace should have path info
expect(workspaceResponse).not.toBeNull();
expect(workspaceResponse).toHaveProperty('path');
expect(workspaceResponse).toHaveProperty('exists');
});
test('SET-GEN-04: Theme preference saves correctly', async ({ page }) => {
// Navigate to settings
await userActions.openSettings(page);
await page.waitForTimeout(500);
// Find theme toggle
const themeToggle = page.locator('button').filter({
hasText: /theme|主题|dark|light|深色|浅色/i,
}).or(
page.locator('[role="switch"]').filter({ hasText: /dark|light/i })
);
if (await themeToggle.first().isVisible()) {
await themeToggle.first().click();
await page.waitForTimeout(500);
// Verify theme changed (check for dark class on html/body)
const isDark = await page.evaluate(() => {
return document.documentElement.classList.contains('dark');
});
// Theme toggle should work
expect(typeof isDark).toBe('boolean');
}
});
test('SET-GEN-05: Plugin status check', async ({ page }) => {
const pluginResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/plugins/status');
return await response.json();
} catch {
return null;
}
});
// Plugins should be an array
if (pluginResponse) {
expect(Array.isArray(pluginResponse)).toBe(true);
if (pluginResponse.length > 0) {
const firstPlugin = pluginResponse[0];
expect(firstPlugin).toHaveProperty('id');
expect(firstPlugin).toHaveProperty('name');
expect(firstPlugin).toHaveProperty('status');
}
}
});
test('SET-GEN-06: Scheduled tasks load correctly', async ({ page }) => {
const tasksResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/scheduler/tasks');
return await response.json();
} catch {
return null;
}
});
// Tasks should be an array
if (tasksResponse) {
expect(tasksResponse).toHaveProperty('tasks');
expect(Array.isArray(tasksResponse.tasks)).toBe(true);
}
});
test('SET-GEN-07: Security status check', async ({ page }) => {
const securityResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/security/status');
return await response.json();
} catch {
return null;
}
});
// Security should have status
if (securityResponse) {
expect(securityResponse).toHaveProperty('status');
}
});
});
// ============================================
// Test Suite 5: Settings Integration Tests
// ============================================
test.describe('Settings - Integration Tests', () => {
test('SET-INT-01: Full settings save and reload cycle', async ({ page }) => {
await setupMockGateway(page);
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
// Open settings
await userActions.openSettings(page);
await page.waitForTimeout(500);
// Get initial config
const initialConfig = await page.evaluate(async () => {
const response = await fetch('/api/config');
return response.json();
});
// Update config
const updatedConfig = {
...initialConfig,
userName: 'E2E Test User',
userRole: 'Tester',
};
await page.evaluate(async (config) => {
await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
}, updatedConfig);
// Reload page
await page.reload();
await waitForAppReady(page);
// Verify config persisted
const reloadedConfig = await page.evaluate(async () => {
const response = await fetch('/api/config');
return response.json();
});
expect(reloadedConfig.userName).toBe(updatedConfig.userName);
expect(reloadedConfig.userRole).toBe(updatedConfig.userRole);
});
test('SET-INT-02: Settings navigation between tabs', async ({ page }) => {
await setupMockGateway(page);
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
await userActions.openSettings(page);
await page.waitForTimeout(500);
// Find all tabs
const tabs = page.locator('[role="tab"]').or(
page.locator('button').filter({ has: page.locator('span') })
);
const tabCount = await tabs.count();
expect(tabCount).toBeGreaterThan(0);
// Click through each tab
for (let i = 0; i < Math.min(tabCount, 5); i++) {
const tab = tabs.nth(i);
if (await tab.isVisible()) {
await tab.click();
await page.waitForTimeout(300);
}
}
// Settings panel should still be visible
const settingsPanel = page.locator('[role="tabpanel"]').or(
page.locator('.settings-content')
);
await expect(settingsPanel.first()).toBeVisible();
});
test('SET-INT-03: Error handling for failed config save', async ({ page }) => {
// Mock error response for config
await mockErrorResponse(page, 'config', 500, 'Failed to save config');
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
// Try to save config
const saveResult = await page.evaluate(async () => {
try {
const response = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userName: 'Test' }),
});
return { status: response.status, ok: response.ok };
} catch {
return { error: true };
}
});
// Should handle error gracefully
expect(saveResult.status).toBe(500);
expect(saveResult.ok).toBe(false);
});
});
// ============================================
// Test Report
// ============================================
test.afterAll(async ({}, testInfo) => {
console.log('\n========================================');
console.log('ZCLAW Settings E2E Tests Complete');
console.log('========================================');
console.log(`Test Time: ${new Date().toISOString()}`);
console.log('========================================\n');
});

File diff suppressed because it is too large Load Diff