mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✅ 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:
111
e2e/CLAUDE.md
111
e2e/CLAUDE.md
@@ -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
68
e2e/docs/llm-mock.md
Normal 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
354
e2e/docs/local-setup.md
Normal 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
94
e2e/docs/testing-tips.md
Normal 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()`
|
||||
- 添加适当的等待时间
|
||||
@@ -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 已生成的内容应该保留
|
||||
|
||||
@@ -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,很高兴为你服务!',
|
||||
|
||||
@@ -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)}..."`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user