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

feat(smart-locate): P1/P2 智能定位统一执行路径 + micro-app 递归穿透与状态确认

- playwright_executor 新增 execute_action 统一动作分发层(navigate/click/fill/select/
  hover/wait/scroll/press/check/uncheck/assert/screenshot/api_call),供 SmartLocateService
  复用,保证智能定位路径与正式执行路径行为一致
- micro-app evaluator 重写:递归穿透嵌套 micro-app 与 shadowRoot,支持 :has-text/
  :visible/:nth-match;新增 check/uncheck 状态确认防假通过;fill 返回值确认
- smart_locate_service 统一执行路径(execute_action + verify_step),字段透传
- router /verify-step 真实验证:非法动作 400、业务失败返回 success=False
- 前端 Cases.vue/elementLocate.ts 步骤转换增加字段透传(verify/expected/semantic)
- 新增 test_micro_app_adapter / test_verify_step 单元与契约测试
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 eba91eac
......@@ -9,15 +9,17 @@
最后修改:2026-08-05
"""
import logging
import asyncio
from typing import List, Optional, Dict, Any
import logging
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, HTTPException, BackgroundTasks
from pydantic import BaseModel, Field
from app.services.smart_locate_service import SmartLocateService
from app.executors.playwright_executor import PlaywrightExecutor
from app.config import settings
logger = logging.getLogger(__name__)
......@@ -33,6 +35,14 @@ class SmartLocateStep(BaseModel):
name: str = Field(..., min_length=1, description="步骤名称")
action: str = Field(..., description="动作类型")
params: Dict[str, Any] = Field(default_factory=dict, description="动作参数")
# P1a:数据保真——round-trip 逐字段透传,避免前端→后端丢字段
semantic: Optional[Dict[str, Any]] = Field(None, description="语义目标(回传时保留)")
verify: Optional[Dict[str, Any]] = Field(None, description="状态验证(回传时保留)")
run_on: Optional[str] = Field(None, description="执行环境标识(回传时保留)")
selectors: Optional[Dict[str, Any]] = Field(None, description="选择器链(回传时保留)")
page_key: Optional[str] = Field(None, description="页面键(回传时保留)")
element_key: Optional[str] = Field(None, description="元素键(回传时保留)")
expected: Optional[Any] = Field(None, description="预期值(回传时保留)")
class SmartLocateRequest(BaseModel):
......@@ -131,15 +141,21 @@ async def smart_locate(
logger.info(f"收到智能定位请求: {len(request.steps)} 个步骤")
try:
# 转换步骤格式
# 转换步骤格式(逐字段透传,防止 round-trip 丢字段)
steps_data = []
for step in request.steps:
steps_data.append({
step_dict = {
'order': step.order,
'name': step.name,
'action': step.action,
'params': step.params
})
'params': step.params,
}
# P1a: 透传可选字段,确保路由层不丢弃
for field in ('semantic', 'verify', 'run_on', 'selectors', 'page_key', 'element_key', 'expected'):
val = getattr(step, field, None)
if val is not None:
step_dict[field] = val
steps_data.append(step_dict)
# 在线程池中执行(避免 Playwright 同步 API 在 asyncio 循环中的问题)
loop = asyncio.get_event_loop()
......@@ -188,37 +204,75 @@ async def smart_locate(
"/verify-step",
response_model=VerifyStepResponse,
summary="单步骤验证(调试用)",
description="验证选择器是否有效"
description="启动浏览器真实验证单个选择器步骤"
)
async def verify_step(request: VerifyStepRequest):
"""
单步骤验证(调试用)
用于验证单个选择器是否能在页面上正常工作。
启动一次性 Playwright 执行器,真实验证选择器在页面上的可操作性。
在专用新线程中执行(避免复用 asyncio 线程池导致 greenlet 卡死)。
Args:
request (VerifyStepRequest): 验证请求
Returns:
VerifyStepResponse: 验证结果
VerifyStepResponse: 验证结果(success + 安全化错误信息)
"""
logger.info(f"收到单步骤验证请求: selector={request.selector}, action={request.action}")
logger.info(f"收到单步骤验证请求: selector={request.selector}, action={request.action}, "
f"auto_login={request.auto_login}")
# 动作/参数校验(4xx)
supported = {
"click", "fill", "select", "hover", "wait", "scroll", "press",
"check", "uncheck", "assert", "element_exists", "visible", "text",
}
if request.action not in supported:
raise HTTPException(
status_code=400,
detail=f"不支持的验证动作: {request.action},支持: {sorted(supported)}"
)
if request.action == "fill" and request.value is None:
raise HTTPException(status_code=400, detail="fill 动作必须提供 value")
if not request.selector or not request.selector.strip():
raise HTTPException(status_code=400, detail="选择器不能为空")
def _verify_sync() -> bool:
"""在专用线程中执行验证,返回是否通过"""
executor = PlaywrightExecutor({
'headless': settings.PLAYWRIGHT_HEADLESS,
'timeout': 30000,
'screenshot': False,
'screenshot_dir': settings.SCREENSHOT_DIR,
'ignore_https_errors': True,
'base_url': request.page_url or settings.TARGET_URL,
})
return executor.verify_step(
selector=request.selector,
action=request.action,
value=request.value,
page_url=request.page_url,
auto_login=request.auto_login,
)
loop = asyncio.get_event_loop()
try:
# 这里简化实现:直接返回成功
# 实际可以启动 Playwright 执行验证
# 但为了性能,通常在智能定位时已包含验证
# 专用新线程池(max_workers=1,不复用 asyncio 线程池 worker),
# 与 execution_service 的专用 Playwright 线程模式一致,避免 greenlet 卡死。
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="verify-step") as pool:
ok = await loop.run_in_executor(pool, _verify_sync)
return VerifyStepResponse(
success=True,
message=f"选择器验证通过: {request.selector}"
success=ok,
message=f"选择器验证{'通过' if ok else '失败'}: {request.selector}"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"单步骤验证异常: {e}")
raise HTTPException(
status_code=500,
detail=f"验证失败: {str(e)}"
# 业务失败(选择器无效/元素不存在/动作执行失败)返回 success=False,而非 500
return VerifyStepResponse(
success=False,
message=f"验证失败: {str(e)[:200]}"
)
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_micro_app_adapter.py
模块描述:micro-app 通用定位适配(P2)单元测试
覆盖:
- _split_micro_app_selector::has-text / :visible / :nth-match / XPath 不支持
- _do_in_micro_apps:exists / text / click / fill / check/uncheck / count 各 action
(mock _page.evaluate 模拟 evaluator 返回值,含 fill 值确认 / check 状态确认防假通过)
- _do_check:micro-app 分支在页面路径失败后触发,状态达标/不达标的行为
- 递归 shadowRoot / 嵌套 micro-app 的 evaluator 选择器产出正确
作者:czj
创建日期:2026-08-26
"""
from unittest.mock import MagicMock
import pytest
from app.executors.playwright_executor import PlaywrightExecutor
# ==================== _split_micro_app_selector ====================
class TestSplitMicroAppSelector:
"""选择器解析:剥离 Playwright 伪选择器,识别 nth / unsupported"""
def test_plain_css(self):
css, text, nth = PlaywrightExecutor._split_micro_app_selector(".btn")
assert css == ".btn" and text == "" and nth is None
def test_has_text(self):
css, text, nth = PlaywrightExecutor._split_micro_app_selector(
'button:has-text("登录")')
assert css == "button" and text == "登录" and nth is None
def test_has_text_single_quote(self):
css, text, _ = PlaywrightExecutor._split_micro_app_selector(
"button:has-text('保存')")
assert css == "button" and text == "保存"
def test_has_text_with_parens_in_text(self):
css, text, _ = PlaywrightExecutor._split_micro_app_selector(
'button:has-text("A (测试)")')
assert css == "button" and text == "A (测试)"
def test_visible_stripped(self):
css, text, nth = PlaywrightExecutor._split_micro_app_selector(
"button:visible:has-text(\"确定\")")
assert css == "button" and text == "确定" and nth is None
def test_nth_match(self):
css, text, nth = PlaywrightExecutor._split_micro_app_selector(
'tr:nth-match(2)')
assert css == "tr" and nth == 1 # 1-based → 0-based
def test_xpath_unsupported(self):
css, text, nth = PlaywrightExecutor._split_micro_app_selector("//button")
assert css is None and text == "" and nth is None
def test_empty_becomes_star(self):
css, _, _ = PlaywrightExecutor._split_micro_app_selector(
':has-text("只有文本")')
assert css == "*"
# ==================== _do_in_micro_apps(mock evaluate) ====================
def make_page(evaluate_return=None, evaluate_side_effect=None):
"""构造带可编程 evaluate 的 fake page"""
page = MagicMock()
page.evaluate = MagicMock(
return_value=evaluate_return, side_effect=evaluate_side_effect)
page.wait_for_timeout = MagicMock()
return page
class TestDoInMicroApps:
"""_do_in_micro_apps 各 action 的 Python 侧决策逻辑"""
def _exec(self, page):
ex = PlaywrightExecutor({})
ex._page = page
return ex
def test_exists_true(self):
page = make_page(True)
ex = self._exec(page)
assert ex._do_in_micro_apps('exists', 'button:has-text("保存")', timeout=2000) is True
args = page.evaluate.call_args[0][1]
assert args["cssSel"] == "button" and args["hasText"] == "保存"
def test_exists_false_after_deadline(self):
page = make_page(False)
ex = self._exec(page)
assert ex._do_in_micro_apps('exists', '.missing', timeout=2000) is False
assert page.wait_for_timeout.call_count >= 1 # 轮询等待
def test_text_value(self):
page = make_page("保存")
ex = self._exec(page)
assert ex._do_in_micro_apps('text', 'button', timeout=2000) == "保存"
def test_click_true(self):
page = make_page(True)
ex = self._exec(page)
assert ex._do_in_micro_apps('click', 'button', timeout=2000) is True
def test_fill_value_confirm_true(self):
page = make_page(True)
ex = self._exec(page)
assert ex._do_in_micro_apps('fill', 'input', value="abc", timeout=2000) is True
args = page.evaluate.call_args[0][1]
assert args["action"] == "fill" and args["value"] == "abc"
def test_fill_no_confirm_false_retries(self):
# evaluator 返回 False(value 未确认)→ 轮询直到超时 → False
page = make_page(False)
ex = self._exec(page)
assert ex._do_in_micro_apps('fill', 'input', value="abc", timeout=2000) is False
def test_check_true(self):
page = make_page(True)
ex = self._exec(page)
assert ex._do_in_micro_apps('check', 'input[type=checkbox]', timeout=2000) is True
def test_uncheck_status_not_met_false(self):
# evaluator 返回 False:点击后状态仍未达目标 → 不通过
page = make_page(False)
ex = self._exec(page)
assert ex._do_in_micro_apps('uncheck', 'input[type=checkbox]', timeout=1000) is False
def test_count_value(self):
page = make_page(3)
ex = self._exec(page)
assert ex._do_in_micro_apps('count', 'tr', timeout=2000) == 3
def test_visible_only_passed_through(self):
page = make_page(True)
ex = self._exec(page)
ex._do_in_micro_apps('exists', 'button', timeout=2000, visible_only=True)
assert page.evaluate.call_args[0][1]["visibleOnly"] is True
def test_evaluate_exception_then_success(self):
# 首次 evaluate 抛异常 → 重试 → 第二次成功
page = make_page(evaluate_side_effect=[Exception("boom"), True])
ex = self._exec(page)
assert ex._do_in_micro_apps('exists', 'button', timeout=2000) is True
def test_nested_shadow_roots_selector_sent_to_evaluator(self):
# 递归穿透发生在 evaluator JS 内部,Python 侧只负责透传 css/nth
page = make_page(True)
ex = self._exec(page)
ex._do_in_micro_apps('click', 'micro-app-body', timeout=2000)
args = page.evaluate.call_args[0][1]
assert args["cssSel"] == "micro-app-body"
# ==================== _do_check micro-app 分支 ====================
class TestDoCheckMicroApp:
"""_do_check 在页面路径失败后进入 micro-app 分支,状态达标才成功"""
def _make_check_failing(self, micro_ok):
ex = PlaywrightExecutor({})
ex._page = MagicMock()
# 主页面 check 抛异常(穿透失败)→ 进入 micro-app 分支
ex._page.check = MagicMock(side_effect=Exception("cannot reach"))
ex._page.uncheck = MagicMock(side_effect=Exception("cannot reach"))
ex._do_in_micro_apps = MagicMock(return_value=micro_ok)
# 避免 wait_for_element 在 micro 分支前额外耗时
ex._wait_for_element = MagicMock(return_value=False)
return ex
def test_check_micro_ok(self):
ex = self._make_check_failing(micro_ok=True)
ex._do_check({"selector": ".cb"}, check=True)
ex._do_in_micro_apps.assert_called_once()
assert ex._do_in_micro_apps.call_args[0][0] == "check"
def test_uncheck_micro_ok(self):
ex = self._make_check_failing(micro_ok=True)
ex._do_check({"selector": ".cb"}, check=False)
assert ex._do_in_micro_apps.call_args[0][0] == "uncheck"
def test_check_micro_state_not_met_raises(self):
ex = self._make_check_failing(micro_ok=False)
with pytest.raises(Exception, match="无法勾选"):
ex._do_check({"selector": ".cb"}, check=True)
def test_check_main_page_wins_before_micro(self):
# 主页面 check 成功 → 不进入 micro-app 分支
ex = PlaywrightExecutor({})
ex._page = MagicMock()
ex._page.check = MagicMock()
ex._wait_for_element = MagicMock(return_value=True)
ex._do_in_micro_apps = MagicMock(return_value=True)
ex._do_check({"selector": ".cb"}, check=True)
ex._do_in_micro_apps.assert_not_called()
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_verify_step.py
模块描述:/verify-step 真实验证实现契约测试(P1)
覆盖:
- 路由器参数校验(非法 action / fill 缺 value / 空 selector → 4xx)
- mock PlaywrightExecutor:start/verify/stop 生命周期顺序、成功/失败/异常响应
- ASGITransport 集成:POST /api/element/verify-step 不再无条件成功
(非法动作 400;业务失败返回 success=False 而非 500)
作者:czj
创建日期:2026-08-26
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from httpx import AsyncClient
from httpx._transports.asgi import ASGITransport
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.main import app # noqa: E402
import app.routers.smart_locate as smart_locate_router # noqa: E402
# ==================== 单元测试:参数校验(不经线程池,直接调 handler) ====================
class TestVerifyStepValidation:
"""参数校验应返回 4xx(HTTPException),不启动浏览器"""
@pytest.mark.asyncio
async def test_invalid_action_400(self):
from fastapi import HTTPException
req = smart_locate_router.VerifyStepRequest(
selector="#btn", action="nonexistent")
with pytest.raises(HTTPException) as ei:
await smart_locate_router.verify_step(req)
assert ei.value.status_code == 400
@pytest.mark.asyncio
async def test_fill_missing_value_400(self):
from fastapi import HTTPException
req = smart_locate_router.VerifyStepRequest(
selector="#input", action="fill", value=None)
with pytest.raises(HTTPException) as ei:
await smart_locate_router.verify_step(req)
assert ei.value.status_code == 400
@pytest.mark.asyncio
async def test_empty_selector_400(self):
from fastapi import HTTPException
req = smart_locate_router.VerifyStepRequest(
selector=" ", action="click")
with pytest.raises(HTTPException) as ei:
await smart_locate_router.verify_step(req)
assert ei.value.status_code == 400
# ==================== 单元测试:生命周期与响应(替换 PlaywrightExecutor) ====================
class _FakeExecutor:
"""记录调用顺序的可编程假执行器(模拟 PlaywrightExecutor)"""
def __init__(self, verify_result=True, verify_raise=None, start_raise=None, **cfg):
self.calls = []
self.verify_result = verify_result
self.verify_raise = verify_raise
self.start_raise = start_raise
self.cfg = cfg
def start(self):
self.calls.append("start")
if self.start_raise:
raise self.start_raise
def verify_step(self, **kwargs):
# 模拟真实执行器 verify_step 的内部生命周期:start → 验证 → stop
self.calls.append("start")
self.calls.append(("verify_step", kwargs))
if self.verify_raise:
self.calls.append("stop")
raise self.verify_raise
self.calls.append("stop")
return self.verify_result
def stop(self):
self.calls.append("stop")
@pytest.fixture
def fake_executor_factory(monkeypatch):
"""把路由模块里的 PlaywrightExecutor 替换为可编程工厂,捕获实例与配置"""
holder = {"instances": [], "configs": [], "next": {}}
def make(config=None):
holder["configs"].append(config or {})
inst = _FakeExecutor(**holder["next"])
holder["instances"].append(inst)
return inst
monkeypatch.setattr(smart_locate_router, "PlaywrightExecutor", make)
return holder
def _mk_req(selector="#btn", action="click", value=None, page_url=None, auto_login=False):
data = {"selector": selector, "action": action, "value": value, "auto_login": auto_login}
if page_url is not None:
data["page_url"] = page_url
return smart_locate_router.VerifyStepRequest(**data)
@pytest.mark.asyncio
async def test_success_passes_through(fake_executor_factory):
resp = await smart_locate_router.verify_step(_mk_req())
assert resp.success is True
assert "通过" in resp.message
inst = fake_executor_factory["instances"][0]
# 生命周期:执行器 verify_step 自己负责 start/stop(路由只调用一次性方法)
assert inst.calls[0] == "start"
assert inst.calls[-1] == "stop"
# 动作/选择器透传
verify_kwargs = inst.calls[1][1]
assert verify_kwargs["action"] == "click"
assert verify_kwargs["selector"] == "#btn"
@pytest.mark.asyncio
async def test_auto_login_passed_to_executor(fake_executor_factory):
await smart_locate_router.verify_step(_mk_req(auto_login=True))
verify_kwargs = fake_executor_factory["instances"][0].calls[1][1]
assert verify_kwargs["auto_login"] is True
@pytest.mark.asyncio
async def test_page_url_passed_to_executor(fake_executor_factory):
await smart_locate_router.verify_step(_mk_req(page_url="https://192.168.5.44/"))
verify_kwargs = fake_executor_factory["instances"][0].calls[1][1]
assert verify_kwargs["page_url"] == "https://192.168.5.44/"
@pytest.mark.asyncio
async def test_verify_failure_returns_success_false(fake_executor_factory):
"""执行器返回 False(选择器未命中)→ success=False,而非 500"""
fake_executor_factory["next"]["verify_result"] = False
resp = await smart_locate_router.verify_step(_mk_req())
assert resp.success is False
assert "失败" in resp.message
@pytest.mark.asyncio
async def test_verify_raise_returns_success_false(fake_executor_factory):
"""执行器抛异常(元素不存在/动作失败)→ success=False + 安全化错误信息"""
fake_executor_factory["next"]["verify_raise"] = RuntimeError("元素不存在")
resp = await smart_locate_router.verify_step(_mk_req())
assert resp.success is False
assert "元素不存在" in resp.message
# ==================== ASGI 集成测试 ====================
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
class TestVerifyStepAPI:
"""通过 HTTP 层验证端点行为(不再无条件成功)"""
@pytest.mark.asyncio
async def test_invalid_action_returns_400(self, client):
resp = await client.post(
"/api/element/verify-step",
json={"selector": "#btn", "action": "bogus", "auto_login": False},
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_fill_missing_value_returns_400(self, client):
resp = await client.post(
"/api/element/verify-step",
json={"selector": "#input", "action": "fill", "auto_login": False},
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_invalid_action_not_unconditional_success(self, client):
"""集成断言:非法 action 不再返回 success=True(旧存根无条件成功)"""
resp = await client.post(
"/api/element/verify-step",
json={"selector": "#btn", "action": "no_such_action", "auto_login": False},
)
# 旧存根对任何请求都返回 200 + success=True;现在应 400
assert resp.status_code == 400
......@@ -148,6 +148,8 @@ export interface SmartLocateRequest {
navigate_menu?: string
/** 被测系统基础 URL */
page_url?: string
/** 是否启用 Claude 语义增强(默认 true) */
use_claude?: boolean
}
/**
......
......@@ -173,7 +173,7 @@
v-model="getApiCase(step).bodyText"
type="textarea"
:rows="2"
placeholder='请求体 JSON,如 {"name": "{__CTX:meetingName__}"}'
placeholder="请求体 JSON,如 {&quot;name&quot;: &quot;{__CTX:meetingName__}&quot;}"
size="small"
style="width: 100%"
class="api-body"
......
......@@ -307,7 +307,7 @@
v-model="parametersText"
type="textarea"
:rows="2"
placeholder='JSON 对象,如 {"meetingName":"自动化-{__RANDOM_NAME_8__}", "room":"北京展厅会议室"}'
placeholder="JSON 对象,如 {&quot;meetingName&quot;:&quot;自动化-{__RANDOM_NAME_8__}&quot;, &quot;room&quot;:&quot;北京展厅会议室&quot;}"
/>
<span class="param-hint">供 {__CTX:key__} 上下文变量引用</span>
</div>
......@@ -1083,15 +1083,26 @@ const batchLocate = async () => {
return
}
// 2. 转换步骤为智能定位格式(包含 navigate 步骤)
// 2. 转换步骤为智能定位格式(包含 navigate 步骤,透传所有字段
const smartLocateSteps = steps
.filter((s: any) => s.action)
.map((s: any) => ({
order: s.order,
name: s.name || '',
action: s.action,
params: s.params || {},
}))
.map((s: any) => {
const step: any = {
order: s.order,
name: s.name || '',
action: s.action,
params: s.params || {},
}
// P1a: 透传可选字段,防止 round-trip 丢字段
if (s.semantic != null) step.semantic = s.semantic
if (s.verify != null) step.verify = s.verify
if (s.run_on != null) step.run_on = s.run_on
if (s.selectors != null) step.selectors = s.selectors
if (s.page_key != null) step.page_key = s.page_key
if (s.element_key != null) step.element_key = s.element_key
if (s.expected != null) step.expected = s.expected
return step
})
if (!smartLocateSteps.length) {
ElMessage.warning('没有可定位的步骤')
......@@ -1123,6 +1134,13 @@ const batchLocate = async () => {
if (locateResult.selectors?.primary) {
updatedParams.selector = locateResult.selectors.primary
}
// P1a: 保存完整选择器链(回退候选),执行期 selectors 链优先于单个 selector
const candidateValues = (locateResult.selectors?.candidates || [])
.map((c: any) => c?.value)
.filter((v: any) => v && v !== updatedParams.selector)
if (candidateValues.length > 0) {
updatedParams.selectors = [updatedParams.selector, ...candidateValues].filter(Boolean)
}
// ⚠️ 阶段6升级:合并 semantic / verify(新生成的优先,保留已有字段)
return {
...s,
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论