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
475 lines
13 KiB
TypeScript
475 lines
13 KiB
TypeScript
/**
|
|
* Store 断言工具
|
|
* 提供类型安全的 Store 状态断言方法
|
|
*/
|
|
|
|
import { Page } from '@playwright/test';
|
|
import { expect } from '@playwright/test';
|
|
import { storeInspectors, STORE_NAMES, type StoreName } from '../fixtures/store-inspectors';
|
|
|
|
/**
|
|
* 通用断言工具
|
|
*/
|
|
export const storeAssertions = {
|
|
/**
|
|
* 断言 Store 状态匹配预期对象
|
|
*/
|
|
async assertStoreState<T>(
|
|
page: Page,
|
|
storeName: StoreName,
|
|
expected: Partial<T>
|
|
): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<T>(page, storeName);
|
|
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);
|
|
},
|
|
|
|
/**
|
|
* 断言 Store 字段包含特定值(数组或字符串)
|
|
*/
|
|
async assertFieldContains(
|
|
page: Page,
|
|
storeName: StoreName,
|
|
fieldPath: string,
|
|
expected: unknown
|
|
): Promise<void> {
|
|
const value = await storeInspectors.getStateField<unknown>(page, storeName, fieldPath);
|
|
|
|
if (Array.isArray(value)) {
|
|
expect(value).toContainEqual(expected);
|
|
} else if (typeof value === 'string') {
|
|
expect(value).toContain(expected);
|
|
} else {
|
|
throw new Error(`Field ${fieldPath} is not an array or string`);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 断言 Store 字段数组长度
|
|
*/
|
|
async assertArrayLength(
|
|
page: Page,
|
|
storeName: StoreName,
|
|
fieldPath: string,
|
|
expected: number
|
|
): Promise<void> {
|
|
const value = await storeInspectors.getStateField<unknown[]>(page, storeName, fieldPath);
|
|
expect(Array.isArray(value)).toBe(true);
|
|
expect(value?.length).toBe(expected);
|
|
},
|
|
|
|
/**
|
|
* 断言 Store 字段数组长度大于指定值
|
|
*/
|
|
async assertArrayLengthGreaterThan(
|
|
page: Page,
|
|
storeName: StoreName,
|
|
fieldPath: string,
|
|
min: number
|
|
): Promise<void> {
|
|
const value = await storeInspectors.getStateField<unknown[]>(page, storeName, fieldPath);
|
|
expect(Array.isArray(value)).toBe(true);
|
|
expect(value?.length).toBeGreaterThan(min);
|
|
},
|
|
|
|
/**
|
|
* 断言 Store 字段为真值
|
|
*/
|
|
async assertFieldTruthy(page: Page, storeName: StoreName, fieldPath: string): Promise<void> {
|
|
const value = await storeInspectors.getStateField<unknown>(page, storeName, fieldPath);
|
|
expect(value).toBeTruthy();
|
|
},
|
|
|
|
/**
|
|
* 断言 Store 字段为假值
|
|
*/
|
|
async assertFieldFalsy(page: Page, storeName: StoreName, fieldPath: string): Promise<void> {
|
|
const value = await storeInspectors.getStateField<unknown>(page, storeName, fieldPath);
|
|
expect(value).toBeFalsy();
|
|
},
|
|
|
|
/**
|
|
* 断言 Store 字段为 null
|
|
*/
|
|
async assertFieldNull(page: Page, storeName: StoreName, fieldPath: string): Promise<void> {
|
|
const value = await storeInspectors.getStateField<unknown>(page, storeName, fieldPath);
|
|
expect(value).toBeNull();
|
|
},
|
|
|
|
/**
|
|
* 断言 Store 字段不为 null
|
|
*/
|
|
async assertFieldNotNull(page: Page, storeName: StoreName, fieldPath: string): Promise<void> {
|
|
const value = await storeInspectors.getStateField<unknown>(page, storeName, fieldPath);
|
|
expect(value).not.toBeNull();
|
|
},
|
|
|
|
/**
|
|
* 断言 Store 字段匹配正则表达式
|
|
*/
|
|
async assertFieldMatches(
|
|
page: Page,
|
|
storeName: StoreName,
|
|
fieldPath: string,
|
|
pattern: RegExp
|
|
): Promise<void> {
|
|
const value = await storeInspectors.getStateField<string>(page, storeName, fieldPath);
|
|
expect(value).toMatch(pattern);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 聊天相关断言
|
|
*/
|
|
export const chatAssertions = {
|
|
/**
|
|
* 断言消息数量
|
|
*/
|
|
async assertMessageCount(page: Page, expected: number): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{
|
|
messages: unknown[];
|
|
}>(page, STORE_NAMES.CHAT);
|
|
expect(state?.messages?.length).toBe(expected);
|
|
},
|
|
|
|
/**
|
|
* 断言消息数量大于
|
|
*/
|
|
async assertMessageCountGreaterThan(page: Page, min: number): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{
|
|
messages: unknown[];
|
|
}>(page, STORE_NAMES.CHAT);
|
|
expect(state?.messages?.length).toBeGreaterThan(min);
|
|
},
|
|
|
|
/**
|
|
* 断言最后一条消息内容
|
|
*/
|
|
async assertLastMessageContent(page: Page, expected: string | RegExp): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{
|
|
messages: Array<{ content: string }>;
|
|
}>(page, STORE_NAMES.CHAT);
|
|
const lastMessage = state?.messages?.[state.messages.length - 1];
|
|
expect(lastMessage).toBeDefined();
|
|
|
|
if (expected instanceof RegExp) {
|
|
expect(lastMessage?.content).toMatch(expected);
|
|
} else {
|
|
expect(lastMessage?.content).toContain(expected);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 断言最后一条消息角色
|
|
*/
|
|
async assertLastMessageRole(
|
|
page: Page,
|
|
role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow'
|
|
): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{
|
|
messages: Array<{ role: string }>;
|
|
}>(page, STORE_NAMES.CHAT);
|
|
const lastMessage = state?.messages?.[state.messages.length - 1];
|
|
expect(lastMessage?.role).toBe(role);
|
|
},
|
|
|
|
/**
|
|
* 断言流式状态
|
|
*/
|
|
async assertStreamingState(page: Page, expected: boolean): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{ isStreaming: boolean }>(
|
|
page,
|
|
STORE_NAMES.CHAT
|
|
);
|
|
expect(state?.isStreaming).toBe(expected);
|
|
},
|
|
|
|
/**
|
|
* 断言当前模型
|
|
*/
|
|
async assertCurrentModel(page: Page, modelId: string): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{ currentModel: string }>(
|
|
page,
|
|
STORE_NAMES.CHAT
|
|
);
|
|
expect(state?.currentModel).toBe(modelId);
|
|
},
|
|
|
|
/**
|
|
* 断言当前 Agent
|
|
*/
|
|
async assertCurrentAgent(page: Page, agentId: string): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{
|
|
currentAgent: { id: string } | null;
|
|
}>(page, STORE_NAMES.CHAT);
|
|
expect(state?.currentAgent?.id).toBe(agentId);
|
|
},
|
|
|
|
/**
|
|
* 断言 sessionKey 存在
|
|
*/
|
|
async assertSessionKeyExists(page: Page): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{ sessionKey: string | null }>(
|
|
page,
|
|
STORE_NAMES.CHAT
|
|
);
|
|
expect(state?.sessionKey).not.toBeNull();
|
|
},
|
|
|
|
/**
|
|
* 断言消息包含错误
|
|
*/
|
|
async assertLastMessageHasError(page: Page): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{
|
|
messages: Array<{ error?: string }>;
|
|
}>(page, STORE_NAMES.CHAT);
|
|
const lastMessage = state?.messages?.[state.messages.length - 1];
|
|
expect(lastMessage?.error).toBeDefined();
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 连接相关断言
|
|
*/
|
|
export const connectionAssertions = {
|
|
/**
|
|
* 断言连接状态
|
|
*/
|
|
async assertConnectionState(
|
|
page: Page,
|
|
expected: 'connected' | 'disconnected' | 'connecting' | 'reconnecting'
|
|
): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{ connectionState: string }>(
|
|
page,
|
|
STORE_NAMES.CONNECTION
|
|
);
|
|
expect(state?.connectionState).toBe(expected);
|
|
},
|
|
|
|
/**
|
|
* 断言已连接
|
|
*/
|
|
async assertConnected(page: Page): Promise<void> {
|
|
await this.assertConnectionState(page, 'connected');
|
|
},
|
|
|
|
/**
|
|
* 断言已断开
|
|
*/
|
|
async assertDisconnected(page: Page): Promise<void> {
|
|
await this.assertConnectionState(page, 'disconnected');
|
|
},
|
|
|
|
/**
|
|
* 断言 Gateway 版本
|
|
*/
|
|
async assertGatewayVersion(page: Page, expected: string): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{ gatewayVersion: string | null }>(
|
|
page,
|
|
STORE_NAMES.CONNECTION
|
|
);
|
|
expect(state?.gatewayVersion).toBe(expected);
|
|
},
|
|
|
|
/**
|
|
* 断言无连接错误
|
|
*/
|
|
async assertNoError(page: Page): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{ error: string | null }>(
|
|
page,
|
|
STORE_NAMES.CONNECTION
|
|
);
|
|
expect(state?.error).toBeNull();
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Hands 相关断言
|
|
*/
|
|
export const handAssertions = {
|
|
/**
|
|
* 断言 Hands 列表非空
|
|
*/
|
|
async assertHandsNotEmpty(page: Page): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{ hands: unknown[] }>(
|
|
page,
|
|
STORE_NAMES.HAND
|
|
);
|
|
expect(state?.hands?.length).toBeGreaterThan(0);
|
|
},
|
|
|
|
/**
|
|
* 断言 Hands 列表数量
|
|
*/
|
|
async assertHandsCount(page: Page, expected: number): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{ hands: unknown[] }>(
|
|
page,
|
|
STORE_NAMES.HAND
|
|
);
|
|
expect(state?.hands?.length).toBe(expected);
|
|
},
|
|
|
|
/**
|
|
* 断言 Hand 状态
|
|
*/
|
|
async assertHandStatus(
|
|
page: Page,
|
|
handId: string,
|
|
expected: string
|
|
): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{
|
|
hands: Array<{ id: string; status: string }>;
|
|
}>(page, STORE_NAMES.HAND);
|
|
const hand = state?.hands?.find((h) => h.id === handId);
|
|
expect(hand?.status).toBe(expected);
|
|
},
|
|
|
|
/**
|
|
* 断言存在运行中的 Hand
|
|
*/
|
|
async assertHasRunningHand(page: Page): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{
|
|
hands: Array<{ status: string }>;
|
|
}>(page, STORE_NAMES.HAND);
|
|
const hasRunning = state?.hands?.some((h) => h.status === 'running');
|
|
expect(hasRunning).toBe(true);
|
|
},
|
|
|
|
/**
|
|
* 断言存在待审批的 Hand
|
|
*/
|
|
async assertHasPendingApproval(page: Page): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{
|
|
approvals: unknown[];
|
|
}>(page, STORE_NAMES.HAND);
|
|
expect(state?.approvals?.length).toBeGreaterThan(0);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 分身/Agent 相关断言
|
|
*/
|
|
export const agentAssertions = {
|
|
/**
|
|
* 断言分身列表数量
|
|
*/
|
|
async assertClonesCount(page: Page, expected: number): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{ clones: unknown[] }>(
|
|
page,
|
|
STORE_NAMES.AGENT
|
|
);
|
|
expect(state?.clones?.length).toBe(expected);
|
|
},
|
|
|
|
/**
|
|
* 断言分身列表包含指定名称
|
|
*/
|
|
async assertClonesContains(page: Page, name: string): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{
|
|
clones: Array<{ name: string }>;
|
|
}>(page, STORE_NAMES.AGENT);
|
|
const hasClone = state?.clones?.some((c) => c.name === name);
|
|
expect(hasClone).toBe(true);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 团队相关断言
|
|
*/
|
|
export const teamAssertions = {
|
|
/**
|
|
* 断言团队数量
|
|
*/
|
|
async assertTeamsCount(page: Page, expected: number): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{ teams: unknown[] }>(
|
|
page,
|
|
STORE_NAMES.TEAM
|
|
);
|
|
expect(state?.teams?.length).toBe(expected);
|
|
},
|
|
|
|
/**
|
|
* 断言活跃团队
|
|
*/
|
|
async assertActiveTeam(page: Page, teamId: string): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{
|
|
activeTeam: { id: string } | null;
|
|
}>(page, STORE_NAMES.TEAM);
|
|
expect(state?.activeTeam?.id).toBe(teamId);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 工作流相关断言
|
|
*/
|
|
export const workflowAssertions = {
|
|
/**
|
|
* 断言工作流数量
|
|
*/
|
|
async assertWorkflowsCount(page: Page, expected: number): Promise<void> {
|
|
const state = await storeInspectors.getPersistedState<{ workflows: unknown[] }>(
|
|
page,
|
|
STORE_NAMES.WORKFLOW
|
|
);
|
|
expect(state?.workflows?.length).toBe(expected);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 组合断言 - 用于复杂场景
|
|
*/
|
|
export const compositeAssertions = {
|
|
/**
|
|
* 断言完整的聊天状态(发送消息后)
|
|
*/
|
|
async assertChatStateAfterSend(
|
|
page: Page,
|
|
expected: {
|
|
messageCount?: number;
|
|
isStreaming?: boolean;
|
|
lastMessageRole?: 'user' | 'assistant';
|
|
}
|
|
): Promise<void> {
|
|
if (expected.messageCount !== undefined) {
|
|
await chatAssertions.assertMessageCount(page, expected.messageCount);
|
|
}
|
|
if (expected.isStreaming !== undefined) {
|
|
await chatAssertions.assertStreamingState(page, expected.isStreaming);
|
|
}
|
|
if (expected.lastMessageRole !== undefined) {
|
|
await chatAssertions.assertLastMessageRole(page, expected.lastMessageRole);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 断言完整的应用状态(健康检查)
|
|
*/
|
|
async assertHealthyAppState(page: Page): Promise<void> {
|
|
// 连接正常
|
|
await connectionAssertions.assertNoError(page);
|
|
|
|
// 聊天可用
|
|
const chatState = await storeInspectors.getPersistedState<{ isStreaming: boolean }>(
|
|
page,
|
|
STORE_NAMES.CHAT
|
|
);
|
|
expect(chatState?.isStreaming).toBe(false);
|
|
},
|
|
};
|