/** * 用户操作模拟工具 * 封装完整的用户操作流程,确保深度验证 * * 基于实际 UI 组件结构: * - ChatArea: textarea 输入框, .bg-orange-500 发送按钮 * - HandsPanel: .bg-white.dark:bg-gray-800 卡片, "激活" 按钮 * - TeamList: .w-full.p-2.rounded-lg 团队项 * - SkillMarket: .border.rounded-lg 技能卡片 * - Sidebar: aside.w-64 侧边栏 */ import { Page, Request, Response } from '@playwright/test'; const BASE_URL = 'http://localhost:1420'; /** * 跳过引导流程 * 设置 localStorage 以跳过首次使用引导 * 必须在页面加载前调用 */ export async function skipOnboarding(page: Page): Promise { // 使用 addInitScript 在页面加载前设置 localStorage await page.addInitScript(() => { // 标记引导已完成 localStorage.setItem('zclaw-onboarding-completed', 'true'); // 设置用户配置文件 (必须同时设置才能跳过引导) localStorage.setItem('zclaw-user-profile', JSON.stringify({ userName: '测试用户', userRole: '开发者', completedAt: new Date().toISOString() })); // 设置 Gateway URL (使用 REST 模式) localStorage.setItem('zclaw_gateway_url', 'http://127.0.0.1:50051'); localStorage.setItem('zclaw_gateway_token', ''); // 设置默认聊天 Store localStorage.setItem('zclaw-chat-storage', JSON.stringify({ state: { conversations: [], currentConversationId: null, currentAgent: { id: 'default', name: 'ZCLAW', icon: '🤖', color: '#3B82F6', lastMessage: '', time: '' }, isStreaming: false, currentModel: 'claude-sonnet-4-20250514', sessionKey: null, messages: [] }, version: 0 })); }); } /** * 模拟 Gateway 连接状态 * 直接在页面上设置 store 状态来绕过实际连接 */ export async function mockGatewayConnection(page: Page): Promise { await page.evaluate(() => { try { const stores = (window as any).__ZCLAW_STORES__; if (stores?.gateway) { // zustand store 的 setState 方法 const store = stores.gateway; if (typeof store.setState === 'function') { store.setState({ connectionState: 'connected', gatewayVersion: '0.4.0', error: null }); console.log('[E2E] Gateway store state mocked'); } else { console.warn('[E2E] Store setState not available'); } } else { console.warn('[E2E] __ZCLAW_STORES__.gateway not found'); } } catch (e) { console.warn('[E2E] Failed to mock connection:', e); } }); } /** * 等待应用就绪 * 注意:必须在 page.goto() 之前调用 skipOnboarding */ export async function waitForAppReady(page: Page, timeout = 30000): Promise { await page.waitForLoadState('networkidle', { timeout }); // 等待侧边栏出现 await page.waitForSelector('aside', { timeout }).catch(() => { console.warn('Sidebar not found'); }); // 等待聊天区域出现 await page.waitForSelector('textarea', { timeout: 10000 }).catch(() => {}); // 等待状态初始化 await page.waitForTimeout(2000); // 尝试模拟连接状态 await mockGatewayConnection(page); // 再等待一会 await page.waitForTimeout(500); } /** * 侧边栏导航项映射 */ const NAV_ITEMS: Record = { 分身: { text: '分身', key: 'clones' }, 自动化: { text: '自动化', key: 'automation' }, 技能: { text: '技能', key: 'skills' }, 团队: { text: '团队', key: 'team' }, 协作: { text: '协作', key: 'swarm' }, Hands: { text: 'Hands', key: 'automation' }, 工作流: { text: '工作流', key: 'automation' }, }; /** * 导航到指定标签页 */ export async function navigateToTab(page: Page, tabName: string): Promise { const navItem = NAV_ITEMS[tabName]; if (!navItem) { console.warn(`Unknown tab: ${tabName}`); return; } // 查找侧边栏中的导航按钮 const navButton = page.locator('nav button').filter({ hasText: navItem.text, }).or( page.locator('aside button').filter({ hasText: navItem.text }) ); if (await navButton.first().isVisible()) { await navButton.first().click(); await page.waitForTimeout(500); } } /** * 等待聊天输入框可用 */ export async function waitForChatReady(page: Page, timeout = 30000): Promise { await page.waitForFunction( () => { const textarea = document.querySelector('textarea'); return textarea && !textarea.disabled; }, { timeout } ); } /** * 用户操作集合 */ export const userActions = { // ============================================ // 聊天相关操作 // ============================================ /** * 发送聊天消息(完整流程) * @returns 请求对象,用于验证请求格式 */ async sendChatMessage( page: Page, message: string, options?: { waitForResponse?: boolean; timeout?: number } ): Promise<{ request: Request; response?: Response }> { // 等待聊天输入框可用 await waitForChatReady(page, options?.timeout ?? 30000); const chatInput = page.locator('textarea').first(); await chatInput.fill(message); // 点击发送按钮 (.bg-orange-500) const sendButton = page.locator('button.bg-orange-500').or( page.getByRole('button', { name: '发送消息' }) ).or( page.locator('button').filter({ has: page.locator('svg') }).last() ); // 同时等待请求和点击 const [request] = await Promise.all([ page.waitForRequest('**/api/agents/*/message**', { timeout: options?.timeout ?? 30000 }).catch( () => page.waitForRequest('**/api/chat**', { timeout: options?.timeout ?? 30000 }) ), sendButton.first().click(), ]); let response: Response | undefined; if (options?.waitForResponse) { response = await page.waitForResponse( (r) => r.url().includes('/message') || r.url().includes('/chat'), { timeout: options?.timeout ?? 60000 } ); } return { request, response }; }, /** * 发送消息并等待流式响应完成 */ async sendChatMessageAndWaitForStream(page: Page, message: string): Promise { await this.sendChatMessage(page, message); // 等待流式响应开始 await page.waitForFunction( () => { const stored = localStorage.getItem('zclaw-chat-storage'); if (!stored) return false; const state = JSON.parse(stored).state; return state.isStreaming === true; }, { timeout: 5000 } ).catch(() => {}); // 可能太快错过了 // 等待流式响应结束 await page.waitForFunction( () => { const stored = localStorage.getItem('zclaw-chat-storage'); if (!stored) return true; // 没有 store 也算完成 const state = JSON.parse(stored).state; return state.isStreaming === false; }, { timeout: 60000 } ); }, /** * 切换模型 */ async switchModel(page: Page, modelName: string): Promise { // 点击模型选择器 (在聊天区域底部) const modelSelector = page.locator('.absolute.bottom-full').filter({ hasText: /model|模型/i, }).or( page.locator('[class*="model"]').filter({ has: page.locator('button') }) ); if (await modelSelector.isVisible()) { await modelSelector.click(); // 选择模型 const modelOption = page.getByRole('option', { name: new RegExp(modelName, 'i') }).or( page.locator('li').filter({ hasText: new RegExp(modelName, 'i') }) ); await modelOption.click(); await page.waitForTimeout(300); } }, /** * 新建对话 */ async newConversation(page: Page): Promise { // 侧边栏中的新对话按钮 const newChatBtn = page.locator('aside button').filter({ hasText: '新对话', }).or( page.getByRole('button', { name: /新对话|new/i }) ); if (await newChatBtn.first().isVisible()) { await newChatBtn.first().click(); await page.waitForTimeout(500); } }, /** * 获取连接状态 */ async getConnectionStatus(page: Page): Promise { const statusElement = page.locator('span.text-xs').filter({ hasText: /连接|Gateway|connected/i, }); if (await statusElement.isVisible()) { return statusElement.textContent() || ''; } return ''; }, // ============================================ // 分身/Agent 相关操作 // ============================================ /** * 创建分身(完整流程) */ async createClone( page: Page, data: { name: string; role?: string; model?: string } ): Promise<{ request: Request; response: Response }> { // 导航到分身标签 await navigateToTab(page, '分身'); // 点击创建按钮 const createBtn = page.locator('aside button').filter({ hasText: /\+|创建|new/i, }).or( page.getByRole('button', { name: /\+|创建|new/i }) ); await createBtn.first().click(); // 等待对话框出现 await page.waitForSelector('[role="dialog"], .fixed.inset-0', { timeout: 5000 }).catch(() => {}); const dialog = page.locator('[role="dialog"]').or(page.locator('.fixed.inset-0').last()); // 填写名称 const nameInput = dialog.locator('input').first(); await nameInput.fill(data.name); // 填写角色(如果有) if (data.role) { const roleInput = dialog.locator('input').nth(1).or( dialog.locator('textarea').first() ); if (await roleInput.isVisible()) { await roleInput.fill(data.role); } } // 提交创建 const submitBtn = dialog.getByRole('button', { name: /确认|创建|save|submit/i }).or( dialog.locator('button').filter({ hasText: /确认|创建|保存/ }) ); const [request, response] = await Promise.all([ page.waitForRequest('**/api/agents**', { timeout: 10000 }).catch( () => page.waitForRequest('**/api/clones**', { timeout: 10000 }) ), page.waitForResponse('**/api/agents**', { timeout: 10000 }).catch( () => page.waitForResponse('**/api/clones**', { timeout: 10000 }) ), submitBtn.first().click(), ]); return { request, response }; }, /** * 切换分身 */ async switchClone(page: Page, cloneName: string): Promise { await navigateToTab(page, '分身'); const cloneItem = page.locator('aside button').filter({ hasText: new RegExp(cloneName, 'i'), }); await cloneItem.first().click(); await page.waitForTimeout(500); }, /** * 删除分身 */ async deleteClone(page: Page, cloneName: string): Promise { await navigateToTab(page, '分身'); const cloneItem = page.locator('aside button').filter({ hasText: new RegExp(cloneName, 'i'), }).first(); // 悬停显示操作按钮 await cloneItem.hover(); // 查找删除按钮 const deleteBtn = cloneItem.locator('button').filter({ has: page.locator('svg'), }).or( cloneItem.getByRole('button', { name: /删除|delete|remove/i }) ); if (await deleteBtn.isVisible()) { await deleteBtn.click(); // 确认删除 const confirmBtn = page.getByRole('button', { name: /确认|confirm|delete/i }); if (await confirmBtn.isVisible()) { await confirmBtn.click(); } } }, // ============================================ // Hands 相关操作 // ============================================ /** * 触发 Hand 执行(完整流程) */ async triggerHand( page: Page, handName: string, params?: Record ): Promise<{ request: Request; response?: Response }> { // 导航到 Hands/自动化 await navigateToTab(page, 'Hands'); await page.waitForTimeout(1000); // 找到 Hand 卡片 (.bg-white.dark:bg-gray-800) const handCard = page.locator('.bg-white.dark\\:bg-gray-800, .bg-gray-800').filter({ hasText: new RegExp(handName, 'i'), }).or( page.locator('[class*="rounded-lg"]').filter({ hasText: new RegExp(handName, 'i') }) ); // 查找激活按钮 const activateBtn = handCard.getByRole('button', { name: /激活|activate|run/i }).or( handCard.locator('button').filter({ hasText: /激活/ }) ); // 如果有参数表单,先填写参数 if (params) { // 点击卡片展开 await handCard.click(); await page.waitForTimeout(300); for (const [key, value] of Object.entries(params)) { const input = page.locator(`[name="${key}"]`).or( page.locator('label').filter({ hasText: key }).locator('..').locator('input, textarea, select') ); if (await input.isVisible()) { if (typeof value === 'boolean') { if (value) { await input.check(); } else { await input.uncheck(); } } else if (typeof value === 'string') { await input.fill(value); } else { await input.fill(JSON.stringify(value)); } } } } // 触发执行 const [request] = await Promise.all([ page.waitForRequest(`**/api/hands/${handName}/activate**`, { timeout: 10000 }).catch( () => page.waitForRequest(`**/api/hands/${handName}/trigger**`, { timeout: 10000 }) ), activateBtn.first().click(), ]); return { request }; }, /** * 查看 Hand 详情 */ async viewHandDetails(page: Page, handName: string): Promise { await navigateToTab(page, 'Hands'); const handCard = page.locator('.bg-white.dark\\:bg-gray-800, .bg-gray-800').filter({ hasText: new RegExp(handName, 'i'), }); // 点击详情按钮 const detailsBtn = handCard.getByRole('button', { name: /详情|details|info/i }); if (await detailsBtn.isVisible()) { await detailsBtn.click(); await page.waitForSelector('[role="dialog"], .fixed.inset-0', { timeout: 5000 }); } }, /** * 审批 Hand 执行 */ async approveHand(page: Page, approved: boolean, reason?: string): Promise { const dialog = page.locator('[role="dialog"]').filter({ hasText: /审批|approval|approve/i, }).or( page.locator('.fixed.inset-0').filter({ hasText: /审批|approval/i }) ); if (await dialog.isVisible()) { if (!approved && reason) { const reasonInput = dialog.locator('textarea').or( dialog.locator('input[type="text"]') ); await reasonInput.fill(reason); } const actionBtn = approved ? dialog.getByRole('button', { name: /批准|approve|yes|确认/i }) : dialog.getByRole('button', { name: /拒绝|reject|no/i }); await actionBtn.click(); await page.waitForTimeout(500); } }, // ============================================ // 工作流相关操作 // ============================================ /** * 创建工作流 */ async createWorkflow( page: Page, data: { name: string; description?: string; steps: Array<{ handName: string; params?: Record }>; } ): Promise { await navigateToTab(page, '工作流'); // 点击创建按钮 const createBtn = page.getByRole('button', { name: /创建|new|\+/i }).first(); await createBtn.click(); await page.waitForTimeout(500); // 填写名称 const nameInput = page.locator('input').first(); await nameInput.fill(data.name); // 填写描述 if (data.description) { const descInput = page.locator('textarea').first(); if (await descInput.isVisible()) { await descInput.fill(data.description); } } // 添加步骤 for (const step of data.steps) { const addStepBtn = page.getByRole('button', { name: /添加步骤|add step|\+/i }); await addStepBtn.click(); // 选择 Hand const handSelector = page.locator('select').last().or( page.locator('[role="listbox"]').last() ); await handSelector.click(); await page.getByText(new RegExp(step.handName, 'i')).click(); // 填写参数(如果有) if (step.params) { const paramsInput = page.locator('textarea').filter({ hasText: /{/, }).or( page.locator('input[placeholder*="JSON"]') ); await paramsInput.fill(JSON.stringify(step.params)); } } // 保存 const saveBtn = page.getByRole('button', { name: /保存|save/i }); await saveBtn.click(); }, /** * 执行工作流 */ async executeWorkflow(page: Page, workflowId: string): Promise { await navigateToTab(page, '工作流'); const workflowItem = page.locator(`[data-workflow-id="${workflowId}"]`).or( page.locator('[class*="workflow"]').filter({ hasText: workflowId }) ); const executeBtn = workflowItem.getByRole('button', { name: /执行|run|execute/i }); await executeBtn.click(); }, // ============================================ // 团队相关操作 // ============================================ /** * 创建团队 */ async createTeam( page: Page, data: { name: string; description?: string; pattern?: 'sequential' | 'parallel' | 'pipeline'; } ): Promise { await navigateToTab(page, '团队'); // 查找创建按钮 (Plus 图标) const createBtn = page.locator('aside button').filter({ has: page.locator('svg'), }).or( page.getByRole('button', { name: /\+/i }) ); await createBtn.first().click(); // 等待创建界面出现 (.absolute.inset-0.bg-black/50) await page.waitForSelector('.absolute.inset-0, [role="dialog"]', { timeout: 5000 }); const dialog = page.locator('.absolute.inset-0, [role="dialog"]').last(); // 填写名称 const nameInput = dialog.locator('input[type="text"]').first(); await nameInput.fill(data.name); // 选择模式 if (data.pattern) { const patternSelector = dialog.locator('select').or( dialog.locator('[role="listbox"]') ); await patternSelector.click(); await page.getByText(new RegExp(data.pattern, 'i')).click(); } // 提交 const submitBtn = dialog.getByRole('button', { name: /确认|创建|save/i }); await submitBtn.click(); }, /** * 选择团队 */ async selectTeam(page: Page, teamName: string): Promise { await navigateToTab(page, '团队'); const teamItem = page.locator('.w-full.p-2.rounded-lg').filter({ hasText: new RegExp(teamName, 'i'), }); await teamItem.click(); await page.waitForTimeout(300); }, // ============================================ // 技能市场相关操作 // ============================================ /** * 搜索技能 */ async searchSkill(page: Page, query: string): Promise { await navigateToTab(page, '技能'); // 搜索框 (.pl-9 表示有搜索图标) const searchInput = page.locator('input.pl-9').or( page.locator('input[placeholder*="搜索"]') ).or( page.locator('input[type="search"]') ); await searchInput.first().fill(query); await page.waitForTimeout(500); }, /** * 安装技能 */ async installSkill(page: Page, skillName: string): Promise { await navigateToTab(page, '技能'); // 技能卡片 (.border.rounded-lg) const skillCard = page.locator('.border.rounded-lg').filter({ hasText: new RegExp(skillName, 'i'), }); const installBtn = skillCard.getByRole('button', { name: /安装|install/i }); await installBtn.click(); await page.waitForTimeout(1000); }, /** * 卸载技能 */ async uninstallSkill(page: Page, skillName: string): Promise { await navigateToTab(page, '技能'); const skillCard = page.locator('.border.rounded-lg').filter({ hasText: new RegExp(skillName, 'i'), }); const uninstallBtn = skillCard.getByRole('button', { name: /卸载|uninstall/i }); await uninstallBtn.click(); await page.waitForTimeout(1000); }, // ============================================ // 设置相关操作 // ============================================ /** * 打开设置页面 */ async openSettings(page: Page): Promise { // 底部用户栏中的设置按钮 const settingsBtn = page.locator('aside button').filter({ hasText: /设置|settings|⚙/i, }).or( page.locator('.p-3.border-t button') ); await settingsBtn.first().click(); await page.waitForTimeout(500); }, /** * 保存设置 */ async saveSettings(page: Page): Promise { const saveBtn = page.getByRole('button', { name: /保存|save|apply/i }); await saveBtn.click(); await page.waitForTimeout(500); }, // ============================================ // 通用操作 // ============================================ /** * 关闭对话框 */ async closeModal(page: Page): Promise { const closeBtn = page.locator('[role="dialog"] button, .fixed.inset-0 button').filter({ has: page.locator('svg'), }).or( page.getByRole('button', { name: /关闭|close|cancel|取消/i }) ); if (await closeBtn.first().isVisible()) { await closeBtn.first().click(); } }, /** * 按 Escape 键 */ async pressEscape(page: Page): Promise { await page.keyboard.press('Escape'); await page.waitForTimeout(300); }, /** * 刷新页面并等待就绪 */ async refreshAndWait(page: Page): Promise { await page.reload(); await waitForAppReady(page); }, /** * 等待元素可见 */ async waitForVisible(page: Page, selector: string, timeout = 5000): Promise { await page.waitForSelector(selector, { state: 'visible', timeout }); }, /** * 截图 */ async takeScreenshot(page: Page, name: string): Promise { await page.screenshot({ path: `test-results/${name}.png` }); }, };