docs(guide): rewrite CLAUDE.md with ZCLAW-first perspective
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
This commit is contained in:
366
desktop/tests/e2e/utils/network-helpers.ts
Normal file
366
desktop/tests/e2e/utils/network-helpers.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* 网络拦截和 Mock 工具
|
||||
* 用于深度验证 API 调用、模拟响应和网络错误
|
||||
*/
|
||||
|
||||
import { Page, Route, Request } from '@playwright/test';
|
||||
|
||||
export interface CapturedRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
body?: unknown;
|
||||
headers: Record<string, string>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface MockResponse {
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 网络拦截和 Mock 工具集
|
||||
*/
|
||||
export const networkHelpers = {
|
||||
/**
|
||||
* 拦截并记录所有 API 请求
|
||||
* 返回请求列表,用于后续断言
|
||||
*/
|
||||
async interceptAllAPI(page: Page): Promise<CapturedRequest[]> {
|
||||
const requests: CapturedRequest[] = [];
|
||||
|
||||
await page.route('**/api/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
const body = request.postData();
|
||||
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
method: request.method(),
|
||||
body: body ? safeParseJSON(body) : undefined,
|
||||
headers: request.headers(),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
return requests;
|
||||
},
|
||||
|
||||
/**
|
||||
* Mock API 响应
|
||||
* @param path - API 路径(不含 /api 前缀)
|
||||
* @param response - Mock 响应配置
|
||||
*/
|
||||
async mockAPI(
|
||||
page: Page,
|
||||
path: string,
|
||||
response: MockResponse | ((request: Request) => MockResponse)
|
||||
): Promise<void> {
|
||||
await page.route(`**/api/${path}**`, async (route: Route) => {
|
||||
const request = route.request();
|
||||
const mockConfig = typeof response === 'function' ? response(request) : response;
|
||||
|
||||
if (mockConfig.delay) {
|
||||
await new Promise((r) => setTimeout(r, mockConfig.delay));
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: mockConfig.status ?? 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockConfig.body ?? {}),
|
||||
headers: mockConfig.headers,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Mock 多个 API 响应
|
||||
*/
|
||||
async mockMultipleAPIs(
|
||||
page: Page,
|
||||
mocks: Record<string, MockResponse>
|
||||
): Promise<void> {
|
||||
for (const [path, response] of Object.entries(mocks)) {
|
||||
await this.mockAPI(page, path, response);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 模拟网络错误
|
||||
*/
|
||||
async simulateNetworkError(page: Page, path: string): Promise<void> {
|
||||
await page.route(`**/api/${path}**`, async (route: Route) => {
|
||||
await route.abort('failed');
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 模拟连接超时
|
||||
*/
|
||||
async simulateTimeout(page: Page, path: string, timeoutMs: number = 60000): Promise<void> {
|
||||
await page.route(`**/api/${path}**`, async (route: Route) => {
|
||||
await new Promise((r) => setTimeout(r, timeoutMs));
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 模拟延迟响应
|
||||
*/
|
||||
async simulateDelay(page: Page, path: string, delayMs: number): Promise<void> {
|
||||
await page.route(`**/api/${path}**`, async (route: Route) => {
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
await route.continue();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 模拟 HTTP 错误状态码
|
||||
*/
|
||||
async simulateHTTPError(page: Page, path: string, status: number, message?: string): Promise<void> {
|
||||
await page.route(`**/api/${path}**`, async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: true,
|
||||
message: message || `HTTP ${status} Error`,
|
||||
}),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 模拟限流 (429 Too Many Requests)
|
||||
*/
|
||||
async simulateRateLimit(page: Page, path: string, retryAfter: number = 60): Promise<void> {
|
||||
await page.route(`**/api/${path}**`, async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(retryAfter),
|
||||
},
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: true,
|
||||
message: 'Too Many Requests',
|
||||
retryAfter,
|
||||
}),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 拦截 WebSocket 连接
|
||||
*/
|
||||
async interceptWebSocket(page: Page): Promise<{
|
||||
messages: Array<{ direction: 'sent' | 'received'; data: unknown }>;
|
||||
isConnected: () => boolean;
|
||||
}> {
|
||||
const messages: Array<{ direction: 'sent' | 'received'; data: unknown }> = [];
|
||||
let connected = false;
|
||||
|
||||
// Playwright 的 WebSocket 拦截需要特殊处理
|
||||
page.on('websocket', (ws) => {
|
||||
connected = true;
|
||||
|
||||
ws.on('framereceived', (frame) => {
|
||||
try {
|
||||
const data = safeParseJSON(frame.payload as string);
|
||||
messages.push({ direction: 'received', data });
|
||||
} catch {
|
||||
messages.push({ direction: 'received', data: frame.payload });
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('framesent', (frame) => {
|
||||
try {
|
||||
const data = safeParseJSON(frame.payload as string);
|
||||
messages.push({ direction: 'sent', data });
|
||||
} catch {
|
||||
messages.push({ direction: 'sent', data: frame.payload });
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
connected = false;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
messages,
|
||||
isConnected: () => connected,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Mock 流式响应(用于聊天功能)
|
||||
*/
|
||||
async mockStreamResponse(
|
||||
page: Page,
|
||||
path: string,
|
||||
chunks: Array<{ delta?: string; phase?: string; content?: string }>,
|
||||
chunkDelay: number = 100
|
||||
): Promise<void> {
|
||||
await page.route(`**/api/${path}**`, async (route: Route) => {
|
||||
// 创建流式响应
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
const data = `data: ${JSON.stringify(chunk)}\n\n`;
|
||||
controller.enqueue(encoder.encode(data));
|
||||
await new Promise((r) => setTimeout(r, chunkDelay));
|
||||
}
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
body: stream as any,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 等待特定 API 请求
|
||||
*/
|
||||
async waitForAPIRequest(
|
||||
page: Page,
|
||||
pathPattern: string | RegExp,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Request> {
|
||||
return page.waitForRequest(
|
||||
(request) => {
|
||||
const url = request.url();
|
||||
if (typeof pathPattern === 'string') {
|
||||
return url.includes(pathPattern);
|
||||
}
|
||||
return pathPattern.test(url);
|
||||
},
|
||||
{ timeout: options?.timeout ?? 30000 }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 等待特定 API 响应
|
||||
*/
|
||||
async waitForAPIResponse(
|
||||
page: Page,
|
||||
pathPattern: string | RegExp,
|
||||
options?: { timeout?: number }
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
const response = await page.waitForResponse(
|
||||
(response) => {
|
||||
const url = response.url();
|
||||
if (typeof pathPattern === 'string') {
|
||||
return url.includes(pathPattern);
|
||||
}
|
||||
return pathPattern.test(url);
|
||||
},
|
||||
{ timeout: options?.timeout ?? 30000 }
|
||||
);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
body = await response.text();
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status(),
|
||||
body,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除所有路由拦截
|
||||
*/
|
||||
async clearRoutes(page: Page): Promise<void> {
|
||||
await page.unrouteAll();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 安全解析 JSON
|
||||
*/
|
||||
function safeParseJSON(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求匹配器 - 用于断言
|
||||
*/
|
||||
export const requestMatchers = {
|
||||
/**
|
||||
* 验证请求包含特定字段
|
||||
*/
|
||||
hasField(request: CapturedRequest, field: string, value?: unknown): boolean {
|
||||
if (!request.body || typeof request.body !== 'object') return false;
|
||||
const body = request.body as Record<string, unknown>;
|
||||
if (value === undefined) return field in body;
|
||||
return body[field] === value;
|
||||
},
|
||||
|
||||
/**
|
||||
* 验证请求方法
|
||||
*/
|
||||
isMethod(request: CapturedRequest, method: string): boolean {
|
||||
return request.method.toUpperCase() === method.toUpperCase();
|
||||
},
|
||||
|
||||
/**
|
||||
* 验证请求路径
|
||||
*/
|
||||
matchesPath(request: CapturedRequest, pattern: string | RegExp): boolean {
|
||||
if (typeof pattern === 'string') {
|
||||
return request.url.includes(pattern);
|
||||
}
|
||||
return pattern.test(request.url);
|
||||
},
|
||||
|
||||
/**
|
||||
* 查找匹配的请求
|
||||
*/
|
||||
findRequests(
|
||||
requests: CapturedRequest[],
|
||||
predicate: (req: CapturedRequest) => boolean
|
||||
): CapturedRequest[] {
|
||||
return requests.filter(predicate);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取特定路径的所有请求
|
||||
*/
|
||||
getRequestsForPath(requests: CapturedRequest[], path: string): CapturedRequest[] {
|
||||
return requests.filter((r) => r.url.includes(path));
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 POST 请求
|
||||
*/
|
||||
getPostRequests(requests: CapturedRequest[]): CapturedRequest[] {
|
||||
return requests.filter((r) => r.method === 'POST');
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 GET 请求
|
||||
*/
|
||||
getGetRequests(requests: CapturedRequest[]): CapturedRequest[] {
|
||||
return requests.filter((r) => r.method === 'GET');
|
||||
},
|
||||
};
|
||||
474
desktop/tests/e2e/utils/store-assertions.ts
Normal file
474
desktop/tests/e2e/utils/store-assertions.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
};
|
||||
778
desktop/tests/e2e/utils/user-actions.ts
Normal file
778
desktop/tests/e2e/utils/user-actions.ts
Normal file
@@ -0,0 +1,778 @@
|
||||
/**
|
||||
* 用户操作模拟工具
|
||||
* 封装完整的用户操作流程,确保深度验证
|
||||
*
|
||||
* 基于实际 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<void> {
|
||||
// 使用 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<void> {
|
||||
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<void> {
|
||||
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<string, { text: string; key: string }> = {
|
||||
分身: { 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 点击模型选择器 (在聊天区域底部)
|
||||
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<void> {
|
||||
// 侧边栏中的新对话按钮
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, unknown>
|
||||
): 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<void> {
|
||||
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<void> {
|
||||
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<string, unknown> }>;
|
||||
}
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 底部用户栏中的设置按钮
|
||||
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<void> {
|
||||
const saveBtn = page.getByRole('button', { name: /保存|save|apply/i });
|
||||
await saveBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
},
|
||||
|
||||
// ============================================
|
||||
// 通用操作
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 关闭对话框
|
||||
*/
|
||||
async closeModal(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
},
|
||||
|
||||
/**
|
||||
* 刷新页面并等待就绪
|
||||
*/
|
||||
async refreshAndWait(page: Page): Promise<void> {
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
},
|
||||
|
||||
/**
|
||||
* 等待元素可见
|
||||
*/
|
||||
async waitForVisible(page: Page, selector: string, timeout = 5000): Promise<void> {
|
||||
await page.waitForSelector(selector, { state: 'visible', timeout });
|
||||
},
|
||||
|
||||
/**
|
||||
* 截图
|
||||
*/
|
||||
async takeScreenshot(page: Page, name: string): Promise<void> {
|
||||
await page.screenshot({ path: `test-results/${name}.png` });
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user