Files
zclaw_openfang/desktop/tests/e2e/specs/user-scenarios-core.spec.ts
iven 6d2bedcfd7
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
test(desktop): Phase 4 E2E scenario tests — 47 tests for 10 user scenarios
4 new Playwright spec files covering all 10 planned E2E scenarios:

- user-scenarios-core.spec.ts (14 tests): Onboarding, multi-turn dialogue,
  model switching — covers scenarios 1-3
- user-scenarios-automation.spec.ts (16 tests): Hands CRUD/trigger/approval,
  Pipeline workflow, automation triggers — covers scenarios 4, 6, 9
- user-scenarios-saas-memory.spec.ts (16 tests): Memory system, settings
  config, SaaS integration, butler panel — covers scenarios 5, 7, 8, 10
- user-scenarios-live.spec.ts (1 test): 100+ round real LLM conversation
  with context recall verification — uses live backend
2026-04-07 17:44:31 +08:00

423 lines
15 KiB
TypeScript
Raw 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 User Scenario E2E Tests — Core Scenarios
*
* Scenario 1: 新用户首次体验 (First-time user experience)
* Scenario 2: 多轮对话 (Multi-turn dialogue with mock LLM)
* Scenario 3: 模型切换 (Model switching)
*
* Uses mock gateway for deterministic responses.
*/
import { test, expect } from '@playwright/test';
import {
setupMockGateway,
setupMockGatewayWithWebSocket,
} from '../fixtures/mock-gateway';
import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors';
import { skipOnboarding, waitForAppReady, navigateToTab } from '../utils/user-actions';
const BASE_URL = 'http://localhost:1420';
test.setTimeout(120000);
/** Helper: click send button */
async function clickSend(page: import('@playwright/test').Page) {
const sendButton = page.getByRole('button', { name: '发送消息' }).or(
page.locator('button.bg-orange-500').first()
);
await sendButton.first().click();
}
// ═══════════════════════════════════════════════════════════════════
// Scenario 1: 新用户首次体验
// ═══════════════════════════════════════════════════════════════════
test.describe('Scenario 1: 新用户首次体验', () => {
test.describe.configure({ mode: 'parallel' });
test('S1-01: 首次打开应显示引导或主界面', async ({ page }) => {
await setupMockGateway(page);
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
const body = page.locator('body');
await expect(body).not.toBeEmpty({ timeout: 10000 });
await page.screenshot({ path: 'test-results/screenshots/s1-01-first-load.png' });
});
test('S1-02: 跳过引导后应显示主界面', async ({ page }) => {
await setupMockGateway(page);
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
const sidebar = page.locator('aside');
await expect(sidebar).toBeVisible({ timeout: 10000 });
const chatInput = page.locator('textarea').first();
await expect(chatInput).toBeVisible({ timeout: 10000 });
await page.screenshot({ path: 'test-results/screenshots/s1-02-main-ui.png' });
});
test('S1-03: 发送第一条消息应收到回复', async ({ page }) => {
await setupMockGatewayWithWebSocket(page, {
wsConfig: { responseContent: '你好!我是 ZCLAW很高兴为您服务。', streaming: true },
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
const chatInput = page.locator('textarea').first();
await chatInput.fill('你好');
await clickSend(page);
await page.waitForTimeout(3000);
const userMsg = page.locator('text=你好').first();
await expect(userMsg).toBeVisible({ timeout: 10000 });
await page.screenshot({ path: 'test-results/screenshots/s1-03-first-message.png' });
});
test('S1-04: 消息持久化 — 切换 Tab 后消息仍在', async ({ page }) => {
await setupMockGatewayWithWebSocket(page, {
wsConfig: { responseContent: '消息持久化测试回复', streaming: true },
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
await storeInspectors.clearStore(page, STORE_NAMES.CHAT);
await page.waitForTimeout(500);
const chatInput = page.locator('textarea').first();
await chatInput.fill('这条消息应该被持久化');
await clickSend(page);
await page.waitForTimeout(3000);
const stateBefore = await storeInspectors.getChatState<{
messages: Array<{ content: string }>;
}>(page);
const countBefore = stateBefore?.messages?.length ?? 0;
if (countBefore === 0) {
test.skip();
return;
}
// 切换到其他 Tab 再切回
await navigateToTab(page, '自动化');
await page.waitForTimeout(1000);
await navigateToTab(page, '分身');
await page.waitForTimeout(1000);
const stateAfter = await storeInspectors.getChatState<{
messages: Array<{ content: string }>;
}>(page);
expect(stateAfter?.messages?.length).toBeGreaterThanOrEqual(countBefore);
});
test('S1-05: 侧边栏显示默认 Agent', async ({ page }) => {
await setupMockGateway(page);
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
const sidebar = page.locator('aside');
await expect(sidebar).toBeVisible();
const sidebarContent = await sidebar.textContent();
expect(sidebarContent).toBeTruthy();
expect(sidebarContent!.length).toBeGreaterThan(0);
});
});
// ═══════════════════════════════════════════════════════════════════
// Scenario 2: 多轮对话 (Mock LLM)
// ═══════════════════════════════════════════════════════════════════
test.describe('Scenario 2: 多轮对话', () => {
test.describe.configure({ mode: 'serial' });
test('S2-01: 连续多轮对话应保持上下文', async ({ page }) => {
await setupMockGatewayWithWebSocket(page, {
wsConfig: { responseContent: '这是模拟回复,我会记住您说的话。', streaming: true },
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
await storeInspectors.clearStore(page, STORE_NAMES.CHAT);
await page.waitForTimeout(500);
const userMessages = [
'你好,我是玩具厂老板',
'我们主要做塑料玩具ABS材质的',
'最近ABS原料价格波动很大你帮我分析一下趋势',
'那现在适合囤货吗?',
'澄海这边有什么好的供应商推荐吗?',
];
for (const msg of userMessages) {
const chatInput = page.locator('textarea').first();
await chatInput.fill(msg);
await clickSend(page);
await page.waitForTimeout(2000);
}
const state = await storeInspectors.getChatState<{
messages: Array<{ content: string; role: string }>;
}>(page);
const userMsgCount = state?.messages?.filter(m => m.role === 'user').length ?? 0;
expect(userMsgCount).toBeGreaterThanOrEqual(3);
});
test('S2-02: Token 统计应正常累加', async ({ page }) => {
await setupMockGatewayWithWebSocket(page, {
wsConfig: { responseContent: 'Token统计测试回复', streaming: true },
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
const initialState = await storeInspectors.getChatState<{
totalInputTokens?: number;
totalOutputTokens?: number;
}>(page);
const initialInput = initialState?.totalInputTokens ?? 0;
const chatInput = page.locator('textarea').first();
await chatInput.fill('Token test message');
await clickSend(page);
await page.waitForTimeout(3000);
const newState = await storeInspectors.getChatState<{
totalInputTokens?: number;
totalOutputTokens?: number;
}>(page);
const newInput = newState?.totalInputTokens ?? 0;
expect(newInput).toBeGreaterThanOrEqual(initialInput);
});
test('S2-03: 流式响应应稳定不中断', async ({ page }) => {
const longResponse = '这是一个较长的流式回复测试,用于验证流式传输的稳定性。'.repeat(15);
await setupMockGatewayWithWebSocket(page, {
wsConfig: { responseContent: longResponse, streaming: true, chunkDelay: 30 },
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
const chatInput = page.locator('textarea').first();
await chatInput.fill('请写一段详细的分析');
await clickSend(page);
// 等待流式完成
await page.waitForFunction(
() => {
const stored = localStorage.getItem('zclaw-chat-storage');
if (!stored) return true;
try {
const state = JSON.parse(stored).state;
return state.isStreaming === false;
} catch {
return true;
}
},
{ timeout: 30000 }
).catch(() => {});
await expect(page.locator('textarea').first()).toBeVisible({ timeout: 5000 });
});
test('S2-04: 发送空消息应被阻止', async ({ page }) => {
await setupMockGatewayWithWebSocket(page, {
wsConfig: { responseContent: '空消息测试', streaming: true },
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
const stateBefore = await storeInspectors.getChatState<{
messages: Array<{ content: string }>;
}>(page);
const countBefore = stateBefore?.messages?.length ?? 0;
const chatInput = page.locator('textarea').first();
await chatInput.fill('');
await clickSend(page).catch(() => {});
await page.waitForTimeout(1000);
const stateAfter = await storeInspectors.getChatState<{
messages: Array<{ content: string }>;
}>(page);
expect(stateAfter?.messages?.length).toBeLessThanOrEqual(countBefore);
});
test('S2-05: 取消流式响应应停止生成', async ({ page }) => {
await setupMockGatewayWithWebSocket(page, {
wsConfig: {
responseContent: '这条回复比较长,用于测试取消功能。'.repeat(10),
streaming: true,
chunkDelay: 200,
},
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
const chatInput = page.locator('textarea').first();
await chatInput.fill('写一篇长文');
await clickSend(page);
await page.waitForTimeout(500);
// 查找取消按钮
const cancelButton = page.getByRole('button', { name: /停止|取消|stop|cancel/i }).or(
page.locator('button').filter({ hasText: /停止|取消/ })
);
if (await cancelButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await cancelButton.click();
await page.waitForTimeout(1000);
}
await expect(page.locator('textarea').first()).toBeVisible();
});
});
// ═══════════════════════════════════════════════════════════════════
// Scenario 3: 模型切换
// ═══════════════════════════════════════════════════════════════════
test.describe('Scenario 3: 模型切换', () => {
test.describe.configure({ mode: 'serial' });
test('S3-01: 模型列表应可加载', async ({ page }) => {
await setupMockGateway(page);
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
const modelsResponse = await page.evaluate(async () => {
try {
const response = await fetch('/api/models');
return await response.json();
} catch {
return null;
}
});
expect(Array.isArray(modelsResponse)).toBe(true);
expect(modelsResponse.length).toBeGreaterThan(0);
});
test('S3-02: 切换模型后发送消息应正常', async ({ page }) => {
await setupMockGatewayWithWebSocket(page, {
wsConfig: { responseContent: '模型切换后的回复', streaming: true },
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
const chatInput = page.locator('textarea').first();
await chatInput.fill('模型A的消息');
await clickSend(page);
await page.waitForTimeout(3000);
const stateBefore = await storeInspectors.getChatState<{
messages: Array<{ content: string }>;
}>(page);
const countBefore = stateBefore?.messages?.length ?? 0;
// 通过 store 切换模型
await page.evaluate(() => {
const stores = (window as any).__ZCLAW_STORES__;
if (stores?.chat?.setState) {
stores.chat.setState({ currentModel: 'gpt-4o' });
}
});
await chatInput.fill('模型B的消息');
await clickSend(page);
await page.waitForTimeout(3000);
const stateAfter = await storeInspectors.getChatState<{
messages: Array<{ content: string }>;
}>(page);
expect(stateAfter?.messages?.length).toBeGreaterThan(countBefore);
});
test('S3-03: 切换回原模型上下文不丢', async ({ page }) => {
await setupMockGatewayWithWebSocket(page, {
wsConfig: { responseContent: '上下文保持测试回复', streaming: true },
});
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
await storeInspectors.clearStore(page, STORE_NAMES.CHAT);
const chatInput = page.locator('textarea').first();
await chatInput.fill('上下文测试消息');
await clickSend(page);
await page.waitForTimeout(3000);
const state = await storeInspectors.getChatState<{
messages: Array<{ content: string }>;
}>(page);
const originalCount = state?.messages?.length ?? 0;
if (originalCount === 0) {
test.skip();
return;
}
await page.evaluate(() => {
const stores = (window as any).__ZCLAW_STORES__;
if (stores?.chat?.setState) {
stores.chat.setState({ currentModel: 'gpt-4o' });
}
});
await page.evaluate(() => {
const stores = (window as any).__ZCLAW_STORES__;
if (stores?.chat?.setState) {
stores.chat.setState({ currentModel: 'claude-sonnet-4-20250514' });
}
});
const stateAfter = await storeInspectors.getChatState<{
messages: Array<{ content: string }>;
}>(page);
expect(stateAfter?.messages?.length).toBeGreaterThanOrEqual(originalCount);
});
test('S3-04: 不存在的模型应优雅处理', async ({ page }) => {
await setupMockGateway(page);
await skipOnboarding(page);
await page.goto(BASE_URL);
await waitForAppReady(page);
await page.evaluate(() => {
const stores = (window as any).__ZCLAW_STORES__;
if (stores?.chat?.setState) {
stores.chat.setState({ currentModel: 'nonexistent-model-xyz' });
}
});
await expect(page.locator('textarea').first()).toBeVisible({ timeout: 5000 });
});
});
// ═══════════════════════════════════════════════════════════════════
// Test Report
// ═══════════════════════════════════════════════════════════════════
test.afterAll(async ({}, testInfo) => {
console.log('\n========================================');
console.log('ZCLAW User Scenario Core Tests Complete');
console.log(`Test Time: ${new Date().toISOString()}`);
console.log('========================================\n');
});