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

feat(api-test): finally api_call 端点真实化 + 浏览器会话 token 自动注入

- AssertionEngine 新增 body_not_contains 断言(数据自清理"无残留"校验,expected 空视为失败防假通过)
- _do_api_call 新增 _inject_session_auth:目标为被测系统且未显式指定鉴权头时,
  自动从浏览器 localStorage.meet_token 注入 authorization,复用主流程登录态
- 修复 finally/on_failure 步骤主流程双跑:主循环加守卫推迟到后置块单次执行
- 示范用例 finally 步骤换真实端点 GET /meetingV3/api/message/getMessagePageList
  (5.44 会议列表首屏 HTTP 接口,带 authorization 即可,无需 x-sign)

完整 17 步示范用例(含 finally 自清理)5.60 生产验收通过(exec_a8dbb9c4,
96.1s 全绿、5.44 零残留)。详见 HANDOFF_UI自动化.md 会话 24。
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 93b8ebd0
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
> **生成时间**: 2026-08-18 > **生成时间**: 2026-08-18
> **当前分支**: `platform-auto-test` > **当前分支**: `platform-auto-test`
> **最近提交**: `345572f2` fix: 语义化页面身份识别修复(+ `06b6d9c0` HANDOFF 更新) > **最近提交**: `345572f2` fix: 语义化页面身份识别修复(+ `06b6d9c0` HANDOFF 更新)
> **状态**: 🟢 **会话23:语义化机制已部署 5.60 生产并验证通过——16步主流程经 5.60 网页连跑两次全绿(107.5s/106.9s),5.44 零残留,步骤15「取消会议」修复在生产确认有效;剩余:finally api_call 端点替换(完整 17 步示范用例收尾)** > **状态**: 🟢 **会话24:finally api_call 端点真实化 + 完整 17 步示范用例 5.60 生产验收通过——真实端点 `GET /meetingV3/api/message/getMessagePageList` + 浏览器 token 自动注入 + `body_not_contains` 断言,完整 17 步(含 finally 自清理)在 5.60 跑通、5.44 零残留,并修复 finally 步骤双跑缺陷(步骤 17 单次执行)**
--- ---
...@@ -84,9 +84,34 @@ ...@@ -84,9 +84,34 @@
**结论:步骤 15「选择器不能为空」修复在生产环境确认有效。** **结论:步骤 15「选择器不能为空」修复在生产环境确认有效。**
### ⚠️ 剩余风险(PRD 九 ### 🎯 finally 端点真实化 + 完整 17 步验收通过(会话 24,2026-08-18
完整 17 步示范用例的 finally 清理步骤端点 **`POST /api/meeting/query` 仍是 PRD 占位**——5.44 meetingV3 列表查询走 MQTT,无简单 HTTP 端点。须按真实接口替换 `api_case.steps.url/body` 后,完整版(含 finally 自清理)才能通过。**finally 步骤失败会把主流程 passed 翻为 failed("清理步骤失败")**。已部署的 16 步主流程不受影响。 **完整 17 步示范用例(含 finally 自清理)在 5.60 生产验收通过。**
**根因**:原 finally 步骤 `POST /api/meeting/query` 是 PRD 占位——5.44 meetingV3 列表实时刷新走 MQTT,但**首屏列表走 HTTP 接口 `GET /meetingV3/api/message/getMessagePageList`**(浏览器抓包确认;带 `authorization` 头即可通过,无需 x-sign 签名头)。
**修复(3 处后端改动 + 1 处缺陷修复)**
1. **`api_test_executor.py`**:AssertionEngine 新增 `body_not_contains` 断言——原始响应体不应包含指定字符串("无残留"校验;expected 为空视为失败防假通过)
2. **`playwright_executor.py` `_do_api_call`**:新增 `_inject_session_auth`——api_call 目标为被测系统且未显式指定鉴权头时,自动从浏览器会话 `localStorage.meet_token` 注入 authorization(finally 复用主流程登录态,无需手动维护会过期的 token)
3. **`create_semantic_meeting_case.py`**:finally 步骤换真实端点,body(GET→query params)带 `companyNumber/queryType/pageSize/search={__CTX:meetingName__}`,断言 `body_not_contains` 会议名
4. **finally 步骤双跑缺陷**:主流程循环原未跳过 `run_on:finally/on_failure`,导致 finally 步骤主循环+后置块执行两次 → 加守卫推迟到后置块统一执行(步骤 17 单次)
**5.60 执行结果**(API 触发:`POST /api/executions` 创建 → `/run` 同步执行 → `GET .../results/{case_id}` 拉步骤):
| 执行 | ID | 结果 | 用时 | 步骤 17 finally | 5.44 残留 |
|------|-----|------|------|-----------------|----------|
| 修复前(双跑) | `exec_431e2e23` | passed | 96.7s | ✅ 出现两次但均通过 | 无 |
| 修复后 | `exec_a8dbb9c4` | **passed** | 96.1s | ✅ **单次执行** | 无 |
- 16 步主流程 + finally 全绿;步骤 1-3 URL 直达跳过
- finally 断言 `body_not_contains` 校验 5.44 接口响应不含该会议名 → 通过
- 执行后核验 5.44 会议列表「暂无数据 / 共 0 条」——**零残留**
**结论:完整 17 步示范用例(含 finally 数据自清理)5.60 生产验收通过,阶段 6 最终验收完成。**
### ⚠️ 已知限制(非阻塞)
- `body_not_contains` 断言类型未同步进前端 CaseStepEditor 的 ASSERT_TYPE_OPTIONS(用例由脚本创建、前端仅展示),后续前端补充该选项
--- ---
...@@ -94,6 +119,7 @@ ...@@ -94,6 +119,7 @@
| 会话 | 日期 | 主题 | 结果 | | 会话 | 日期 | 主题 | 结果 |
|------|------|------|------| |------|------|------|------|
| 24 | 08-18 | **finally api_call 端点真实化 + 完整 17 步验收**:真实端点 + 浏览器 token 自动注入 + `body_not_contains` 断言 + 修复 finally 双跑 | ✅ 完整 17 步 5.60 全绿、零残留,阶段 6 完成 |
| 23 | 08-18 | **语义化机制部署 5.60 + 生产验证**:37 后端文件 + 前端 dist 部署,5.60 网页连跑两次全绿、零残留 | ✅ 步骤15 修复生产确认,提交 `345572f2`+`06b6d9c0` | | 23 | 08-18 | **语义化机制部署 5.60 + 生产验证**:37 后端文件 + 前端 dist 部署,5.60 网页连跑两次全绿、零残留 | ✅ 步骤15 修复生产确认,提交 `345572f2`+`06b6d9c0` |
| 22 | 08-18 | **语义化机制真实验证**:16 步主流程连跑两次全绿 + 零残留 + 修复步骤15 页面识别根因 | ✅ 验证通过,提交 `345572f2` | | 22 | 08-18 | **语义化机制真实验证**:16 步主流程连跑两次全绿 + 零残留 + 修复步骤15 页面识别根因 | ✅ 验证通过,提交 `345572f2` |
| 21 | 08-18 | **语义化机制阶段6**:前端语义编辑 + 语义化重写示范用例 + 参数持久化修复 | ✅ 代码已提交 `7cf4f03f` | | 21 | 08-18 | **语义化机制阶段6**:前端语义编辑 + 语义化重写示范用例 + 参数持久化修复 | ✅ 代码已提交 `7cf4f03f` |
...@@ -192,11 +218,11 @@ cd frontend && npm run build ...@@ -192,11 +218,11 @@ cd frontend && npm run build
### P0(高优先级) ### P0(高优先级)
- [ ] **阶段6 最终验收:完整 17 步示范用例(含 finally 自清理)** - [x] **阶段6 最终验收:完整 17 步示范用例(含 finally 自清理)**(会话24 完成)
-**16 步主流程真实验证通过**(本地会话22 + 5.60 生产会话23,各连跑两次全绿、零残留)——提交 `345572f2` -**16 步主流程真实验证通过**(本地会话22 + 5.60 生产会话23,各连跑两次全绿、零残留)——提交 `345572f2`
-**已部署 5.60 生产**(37 后端文件 + 前端 dist,含 semantic_resolver / ui_context / 映射表 v2 / 前端语义编辑)——部署脚本 `backend/_deploy_560_semantic.py` -**已部署 5.60 生产**(37 后端文件 + 前端 dist,含 semantic_resolver / ui_context / 映射表 v2 / 前端语义编辑)——部署脚本 `backend/_deploy_560_semantic.py`
- [ ] **替换 finally api_call 端点**`POST /api/meeting/query` 为 PRD 占位,5.44 走 MQTT 无 HTTP 端点,按真实接口替换 - **finally api_call 端点真实化**`GET /meetingV3/api/message/getMessagePageList` + 浏览器 token 自动注入 + `body_not_contains` 断言
- [ ] 完整版(17 步含 finally 自清理)「新建会议-语义化重写示范」在 5.60 真实执行验收 - **完整版(17 步含 finally 自清理)5.60 真实执行验收通过**`exec_a8dbb9c4`,96.1s,全绿、零残留)
- [ ] **修复 3 个预存映射问题**(2026-08-13 确认,非 P0 回归) - [ ] **修复 3 个预存映射问题**(2026-08-13 确认,非 P0 回归)
- `点击选择时间` 误配 → 会议列表-全部-时间(期望:创建会议-选择时间) - `点击选择时间` 误配 → 会议列表-全部-时间(期望:创建会议-选择时间)
- `点击会议室列表` 误配 → 首页-置顶功能-会议室列表(期望:创建会议-会议室列表) - `点击会议室列表` 误配 → 首页-置顶功能-会议室列表(期望:创建会议-会议室列表)
...@@ -219,4 +245,4 @@ cd frontend && npm run build ...@@ -219,4 +245,4 @@ cd frontend && npm run build
--- ---
*本文档由 Claude Code 于 2026-08-18 更新(会话 23:语义化机制部署 5.60 生产并验证通过后更新)。* *本文档由 Claude Code 于 2026-08-18 更新(会话 24:finally 端点真实化 + 完整 17 步示范用例 5.60 生产验收通过后更新)。*
...@@ -141,10 +141,11 @@ class AssertionEngine: ...@@ -141,10 +141,11 @@ class AssertionEngine:
断言引擎 断言引擎
执行断言规则列表,判断一次 HTTP 请求结果是否符合预期。 执行断言规则列表,判断一次 HTTP 请求结果是否符合预期。
支持种断言类型: 支持种断言类型:
- status_code: HTTP 状态码比较 - status_code: HTTP 状态码比较
- response_time: 响应时间比较(ms) - response_time: 响应时间比较(ms)
- body_field: 响应体 JSONPath 字段检查(支持 $.a.b[0].c 和 $.a.b) - body_field: 响应体 JSONPath 字段检查(支持 $.a.b[0].c 和 $.a.b)
- body_not_contains: 响应体原始文本不包含指定字符串(数据自清理无残留校验)
- header: 响应头检查 - header: 响应头检查
""" """
...@@ -227,6 +228,14 @@ class AssertionEngine: ...@@ -227,6 +228,14 @@ class AssertionEngine:
result.passed = AssertionEngine._check_value(result.actual, operator, expected) result.passed = AssertionEngine._check_value(result.actual, operator, expected)
result.message = f"响应体断言 [{path}]: 期望{operator} {expected}, 实际 {result.actual}" result.message = f"响应体断言 [{path}]: 期望{operator} {expected}, 实际 {result.actual}"
elif atype == "body_not_contains":
# 数据自清理:原始响应体文本不应包含指定字符串(如已取消的会议名)
result.actual = step.response_body
expected_str = str(expected) if expected is not None else ""
# expected 为空(如 {__CTX:key__} 未渲染)视为失败,避免假通过
result.passed = bool(expected is not None and expected_str not in (result.actual or ""))
result.message = f"响应体不应包含 [{expected_str}]"
elif atype == "header": elif atype == "header":
path = assertion.get("path", "Content-Type") path = assertion.get("path", "Content-Type")
actual = step.response_headers.get(path, "") actual = step.response_headers.get(path, "")
......
...@@ -1009,6 +1009,12 @@ class PlaywrightExecutor: ...@@ -1009,6 +1009,12 @@ class PlaywrightExecutor:
logger.info(f"⏭ 跳过步骤 {order}: {step.get('name', '')}") logger.info(f"⏭ 跳过步骤 {order}: {step.get('name', '')}")
continue continue
# finally/on_failure 步骤不在主流程执行,统一推迟到主流程结束后的
# _run_finally_steps 后置块(否则会被执行两次——主循环一遍 + 后置一遍)
if step.get("run_on", "always") in ("finally", "on_failure"):
logger.debug(f"步骤 {order} run_on={step.get('run_on')},推迟到后置执行")
continue
# 执行步骤 # 执行步骤
step_result = self.execute_step(step, callback=callback) step_result = self.execute_step(step, callback=callback)
result.steps_result.append(step_result) result.steps_result.append(step_result)
...@@ -2080,6 +2086,11 @@ class PlaywrightExecutor: ...@@ -2080,6 +2086,11 @@ class PlaywrightExecutor:
if isinstance(rendered, dict): if isinstance(rendered, dict):
api_case = rendered api_case = rendered
# 被测系统接口需鉴权(无 token 返回 401 令牌不能为空):
# 目标为 TARGET_URL 且未显式指定鉴权头时,自动从浏览器会话注入
# localStorage.meet_token —— finally 数据自清理复用主流程已建立的登录态。
api_case = self._inject_session_auth(api_case)
client_config = { client_config = {
"target": {"base_url": settings.TARGET_URL, "verify_ssl": False, "timeout": 30} "target": {"base_url": settings.TARGET_URL, "verify_ssl": False, "timeout": 30}
} }
...@@ -2113,6 +2124,55 @@ class PlaywrightExecutor: ...@@ -2113,6 +2124,55 @@ class PlaywrightExecutor:
return result return result
def _inject_session_auth(self, api_case: dict) -> dict:
"""
api_call 目标为被测系统且步骤未显式指定鉴权头时,
自动从浏览器会话(localStorage.meet_token)注入 authorization。
背景:5.44 被测系统接口无 token 时返回 401「令牌不能为空」;api_call
(尤其 finally 数据自清理)复用主流程已建立的登录态最可靠,无需手动
维护会过期的 token。步骤显式配置了 authorization 时尊重作者配置。
"""
steps = api_case.get("steps") or {}
if not isinstance(steps, dict):
return api_case
url = steps.get("url") or ""
base = str(settings.TARGET_URL or "").rstrip("/")
if not base:
return api_case
full_url = url if str(url).startswith("http") else f"{base}/{str(url).lstrip('/')}"
if not full_url.startswith(base):
return api_case # 目标非被测系统,不注入
headers = steps.get("headers") or {}
if not isinstance(headers, dict):
headers = {}
if headers.get("authorization") or headers.get("Authorization"):
return api_case # 已显式指定,尊重作者配置
if self._page is None:
return api_case
try:
token = self._page.evaluate("localStorage.getItem('meet_token')")
except Exception as e:
logger.debug(f"[api_call] 浏览器会话 token 读取失败: {e}")
token = None
if not token:
logger.warning("[api_call] 浏览器无 meet_token,无法注入鉴权(被测系统接口可能 401)")
return api_case
headers = dict(headers)
headers["authorization"] = str(token)
steps = dict(steps)
steps["headers"] = headers
# 用浏览器 token,不再走账号库登录(_do_api_call 的 client_config 无 accounts)
steps.setdefault("auth_required", False)
api_case = dict(api_case)
api_case["steps"] = steps
logger.info(f"[api_call] 自动注入浏览器会话 token (meet_token {str(token)[:24]}...)")
return api_case
def _verify_step_state( def _verify_step_state(
self, self,
step: Dict[str, Any], step: Dict[str, Any],
......
...@@ -145,22 +145,25 @@ def build_steps() -> list: ...@@ -145,22 +145,25 @@ def build_steps() -> list:
"verify": {"type": "dialog", "text": "取消成功"}}, "verify": {"type": "dialog", "text": "取消成功"}},
# 22 finally 数据自清理:查询同名会议断言无残留(api_call,不依赖浏览器) # 22 finally 数据自清理:查询同名会议断言无残留(api_call,不依赖浏览器)
# ⚠️ 端点部署期联调:5.44 meetingV3 列表走 MQTT 无简单 HTTP 端点, # 真实端点(2026-08-18 抓包确认):GET /meetingV3/api/message/getMessagePageList
# 部署 5.60 时按真实接口替换 url/body(见 PRD 九、风险)。finally 失败 # - 5.44 meetingV3 会议列表首屏即该 HTTP 接口(实时刷新走 MQTT 但首屏走 HTTP)
# 会将主流程 passed 翻为 failed(标注"清理步骤失败"),故须在服务器联调通过。 # - 需 authorization 头(无 token 返回 401 令牌不能为空);_do_api_call 已实现
# 自动从浏览器会话 localStorage.meet_token 注入,无需手动维护 token
# - body(GET→query params)带 search 会议名;断言 body_not_contains 校验
# 响应体不含该会议名(无残留)。finally 失败会将主流程 passed 翻为 failed。
{"order": 17, "name": "清理残留会议(同名断言无残留)", "action": "api_call", {"order": 17, "name": "清理残留会议(同名断言无残留)", "action": "api_call",
"run_on": "finally", "run_on": "finally",
"params": { "params": {
"api_case": { "api_case": {
"name": "查询同名会议", "name": "查询同名会议",
"steps": { "steps": {
"method": "POST", "method": "GET",
"url": "/api/meeting/query", "url": "/meetingV3/api/message/getMessagePageList",
"headers": {"Content-Type": "application/json"}, "body": {"companyNumber": "CN-WQF-UBAINS", "queryType": 0,
"body": {"name": "{__CTX:meetingName__}"}, "pageNum": 1, "pageSize": 100,
"search": "{__CTX:meetingName__}"},
"assertions": [ "assertions": [
{"type": "body_field", "path": "$.data.total", {"type": "body_not_contains", "value": "{__CTX:meetingName__}"},
"operator": "equals", "value": 0},
], ],
}, },
}, },
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论