test: add more user journey (#11072)

*  test(e2e): add Agent conversation E2E test with LLM mock

- Add LLM mock framework to intercept /webapi/chat/openai requests
- Create Agent conversation journey test (AGENT-CHAT-001)
- Add data-testid="chat-input" to Desktop ChatInput for E2E testing
- Mock returns SSE streaming responses matching LobeChat's actual format

Test scenario: Enter Lobe AI → Send "hello" → Verify AI response

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* 📝 docs(e2e): add experience-driven E2E testing strategy

Add comprehensive testing strategy from LOBE-2417:
- Core philosophy: user experience baseline for refactoring safety
- Product architecture coverage with priority levels
- Tag system (@journey, @P0/@P1/@P2, module tags)
- Execution strategies for CI, Nightly, and Release
- Updated directory structure with full journey coverage plan

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

📝 docs(e2e): add E2E testing guide for Claude

Document key learnings from implementing Agent conversation test:
- LLM Mock SSE format and usage
- Desktop/Mobile dual component handling with boundingBox
- contenteditable input handling
- Debugging tips and common issues

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* 📝 docs(e2e): add experience-driven E2E testing strategy

Add comprehensive testing strategy from LOBE-2417:
- Core philosophy: user experience baseline for refactoring safety
- Product architecture coverage with priority levels
- Tag system (@journey, @P0/@P1/@P2, module tags)
- Execution strategies for CI, Nightly, and Release
- Updated directory structure with full journey coverage plan

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

📝 docs(e2e): add E2E testing guide for Claude

Document key learnings from implementing Agent conversation test:
- LLM Mock SSE format and usage
- Desktop/Mobile dual component handling with boundingBox
- contenteditable input handling
- Debugging tips and common issues

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* update sop

* update sop

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-01-01 23:53:25 +08:00
committed by GitHub
parent 2bc3b16671
commit e3f0f46436
7 changed files with 634 additions and 511 deletions

View File

@@ -104,6 +104,62 @@ e2e/
└── CLAUDE.md # 本文档
```
## 本地环境启动
> 详细流程参考 [e2e/docs/local-setup.md](./docs/local-setup.md)
### 快速启动流程
```bash
# Step 1: 清理环境
docker stop postgres-e2e 2> /dev/null; docker rm postgres-e2e 2> /dev/null
lsof -ti:3006 | xargs kill -9 2> /dev/null
lsof -ti:5433 | xargs kill -9 2> /dev/null
# Step 2: 启动数据库(使用 paradedb 镜像,支持 pgvector
docker run -d --name postgres-e2e \
-e POSTGRES_PASSWORD=postgres \
-p 5433:5432 \
paradedb/paradedb:latest
# 等待数据库就绪
until docker exec postgres-e2e pg_isready; do sleep 2; done
# Step 3: 运行数据库迁移(项目根目录)
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
DATABASE_DRIVER=node \
KEY_VAULTS_SECRET=LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s= \
bun run db:migrate
# Step 4: 构建应用(首次或代码变更后)
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
DATABASE_DRIVER=node \
KEY_VAULTS_SECRET=LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s= \
BETTER_AUTH_SECRET=e2e-test-secret-key-for-better-auth-32chars! \
NEXT_PUBLIC_ENABLE_BETTER_AUTH=1 \
SKIP_LINT=1 \
bun run build
# Step 5: 启动服务器(必须在项目根目录运行!)
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
DATABASE_DRIVER=node \
KEY_VAULTS_SECRET=LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s= \
BETTER_AUTH_SECRET=e2e-test-secret-key-for-better-auth-32chars! \
NEXT_PUBLIC_ENABLE_BETTER_AUTH=1 \
NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION=0 \
S3_ACCESS_KEY_ID=e2e-mock-access-key \
S3_SECRET_ACCESS_KEY=e2e-mock-secret-key \
S3_BUCKET=e2e-mock-bucket \
S3_ENDPOINT=https://e2e-mock-s3.localhost \
bunx next start -p 3006
```
**重要提示**:
- 必须使用 `paradedb/paradedb:latest` 镜像(支持 pgvector 扩展)
- 服务器必须在**项目根目录**启动,不能在 e2e 目录
- S3 环境变量是**必需**的,即使不测试文件上传
## 运行测试
```bash
@@ -111,12 +167,19 @@ e2e/
cd e2e
# 运行特定标签的测试
HEADLESS=false BASE_URL=http://localhost:3010 \
BASE_URL=http://localhost:3006 \
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
pnpm exec cucumber-js --config cucumber.config.js --tags "@AGENT-CHAT-001"
# 调试模式(显示浏览器)
HEADLESS=false BASE_URL=http://localhost:3006 \
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
pnpm exec cucumber-js --config cucumber.config.js --tags "@conversation"
# 运行所有测试
pnpm exec cucumber-js --config cucumber.config.js
BASE_URL=http://localhost:3006 \
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
pnpm exec cucumber-js --config cucumber.config.js
```
**重要**: 必须显式指定 `--config cucumber.config.js`,否则配置不会被正确加载。
@@ -263,6 +326,50 @@ S3_BUCKET=e2e-mock-bucket
S3_ENDPOINT=https://e2e-mock-s3.localhost
```
## 清理环境
测试完成后或需要重置环境时,执行以下清理操作:
### 停止服务器
```bash
# 查找并停止占用端口的进程
lsof -ti:3006 | xargs kill -9 2> /dev/null
lsof -ti:3010 | xargs kill -9 2> /dev/null
```
### 停止 Docker 容器
```bash
# 停止并删除 PostgreSQL 容器
docker stop postgres-e2e 2> /dev/null
docker rm postgres-e2e 2> /dev/null
```
### 一键清理(推荐)
```bash
# 清理所有 E2E 相关进程和容器
docker stop postgres-e2e 2> /dev/null
docker rm postgres-e2e 2> /dev/null
lsof -ti:3006 | xargs kill -9 2> /dev/null
lsof -ti:3010 | xargs kill -9 2> /dev/null
lsof -ti:5433 | xargs kill -9 2> /dev/null
echo "Cleanup done"
```
### 清理端口占用
如果遇到端口被占用的错误,可以清理特定端口:
```bash
# 清理 Next.js 服务器端口
lsof -ti:3006 | xargs kill -9
# 清理 PostgreSQL 端口
lsof -ti:5433 | xargs kill -9
```
## 常见问题
### 1. 测试超时 (function timed out)

68
e2e/docs/llm-mock.md Normal file
View File

@@ -0,0 +1,68 @@
# LLM Mock 实现
## 核心原理
LLM Mock 通过 Playwright 的 `page.route()` 拦截对 `/webapi/chat/openai` 的请求,返回预设的 SSE 流式响应。
## SSE 响应格式
LobeHub 使用特定的 SSE 格式,必须严格匹配:
```
// 1. 初始 data 事件
id: msg_xxx
event: data
data: {"id":"msg_xxx","model":"gpt-4o-mini","role":"assistant","type":"message",...}
// 2. 文本内容分块text 事件)
id: msg_xxx
event: text
data: "Hello"
id: msg_xxx
event: text
data: "! I am"
// 3. 停止事件
id: msg_xxx
event: stop
data: "end_turn"
// 4. 使用量统计
id: msg_xxx
event: usage
data: {"totalTokens":100,...}
// 5. 最终停止
id: msg_xxx
event: stop
data: "message_stop"
```
## 使用示例
```typescript
import { llmMockManager, presetResponses } from '../../mocks/llm';
// 在测试步骤中设置 mock
llmMockManager.setResponse('hello', presetResponses.greeting);
await llmMockManager.setup(this.page);
```
## 添加自定义响应
```typescript
// 为特定用户消息设置响应
llmMockManager.setResponse('你好', '你好!我是 Lobe AI有什么可以帮助你的');
// 清除所有自定义响应
llmMockManager.clearResponses();
```
## 常见问题
### LLM Mock 未生效
**原因**: 路由拦截设置在页面导航之后
**解决**: 确保在 `page.goto()` 之前调用 `llmMockManager.setup(page)`

354
e2e/docs/local-setup.md Normal file
View File

@@ -0,0 +1,354 @@
# 本地运行 E2E 测试
## 前置要求
- Docker Desktop 已安装并**正在运行**
- Node.js 18+
- pnpm 已安装
- 项目已 `pnpm install`
## 完整启动流程
### Step 0: 环境清理(重要!)
每次运行测试前,建议先清理环境,避免残留状态导致问题。
```bash
# 0.1 确保 Docker Desktop 正在运行
# 如果未运行,请先启动 Docker Desktop
# 0.2 清理旧的 PostgreSQL 容器
docker stop postgres-e2e 2> /dev/null
docker rm postgres-e2e 2> /dev/null
# 0.3 清理占用的端口
lsof -ti:3006 | xargs kill -9 2> /dev/null # Next.js 服务器端口
lsof -ti:5433 | xargs kill -9 2> /dev/null # PostgreSQL 端口
```
### Step 1: 启动数据库
```bash
# 启动 PostgreSQL (端口 5433)
docker run -d --name postgres-e2e \
-e POSTGRES_PASSWORD=postgres \
-p 5433:5432 \
paradedb/paradedb:latest
# 等待数据库就绪
until docker exec postgres-e2e pg_isready; do sleep 2; done
echo "PostgreSQL is ready!"
```
### Step 2: 运行数据库迁移
```bash
# 在项目根目录运行
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
DATABASE_DRIVER=node \
bun run db:migrate
```
### Step 3: 构建应用(首次或代码变更后)
```bash
# 在项目根目录运行
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
DATABASE_DRIVER=node \
KEY_VAULTS_SECRET=LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s= \
BETTER_AUTH_SECRET=e2e-test-secret-key-for-better-auth-32chars! \
NEXT_PUBLIC_ENABLE_BETTER_AUTH=1 \
SKIP_LINT=1 \
bun run build
```
### Step 4: 启动应用服务器
**重要**: 必须在**项目根目录**运行,不能在 e2e 目录运行!
```bash
# 在项目根目录运行(注意:不是 e2e 目录)
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
DATABASE_DRIVER=node \
KEY_VAULTS_SECRET=LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s= \
BETTER_AUTH_SECRET=e2e-test-secret-key-for-better-auth-32chars! \
NEXT_PUBLIC_ENABLE_BETTER_AUTH=1 \
NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION=0 \
S3_ACCESS_KEY_ID=e2e-mock-access-key \
S3_SECRET_ACCESS_KEY=e2e-mock-secret-key \
S3_BUCKET=e2e-mock-bucket \
S3_ENDPOINT=https://e2e-mock-s3.localhost \
bunx next start -p 3006
```
### Step 5: 等待服务器就绪
```bash
# 在另一个终端运行,确认服务器已启动
until curl -s http://localhost:3006 > /dev/null; do
sleep 2
echo "Waiting..."
done
echo "Server is ready!"
```
### Step 6: 运行测试
```bash
# 在 e2e 目录运行测试
cd e2e
# 运行特定标签(默认无头模式)
BASE_URL=http://localhost:3006 \
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
pnpm exec cucumber-js --config cucumber.config.js --tags "@conversation"
# 运行所有测试
BASE_URL=http://localhost:3006 \
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
pnpm exec cucumber-js --config cucumber.config.js
# 调试模式(显示浏览器,观察执行过程)
HEADLESS=false \
BASE_URL=http://localhost:3006 \
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
pnpm exec cucumber-js --config cucumber.config.js --tags "@conversation"
```
## 一键启动脚本
### 完整初始化(首次运行或需要重建)
在项目根目录创建 `e2e-init.sh`
```bash
#!/bin/bash
set -e
echo "🧹 Step 0: Cleaning up..."
docker stop postgres-e2e 2> /dev/null || true
docker rm postgres-e2e 2> /dev/null || true
lsof -ti:3006 | xargs kill -9 2> /dev/null || true
lsof -ti:5433 | xargs kill -9 2> /dev/null || true
echo "🐘 Step 1: Starting PostgreSQL..."
docker run -d --name postgres-e2e \
-e POSTGRES_PASSWORD=postgres \
-p 5433:5432 \
paradedb/paradedb:latest
echo "⏳ Waiting for PostgreSQL..."
until docker exec postgres-e2e pg_isready 2> /dev/null; do sleep 2; done
echo "✅ PostgreSQL is ready!"
echo "🔄 Step 2: Running migrations..."
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
DATABASE_DRIVER=node \
bun run db:migrate
echo "🔨 Step 3: Building application..."
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
DATABASE_DRIVER=node \
KEY_VAULTS_SECRET=LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s= \
BETTER_AUTH_SECRET=e2e-test-secret-key-for-better-auth-32chars! \
NEXT_PUBLIC_ENABLE_BETTER_AUTH=1 \
SKIP_LINT=1 \
bun run build
echo "✅ Initialization complete! Now run e2e-start.sh to start the server."
```
### 快速启动服务器
在项目根目录创建 `e2e-start.sh`
```bash
#!/bin/bash
set -e
echo "🧹 Cleaning up ports..."
lsof -ti:3006 | xargs kill -9 2> /dev/null || true
echo "🚀 Starting Next.js server on port 3006..."
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
DATABASE_DRIVER=node \
KEY_VAULTS_SECRET=LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s= \
BETTER_AUTH_SECRET=e2e-test-secret-key-for-better-auth-32chars! \
NEXT_PUBLIC_ENABLE_BETTER_AUTH=1 \
NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION=0 \
S3_ACCESS_KEY_ID=e2e-mock-access-key \
S3_SECRET_ACCESS_KEY=e2e-mock-secret-key \
S3_BUCKET=e2e-mock-bucket \
S3_ENDPOINT=https://e2e-mock-s3.localhost \
bunx next start -p 3006
```
### 运行测试
在 e2e 目录创建 `run-test.sh`
```bash
#!/bin/bash
# 默认参数
TAGS="${1:-@journey}"
HEADLESS="${HEADLESS:-true}" # 默认无头模式
echo "🧪 Running E2E tests with tags: $TAGS"
echo " Headless: $HEADLESS"
HEADLESS=$HEADLESS \
BASE_URL=http://localhost:3006 \
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
pnpm exec cucumber-js --config cucumber.config.js --tags "$TAGS"
```
使用方式:
```bash
# 运行特定标签(默认无头模式)
./run-test.sh "@conversation"
# 调试模式(显示浏览器)
HEADLESS=false ./run-test.sh "@conversation"
```
## 快速启动(假设数据库和构建已完成)
```bash
# Terminal 1: 启动服务器(项目根目录)
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
DATABASE_DRIVER=node \
KEY_VAULTS_SECRET=LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s= \
BETTER_AUTH_SECRET=e2e-test-secret-key-for-better-auth-32chars! \
NEXT_PUBLIC_ENABLE_BETTER_AUTH=1 \
NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION=0 \
S3_ACCESS_KEY_ID=e2e-mock-access-key \
S3_SECRET_ACCESS_KEY=e2e-mock-secret-key \
S3_BUCKET=e2e-mock-bucket \
S3_ENDPOINT=https://e2e-mock-s3.localhost \
bunx next start -p 3006
# Terminal 2: 运行测试e2e 目录,默认无头模式)
cd e2e
BASE_URL=http://localhost:3006 \
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
pnpm exec cucumber-js --config cucumber.config.js --tags "@conversation"
# 调试模式(显示浏览器)
HEADLESS=false BASE_URL=http://localhost:3006 \
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
pnpm exec cucumber-js --config cucumber.config.js --tags "@conversation"
```
## 环境变量参考
### 测试运行时环境变量
| 变量 | 值 | 说明 |
| -------------- | -------------------------------------------------------- | --------------------------------------------------- |
| `BASE_URL` | `http://localhost:3006` | 测试服务器地址 |
| `DATABASE_URL` | `postgresql://postgres:postgres@localhost:5433/postgres` | 数据库连接 |
| `HEADLESS` | `true`(默认)/`false` | 是否无头模式运行浏览器,设为 `false` 可观察执行过程 |
### 服务器启动环境变量(全部必需)
| 变量 | 值 | 说明 |
| ------------------------------------- | -------------------------------------------------------- | ---------------- |
| `DATABASE_URL` | `postgresql://postgres:postgres@localhost:5433/postgres` | 数据库连接 |
| `DATABASE_DRIVER` | `node` | 数据库驱动 |
| `KEY_VAULTS_SECRET` | `LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=` | 密钥保险库密钥 |
| `BETTER_AUTH_SECRET` | `e2e-test-secret-key-for-better-auth-32chars!` | 认证密钥 |
| `NEXT_PUBLIC_ENABLE_BETTER_AUTH` | `1` | 启用 Better Auth |
| `NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION` | `0` | 禁用邮箱验证 |
### S3 Mock 变量(必需!)
| 变量 | 值 |
| ---------------------- | ------------------------------- |
| `S3_ACCESS_KEY_ID` | `e2e-mock-access-key` |
| `S3_SECRET_ACCESS_KEY` | `e2e-mock-secret-key` |
| `S3_BUCKET` | `e2e-mock-bucket` |
| `S3_ENDPOINT` | `https://e2e-mock-s3.localhost` |
**注意**: S3 环境变量是**必需**的,即使不测试文件上传功能。缺少这些变量会导致发送消息时报错 "S3 environment variables are not set completely"。
## 常见问题排查
### Docker daemon is not running
**症状**: `Cannot connect to the Docker daemon`
**解决**: 启动 Docker Desktop 应用
### PostgreSQL 容器已存在
**症状**: `docker: Error response from daemon: Conflict. The container name "/postgres-e2e" is already in use`
**解决**:
```bash
docker stop postgres-e2e
docker rm postgres-e2e
```
### S3 environment variables are not set completely
**原因**: 服务器启动时缺少 S3 环境变量
**解决**: 启动服务器时必须设置所有 S3 mock 变量
### Cannot find module './src/libs/next/config/define-config'
**原因**: 在 e2e 目录下运行 `next start`
**解决**: 必须在**项目根目录**运行 `bunx next start`,不能在 e2e 目录运行
### EADDRINUSE: address already in use
**原因**: 端口被占用
**解决**:
```bash
# 查找并杀掉占用端口的进程
lsof -ti:3006 | xargs kill -9
lsof -ti:5433 | xargs kill -9
```
### BeforeAll hook errored: net::ERR_CONNECTION_REFUSED
**原因**: 服务器未启动或未就绪
**解决**:
1. 确认服务器已启动:`curl http://localhost:3006`
2. 确认 `BASE_URL` 环境变量设置正确
3. 等待服务器完全就绪后再运行测试
### 测试超时或不稳定
**可能原因**:
1. 网络延迟
2. 服务器响应慢
3. 元素定位问题
**解决**:
1. 使用 `HEADLESS=false` 观察测试执行过程
2. 检查 `screenshots/` 目录中的失败截图
3. 增加等待时间或使用更稳定的定位器
## 清理环境
测试完成后,清理环境:
```bash
# 停止服务器
lsof -ti:3006 | xargs kill -9
# 停止并删除 PostgreSQL 容器
docker stop postgres-e2e
docker rm postgres-e2e
```

94
e2e/docs/testing-tips.md Normal file
View File

@@ -0,0 +1,94 @@
# 测试技巧
## 页面元素定位
### 富文本编辑器 (contenteditable) 输入
LobeHub 使用 `@lobehub/editor` 作为聊天输入框,是一个 contenteditable 的富文本编辑器。
**关键点**:
1. 不能直接用 `locator.fill()` - 对 contenteditable 不生效
2. 需要先 click 容器让编辑器获得焦点
3. 使用 `keyboard.type()` 输入文本
```typescript
// 正确的输入方式
await chatInputContainer.click();
await this.page.waitForTimeout(500); // 等待焦点
await this.page.keyboard.type(message, { delay: 30 });
await this.page.keyboard.press('Enter'); // 发送
```
### 添加 data-testid
为了更可靠的元素定位,可以在组件上添加 `data-testid`
```tsx
// src/features/ChatInput/Desktop/index.tsx
<ChatInput
data-testid="chat-input"
...
/>
```
## 调试技巧
### 添加步骤日志
在每个关键步骤添加 console.log帮助定位问题
```typescript
Given('用户进入页面', async function (this: CustomWorld) {
console.log(' 📍 Step: 导航到首页...');
await this.page.goto('/');
console.log(' 📍 Step: 查找元素...');
const element = this.page.locator('...');
console.log(' ✅ 步骤完成');
});
```
### 查看失败截图
测试失败时会自动保存截图到 `e2e/screenshots/` 目录。
### 非 headless 模式
设置 `HEADLESS=false` 可以看到浏览器操作:
```bash
HEADLESS=false pnpm exec cucumber-js --config cucumber.config.js --tags "@smoke"
```
## 常见问题
### 测试超时 (function timed out)
**原因**: 元素定位失败或等待时间不足
**解决**:
- 检查选择器是否正确
- 增加 timeout 参数
- 添加显式等待 `waitForTimeout()`
### strict mode violation (多个元素匹配)
**原因**: 选择器匹配到多个元素(如 desktop/mobile 双组件)
**解决**:
- 使用 `.first()``.nth(n)`
- 使用 `boundingBox()` 过滤可见元素
### 输入框内容为空
**原因**: contenteditable 编辑器的特殊性
**解决**:
- 先 click 容器确保焦点
- 使用 `keyboard.type()` 而非 `fill()`
- 添加适当的等待时间

View File

@@ -11,35 +11,3 @@ Feature: Agent 对话用户体验链路
When "hello"
Then
And
@AGENT-CHAT-002 @P0
Scenario: 多轮对话保持上下文
Given Lobe AI
When ""
Then
When ""
Then
And ""
@AGENT-CHAT-003 @P0
Scenario: 清空对话历史
Given Lobe AI
And "hello"
When
Then
And
@AGENT-CHAT-004 @P0
Scenario: 重新生成回复
Given Lobe AI
And "hello"
When
Then
@AGENT-CHAT-005 @P0
Scenario: 停止生成回复
Given Lobe AI
When ""
And
Then
And

View File

@@ -231,14 +231,14 @@ export const presetResponses = {
codeHelp: 'I can help you with coding! Please share the code you would like me to review.',
error: 'I apologize, but I encountered an error processing your request.',
greeting: 'Hello! I am Lobe AI, your AI assistant. How can I help you today?',
// Long response for stop generation test
longArticle:
longArticle:
'这是一篇很长的文章。第一段:人工智能是计算机科学的一个分支,它企图了解智能的实质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器。第二段:人工智能研究的主要目标包括推理、知识、规划、学习、自然语言处理、感知和移动与操控物体的能力。第三段:目前,人工智能已经在许多领域取得了重大突破,包括图像识别、语音识别、自然语言处理等。',
// Multi-turn conversation responses
nameIntro: '好的,我记住了,你的名字是小明。很高兴认识你,小明!有什么我可以帮助你的吗?',
// Multi-turn conversation responses
nameIntro: '好的,我记住了,你的名字是小明。很高兴认识你,小明!有什么我可以帮助你的吗?',
nameRecall: '你刚才说你的名字是小明。',
// Regenerate response
regenerated: '这是重新生成的回复内容。我是 Lobe AI很高兴为你服务',

View File

@@ -22,31 +22,19 @@ Given('用户已登录系统', async function (this: CustomWorld) {
Given('用户进入 Lobe AI 对话页面', async function (this: CustomWorld) {
console.log(' 📍 Step: 设置 LLM mock...');
// Setup LLM mock before navigation with all preset responses
// Setup LLM mock before navigation
llmMockManager.setResponse('hello', presetResponses.greeting);
llmMockManager.setResponse('hello world', presetResponses.greeting);
llmMockManager.setResponse('我的名字是小明', presetResponses.nameIntro);
llmMockManager.setResponse('我刚才说我的名字是什么?', presetResponses.nameRecall);
llmMockManager.setResponse('我刚才说我的名字是什么', presetResponses.nameRecall);
llmMockManager.setResponse('写一篇很长的文章', presetResponses.longArticle);
llmMockManager.setResponse('测试对话内容', '这是测试对话的回复内容。');
llmMockManager.setResponse('第一个对话', '这是第一个对话的回复。');
llmMockManager.setResponse('第二个对话', '这是第二个对话的回复。');
await llmMockManager.setup(this.page);
console.log(' 📍 Step: 导航到首页...');
// Navigate to home page first
await this.page.goto('/');
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
console.log(' 📍 Step: 等待助手列表加载...');
// Wait for skeletons to disappear (assistant list to load)
await this.page.waitForTimeout(2000);
await this.page.waitForLoadState('networkidle', { timeout: 10_000 });
console.log(' 📍 Step: 查找 Lobe AI...');
// Find and click on "Lobe AI" agent in the sidebar/home
const lobeAIAgent = this.page.locator('text=Lobe AI').first();
await expect(lobeAIAgent).toBeVisible({ timeout: 20_000 });
await expect(lobeAIAgent).toBeVisible({ timeout: 10_000 });
console.log(' 📍 Step: 点击 Lobe AI...');
await lobeAIAgent.click();
@@ -163,459 +151,3 @@ Then('回复内容应该可见', async function (this: CustomWorld) {
console.log(` ✅ Assistant replied: "${text?.slice(0, 50)}..."`);
});
Then('回复内容应该包含 {string}', async function (this: CustomWorld, expectedText: string) {
console.log(` 📍 Step: 验证回复包含 "${expectedText}"...`);
// Get the last assistant message
const assistantMessages = this.page.locator(
'[data-role="assistant"], [class*="assistant"], [class*="message"]',
);
const lastMessage = assistantMessages.last();
await expect(lastMessage).toBeVisible({ timeout: 10_000 });
// Get text content
const text = await lastMessage.textContent();
console.log(` 📍 回复内容: "${text?.slice(0, 100)}..."`);
expect(text).toContain(expectedText);
console.log(` ✅ 回复包含 "${expectedText}"`);
});
// ============================================
// Given Steps for Advanced Scenarios
// ============================================
Given('用户已发送消息 {string}', async function (this: CustomWorld, message: string) {
console.log(` 📍 Step: 发送预备消息 "${message}"...`);
// Find and click the chat input
const chatInputs = this.page.locator('[data-testid="chat-input"]');
const count = await chatInputs.count();
let chatInputContainer = chatInputs.first();
for (let i = 0; i < count; i++) {
const elem = chatInputs.nth(i);
const box = await elem.boundingBox();
if (box && box.width > 0 && box.height > 0) {
chatInputContainer = elem;
break;
}
}
await chatInputContainer.click();
await this.page.waitForTimeout(300);
await this.page.keyboard.type(message, { delay: 30 });
await this.page.keyboard.press('Enter');
// Wait for response
await this.page.waitForTimeout(2000);
// Verify we got a response
const assistantMessage = this.page
.locator('[data-role="assistant"], [class*="assistant"], [class*="message"]')
.last();
await expect(assistantMessage).toBeVisible({ timeout: 15_000 });
console.log(` ✅ 预备消息已发送并收到回复`);
});
// ============================================
// When Steps for Advanced Scenarios
// ============================================
When('用户点击清空对话按钮', async function (this: CustomWorld) {
console.log(' 📍 Step: 查找清空对话按钮...');
// The clear button uses an Eraser icon from lucide-react and is visible in the ActionBar
// The ActionBar is in the footer of ChatInput component
// We need to find all buttons on the page and look for the one with the Eraser icon
// Look for ALL buttons on the page that have SVG icons
// This is a broader search to capture all action bar buttons
const allPageButtons = this.page.locator('button:has(svg)');
const pageButtonCount = await allPageButtons.count();
console.log(` 📍 Found ${pageButtonCount} buttons with SVG on page`);
let clearButtonFound = false;
// First try to find by lucide class name for eraser
const eraserByClass = this.page.locator('svg.lucide-eraser').locator('..');
if ((await eraserByClass.count()) > 0) {
console.log(' 📍 Found eraser button by class name');
await eraserByClass.first().click();
clearButtonFound = true;
}
// If not found by class, iterate through buttons and check SVG path data
if (!clearButtonFound) {
for (let i = 0; i < pageButtonCount; i++) {
const btn = allPageButtons.nth(i);
const box = await btn.boundingBox();
if (!box || box.width === 0 || box.height === 0) continue;
// Check SVG class
const svgInButton = btn.locator('svg').first();
const svgClass = await svgInButton.getAttribute('class').catch(() => '');
if (svgClass?.includes('eraser') || svgClass?.toLowerCase().includes('eraser')) {
console.log(` 📍 Found eraser by class at button ${i}: ${svgClass}`);
await btn.click();
clearButtonFound = true;
break;
}
// Check path data - the Eraser icon has specific path
const pathElement = btn.locator('svg path').first();
const pathD = await pathElement.getAttribute('d').catch(() => '');
// Eraser icon path data pattern from lucide-react
// Check for multiple possible patterns
if (
pathD?.includes('m7 21') ||
pathD?.includes('M7 21') ||
pathD?.includes('7 21-4.3-4.3') ||
pathD?.includes('21l-4.3')
) {
console.log(` 📍 Found eraser button by path at index ${i}`);
await btn.click();
clearButtonFound = true;
break;
}
}
}
// Fallback: hover over buttons in bottom area and find one with "清空" tooltip
if (!clearButtonFound) {
console.log(' 📍 Trying hover approach to find button with 清空 tooltip...');
// Focus on buttons in the bottom 200px of viewport
for (let i = 0; i < pageButtonCount; i++) {
const btn = allPageButtons.nth(i);
const box = await btn.boundingBox();
// Only check buttons in the bottom area (action bar)
if (!box || box.width === 0 || box.height === 0) continue;
if (box.y < 500) continue; // Skip buttons not in bottom area
// Hover to trigger tooltip
await btn.hover();
await this.page.waitForTimeout(300);
// Check if tooltip with "清空" appeared
const tooltip = this.page.locator('.ant-tooltip:has-text("清空")');
if ((await tooltip.count()) > 0) {
console.log(` 📍 Found clear button by tooltip at index ${i}`);
await btn.click();
clearButtonFound = true;
break;
}
}
}
// Last resort: click buttons in bottom area and check for Popconfirm
if (!clearButtonFound) {
console.log(' 📍 Last resort: clicking bottom buttons to find Popconfirm...');
for (let i = 0; i < pageButtonCount; i++) {
const btn = allPageButtons.nth(i);
const box = await btn.boundingBox();
if (!box || box.width === 0 || box.height === 0) continue;
if (box.y < 500) continue; // Focus on bottom area
await btn.click();
await this.page.waitForTimeout(300);
// Check if Popconfirm appeared
const popconfirm = this.page.locator(
'.ant-popconfirm, .ant-popover:has(button.ant-btn-dangerous)',
);
if ((await popconfirm.count()) > 0 && (await popconfirm.first().isVisible())) {
console.log(` 📍 Found Popconfirm after clicking button ${i}`);
clearButtonFound = true;
break;
}
// Press Escape to dismiss any popover
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(100);
}
}
if (!clearButtonFound) {
throw new Error('Could not find the clear button');
}
// Wait for Popconfirm to appear and click the confirm button
console.log(' 📍 Step: 确认清空...');
await this.page.waitForTimeout(500);
// The Popconfirm has a danger primary button for confirmation
const confirmButton = this.page.locator(
'.ant-popconfirm button.ant-btn-primary, .ant-popover button.ant-btn-primary',
);
await expect(confirmButton).toBeVisible({ timeout: 5000 });
await confirmButton.click();
await this.page.waitForTimeout(500);
console.log(' ✅ 已点击清空对话按钮');
});
When('用户点击重新生成按钮', async function (this: CustomWorld) {
console.log(' 📍 Step: 查找重新生成按钮...');
// The regenerate action is in the ActionIconGroup menu for assistant messages
// ActionIconGroup renders ActionIcon buttons and a "more" button (MoreHorizontal icon)
// The "more" button opens a dropdown menu with "重新生成" option
// Action buttons only appear on hover over the message
// Wait for the message to be rendered
await this.page.waitForTimeout(500);
// Find assistant messages by their structure
// Assistant messages have class "message-wrapper" and are aligned to the left
const messageWrappers = this.page.locator('.message-wrapper');
const wrapperCount = await messageWrappers.count();
console.log(` 📍 Found ${wrapperCount} message wrappers`);
// Find the assistant message by looking for the one with "Lobe AI" text
let assistantMessage = null;
for (let i = wrapperCount - 1; i >= 0; i--) {
const wrapper = messageWrappers.nth(i);
const titleText = await wrapper
.locator('.message-header')
.textContent()
.catch(() => '');
console.log(` 📍 Message ${i} title: "${titleText?.slice(0, 30)}..."`);
// Check if this is an assistant message (has "Lobe AI" or similar in title)
if (titleText?.includes('Lobe AI') || titleText?.includes('AI')) {
assistantMessage = wrapper;
console.log(` 📍 Found assistant message at index ${i}`);
break;
}
}
if (!assistantMessage) {
throw new Error('No assistant messages found');
}
// Hover over the message to reveal action buttons
console.log(' 📍 Hovering over assistant message to reveal actions...');
await assistantMessage.hover();
await this.page.waitForTimeout(800);
// The action bar with role="menubar" contains the ActionIconGroup
// The "more" button uses MoreHorizontal icon from lucide-react (class: lucide-more-horizontal)
// Try to find the more button by its icon class
const moreButtonByClass = this.page.locator('svg.lucide-more-horizontal').locator('..');
let moreButtonCount = await moreButtonByClass.count();
console.log(` 📍 Found ${moreButtonCount} buttons with more-horizontal icon`);
let menuOpened = false;
if (moreButtonCount > 0) {
// Find the one in the main content area (not sidebar)
for (let i = 0; i < moreButtonCount; i++) {
const btn = moreButtonByClass.nth(i);
const btnBox = await btn.boundingBox();
if (!btnBox || btnBox.x < 320) continue; // Skip sidebar buttons
console.log(` 📍 More button ${i} at (${btnBox.x}, ${btnBox.y})`);
await btn.click();
await this.page.waitForTimeout(500);
// Check if dropdown menu appeared with regenerate option
const menu = this.page.locator('.ant-dropdown-menu:visible');
if ((await menu.count()) > 0) {
const hasRegenerate = this.page.locator('.ant-dropdown-menu-item:has-text("重新生成")');
if ((await hasRegenerate.count()) > 0) {
console.log(` 📍 Found menu with regenerate option`);
menuOpened = true;
break;
} else {
const menuItems = await this.page.locator('.ant-dropdown-menu-item').allTextContents();
console.log(` 📍 Menu items: ${menuItems.slice(0, 5).join(', ')}...`);
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(200);
// Re-hover to keep action bar visible
await assistantMessage.hover();
await this.page.waitForTimeout(300);
}
}
}
}
// Fallback: Look for all buttons in the action bar area after hovering
if (!menuOpened) {
console.log(' 📍 Fallback: Looking for buttons in action bar area...');
await assistantMessage.hover();
await this.page.waitForTimeout(500);
// Find the action bar within message
const actionBar = assistantMessage.locator('[role="menubar"]');
if ((await actionBar.count()) > 0) {
// Look for all buttons (ActionIcon components render as buttons)
const allButtons = actionBar.locator('button, [role="button"]');
const allButtonCount = await allButtons.count();
console.log(` 📍 Found ${allButtonCount} buttons in action bar`);
// Try clicking the last button (usually the "more" button)
for (let i = allButtonCount - 1; i >= 0; i--) {
const btn = allButtons.nth(i);
await btn.click();
await this.page.waitForTimeout(500);
const menu = this.page.locator('.ant-dropdown-menu:visible');
if ((await menu.count()) > 0) {
const hasRegenerate = this.page.locator('.ant-dropdown-menu-item:has-text("重新生成")');
if ((await hasRegenerate.count()) > 0) {
menuOpened = true;
break;
}
await this.page.keyboard.press('Escape');
await assistantMessage.hover();
await this.page.waitForTimeout(300);
}
}
}
}
// Click on the regenerate option in the dropdown menu
console.log(' 📍 Looking for regenerate option in menu...');
const regenerateOption = this.page.locator(
'.ant-dropdown-menu-item:has-text("重新生成"), .ant-dropdown-menu-item:has-text("Regenerate"), [data-menu-id*="regenerate"]',
);
if ((await regenerateOption.count()) > 0) {
await expect(regenerateOption.first()).toBeVisible({ timeout: 5000 });
console.log(' 📍 Clicking regenerate option...');
await regenerateOption.first().click();
} else {
throw new Error('Regenerate option not found in menu');
}
console.log(' ✅ 已点击重新生成按钮');
});
When('用户在生成过程中点击停止按钮', async function (this: CustomWorld) {
console.log(' 📍 Step: 等待生成开始...');
await this.page.waitForTimeout(500);
console.log(' 📍 Step: 查找停止按钮...');
const stopButton = this.page.locator(
'button[aria-label*="停止"], button[aria-label*="stop"], [data-testid="stop-generate"]',
);
// The stop button should appear during generation
const stopButtonVisible = await stopButton
.first()
.isVisible()
.catch(() => false);
if (stopButtonVisible) {
await stopButton.first().click();
console.log(' ✅ 已点击停止按钮');
} else {
console.log(' ⚠️ 停止按钮不可见,可能生成已完成');
}
});
// ============================================
// Then Steps for Advanced Scenarios
// ============================================
Then('对话历史应该被清空', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证对话历史已清空...');
// Wait for the clear to take effect
await this.page.waitForTimeout(1000);
// Check that there are no user/assistant messages in the main chat area
// Only look for messages with explicit data-role attribute, which are actual chat messages
// Avoid matching sidebar items or other elements with "message" in class
const userMessages = this.page.locator('[data-role="user"]');
const assistantMessages = this.page.locator('[data-role="assistant"]');
const userCount = await userMessages.count();
const assistantCount = await assistantMessages.count();
console.log(` 📍 用户消息数量: ${userCount}, 助手消息数量: ${assistantCount}`);
// There should be no user or assistant messages after clearing
expect(userCount).toBe(0);
expect(assistantCount).toBe(0);
console.log(' ✅ 对话历史已清空');
});
Then('页面应该显示欢迎界面', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证显示欢迎界面...');
// Look for welcome elements - Lobe AI title or welcome text in the main chat area
// The welcome page shows Lobe AI avatar and introductory text
// Try multiple selectors to find the welcome content
const welcomeText = this.page.locator('text=我是你的智能助理');
const lobeAITitle = this.page.locator('h1:has-text("Lobe AI"), h2:has-text("Lobe AI")');
const welcomeStart = this.page.locator('text=从任何想法开始');
const hasWelcomeText = (await welcomeText.count()) > 0;
const hasLobeTitle = (await lobeAITitle.count()) > 0;
const hasStartText = (await welcomeStart.count()) > 0;
console.log(
` 📍 欢迎文本: ${hasWelcomeText}, Lobe标题: ${hasLobeTitle}, 开始提示: ${hasStartText}`,
);
// At least one of the welcome elements should be visible
expect(hasWelcomeText || hasLobeTitle || hasStartText).toBeTruthy();
console.log(' ✅ 欢迎界面可见');
});
Then('用户应该收到新的助手回复', async function (this: CustomWorld) {
console.log(' 📍 Step: 等待新回复...');
// Wait for a new response to appear
await this.page.waitForTimeout(2000);
const assistantMessage = this.page
.locator('[data-role="assistant"], [class*="assistant"], [class*="message"]')
.last();
await expect(assistantMessage).toBeVisible({ timeout: 15_000 });
console.log(' ✅ 收到新的助手回复');
});
Then('回复应该停止生成', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证生成已停止...');
// The stop button should no longer be visible
const stopButton = this.page.locator(
'button[aria-label*="停止"], button[aria-label*="stop"], [data-testid="stop-generate"]',
);
// Wait a bit and check if stop button is gone
await this.page.waitForTimeout(1000);
const isStopVisible = await stopButton
.first()
.isVisible()
.catch(() => false);
// Stop button should be hidden after stopping
expect(isStopVisible).toBeFalsy();
console.log(' ✅ 生成已停止');
});
Then('已生成的内容应该保留', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证已生成内容...');
// There should be some content in the last assistant message
const assistantMessage = this.page
.locator('[data-role="assistant"], [class*="assistant"], [class*="message"]')
.last();
const text = await assistantMessage.textContent();
expect(text).toBeTruthy();
expect(text!.length).toBeGreaterThan(0);
console.log(` ✅ 已生成内容保留: "${text?.slice(0, 50)}..."`);
});