提交 f5003c87 authored 作者: 陈泽健's avatar 陈泽健

feat(executor): 新增 SelectorMapper 选择器增强与步骤级实时推送

核心变更:
- SelectorMapper 集成:支持 page_key + element_key 多选择器回退解析
- 步骤级实时推送:通过 WebSocket 广播每一步执行状态
- 单步重试机制:step_retry_count 配置支持
- 录制器统一使用 StepDefinition 格式,透传新字段
- 前端实时步骤时间线组件

新增文档:
- README.md / CLAUDE.md / HANDOFF.md 项目文档
- Handoff skill 自动生成交接文档
- 执行器准确率优化相关 PRD/计划文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 3c4cff22
@echo off
cd /d E:\GithubData\ubains-module-test\platform-auto-test
E:\nodejs\claude.cmd --permission-mode bypassPermissions
cd /d C:\PycharmData\ubains-module-test\platform-auto-test
C:\nvm4w\nodejs\claude.cmd --permission-mode bypassPermissions
\ No newline at end of file
---
name: Handoff
description: 生成交接文档 HANDOFF.md,供下一次会话快速恢复上下文
---
## Usage
/Handoff
## Description
在当前会话结束时执行,生成一份 **HANDOFF.md** 交接文档,内容包括:
- **项目概览**:项目是什么、技术栈、目录结构
- **当前任务**:本次会话在做什么
- **已完成事项**:已完成的里程碑和关键成果
- **卡点和问题**:当前卡在什么地方、未解决的问题
- **下一步计划**:优先级最高的待办任务
- **踩坑记录**:绝对不要重复踩的坑(含 Windows 平台特有坑)
- **启动指南**:如何快速启动项目
- **关键人员/文档索引**:重要文档和 PRD 位置
## 执行步骤
1. 读取项目关键状态:
- `git log --oneline -5`(最近提交)
- `git status --short`(未提交变更)
- `git branch --show-current`(当前分支)
- `ls Docs/PRD/自动化测试平台/`(检查文档目录)
2. 读取已有进度文档:`Docs/PRD/自动化测试平台/当前进度记录.md`
3. 根据上述信息合成 HANDOFF.md,写入项目根目录 `HANDOFF.md`
4. 告知用户 HANDOFF.md 已生成
## 输出格式
- 写入 `C:\PycharmData\ubains-module-test\platform-auto-test\HANDOFF.md`
- 使用 Markdown 格式,中文字符
- 每个章节用 `---` 分隔,便于快速扫读
\ No newline at end of file
# CLAUDE.md
> 本文件为 Claude Code 在此项目中工作时的指导文档。Claude Code 在每次会话开始时会自动读取本文件。
> 维护者:czj · 最后更新:2026-07-13
---
## 项目概述
**平台自动化测试可视化系统 (platform-auto-test)** — 一个 Web 可视化自动化测试平台,核心创新是用例录制器:用户在目标网站上操作,系统自动捕获行为并转换为测试步骤,无需手动编写测试代码。
- **被测系统**:统一管理平台 (https://192.168.5.44)
- **登录凭据**:admin@xty / Ubains@13579 · 验证码固定 `csba`
- **远程仓库**:http://git.ubainsyun.com/bing/ubains-module-test.git
- **当前分支**`platform-auto-test`(主分支:`main`
---
## 常用命令
### 启动服务
```bash
# 后端(端口 8001,工作目录 backend/)
cd backend
pip install -r requirements.txt
playwright install chromium
uvicorn app.main:app --reload --port 8001
# 前端(端口 3000,代理指向 8001,工作目录 frontend/)
cd frontend
npm install
npm run dev
```
### 构建 & 测试
```bash
# 前端构建(类型检查 + Vite 打包)
cd frontend && npm run build
# 前端 Lint
cd frontend && npm run lint
# 后端测试
cd backend && pytest tests/ -v --cov=app --cov-report=html
# WebSocket 流程集成测试
cd backend && python scripts/test_websocket_flow.py
```
### 访问地址
- 前端:http://localhost:3000
- API 文档:http://localhost:8001/docs
- 健康检查:http://localhost:8001/health
---
## 代码架构
### 后端分层(`backend/app/`)
- **`main.py`** — FastAPI 入口,注册 10 个路由模块(modules / cases / executions / recorder / stats / reports / cleanup / batch / dependencies)。**已配置 Windows ProactorEventLoop,勿删**
- **`config.py`**`Settings` 类,支持环境变量覆盖(DATABASE_URL / PLAYWRIGHT_HEADLESS / SCREENSHOT_DIR 等)。
- **`database.py`** — SQLite 异步引擎 + 表初始化。已配置 `check_same_thread=False` + `pool_pre_ping=True`
- **`models/`** — SQLAlchemy ORM:module / test_case / execution / case_result / case_dependency。
- **`schemas/`** — Pydantic 校验层。
- **`routers/`** — API 路由(薄层,仅参数校验 + 调用 service)。
- **`services/`** — 业务逻辑层(核心业务在此)。
- **`executors/playwright_executor.py`** — 执行引擎核心,**同步 API**,13 种动作 + 8 种断言。
- **`websocket/manager.py`** — WebSocket 连接管理(分组广播 + 断线重连)。
- **`utils/selector_mapper.py`** — 选择器映射,多选器回退策略。
- **`scripts/`** — 15 个辅助脚本(create_cases / test_websocket_flow / 数据清理等)。
### 前端分层(`frontend/src/`)
- **`App.vue`** — 根布局(侧边栏 220px + 顶部栏 + 内容区)。
- **`router/index.ts`** — 7 个路由,懒加载。
- **`api/`** — 各模块的 axios 封装调用。
- **`utils/request.ts`** — Axios 全局封装,**baseURL 为空字符串**,超时 5 分钟。
- **`utils/websocket.ts`** — WS 客户端,事件驱动 + 自动重连 3 次。
- **`views/`** — 7 个页面:Dashboard / Modules / Cases / Recorder / Execution / Reports / Settings。
- **`types/`** — TypeScript 类型定义。
### API 前缀约定
所有 API 前缀为 `/api/{模块}`(如 `/api/cases``/api/executions`)。前端 axios `baseURL` **必须为空字符串**,路由路径写完整 `/api/...`,否则会出现 `/api/api/cases` 双重前缀 404。
---
## 关键约束(务必遵守)
### 1. Playwright 必须用同步 API
Windows 上 Playwright 异步 API 在 FastAPI asyncio 循环中无法启动。**不要**尝试改回 `async_playwright`,也不要用 `asyncio.create_task` / `threading` / `multiprocessing` 在 Windows 上实现异步执行——都已验证失败。
正确模式(已固化):
1. `main.py` 设置 `asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())`
2. `playwright_executor.py` 使用 `sync_playwright` 同步 API
3. `execution_service.py` 通过 `loop.run_in_executor()` 在线程池中执行
4. **每个测试用例创建独立的 `PlaywrightExecutor` 实例**(共享实例在 `stop()``_is_running=False`,下次 `start()` 会失败)
### 2. 前端 useRoute() 必须在 setup 顶层
`useRoute()` 不能在 `computed()` 回调内调用,否则报 `inject() can only be used inside setup()`,导致页面主内容区域为空。统一在 `<script setup>` 顶层调用。
### 3. 前端 axios 超时设 5 分钟
默认 30s 太短,Playwright 执行可能需要几分钟。`request.ts` 已配置 `timeout: 300000`
### 4. SQLite 并发写锁
已配置 `check_same_thread=False` + `pool_pre_ping=True`。同步执行期间的并发写入仍需注意;如遇 `database is locked`,检查是否有未关闭的会话。`error_message` 字段必须 `nullable=True`(成功执行后为 None)。
### 5. 数据目录(gitignored)
- `data/test_platform.db` — SQLite 数据库
- `data/screenshots/` — 执行截图
- `data/reports/` — 生成的报告
这些目录在应用启动时由 `lifespan` 自动创建,无需手动 mkdir。
---
## 踩坑记录(绝对不要重复踩)
| # | 现象 | 根因 | 正确做法 |
|---|------|------|---------|
| 1 | `NotImplementedError` / Sync API inside asyncio | Windows 默认 SelectorEventLoop 不支持子进程 | 同步 API + ProactorEventLoop + 线程池 |
| 2 | 第二次执行 `start()` 失败 | 共享执行器 `_is_running` 状态 | 每用例新建 PlaywrightExecutor 实例 |
| 3 | 请求 `/api/api/cases` 404 | `baseURL:'/api'` 与路径叠加 | baseURL 留空,路径写完整 `/api/...` |
| 4 | `SQLite database is locked` | 同步执行并发写入 | check_same_thread=False + pool_pre_ping |
| 5 | bash 中 `start` 启动 CMD 失败 | MSYS 封装损坏 `&&` 引号 | `MSYS_NO_PATHCONV=1 cmd.exe /c start ...` |
| 6 | `claude` 命令不在 PATH | Windows 未配全局 PATH | `which claude` 找路径,补 `.cmd` 后缀 |
| 7 | 前端页面空白 | `useRoute()` 在 computed 内调用 | 移到 setup 顶层 |
| 8 | `NOT NULL constraint failed: error_message` | 字段 nullable=False 但成功时为 None | 改为 nullable=True |
详见 `HANDOFF.md` 第五节。
---
## 文档体系
PRD 与进度文档全部在 `Docs/PRD/自动化测试平台/`,遵循工作流:
```
PRD 需求文档 → (prd-plan skill) → 计划执行文档 → (prd-code skill) → 代码
```
| 文档 | 用途 |
|------|------|
| `_PRD_平台自动化测试可视化系统需求文档.md` | 主 PRD |
| `_PRD_..._计划执行.md` | 分阶段执行计划 |
| `_开发进度报告_20260709.md` | 进度 + 完整 API 清单 + Phase 5 待办 |
| `当前进度记录.md` | 26 个用例测试结果 + 关键决策 |
| `测试结果汇总报告.md` | 执行结果详细数据 |
| `HANDOFF.md` | 会话交接(由 /Handoff skill 生成) |
**修改 PRD 或新增功能时**:先写 PRD → 走 prd-plan → 走 prd-code,保持文档与代码同步。
---
## Git 工作流
- **当前分支**`platform-auto-test`,主分支:`main`
- **提交规范**:Conventional Commits(`feat:` / `fix:` / `docs:` / `test:` / `refactor:`
- **提交工具**:使用 `/GitCommit` skill(三重门控 + 分支检查 + 推送确认)
- **提交后缀**:所有 commit message 结尾加 `Co-Authored-By: Claude <noreply@anthropic.com>`
- **未在用户明确要求时不要自动 commit/push**
---
## 可用 Skills(`.claude/skills/`)
| Skill | 用途 |
|-------|------|
| `CreateCMD` | 在当前目录打开 N 个 CMD 窗口运行 Claude |
| `GitCommit` | 代码提交辅助(审查 + 生成 commit + add/commit/push) |
| `Handoff` | 会话结束生成 HANDOFF.md 交接文档 |
| `prd-plan` | PRD 需求文档 → 执行计划文档 |
| `prd-code` | 执行计划文档 → 代码 |
---
## 当前进度与待办
- **Phase 1-4** 全部完成(基础框架、核心功能、执行与可视化、分析与报告)
- **Phase 5** 进行中(约 30%)— 已完成 Excel 导入、执行引擎调通、数据库锁修复、用例依赖管理、选择器优化、CI/CD
- **Phase 5 剩余 P0**:Settings.vue 功能实现、Pinia 状态管理、单元测试
- **远期规划**:迁移 PostgreSQL、用户权限系统、分布式执行、多浏览器录制
### 已知风险
- **SQLite 并发瓶颈** — 多用户并发场景可能 `database is locked`,远期迁移 PostgreSQL
- **Windows 子进程限制** — 当前用同步 + 线程池规避,非完美方案
- **无用户权限系统** — 当前无认证/授权,仅限内网使用
---
## 代码风格约定
- 后端 Python 文件头部注释模板:模块名称、模块描述、作者、创建日期、最后修改日期
- 前端 Vue/TS 文件头部注释:组件/文件名称、描述、@author、@date
- 命名:后端用 snake_case,前端用 camelCase,类型用 PascalCase
- API 路径统一 `/api/{模块}` 前缀
- 数据库 ID 使用 UUID(`utils/id_generator.py`
---
*本文件由 Claude Code 维护,修改项目结构或新增约束时请同步更新。*
\ No newline at end of file
# 执行器准确率优化需求文档
> **文档版本**: v1.0
> **创建日期**: 2026-07-13
> **文档状态**: 初稿待评审
> **负责人**: czj
---
## 一、背景
### 1.1 问题描述
当前 `platform-auto-test` 平台的测试用例执行准确率低于预期。对比同仓库的 `pc自动化测试`(Playwright 原生 E2E 测试,51 个工作流,通过率 100%),平台在相同目标系统上的执行成功率明显偏低。
### 1.2 根因分析
通过深度对比两套方案的实现源码,发现主要差距在以下维度:
| 维度 | pc自动化测试 | platform-auto-test | 影响程度 |
|------|-------------|-------------------|----------|
| 选择器质量 | 手写多级 CSS 链,唯一性高 | 录制器生成单层 class,易匹配多个 | ★★★★★ |
| 等待策略 | 显式 waitFor(visible) + 500-800ms 延时 | 等待超时后静默继续 | ★★★★☆ |
| 交互健壮性 | force click + 多选器回退 + triple-click 清空 | 简单 click/fill,无回退 | ★★★★☆ |
| 重试粒度 | 单步重试 + 动作特定 recovery | 整用例从第 1 步重来 | ★★★☆☆ |
| 日志反馈 | 每步控制台输出带图标 | logger.debug 级别,默认不可见 | ★★☆☆☆ |
### 1.3 关键发现
代码审查中发现两个关键问题:
1. **SelectorMapper 悬空**`backend/app/utils/selector_mapper.py` 已定义丰富的多选器回退链,但执行器从未导入使用
2. **WebSocket 回调未接通**`execution_service.py``_create_step_callback` 已实现但从未传给 `executor.execute_case()`,导致前端无法实时看到步骤执行
---
## 二、需求内容
### 2.1 目标
提升平台执行器的执行准确率,使其在同等条件下达到与 `pc自动化测试` 接近的通过率(>90%),同时保持平台的"可视化/录制器"定位。
### 2.2 功能需求
#### 2.2.1 多选择器回退机制
**现状**:每个步骤依赖单一 CSS 选择器,无备选方案。
**需求**
- 执行器支持单个步骤传入多个选择器(`selectors: List[str]`
- 主选择器失败时自动尝试下一个,全部失败才标记为 failed
- 集成 SelectorMapper 中预定义的丰富选择器链
- 兼容旧格式(仅 `params.selector` 单值)
#### 2.2.2 显式等待策略
**现状**:动作前等待过于简单,缺少延时和状态检查。
**需求**
- 每个动作前执行 `wait_for_selector(state="visible")` 显式等待
- 每个动作后根据类型增加 300-2000ms 延时(应对页面动画)
- 导航后等待 `networkidle`
- 加载遮罩两阶段等待(先等 visible 再等 hidden)
#### 2.2.3 交互健壮性增强
**现状**`_do_click` 无 force 参数,`_do_fill` 不清空输入,`_do_assert` 无重试。
**需求**
- Click 支持 `force=True` 参数,绕过覆盖层
- Fill 执行 triple-click → Ctrl+A → Delete → fill 清空模式
- 输入框填充前等待元素可见
- 断言验证支持重试(如列表数量断言等待 3 秒后重试)
#### 2.2.4 单步重试机制
**现状**:整用例从头重试,浪费时间和资源。
**需求**
- 单步执行失败后自动重试(默认 2 次)
- 重试间隔 1 秒
- 配置项 `step_retry_count` 可调
- 整用例重试保留为可选配置(默认禁用)
#### 2.2.5 实时执行反馈
**现状**:WebSocket 步骤回调已实现但未接通。
**需求**
- 接通 `_create_step_callback``executor.execute_case()`
- 每步执行完成通过 WebSocket 推送 `step_update` 事件
- 前端 Execution.vue 实时显示步骤执行情况
#### 2.2.6 统一的步骤数据结构
**现状**:Schema / Recorder / Executor 三处步骤表示不一致。
**需求**
- `StepDefinition` 增加可选字段(向后兼容):
- `selectors: List[str]` — 多选择器回退链
- `page_key: str` — SelectorMapper 页面标识
- `element_key: str` — 元素标识
- `force: bool` — 强制点击
- `wait_after: int` — 自定义操作后延时
- Recorder 统一使用 `StepDefinition` 格式
- 旧数据(仅 `params.selector`)继续有效
### 2.3 非功能需求
- 向后兼容:现有 26 个用例无需修改即可继续执行
- 执行效率:单步重试不显著增加整体执行时间
- 稳定性:Windows 11 + Playwright 同步 API + 线程池仍正常
---
## 三、Why
执行准确率是自动化测试平台的**核心指标**。当前平台虽然有完整的 Web 管理界面、录制器、报告系统,但如果执行准确率不可靠,这些功能的价值将大打折扣。`pc自动化测试` 项目已验证了这些最佳实践在同一个目标系统上的有效性,本需求旨在将这些已证明有效的策略引入平台。
## 四、How to apply
当后续修改执行器逻辑时:
1. 确保新增字段全部 Optional,不影响旧数据
2. 多选择器回退按优先级排序,首个为最优选择器
3. 等待时间以实际页面动画时间为准,不过度延长
4. 日志统一使用图标前缀(✓/⚠/✗/ℹ/⏳)
5. 修改后运行现有 26 个用例回归验证
---
## 五、验收标准
| 验收项 | 标准 |
|-------|------|
| 多选择器回退 | 主选择器失效时,自动切换到备选选择器执行成功 |
| 等待策略 | 页面动画/弹窗后,操作正常执行不报元素不可见 |
| 交互健壮性 | 覆盖层上的元素可通过 force click 点击;输入框先清空再填充 |
| 单步重试 | 某步失败后自动重试 2 次,不重新执行前置步骤 |
| 实时反馈 | 前端 WebSocket 收到每步的 step_update 事件 |
| 向后兼容 | 旧格式 26 个用例无需修改,正常执行 |
| 日志 | 后端控制台输出带图标前缀的步骤执行日志 |
\ No newline at end of file
# 执行准确率差异分析:`pc自动化测试` vs `platform-auto-test`
## 核心结论
**`pc自动化测试` 准确率远高于 `platform-auto-test`,根本原因不是框架本身的差异,而是"选择器质量"和"等待策略"两个维度的差距。**
---
## 差异一:选择器的精确度(最关键差异)
### `pc自动化测试` — 精确到具体组件的 CSS class
Page Object 中使用**从 Vue 组件源码直接确定的、多级嵌套的具体 class**
```javascript
// Step1Page.js — 选择器非常具体
get meetingNameInput() {
return this.page.locator('.bookv3-slim-form .field-name input[placeholder="请输入"]').first();
}
get roomInput() {
return this.page.locator('.bookv3-slim-form .field-room .room-input');
}
get nextButton() {
return this.page.locator('.footer-next-btn-v3');
}
```
**特点**
- 选择器由**人写代码时直接从组件 DOM 结构确定**
- 多层嵌套(form > field > input),唯一性极高
- 使用 `placeholder``class` 等稳定属性组合定位
### `platform-auto-test` — 通用选择器,录制器自动生成
```python
# playwright_executor.py — 自动登录时的选择器
inputs = self._page.query_selector_all('input:visible')
for inp in inputs:
ph = inp.get_attribute('placeholder') or ''
if '手机' in ph or '用户' in ph or '邮箱' in ph or 'account' in ph.lower():
inp.fill(self.LOGIN_CONFIG["username"])
```
**特点**
- 选择器存在数据库的步骤 params 中,由**录制器自动生成**
- 录制器生成的选择器通常是单层 class(如 `.el-input``button`
- 自动生成的选择器缺乏上下文,容易匹配到多个元素
- 录制器捕获时的 DOM 结构与执行时的 DOM 结构可能有差异
### 对比
| 维度 | pc自动化测试 | platform-auto-test |
|------|-------------|-------------------|
| 选择器来源 | 开发人员手写 | 录制器自动生成 |
| 选择器粒度 | 多级嵌套,精确到具体字段 | 单层/通用 |
| 唯一性 | 高 — 一个选择器对应一个元素 | 低 — 易匹配到多个 |
| 稳定性 | 高 — 针对组件结构设计 | 中 — 依赖录制时 DOM |
| 抗页面变化 | 中 — class 变化会断 | 低 — 通用选择器更容易误点 |
---
## 差异二:等待策略
### `pc自动化测试` — 显式等待 + 固定延时 + 容错
```javascript
// 1. 明确等待元素可见
await this.meetingNameInput.waitFor({ state: 'visible' });
// 2. 固定延时确保弹窗动画完成
await this.page.waitForTimeout(800);
// 3. 多重容错:多种方式尝试
if (await emptyBtn.first().isVisible().catch(() => false)) {
// 方式A
} else if (await addSquare.first().isVisible().catch(() => false)) {
// 方式B
}
```
### `platform-auto-test` — 简单等待 + 静默失败
```python
def _do_click(self, params: dict) -> None:
selector = params.get("selector", "")
# 等待10秒,失败则直接忽略
try:
self._page.wait_for_selector(selector, state="visible", timeout=10000)
except Exception:
pass # ← 静默忽略,继续尝试点击
# 尝试点击,失败则静默忽略
try:
self._page.click(selector, timeout=5000)
return
except Exception:
pass # ← 静默忽略
# iframe fallback
for frame in self._page.frames:
...
```
**问题**:静默太多,步骤报告显示 "passed" 但实际上可能根本没点到正确元素。
### 对比
| 等待策略 | pc自动化测试 | platform-auto-test |
|---------|-------------|-------------------|
| 元素可见等待 | ✅ 明确 waitFor({state:'visible'}) | ⚠️ 等待超时后静默继续 |
| 弹窗/动画等待 | ✅ 固定延时 500-800ms | ❌ 无 |
| 操作间间隔 | ✅ 每步间有 waitForTimeout | ❌ 连续执行 |
| 失败反馈 | ❌ throw error(测试直接 fail) | ⚠️ 静默 pass,但操作无效 |
---
## 差异三:操作交互的健壮性
### `pc自动化测试` 的做法
```javascript
// 清空输入框:先 triple-click 全选 → Ctrl+A → Delete → fill
await this.meetingNameInput.click({ clickCount: 3, force: true });
await this.page.keyboard.press('Control+A');
await this.page.keyboard.press('Delete');
await this.meetingNameInput.fill(name);
// 点击被覆盖的元素:force: true 绕过覆盖层
await moreBtn.click({ force: true });
// 多选器回退:一个选择器不行就换一个
var confirmBtn = dialogContainer.locator('button').filter({ hasText: /^确定$/ }).first();
if (!btnVisible) {
confirmBtn = dialogContainer.locator('.el-dialog__footer button, .el-message-box__btns button')
.filter({ hasText: '确定' }).first();
}
```
### `platform-auto-test` 的做法
```python
# 简单的 fill
self._page.fill(selector, str(value))
# 简单的 click
self._page.click(selector, timeout=5000)
```
### 关键差别
| 操作 | pc自动化测试 | platform-auto-test |
|------|-------------|-------------------|
| 输入框清空 | triple-click + Ctrl+A + Delete + fill | 直接 fill(不清空已有内容) |
| 覆盖层点击 | `force: true` 绕过 | 无 force,被覆盖时失败 |
| 多选器回退 | 2-3 种选择器依次尝试 | 无回退 |
| 按钮匹配 | `filter({hasText})` 文本匹配 | 无 |
---
## 差异四:浏览器配置
```javascript
// pc自动化测试 — 使用系统已安装的 Chrome
channel: 'chrome',
launchOptions: {
args: [
'--start-maximized',
'--window-size=1920,1080',
'--disable-infobars',
],
},
```
```python
# platform-auto-test — 使用 Playwright 内置 Chromium
self._browser = self._playwright.chromium.launch(
headless=self.headless,
args=["--no-sandbox", "--disable-setuid-sandbox"],
)
```
**差异**:系统 Chrome 更接近用户真实环境,内置 Chromium 的行为可能与真实环境有偏差。`--no-sandbox` 模式在某些场景下会导致 CSS 渲染差异。
---
## 差异五:日志和调试能力
```javascript
// pc自动化测试 — 每步都有详细日志
console.log(` ✓ 已填写会议名称: ${name}`);
console.log(` ✓ 已打开会议室选择弹窗`);
console.log(` ✓ 已点击"下一步"`);
```
```python
# platform-auto-test — 缺乏细粒度执行日志
logger.debug(f"填充输入框: {params.get('selector', '')} = {params.get('value', '')}")
```
**问题**`platform-auto-test` 的日志级别是 `debug`,在默认配置下**看不到**。即使步骤失败了,也只能看到 "failed" 状态,不知道具体卡在哪一步的哪个选择器。
---
## 差异六:测试用例的设计粒度
| 维度 | pc自动化测试 | platform-auto-test |
|------|-------------|-------------------|
| 用例粒度 | **业务场景级**(51 个完整工作流) | **页面访问级**(26 个基本验证) |
| 单用例步骤数 | 10-30+ 步(全流程) | 3 步(导航 + 等待 + 截图) |
| 复杂交互 | 多步表单填写、弹窗交互、下拉菜单 | 基本页面访问 |
| 数据清理 | 自动取消会议,不留脏数据 | 无 |
`pc自动化测试` 的用例更"深"——每个用例经过多次"创建→验证→编辑→验证"的循环,每一步都验证了上一步确实生效。而 `platform-auto-test` 的用例更"浅"——大多是"访问页面 → 等待 → 截图",即使有操作也是单步的,无法形成验证闭环。
---
## 根因总结
| 根因 | 影响程度 | 说明 |
|------|---------|------|
| **选择器质量** | ★★★★★ | pc 的手写选择器 vs 平台的录制自动生成,这是最大差距 |
| **等待策略** | ★★★★☆ | pc 有显式等待 + 固定延时 + 容错,平台过于简陋 |
| **交互健壮性** | ★★★★☆ | pc 有 force click、多选器回退、文本匹配,平台没有 |
| **用例深度** | ★★★☆☆ | pc 的用例步骤多、形成验证闭环,平台的用例太浅 |
| **浏览器配置** | ★★☆☆☆ | 系统 Chrome vs 内置 Chromium,影响较小 |
| **日志调试** | ★★☆☆☆ | pc 能看到每步详细输出,平台日志不直观 |
---
## 改进建议
### 短期(可快速落地)
1. **选择器优化**:改进录制器,生成带上下文的多级选择器(如 `.bookv3-slim-form .field-name input`),而非单层 class
2. **增加等待延时**:每个关键操作后加 300-800ms 等待,给页面动画留时间
3. **添加 force click**:对 click 操作增加 `force=True` 参数,绕过覆盖层
4. **优化日志**:将关键操作日志从 debug 改为 info,让用户在界面上能看到每步执行情况
### 中期
5. **引入多选器回退机制**:每个步骤存 2-3 个备选选择器,主选择器失败时自动回退
6. **改进录制器**:录制时不仅捕获 class,还捕获 `placeholder``aria-label``text` 等稳定属性
7. **丰富用例深度**:将现有的 26 个浅用例扩展为包含"操作+验证"的完整业务流程
### 长期
8. **引入视觉定位**:使用 AI/Screenshot 比对来定位元素,而非完全依赖 DOM 选择器
9. **自动愈合 (Self-Healing)**:当选择器失效时,自动分析 DOM 差异并修复选择器
---
## 一句话总结
**不是平台本身不准,而是"手写测试代码"天然比"录制器自动生成"更精准**——人写代码时知道要点击哪个具体的按钮,而录制器只能记录"点击了第 3 个 `.el-button`",后者的容错空间小得多。改进录制器的选择器生成策略,是拉平差距的最有效手段。
\ No newline at end of file
此差异已折叠。
# HANDOFF — 会话交接文档
> **生成时间**: 2026-07-13
> **当前分支**: `platform-auto-test`
> **最近提交**: `3c4cff2` — feat(test): 新增测试用例依赖管理和批量操作功能
---
## 一、项目概览
**项目名称**: 平台自动化测试可视化系统 (platform-auto-test)
**一句话描述**: 一个 Web 可视化自动化测试平台,核心创新点在于**用例录制器**功能——用户在目标网站上操作,系统自动捕获行为并转换为测试步骤,无需手动编写测试代码。
### 技术栈
| 层级 | 技术 | 版本 |
|------|------|------|
| 后端 | FastAPI + SQLAlchemy + SQLite | Python 3.x |
| 前端 | Vue 3 + Element Plus + Vite 5 + TypeScript | Node 18+ |
| 浏览器自动化 | Playwright (Chromium) | 1.40.x |
| 实时通信 | WebSocket (FastAPI 内置) | — |
| 图表库 | ECharts | 5.5.x |
### 目录结构(关键路径)
```
platform-auto-test/
├── backend/app/
│ ├── main.py # FastAPI 入口
│ ├── executors/playwright_executor.py # 执行引擎核心
│ ├── services/ # 业务逻辑层
│ ├── routers/ # API 路由层
│ ├── models/ # 数据模型(含新增的依赖模型)
│ └── scripts/ # 辅助脚本(15个)
├── frontend/src/
│ ├── views/ # 7 个页面组件
│ └── api/ # API 调用层
├── Docs/PRD/自动化测试平台/ # 所有需求/计划/进度文档
│ ├── _PRD_平台自动化测试可视化系统需求文档.md
│ ├── _PRD_平台自动化测试可视化系统需求文档_计划执行.md
│ ├── 当前进度记录.md
│ ├── 测试结果汇总报告.md
│ └── ... (其他 PRD 文档)
└── .claude/skills/ # Claude Code 技能
├── CreateCMD/ # 打开 CMD 窗口
├── GitCommit/ # 代码提交辅助
├── Handoff/ # 生成交接文档(本技能)
├── prd-plan/ # PRD → 执行计划
└── prd-code/ # 执行计划 → 代码
```
---
## 二、当前任务
本次会话主要做了以下工作:
1. **修复了 `CreateCMD` skill 的路径配置**:将 bat 文件中的 `E:\nodejs\claude.cmd` 更新为 `C:\nvm4w\nodejs\claude.cmd``cd` 目录从 `E:\GithubData\ubains-module-test\platform-auto-test` 更新为 `C:\PycharmData\ubains-module-test\platform-auto-test`
2. **新增了 `Handoff` skill**:用于在会话结束时自动生成 HANDOFF.md 交接文档。
3. **生成了本 HANDOFF.md**
---
## 三、已完成事项
| 里程碑 | 状态 | 说明 |
|--------|------|------|
| Phase 1-4 全部完成 | ✅ | 基础框架、核心功能、执行与可视化、分析与报告 |
| Phase 5 约 30% | 🔄 | 已完成为 Excel 导入、执行引擎调通、数据库锁修复 |
| 26 个测试用例全部通过 | ✅ | 含 7 高优先级 + 12 中优先级 + 7 低优先级 |
| 深层交互测试(5 个) | ✅ | 表单填写、按钮点击、断言验证 |
| WebSocket 实时推送 | ✅ | 毫秒级延迟,6 种消息类型 |
| 报告生成和导出 | ✅ | HTML 报告,支持预览/导出/下载 |
| 数据清理功能 | ✅ | 截图清理、孤儿清理、数据库优化 |
| 选择器优化 | ✅ | 多选器回退策略,19 个用例更新 |
| CI/CD 集成 | ✅ | GitHub Actions 工作流 |
| 测试用例依赖管理 | ✅ | 新增依赖模型、批量操作、拓扑排序 |
| GitCommit skill 完善 | ✅ | 三重门控、分支检查、推送确认 |
---
## 四、卡点和问题
### 当前无严重阻塞项
Phase 5 剩余任务尚未启动,但无技术阻断:
| 待办 | 优先级 | 说明 |
|------|--------|------|
| 系统配置页面 (Settings.vue) | P0 | 占位页面,需实现功能 |
| Pinia 状态管理 | P0 | 需创建 executionStore / settingsStore |
| 单元测试 | P0 | 后端 pytest + 前端 Vitest |
| 数据库迁移 (Alembic) | P1 | 当前手动管理表结构 |
| 后台执行优化 | P1 | Windows 子进程/队列方式异步执行 |
| 前端布局抽取 | P1 | 独立 Layout 组件 + 侧边栏折叠 |
| 定时任务 | P2 | Celery/APScheduler |
| Docker 部署 | P2 | Docker Compose 配置 |
### 已知风险
- **SQLite 并发瓶颈**:当前使用 SQLite,多用户并发场景下可能出现 `database is locked`,远期需迁移 PostgreSQL
- **Windows 子进程限制**:Playwright 在 Windows asyncio 下无法正常启动,当前用同步 + 线程池规避,非完美方案
- **无用户权限系统**:当前无认证/授权机制,仅限内网使用
---
## 五、踩坑记录(绝对不要重复踩)
### 坑 1:Playwright 在 Windows 上必须用同步 API
- **现象**`NotImplementedError` (SelectorEventLoop 不支持子进程),`It looks like you are using Playwright Sync API inside the asyncio loop`
- **根因**:Windows 默认 `SelectorEventLoop` 不支持子进程管道;`ProactorEventLoop` 虽支持但 Playwright 仍会检测 asyncio 环境
- **正确做法**
1. `main.py` 设置 `asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())`
2. 使用 `sync_playwright`(同步 API)而非 `async_playwright`
3. 通过 `loop.run_in_executor()` 在线程池中执行
4. 每个测试用例创建独立的 `PlaywrightExecutor` 实例
- **不要**:尝试用 `asyncio.create_task``threading``multiprocessing` 在 Windows 上实现异步执行——都已试过,全部失败
### 坑 2:每个用例必须用独立的执行器实例
- **现象**:第二次执行时 `execute_case()` 调用 `start()` 失败
- **根因**:共享执行器在 `stop()` 后将 `_is_running` 设为 `False`,下次 `start()` 在 asyncio 环境中失败
- **正确做法**:每次执行都 `PlaywrightExecutor(...)` 新实例
### 坑 3:前端 baseURL 不能带 `/api`
- **现象**:请求路径变成 `/api/api/cases` 导致 404
- **根因**`baseURL: '/api'` 与路由路径 `/api/cases` 叠加
- **正确做法**`baseURL` 设为空字符串,路由路径写完整 `/api/...`
### 坑 4:SQLite 并发写锁
- **现象**`SQLite database is locked`
- **正确做法**`check_same_thread=False` + `pool_pre_ping=True`,仍需注意同步执行期间的并发写入
### 坑 5:bash 中 `start` 不是 Windows 原生 cmd
- **现象**:CMD 窗口打开、cd 成功但 claude 不执行
- **根因**:bash 里的 `start` 解析为 `/usr/bin/start`(MSYS 封装),会损坏 `&&` 复合命令的引号
- **正确做法**`MSYS_NO_PATHCONV=1 cmd.exe /c start "Title" cmd /k "<bat 路径>"`
### 坑 6:`claude` 命令不在 Windows PATH
- **现象**:新开的 cmd 窗口敲 `claude` 报"不是内部或外部命令"
- **正确做法**:用 `which claude` 找出完整路径,补 `.cmd` 后缀,用完整路径调用
### 坑 7:前端 axios 超时
- 默认 30s 超时,但 Playwright 执行可能需要几分钟
- **正确做法**:超时设为 5 分钟(300000ms)
---
## 六、重要文档索引
| 文档 | 路径 | 说明 |
|------|------|------|
| PRD 主文档 | `Docs/PRD/自动化测试平台/_PRD_平台自动化测试可视化系统需求文档.md` | 项目需求定义 |
| 执行计划 | `Docs/PRD/自动化测试平台/_PRD_平台自动化测试可视化系统需求文档_计划执行.md` | 分阶段执行计划 |
| 当前进度记录 | `Docs/PRD/自动化测试平台/当前进度记录.md` | 含 26 个测试用例结果、关键决策 |
| 进度报告 | `Docs/PRD/自动化测试平台/_开发进度报告_20260709.md` | 含完整 API 清单、Phase 5 待办 |
| 测试结果汇总 | `Docs/PRD/自动化测试平台/测试结果汇总报告.md` | 执行结果详细数据 |
| 登录模块优化 | `Docs/PRD/自动化测试平台/_PRD_需求文档_登录模块测试优化.md` | 最新 PRD 需求 |
| 登录模块执行计划 | `Docs/PRD/自动化测试平台/_执行计划_登录模块测试优化.md` | 对应的执行计划 |
| 用例管理完成报告 | `Docs/PRD/自动化测试平台/测试用例管理优化-第一阶段完成报告.md` | 依赖管理功能完成报告 |
---
## 七、启动指南
```bash
# 后端(端口 8001)
cd backend
pip install -r requirements.txt
playwright install chromium
uvicorn app.main:app --reload --port 8001
# 前端(端口 3000,代理指向 8001)
cd frontend
npm install
npm run dev
```
**访问地址**
- 前端页面: http://localhost:3000
- API 文档: http://localhost:8001/docs
- 健康检查: http://localhost:8001/health
**被测系统**:https://192.168.5.44 (统一管理平台)
- 登录用户: admin@xty / Ubains@13579
- 验证码: csba(固定值)
---
## 八、本次会话详细信息
### 会话时间
2026-07-13 ~ 2026-07-13
### 执行的操作
1. 使用 `/CreateCMD 3` 打开 3 个 CMD 窗口运行 Claude
2. 修复 `CreateCMD` skill 的路径配置(从 `E:\` 更新为 `C:\`
3. 创建 `Handoff` skill
4. 生成本 HANDOFF.md
### 未提交的变更
- `.claude/skills/CreateCMD/_run_claude.bat` — 路径配置修改
- `pc自动化测试/` 目录 — 新出现的 untracked 目录
### 远程仓库
- `origin`: http://git.ubainsyun.com/bing/ubains-module-test.git
---
*本文档由 `/Handoff` skill 自动生成,供下一次会话快速恢复上下文。*
\ No newline at end of file
此差异已折叠。
......@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.case_service import CaseService
from app.schemas.test_case import StepDefinition
from app.utils.id_generator import generate_id
logger = logging.getLogger(__name__)
......@@ -60,21 +61,10 @@ class StartRecordingResponse(BaseModel):
message: str
class RecordedStep(BaseModel):
"""录制的步骤"""
order: int
name: str
action: str
selector: Optional[str] = None
value: Optional[str] = None
params: Optional[dict] = None
expected: Optional[str] = None
class AddStepRequest(BaseModel):
"""添加步骤请求"""
session_id: str
step: RecordedStep
step: StepDefinition
class AddAssertionRequest(BaseModel):
......@@ -176,13 +166,14 @@ async def add_step(
if not session.is_recording:
raise HTTPException(status_code=400, detail="录制会话已停止")
step_data = request.step.dict()
# 直接以 StepDefinition 格式存储(含 selectors/page_key/element_key/force/wait_after)
step_data = request.step.model_dump(by_alias=False, exclude_none=True)
session.steps.append(step_data)
logger.info(f"步骤已添加: session_id={request.session_id}, step_order={step_data['order']}")
logger.info(f"步骤已添加: session_id={request.session_id}, step_order={step_data.get('order')}")
return {
"message": "步骤已添加",
"step_order": step_data["order"],
"step_order": step_data.get("order"),
"total_steps": len(session.steps),
}
......@@ -208,13 +199,15 @@ async def add_assertion(
raise HTTPException(status_code=400, detail="录制会话已停止")
order = len(session.steps) + 1
# 统一存储为 StepDefinition 格式:selectors 列表 + params
step = {
"order": order,
"name": f"断言验证: {request.selector}",
"action": "assert",
"selector": request.selector,
"selectors": [request.selector] if request.selector else [],
"params": {
"selector": request.selector,
"selectors": [request.selector] if request.selector else [],
"type": request.type,
"expected": request.expected or "",
},
......@@ -284,23 +277,22 @@ async def save_as_case(
try:
case_service = CaseService(db)
# 构建步骤数据
# 录制器已统一为 StepDefinition 格式(含 selectors/page_key/element_key/force/wait_after)
# 直接透传给 CaseService,无需手动字段转换
steps = []
for s in session.steps:
# 保留所有已知字段,未知字段原样放在 params 中
step = {
"order": s.get("order"),
"name": s.get("name", ""),
"action": s.get("action", ""),
"params": s.get("params", {}),
"expected": s.get("expected", ""),
}
if s.get("params"):
step["params"] = s["params"]
else:
step["params"] = {
"selector": s.get("selector", ""),
"value": s.get("value", ""),
}
if s.get("expected"):
step["expected"] = s["expected"]
# 透传可选字段(执行器识别)
for opt_field in ("selectors", "page_key", "element_key", "force", "wait_after"):
if opt_field in s and s[opt_field] is not None:
step[opt_field] = s[opt_field]
steps.append(step)
# 创建用例
......@@ -314,6 +306,7 @@ async def save_as_case(
"config": {
"timeout": 30000,
"screenshot": True,
"step_retry_count": 2,
},
}
......
......@@ -32,6 +32,11 @@ class StepDefinition(BaseModel):
action (str): 动作类型 (navigate/click/fill/assert等)
params (dict): 动作参数
expected (str): 预期结果
selectors (Optional[List[str]]): 多选择器回退链
page_key (Optional[str]): SelectorMapper 页面标识
element_key (Optional[str]): 元素标识
force (Optional[bool]): 强制点击
wait_after (Optional[int]): 自定义操作后延时(ms)
"""
order: int = Field(..., ge=1, description="步骤顺序")
......@@ -39,6 +44,11 @@ class StepDefinition(BaseModel):
action: str = Field(..., description="动作类型")
params: Dict[str, Any] = Field(default_factory=dict, description="动作参数")
expected: str = Field(default="", description="预期结果")
selectors: Optional[List[str]] = Field(None, description="多选择器回退链")
page_key: Optional[str] = Field(None, description="SelectorMapper 页面标识")
element_key: Optional[str] = Field(None, description="元素标识")
force: Optional[bool] = Field(None, description="强制点击")
wait_after: Optional[int] = Field(None, description="自定义操作后延时(ms)")
class TestCaseBase(BaseModel):
......
......@@ -229,10 +229,20 @@ class ExecutionService:
loop = asyncio.get_event_loop()
# 定义一个共享的执行函数,复用同一个执行器
def run_all_cases_sync(cases_list, exec_config):
"""在一个执行器中执行所有用例,保持登录态"""
def run_all_cases_sync(cases_list, exec_config, ev_loop, broadcast_fn):
"""
在一个执行器中执行所有用例,保持登录态
Args:
cases_list: 用例字典列表
exec_config: 执行配置
ev_loop: 主事件循环(用于线程安全广播)
broadcast_fn: 异步广播函数(async broadcast)
"""
executor_config = exec_config or {}
executor_config["auto_login"] = True
# 透传单步重试次数(默认 2)
executor_config.setdefault("step_retry_count", exec_config.get("step_retry_count", 2) if exec_config else 2)
executor = PlaywrightExecutor(config=executor_config)
results = []
......@@ -242,8 +252,36 @@ class ExecutionService:
# 第一个用例会自动登录
for i, case_dict in enumerate(cases_list):
logger.info(f"执行用例 {i+1}/{len(cases_list)}: {case_dict.get('name', '')}")
result = executor.execute_case(case=case_dict)
case_id = case_dict.get("id", "")
case_name = case_dict.get("name", "")
logger.info(f"⏳ 执行用例 {i+1}/{len(cases_list)}: {case_name}")
# 构造线程安全的步骤回调(跨线程提交到事件循环)
def step_callback(step_result, _cid=case_id, _cname=case_name):
try:
asyncio.run_coroutine_threadsafe(
broadcast_fn(execution_id, {
"type": "step_update",
"data": {
"execution_id": execution_id,
"case_id": _cid,
"case_name": _cname,
"step_order": step_result.order,
"step_name": step_result.name,
"action": step_result.action,
"status": step_result.status,
"duration": step_result.duration,
"screenshot": step_result.screenshot,
"log": step_result.log,
"error": step_result.error,
},
}),
ev_loop,
)
except Exception as e:
logger.error(f"✗ 步骤回调提交失败: {str(e)}")
result = executor.execute_case(case=case_dict, callback=step_callback)
results.append(result)
return results
......@@ -261,12 +299,14 @@ class ExecutionService:
"case_dict": case.to_dict(),
})
# 在线程池中批量执行
# 在线程池中批量执行(传入事件循环和广播函数以接通 WebSocket 步骤回调)
all_results = await loop.run_in_executor(
None,
run_all_cases_sync,
[c["case_dict"] for c in cases_to_run],
config
config,
loop,
manager.broadcast,
)
# 更新结果
......
......@@ -389,6 +389,57 @@ class SelectorMapper:
"""获取通用选择器"""
return self.selectors.get("common", {})
def resolve_selectors(
self,
page_key: Optional[str] = None,
element_key: Optional[str] = None,
) -> List[str]:
"""
通过 page_key + element_key 解析完整选择器链
会在 SELECTORS 中查找以 page_key 为根的所有 category,
收集 element_key 对应的选择器列表。
Args:
page_key (Optional[str]): 页面标识(如 "login")
element_key (Optional[str]): 元素标识(如 "username")
Returns:
List[str]: 选择器列表(可能为空)
"""
if not page_key or not element_key:
return []
page_def = self.selectors.get(page_key)
if not isinstance(page_def, dict):
return []
collected: List[str] = []
# 情况1:element_key 直接映射到顶层列表(如 common.loading)
top_candidate = page_def.get(element_key)
if isinstance(top_candidate, list) and top_candidate:
collected.extend(top_candidate)
# 情况2:element_key 嵌套在某个 category 下
for category_name, category in page_def.items():
# 跳过非字典字段(如 url)
if not isinstance(category, dict):
continue
# 直接以 element_key 为键的列表
candidate = category.get(element_key)
if isinstance(candidate, list) and candidate:
collected.extend(candidate)
# 去重但保留顺序
seen = set()
deduped: List[str] = []
for s in collected:
if s not in seen:
seen.add(s)
deduped.append(s)
return deduped
# 全局选择器映射器实例
mapper = SelectorMapper()
......
......@@ -18,6 +18,16 @@ export interface StepDefinition {
params: Record<string, any>
/** 预期结果 */
expected?: string
/** 多选择器回退链 */
selectors?: string[]
/** SelectorMapper 页面标识 */
pageKey?: string
/** 元素标识 */
elementKey?: string
/** 强制点击 */
force?: boolean
/** 自定义操作后延时(ms) */
waitAfter?: number
}
/** 测试用例接口 */
......
......@@ -62,6 +62,27 @@
</span>
</el-progress>
</div>
<!-- 实时步骤时间线 -->
<div class="live-timeline" v-if="hasLiveSteps">
<div class="timeline-title">实时步骤时间线</div>
<el-scrollbar max-height="280px">
<div
v-for="(step, idx) in allLiveSteps"
:key="`${step.case_id}-${step.step_order}-${idx}`"
class="timeline-item"
>
<span class="timeline-icon">{{ getStepIcon(step.status) }}</span>
<span class="timeline-step-name">{{ step.step_name }}</span>
<el-tag :type="getResultType(step.status)" size="small" effect="plain">
{{ getResultText(step.status) }}
</el-tag>
<span class="timeline-duration" v-if="step.duration">
{{ step.duration.toFixed(2) }}s
</span>
</div>
</el-scrollbar>
</div>
</el-card>
<!-- 执行操作栏 -->
......@@ -298,6 +319,9 @@ const resultsLoading = ref<Record<string, boolean>>({})
// 用例列表缓存(用于选择用例时显示名称)
const casesCache = ref<Record<string, any>>({})
// 实时步骤时间线(按 case_id 聚合)
const liveSteps = ref<Record<string, any[]>>({})
const runConfig = ref({
name: '',
environment: 'default',
......@@ -339,6 +363,32 @@ const passRateClass = computed(() => {
return 'rate-danger'
})
// 实时步骤时间线:聚合所有用例的步骤
const allLiveSteps = computed(() => {
const all: any[] = []
for (const cid in liveSteps.value) {
for (const s of liveSteps.value[cid]) {
all.push({ ...s, case_id: cid })
}
}
// 按 step_order 排序
all.sort((a, b) => (a.step_order || 0) - (b.step_order || 0))
return all
})
const hasLiveSteps = computed(() => allLiveSteps.value.length > 0)
const getStepIcon = (status: string) => {
const map: Record<string, string> = {
passed: '✓',
failed: '✗',
running: '⏳',
skipped: '⏭',
pending: '⋯',
}
return map[status] || '•'
}
// ==================== 方法定义 ====================
const getStatusType = (status: string) => {
......@@ -558,6 +608,8 @@ const cancelExecution = async (id: string) => {
const connectWebSocket = (executionId: string) => {
// 先断开旧连接
disconnectWebSocket()
// 清空实时步骤时间线
liveSteps.value = {}
wsClient = new WebSocketClient(executionId)
......@@ -575,8 +627,19 @@ const connectWebSocket = (executionId: string) => {
})
wsClient.on('step_update', (data: any) => {
// 实时更新步骤状态
// 实时更新步骤状态 + 聚合到时间线
console.log('步骤更新:', data.step_name, data.status)
const cid = data.case_id
if (!liveSteps.value[cid]) {
liveSteps.value[cid] = []
}
const arr = liveSteps.value[cid]
const idx = arr.findIndex(s => s.step_order === data.step_order)
if (idx >= 0) {
arr[idx] = data
} else {
arr.push(data)
}
})
wsClient.on('case_complete', (data: any) => {
......@@ -590,6 +653,28 @@ const connectWebSocket = (executionId: string) => {
results.push(data)
}
}
// 用例完成时合并时间线到 rowResults 的 steps_result
const steps = liveSteps.value[data.case_id]
if (steps && steps.length) {
// 将时间线步骤映射回 steps_result 格式
const mapped = steps.map(s => ({
order: s.step_order,
name: s.step_name,
action: s.action,
status: s.status,
duration: s.duration,
screenshot: s.screenshot,
log: s.log,
error: s.error,
}))
if (rowResults.value[activeExecution.value?.id]) {
const results = rowResults.value[activeExecution.value?.id]
const ridx = results.findIndex(r => r.case_id === data.case_id)
if (ridx >= 0 && (!results[ridx].steps_result || results[ridx].steps_result.length === 0)) {
results[ridx].steps_result = mapped
}
}
}
})
wsClient.on('execution_complete', (data: any) => {
......@@ -670,6 +755,44 @@ onUnmounted(() => {
font-size: 14px;
}
}
.live-timeline {
margin-top: 16px;
padding-top: 12px;
border-top: 1px dashed #ebeef5;
.timeline-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.timeline-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
font-size: 13px;
border-bottom: 1px dashed #f0f0f0;
.timeline-icon {
width: 18px;
text-align: center;
font-weight: 600;
}
.timeline-step-name {
flex: 1;
color: #303133;
}
.timeline-duration {
color: #909399;
font-size: 12px;
}
}
}
}
.page-header {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论