/** * Store 状态检查工具 * 用于验证 Zustand Store 的状态变化 * * 实际 localStorage keys: * - chatStore: zclaw-chat-storage (持久化) * - teamStore: zclaw-teams (持久化) * - gatewayStore: zclaw-gateway-url, zclaw-gateway-token, zclaw-device-id (单独键) * - handStore: 不持久化 * - agentStore: 不持久化 */ import { Page } from '@playwright/test'; import { expect } from '@playwright/test'; /** * localStorage key 映射 */ export const STORAGE_KEYS = { // 标准持久化 Store (使用 zustand persist) CHAT: 'zclaw-chat-storage', TEAMS: 'zclaw-teams', // Gateway Store 使用单独的键 GATEWAY: 'zclaw-gateway-url', // 主键 GATEWAY_URL: 'zclaw-gateway-url', GATEWAY_TOKEN: 'zclaw-gateway-token', DEVICE_ID: 'zclaw-device-id', // 非持久化 Store (运行时状态,不在 localStorage) HAND: null, AGENT: null, WORKFLOW: null, CONNECTION: null, CONFIG: null, } as const; /** * Store 名称类型 */ export type StoreName = keyof typeof STORAGE_KEYS; /** * 向后兼容: STORE_NAMES 枚举 * @deprecated 使用 STORAGE_KEYS 代替 */ export const STORE_NAMES = { CHAT: 'CHAT' as StoreName, GATEWAY: 'GATEWAY' as StoreName, AGENT: 'AGENT' as StoreName, HAND: 'HAND' as StoreName, WORKFLOW: 'WORKFLOW' as StoreName, CONFIG: 'CONFIG' as StoreName, TEAM: 'TEAMS' as StoreName, CONNECTION: 'CONNECTION' as StoreName, } as const; /** * Store 状态检查工具 */ export const storeInspectors = { /** * 获取 localStorage key */ getStorageKey(storeName: StoreName): string | null { return STORAGE_KEYS[storeName]; }, /** * 获取持久化的 Chat Store 状态 */ async getChatState(page: Page): Promise { return page.evaluate((key) => { const stored = localStorage.getItem(key); if (!stored) return null; try { const parsed = JSON.parse(stored); return parsed.state as T; } catch { return null; } }, STORAGE_KEYS.CHAT); }, /** * 获取持久化的 Teams Store 状态 */ async getTeamsState(page: Page): Promise { return page.evaluate((key) => { const stored = localStorage.getItem(key); if (!stored) return null; try { // teams store 可能直接存储数组或对象 const parsed = JSON.parse(stored); return parsed.state ? parsed.state : parsed; } catch { return null; } }, STORAGE_KEYS.TEAMS); }, /** * 获取 Gateway 配置 */ async getGatewayConfig(page: Page): Promise<{ url: string | null; token: string | null; deviceId: string | null; }> { return page.evaluate((keys) => { return { url: localStorage.getItem(keys.url), token: localStorage.getItem(keys.token), deviceId: localStorage.getItem(keys.deviceId), }; }, { url: STORAGE_KEYS.GATEWAY_URL, token: STORAGE_KEYS.GATEWAY_TOKEN, deviceId: STORAGE_KEYS.DEVICE_ID, }); }, /** * 获取持久化的 Store 状态 (通用方法) */ async getPersistedState(page: Page, storeName: StoreName): Promise { const key = STORAGE_KEYS[storeName]; if (!key) { // 非持久化 Store,尝试从运行时获取 return this.getRuntimeState(page, storeName); } return page.evaluate((storageKey) => { const stored = localStorage.getItem(storageKey); if (!stored) return null; try { const parsed = JSON.parse(stored); return parsed.state ? (parsed.state as T) : (parsed as T); } catch { return null; } }, key); }, /** * 获取运行时 Store 状态 (通过 window 全局变量) * 注意:需要在应用中暴露 store 到 window 对象 */ async getRuntimeState(page: Page, storeName: string): Promise { return page.evaluate((name) => { // 尝试从 window.__ZCLAW_STORES__ 获取 const stores = (window as any).__ZCLAW_STORES__; if (stores && stores[name]) { return stores[name].getState() as T; } return null; }, storeName); }, /** * 获取整个持久化对象(包含 state 和 version) */ async getFullStorage( page: Page, storeName: StoreName ): Promise<{ state: T; version: number } | null> { const key = STORAGE_KEYS[storeName]; if (!key) return null; return page.evaluate((storageKey) => { const stored = localStorage.getItem(storageKey); if (!stored) return null; try { return JSON.parse(stored); } catch { return null; } }, key); }, /** * 获取 Store 中的特定字段 */ async getStateField( page: Page, storeName: StoreName, fieldPath: string ): Promise { const key = STORAGE_KEYS[storeName]; if (!key) return null; return page.evaluate( ({ storageKey, path }) => { const stored = localStorage.getItem(storageKey); if (!stored) return null; try { const parsed = JSON.parse(stored); const state = parsed.state ? parsed.state : parsed; const value = path.split('.').reduce((obj: any, key) => obj?.[key], state); return value as T; } catch { return null; } }, { storageKey: key, path: fieldPath } ); }, /** * 等待 Store 状态变化 */ async waitForStateChange( page: Page, storeName: StoreName, fieldPath: string, expectedValue: T, options?: { timeout?: number } ): Promise { const key = STORAGE_KEYS[storeName]; if (!key) { throw new Error(`Store ${storeName} is not persisted`); } await page.waitForFunction( ({ storageKey, path, expected }) => { const stored = localStorage.getItem(storageKey); if (!stored) return false; try { const parsed = JSON.parse(stored); const state = parsed.state ? parsed.state : parsed; const value = path.split('.').reduce((obj: any, key) => obj?.[key], state); return JSON.stringify(value) === JSON.stringify(expected); } catch { return false; } }, { storageKey: key, path: fieldPath, expected: expectedValue, }, { timeout: options?.timeout ?? 5000 } ); }, /** * 等待 Store 中某个字段存在 */ async waitForFieldExists( page: Page, storeName: StoreName, fieldPath: string, options?: { timeout?: number } ): Promise { const key = STORAGE_KEYS[storeName]; if (!key) return; await page.waitForFunction( ({ storageKey, path }) => { const stored = localStorage.getItem(storageKey); if (!stored) return false; try { const parsed = JSON.parse(stored); const state = parsed.state ? parsed.state : parsed; const value = path.split('.').reduce((obj: any, key) => obj?.[key], state); return value !== undefined && value !== null; } catch { return false; } }, { storageKey: key, path: fieldPath }, { timeout: options?.timeout ?? 5000 } ); }, /** * 等待消息数量变化 */ async waitForMessageCount( page: Page, expectedCount: number, options?: { timeout?: number } ): Promise { await page.waitForFunction( ({ expected, key }) => { const stored = localStorage.getItem(key); if (!stored) return false; try { const parsed = JSON.parse(stored); return parsed.state?.messages?.length === expected; } catch { return false; } }, { expected: expectedCount, key: STORAGE_KEYS.CHAT }, { timeout: options?.timeout ?? 10000 } ); }, /** * 清除特定 Store 的持久化数据 */ async clearStore(page: Page, storeName: StoreName): Promise { const key = STORAGE_KEYS[storeName]; if (key) { await page.evaluate((storageKey) => { localStorage.removeItem(storageKey); }, key); } }, /** * 清除所有 Store 数据 */ async clearAllStores(page: Page): Promise { await page.evaluate(() => { const keys = Object.keys(localStorage); keys.forEach((key) => { if (key.startsWith('zclaw-')) { localStorage.removeItem(key); } }); }); }, /** * 设置 Store 状态(用于测试初始化) */ async setStoreState(page: Page, storeName: StoreName, state: T): Promise { const key = STORAGE_KEYS[storeName]; if (!key) return; await page.evaluate( ({ storageKey, stateObj }) => { const data = { state: stateObj, version: 0, }; localStorage.setItem(storageKey, JSON.stringify(data)); }, { storageKey: key, stateObj: state } ); }, /** * 设置 Chat Store 状态 */ async setChatState(page: Page, state: T): Promise { await page.evaluate( ({ key, stateObj }) => { const data = { state: stateObj, version: 0, }; localStorage.setItem(key, JSON.stringify(data)); }, { key: STORAGE_KEYS.CHAT, stateObj: state } ); }, /** * 获取所有 Store 状态快照 */ async getAllStoresSnapshot(page: Page): Promise> { return page.evaluate(() => { const snapshot: Record = {}; const keys = Object.keys(localStorage); keys.forEach((key) => { if (key.startsWith('zclaw-')) { const storeName = key.replace('zclaw-', '').replace('-storage', ''); try { const stored = localStorage.getItem(key); if (stored) { const parsed = JSON.parse(stored); snapshot[storeName] = parsed.state ? parsed.state : parsed; } } catch { // ignore parse errors } } }); return snapshot; }); }, /** * 检查 Store 是否持久化 */ isPersistedStore(storeName: StoreName): boolean { return STORAGE_KEYS[storeName] !== null; }, }; /** * Store 断言工具 */ export const storeAssertions = { /** * 断言 Chat Store 状态匹配预期 */ async assertChatState( page: Page, expected: Partial ): Promise { const state = await storeInspectors.getChatState(page); expect(state).not.toBeNull(); for (const [key, value] of Object.entries(expected)) { expect(state).toHaveProperty(key, value); } }, /** * 断言 Teams Store 状态匹配预期 */ async assertTeamsState( page: Page, expected: Partial ): Promise { const state = await storeInspectors.getTeamsState(page); expect(state).not.toBeNull(); for (const [key, value] of Object.entries(expected)) { expect(state).toHaveProperty(key, value); } }, /** * 断言 Store 字段值 */ async assertFieldEquals( page: Page, storeName: StoreName, fieldPath: string, expected: T ): Promise { const value = await storeInspectors.getStateField(page, storeName, fieldPath); expect(value).toEqual(expected); }, /** * 断言消息数量 */ async assertMessageCount(page: Page, expected: number): Promise { const state = await storeInspectors.getChatState<{ messages: unknown[] }>(page); expect(state?.messages?.length).toBe(expected); }, /** * 断言最后一条消息内容 */ async assertLastMessageContent(page: Page, expected: string): Promise { const state = await storeInspectors.getChatState<{ messages: Array<{ content: string }>; }>(page); const lastMessage = state?.messages?.[state.messages.length - 1]; expect(lastMessage?.content).toContain(expected); }, /** * 断言 Gateway 配置 */ async assertGatewayConfig( page: Page, expected: { url?: string; token?: string; deviceId?: string } ): Promise { const config = await storeInspectors.getGatewayConfig(page); if (expected.url !== undefined) { expect(config.url).toBe(expected.url); } if (expected.token !== undefined) { expect(config.token).toBe(expected.token); } if (expected.deviceId !== undefined) { expect(config.deviceId).toBe(expected.deviceId); } }, /** * 断言 Teams 列表非空 */ async assertTeamsNotEmpty(page: Page): Promise { const state = await storeInspectors.getTeamsState<{ teams: unknown[] }>(page); expect(state?.teams?.length).toBeGreaterThan(0); }, /** * 断言当前 Agent */ async assertCurrentAgent(page: Page, agentId: string): Promise { const state = await storeInspectors.getChatState<{ currentAgent: { id: string }; }>(page); expect(state?.currentAgent?.id).toBe(agentId); }, /** * 断言 isStreaming 状态 */ async assertStreamingState(page: Page, expected: boolean): Promise { const state = await storeInspectors.getChatState<{ isStreaming: boolean }>(page); expect(state?.isStreaming).toBe(expected); }, /** * 断言当前模型 */ async assertCurrentModel(page: Page, expectedModel: string): Promise { const state = await storeInspectors.getChatState<{ currentModel: string }>(page); expect(state?.currentModel).toBe(expectedModel); }, /** * 断言会话存在 */ async assertConversationExists(page: Page, conversationId: string): Promise { const state = await storeInspectors.getChatState<{ conversations: Array<{ id: string }>; }>(page); const exists = state?.conversations?.some(c => c.id === conversationId); expect(exists).toBe(true); }, }; /** * 类型定义 - Chat Store 状态 */ export interface ChatStoreState { messages: Array<{ id: string; role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow'; content: string; timestamp: string; streaming?: boolean; error?: string; runId?: string; toolName?: string; toolInput?: string; toolOutput?: string; handName?: string; handStatus?: string; handResult?: unknown; workflowId?: string; workflowStep?: string; workflowStatus?: string; workflowResult?: unknown; }>; conversations: Array<{ id: string; title: string; messages: string[]; sessionKey: string | null; agentId: string | null; createdAt: string; updatedAt: string; }>; currentConversationId: string | null; currentAgent: { id: string; name: string; icon: string; color: string; lastMessage: string; time: string; } | null; isStreaming: boolean; currentModel: string; sessionKey: string | null; } /** * 类型定义 - Team Store 状态 */ export interface TeamStoreState { teams: Array<{ id: string; name: string; description?: string; members: Array<{ id: string; agentId: string; name: string; role: 'orchestrator' | 'reviewer' | 'worker'; skills: string[]; workload: number; status: 'idle' | 'working' | 'offline'; maxConcurrentTasks: number; currentTasks: string[]; }>; tasks: Array<{ id: string; title: string; description?: string; status: 'pending' | 'assigned' | 'in_progress' | 'review' | 'completed'; priority: 'low' | 'medium' | 'high'; assigneeId?: string; dependencies: string[]; type: string; estimate?: number; createdAt: string; updatedAt: string; }>; pattern: 'sequential' | 'parallel' | 'pipeline'; activeLoops: Array<{ id: string; developerId: string; reviewerId: string; taskId: string; state: 'developing' | 'revising' | 'reviewing' | 'approved' | 'escalated'; iterationCount: number; maxIterations: number; }>; status: 'active' | 'paused' | 'completed'; createdAt: string; updatedAt: string; }>; activeTeam: { id: string; name: string; } | null; metrics: { tasksCompleted: number; avgCompletionTime: number; passRate: number; avgIterations: number; escalations: number; efficiency: number; } | null; isLoading: boolean; error: string | null; }