/** * 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 or return appropriate error if (deleteResponse) { expect([200, 204, 404, 500]).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 or return appropriate error if (deleteResponse) { expect([200, 204, 404, 500]).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 navigation buttons in settings sidebar const navButtons = page.locator('aside nav button').or( page.locator('[role="tab"]') ); const buttonCount = await navButtons.count(); expect(buttonCount).toBeGreaterThan(0); // Click through each navigation button for (let i = 0; i < Math.min(buttonCount, 5); i++) { const btn = navButtons.nth(i); if (await btn.isVisible()) { await btn.click(); await page.waitForTimeout(300); } } // Settings main content should still be visible const mainContent = page.locator('main').filter({ has: page.locator('h1, h2, .text-xl'), }); await expect(mainContent.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'); });