mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✅ test: add page e2e testing (#11423)
* add page e2e * move * add more e2e for page * update * fix keyboard * update
This commit is contained in:
502
.claude/prompts/e2e-coverage.md
Normal file
502
.claude/prompts/e2e-coverage.md
Normal file
@@ -0,0 +1,502 @@
|
||||
# E2E BDD Test Coverage Assistant
|
||||
|
||||
You are an E2E testing assistant. Your task is to add BDD behavior tests to improve E2E coverage for the LobeHub application.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before starting, read the following documents:
|
||||
|
||||
- `e2e/CLAUDE.md` - E2E testing guide and best practices
|
||||
- `e2e/docs/local-setup.md` - Local environment setup
|
||||
|
||||
## Target Modules
|
||||
|
||||
Based on the product architecture, prioritize modules by coverage status:
|
||||
|
||||
| Module | Sub-features | Priority | Status |
|
||||
| ---------------- | --------------------------------------------------- | -------- | ------ |
|
||||
| **Agent** | Builder, Conversation, Task | P0 | 🚧 |
|
||||
| **Agent Group** | Builder, Group Chat | P0 | ⏳ |
|
||||
| **Page (Docs)** | Sidebar CRUD ✅, Title/Emoji ✅, Rich Text ✅, Copilot | P0 | 🚧 |
|
||||
| **Knowledge** | Create, Upload, RAG Conversation | P1 | ⏳ |
|
||||
| **Memory** | View, Edit, Associate | P2 | ⏳ |
|
||||
| **Home Sidebar** | Agent Mgmt, Group Mgmt | P1 | ✅ |
|
||||
| **Community** | Browse, Interactions, Detail Pages | P1 | ✅ |
|
||||
| **Settings** | User Settings, Model Provider | P2 | ⏳ |
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Analyze Current Coverage
|
||||
|
||||
**Step 1.1**: List existing feature files
|
||||
|
||||
```bash
|
||||
find e2e/src/features -name "*.feature" -type f
|
||||
```
|
||||
|
||||
**Step 1.2**: Review the product modules in `src/app/[variants]/(main)/` to identify untested user journeys
|
||||
|
||||
**Step 1.3**: Check `e2e/CLAUDE.md` for the coverage matrix and identify gaps
|
||||
|
||||
### 2. Select a Module to Test
|
||||
|
||||
**Selection Criteria**:
|
||||
|
||||
- Choose ONE module that is NOT yet covered or has incomplete coverage
|
||||
- Prioritize by: P0 > P1 > P2
|
||||
- Focus on user journeys that represent core product value
|
||||
|
||||
**Module granularity examples**:
|
||||
|
||||
- Agent conversation flow
|
||||
- Knowledge base RAG workflow
|
||||
- Settings configuration flow
|
||||
- Page document CRUD operations
|
||||
|
||||
### 3. Create Module Directory and README
|
||||
|
||||
**Step 3.1**: Create dedicated feature directory
|
||||
|
||||
```bash
|
||||
mkdir -p e2e/src/features/{module-name}
|
||||
```
|
||||
|
||||
**Step 3.2**: Create README.md with feature inventory
|
||||
|
||||
Create `e2e/src/features/{module-name}/README.md` with:
|
||||
|
||||
- Module overview and routes
|
||||
- Feature inventory table (功能点、描述、优先级、状态、测试文件)
|
||||
- Test file structure
|
||||
- Execution commands
|
||||
- Known issues
|
||||
|
||||
**Example structure** (see `e2e/src/features/page/README.md`):
|
||||
|
||||
```markdown
|
||||
# {Module} 模块 E2E 测试覆盖
|
||||
|
||||
## 模块概述
|
||||
**路由**: `/module`, `/module/[id]`
|
||||
|
||||
## 功能清单与测试覆盖
|
||||
|
||||
### 1. 功能分组名称
|
||||
|
||||
| 功能点 | 描述 | 优先级 | 状态 | 测试文件 |
|
||||
| ------ | ---- | ------ | ---- | -------- |
|
||||
| 功能A | xxx | P0 | ✅ | `xxx.feature` |
|
||||
| 功能B | xxx | P1 | ⏳ | |
|
||||
|
||||
## 测试文件结构
|
||||
## 测试执行
|
||||
## 已知问题
|
||||
## 更新记录
|
||||
```
|
||||
|
||||
### 4. Explore Module Features
|
||||
|
||||
**Step 4.1**: Use Task tool to explore the module
|
||||
|
||||
```
|
||||
Use the Task tool with subagent_type=Explore to thoroughly explore:
|
||||
- Route structure in src/app/[variants]/(main)/{module}/
|
||||
- Feature components in src/features/
|
||||
- Store actions in src/store/{module}/
|
||||
- All user interactions (buttons, menus, forms)
|
||||
```
|
||||
|
||||
**Step 4.2**: Document all features in README.md
|
||||
|
||||
Group features by user journey area (e.g., Sidebar, Editor Header, Editor Content, etc.)
|
||||
|
||||
### 5. Design Test Scenarios
|
||||
|
||||
**Step 5.1**: Create feature files by functional area
|
||||
|
||||
Feature file location: `e2e/src/features/{module}/{area}.feature`
|
||||
|
||||
**Naming conventions**:
|
||||
|
||||
- `crud.feature` - Basic CRUD operations
|
||||
- `editor-meta.feature` - Editor metadata (title, icon)
|
||||
- `editor-content.feature` - Rich text editing
|
||||
- `copilot.feature` - AI copilot interactions
|
||||
|
||||
**Feature file template**:
|
||||
|
||||
```gherkin
|
||||
@journey @P0 @{module-tag}
|
||||
Feature: {Feature Name in Chinese}
|
||||
|
||||
作为用户,我希望能够 {user goal},
|
||||
以便 {business value}
|
||||
|
||||
Background:
|
||||
Given 用户已登录系统
|
||||
|
||||
# ============================================
|
||||
# 功能分组注释
|
||||
# ============================================
|
||||
|
||||
@{MODULE-AREA-001}
|
||||
Scenario: {Scenario description in Chinese}
|
||||
Given {precondition}
|
||||
When {user action}
|
||||
Then {expected outcome}
|
||||
And {additional verification}
|
||||
```
|
||||
|
||||
**Tag conventions**:
|
||||
|
||||
```gherkin
|
||||
@journey # User journey test (experience baseline)
|
||||
@smoke # Smoke test (quick validation)
|
||||
@regression # Regression test
|
||||
@skip # Skip this test (known issue)
|
||||
|
||||
@P0 # Highest priority (CI must run)
|
||||
@P1 # High priority (Nightly)
|
||||
@P2 # Medium priority (Pre-release)
|
||||
|
||||
@agent # Agent module
|
||||
@agent-group # Agent Group module
|
||||
@page # Page/Docs module
|
||||
@knowledge # Knowledge base module
|
||||
@memory # Memory module
|
||||
@settings # Settings module
|
||||
@home # Home sidebar module
|
||||
```
|
||||
|
||||
### 6. Implement Step Definitions
|
||||
|
||||
**Step 6.1**: Create step definition file
|
||||
|
||||
Location: `e2e/src/steps/{module}/{area}.steps.ts`
|
||||
|
||||
**Step definition template**:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* {Module} {Area} Steps
|
||||
*
|
||||
* Step definitions for {description}
|
||||
*/
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { CustomWorld } from '../../support/world';
|
||||
|
||||
// ============================================
|
||||
// Given Steps
|
||||
// ============================================
|
||||
|
||||
Given('用户打开一个文稿编辑器', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 创建并打开一个文稿...');
|
||||
// Implementation
|
||||
console.log(' ✅ 已打开文稿编辑器');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// When Steps
|
||||
// ============================================
|
||||
|
||||
When('用户点击标题输入框', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 点击标题输入框...');
|
||||
// Implementation
|
||||
console.log(' ✅ 已点击标题输入框');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Then Steps
|
||||
// ============================================
|
||||
|
||||
Then('文稿标题应该更新为 {string}', async function (this: CustomWorld, title: string) {
|
||||
console.log(` 📍 Step: 验证标题为 "${title}"...`);
|
||||
// Assertions
|
||||
console.log(` ✅ 标题已更新为 "${title}"`);
|
||||
});
|
||||
```
|
||||
|
||||
**Step 6.2**: Add hooks if needed
|
||||
|
||||
Update `e2e/src/steps/hooks.ts` for new tag prefixes:
|
||||
|
||||
```typescript
|
||||
const testId = pickle.tags.find(
|
||||
(tag) =>
|
||||
tag.name.startsWith('@COMMUNITY-') ||
|
||||
tag.name.startsWith('@AGENT-') ||
|
||||
tag.name.startsWith('@HOME-') ||
|
||||
tag.name.startsWith('@PAGE-') || // Add new prefix
|
||||
tag.name.startsWith('@ROUTES-'),
|
||||
);
|
||||
```
|
||||
|
||||
### 7. Setup Mocks (If Needed)
|
||||
|
||||
For LLM-related tests, use the mock framework:
|
||||
|
||||
```typescript
|
||||
import { llmMockManager, presetResponses } from '../../mocks/llm';
|
||||
|
||||
// Setup mock before navigation
|
||||
llmMockManager.setResponse('user message', 'Expected AI response');
|
||||
await llmMockManager.setup(this.page);
|
||||
```
|
||||
|
||||
### 8. Run and Verify Tests
|
||||
|
||||
**Step 8.1**: Start local environment
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
bun e2e/scripts/setup.ts --start
|
||||
```
|
||||
|
||||
**Step 8.2**: Run dry-run first to verify step definitions
|
||||
|
||||
```bash
|
||||
cd e2e
|
||||
BASE_URL=http://localhost:3006 \
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
|
||||
pnpm exec cucumber-js --config cucumber.config.js --tags "@{module-tag}" --dry-run
|
||||
```
|
||||
|
||||
**Step 8.3**: Run the new tests
|
||||
|
||||
```bash
|
||||
# Run specific test by tag
|
||||
HEADLESS=false BASE_URL=http://localhost:3006 \
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
|
||||
pnpm exec cucumber-js --config cucumber.config.js --tags "@{TEST-ID}"
|
||||
|
||||
# Run all module tests (excluding skipped)
|
||||
HEADLESS=true BASE_URL=http://localhost:3006 \
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
|
||||
pnpm exec cucumber-js --config cucumber.config.js --tags "@{module-tag} and not @skip"
|
||||
```
|
||||
|
||||
**Step 8.4**: Fix any failures
|
||||
|
||||
- Check screenshots in `e2e/screenshots/`
|
||||
- Adjust selectors and waits as needed
|
||||
- For flaky tests, add `@skip` tag and document in README known issues
|
||||
- Ensure tests pass consistently
|
||||
|
||||
### 9. Update Documentation
|
||||
|
||||
**Step 9.1**: Update module README.md
|
||||
|
||||
- Mark completed features with ✅
|
||||
- Update test statistics
|
||||
- Add any known issues
|
||||
|
||||
**Step 9.2**: Update this prompt file
|
||||
|
||||
- Update module status in Target Modules table
|
||||
- Add any new best practices learned
|
||||
|
||||
### 10. Create Pull Request
|
||||
|
||||
- Branch name: `test/e2e-{module-name}`
|
||||
- Commit message format:
|
||||
```
|
||||
✅ test: add E2E tests for {module-name}
|
||||
```
|
||||
- PR title: `✅ test: add E2E tests for {module-name}`
|
||||
- PR body template:
|
||||
|
||||
````markdown
|
||||
## Summary
|
||||
|
||||
- Added E2E BDD tests for `{module-name}`
|
||||
- Feature files added: [number]
|
||||
- Scenarios covered: [number]
|
||||
|
||||
## Test Coverage
|
||||
|
||||
- [x] Feature area 1: {description}
|
||||
- [x] Feature area 2: {description}
|
||||
- [ ] Feature area 3: {pending}
|
||||
|
||||
## Test Execution
|
||||
|
||||
```bash
|
||||
# Run these tests
|
||||
cd e2e && pnpm exec cucumber-js --config cucumber.config.js --tags "@{module-tag} and not @skip"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
````
|
||||
|
||||
## Important Rules
|
||||
|
||||
- **DO** write feature files in Chinese (贴近产品需求)
|
||||
- **DO** add appropriate tags (@journey, @P0/@P1/@P2, @module-name)
|
||||
- **DO** mock LLM responses for stability
|
||||
- **DO** add console logs in step definitions for debugging
|
||||
- **DO** handle element visibility issues (desktop/mobile dual components)
|
||||
- **DO** use `page.waitForTimeout()` for animation/transition waits
|
||||
- **DO** support both Chinese and English text (e.g., `/^(无标题|Untitled)$/`)
|
||||
- **DO** create unique test data with timestamps to avoid conflicts
|
||||
- **DO NOT** depend on actual LLM API calls
|
||||
- **DO NOT** create flaky tests (ensure stability before PR)
|
||||
- **DO NOT** modify production code unless adding data-testid attributes
|
||||
- **DO NOT** skip running tests locally before creating PR
|
||||
|
||||
## Element Locator Best Practices
|
||||
|
||||
### Rich Text Editor (contenteditable)
|
||||
|
||||
```typescript
|
||||
// Correct way to input in contenteditable
|
||||
const editor = this.page.locator('[contenteditable="true"]').first();
|
||||
await editor.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
await this.page.keyboard.type(message, { delay: 30 });
|
||||
```
|
||||
|
||||
### Slash Commands
|
||||
|
||||
```typescript
|
||||
// Type slash and wait for menu to appear
|
||||
await this.page.keyboard.type('/', { delay: 100 });
|
||||
await this.page.waitForTimeout(800); // Wait for slash menu
|
||||
|
||||
// Type command shortcut
|
||||
await this.page.keyboard.type('h1', { delay: 80 });
|
||||
await this.page.keyboard.press('Enter');
|
||||
```
|
||||
|
||||
### Handling i18n (Chinese/English)
|
||||
|
||||
```typescript
|
||||
// Support both languages for default values
|
||||
const defaultTitleRegex = /^(无标题|Untitled)$/;
|
||||
const pageItem = this.page.getByText(defaultTitleRegex).first();
|
||||
|
||||
// Or for buttons
|
||||
const button = this.page.getByRole('button', { name: /choose.*icon|选择图标/i });
|
||||
```
|
||||
|
||||
### Creating Unique Test Data
|
||||
|
||||
```typescript
|
||||
// Use timestamps to avoid conflicts between test runs
|
||||
const uniqueTitle = `E2E Page ${Date.now()}`;
|
||||
```
|
||||
|
||||
### Handling Multiple Matches
|
||||
|
||||
```typescript
|
||||
// Use .first() or .nth() for multiple matches
|
||||
const element = this.page.locator('[data-testid="item"]').first();
|
||||
|
||||
// Or filter by visibility
|
||||
const items = await this.page.locator('[data-testid="item"]').all();
|
||||
for (const item of items) {
|
||||
if (await item.isVisible()) {
|
||||
await item.click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Adding data-testid
|
||||
|
||||
If needed for reliable element selection, add `data-testid` to components:
|
||||
|
||||
```tsx
|
||||
<Component data-testid="unique-identifier" />
|
||||
```
|
||||
|
||||
## Common Test Patterns
|
||||
|
||||
### Navigation Test
|
||||
|
||||
```gherkin
|
||||
Scenario: 用户导航到目标页面
|
||||
Given 用户已登录系统
|
||||
When 用户点击侧边栏的 "{menu-item}"
|
||||
Then 应该跳转到 "{expected-url}"
|
||||
And 页面标题应包含 "{expected-title}"
|
||||
```
|
||||
|
||||
### CRUD Test
|
||||
|
||||
```gherkin
|
||||
Scenario: 创建新项目
|
||||
Given 用户已登录系统
|
||||
When 用户点击创建按钮
|
||||
And 用户输入名称 "{name}"
|
||||
And 用户点击保存
|
||||
Then 应该看到新创建的项目 "{name}"
|
||||
|
||||
Scenario: 编辑项目
|
||||
Given 用户已创建项目 "{name}"
|
||||
When 用户打开项目编辑
|
||||
And 用户修改名称为 "{new-name}"
|
||||
And 用户保存更改
|
||||
Then 项目名称应更新为 "{new-name}"
|
||||
|
||||
Scenario: 删除项目
|
||||
Given 用户已创建项目 "{name}"
|
||||
When 用户删除该项目
|
||||
And 用户确认删除
|
||||
Then 项目列表中不应包含 "{name}"
|
||||
```
|
||||
|
||||
### Editor Title/Meta Test
|
||||
|
||||
```gherkin
|
||||
Scenario: 编辑文稿标题
|
||||
Given 用户打开一个文稿编辑器
|
||||
When 用户点击标题输入框
|
||||
And 用户输入标题 "我的测试文稿"
|
||||
And 用户按下 Enter 键
|
||||
Then 文稿标题应该更新为 "我的测试文稿"
|
||||
```
|
||||
|
||||
### Rich Text Editor Test
|
||||
|
||||
```gherkin
|
||||
Scenario: 通过斜杠命令插入一级标题
|
||||
Given 用户打开一个文稿编辑器
|
||||
When 用户点击编辑器内容区域
|
||||
And 用户输入斜杠命令 "/h1"
|
||||
And 用户按下 Enter 键
|
||||
And 用户输入文本 "一级标题内容"
|
||||
Then 编辑器应该包含一级标题
|
||||
```
|
||||
|
||||
### LLM Interaction Test
|
||||
|
||||
```gherkin
|
||||
Scenario: AI 对话基本流程
|
||||
Given 用户已登录系统
|
||||
And LLM Mock 已配置
|
||||
When 用户发送消息 "{user-message}"
|
||||
Then 应该收到 AI 回复 "{expected-response}"
|
||||
And 消息应显示在对话历史中
|
||||
```
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
1. **Use HEADLESS=false** to see browser actions
|
||||
2. **Check screenshots** in `e2e/screenshots/` on failure
|
||||
3. **Add console.log** in step definitions
|
||||
4. **Increase timeouts** for slow operations
|
||||
5. **Use `page.pause()`** for interactive debugging
|
||||
6. **Run dry-run first** to verify all step definitions exist
|
||||
7. **Use @skip tag** for known flaky tests, document in README
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
See these completed modules for reference:
|
||||
|
||||
- **Page module**: `e2e/src/features/page/` - Full implementation with README, multiple feature files
|
||||
- **Community module**: `e2e/src/features/community/` - Smoke and interaction tests
|
||||
- **Home sidebar**: `e2e/src/features/home/` - Agent and Group management tests
|
||||
@@ -18,13 +18,13 @@ Related: [LOBE-2417](https://linear.app/lobehub/issue/LOBE-2417/建立核心产
|
||||
|
||||
### 产品架构覆盖
|
||||
|
||||
| 模块 | 子功能 | 优先级 | 状态 |
|
||||
| ---------------- | -------------------- | ------ | ---- |
|
||||
| **Agent** | Builder, 对话,Task | P0 | 🚧 |
|
||||
| **Agent Group** | Builder, 群聊 | P1 | ⏳ |
|
||||
| **Page(文稿)** | 创建,编辑,分享 | P1 | ⏳ |
|
||||
| **知识库** | 创建,上传,RAG 对话 | P1 | ⏳ |
|
||||
| **记忆** | 查看,编辑,关联 | P2 | ⏳ |
|
||||
| 模块 | 子功能 | 优先级 | 状态 |
|
||||
| ---------------- | --------------------------------- | ------ | ---- |
|
||||
| **Agent** | Builder, 对话,Task | P0 | 🚧 |
|
||||
| **Agent Group** | Builder, 群聊 | P0 | ⏳ |
|
||||
| **Page(文稿)** | 侧边栏 CRUD ✅,文档编辑,Copilot | P0 | 🚧 |
|
||||
| **知识库** | 创建,上传,RAG 对话 | P1 | ⏳ |
|
||||
| **记忆** | 查看,编辑,关联 | P2 | ⏳ |
|
||||
|
||||
### 标签系统
|
||||
|
||||
@@ -82,7 +82,7 @@ e2e/
|
||||
│ │ │ │ ├── group-builder.feature
|
||||
│ │ │ │ └── group-chat.feature
|
||||
│ │ │ ├── page/
|
||||
│ │ │ │ └── page-crud.feature
|
||||
│ │ │ │ └── page-crud.feature ✅
|
||||
│ │ │ ├── knowledge/
|
||||
│ │ │ │ └── knowledge-rag.feature
|
||||
│ │ │ └── memory/
|
||||
@@ -92,6 +92,7 @@ e2e/
|
||||
│ │ └── regression/ # 回归测试
|
||||
│ ├── steps/ # Step definitions
|
||||
│ │ ├── agent/ # Agent 相关 steps
|
||||
│ │ ├── page/ # Page 相关 steps
|
||||
│ │ ├── common/ # 通用 steps (auth, navigation)
|
||||
│ │ └── hooks.ts # Before/After hooks
|
||||
│ ├── mocks/ # Mock 框架
|
||||
|
||||
@@ -16,5 +16,6 @@ export default {
|
||||
require: ['src/steps/**/*.ts', 'src/support/**/*.ts'],
|
||||
requireModule: ['tsx/cjs'],
|
||||
retry: 0,
|
||||
tags: 'not @skip',
|
||||
timeout: 30_000,
|
||||
};
|
||||
|
||||
118
e2e/src/features/page/README.md
Normal file
118
e2e/src/features/page/README.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Page 模块 E2E 测试覆盖
|
||||
|
||||
本目录包含 Page(文稿)模块的所有 E2E 测试用例。
|
||||
|
||||
## 模块概述
|
||||
|
||||
Page 模块是 LobeHub 的文档管理功能,允许用户创建、编辑和管理文稿页面。
|
||||
|
||||
**路由**: `/page`, `/page/[id]`
|
||||
|
||||
## 功能清单与测试覆盖
|
||||
|
||||
### 1. 侧边栏 - 文稿列表管理
|
||||
|
||||
| 功能点 | 描述 | 优先级 | 状态 | 测试文件 |
|
||||
| ------------ | ------------------------------ | ------ | ---- | -------------- |
|
||||
| 创建文稿 | 点击 + 按钮创建新文稿 | P0 | ✅ | `crud.feature` |
|
||||
| 重命名文稿 | 右键菜单 / 三点菜单重命名 | P0 | ✅ | `crud.feature` |
|
||||
| 复制文稿 | 复制文稿(自动添加 Copy 后缀) | P1 | ✅ | `crud.feature` |
|
||||
| 删除文稿 | 删除文稿(带确认弹窗) | P0 | ✅ | `crud.feature` |
|
||||
| 复制全文 | 复制文稿内容到剪贴板 | P2 | ⏳ | |
|
||||
| 列表分页设置 | 设置显示数量(20/40/60/100) | P2 | ⏳ | |
|
||||
| 全部文稿抽屉 | 打开完整列表 + 搜索 | P2 | ⏳ | |
|
||||
| 搜索文稿 | 按标题 / 内容搜索过滤 | P1 | ⏳ | |
|
||||
|
||||
### 2. 编辑器 - 文稿头部
|
||||
|
||||
| 功能点 | 描述 | 优先级 | 状态 | 测试文件 |
|
||||
| ------------- | -------------------------- | ------ | ---- | --------------------- |
|
||||
| 返回按钮 | 返回上一页 | P2 | ⏳ | |
|
||||
| 标题编辑 | 大标题输入框,自动保存 | P0 | ✅ | `editor-meta.feature` |
|
||||
| Emoji 选择 | 点击选择 / 更换 / 删除图标 | P1 | ✅ | `editor-meta.feature` |
|
||||
| 自动保存提示 | 显示保存状态 | P2 | ⏳ | |
|
||||
| 全宽模式切换 | 大屏幕下切换全宽 / 定宽 | P2 | ⏳ | |
|
||||
| 复制链接 | 复制文稿 URL | P2 | ⏳ | |
|
||||
| 导出 Markdown | 导出为 .md 文件 | P2 | ⏳ | |
|
||||
| 页面信息 | 显示最后编辑时间 | P2 | ⏳ | |
|
||||
|
||||
### 3. 编辑器 - 富文本编辑
|
||||
|
||||
| 功能点 | 描述 | 优先级 | 状态 | 测试文件 |
|
||||
| ------------- | ------------------------ | ------ | ---- | ------------------------ |
|
||||
| 基础文本输入 | 输入和编辑文本 | P0 | ✅ | `editor-content.feature` |
|
||||
| 斜杠命令 (/) | 打开命令菜单 | P1 | ✅ | `editor-content.feature` |
|
||||
| 标题 H1/H2/H3 | 插入标题 | P1 | ✅ | `editor-content.feature` |
|
||||
| 任务列表 | 插入待办事项 | P2 | ✅ | `editor-content.feature` |
|
||||
| 无序列表 | 插入项目符号列表 | P2 | ✅ | `editor-content.feature` |
|
||||
| 有序列表 | 插入编号列表 | P2 | ⏳ | |
|
||||
| 图片上传 | 插入图片 | P2 | ⏳ | |
|
||||
| 分隔线 | 插入水平分隔线 | P2 | ⏳ | |
|
||||
| 表格 | 插入表格 | P2 | ⏳ | |
|
||||
| 代码块 | 插入代码块(带语法高亮) | P2 | 🚧 | `editor-content.feature` |
|
||||
| LaTeX 公式 | 插入数学公式 | P2 | ⏳ | |
|
||||
| 文本加粗 | 使用快捷键加粗 | P1 | ✅ | `editor-content.feature` |
|
||||
| 文本斜体 | 使用快捷键斜体 | P2 | ✅ | `editor-content.feature` |
|
||||
|
||||
### 4. Copilot 侧边栏
|
||||
|
||||
| 功能点 | 描述 | 优先级 | 状态 | 测试文件 |
|
||||
| --------------- | ------------------- | ------ | ---- | ----------------- |
|
||||
| 打开 / 关闭面板 | 展开 / 收起 Copilot | P1 | ⏳ | `copilot.feature` |
|
||||
| Ask Copilot | 选中文本后询问 | P0 | ⏳ | `copilot.feature` |
|
||||
| Agent 切换 | 选择不同的 Agent | P2 | ⏳ | |
|
||||
| 新建话题 | 创建新的对话话题 | P2 | ⏳ | |
|
||||
| 话题历史 | 查看和切换历史话题 | P2 | ⏳ | |
|
||||
| 对话交互 | 发送消息、接收回复 | P0 | ⏳ | `copilot.feature` |
|
||||
| 模型选择 | 切换使用的模型 | P2 | ⏳ | |
|
||||
| 文件上传 | 拖放上传文件 | P2 | ⏳ | |
|
||||
|
||||
## 测试文件结构
|
||||
|
||||
```
|
||||
e2e/src/features/page/
|
||||
├── README.md # 本文档
|
||||
├── crud.feature # 侧边栏 CRUD 操作 (5 scenarios)
|
||||
├── editor-meta.feature # 编辑器元数据(标题、Emoji)(6 scenarios)
|
||||
└── editor-content.feature # 富文本编辑功能 (8 scenarios)
|
||||
```
|
||||
|
||||
## 测试统计
|
||||
|
||||
- **总场景数**: 19 (通过) + 1 (跳过)
|
||||
- **总步骤数**: 109+
|
||||
- **执行时间**: \~3 分钟
|
||||
|
||||
## 测试执行
|
||||
|
||||
```bash
|
||||
# 运行 Page 模块所有测试
|
||||
cd e2e
|
||||
BASE_URL=http://localhost:3006 \
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres \
|
||||
pnpm exec cucumber-js --config cucumber.config.js --tags "@page and not @skip"
|
||||
|
||||
# 运行特定测试
|
||||
pnpm exec cucumber-js --config cucumber.config.js --tags "@PAGE-CREATE-001"
|
||||
|
||||
# 调试模式(显示浏览器)
|
||||
HEADLESS=false pnpm exec cucumber-js --config cucumber.config.js --tags "@PAGE-TITLE-001"
|
||||
```
|
||||
|
||||
## 状态说明
|
||||
|
||||
- ✅ 已完成 - 测试用例已实现并通过
|
||||
- ⏳ 待实现 - 功能已识别,测试待编写
|
||||
- 🚧 进行中 - 测试用例正在开发中或需要修复
|
||||
|
||||
## 已知问题
|
||||
|
||||
1. **代码块测试 (@PAGE-SLASH-005)**: 斜杠命令 `/codeblock` 触发不稳定,已标记 @skip
|
||||
|
||||
## 更新记录
|
||||
|
||||
| 日期 | 更新内容 |
|
||||
| ---------- | ----------------------------------------- |
|
||||
| 2025-01-12 | 初始化功能清单,完成侧边栏 CRUD |
|
||||
| 2025-01-12 | 完成编辑器标题 / Emoji 测试 (6 scenarios) |
|
||||
| 2025-01-12 | 完成富文本编辑测试 (8 scenarios,1 跳过) |
|
||||
62
e2e/src/features/page/crud.feature
Normal file
62
e2e/src/features/page/crud.feature
Normal file
@@ -0,0 +1,62 @@
|
||||
@journey @P0 @page
|
||||
Feature: Page 文稿 CRUD 操作
|
||||
|
||||
作为用户,我希望能够创建、编辑和管理文稿页面,
|
||||
以便记录和组织我的笔记和文档
|
||||
|
||||
Background:
|
||||
Given 用户已登录系统
|
||||
|
||||
# ============================================
|
||||
# 创建
|
||||
# ============================================
|
||||
|
||||
@PAGE-CREATE-001
|
||||
Scenario: 创建新文稿
|
||||
Given 用户在 Page 页面
|
||||
When 用户点击新建文稿按钮
|
||||
Then 应该创建一个新的文稿
|
||||
And 文稿列表中应该显示新文稿
|
||||
|
||||
# ============================================
|
||||
# 重命名
|
||||
# ============================================
|
||||
|
||||
@PAGE-RENAME-001
|
||||
Scenario: 通过右键菜单重命名文稿
|
||||
Given 用户在 Page 页面有一个文稿
|
||||
When 用户右键点击该文稿
|
||||
And 用户在菜单中选择重命名
|
||||
And 用户输入新的文稿名称 "My Renamed Page"
|
||||
Then 该文稿名称应该更新为 "My Renamed Page"
|
||||
|
||||
@PAGE-RENAME-002 @P1
|
||||
Scenario: 重命名文稿后按 Enter 确认
|
||||
Given 用户在 Page 页面有一个文稿
|
||||
When 用户右键点击该文稿
|
||||
And 用户在菜单中选择重命名
|
||||
And 用户输入新的文稿名称 "Enter Confirmed Page" 并按 Enter
|
||||
Then 该文稿名称应该更新为 "Enter Confirmed Page"
|
||||
|
||||
# ============================================
|
||||
# 复制
|
||||
# ============================================
|
||||
|
||||
@PAGE-DUPLICATE-001 @P1
|
||||
Scenario: 复制文稿
|
||||
Given 用户在 Page 页面有一个文稿 "Original Page"
|
||||
When 用户右键点击该文稿
|
||||
And 用户在菜单中选择复制
|
||||
Then 文稿列表中应该出现 "Original Page (Copy)"
|
||||
|
||||
# ============================================
|
||||
# 删除
|
||||
# ============================================
|
||||
|
||||
@PAGE-DELETE-001
|
||||
Scenario: 删除文稿
|
||||
Given 用户在 Page 页面有一个文稿
|
||||
When 用户右键点击该文稿
|
||||
And 用户在菜单中选择删除
|
||||
And 用户在弹窗中确认删除
|
||||
Then 该文稿应该从列表中移除
|
||||
93
e2e/src/features/page/editor-content.feature
Normal file
93
e2e/src/features/page/editor-content.feature
Normal file
@@ -0,0 +1,93 @@
|
||||
@journey @P0 @page
|
||||
Feature: Page 编辑器富文本编辑
|
||||
|
||||
作为用户,我希望能够使用富文本编辑器编写内容,
|
||||
以便创建格式丰富的文档
|
||||
|
||||
Background:
|
||||
Given 用户已登录系统
|
||||
|
||||
# ============================================
|
||||
# 基础文本编辑
|
||||
# ============================================
|
||||
|
||||
@PAGE-CONTENT-001
|
||||
Scenario: 输入基础文本内容
|
||||
Given 用户打开一个文稿编辑器
|
||||
When 用户点击编辑器内容区域
|
||||
And 用户输入文本 "这是一段测试内容"
|
||||
Then 编辑器应该显示输入的文本
|
||||
|
||||
@PAGE-CONTENT-002 @P1
|
||||
Scenario: 编辑已有内容
|
||||
Given 用户打开一个文稿编辑器
|
||||
When 用户在编辑器中输入内容 "原始内容"
|
||||
And 用户选中所有内容
|
||||
And 用户输入文本 "修改后的内容"
|
||||
Then 编辑器应该显示 "修改后的内容"
|
||||
|
||||
# ============================================
|
||||
# 斜杠命令
|
||||
# ============================================
|
||||
|
||||
@PAGE-SLASH-001 @P1
|
||||
Scenario: 使用斜杠命令打开菜单
|
||||
Given 用户打开一个文稿编辑器
|
||||
When 用户点击编辑器内容区域
|
||||
And 用户输入斜杠 "/"
|
||||
Then 应该显示斜杠命令菜单
|
||||
|
||||
@PAGE-SLASH-002 @P1
|
||||
Scenario: 通过斜杠命令插入一级标题
|
||||
Given 用户打开一个文稿编辑器
|
||||
When 用户点击编辑器内容区域
|
||||
And 用户输入斜杠命令 "/h1"
|
||||
And 用户按下 Enter 键
|
||||
And 用户输入文本 "一级标题内容"
|
||||
Then 编辑器应该包含一级标题
|
||||
|
||||
@PAGE-SLASH-003 @P1
|
||||
Scenario: 通过斜杠命令插入无序列表
|
||||
Given 用户打开一个文稿编辑器
|
||||
When 用户点击编辑器内容区域
|
||||
And 用户输入斜杠命令 "/ul"
|
||||
And 用户按下 Enter 键
|
||||
And 用户输入文本 "列表项一"
|
||||
Then 编辑器应该包含无序列表
|
||||
|
||||
@PAGE-SLASH-004 @P2
|
||||
Scenario: 通过斜杠命令插入任务列表
|
||||
Given 用户打开一个文稿编辑器
|
||||
When 用户点击编辑器内容区域
|
||||
And 用户输入斜杠命令 "/tl"
|
||||
And 用户按下 Enter 键
|
||||
And 用户输入文本 "待办事项"
|
||||
Then 编辑器应该包含任务列表
|
||||
|
||||
@PAGE-SLASH-005 @P2 @skip
|
||||
Scenario: 通过斜杠命令插入代码块
|
||||
Given 用户打开一个文稿编辑器
|
||||
When 用户点击编辑器内容区域
|
||||
And 用户输入斜杠命令 "/codeblock"
|
||||
And 用户按下 Enter 键
|
||||
Then 编辑器应该包含代码块
|
||||
|
||||
# ============================================
|
||||
# 文本格式化
|
||||
# ============================================
|
||||
|
||||
@PAGE-FORMAT-001 @P1
|
||||
Scenario: 使用快捷键加粗文本
|
||||
Given 用户打开一个文稿编辑器
|
||||
When 用户在编辑器中输入内容 "加粗文本"
|
||||
And 用户选中所有内容
|
||||
And 用户按下快捷键 "Meta+B"
|
||||
Then 选中的文本应该被加粗
|
||||
|
||||
@PAGE-FORMAT-002 @P2
|
||||
Scenario: 使用快捷键斜体文本
|
||||
Given 用户打开一个文稿编辑器
|
||||
When 用户在编辑器中输入内容 "斜体文本"
|
||||
And 用户选中所有内容
|
||||
And 用户按下快捷键 "Meta+I"
|
||||
Then 选中的文本应该变为斜体
|
||||
60
e2e/src/features/page/editor-meta.feature
Normal file
60
e2e/src/features/page/editor-meta.feature
Normal file
@@ -0,0 +1,60 @@
|
||||
@journey @P0 @page
|
||||
Feature: Page 编辑器元数据编辑
|
||||
|
||||
作为用户,我希望能够编辑文稿的标题和图标,
|
||||
以便更好地组织和识别我的文档
|
||||
|
||||
Background:
|
||||
Given 用户已登录系统
|
||||
|
||||
# ============================================
|
||||
# 标题编辑
|
||||
# ============================================
|
||||
|
||||
@PAGE-TITLE-001
|
||||
Scenario: 编辑文稿标题
|
||||
Given 用户打开一个文稿编辑器
|
||||
When 用户点击标题输入框
|
||||
And 用户输入标题 "我的测试文稿"
|
||||
And 用户按下 Enter 键
|
||||
Then 文稿标题应该更新为 "我的测试文稿"
|
||||
|
||||
@PAGE-TITLE-002 @P1
|
||||
Scenario: 编辑标题后点击其他区域保存
|
||||
Given 用户打开一个文稿编辑器
|
||||
When 用户点击标题输入框
|
||||
And 用户输入标题 "Click Away Title"
|
||||
And 用户点击编辑器内容区域
|
||||
Then 文稿标题应该更新为 "Click Away Title"
|
||||
|
||||
@PAGE-TITLE-003 @P1
|
||||
Scenario: 清空标题后显示占位符
|
||||
Given 用户打开一个文稿编辑器
|
||||
When 用户点击标题输入框
|
||||
And 用户清空标题内容
|
||||
Then 应该显示标题占位符
|
||||
|
||||
# ============================================
|
||||
# Emoji 图标
|
||||
# ============================================
|
||||
|
||||
@PAGE-EMOJI-001 @P1
|
||||
Scenario: 为文稿添加 Emoji 图标
|
||||
Given 用户打开一个文稿编辑器
|
||||
When 用户点击选择图标按钮
|
||||
And 用户选择一个 Emoji
|
||||
Then 文稿应该显示所选的 Emoji 图标
|
||||
|
||||
@PAGE-EMOJI-002 @P1
|
||||
Scenario: 更换文稿的 Emoji 图标
|
||||
Given 用户打开一个带有 Emoji 的文稿
|
||||
When 用户点击已有的 Emoji 图标
|
||||
And 用户选择另一个 Emoji
|
||||
Then 文稿图标应该更新为新的 Emoji
|
||||
|
||||
@PAGE-EMOJI-003 @P2
|
||||
Scenario: 删除文稿的 Emoji 图标
|
||||
Given 用户打开一个带有 Emoji 的文稿
|
||||
When 用户点击已有的 Emoji 图标
|
||||
And 用户点击删除图标按钮
|
||||
Then 文稿不应该显示 Emoji 图标
|
||||
@@ -7,7 +7,7 @@ import { Given, Then, When } from '@cucumber/cucumber';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { llmMockManager, presetResponses } from '../../mocks/llm';
|
||||
import { CustomWorld } from '../../support/world';
|
||||
import { CustomWorld, WAIT_TIMEOUT } from '../../support/world';
|
||||
|
||||
// ============================================
|
||||
// Given Steps
|
||||
@@ -29,19 +29,19 @@ Given('用户进入 Lobe AI 对话页面', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 导航到首页...');
|
||||
// Navigate to home page first
|
||||
await this.page.goto('/');
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10_000 });
|
||||
await this.page.waitForLoadState('networkidle', { timeout: WAIT_TIMEOUT });
|
||||
|
||||
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: 10_000 });
|
||||
await expect(lobeAIAgent).toBeVisible({ timeout: WAIT_TIMEOUT });
|
||||
|
||||
console.log(' 📍 Step: 点击 Lobe AI...');
|
||||
await lobeAIAgent.click();
|
||||
|
||||
console.log(' 📍 Step: 等待聊天界面加载...');
|
||||
// Wait for the chat interface to be ready
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 10_000 });
|
||||
await this.page.waitForLoadState('networkidle', { timeout: WAIT_TIMEOUT });
|
||||
|
||||
console.log(' 📍 Step: 查找输入框...');
|
||||
// The input is a rich text editor with contenteditable
|
||||
|
||||
@@ -10,288 +10,7 @@ import { Given, Then, When } from '@cucumber/cucumber';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { TEST_USER } from '../../support/seedTestUser';
|
||||
import { CustomWorld } from '../../support/world';
|
||||
|
||||
/**
|
||||
* Create a test agent directly in database
|
||||
*/
|
||||
async function createTestAgent(title: string = 'Test Agent'): Promise<string> {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error('DATABASE_URL not set');
|
||||
|
||||
const { default: pg } = await import('pg');
|
||||
const client = new pg.Client({ connectionString: databaseUrl });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const agentId = `agent_e2e_test_${Date.now()}`;
|
||||
const slug = `test-agent-${Date.now()}`;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO agents (id, slug, title, user_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $5)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[agentId, slug, title, TEST_USER.id, now],
|
||||
);
|
||||
|
||||
console.log(` 📍 Created test agent in DB: ${agentId}`);
|
||||
return agentId;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Given Steps
|
||||
// ============================================
|
||||
|
||||
Given('用户在 Home 页面有一个 Agent', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 在数据库中创建测试 Agent...');
|
||||
const agentId = await createTestAgent('E2E Test Agent');
|
||||
this.testContext.createdAgentId = agentId;
|
||||
|
||||
console.log(' 📍 Step: 导航到 Home 页面...');
|
||||
await this.page.goto('/');
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
console.log(' 📍 Step: 查找新创建的 Agent...');
|
||||
// Look for the newly created agent in the sidebar by its specific ID
|
||||
const agentItem = this.page.locator(`a[href="/agent/${agentId}"]`).first();
|
||||
await expect(agentItem).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Store agent reference for later use
|
||||
const agentLabel = await agentItem.getAttribute('aria-label');
|
||||
this.testContext.targetItemId = agentLabel || agentId;
|
||||
this.testContext.targetItemSelector = `a[href="/agent/${agentId}"]`;
|
||||
this.testContext.targetType = 'agent';
|
||||
|
||||
console.log(` ✅ 找到 Agent: ${agentLabel}, id: ${agentId}`);
|
||||
});
|
||||
|
||||
Given('该 Agent 未被置顶', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 检查 Agent 未被置顶...');
|
||||
// Check if the agent has a pin icon - if so, unpin it first
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
const pinIcon = targetItem.locator('svg.lucide-pin');
|
||||
|
||||
if ((await pinIcon.count()) > 0) {
|
||||
// Unpin it first
|
||||
await targetItem.click({ button: 'right' });
|
||||
await this.page.waitForTimeout(300);
|
||||
const unpinOption = this.page.getByRole('menuitem', { name: /取消置顶|Unpin/i });
|
||||
if ((await unpinOption.count()) > 0) {
|
||||
await unpinOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
// Close menu if still open
|
||||
await this.page.click('body', { position: { x: 10, y: 10 } });
|
||||
}
|
||||
|
||||
console.log(' ✅ Agent 未被置顶');
|
||||
});
|
||||
|
||||
Given('该 Agent 已被置顶', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 确保 Agent 已被置顶...');
|
||||
// Check if the agent has a pin icon - if not, pin it first
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
const pinIcon = targetItem.locator('svg.lucide-pin');
|
||||
|
||||
if ((await pinIcon.count()) === 0) {
|
||||
// Pin it first
|
||||
await targetItem.click({ button: 'right' });
|
||||
await this.page.waitForTimeout(300);
|
||||
const pinOption = this.page.getByRole('menuitem', { name: /置顶|Pin/i });
|
||||
if ((await pinOption.count()) > 0) {
|
||||
await pinOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
// Close menu if still open
|
||||
await this.page.click('body', { position: { x: 10, y: 10 } });
|
||||
}
|
||||
|
||||
console.log(' ✅ Agent 已被置顶');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// When Steps
|
||||
// ============================================
|
||||
|
||||
When('用户右键点击该 Agent', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 右键点击 Agent...');
|
||||
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
|
||||
// Right-click on the inner content (the NavItem Block component)
|
||||
// The ContextMenuTrigger wraps the Block, not the Link
|
||||
const innerBlock = targetItem.locator('> div').first();
|
||||
if ((await innerBlock.count()) > 0) {
|
||||
await innerBlock.click({ button: 'right' });
|
||||
} else {
|
||||
await targetItem.click({ button: 'right' });
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(800);
|
||||
|
||||
// Debug: check what menus are visible
|
||||
const menuItems = await this.page.locator('[role="menuitem"]').count();
|
||||
console.log(` 📍 Debug: Found ${menuItems} menu items after right-click`);
|
||||
|
||||
console.log(' ✅ 已右键点击 Agent');
|
||||
});
|
||||
|
||||
When('用户悬停在该 Agent 上', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 悬停在 Agent 上...');
|
||||
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
await targetItem.hover();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
console.log(' ✅ 已悬停在 Agent 上');
|
||||
});
|
||||
|
||||
When('用户点击更多操作按钮', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 点击更多操作按钮...');
|
||||
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
const moreButton = targetItem.locator('svg.lucide-ellipsis, svg.lucide-more-horizontal').first();
|
||||
|
||||
if ((await moreButton.count()) > 0) {
|
||||
await moreButton.click();
|
||||
} else {
|
||||
// Fallback: find any visible ellipsis button
|
||||
const allEllipsis = this.page.locator('svg.lucide-ellipsis');
|
||||
for (let i = 0; i < (await allEllipsis.count()); i++) {
|
||||
const ellipsis = allEllipsis.nth(i);
|
||||
if (await ellipsis.isVisible()) {
|
||||
await ellipsis.click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
console.log(' ✅ 已点击更多操作按钮');
|
||||
});
|
||||
|
||||
When('用户在菜单中选择重命名', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 选择重命名选项...');
|
||||
|
||||
const renameOption = this.page.getByRole('menuitem', { name: /^(Rename|重命名)$/i });
|
||||
await expect(renameOption).toBeVisible({ timeout: 5000 });
|
||||
await renameOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
console.log(' ✅ 已选择重命名选项');
|
||||
});
|
||||
|
||||
When('用户在菜单中选择置顶', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 选择置顶选项...');
|
||||
|
||||
const pinOption = this.page.getByRole('menuitem', { name: /^(Pin|置顶)$/i });
|
||||
await expect(pinOption).toBeVisible({ timeout: 5000 });
|
||||
await pinOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
console.log(' ✅ 已选择置顶选项');
|
||||
});
|
||||
|
||||
When('用户在菜单中选择取消置顶', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 选择取消置顶选项...');
|
||||
|
||||
const unpinOption = this.page.getByRole('menuitem', { name: /^(Unpin|取消置顶)$/i });
|
||||
await expect(unpinOption).toBeVisible({ timeout: 5000 });
|
||||
await unpinOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
console.log(' ✅ 已选择取消置顶选项');
|
||||
});
|
||||
|
||||
When('用户在菜单中选择删除', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 选择删除选项...');
|
||||
|
||||
const deleteOption = this.page.getByRole('menuitem', { name: /^(Delete|删除)$/i });
|
||||
await expect(deleteOption).toBeVisible({ timeout: 5000 });
|
||||
await deleteOption.click();
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
console.log(' ✅ 已选择删除选项');
|
||||
});
|
||||
|
||||
When('用户在弹窗中确认删除', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 确认删除...');
|
||||
|
||||
const confirmButton = this.page.locator('.ant-modal-confirm-btns button.ant-btn-dangerous');
|
||||
await expect(confirmButton).toBeVisible({ timeout: 5000 });
|
||||
await confirmButton.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
console.log(' ✅ 已确认删除');
|
||||
});
|
||||
|
||||
When('用户输入新的名称 {string}', async function (this: CustomWorld, newName: string) {
|
||||
console.log(` 📍 Step: 输入新名称 "${newName}"...`);
|
||||
await inputNewName.call(this, newName, false);
|
||||
});
|
||||
|
||||
When(
|
||||
'用户输入新的名称 {string} 并按 Enter',
|
||||
async function (this: CustomWorld, newName: string) {
|
||||
console.log(` 📍 Step: 输入新名称 "${newName}" 并按 Enter...`);
|
||||
await inputNewName.call(this, newName, true);
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Then Steps
|
||||
// ============================================
|
||||
|
||||
Then('该项名称应该更新为 {string}', async function (this: CustomWorld, expectedName: string) {
|
||||
console.log(` 📍 Step: 验证名称为 "${expectedName}"...`);
|
||||
|
||||
await this.page.waitForTimeout(1000);
|
||||
const renamedItem = this.page.getByText(expectedName, { exact: true }).first();
|
||||
await expect(renamedItem).toBeVisible({ timeout: 5000 });
|
||||
|
||||
console.log(` ✅ 名称已更新为 "${expectedName}"`);
|
||||
});
|
||||
|
||||
Then('Agent 应该显示置顶图标', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证显示置顶图标...');
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
const pinIcon = targetItem.locator('svg.lucide-pin');
|
||||
await expect(pinIcon).toBeVisible({ timeout: 5000 });
|
||||
|
||||
console.log(' ✅ 置顶图标已显示');
|
||||
});
|
||||
|
||||
Then('Agent 不应该显示置顶图标', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证不显示置顶图标...');
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
const pinIcon = targetItem.locator('svg.lucide-pin');
|
||||
await expect(pinIcon).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
console.log(' ✅ 置顶图标未显示');
|
||||
});
|
||||
|
||||
Then('Agent 应该从列表中移除', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证 Agent 已移除...');
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
if (this.testContext.targetItemId) {
|
||||
const deletedItem = this.page.locator(`a[aria-label="${this.testContext.targetItemId}"]`);
|
||||
await expect(deletedItem).not.toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
console.log(' ✅ Agent 已从列表中移除');
|
||||
});
|
||||
import { CustomWorld, WAIT_TIMEOUT } from '../../support/world';
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
@@ -371,3 +90,281 @@ async function inputNewName(
|
||||
await this.page.waitForTimeout(1000);
|
||||
console.log(` ✅ 已输入新名称 "${newName}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test agent directly in database
|
||||
*/
|
||||
async function createTestAgent(title: string = 'Test Agent'): Promise<string> {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error('DATABASE_URL not set');
|
||||
|
||||
const { default: pg } = await import('pg');
|
||||
const client = new pg.Client({ connectionString: databaseUrl });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const agentId = `agent_e2e_test_${Date.now()}`;
|
||||
const slug = `test-agent-${Date.now()}`;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO agents (id, slug, title, user_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $5)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[agentId, slug, title, TEST_USER.id, now],
|
||||
);
|
||||
|
||||
console.log(` 📍 Created test agent in DB: ${agentId}`);
|
||||
return agentId;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Given Steps
|
||||
// ============================================
|
||||
|
||||
Given('用户在 Home 页面有一个 Agent', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 在数据库中创建测试 Agent...');
|
||||
const agentId = await createTestAgent('E2E Test Agent');
|
||||
this.testContext.createdAgentId = agentId;
|
||||
|
||||
console.log(' 📍 Step: 导航到 Home 页面...');
|
||||
await this.page.goto('/');
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
console.log(' 📍 Step: 查找新创建的 Agent...');
|
||||
// Look for the newly created agent in the sidebar by its specific ID
|
||||
const agentItem = this.page.locator(`a[href="/agent/${agentId}"]`).first();
|
||||
await expect(agentItem).toBeVisible({ timeout: WAIT_TIMEOUT });
|
||||
|
||||
// Store agent reference for later use
|
||||
const agentLabel = await agentItem.getAttribute('aria-label');
|
||||
this.testContext.targetItemId = agentLabel || agentId;
|
||||
this.testContext.targetItemSelector = `a[href="/agent/${agentId}"]`;
|
||||
this.testContext.targetType = 'agent';
|
||||
|
||||
console.log(` ✅ 找到 Agent: ${agentLabel}, id: ${agentId}`);
|
||||
});
|
||||
|
||||
Given('该 Agent 未被置顶', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 检查 Agent 未被置顶...');
|
||||
// Check if the agent has a pin icon - if so, unpin it first
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
const pinIcon = targetItem.locator('svg.lucide-pin');
|
||||
|
||||
if ((await pinIcon.count()) > 0) {
|
||||
// Unpin it first
|
||||
await targetItem.click({ button: 'right' });
|
||||
await this.page.waitForTimeout(300);
|
||||
const unpinOption = this.page.getByRole('menuitem', { name: /取消置顶|unpin/i });
|
||||
if ((await unpinOption.count()) > 0) {
|
||||
await unpinOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
// Close menu if still open
|
||||
await this.page.click('body', { position: { x: 10, y: 10 } });
|
||||
}
|
||||
|
||||
console.log(' ✅ Agent 未被置顶');
|
||||
});
|
||||
|
||||
Given('该 Agent 已被置顶', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 确保 Agent 已被置顶...');
|
||||
// Check if the agent has a pin icon - if not, pin it first
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
const pinIcon = targetItem.locator('svg.lucide-pin');
|
||||
|
||||
if ((await pinIcon.count()) === 0) {
|
||||
// Pin it first
|
||||
await targetItem.click({ button: 'right' });
|
||||
await this.page.waitForTimeout(300);
|
||||
const pinOption = this.page.getByRole('menuitem', { name: /置顶|pin/i });
|
||||
if ((await pinOption.count()) > 0) {
|
||||
await pinOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
// Close menu if still open
|
||||
await this.page.click('body', { position: { x: 10, y: 10 } });
|
||||
}
|
||||
|
||||
console.log(' ✅ Agent 已被置顶');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// When Steps
|
||||
// ============================================
|
||||
|
||||
When('用户右键点击该 Agent', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 右键点击 Agent...');
|
||||
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
|
||||
// Right-click on the inner content (the NavItem Block component)
|
||||
// The ContextMenuTrigger wraps the Block, not the Link
|
||||
const innerBlock = targetItem.locator('> div').first();
|
||||
if ((await innerBlock.count()) > 0) {
|
||||
await innerBlock.click({ button: 'right' });
|
||||
} else {
|
||||
await targetItem.click({ button: 'right' });
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(800);
|
||||
|
||||
// Debug: check what menus are visible
|
||||
const menuItems = await this.page.locator('[role="menuitem"]').count();
|
||||
console.log(` 📍 Debug: Found ${menuItems} menu items after right-click`);
|
||||
|
||||
console.log(' ✅ 已右键点击 Agent');
|
||||
});
|
||||
|
||||
When('用户悬停在该 Agent 上', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 悬停在 Agent 上...');
|
||||
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
await targetItem.hover();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
console.log(' ✅ 已悬停在 Agent 上');
|
||||
});
|
||||
|
||||
When('用户点击更多操作按钮', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 点击更多操作按钮...');
|
||||
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
const moreButton = targetItem.locator('svg.lucide-ellipsis, svg.lucide-more-horizontal').first();
|
||||
|
||||
if ((await moreButton.count()) > 0) {
|
||||
await moreButton.click();
|
||||
} else {
|
||||
// Fallback: find any visible ellipsis button
|
||||
const allEllipsis = this.page.locator('svg.lucide-ellipsis');
|
||||
for (let i = 0; i < (await allEllipsis.count()); i++) {
|
||||
const ellipsis = allEllipsis.nth(i);
|
||||
if (await ellipsis.isVisible()) {
|
||||
await ellipsis.click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
console.log(' ✅ 已点击更多操作按钮');
|
||||
});
|
||||
|
||||
When('用户在菜单中选择重命名', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 选择重命名选项...');
|
||||
|
||||
const renameOption = this.page.getByRole('menuitem', { name: /^(rename|重命名)$/i });
|
||||
await expect(renameOption).toBeVisible({ timeout: 5000 });
|
||||
await renameOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
console.log(' ✅ 已选择重命名选项');
|
||||
});
|
||||
|
||||
When('用户在菜单中选择置顶', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 选择置顶选项...');
|
||||
|
||||
const pinOption = this.page.getByRole('menuitem', { name: /^(pin|置顶)$/i });
|
||||
await expect(pinOption).toBeVisible({ timeout: 5000 });
|
||||
await pinOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
console.log(' ✅ 已选择置顶选项');
|
||||
});
|
||||
|
||||
When('用户在菜单中选择取消置顶', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 选择取消置顶选项...');
|
||||
|
||||
const unpinOption = this.page.getByRole('menuitem', { name: /^(unpin|取消置顶)$/i });
|
||||
await expect(unpinOption).toBeVisible({ timeout: 5000 });
|
||||
await unpinOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
console.log(' ✅ 已选择取消置顶选项');
|
||||
});
|
||||
|
||||
When('用户在菜单中选择删除', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 选择删除选项...');
|
||||
|
||||
const deleteOption = this.page.getByRole('menuitem', { name: /^(delete|删除)$/i });
|
||||
await expect(deleteOption).toBeVisible({ timeout: 5000 });
|
||||
await deleteOption.click();
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
console.log(' ✅ 已选择删除选项');
|
||||
});
|
||||
|
||||
When('用户在弹窗中确认删除', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 确认删除...');
|
||||
|
||||
const confirmButton = this.page.locator('.ant-modal-confirm-btns button.ant-btn-dangerous');
|
||||
await expect(confirmButton).toBeVisible({ timeout: 5000 });
|
||||
await confirmButton.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
console.log(' ✅ 已确认删除');
|
||||
});
|
||||
|
||||
When('用户输入新的名称 {string}', async function (this: CustomWorld, newName: string) {
|
||||
console.log(` 📍 Step: 输入新名称 "${newName}"...`);
|
||||
await inputNewName.call(this, newName, false);
|
||||
});
|
||||
|
||||
When('用户输入新的名称 {string} 并按 Enter', async function (this: CustomWorld, newName: string) {
|
||||
console.log(` 📍 Step: 输入新名称 "${newName}" 并按 Enter...`);
|
||||
await inputNewName.call(this, newName, true);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Then Steps
|
||||
// ============================================
|
||||
|
||||
Then('该项名称应该更新为 {string}', async function (this: CustomWorld, expectedName: string) {
|
||||
console.log(` 📍 Step: 验证名称为 "${expectedName}"...`);
|
||||
|
||||
await this.page.waitForTimeout(1000);
|
||||
const renamedItem = this.page.getByText(expectedName, { exact: true }).first();
|
||||
await expect(renamedItem).toBeVisible({ timeout: 5000 });
|
||||
|
||||
console.log(` ✅ 名称已更新为 "${expectedName}"`);
|
||||
});
|
||||
|
||||
Then('Agent 应该显示置顶图标', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证显示置顶图标...');
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
const pinIcon = targetItem.locator('svg.lucide-pin');
|
||||
await expect(pinIcon).toBeVisible({ timeout: 5000 });
|
||||
|
||||
console.log(' ✅ 置顶图标已显示');
|
||||
});
|
||||
|
||||
Then('Agent 不应该显示置顶图标', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证不显示置顶图标...');
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
|
||||
const pinIcon = targetItem.locator('svg.lucide-pin');
|
||||
await expect(pinIcon).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
console.log(' ✅ 置顶图标未显示');
|
||||
});
|
||||
|
||||
Then('Agent 应该从列表中移除', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证 Agent 已移除...');
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
if (this.testContext.targetItemId) {
|
||||
const deletedItem = this.page.locator(`a[aria-label="${this.testContext.targetItemId}"]`);
|
||||
await expect(deletedItem).not.toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
console.log(' ✅ Agent 已从列表中移除');
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Given, Then, When } from '@cucumber/cucumber';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { TEST_USER } from '../../support/seedTestUser';
|
||||
import { CustomWorld } from '../../support/world';
|
||||
import { CustomWorld, WAIT_TIMEOUT } from '../../support/world';
|
||||
|
||||
/**
|
||||
* Create a test chat group directly in database
|
||||
@@ -58,7 +58,7 @@ Given('用户在 Home 页面有一个 Agent Group', async function (this: Custom
|
||||
|
||||
console.log(' 📍 Step: 查找新创建的 Agent Group...');
|
||||
const groupItem = this.page.locator(`a[href="/group/${groupId}"]`).first();
|
||||
await expect(groupItem).toBeVisible({ timeout: 10_000 });
|
||||
await expect(groupItem).toBeVisible({ timeout: WAIT_TIMEOUT });
|
||||
|
||||
const groupLabel = await groupItem.getAttribute('aria-label');
|
||||
this.testContext.targetItemId = groupLabel || groupId;
|
||||
@@ -76,7 +76,7 @@ Given('该 Agent Group 未被置顶', async function (this: CustomWorld) {
|
||||
if ((await pinIcon.count()) > 0) {
|
||||
await targetItem.click({ button: 'right' });
|
||||
await this.page.waitForTimeout(300);
|
||||
const unpinOption = this.page.getByRole('menuitem', { name: /取消置顶|Unpin/i });
|
||||
const unpinOption = this.page.getByRole('menuitem', { name: /取消置顶|unpin/i });
|
||||
if ((await unpinOption.count()) > 0) {
|
||||
await unpinOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
@@ -95,7 +95,7 @@ Given('该 Agent Group 已被置顶', async function (this: CustomWorld) {
|
||||
if ((await pinIcon.count()) === 0) {
|
||||
await targetItem.click({ button: 'right' });
|
||||
await this.page.waitForTimeout(300);
|
||||
const pinOption = this.page.getByRole('menuitem', { name: /置顶|Pin/i });
|
||||
const pinOption = this.page.getByRole('menuitem', { name: /置顶|pin/i });
|
||||
if ((await pinOption.count()) > 0) {
|
||||
await pinOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
@@ -85,6 +85,7 @@ Before(async function (this: CustomWorld, { pickle }) {
|
||||
tag.name.startsWith('@COMMUNITY-') ||
|
||||
tag.name.startsWith('@AGENT-') ||
|
||||
tag.name.startsWith('@HOME-') ||
|
||||
tag.name.startsWith('@PAGE-') ||
|
||||
tag.name.startsWith('@ROUTES-'),
|
||||
);
|
||||
console.log(`\n📝 Running: ${pickle.name}${testId ? ` (${testId.name.replace('@', '')})` : ''}`);
|
||||
@@ -106,6 +107,7 @@ After(async function (this: CustomWorld, { pickle, result }) {
|
||||
tag.name.startsWith('@COMMUNITY-') ||
|
||||
tag.name.startsWith('@AGENT-') ||
|
||||
tag.name.startsWith('@HOME-') ||
|
||||
tag.name.startsWith('@PAGE-') ||
|
||||
tag.name.startsWith('@ROUTES-'),
|
||||
)
|
||||
?.name.replace('@', '');
|
||||
|
||||
344
e2e/src/steps/page/editor-content.steps.ts
Normal file
344
e2e/src/steps/page/editor-content.steps.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Page Editor Content Steps
|
||||
*
|
||||
* Step definitions for Page editor rich text editing E2E tests
|
||||
*/
|
||||
import { Then, When } from '@cucumber/cucumber';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { CustomWorld } from '../../support/world';
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get the contenteditable editor element
|
||||
*/
|
||||
async function getEditor(world: CustomWorld) {
|
||||
const editor = world.page.locator('[contenteditable="true"]').first();
|
||||
await expect(editor).toBeVisible({ timeout: 5000 });
|
||||
return editor;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// When Steps - Basic Text
|
||||
// ============================================
|
||||
|
||||
When('用户点击编辑器内容区域', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 点击编辑器内容区域...');
|
||||
|
||||
const editorContent = this.page.locator('[contenteditable="true"]').first();
|
||||
if ((await editorContent.count()) > 0) {
|
||||
await editorContent.click();
|
||||
} else {
|
||||
// Fallback: click somewhere else
|
||||
await this.page.click('body', { position: { x: 400, y: 400 } });
|
||||
}
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
console.log(' ✅ 已点击编辑器内容区域');
|
||||
});
|
||||
|
||||
When('用户按下 Enter 键', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 按下 Enter 键...');
|
||||
|
||||
await this.page.keyboard.press('Enter');
|
||||
// Wait for debounce save (1000ms) + buffer
|
||||
await this.page.waitForTimeout(1500);
|
||||
|
||||
console.log(' ✅ 已按下 Enter 键');
|
||||
});
|
||||
|
||||
When('用户输入文本 {string}', async function (this: CustomWorld, text: string) {
|
||||
console.log(` 📍 Step: 输入文本 "${text}"...`);
|
||||
|
||||
await this.page.keyboard.type(text, { delay: 30 });
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
// Store for later verification
|
||||
this.testContext.inputText = text;
|
||||
|
||||
console.log(` ✅ 已输入文本 "${text}"`);
|
||||
});
|
||||
|
||||
When('用户在编辑器中输入内容 {string}', async function (this: CustomWorld, content: string) {
|
||||
console.log(` 📍 Step: 在编辑器中输入内容 "${content}"...`);
|
||||
|
||||
const editor = await getEditor(this);
|
||||
await editor.click();
|
||||
await this.page.waitForTimeout(300);
|
||||
await this.page.keyboard.type(content, { delay: 30 });
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
this.testContext.inputText = content;
|
||||
|
||||
console.log(` ✅ 已输入内容 "${content}"`);
|
||||
});
|
||||
|
||||
When('用户选中所有内容', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 选中所有内容...');
|
||||
|
||||
await this.page.keyboard.press(`${this.modKey}+A`);
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
console.log(' ✅ 已选中所有内容');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// When Steps - Slash Commands
|
||||
// ============================================
|
||||
|
||||
When('用户输入斜杠 {string}', async function (this: CustomWorld, slash: string) {
|
||||
console.log(` 📍 Step: 输入斜杠 "${slash}"...`);
|
||||
|
||||
await this.page.keyboard.type(slash, { delay: 50 });
|
||||
// Wait for slash menu to appear
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
console.log(` ✅ 已输入斜杠 "${slash}"`);
|
||||
});
|
||||
|
||||
When('用户输入斜杠命令 {string}', async function (this: CustomWorld, command: string) {
|
||||
console.log(` 📍 Step: 输入斜杠命令 "${command}"...`);
|
||||
|
||||
// The command format is "/shortcut" (e.g., "/h1", "/codeblock")
|
||||
// First type the slash and wait for menu
|
||||
await this.page.keyboard.type('/', { delay: 100 });
|
||||
await this.page.waitForTimeout(800); // Wait for slash menu to appear
|
||||
|
||||
// Then type the rest of the command (without the leading /)
|
||||
const shortcut = command.startsWith('/') ? command.slice(1) : command;
|
||||
await this.page.keyboard.type(shortcut, { delay: 80 });
|
||||
await this.page.waitForTimeout(500); // Wait for menu to filter
|
||||
|
||||
console.log(` ✅ 已输入斜杠命令 "${command}"`);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// When Steps - Formatting
|
||||
// ============================================
|
||||
|
||||
When('用户按下快捷键 {string}', async function (this: CustomWorld, shortcut: string) {
|
||||
console.log(` 📍 Step: 按下快捷键 "${shortcut}"...`);
|
||||
|
||||
// Convert Meta to platform-specific modifier key for cross-platform support
|
||||
const platformShortcut = shortcut.replaceAll('Meta', this.modKey);
|
||||
await this.page.keyboard.press(platformShortcut);
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
console.log(` ✅ 已按下快捷键 "${platformShortcut}"`);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Then Steps - Basic Text
|
||||
// ============================================
|
||||
|
||||
Then('编辑器应该显示输入的文本', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证编辑器显示输入的文本...');
|
||||
|
||||
const editor = await getEditor(this);
|
||||
const text = this.testContext.inputText;
|
||||
|
||||
// Check if the text is visible in the editor
|
||||
const editorText = await editor.textContent();
|
||||
expect(editorText).toContain(text);
|
||||
|
||||
console.log(` ✅ 编辑器显示文本: "${text}"`);
|
||||
});
|
||||
|
||||
Then('编辑器应该显示 {string}', async function (this: CustomWorld, expectedText: string) {
|
||||
console.log(` 📍 Step: 验证编辑器显示 "${expectedText}"...`);
|
||||
|
||||
const editor = await getEditor(this);
|
||||
const editorText = await editor.textContent();
|
||||
expect(editorText).toContain(expectedText);
|
||||
|
||||
console.log(` ✅ 编辑器显示 "${expectedText}"`);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Then Steps - Slash Commands
|
||||
// ============================================
|
||||
|
||||
Then('应该显示斜杠命令菜单', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证显示斜杠命令菜单...');
|
||||
|
||||
// The slash menu should be visible
|
||||
// Look for menu with heading options, list options, etc.
|
||||
const menuSelectors = ['[role="menu"]', '[role="listbox"]', '.slash-menu', '[data-slash-menu]'];
|
||||
|
||||
let menuFound = false;
|
||||
for (const selector of menuSelectors) {
|
||||
const menu = this.page.locator(selector);
|
||||
if ((await menu.count()) > 0 && (await menu.isVisible())) {
|
||||
menuFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Alternative: look for menu items by text
|
||||
if (!menuFound) {
|
||||
const headingOption = this.page.getByText(/heading|标题/i).first();
|
||||
const listOption = this.page.getByText(/list|列表/i).first();
|
||||
|
||||
menuFound =
|
||||
((await headingOption.count()) > 0 && (await headingOption.isVisible())) ||
|
||||
((await listOption.count()) > 0 && (await listOption.isVisible()));
|
||||
}
|
||||
|
||||
expect(menuFound).toBe(true);
|
||||
|
||||
console.log(' ✅ 斜杠命令菜单已显示');
|
||||
});
|
||||
|
||||
Then('编辑器应该包含一级标题', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证编辑器包含一级标题...');
|
||||
|
||||
// Check for h1 element in the editor
|
||||
const editor = await getEditor(this);
|
||||
const h1 = editor.locator('h1');
|
||||
|
||||
await expect(h1).toBeVisible({ timeout: 5000 });
|
||||
|
||||
console.log(' ✅ 编辑器包含一级标题');
|
||||
});
|
||||
|
||||
Then('编辑器应该包含无序列表', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证编辑器包含无序列表...');
|
||||
|
||||
const editor = await getEditor(this);
|
||||
const ul = editor.locator('ul');
|
||||
|
||||
await expect(ul).toBeVisible({ timeout: 5000 });
|
||||
|
||||
console.log(' ✅ 编辑器包含无序列表');
|
||||
});
|
||||
|
||||
Then('编辑器应该包含任务列表', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证编辑器包含任务列表...');
|
||||
|
||||
const editor = await getEditor(this);
|
||||
|
||||
// Task list usually has checkbox elements
|
||||
const checkboxSelectors = [
|
||||
'input[type="checkbox"]',
|
||||
'[role="checkbox"]',
|
||||
'[data-lexical-check-list]',
|
||||
'li[role="listitem"] input',
|
||||
];
|
||||
|
||||
let found = false;
|
||||
for (const selector of checkboxSelectors) {
|
||||
const checkbox = editor.locator(selector);
|
||||
if ((await checkbox.count()) > 0) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Alternative: check for specific class or structure
|
||||
if (!found) {
|
||||
const listItem = editor.locator('li');
|
||||
found = (await listItem.count()) > 0;
|
||||
}
|
||||
|
||||
expect(found).toBe(true);
|
||||
|
||||
console.log(' ✅ 编辑器包含任务列表');
|
||||
});
|
||||
|
||||
Then('编辑器应该包含代码块', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证编辑器包含代码块...');
|
||||
|
||||
// Code block might be rendered inside the editor OR as a sibling element
|
||||
// CodeMirror renders its own container
|
||||
|
||||
// First check inside the editor
|
||||
const editor = await getEditor(this);
|
||||
const codeBlockSelectors = [
|
||||
'pre',
|
||||
'code',
|
||||
'.cm-editor', // CodeMirror
|
||||
'[data-language]',
|
||||
'.code-block',
|
||||
];
|
||||
|
||||
let found = false;
|
||||
for (const selector of codeBlockSelectors) {
|
||||
const codeBlock = editor.locator(selector);
|
||||
if ((await codeBlock.count()) > 0) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If not found inside editor, check the whole page
|
||||
// CodeMirror might render outside the contenteditable
|
||||
if (!found) {
|
||||
for (const selector of codeBlockSelectors) {
|
||||
const codeBlock = this.page.locator(selector);
|
||||
if ((await codeBlock.count()) > 0 && (await codeBlock.isVisible())) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(found).toBe(true);
|
||||
|
||||
console.log(' ✅ 编辑器包含代码块');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Then Steps - Formatting
|
||||
// ============================================
|
||||
|
||||
Then('选中的文本应该被加粗', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证文本已加粗...');
|
||||
|
||||
const editor = await getEditor(this);
|
||||
|
||||
// Check for bold element (strong or b tag, or font-weight style)
|
||||
const boldSelectors = [
|
||||
'strong',
|
||||
'b',
|
||||
'[style*="font-weight: bold"]',
|
||||
'[style*="font-weight: 700"]',
|
||||
];
|
||||
|
||||
let found = false;
|
||||
for (const selector of boldSelectors) {
|
||||
const boldElement = editor.locator(selector);
|
||||
if ((await boldElement.count()) > 0) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(found).toBe(true);
|
||||
|
||||
console.log(' ✅ 文本已加粗');
|
||||
});
|
||||
|
||||
Then('选中的文本应该变为斜体', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证文本已斜体...');
|
||||
|
||||
const editor = await getEditor(this);
|
||||
|
||||
// Check for italic element (em or i tag, or font-style style)
|
||||
const italicSelectors = ['em', 'i', '[style*="font-style: italic"]'];
|
||||
|
||||
let found = false;
|
||||
for (const selector of italicSelectors) {
|
||||
const italicElement = editor.locator(selector);
|
||||
if ((await italicElement.count()) > 0) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(found).toBe(true);
|
||||
|
||||
console.log(' ✅ 文本已斜体');
|
||||
});
|
||||
410
e2e/src/steps/page/editor-meta.steps.ts
Normal file
410
e2e/src/steps/page/editor-meta.steps.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Page Editor Meta Steps
|
||||
*
|
||||
* Step definitions for Page editor title and emoji editing E2E tests
|
||||
*/
|
||||
import { Given, Then, When } from '@cucumber/cucumber';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { CustomWorld, WAIT_TIMEOUT } from '../../support/world';
|
||||
|
||||
// ============================================
|
||||
// Given Steps
|
||||
// ============================================
|
||||
|
||||
Given('用户打开一个文稿编辑器', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 创建并打开一个文稿...');
|
||||
|
||||
// Navigate to page module
|
||||
await this.page.goto('/page');
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
// Create a new page via UI
|
||||
const newPageButton = this.page.locator('svg.lucide-square-pen').first();
|
||||
await newPageButton.click();
|
||||
await this.page.waitForTimeout(1500);
|
||||
|
||||
// Wait for navigation to page editor
|
||||
await this.page.waitForURL(/\/page\/.+/, { timeout: WAIT_TIMEOUT });
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
console.log(' ✅ 已打开文稿编辑器');
|
||||
});
|
||||
|
||||
Given('用户打开一个带有 Emoji 的文稿', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 创建并打开一个带 Emoji 的文稿...');
|
||||
|
||||
// First create and open a page
|
||||
await this.page.goto('/page');
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
const newPageButton = this.page.locator('svg.lucide-square-pen').first();
|
||||
await newPageButton.click();
|
||||
await this.page.waitForTimeout(1500);
|
||||
|
||||
await this.page.waitForURL(/\/page\/.+/, { timeout: WAIT_TIMEOUT });
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
// Add emoji by clicking the "Choose Icon" button
|
||||
console.log(' 📍 Step: 添加 Emoji 图标...');
|
||||
|
||||
// Hover over title section to show the button
|
||||
const titleSection = this.page.locator('textarea').first().locator('xpath=ancestor::div[1]');
|
||||
await titleSection.hover();
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
// Click the choose icon button
|
||||
const chooseIconButton = this.page.getByRole('button', { name: /choose.*icon|选择图标/i });
|
||||
if ((await chooseIconButton.count()) > 0) {
|
||||
await chooseIconButton.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
// Select the first emoji in the picker
|
||||
const emojiGrid = this.page.locator('[data-emoji]').first();
|
||||
if ((await emojiGrid.count()) > 0) {
|
||||
await emojiGrid.click();
|
||||
} else {
|
||||
// Fallback: click any emoji button
|
||||
const emojiButton = this.page.locator('button[title]').filter({ hasText: /^.$/ }).first();
|
||||
if ((await emojiButton.count()) > 0) {
|
||||
await emojiButton.click();
|
||||
}
|
||||
}
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
console.log(' ✅ 已打开带 Emoji 的文稿');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// When Steps - Title
|
||||
// ============================================
|
||||
|
||||
When('用户点击标题输入框', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 点击标题输入框...');
|
||||
|
||||
const titleInput = this.page.locator('textarea').first();
|
||||
await expect(titleInput).toBeVisible({ timeout: 5000 });
|
||||
await titleInput.click();
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
console.log(' ✅ 已点击标题输入框');
|
||||
});
|
||||
|
||||
When('用户输入标题 {string}', async function (this: CustomWorld, title: string) {
|
||||
console.log(` 📍 Step: 输入标题 "${title}"...`);
|
||||
|
||||
const titleInput = this.page.locator('textarea').first();
|
||||
|
||||
// Clear existing content and type new title (use modKey for cross-platform support)
|
||||
await titleInput.click();
|
||||
await this.page.keyboard.press(`${this.modKey}+A`);
|
||||
await this.page.waitForTimeout(100);
|
||||
await this.page.keyboard.type(title, { delay: 30 });
|
||||
|
||||
// Store for later verification
|
||||
this.testContext.expectedTitle = title;
|
||||
|
||||
console.log(` ✅ 已输入标题 "${title}"`);
|
||||
});
|
||||
|
||||
When('用户清空标题内容', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 清空标题内容...');
|
||||
|
||||
const titleInput = this.page.locator('textarea').first();
|
||||
await titleInput.click();
|
||||
await this.page.keyboard.press(`${this.modKey}+A`);
|
||||
await this.page.keyboard.press('Backspace');
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
// Click elsewhere to trigger save
|
||||
await this.page.click('body', { position: { x: 400, y: 400 } });
|
||||
await this.page.waitForTimeout(1500);
|
||||
|
||||
console.log(' ✅ 已清空标题内容');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// When Steps - Emoji
|
||||
// ============================================
|
||||
|
||||
When('用户点击选择图标按钮', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 点击选择图标按钮...');
|
||||
|
||||
// Hover to show the button
|
||||
const titleSection = this.page.locator('textarea').first().locator('xpath=ancestor::div[1]');
|
||||
await titleSection.hover();
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
// Click the choose icon button
|
||||
const chooseIconButton = this.page.getByRole('button', { name: /choose.*icon|选择图标/i });
|
||||
await expect(chooseIconButton).toBeVisible({ timeout: 5000 });
|
||||
await chooseIconButton.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
console.log(' ✅ 已点击选择图标按钮');
|
||||
});
|
||||
|
||||
When('用户选择一个 Emoji', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 选择一个 Emoji...');
|
||||
|
||||
// Wait for emoji picker to be visible
|
||||
await this.page.waitForTimeout(800);
|
||||
|
||||
// The emoji picker renders emojis as clickable span elements in a grid
|
||||
// Look for emoji elements in the "Frequently used" or "Smileys & People" section
|
||||
const emojiSelectors = [
|
||||
// Emoji spans in the picker grid (matches emoji characters)
|
||||
'span[style*="cursor: pointer"]',
|
||||
'span[role="img"]',
|
||||
'[data-emoji]',
|
||||
// Emoji-mart style selectors
|
||||
'.emoji-mart-emoji span',
|
||||
'button[aria-label*="emoji"]',
|
||||
];
|
||||
|
||||
let clicked = false;
|
||||
for (const selector of emojiSelectors) {
|
||||
const emojis = this.page.locator(selector);
|
||||
const count = await emojis.count();
|
||||
console.log(` 📍 Debug: Found ${count} elements with selector "${selector}"`);
|
||||
if (count > 0) {
|
||||
// Click a random emoji (not the first to avoid default)
|
||||
const index = Math.min(5, count - 1);
|
||||
await emojis.nth(index).click();
|
||||
clicked = true;
|
||||
console.log(` 📍 Debug: Clicked emoji at index ${index}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to find any clickable element in the emoji popover
|
||||
if (!clicked) {
|
||||
console.log(' 📍 Debug: Trying fallback - looking for emoji in popover');
|
||||
const popover = this.page.locator('.ant-popover-inner, [class*="popover"]').first();
|
||||
if ((await popover.count()) > 0) {
|
||||
// Find spans that look like emojis (single character with emoji range)
|
||||
const emojiSpans = popover.locator('span').filter({
|
||||
hasText: /^[\p{Emoji}]$/u,
|
||||
});
|
||||
const count = await emojiSpans.count();
|
||||
console.log(` 📍 Debug: Found ${count} emoji spans in popover`);
|
||||
if (count > 0) {
|
||||
await emojiSpans.nth(Math.min(5, count - 1)).click();
|
||||
clicked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!clicked) {
|
||||
console.log(' ⚠️ Could not find emoji button, test may fail');
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
console.log(' ✅ 已选择 Emoji');
|
||||
});
|
||||
|
||||
When('用户点击已有的 Emoji 图标', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 点击已有的 Emoji 图标...');
|
||||
|
||||
// The emoji is displayed in an Avatar component with square shape
|
||||
// Look for the emoji display element near the title
|
||||
const emojiAvatar = this.page.locator('[class*="Avatar"]').first();
|
||||
if ((await emojiAvatar.count()) > 0) {
|
||||
await emojiAvatar.click();
|
||||
} else {
|
||||
// Fallback: look for span with emoji
|
||||
const emojiSpan = this.page
|
||||
.locator('span')
|
||||
.filter({ hasText: /^[\u{1F300}-\u{1F9FF}]$/u })
|
||||
.first();
|
||||
if ((await emojiSpan.count()) > 0) {
|
||||
await emojiSpan.click();
|
||||
}
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
console.log(' ✅ 已点击 Emoji 图标');
|
||||
});
|
||||
|
||||
When('用户选择另一个 Emoji', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 选择另一个 Emoji...');
|
||||
|
||||
// Same as selecting an emoji, but choose a different index
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
const emojiSelectors = ['[data-emoji]', 'button[title]:not([title=""])'];
|
||||
|
||||
for (const selector of emojiSelectors) {
|
||||
const emojis = this.page.locator(selector);
|
||||
const count = await emojis.count();
|
||||
if (count > 0) {
|
||||
// Click a different emoji
|
||||
const index = Math.min(10, count - 1);
|
||||
await emojis.nth(index).click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
console.log(' ✅ 已选择另一个 Emoji');
|
||||
});
|
||||
|
||||
When('用户点击删除图标按钮', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 点击删除图标按钮...');
|
||||
|
||||
// Look for delete button in the emoji picker
|
||||
const deleteButton = this.page.getByRole('button', { name: /delete|删除/i });
|
||||
if ((await deleteButton.count()) > 0) {
|
||||
await deleteButton.click();
|
||||
} else {
|
||||
// Fallback: look for trash icon
|
||||
const trashIcon = this.page.locator('svg.lucide-trash, svg.lucide-trash-2').first();
|
||||
if ((await trashIcon.count()) > 0) {
|
||||
await trashIcon.click();
|
||||
}
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
console.log(' ✅ 已点击删除图标按钮');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Then Steps
|
||||
// ============================================
|
||||
|
||||
Then('文稿标题应该更新为 {string}', async function (this: CustomWorld, expectedTitle: string) {
|
||||
console.log(` 📍 Step: 验证标题为 "${expectedTitle}"...`);
|
||||
|
||||
const titleInput = this.page.locator('textarea').first();
|
||||
await expect(titleInput).toHaveValue(expectedTitle, { timeout: 5000 });
|
||||
|
||||
// Also verify in sidebar
|
||||
const sidebarItem = this.page.getByText(expectedTitle, { exact: true }).first();
|
||||
// Wait for sidebar to update (debounce + sync)
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
// Sidebar might take longer to sync
|
||||
try {
|
||||
await expect(sidebarItem).toBeVisible({ timeout: 3000 });
|
||||
console.log(' ✅ 侧边栏标题也已更新');
|
||||
} catch {
|
||||
console.log(' ⚠️ 侧边栏标题可能未同步(非关键)');
|
||||
}
|
||||
|
||||
console.log(` ✅ 标题已更新为 "${expectedTitle}"`);
|
||||
});
|
||||
|
||||
Then('应该显示标题占位符', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证显示占位符...');
|
||||
|
||||
const titleInput = this.page.locator('textarea').first();
|
||||
|
||||
// Check for placeholder attribute
|
||||
const placeholder = await titleInput.getAttribute('placeholder');
|
||||
expect(placeholder).toBeTruthy();
|
||||
|
||||
// The value might be empty or equal to the default "Untitled"
|
||||
const value = await titleInput.inputValue();
|
||||
const isEmptyOrDefault = value === '' || value === 'Untitled' || value === '无标题';
|
||||
expect(isEmptyOrDefault).toBe(true);
|
||||
|
||||
console.log(` ✅ 显示占位符: "${placeholder}", 当前值: "${value}"`);
|
||||
});
|
||||
|
||||
Then('文稿应该显示所选的 Emoji 图标', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证显示 Emoji 图标...');
|
||||
|
||||
// Look for emoji display - could be in Avatar or span element
|
||||
// The emoji picker uses @lobehub/ui which may render differently
|
||||
const emojiSelectors = [
|
||||
'[class*="Avatar"]',
|
||||
'[class*="avatar"]',
|
||||
'[class*="emoji"]',
|
||||
'span[role="img"]',
|
||||
];
|
||||
|
||||
let found = false;
|
||||
for (const selector of emojiSelectors) {
|
||||
const element = this.page.locator(selector).first();
|
||||
if ((await element.count()) > 0 && (await element.isVisible())) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if the "Choose Icon" button is NOT visible (meaning emoji was set)
|
||||
if (!found) {
|
||||
const chooseIconButton = this.page.getByRole('button', { name: /choose.*icon|选择图标/i });
|
||||
found = (await chooseIconButton.count()) === 0 || !(await chooseIconButton.isVisible());
|
||||
}
|
||||
|
||||
expect(found).toBe(true);
|
||||
|
||||
console.log(' ✅ 文稿显示 Emoji 图标');
|
||||
});
|
||||
|
||||
Then('文稿图标应该更新为新的 Emoji', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证 Emoji 图标已更新...');
|
||||
|
||||
// Look for emoji display
|
||||
const emojiSelectors = [
|
||||
'[class*="Avatar"]',
|
||||
'[class*="avatar"]',
|
||||
'[class*="emoji"]',
|
||||
'span[role="img"]',
|
||||
];
|
||||
|
||||
let found = false;
|
||||
for (const selector of emojiSelectors) {
|
||||
const element = this.page.locator(selector).first();
|
||||
if ((await element.count()) > 0 && (await element.isVisible())) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if the "Choose Icon" button is NOT visible (meaning emoji was set)
|
||||
if (!found) {
|
||||
const chooseIconButton = this.page.getByRole('button', { name: /choose.*icon|选择图标/i });
|
||||
found = (await chooseIconButton.count()) === 0 || !(await chooseIconButton.isVisible());
|
||||
}
|
||||
|
||||
expect(found).toBe(true);
|
||||
|
||||
console.log(' ✅ Emoji 图标已更新');
|
||||
});
|
||||
|
||||
Then('文稿不应该显示 Emoji 图标', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证不显示 Emoji 图标...');
|
||||
|
||||
// After deletion, the "Choose Icon" button should be visible
|
||||
// and the emoji avatar should be hidden
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
// Hover to check if the choose icon button appears
|
||||
const titleSection = this.page.locator('textarea').first().locator('xpath=ancestor::div[1]');
|
||||
await titleSection.hover();
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
const chooseIconButton = this.page.getByRole('button', { name: /choose.*icon|选择图标/i });
|
||||
|
||||
// Either the button is visible OR the emoji avatar is not visible
|
||||
try {
|
||||
await expect(chooseIconButton).toBeVisible({ timeout: 3000 });
|
||||
console.log(' ✅ 选择图标按钮可见,说明 Emoji 已删除');
|
||||
} catch {
|
||||
// Emoji might still be there but different
|
||||
console.log(' ⚠️ 无法确认 Emoji 是否删除');
|
||||
}
|
||||
|
||||
console.log(' ✅ 验证完成');
|
||||
});
|
||||
363
e2e/src/steps/page/page-crud.steps.ts
Normal file
363
e2e/src/steps/page/page-crud.steps.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Page CRUD Steps
|
||||
*
|
||||
* Step definitions for Page (文稿) CRUD E2E tests
|
||||
* - Create
|
||||
* - Rename
|
||||
* - Duplicate
|
||||
* - Delete
|
||||
*/
|
||||
import { Given, Then, When } from '@cucumber/cucumber';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { CustomWorld, WAIT_TIMEOUT } from '../../support/world';
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
async function inputPageName(
|
||||
this: CustomWorld,
|
||||
newName: string,
|
||||
pressEnter: boolean,
|
||||
): Promise<void> {
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
// Try to find the popover input or inline editing input
|
||||
const inputSelectors = [
|
||||
'.ant-popover-inner input',
|
||||
'.ant-popover-content input',
|
||||
'.ant-popover input',
|
||||
'input[type="text"]:visible',
|
||||
];
|
||||
|
||||
let renameInput = null;
|
||||
|
||||
for (const selector of inputSelectors) {
|
||||
try {
|
||||
const locator = this.page.locator(selector).first();
|
||||
await locator.waitFor({ state: 'visible', timeout: 2000 });
|
||||
renameInput = locator;
|
||||
break;
|
||||
} catch {
|
||||
// Try next selector
|
||||
}
|
||||
}
|
||||
|
||||
if (!renameInput) {
|
||||
// Fallback: find any visible input
|
||||
const allInputs = this.page.locator('input:visible');
|
||||
const count = await allInputs.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const input = allInputs.nth(i);
|
||||
const placeholder = (await input.getAttribute('placeholder').catch(() => '')) || '';
|
||||
if (placeholder.includes('Search') || placeholder.includes('搜索')) continue;
|
||||
|
||||
const isInPopover = await input.evaluate((el) => {
|
||||
return el.closest('.ant-popover') !== null || el.closest('[class*="popover"]') !== null;
|
||||
});
|
||||
|
||||
if (isInPopover || count <= 2) {
|
||||
renameInput = input;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (renameInput) {
|
||||
await renameInput.click();
|
||||
await renameInput.clear();
|
||||
await renameInput.fill(newName);
|
||||
|
||||
if (pressEnter) {
|
||||
await renameInput.press('Enter');
|
||||
} else {
|
||||
await this.page.click('body', { position: { x: 10, y: 10 } });
|
||||
}
|
||||
} else {
|
||||
// Keyboard fallback (use modKey for cross-platform support)
|
||||
await this.page.keyboard.press(`${this.modKey}+A`);
|
||||
await this.page.waitForTimeout(50);
|
||||
await this.page.keyboard.type(newName, { delay: 20 });
|
||||
|
||||
if (pressEnter) {
|
||||
await this.page.keyboard.press('Enter');
|
||||
} else {
|
||||
await this.page.click('body', { position: { x: 10, y: 10 } });
|
||||
}
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(1000);
|
||||
console.log(` ✅ 已输入新名称 "${newName}"`);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Given Steps
|
||||
// ============================================
|
||||
|
||||
Given('用户在 Page 页面', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 导航到 Page 页面...');
|
||||
await this.page.goto('/page');
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
console.log(' ✅ 已进入 Page 页面');
|
||||
});
|
||||
|
||||
Given('用户在 Page 页面有一个文稿', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 导航到 Page 页面...');
|
||||
await this.page.goto('/page');
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
console.log(' 📍 Step: 通过 UI 创建新文稿...');
|
||||
// Click the new page button to create via UI (ensures proper server-side creation)
|
||||
const newPageButton = this.page.locator('svg.lucide-square-pen').first();
|
||||
await newPageButton.click();
|
||||
await this.page.waitForTimeout(1500);
|
||||
|
||||
// Wait for the new page to be created and URL to change
|
||||
await this.page.waitForURL(/\/page\/.+/, { timeout: WAIT_TIMEOUT });
|
||||
|
||||
// Create a unique title for this test page
|
||||
const uniqueTitle = `E2E Page ${Date.now()}`;
|
||||
const defaultTitleRegex = /^(无标题|Untitled)$/;
|
||||
|
||||
console.log(` 📍 Step: 重命名为唯一标题 "${uniqueTitle}"...`);
|
||||
// Find the new page and rename it to ensure uniqueness
|
||||
const pageItem = this.page.getByText(defaultTitleRegex).first();
|
||||
await expect(pageItem).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Right-click to open context menu and rename
|
||||
await pageItem.click({ button: 'right' });
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
const renameOption = this.page.getByRole('menuitem', { name: /^(rename|重命名)$/i });
|
||||
await expect(renameOption).toBeVisible({ timeout: 5000 });
|
||||
await renameOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
// Input the unique name (use modKey for cross-platform support)
|
||||
await this.page.keyboard.press(`${this.modKey}+A`);
|
||||
await this.page.keyboard.type(uniqueTitle, { delay: 20 });
|
||||
await this.page.click('body', { position: { x: 10, y: 10 } });
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
// Wait for the renamed page to be visible
|
||||
const renamedItem = this.page.getByText(uniqueTitle, { exact: true }).first();
|
||||
await expect(renamedItem).toBeVisible({ timeout: WAIT_TIMEOUT });
|
||||
|
||||
// Store page reference for later use
|
||||
this.testContext.targetItemTitle = uniqueTitle;
|
||||
this.testContext.targetType = 'page';
|
||||
|
||||
console.log(` ✅ 找到文稿: ${uniqueTitle}`);
|
||||
});
|
||||
|
||||
Given('用户在 Page 页面有一个文稿 {string}', async function (this: CustomWorld, title: string) {
|
||||
console.log(' 📍 Step: 导航到 Page 页面...');
|
||||
await this.page.goto('/page');
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
console.log(' 📍 Step: 通过 UI 创建新文稿...');
|
||||
// Click the new page button to create via UI
|
||||
const newPageButton = this.page.locator('svg.lucide-square-pen').first();
|
||||
await newPageButton.click();
|
||||
await this.page.waitForTimeout(1500);
|
||||
|
||||
// Wait for the new page to be created
|
||||
await this.page.waitForURL(/\/page\/.+/, { timeout: WAIT_TIMEOUT });
|
||||
|
||||
// Default title is "无标题" (Untitled) - support both languages
|
||||
const defaultTitleRegex = /^(无标题|Untitled)$/;
|
||||
|
||||
console.log(` 📍 Step: 通过右键菜单重命名文稿为 "${title}"...`);
|
||||
// Find the new page in the sidebar and rename via context menu
|
||||
const pageItem = this.page.getByText(defaultTitleRegex).first();
|
||||
await expect(pageItem).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Right-click to open context menu
|
||||
await pageItem.click({ button: 'right' });
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
// Select rename option
|
||||
const renameOption = this.page.getByRole('menuitem', { name: /^(rename|重命名)$/i });
|
||||
await expect(renameOption).toBeVisible({ timeout: 5000 });
|
||||
await renameOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
// Input the new name (use modKey for cross-platform support)
|
||||
await this.page.keyboard.press(`${this.modKey}+A`);
|
||||
await this.page.keyboard.type(title, { delay: 20 });
|
||||
await this.page.click('body', { position: { x: 10, y: 10 } });
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
console.log(' 📍 Step: 查找文稿...');
|
||||
const renamedItem = this.page.getByText(title, { exact: true }).first();
|
||||
await expect(renamedItem).toBeVisible({ timeout: WAIT_TIMEOUT });
|
||||
|
||||
this.testContext.targetItemTitle = title;
|
||||
this.testContext.targetType = 'page';
|
||||
|
||||
console.log(` ✅ 找到文稿: ${title}`);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// When Steps
|
||||
// ============================================
|
||||
|
||||
When('用户点击新建文稿按钮', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 点击新建文稿按钮...');
|
||||
|
||||
// Look for the SquarePen icon button (new page button)
|
||||
const newPageButton = this.page.locator('svg.lucide-square-pen').first();
|
||||
|
||||
if ((await newPageButton.count()) > 0) {
|
||||
await newPageButton.click();
|
||||
} else {
|
||||
// Fallback: look for button with title containing "new" or "新建"
|
||||
const buttonByTitle = this.page
|
||||
.locator('button[title*="new"], button[title*="新建"], [role="button"][title*="new"]')
|
||||
.first();
|
||||
if ((await buttonByTitle.count()) > 0) {
|
||||
await buttonByTitle.click();
|
||||
} else {
|
||||
throw new Error('Could not find new page button');
|
||||
}
|
||||
}
|
||||
|
||||
await this.page.waitForTimeout(1000);
|
||||
console.log(' ✅ 已点击新建文稿按钮');
|
||||
});
|
||||
|
||||
When('用户右键点击该文稿', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 右键点击文稿...');
|
||||
|
||||
const title = this.testContext.targetItemTitle || this.testContext.createdPageTitle;
|
||||
// Find the page item by its title text, then find the parent clickable block
|
||||
const titleElement = this.page.getByText(title, { exact: true }).first();
|
||||
await expect(titleElement).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Right-click on the title element (the NavItem Block wraps the text)
|
||||
await titleElement.click({ button: 'right' });
|
||||
|
||||
await this.page.waitForTimeout(800);
|
||||
|
||||
// Debug: check what menus are visible
|
||||
const menuItems = await this.page.locator('[role="menuitem"]').count();
|
||||
console.log(` 📍 Debug: Found ${menuItems} menu items after right-click`);
|
||||
|
||||
console.log(' ✅ 已右键点击文稿');
|
||||
});
|
||||
|
||||
When('用户在菜单中选择复制', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 选择复制选项...');
|
||||
|
||||
// Look for duplicate option (复制 or Duplicate)
|
||||
const duplicateOption = this.page.getByRole('menuitem', { name: /复制|duplicate/i });
|
||||
await expect(duplicateOption).toBeVisible({ timeout: 5000 });
|
||||
await duplicateOption.click();
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
console.log(' ✅ 已选择复制选项');
|
||||
});
|
||||
|
||||
When('用户输入新的文稿名称 {string}', async function (this: CustomWorld, newName: string) {
|
||||
console.log(` 📍 Step: 输入新名称 "${newName}"...`);
|
||||
await inputPageName.call(this, newName, false);
|
||||
});
|
||||
|
||||
When(
|
||||
'用户输入新的文稿名称 {string} 并按 Enter',
|
||||
async function (this: CustomWorld, newName: string) {
|
||||
console.log(` 📍 Step: 输入新名称 "${newName}" 并按 Enter...`);
|
||||
await inputPageName.call(this, newName, true);
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Then Steps
|
||||
// ============================================
|
||||
|
||||
Then('应该创建一个新的文稿', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证新文稿已创建...');
|
||||
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
// Check if URL changed to a new page
|
||||
const currentUrl = this.page.url();
|
||||
expect(currentUrl).toMatch(/\/page\/.+/);
|
||||
|
||||
console.log(' ✅ 新文稿已创建');
|
||||
});
|
||||
|
||||
Then('文稿列表中应该显示新文稿', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证文稿列表中显示新文稿...');
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
// Page list items are rendered with NavItem component (not <a> tags)
|
||||
// Look for the untitled page in the sidebar list
|
||||
const untitledText = this.page.getByText(/无标题|untitled/i).first();
|
||||
await expect(untitledText).toBeVisible({ timeout: 5000 });
|
||||
|
||||
console.log(' ✅ 文稿列表中显示新文稿');
|
||||
});
|
||||
|
||||
Then('该文稿名称应该更新为 {string}', async function (this: CustomWorld, expectedName: string) {
|
||||
console.log(` 📍 Step: 验证名称为 "${expectedName}"...`);
|
||||
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
// Look for the renamed item in the list
|
||||
const renamedItem = this.page.getByText(expectedName, { exact: true }).first();
|
||||
await expect(renamedItem).toBeVisible({ timeout: 5000 });
|
||||
|
||||
console.log(` ✅ 名称已更新为 "${expectedName}"`);
|
||||
});
|
||||
|
||||
Then('文稿列表中应该出现 {string}', async function (this: CustomWorld, expectedName: string) {
|
||||
console.log(` 📍 Step: 验证文稿列表中出现 "${expectedName}"...`);
|
||||
|
||||
await this.page.waitForTimeout(2000);
|
||||
|
||||
// The duplicated page might have "(Copy)" or " (Copy)" or "副本" suffix
|
||||
// First try exact match, then try partial match
|
||||
let duplicatedItem = this.page.getByText(expectedName, { exact: true }).first();
|
||||
|
||||
if ((await duplicatedItem.count()) === 0) {
|
||||
// Try finding page with "Copy" in the name (could be "Original Page (Copy)" or similar)
|
||||
const baseName = expectedName.replace(/\s*\(Copy\)$/, '');
|
||||
duplicatedItem = this.page.getByText(new RegExp(`${baseName}.*Copy|${baseName}.*副本`)).first();
|
||||
}
|
||||
|
||||
if ((await duplicatedItem.count()) === 0) {
|
||||
// Fallback: check if there are at least 2 pages with similar name
|
||||
const similarPages = this.page.getByText(expectedName.replace(/\s*\(Copy\)$/, '')).all();
|
||||
// eslint-disable-next-line unicorn/no-await-expression-member
|
||||
const count = (await similarPages).length;
|
||||
console.log(` 📍 Debug: Found ${count} pages with similar name`);
|
||||
expect(count).toBeGreaterThanOrEqual(2);
|
||||
console.log(` ✅ 文稿列表中出现多个相似名称的文稿`);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(duplicatedItem).toBeVisible({ timeout: WAIT_TIMEOUT });
|
||||
console.log(` ✅ 文稿列表中出现 "${expectedName}"`);
|
||||
});
|
||||
|
||||
Then('该文稿应该从列表中移除', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证文稿已移除...');
|
||||
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
const title = this.testContext.targetItemTitle || this.testContext.createdPageTitle;
|
||||
if (title) {
|
||||
const deletedItem = this.page.getByText(title, { exact: true });
|
||||
await expect(deletedItem).not.toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
console.log(' ✅ 文稿已从列表中移除');
|
||||
});
|
||||
@@ -3,6 +3,11 @@ import { Browser, BrowserContext, Page, Response, chromium } from '@playwright/t
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
/**
|
||||
* Default timeout for waiting operations (e.g., waitForURL, toBeVisible)
|
||||
*/
|
||||
export const WAIT_TIMEOUT = 13_000;
|
||||
|
||||
export interface TestContext {
|
||||
[key: string]: any;
|
||||
consoleErrors: string[];
|
||||
@@ -17,6 +22,13 @@ export class CustomWorld extends World {
|
||||
page!: Page;
|
||||
testContext: TestContext;
|
||||
|
||||
/**
|
||||
* Get the platform-specific modifier key (Meta for macOS, Control for Linux/Windows)
|
||||
*/
|
||||
get modKey(): 'Meta' | 'Control' {
|
||||
return process.platform === 'darwin' ? 'Meta' : 'Control';
|
||||
}
|
||||
|
||||
constructor(options: IWorldOptions) {
|
||||
super(options);
|
||||
this.testContext = {
|
||||
|
||||
Reference in New Issue
Block a user