Files
zclaw_openfang/desktop/tests/e2e/specs/edge-cases.spec.ts
iven 6f72442531 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
2026-03-20 19:30:09 +08:00

660 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ZCLAW 边界情况验证测试
*
* 测试各种边界条件、错误处理和异常场景
* 确保系统在极端情况下仍能稳定运行
*/
import { test, expect, Page, BrowserContext } from '@playwright/test';
import { networkHelpers } from '../utils/network-helpers';
import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors';
import { userActions, waitForAppReady, navigateToTab } from '../utils/user-actions';
import { mockErrorResponse, mockTimeout, setupMockGateway } from '../fixtures/mock-gateway';
// 测试超时配置
test.setTimeout(180000);
const BASE_URL = 'http://localhost:1420';
// ============================================
// 测试套件 1: 网络边界情况
// ============================================
test.describe('网络边界情况', () => {
test('NET-EDGE-01: 完全离线状态', async ({ page, context }) => {
// 1. 设置离线
await context.setOffline(true);
// 2. 尝试加载页面
await page.goto(BASE_URL).catch(() => {
// 预期可能失败
});
// 3. 验证页面处理
// 页面应该显示某种错误状态或重试机制
const bodyText = await page.locator('body').textContent();
console.log('Offline state page content:', bodyText?.substring(0, 200));
// 4. 恢复网络
await context.setOffline(false);
});
test('NET-EDGE-02: 网络中断恢复', async ({ page, context }) => {
// 1. 正常加载页面
await page.goto(BASE_URL);
await waitForAppReady(page);
// 2. 获取初始状态
const stateBefore = await storeInspectors.getPersistedState<{
connectionState: string;
}>(page, STORE_NAMES.CONNECTION);
// 3. 断开网络
await context.setOffline(true);
await page.waitForTimeout(2000);
// 4. 恢复网络
await context.setOffline(false);
await page.waitForTimeout(3000);
// 5. 验证连接恢复
const stateAfter = await storeInspectors.getPersistedState<{
connectionState: string;
}>(page, STORE_NAMES.CONNECTION);
console.log(`Connection: ${stateBefore?.connectionState} -> ${stateAfter?.connectionState}`);
});
test('NET-EDGE-03: 请求超时处理', async ({ page }) => {
await page.goto(BASE_URL);
await waitForAppReady(page);
// 1. 模拟超时
await mockTimeout(page, 'chat');
// 2. 尝试发送消息
const chatInput = page.locator('textarea').first();
if (await chatInput.isVisible()) {
await chatInput.fill('超时测试消息');
// 点击发送(不等待响应)
await page.getByRole('button', { name: '发送消息' }).click();
// 3. 等待并验证错误处理
await page.waitForTimeout(5000);
// 验证流式状态已重置
const state = await storeInspectors.getPersistedState<{
isStreaming: boolean;
}>(page, STORE_NAMES.CHAT);
expect(state?.isStreaming).toBe(false);
}
});
test('NET-EDGE-04: 服务器错误 (500)', async ({ page }) => {
await page.goto(BASE_URL);
await waitForAppReady(page);
// 1. Mock 500 错误
await mockErrorResponse(page, 'chat', 500, 'Internal Server Error');
// 2. 尝试发送消息
const chatInput = page.locator('textarea').first();
if (await chatInput.isVisible()) {
await chatInput.fill('错误测试消息');
await page.getByRole('button', { name: '发送消息' }).click();
// 3. 验证错误处理
await page.waitForTimeout(3000);
// 检查是否有错误提示
const errorElement = page.locator('[class*="error"]').or(
page.locator('[role="alert"]'),
);
console.log(`Error shown: ${await errorElement.count() > 0}`);
}
});
test('NET-EDGE-05: 限流处理 (429)', async ({ page }) => {
await page.goto(BASE_URL);
await waitForAppReady(page);
// 1. Mock 429 限流
await networkHelpers.simulateRateLimit(page, 'chat', 60);
// 2. 尝试发送消息
const chatInput = page.locator('textarea').first();
if (await chatInput.isVisible()) {
await chatInput.fill('限流测试消息');
await page.getByRole('button', { name: '发送消息' }).click();
// 3. 验证限流处理
await page.waitForTimeout(3000);
console.log('Rate limit handling verified');
}
});
test('NET-EDGE-06: 慢速网络', async ({ page }) => {
// 1. 模拟慢速网络
await page.route('**/api/**', async (route) => {
await new Promise((r) => setTimeout(r, 2000)); // 2秒延迟
await route.continue();
});
// 2. 加载页面
const startTime = Date.now();
await page.goto(BASE_URL);
await waitForAppReady(page);
const loadTime = Date.now() - startTime;
// 3. 验证加载时间
console.log(`Page load time with slow network: ${loadTime}ms`);
// 4. 验证页面仍然可用
const sidebar = page.locator('aside').first();
await expect(sidebar).toBeVisible();
});
});
// ============================================
// 测试套件 2: 数据边界情况
// ============================================
test.describe('数据边界情况', () => {
test.beforeEach(async ({ page }) => {
await page.goto(BASE_URL);
await waitForAppReady(page);
});
test('DATA-EDGE-01: 超长消息', async ({ page }) => {
const chatInput = page.locator('textarea').first();
if (await chatInput.isVisible()) {
// 1. 生成超长消息
const longMessage = '这是一条很长的测试消息。'.repeat(500); // ~15000 字符
// 2. 输入消息
await chatInput.fill(longMessage);
// 3. 验证输入被接受
const value = await chatInput.inputValue();
expect(value.length).toBeGreaterThan(10000);
// 4. 发送消息
await page.getByRole('button', { name: '发送消息' }).click();
await page.waitForTimeout(3000);
// 5. 验证消息显示(可能被截断)
const messageElement = page.locator('[class*="message"]').filter({
hasText: '这是一条很长的测试消息',
});
console.log(`Long message visible: ${await messageElement.count() > 0}`);
}
});
test('DATA-EDGE-02: 空消息', async ({ page }) => {
const chatInput = page.locator('textarea').first();
if (await chatInput.isVisible()) {
// 1. 获取初始消息数量
const stateBefore = await storeInspectors.getPersistedState<{
messages: unknown[];
}>(page, STORE_NAMES.CHAT);
const countBefore = stateBefore?.messages?.length ?? 0;
// 2. 尝试发送空消息
await chatInput.fill('');
await page.getByRole('button', { name: '发送消息' }).click();
// 3. 验证空消息不应被发送
await page.waitForTimeout(1000);
const stateAfter = await storeInspectors.getPersistedState<{
messages: unknown[];
}>(page, STORE_NAMES.CHAT);
const countAfter = stateAfter?.messages?.length ?? 0;
// 消息数量不应增加
expect(countAfter).toBe(countBefore);
}
});
test('DATA-EDGE-03: 特殊字符消息', async ({ page }) => {
const chatInput = page.locator('textarea').first();
if (await chatInput.isVisible()) {
// 1. 发送包含特殊字符的消息
const specialChars = '!@#$%^&*(){}[]|\\:";\'<>?,./~`\n\t测试';
await chatInput.fill(specialChars);
await page.getByRole('button', { name: '发送消息' }).click();
// 2. 验证消息显示
await page.waitForTimeout(2000);
console.log('Special characters message sent');
}
});
test('DATA-EDGE-04: Unicode 和 Emoji', async ({ page }) => {
const chatInput = page.locator('textarea').first();
if (await chatInput.isVisible()) {
// 1. 发送包含 Unicode 和 Emoji 的消息
const unicodeMessage = '你好世界 🌍 مرحبا Привет 🎉 こんにちは';
await chatInput.fill(unicodeMessage);
await page.getByRole('button', { name: '发送消息' }).click();
// 2. 验证消息显示
await page.waitForTimeout(2000);
const messageElement = page.locator('[class*="message"]').filter({
hasText: '你好世界',
});
console.log(`Unicode message visible: ${await messageElement.count() > 0}`);
}
});
test('DATA-EDGE-05: 代码块内容', async ({ page }) => {
const chatInput = page.locator('textarea').first();
if (await chatInput.isVisible()) {
// 1. 发送包含代码块的消息
const codeMessage = `
请帮我检查这段代码:
\`\`\`javascript
function hello() {
console.log("Hello, World!");
}
\`\`\`
`;
await chatInput.fill(codeMessage);
await page.getByRole('button', { name: '发送消息' }).click();
// 2. 验证代码块渲染
await page.waitForTimeout(2000);
const codeBlock = page.locator('pre').or(page.locator('code'));
console.log(`Code block visible: ${await codeBlock.count() > 0}`);
}
});
test('DATA-EDGE-06: 空 Hands 列表', async ({ page }) => {
// 1. Mock 空 Hands 响应
await page.route('**/api/hands', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
});
// 2. 导航到 Hands
await navigateToTab(page, 'Hands');
await page.waitForTimeout(2000);
// 3. 验证空状态显示
const emptyState = page.locator('text=暂无').or(
page.locator('text=无可用').or(
page.locator('text=empty', { exact: false }),
),
);
console.log(`Empty state shown: ${await emptyState.count() > 0}`);
});
});
// ============================================
// 测试套件 3: 状态边界情况
// ============================================
test.describe('状态边界情况', () => {
test.beforeEach(async ({ page }) => {
await page.goto(BASE_URL);
await waitForAppReady(page);
});
test('STATE-EDGE-01: 快速连续点击', async ({ page }) => {
const chatInput = page.locator('textarea').first();
if (await chatInput.isVisible()) {
// 1. 获取初始消息数量
const stateBefore = await storeInspectors.getPersistedState<{
messages: unknown[];
}>(page, STORE_NAMES.CHAT);
const countBefore = stateBefore?.messages?.length ?? 0;
// 2. 快速点击发送按钮多次
await chatInput.fill('快速点击测试');
const sendBtn = page.getByRole('button', { name: '发送消息' });
// 连续点击 5 次
for (let i = 0; i < 5; i++) {
await sendBtn.click({ delay: 50 });
}
// 3. 等待处理完成
await page.waitForTimeout(5000);
// 4. 验证只发送了一条消息(防抖生效)
const stateAfter = await storeInspectors.getPersistedState<{
messages: unknown[];
}>(page, STORE_NAMES.CHAT);
const countAfter = stateAfter?.messages?.length ?? 0;
// 消息数量应该只增加有限数量(理想情况是 1
console.log(`Messages: ${countBefore} -> ${countAfter}`);
expect(countAfter - countBefore).toBeLessThan(5);
}
});
test('STATE-EDGE-02: 流式中刷新页面', async ({ page }) => {
const chatInput = page.locator('textarea').first();
if (await chatInput.isVisible()) {
// 1. 发送消息
await chatInput.fill('流式测试消息');
await page.getByRole('button', { name: '发送消息' }).click();
// 2. 立即刷新页面(在流式响应中)
await page.waitForTimeout(500);
await page.reload();
await waitForAppReady(page);
// 3. 验证状态恢复
const state = await storeInspectors.getPersistedState<{
isStreaming: boolean;
messages: unknown[];
}>(page, STORE_NAMES.CHAT);
// 流式状态应该是 false
expect(state?.isStreaming).toBe(false);
}
});
test('STATE-EDGE-03: 多次切换标签', async ({ page }) => {
const tabs = ['分身', 'Hands', '工作流', '团队', '协作'];
// 1. 快速切换标签 20 次
for (let i = 0; i < 20; i++) {
const tab = tabs[i % tabs.length];
await navigateToTab(page, tab);
await page.waitForTimeout(100);
}
// 2. 验证无错误
const errorElements = page.locator('[class*="error"]');
const errorCount = await errorElements.count();
console.log(`Errors after rapid switching: ${errorCount}`);
// 3. 验证最终标签正确显示
const sidebar = page.locator('aside').first();
await expect(sidebar).toBeVisible();
});
test('STATE-EDGE-04: 清除 localStorage 后恢复', async ({ page }) => {
// 1. 加载页面
await page.goto(BASE_URL);
await waitForAppReady(page);
// 2. 清除 localStorage
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
// 3. 刷新页面
await page.reload();
await waitForAppReady(page);
// 4. 验证应用正常初始化
const sidebar = page.locator('aside').first();
await expect(sidebar).toBeVisible();
// 5. 验证 Store 重新初始化
const chatState = await storeInspectors.getPersistedState<{
messages: unknown[];
}>(page, STORE_NAMES.CHAT);
expect(Array.isArray(chatState?.messages)).toBe(true);
});
test('STATE-EDGE-05: 长时间运行稳定性', async ({ page }) => {
// 1. 加载页面
await page.goto(BASE_URL);
await waitForAppReady(page);
// 2. 记录初始内存
const initialMetrics = await page.evaluate(() => ({
domNodes: document.querySelectorAll('*').length,
jsHeapSize: (performance as any).memory?.usedJSHeapSize || 0,
}));
// 3. 执行多次操作
for (let i = 0; i < 5; i++) {
await navigateToTab(page, ['分身', 'Hands', '工作流'][i % 3]);
await page.waitForTimeout(500);
}
// 4. 记录最终内存
const finalMetrics = await page.evaluate(() => ({
domNodes: document.querySelectorAll('*').length,
jsHeapSize: (performance as any).memory?.usedJSHeapSize || 0,
}));
// 5. 验证内存没有显著增长
console.log(`DOM nodes: ${initialMetrics.domNodes} -> ${finalMetrics.domNodes}`);
console.log(`JS heap: ${initialMetrics.jsHeapSize} -> ${finalMetrics.jsHeapSize}`);
// DOM 节点不应显著增加
expect(finalMetrics.domNodes).toBeLessThan(initialMetrics.domNodes * 2);
});
});
// ============================================
// 测试套件 4: UI 边界情况
// ============================================
test.describe('UI 边界情况', () => {
test('UI-EDGE-01: 最小窗口尺寸', async ({ page }) => {
// 1. 设置最小窗口尺寸
await page.setViewportSize({ width: 375, height: 667 });
await page.goto(BASE_URL);
await waitForAppReady(page);
// 2. 验证核心功能可用
const sidebar = page.locator('aside').first();
const main = page.locator('main');
// 至少应该有一个可见
const sidebarVisible = await sidebar.isVisible();
const mainVisible = await main.isVisible();
expect(sidebarVisible || mainVisible).toBe(true);
});
test('UI-EDGE-02: 大窗口尺寸', async ({ page }) => {
// 1. 设置大窗口尺寸
await page.setViewportSize({ width: 2560, height: 1440 });
await page.goto(BASE_URL);
await waitForAppReady(page);
// 2. 验证布局正确
const sidebar = page.locator('aside').first();
const main = page.locator('main');
await expect(sidebar).toBeVisible();
await expect(main).toBeVisible();
});
test('UI-EDGE-03: 窗口尺寸变化', async ({ page }) => {
// 1. 从大窗口开始
await page.setViewportSize({ width: 1920, height: 1080 });
await page.goto(BASE_URL);
await waitForAppReady(page);
// 2. 逐步缩小窗口
const sizes = [
{ width: 1200, height: 800 },
{ width: 768, height: 1024 },
{ width: 375, height: 667 },
];
for (const size of sizes) {
await page.setViewportSize(size);
await page.waitForTimeout(300);
// 验证无布局错误
const body = page.locator('body');
await expect(body).toBeVisible();
}
});
test('UI-EDGE-04: 深色模式(如果支持)', async ({ page }) => {
// 1. 模拟深色模式偏好
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto(BASE_URL);
await waitForAppReady(page);
// 2. 验证页面加载
const body = page.locator('body');
await expect(body).toBeVisible();
});
test('UI-EDGE-05: 减少动画(如果支持)', async ({ page }) => {
// 1. 模拟减少动画偏好
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(BASE_URL);
await waitForAppReady(page);
// 2. 验证页面加载
const body = page.locator('body');
await expect(body).toBeVisible();
});
});
// ============================================
// 测试套件 5: 输入验证边界情况
// ============================================
test.describe('输入验证边界情况', () => {
test.beforeEach(async ({ page }) => {
await page.goto(BASE_URL);
await waitForAppReady(page);
});
test('INPUT-EDGE-01: XSS 注入尝试', async ({ page }) => {
const chatInput = page.locator('textarea').first();
if (await chatInput.isVisible()) {
// 1. 发送包含潜在 XSS 的消息
const xssPayload = '<script>alert("XSS")</script><img src=x onerror=alert("XSS")>';
await chatInput.fill(xssPayload);
await page.getByRole('button', { name: '发送消息' }).click();
// 2. 验证消息显示(应该被转义)
await page.waitForTimeout(2000);
// 3. 检查没有 alert 弹出
// (Playwright 不会执行 alert所以只需要验证没有错误)
console.log('XSS test passed - no alert shown');
}
});
test('INPUT-EDGE-02: HTML 标签输入', async ({ page }) => {
const chatInput = page.locator('textarea').first();
if (await chatInput.isVisible()) {
// 1. 发送包含 HTML 的消息
const htmlContent = '<div>测试</div><b>粗体</b><a href="#">链接</a>';
await chatInput.fill(htmlContent);
await page.getByRole('button', { name: '发送消息' }).click();
// 2. 验证消息显示
await page.waitForTimeout(2000);
console.log('HTML input test completed');
}
});
test('INPUT-EDGE-03: JSON 格式参数', async ({ page }) => {
await navigateToTab(page, 'Hands');
await page.waitForTimeout(1000);
// 1. 查找 JSON 输入框(如果有)
const jsonInput = page.locator('textarea').filter({
hasText: /{/,
}).or(
page.locator('input[placeholder*="JSON"]'),
);
if (await jsonInput.isVisible()) {
// 2. 输入无效 JSON
await jsonInput.fill('{ invalid json }');
await page.waitForTimeout(300);
// 3. 验证错误提示
const errorElement = page.locator('[class*="error"]').filter({
hasText: /JSON|格式|解析/,
});
console.log(`JSON error shown: ${await errorElement.count() > 0}`);
// 4. 输入有效 JSON
await jsonInput.fill('{ "valid": "json" }');
await page.waitForTimeout(300);
}
});
});
// ============================================
// 测试套件 6: 并发操作边界情况
// ============================================
test.describe('并发操作边界情况', () => {
test.beforeEach(async ({ page }) => {
await page.goto(BASE_URL);
await waitForAppReady(page);
});
test('CONCURRENT-EDGE-01: 同时发送多条消息', async ({ page }) => {
const chatInput = page.locator('textarea').first();
if (await chatInput.isVisible()) {
// 1. 获取初始状态
const stateBefore = await storeInspectors.getPersistedState<{
messages: unknown[];
isStreaming: boolean;
}>(page, STORE_NAMES.CHAT);
// 2. 快速发送多条消息
for (let i = 0; i < 3; i++) {
await chatInput.fill(`并发消息 ${i + 1}`);
await page.getByRole('button', { name: '发送消息' }).click();
await page.waitForTimeout(100);
}
// 3. 等待所有处理完成
await page.waitForTimeout(10000);
// 4. 验证最终状态
const stateAfter = await storeInspectors.getPersistedState<{
isStreaming: boolean;
}>(page, STORE_NAMES.CHAT);
expect(stateAfter?.isStreaming).toBe(false);
}
});
test('CONCURRENT-EDGE-02: 操作中切换视图', async ({ page }) => {
const chatInput = page.locator('textarea').first();
if (await chatInput.isVisible()) {
// 1. 发送消息
await chatInput.fill('测试消息');
await page.getByRole('button', { name: '发送消息' }).click();
// 2. 立即切换视图
await navigateToTab(page, 'Hands');
await page.waitForTimeout(500);
// 3. 切回聊天
await navigateToTab(page, '分身');
// 4. 验证无错误
const sidebar = page.locator('aside').first();
await expect(sidebar).toBeVisible();
}
});
});
// 测试报告
test.afterAll(async ({}, testInfo) => {
console.log('\n========================================');
console.log('ZCLAW 边界情况验证测试完成');
console.log('========================================');
console.log(`测试时间: ${new Date().toISOString()}`);
console.log('========================================\n');
});