Major changes: - Shift from "OpenFang desktop client" to "independent AI Agent desktop app" - Add decision principle: "Is this useful for ZCLAW? Does it affect ZCLAW?" - Simplify project structure and tech stack sections - Replace OpenClaw vs OpenFang comparison with unified backend approach - Consolidate troubleshooting from scattered sections into organized FAQ - Update Hands system documentation with 8 capabilities and status - Stream
636 lines
16 KiB
TypeScript
636 lines
16 KiB
TypeScript
/**
|
||
* 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<T = unknown>(page: Page): Promise<T | null> {
|
||
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<T = unknown>(page: Page): Promise<T | null> {
|
||
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<T = unknown>(page: Page, storeName: StoreName): Promise<T | null> {
|
||
const key = STORAGE_KEYS[storeName];
|
||
|
||
if (!key) {
|
||
// 非持久化 Store,尝试从运行时获取
|
||
return this.getRuntimeState<T>(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<T = unknown>(page: Page, storeName: string): Promise<T | null> {
|
||
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<T = unknown>(
|
||
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<T = unknown>(
|
||
page: Page,
|
||
storeName: StoreName,
|
||
fieldPath: string
|
||
): Promise<T | null> {
|
||
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<T = unknown>(
|
||
page: Page,
|
||
storeName: StoreName,
|
||
fieldPath: string,
|
||
expectedValue: T,
|
||
options?: { timeout?: number }
|
||
): Promise<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
const key = STORAGE_KEYS[storeName];
|
||
if (key) {
|
||
await page.evaluate((storageKey) => {
|
||
localStorage.removeItem(storageKey);
|
||
}, key);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 清除所有 Store 数据
|
||
*/
|
||
async clearAllStores(page: Page): Promise<void> {
|
||
await page.evaluate(() => {
|
||
const keys = Object.keys(localStorage);
|
||
keys.forEach((key) => {
|
||
if (key.startsWith('zclaw-')) {
|
||
localStorage.removeItem(key);
|
||
}
|
||
});
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 设置 Store 状态(用于测试初始化)
|
||
*/
|
||
async setStoreState<T>(page: Page, storeName: StoreName, state: T): Promise<void> {
|
||
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<T>(page: Page, state: T): Promise<void> {
|
||
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<Record<string, unknown>> {
|
||
return page.evaluate(() => {
|
||
const snapshot: Record<string, unknown> = {};
|
||
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<T>(
|
||
page: Page,
|
||
expected: Partial<T>
|
||
): Promise<void> {
|
||
const state = await storeInspectors.getChatState<T>(page);
|
||
expect(state).not.toBeNull();
|
||
|
||
for (const [key, value] of Object.entries(expected)) {
|
||
expect(state).toHaveProperty(key, value);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 断言 Teams Store 状态匹配预期
|
||
*/
|
||
async assertTeamsState<T>(
|
||
page: Page,
|
||
expected: Partial<T>
|
||
): Promise<void> {
|
||
const state = await storeInspectors.getTeamsState<T>(page);
|
||
expect(state).not.toBeNull();
|
||
|
||
for (const [key, value] of Object.entries(expected)) {
|
||
expect(state).toHaveProperty(key, value);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 断言 Store 字段值
|
||
*/
|
||
async assertFieldEquals<T>(
|
||
page: Page,
|
||
storeName: StoreName,
|
||
fieldPath: string,
|
||
expected: T
|
||
): Promise<void> {
|
||
const value = await storeInspectors.getStateField<T>(page, storeName, fieldPath);
|
||
expect(value).toEqual(expected);
|
||
},
|
||
|
||
/**
|
||
* 断言消息数量
|
||
*/
|
||
async assertMessageCount(page: Page, expected: number): Promise<void> {
|
||
const state = await storeInspectors.getChatState<{ messages: unknown[] }>(page);
|
||
expect(state?.messages?.length).toBe(expected);
|
||
},
|
||
|
||
/**
|
||
* 断言最后一条消息内容
|
||
*/
|
||
async assertLastMessageContent(page: Page, expected: string): Promise<void> {
|
||
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<void> {
|
||
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<void> {
|
||
const state = await storeInspectors.getTeamsState<{ teams: unknown[] }>(page);
|
||
expect(state?.teams?.length).toBeGreaterThan(0);
|
||
},
|
||
|
||
/**
|
||
* 断言当前 Agent
|
||
*/
|
||
async assertCurrentAgent(page: Page, agentId: string): Promise<void> {
|
||
const state = await storeInspectors.getChatState<{
|
||
currentAgent: { id: string };
|
||
}>(page);
|
||
expect(state?.currentAgent?.id).toBe(agentId);
|
||
},
|
||
|
||
/**
|
||
* 断言 isStreaming 状态
|
||
*/
|
||
async assertStreamingState(page: Page, expected: boolean): Promise<void> {
|
||
const state = await storeInspectors.getChatState<{ isStreaming: boolean }>(page);
|
||
expect(state?.isStreaming).toBe(expected);
|
||
},
|
||
|
||
/**
|
||
* 断言当前模型
|
||
*/
|
||
async assertCurrentModel(page: Page, expectedModel: string): Promise<void> {
|
||
const state = await storeInspectors.getChatState<{ currentModel: string }>(page);
|
||
expect(state?.currentModel).toBe(expectedModel);
|
||
},
|
||
|
||
/**
|
||
* 断言会话存在
|
||
*/
|
||
async assertConversationExists(page: Page, conversationId: string): Promise<void> {
|
||
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;
|
||
}
|