/** * 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'); });