test(desktop): Phase 4 E2E scenario tests — 47 tests for 10 user scenarios
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

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
This commit is contained in:
iven
2026-04-07 17:44:31 +08:00
parent d758a4477f
commit 6d2bedcfd7
4 changed files with 1624 additions and 0 deletions

View File

@@ -0,0 +1,422 @@
/**
* 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');
});