/** * Smoke Tests — Desktop 聊天流断裂探测 * * 6 个冒烟测试验证 Desktop 聊天功能的完整连通性。 * 覆盖 3 种聊天模式 + 流取消 + 离线恢复 + 错误处理。 * * 前提条件: * - Desktop App 运行在 http://localhost:1420 (pnpm tauri dev) * - 后端服务可用 (SaaS 或 Kernel) * * 运行: cd desktop && npx playwright test smoke_chat */ import { test, expect, type Page } from '@playwright/test'; test.setTimeout(120000); // Helper: 等待应用就绪 async function waitForAppReady(page: Page) { await page.goto('/'); // 等待 React 挂载 — 检查 #root 有内容 await page.waitForSelector('#root > *', { timeout: 15000 }); await page.waitForTimeout(1000); // 给 React 额外渲染时间 } // Helper: 查找聊天输入框 async function findChatInput(page: Page) { // 多种选择器尝试,兼容不同 UI 状态 const selectors = [ 'textarea[placeholder*="消息"]', 'textarea[placeholder*="输入"]', 'textarea[placeholder*="chat"]', 'textarea', 'input[type="text"]', '.chat-input textarea', '[data-testid="chat-input"]', ]; for (const sel of selectors) { const el = page.locator(sel).first(); if (await el.isVisible().catch(() => false)) { return el; } } // 截图帮助调试 await page.screenshot({ path: 'test-results/smoke_chat_input_not_found.png' }); throw new Error('Chat input not found — screenshot saved'); } // Helper: 发送消息 async function sendMessage(page: Page, message: string) { const input = await findChatInput(page); await input.fill(message); // 按回车发送或点击发送按钮 await input.press('Enter').catch(async () => { const sendBtn = page.locator('button[aria-label*="发送"], button[aria-label*="send"], button:has-text("发送")').first(); if (await sendBtn.isVisible().catch(() => false)) { await sendBtn.click(); } }); } // ── D1: Gateway 模式聊天 ────────────────────────────────────────── test('D1: Gateway 模式 — 发送消息→接收响应', async ({ page }) => { await waitForAppReady(page); // 检查连接状态 const connectionStatus = page.locator('[data-testid="connection-status"], .connection-status, [aria-label*="连接"]'); const isConnected = await connectionStatus.isVisible().catch(() => false); if (!isConnected) { // 尝试通过 Store 检查连接状态 const state = await page.evaluate(() => { const stores = (window as any).__ZCLAW_STORES__; return stores ? { hasStores: true } : { hasStores: false }; }); console.log('Store state:', JSON.stringify(state)); } // 发送测试消息 await sendMessage(page, '你好,这是一个冒烟测试'); // 等待响应 (流式消息或错误) // 检查是否有新的消息气泡出现 const messageBubble = page.locator('.message-bubble, [class*="message"], [data-role="assistant"]').last(); const gotResponse = await messageBubble.waitFor({ state: 'visible', timeout: 30000 }).catch(() => false); if (!gotResponse) { await page.screenshot({ path: 'test-results/smoke_d1_no_response.png' }); } // 记录结果但不硬性失败 — Smoke 的目的是探测 console.log(`D1 result: ${gotResponse ? 'PASS — response received' : 'BROKEN — no response'}`); expect(gotResponse).toBeTruthy(); }); // ── D2: Kernel 模式 (Tauri IPC) ─────────────────────────────────── test('D2: Kernel 模式 — Tauri invoke 检查', async ({ page }) => { await waitForAppReady(page); // 检查 Tauri 是否可用 const tauriAvailable = await page.evaluate(() => { return !!(window as any).__TAURI__; }); if (!tauriAvailable) { console.log('D2 SKIP: Not running in Tauri context (browser mode)'); test.skip(); return; } // 检查 connectionStore 模式 const connectionMode = await page.evaluate(() => { const stores = (window as any).__ZCLAW_STORES__; if (stores?.connection) { const state = stores.connection.getState?.(); return state?.connectionMode || state?.mode || 'unknown'; } return 'no_store'; }); console.log(`Connection mode: ${connectionMode}`); // 尝试通过 Kernel 发送消息 (需要 Kernel 模式) if (connectionMode === 'tauri' || connectionMode === 'unknown') { await sendMessage(page, 'Kernel 模式冒烟测试'); // 等待流式响应 const gotChunk = await page.evaluate(() => { return new Promise((resolve) => { const stores = (window as any).__ZCLAW_STORES__; if (!stores?.stream) { resolve(false); return; } const unsub = stores.stream.subscribe((state: any) => { if (state.chunks?.length > 0) { unsub(); resolve(true); } }); // 超时 15s setTimeout(() => { unsub(); resolve(false); }, 15000); }); }); console.log(`D2 result: ${gotChunk ? 'PASS' : 'BROKEN'}`); } }); // ── D3: SaaS Relay 模式 ─────────────────────────────────────────── test('D3: SaaS Relay — SSE 流式检查', async ({ page }) => { await waitForAppReady(page); // 检查 SaaS 登录状态 const saasState = await page.evaluate(() => { const stores = (window as any).__ZCLAW_STORES__; if (!stores) return { hasStores: false }; const saas = stores.saas?.getState?.(); return { hasStores: true, isAuthenticated: saas?.isAuthenticated || false, connectionMode: stores.connection?.getState?.()?.connectionMode || 'unknown', }; }); if (saasState.connectionMode !== 'saas' && saasState.connectionMode !== 'browser') { console.log(`D3 SKIP: Not in SaaS mode (current: ${saasState.connectionMode})`); test.skip(); return; } // 发送消息 await sendMessage(page, 'SaaS Relay 冒烟测试'); // 检查是否有 relay 请求发出 const relayEvents = await page.evaluate(() => { return new Promise((resolve) => { const stores = (window as any).__ZCLAW_STORES__; if (!stores?.stream) { resolve(false); return; } const unsub = stores.stream.subscribe((state: any) => { if (state.isStreaming || state.chunks?.length > 0) { unsub(); resolve(true); } }); setTimeout(() => { unsub(); resolve(false); }, 20000); }); }); if (!relayEvents) { await page.screenshot({ path: 'test-results/smoke_d3_no_relay.png' }); } console.log(`D3 result: ${relayEvents ? 'PASS' : 'BROKEN'}`); expect(relayEvents).toBeTruthy(); }); // ── D4: 流取消 ──────────────────────────────────────────────────── test('D4: 流取消 — 发送→取消→停止', async ({ page }) => { await waitForAppReady(page); // 发送一条长回复请求 await sendMessage(page, '请详细解释量子计算的基本原理,包括量子比特、叠加态和纠缠'); // 等待流式开始 await page.waitForTimeout(1000); // 查找取消按钮 const cancelBtn = page.locator( 'button[aria-label*="取消"], button[aria-label*="停止"], button:has-text("停止"), [data-testid="cancel-stream"]' ).first(); if (await cancelBtn.isVisible().catch(() => false)) { await cancelBtn.click(); // 验证流状态变为 cancelled/stopped const streamState = await page.evaluate(() => { const stores = (window as any).__ZCLAW_STORES__; return stores?.stream?.getState?.()?.isStreaming ?? 'no_store'; }); console.log(`D4 result: stream cancelled, isStreaming=${streamState}`); expect(streamState).toBe(false); } else { // 如果没有取消按钮,尝试通过 Store 取消 const cancelled = await page.evaluate(() => { const stores = (window as any).__ZCLAW_STORES__; if (stores?.stream?.getState?.()?.cancelStream) { stores.stream.getState().cancelStream(); return true; } return false; }); console.log(`D4 result: ${cancelled ? 'cancelled via Store' : 'no cancel mechanism found'}`); } }); // ── D5: 离线→在线 ───────────────────────────────────────────────── test('D5: 离线队列 — 断网→发消息→恢复→重放', async ({ page, context }) => { await waitForAppReady(page); // 模拟离线 await context.setOffline(true); // 发送消息 (应该被排队) await sendMessage(page, '离线测试消息'); // 检查 offlineStore 是否有队列 const offlineState = await page.evaluate(() => { const stores = (window as any).__ZCLAW_STORES__; // 没有 offlineStore 暴露在 window 上,通过 connection 状态检查 return { hasOfflineStore: !!stores?.offline, isOnline: stores?.connection?.getState?.()?.isOnline ?? 'unknown', }; }); console.log('Offline state:', JSON.stringify(offlineState)); // 恢复网络 await context.setOffline(false); // 等待重连 await page.waitForTimeout(3000); // 检查消息是否被重放 (或至少没有被丢失) const afterReconnect = await page.evaluate(() => { const stores = (window as any).__ZCLAW_STORES__; return { isOnline: stores?.connection?.getState?.()?.isOnline ?? 'unknown', }; }); console.log('After reconnect:', JSON.stringify(afterReconnect)); }); // ── D6: 错误恢复 ────────────────────────────────────────────────── test('D6: 错误恢复 — 无效模型→错误提示→恢复', async ({ page }) => { await waitForAppReady(page); // 尝试通过 Store 设置无效模型 const errorTriggered = await page.evaluate(() => { const stores = (window as any).__ZCLAW_STORES__; if (!stores?.chat) return false; // 有些 Store 允许直接设置 model const state = stores.chat.getState?.(); if (state?.setSelectedModel) { state.setSelectedModel('nonexistent-model-xyz'); return true; } return false; }); if (errorTriggered) { // 尝试发送消息,期望看到错误 await sendMessage(page, '测试错误恢复'); // 等待错误 UI 或错误消息 await page.waitForTimeout(5000); // 检查是否有错误提示 const errorVisible = await page.locator( '.error-message, [class*="error"], [role="alert"], .ant-message-error' ).first().isVisible().catch(() => false); // 恢复有效模型 await page.evaluate(() => { const stores = (window as any).__ZCLAW_STORES__; const state = stores?.chat?.getState?.(); if (state?.setSelectedModel && state?.availableModels?.length > 0) { state.setSelectedModel(state.availableModels[0]); } }); console.log(`D6 result: error=${errorVisible}, recovery attempted`); } else { console.log('D6 SKIP: Cannot trigger model error via Store'); } });