diff --git a/desktop/tests/e2e/specs/user-scenarios-automation.spec.ts b/desktop/tests/e2e/specs/user-scenarios-automation.spec.ts new file mode 100644 index 0000000..b247f12 --- /dev/null +++ b/desktop/tests/e2e/specs/user-scenarios-automation.spec.ts @@ -0,0 +1,480 @@ +/** + * ZCLAW User Scenario E2E Tests — Automation Scenarios + * + * Scenario 4: Hands 完整流程 + * Scenario 6: Pipeline/Workflow 执行 + * Scenario 9: 自动化触发器 + */ + +import { test, expect } from '@playwright/test'; +import { + setupMockGateway, + setupMockGatewayWithWebSocket, + mockResponses, +} from '../fixtures/mock-gateway'; +import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors'; +import { skipOnboarding, waitForAppReady, navigateToTab } from '../utils/user-actions'; + +const BASE_URL = 'http://localhost:1420'; +test.setTimeout(120000); + +/** Helper: click send button */ +async function clickSend(page: import('@playwright/test').Page) { + const sendButton = page.getByRole('button', { name: '发送消息' }).or( + page.locator('button.bg-orange-500').first() + ); + await sendButton.first().click(); +} + +// ═══════════════════════════════════════════════════════════════════ +// Scenario 4: Hands 完整流程 +// ═══════════════════════════════════════════════════════════════════ + +test.describe('Scenario 4: Hands 完整流程', () => { + test.describe.configure({ mode: 'serial' }); + + test('S4-01: Hands 列表应显示可用 Hand', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + await navigateToTab(page, 'Hands'); + await page.waitForTimeout(1000); + + // 获取 Hands 列表 + const handsResponse = await page.evaluate(async () => { + try { + const response = await fetch('/api/hands'); + return await response.json(); + } catch { + return null; + } + }); + + expect(handsResponse).toHaveProperty('hands'); + expect(Array.isArray(handsResponse.hands)).toBe(true); + expect(handsResponse.hands.length).toBeGreaterThan(0); + + // 验证每个 Hand 有必要字段 + for (const hand of handsResponse.hands) { + expect(hand).toHaveProperty('id'); + expect(hand).toHaveProperty('name'); + expect(hand).toHaveProperty('status'); + expect(hand).toHaveProperty('requirements_met'); + } + }); + + test('S4-02: 触发 Researcher Hand 应返回 runId', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + // 触发 Researcher + const activateResponse = await page.evaluate(async () => { + try { + const response = await fetch('/api/hands/researcher/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: '玩具出口欧盟标准' }), + }); + return await response.json(); + } catch { + return null; + } + }); + + expect(activateResponse).toHaveProperty('runId'); + expect(activateResponse).toHaveProperty('status'); + expect(activateResponse.status).toBe('running'); + }); + + test('S4-03: Hand 审批流 — 批准执行', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + 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, comment: '看起来安全' }), + }); + return await response.json(); + } catch { + return null; + } + }); + + expect(approveResponse).toHaveProperty('status'); + expect(approveResponse.status).toBe('approved'); + }); + + test('S4-04: Hand 取消执行', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(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; + } + }); + + expect(cancelResponse).toHaveProperty('status'); + expect(cancelResponse.status).toBe('cancelled'); + }); + + test('S4-05: Hand 运行历史应可查询', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const runsResponse = await page.evaluate(async () => { + try { + const response = await fetch('/api/hands/browser/runs'); + return await response.json(); + } catch { + return null; + } + }); + + expect(runsResponse).toHaveProperty('runs'); + expect(Array.isArray(runsResponse.runs)).toBe(true); + }); + + test('S4-06: Hand 需求检查应正确', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const handsResponse = await page.evaluate(async () => { + try { + const response = await fetch('/api/hands'); + return await response.json(); + } catch { + return null; + } + }); + + for (const hand of handsResponse?.hands ?? []) { + expect(hand).toHaveProperty('requirements_met'); + expect(typeof hand.requirements_met).toBe('boolean'); + } + }); + + test('S4-07: 触发 Browser Hand 应返回 runId', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const response = await page.evaluate(async () => { + try { + const res = await fetch('/api/hands/browser/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'https://example.com' }), + }); + return await res.json(); + } catch { + return null; + } + }); + + expect(response).toHaveProperty('runId'); + expect(response).toHaveProperty('status'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Scenario 6: Pipeline/Workflow 执行 +// ═══════════════════════════════════════════════════════════════════ + +test.describe('Scenario 6: Pipeline/Workflow', () => { + test.describe.configure({ mode: 'serial' }); + + test('S6-01: 工作流列表应可加载', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const workflowsResponse = await page.evaluate(async () => { + try { + const response = await fetch('/api/workflows'); + return await response.json(); + } catch { + return null; + } + }); + + expect(workflowsResponse).toHaveProperty('workflows'); + expect(Array.isArray(workflowsResponse.workflows)).toBe(true); + }); + + test('S6-02: 创建新工作流', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const createResponse = await page.evaluate(async () => { + try { + const response = await fetch('/api/workflows', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: '玩具市场调研 Pipeline', + description: '自动收集玩具行业市场数据', + steps: [ + { handName: 'researcher', params: { topic: '2024年玩具市场趋势' } }, + { handName: 'collector', params: { sources: ['行业报告', '海关数据'] } }, + ], + }), + }); + return await response.json(); + } catch { + return null; + } + }); + + expect(createResponse).toHaveProperty('id'); + expect(createResponse.name).toBe('玩具市场调研 Pipeline'); + }); + + test('S6-03: 执行工作流应返回 runId', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const executeResponse = await page.evaluate(async () => { + try { + const response = await fetch('/api/workflows/wf-default/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ params: { topic: '测试执行' } }), + }); + return await response.json(); + } catch { + return null; + } + }); + + expect(executeResponse).toHaveProperty('runId'); + expect(executeResponse).toHaveProperty('status'); + expect(executeResponse.status).toBe('running'); + }); + + test('S6-04: 工作流步骤应有合理结构', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const workflowsResponse = await page.evaluate(async () => { + try { + const response = await fetch('/api/workflows'); + return await response.json(); + } catch { + return null; + } + }); + + const workflows = workflowsResponse?.workflows ?? []; + if (workflows.length > 0) { + const wf = workflows[0]; + expect(wf).toHaveProperty('id'); + expect(wf).toHaveProperty('name'); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Scenario 9: 自动化触发器 +// ═══════════════════════════════════════════════════════════════════ + +test.describe('Scenario 9: 自动化触发器', () => { + test.describe.configure({ mode: 'serial' }); + + test('S9-01: 触发器列表应可加载', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const triggersResponse = await page.evaluate(async () => { + try { + const response = await fetch('/api/triggers'); + return await response.json(); + } catch { + return null; + } + }); + + expect(triggersResponse).toHaveProperty('triggers'); + expect(Array.isArray(triggersResponse.triggers)).toBe(true); + }); + + test('S9-02: 创建定时触发器', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const createResponse = await page.evaluate(async () => { + try { + const response = await fetch('/api/triggers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'schedule', + name: '每日行业动态推送', + enabled: true, + schedule: '0 9 * * *', + handName: 'researcher', + params: { topic: '玩具行业每日动态' }, + }), + }); + return await response.json(); + } catch { + return null; + } + }); + + expect(createResponse).toHaveProperty('id'); + }); + + test('S9-03: 创建条件触发器', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const createResponse = await page.evaluate(async () => { + try { + const response = await fetch('/api/triggers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'event', + name: '关键词触发', + enabled: true, + condition: { keywords: ['ABS', '原料', '涨价'] }, + handName: 'collector', + }), + }); + return await response.json(); + } catch { + return null; + } + }); + + expect(createResponse).toHaveProperty('id'); + }); + + test('S9-04: 触发器创建后列表应增加', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + // 获取初始列表 + const listBefore = await page.evaluate(async () => { + try { + const response = await fetch('/api/triggers'); + return await response.json(); + } catch { + return { triggers: [] }; + } + }); + const countBefore = listBefore.triggers.length; + + // 创建新触发器 + await page.evaluate(async () => { + await fetch('/api/triggers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'webhook', + name: '新触发器', + enabled: true, + }), + }); + }); + + // 列表应增加 + const listAfter = await page.evaluate(async () => { + try { + const response = await fetch('/api/triggers'); + return await response.json(); + } catch { + return { triggers: [] }; + } + }); + expect(listAfter.triggers.length).toBeGreaterThan(countBefore); + }); + + test('S9-05: 删除触发器应成功', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + // 先创建一个 + const createRes = await page.evaluate(async () => { + try { + const response = await fetch('/api/triggers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'webhook', name: '待删除触发器', enabled: true }), + }); + return await response.json(); + } catch { + return null; + } + }); + + const triggerId = createRes?.id; + if (!triggerId) { + test.skip(); + return; + } + + // 删除 + const deleteResponse = await page.evaluate(async (id) => { + try { + const response = await fetch(`/api/triggers/${id}`, { method: 'DELETE' }); + return await response.json(); + } catch { + return null; + } + }, triggerId); + + // 应成功 + expect(deleteResponse).not.toBeNull(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Test Report +// ═══════════════════════════════════════════════════════════════════ + +test.afterAll(async ({}, testInfo) => { + console.log('\n========================================'); + console.log('ZCLAW User Scenario Automation Tests Complete'); + console.log(`Test Time: ${new Date().toISOString()}`); + console.log('========================================\n'); +}); diff --git a/desktop/tests/e2e/specs/user-scenarios-core.spec.ts b/desktop/tests/e2e/specs/user-scenarios-core.spec.ts new file mode 100644 index 0000000..fa7bc76 --- /dev/null +++ b/desktop/tests/e2e/specs/user-scenarios-core.spec.ts @@ -0,0 +1,422 @@ +/** + * ZCLAW User Scenario E2E Tests — Core Scenarios + * + * Scenario 1: 新用户首次体验 (First-time user experience) + * Scenario 2: 多轮对话 (Multi-turn dialogue with mock LLM) + * Scenario 3: 模型切换 (Model switching) + * + * Uses mock gateway for deterministic responses. + */ + +import { test, expect } from '@playwright/test'; +import { + setupMockGateway, + setupMockGatewayWithWebSocket, +} from '../fixtures/mock-gateway'; +import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors'; +import { skipOnboarding, waitForAppReady, navigateToTab } from '../utils/user-actions'; + +const BASE_URL = 'http://localhost:1420'; +test.setTimeout(120000); + +/** Helper: click send button */ +async function clickSend(page: import('@playwright/test').Page) { + const sendButton = page.getByRole('button', { name: '发送消息' }).or( + page.locator('button.bg-orange-500').first() + ); + await sendButton.first().click(); +} + +// ═══════════════════════════════════════════════════════════════════ +// Scenario 1: 新用户首次体验 +// ═══════════════════════════════════════════════════════════════════ + +test.describe('Scenario 1: 新用户首次体验', () => { + test.describe.configure({ mode: 'parallel' }); + + test('S1-01: 首次打开应显示引导或主界面', async ({ page }) => { + await setupMockGateway(page); + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {}); + + const body = page.locator('body'); + await expect(body).not.toBeEmpty({ timeout: 10000 }); + + await page.screenshot({ path: 'test-results/screenshots/s1-01-first-load.png' }); + }); + + test('S1-02: 跳过引导后应显示主界面', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const sidebar = page.locator('aside'); + await expect(sidebar).toBeVisible({ timeout: 10000 }); + + const chatInput = page.locator('textarea').first(); + await expect(chatInput).toBeVisible({ timeout: 10000 }); + + await page.screenshot({ path: 'test-results/screenshots/s1-02-main-ui.png' }); + }); + + test('S1-03: 发送第一条消息应收到回复', async ({ page }) => { + await setupMockGatewayWithWebSocket(page, { + wsConfig: { responseContent: '你好!我是 ZCLAW,很高兴为您服务。', streaming: true }, + }); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const chatInput = page.locator('textarea').first(); + await chatInput.fill('你好'); + await clickSend(page); + await page.waitForTimeout(3000); + + const userMsg = page.locator('text=你好').first(); + await expect(userMsg).toBeVisible({ timeout: 10000 }); + + await page.screenshot({ path: 'test-results/screenshots/s1-03-first-message.png' }); + }); + + test('S1-04: 消息持久化 — 切换 Tab 后消息仍在', async ({ page }) => { + await setupMockGatewayWithWebSocket(page, { + wsConfig: { responseContent: '消息持久化测试回复', streaming: true }, + }); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + await storeInspectors.clearStore(page, STORE_NAMES.CHAT); + await page.waitForTimeout(500); + + const chatInput = page.locator('textarea').first(); + await chatInput.fill('这条消息应该被持久化'); + await clickSend(page); + await page.waitForTimeout(3000); + + const stateBefore = await storeInspectors.getChatState<{ + messages: Array<{ content: string }>; + }>(page); + const countBefore = stateBefore?.messages?.length ?? 0; + + if (countBefore === 0) { + test.skip(); + return; + } + + // 切换到其他 Tab 再切回 + await navigateToTab(page, '自动化'); + await page.waitForTimeout(1000); + await navigateToTab(page, '分身'); + await page.waitForTimeout(1000); + + const stateAfter = await storeInspectors.getChatState<{ + messages: Array<{ content: string }>; + }>(page); + expect(stateAfter?.messages?.length).toBeGreaterThanOrEqual(countBefore); + }); + + test('S1-05: 侧边栏显示默认 Agent', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const sidebar = page.locator('aside'); + await expect(sidebar).toBeVisible(); + + const sidebarContent = await sidebar.textContent(); + expect(sidebarContent).toBeTruthy(); + expect(sidebarContent!.length).toBeGreaterThan(0); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Scenario 2: 多轮对话 (Mock LLM) +// ═══════════════════════════════════════════════════════════════════ + +test.describe('Scenario 2: 多轮对话', () => { + test.describe.configure({ mode: 'serial' }); + + test('S2-01: 连续多轮对话应保持上下文', async ({ page }) => { + await setupMockGatewayWithWebSocket(page, { + wsConfig: { responseContent: '这是模拟回复,我会记住您说的话。', streaming: true }, + }); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + await storeInspectors.clearStore(page, STORE_NAMES.CHAT); + await page.waitForTimeout(500); + + const userMessages = [ + '你好,我是玩具厂老板', + '我们主要做塑料玩具,ABS材质的', + '最近ABS原料价格波动很大,你帮我分析一下趋势', + '那现在适合囤货吗?', + '澄海这边有什么好的供应商推荐吗?', + ]; + + for (const msg of userMessages) { + const chatInput = page.locator('textarea').first(); + await chatInput.fill(msg); + await clickSend(page); + await page.waitForTimeout(2000); + } + + const state = await storeInspectors.getChatState<{ + messages: Array<{ content: string; role: string }>; + }>(page); + const userMsgCount = state?.messages?.filter(m => m.role === 'user').length ?? 0; + expect(userMsgCount).toBeGreaterThanOrEqual(3); + }); + + test('S2-02: Token 统计应正常累加', async ({ page }) => { + await setupMockGatewayWithWebSocket(page, { + wsConfig: { responseContent: 'Token统计测试回复', streaming: true }, + }); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const initialState = await storeInspectors.getChatState<{ + totalInputTokens?: number; + totalOutputTokens?: number; + }>(page); + const initialInput = initialState?.totalInputTokens ?? 0; + + const chatInput = page.locator('textarea').first(); + await chatInput.fill('Token test message'); + await clickSend(page); + await page.waitForTimeout(3000); + + const newState = await storeInspectors.getChatState<{ + totalInputTokens?: number; + totalOutputTokens?: number; + }>(page); + const newInput = newState?.totalInputTokens ?? 0; + + expect(newInput).toBeGreaterThanOrEqual(initialInput); + }); + + test('S2-03: 流式响应应稳定不中断', async ({ page }) => { + const longResponse = '这是一个较长的流式回复测试,用于验证流式传输的稳定性。'.repeat(15); + await setupMockGatewayWithWebSocket(page, { + wsConfig: { responseContent: longResponse, streaming: true, chunkDelay: 30 }, + }); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const chatInput = page.locator('textarea').first(); + await chatInput.fill('请写一段详细的分析'); + await clickSend(page); + + // 等待流式完成 + await page.waitForFunction( + () => { + const stored = localStorage.getItem('zclaw-chat-storage'); + if (!stored) return true; + try { + const state = JSON.parse(stored).state; + return state.isStreaming === false; + } catch { + return true; + } + }, + { timeout: 30000 } + ).catch(() => {}); + + await expect(page.locator('textarea').first()).toBeVisible({ timeout: 5000 }); + }); + + test('S2-04: 发送空消息应被阻止', async ({ page }) => { + await setupMockGatewayWithWebSocket(page, { + wsConfig: { responseContent: '空消息测试', streaming: true }, + }); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const stateBefore = await storeInspectors.getChatState<{ + messages: Array<{ content: string }>; + }>(page); + const countBefore = stateBefore?.messages?.length ?? 0; + + const chatInput = page.locator('textarea').first(); + await chatInput.fill(''); + await clickSend(page).catch(() => {}); + await page.waitForTimeout(1000); + + const stateAfter = await storeInspectors.getChatState<{ + messages: Array<{ content: string }>; + }>(page); + expect(stateAfter?.messages?.length).toBeLessThanOrEqual(countBefore); + }); + + test('S2-05: 取消流式响应应停止生成', async ({ page }) => { + await setupMockGatewayWithWebSocket(page, { + wsConfig: { + responseContent: '这条回复比较长,用于测试取消功能。'.repeat(10), + streaming: true, + chunkDelay: 200, + }, + }); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const chatInput = page.locator('textarea').first(); + await chatInput.fill('写一篇长文'); + await clickSend(page); + await page.waitForTimeout(500); + + // 查找取消按钮 + const cancelButton = page.getByRole('button', { name: /停止|取消|stop|cancel/i }).or( + page.locator('button').filter({ hasText: /停止|取消/ }) + ); + + if (await cancelButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await cancelButton.click(); + await page.waitForTimeout(1000); + } + + await expect(page.locator('textarea').first()).toBeVisible(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Scenario 3: 模型切换 +// ═══════════════════════════════════════════════════════════════════ + +test.describe('Scenario 3: 模型切换', () => { + test.describe.configure({ mode: 'serial' }); + + test('S3-01: 模型列表应可加载', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + 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); + }); + + test('S3-02: 切换模型后发送消息应正常', async ({ page }) => { + await setupMockGatewayWithWebSocket(page, { + wsConfig: { responseContent: '模型切换后的回复', streaming: true }, + }); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const chatInput = page.locator('textarea').first(); + await chatInput.fill('模型A的消息'); + await clickSend(page); + await page.waitForTimeout(3000); + + const stateBefore = await storeInspectors.getChatState<{ + messages: Array<{ content: string }>; + }>(page); + const countBefore = stateBefore?.messages?.length ?? 0; + + // 通过 store 切换模型 + await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + if (stores?.chat?.setState) { + stores.chat.setState({ currentModel: 'gpt-4o' }); + } + }); + + await chatInput.fill('模型B的消息'); + await clickSend(page); + await page.waitForTimeout(3000); + + const stateAfter = await storeInspectors.getChatState<{ + messages: Array<{ content: string }>; + }>(page); + expect(stateAfter?.messages?.length).toBeGreaterThan(countBefore); + }); + + test('S3-03: 切换回原模型上下文不丢', async ({ page }) => { + await setupMockGatewayWithWebSocket(page, { + wsConfig: { responseContent: '上下文保持测试回复', streaming: true }, + }); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + await storeInspectors.clearStore(page, STORE_NAMES.CHAT); + + const chatInput = page.locator('textarea').first(); + await chatInput.fill('上下文测试消息'); + await clickSend(page); + await page.waitForTimeout(3000); + + const state = await storeInspectors.getChatState<{ + messages: Array<{ content: string }>; + }>(page); + const originalCount = state?.messages?.length ?? 0; + + if (originalCount === 0) { + test.skip(); + return; + } + + await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + if (stores?.chat?.setState) { + stores.chat.setState({ currentModel: 'gpt-4o' }); + } + }); + + await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + if (stores?.chat?.setState) { + stores.chat.setState({ currentModel: 'claude-sonnet-4-20250514' }); + } + }); + + const stateAfter = await storeInspectors.getChatState<{ + messages: Array<{ content: string }>; + }>(page); + expect(stateAfter?.messages?.length).toBeGreaterThanOrEqual(originalCount); + }); + + test('S3-04: 不存在的模型应优雅处理', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + if (stores?.chat?.setState) { + stores.chat.setState({ currentModel: 'nonexistent-model-xyz' }); + } + }); + + await expect(page.locator('textarea').first()).toBeVisible({ timeout: 5000 }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Test Report +// ═══════════════════════════════════════════════════════════════════ + +test.afterAll(async ({}, testInfo) => { + console.log('\n========================================'); + console.log('ZCLAW User Scenario Core Tests Complete'); + console.log(`Test Time: ${new Date().toISOString()}`); + console.log('========================================\n'); +}); diff --git a/desktop/tests/e2e/specs/user-scenarios-live.spec.ts b/desktop/tests/e2e/specs/user-scenarios-live.spec.ts new file mode 100644 index 0000000..1a7c76a --- /dev/null +++ b/desktop/tests/e2e/specs/user-scenarios-live.spec.ts @@ -0,0 +1,270 @@ +/** + * ZCLAW Live Multi-Turn Dialogue E2E Test + * + * Uses REAL LLM API for 100+ round conversation. + * Requires: + * - Dev server running at localhost:1420 + * - Backend connected with valid API key + * - Real LLM provider configured + * + * Run: npx playwright test user-scenarios-live.spec.ts --headed + * + * The test simulates a toy factory owner's daily conversations. + */ + +import { test, expect } from '@playwright/test'; +import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors'; +import { skipOnboarding, waitForAppReady, navigateToTab } from '../utils/user-actions'; + +const BASE_URL = 'http://localhost:1420'; +test.setTimeout(600000); // 10 minutes for live test + +/** Helper: click send button */ +async function clickSend(page: import('@playwright/test').Page) { + const sendButton = page.getByRole('button', { name: '发送消息' }).or( + page.locator('button.bg-orange-500').first() + ); + await sendButton.first().click(); +} + +/** Helper: wait for streaming to complete */ +async function waitForStreamComplete(page: import('@playwright/test').Page, timeout = 60000) { + await page.waitForFunction( + () => { + const stored = localStorage.getItem('zclaw-chat-storage'); + if (!stored) return true; + try { + const state = JSON.parse(stored).state; + return state.isStreaming === false; + } catch { + return true; + } + }, + { timeout } + ).catch(() => {}); +} + +/** + * 100+ round conversation plan — simulating a toy factory owner's day + */ +const CONVERSATION_PLAN = [ + // Phase 1: Greeting & introduction (rounds 1-10) + { content: '你好,我是做玩具的,姓李', category: 'greeting' }, + { content: '我有一家小工厂在澄海', category: 'greeting' }, + { content: '做塑料玩具十几年了', category: 'greeting' }, + { content: '最近想了解一下AI能怎么帮我', category: 'greeting' }, + { content: '我们主要做出口,欧洲和北美', category: 'intro' }, + { content: '工人大概50个', category: 'intro' }, + { content: '年产值大概两三千万', category: 'intro' }, + { content: '你能不能帮我介绍一下你的能力?', category: 'intro' }, + { content: '听起来不错,那我们慢慢聊', category: 'greeting' }, + { content: '先帮我看看最近的ABS原料价格趋势', category: 'transition' }, + + // Phase 2: Material price analysis (rounds 11-30) + { content: 'ABS最近的走势怎么样?', category: 'material' }, + { content: '和国际油价有关系吗?', category: 'material' }, + { content: '苯乙烯的价格最近多少一吨?', category: 'material' }, + { content: '那现在14000一吨的ABS算便宜还是贵?', category: 'material' }, + { content: '我一般一个月用多少原料呢?大概50吨', category: 'material' }, + { content: '那算下来一个月原料成本大概70万?', category: 'material' }, + { content: '有没什么方法能降低原料成本?', category: 'material' }, + { content: '囤货的话风险大不大?', category: 'material' }, + { content: '我听说有些厂家用回料,你怎么看?', category: 'material' }, + { content: '出口欧洲的话,回料可以用吗?', category: 'material' }, + { content: 'EN71标准对材质有什么要求?', category: 'material' }, + { content: 'REACH呢?对塑料原料有限制吗?', category: 'material' }, + { content: '那食品级的ABS和非食品级差多少价格?', category: 'material' }, + { content: '澄海这边有好的ABS供应商推荐吗?', category: 'material' }, + { content: '中石化、台化和奇美的料哪个好?', category: 'material' }, + { content: '我之前一直用奇美的757,你觉得怎么样?', category: 'material' }, + { content: '有没有性价比更高的替代品?', category: 'material' }, + { content: '直接从厂家拿货和从经销商拿货差多少?', category: 'material' }, + { content: '付款方式一般是怎么样的?', category: 'material' }, + { content: '好的,原料这块我先了解了,谢谢你', category: 'transition' }, + + // Phase 3: Supplier comparison (rounds 31-50) + { content: '帮我对比一下几个主要供应商', category: 'supplier' }, + { content: '奇美、台化、镇海炼化各有什么优势?', category: 'supplier' }, + { content: '交期方面呢?', category: 'supplier' }, + { content: '售后服务哪个更好?', category: 'supplier' }, + { content: '我之前的供应商突然涨价了30%,合理吗?', category: 'supplier' }, + { content: '一般涨价多少算是正常范围?', category: 'supplier' }, + { content: '我应该怎么和供应商谈判?', category: 'supplier' }, + { content: '签长期合同有什么注意事项?', category: 'supplier' }, + { content: '保底价格和浮动价格哪种更好?', category: 'supplier' }, + { content: '如果我一次订100吨能拿什么折扣?', category: 'supplier' }, + { content: '物流费用怎么算?到澄海大概多少钱?', category: 'supplier' }, + { content: '期货和现货哪个划算?', category: 'supplier' }, + { content: '有没有供应商协会或者展会推荐?', category: 'supplier' }, + { content: '塑料交易网的信息准不准?', category: 'supplier' }, + { content: '好的,供应商这块我先做做功课', category: 'transition' }, + { content: '接下来我想聊聊产品设计', category: 'transition' }, + + // Phase 4: Product design (rounds 51-70) + { content: '今年流行什么类型的玩具?', category: 'design' }, + { content: '欧美市场喜欢什么风格?', category: 'design' }, + { content: '盲盒类的产品还有市场吗?', category: 'design' }, + { content: 'STEAM教育玩具前景怎么样?', category: 'design' }, + { content: '环保材质的玩具能卖贵一点吗?', category: 'design' }, + { content: '用PCR材料做玩具可行吗?', category: 'design' }, + { content: '设计版权怎么保护?', category: 'design' }, + { content: '我请一个自由设计师大概要多少钱?', category: 'design' }, + { content: '开模费用一般是多少?', category: 'design' }, + { content: '一个新产品从设计到量产大概要多久?', category: 'design' }, + { content: '小批量试产有什么好的方案?', category: 'design' }, + { content: '3D打印做原型靠谱吗?', category: 'design' }, + { content: '包装设计有什么要注意的?', category: 'design' }, + { content: '出口欧洲的包装有什么特殊要求?', category: 'design' }, + { content: 'CE认证好办吗?大概多少钱?', category: 'design' }, + { content: '认证周期多长?', category: 'design' }, + { content: '好的,产品设计这块很有收获', category: 'transition' }, + + // Phase 5: Seasonal planning (rounds 71-90) + { content: '马上要进入旺季了,怎么备货比较好?', category: 'planning' }, + { content: '圣诞节一般提前多久开始备货?', category: 'planning' }, + { content: '去年圣诞节的销售情况怎么样?', category: 'planning' }, + { content: '除了圣诞还有什么旺季?', category: 'planning' }, + { content: '万圣节的市场大不大?', category: 'planning' }, + { content: '夏天有什么好的品类?', category: 'planning' }, + { content: '库存管理有什么好的工具推荐?', category: 'planning' }, + { content: '安全库存怎么算?', category: 'planning' }, + { content: '我一般保持多少天的库存比较合适?', category: 'planning' }, + { content: '资金周转有什么好的建议?', category: 'planning' }, + { content: '银行贷款和供应商赊账哪个更好?', category: 'planning' }, + { content: '有什么补贴政策可以利用吗?', category: 'planning' }, + { content: '出口退税能退多少?', category: 'planning' }, + { content: '澄海政府对玩具有什么扶持政策?', category: 'planning' }, + { content: '参加广交会效果好还是香港玩具展好?', category: 'planning' }, + { content: '线上渠道有什么推荐?', category: 'planning' }, + { content: '亚马逊玩具类目竞争激烈吗?', category: 'planning' }, + { content: 'tiktok shop能卖玩具吗?', category: 'planning' }, + { content: '好的,备货和渠道我了解了', category: 'transition' }, + { content: '最后帮我总结一下今天聊的内容', category: 'summary' }, + + // Phase 6: Mixed questions (rounds 91-110) + { content: '对了,你还记得我在哪做玩具吗?', category: 'recall' }, + { content: '我主要用什么材料?', category: 'recall' }, + { content: '出口哪些市场?', category: 'recall' }, + { content: '好的,记忆力不错', category: 'recall' }, + { content: '有没有什么自动化的工具能帮我管理工厂?', category: 'general' }, + { content: 'ERP系统有什么推荐?', category: 'general' }, + { content: '小工厂用Excel够用吗?', category: 'general' }, + { content: '工人管理有什么好的方法?', category: 'general' }, + { content: '计件工资和计时工资哪个好?', category: 'general' }, + { content: '怎么减少废品率?', category: 'general' }, + { content: '品质管控有什么标准流程?', category: 'general' }, + { content: '出货前要做哪些检测?', category: 'general' }, + { content: '客户投诉怎么处理比较好?', category: 'general' }, + { content: '退货率控制在多少以内算正常?', category: 'general' }, + { content: '好的,今天收获很大,谢谢你的建议', category: 'closing' }, + { content: '下次我想聊聊怎么用AI帮我做市场调研', category: 'closing' }, + { content: '你有什么功能能帮我自动化做一些事情吗?', category: 'closing' }, + { content: '比如每天帮我查ABS价格?', category: 'closing' }, + { content: '能帮我整理供应商信息吗?', category: 'closing' }, + { content: '太好了,下次见!', category: 'closing' }, +]; + +test.describe('Live Multi-Turn Dialogue (Real LLM)', () => { + // Mark this test as slow + test.slow(); + + test('LIVE-01: 100+ round conversation with real LLM', async ({ page }) => { + // No mock gateway — use real backend + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + // Clear previous messages + await storeInspectors.clearStore(page, STORE_NAMES.CHAT); + await page.waitForTimeout(1000); + + let successfulRounds = 0; + let contextRecallPassed = false; + const errors: string[] = []; + + for (let i = 0; i < CONVERSATION_PLAN.length; i++) { + const { content, category } = CONVERSATION_PLAN[i]; + + try { + // Find and fill chat input + const chatInput = page.locator('textarea').first(); + await chatInput.waitFor({ state: 'visible', timeout: 10000 }); + await chatInput.fill(content); + await clickSend(page); + + // Wait for streaming to complete + await waitForStreamComplete(page, 120000); + await page.waitForTimeout(1000); // Small buffer + + successfulRounds++; + + // Check context recall at specific points + if (category === 'recall' && content.includes('我在哪做玩具')) { + // The response should mention 澄海 + const state = await storeInspectors.getChatState<{ + messages: Array<{ content: string; role: string }>; + }>(page); + const lastAssistantMsg = [...(state?.messages ?? [])] + .reverse() + .find(m => m.role === 'assistant'); + if (lastAssistantMsg?.content?.includes('澄海')) { + contextRecallPassed = true; + } + } + + // Log progress every 10 rounds + if ((i + 1) % 10 === 0) { + console.log(`[Live Test] Completed ${i + 1}/${CONVERSATION_PLAN.length} rounds`); + await page.screenshot({ + path: `test-results/screenshots/live-round-${i + 1}.png`, + }); + } + } catch (err) { + errors.push(`Round ${i + 1} (${category}): ${err}`); + // Try to recover + await page.waitForTimeout(2000); + } + } + + // ═══ Assertions ═══ + + // 1. Should complete most rounds + expect(successfulRounds).toBeGreaterThanOrEqual( + Math.floor(CONVERSATION_PLAN.length * 0.7) + ); + + // 2. Context recall should work + if (successfulRounds > 80) { + expect(contextRecallPassed).toBe(true); + } + + // 3. Messages should be persisted + const finalState = await storeInspectors.getChatState<{ + messages: Array<{ content: string; role: string }>; + }>(page); + const userMsgs = finalState?.messages?.filter(m => m.role === 'user') ?? []; + expect(userMsgs.length).toBeGreaterThanOrEqual( + Math.floor(CONVERSATION_PLAN.length * 0.5) + ); + + // 4. No console errors that crash the app + const consoleErrors = await page.evaluate(() => { + return (window as any).__consoleErrors ?? []; + }); + expect(consoleErrors.length).toBeLessThan(5); + + // 5. App should still be responsive + await expect(page.locator('textarea').first()).toBeVisible({ timeout: 5000 }); + + // Report + console.log(`\n═══ Live Test Report ═══`); + console.log(`Successful rounds: ${successfulRounds}/${CONVERSATION_PLAN.length}`); + console.log(`Context recall passed: ${contextRecallPassed}`); + console.log(`Total messages in store: ${finalState?.messages?.length ?? 0}`); + console.log(`Errors: ${errors.length}`); + if (errors.length > 0) { + console.log('Error details:', errors); + } + console.log(`════════════════════════\n`); + }); +}); diff --git a/desktop/tests/e2e/specs/user-scenarios-saas-memory.spec.ts b/desktop/tests/e2e/specs/user-scenarios-saas-memory.spec.ts new file mode 100644 index 0000000..eda2890 --- /dev/null +++ b/desktop/tests/e2e/specs/user-scenarios-saas-memory.spec.ts @@ -0,0 +1,452 @@ +/** + * ZCLAW User Scenario E2E Tests — SaaS, Settings, Memory, Butler + * + * Scenario 5: 记忆系统 + * Scenario 7: 设置配置 + * Scenario 8: SaaS 集成 + * Scenario 10: 管家面板 + */ + +import { test, expect } from '@playwright/test'; +import { + setupMockGateway, + setupMockGatewayWithWebSocket, + mockResponses, +} from '../fixtures/mock-gateway'; +import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors'; +import { skipOnboarding, waitForAppReady, navigateToTab } from '../utils/user-actions'; + +const BASE_URL = 'http://localhost:1420'; +test.setTimeout(120000); + +/** Helper: click send button */ +async function clickSend(page: import('@playwright/test').Page) { + const sendButton = page.getByRole('button', { name: '发送消息' }).or( + page.locator('button.bg-orange-500').first() + ); + await sendButton.first().click(); +} + +// ═══════════════════════════════════════════════════════════════════ +// Scenario 5: 记忆系统 +// ═══════════════════════════════════════════════════════════════════ + +test.describe('Scenario 5: 记忆系统', () => { + test.describe.configure({ mode: 'serial' }); + + test('S5-01: 告知信息后应可查询记忆', async ({ page }) => { + await setupMockGatewayWithWebSocket(page, { + wsConfig: { responseContent: '好的,我记住了。您在澄海做塑料玩具。', streaming: true }, + }); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + // 告知关键信息 + const chatInput = page.locator('textarea').first(); + await chatInput.fill('我的工厂在澄海,主要做塑料玩具出口'); + await clickSend(page); + await page.waitForTimeout(3000); + + // 查询记忆 API + const memoryResponse = await page.evaluate(async () => { + try { + const response = await fetch('/api/memory/search?q=澄海'); + return await response.json(); + } catch { + return null; + } + }); + + // 记忆 API 应返回结果(即使为空也应有响应) + expect(memoryResponse).not.toBeNull(); + }); + + test('S5-02: 记忆面板应可访问', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + // 查找记忆相关 UI 元素 + const memoryElements = page.locator('[class*="memory"], [class*="记忆"], [data-testid*="memory"]').or( + page.locator('text=记忆').or(page.locator('text=Memory')) + ); + + // 无论是否可见,页面不应崩溃 + await expect(page.locator('textarea').first()).toBeVisible({ timeout: 5000 }); + }); + + test('S5-03: 多轮对话后搜索记忆', async ({ page }) => { + await setupMockGatewayWithWebSocket(page, { + wsConfig: { responseContent: '好的,我了解了。', streaming: true }, + }); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + // 发送多轮包含关键信息的消息 + const infoMessages = [ + '我们工厂在澄海,做塑料玩具', + '主要出口欧洲和北美市场', + '年产量大约50万件', + ]; + + for (const msg of infoMessages) { + const chatInput = page.locator('textarea').first(); + await chatInput.fill(msg); + await clickSend(page); + await page.waitForTimeout(2000); + } + + // 搜索记忆 + const searchResults = await page.evaluate(async () => { + try { + const response = await fetch('/api/memory/search?q=澄海'); + return await response.json(); + } catch { + return null; + } + }); + + // 应有搜索结果结构 + expect(searchResults).not.toBeNull(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Scenario 7: 设置配置 +// ═══════════════════════════════════════════════════════════════════ + +test.describe('Scenario 7: 设置配置', () => { + test.describe.configure({ mode: 'serial' }); + + test('S7-01: 设置页面应可访问', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + // 尝试打开设置 + const settingsBtn = page.locator('aside button[aria-label="打开设置"]').or( + page.locator('aside button[title="设置"]') + ).or( + page.locator('aside .p-3.border-t button') + ).or( + page.getByRole('button', { name: /打开设置|设置|settings/i }) + ); + + if (await settingsBtn.first().isVisible({ timeout: 3000 }).catch(() => false)) { + await settingsBtn.first().click(); + await page.waitForTimeout(1000); + + // 设置面板应有内容 + const settingsContent = page.locator('[role="dialog"]').or( + page.locator('.fixed.inset-0').last() + ).or( + page.locator('[class*="settings"]') + ); + // 不应崩溃 + await expect(page.locator('body')).toBeVisible(); + } else { + // 设置按钮可能不在侧边栏,跳过 + test.skip(); + } + }); + + test('S7-02: 配置 API 读写应一致', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + // 读取配置 + const configBefore = await page.evaluate(async () => { + try { + const response = await fetch('/api/config'); + return await response.json(); + } catch { + return null; + } + }); + + expect(configBefore).not.toBeNull(); + + // 写入配置 + const updateResponse = await page.evaluate(async () => { + try { + const response = await fetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userName: 'E2E测试用户', userRole: '工厂老板' }), + }); + return await response.json(); + } catch { + return null; + } + }); + + expect(updateResponse).not.toBeNull(); + + // 再次读取,验证一致性 + const configAfter = await page.evaluate(async () => { + try { + const response = await fetch('/api/config'); + return await response.json(); + } catch { + return null; + } + }); + + expect(configAfter?.userName).toBe('E2E测试用户'); + expect(configAfter?.userRole).toBe('工厂老板'); + }); + + test('S7-03: 安全状态应可查询', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const securityStatus = await page.evaluate(async () => { + try { + const response = await fetch('/api/security/status'); + return await response.json(); + } catch { + return null; + } + }); + + expect(securityStatus).toHaveProperty('status'); + expect(securityStatus.status).toBe('secure'); + }); + + test('S7-04: 插件状态应可查询', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const pluginStatus = await page.evaluate(async () => { + try { + const response = await fetch('/api/plugins/status'); + return await response.json(); + } catch { + return null; + } + }); + + expect(Array.isArray(pluginStatus)).toBe(true); + if (pluginStatus.length > 0) { + expect(pluginStatus[0]).toHaveProperty('id'); + expect(pluginStatus[0]).toHaveProperty('name'); + expect(pluginStatus[0]).toHaveProperty('status'); + } + }); + + test('S7-05: 配置边界值处理', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + // 发送过长配置值 + const longValue = 'A'.repeat(500); + const response = await page.evaluate(async (value) => { + try { + const response = await fetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userName: value }), + }); + return { status: response.status, ok: response.ok }; + } catch { + return { status: 0, ok: false }; + } + }, longValue); + + // 应返回响应,不崩溃 + expect(response.status).toBeGreaterThan(0); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Scenario 8: SaaS 集成 +// ═══════════════════════════════════════════════════════════════════ + +test.describe('Scenario 8: SaaS 集成', () => { + test.describe.configure({ mode: 'serial' }); + + test('S8-01: SaaS 连接模式应可切换', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + // 检查连接模式 store + const connectionMode = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + if (stores?.saas?.getState) { + return stores.saas.getState().connectionMode; + } + return null; + }); + + // 默认应为 tauri 模式 + expect(connectionMode).toBeOneOf(['tauri', 'saas', 'gateway', null]); + }); + + test('S8-02: SaaS 登录表单应可访问', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + // 查找 SaaS 登录相关元素 + const loginElements = page.locator('text=登录').or( + page.locator('text=SaaS').or( + page.locator('[class*="login"]').or( + page.locator('[class*="auth"]') + ) + ) + ); + + // 页面不应崩溃 + await expect(page.locator('body')).toBeVisible(); + }); + + test('S8-03: 用量统计应可查询', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const usageStats = await page.evaluate(async () => { + try { + const response = await fetch('/api/stats/usage'); + return await response.json(); + } catch { + return null; + } + }); + + expect(usageStats).not.toBeNull(); + expect(usageStats).toHaveProperty('totalSessions'); + expect(usageStats).toHaveProperty('totalMessages'); + expect(usageStats).toHaveProperty('totalTokens'); + }); + + test('S8-04: 会话统计应可查询', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const sessionStats = await page.evaluate(async () => { + try { + const response = await fetch('/api/stats/sessions'); + return await response.json(); + } catch { + return null; + } + }); + + expect(sessionStats).not.toBeNull(); + expect(sessionStats).toHaveProperty('total'); + }); + + test('S8-05: 审计日志应可查询', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + const auditLogs = await page.evaluate(async () => { + try { + const response = await fetch('/api/audit/logs'); + return await response.json(); + } catch { + return null; + } + }); + + expect(auditLogs).not.toBeNull(); + expect(auditLogs).toHaveProperty('logs'); + expect(auditLogs).toHaveProperty('total'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Scenario 10: 管家面板 +// ═══════════════════════════════════════════════════════════════════ + +test.describe('Scenario 10: 管家面板', () => { + test.describe.configure({ mode: 'serial' }); + + test('S10-01: 管家面板应可访问', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + // 查找管家面板入口 + const butlerTab = page.locator('text=管家').or( + page.locator('text=Butler').or( + page.locator('[data-testid*="butler"]') + ) + ); + + if (await butlerTab.first().isVisible({ timeout: 3000 }).catch(() => false)) { + await butlerTab.first().click(); + await page.waitForTimeout(1000); + } + + // UI 不崩溃 + await expect(page.locator('body')).toBeVisible(); + }); + + test('S10-02: 痛点区块应有结构', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + // 查找痛点相关元素 + const painElements = page.locator('text=痛点').or( + page.locator('text=关注').or( + page.locator('text=Pain') + ) + ); + + // 页面不崩溃 + await expect(page.locator('textarea').first()).toBeVisible({ timeout: 5000 }); + }); + + test('S10-03: 推理链应有逻辑结构', async ({ page }) => { + await setupMockGateway(page); + await skipOnboarding(page); + await page.goto(BASE_URL); + await waitForAppReady(page); + + // 查找推理链相关 UI + const reasoningElements = page.locator('[class*="reasoning"]').or( + page.locator('[class*="chain"]').or( + page.locator('text=推理') + ) + ); + + // 页面不应崩溃 + await expect(page.locator('body')).toBeVisible(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Test Report +// ═══════════════════════════════════════════════════════════════════ + +test.afterAll(async ({}, testInfo) => { + console.log('\n========================================'); + console.log('ZCLAW SaaS/Memory/Butler Scenario Tests Complete'); + console.log(`Test Time: ${new Date().toISOString()}`); + console.log('========================================\n'); +});