feat(trading): 下单流程每步证据带整页截图(html + png + meta 三件套)
- site_interact 新增 PageSnapshot{html, screenshot} 证据载体:
add_to_cart 截商品页、verify_cart 截 cart 页、enter_checkout 截落地
确认页、pay 截付款检查后的完成页;截图全部 best-effort,失败记
warning 留空,不掩盖动作结果
- submit_order 改返回 SubmitOutcome{site_order_id, evidence}:完成页
HTML+截图首次随 step 4 落盘(此前只有 meta)
- fetch_order_detail 详情页截图挂在 OrderStatusSnapshot.screenshot,
监控步骤 write_step 带 png;只读查询通道按字段挑出,不受影响
- runner._run_step 认 PageSnapshot(旧 str 契约保留兼容),step 0/3/4
显式传 png
- 测试:桩同步新契约;新增每步三件套齐全的全流程断言;clear_cart
场景单测补截图断言
- 真账号复验 verify_cart_clear.py:两场景截图均合法 PNG,落盘
.probe/cart_clear/ 并目检为空车页渲染
This commit is contained in:
@@ -535,6 +535,9 @@ class _FakeOrderPage:
|
||||
async def content(self) -> str:
|
||||
return self._html
|
||||
|
||||
async def screenshot(self, *, full_page: bool = True) -> bytes:
|
||||
return b"fake-png"
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
@@ -1283,6 +1286,9 @@ class _FakeClearCartPage:
|
||||
async def content(self) -> str:
|
||||
return self._html
|
||||
|
||||
async def screenshot(self, *, full_page: bool = True) -> bytes:
|
||||
return b"fake-png"
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
@@ -1327,7 +1333,7 @@ async def test_clear_cart_empty_cart_returns_success_without_clicks():
|
||||
"""场景 1:购物车没有商品 —— 找不到「削除」按钮立即结束,按清理成功返回
|
||||
|
||||
空车时 count API 返回 status=101(合法空车),cart_count 必须是 0 而不是
|
||||
-1(2026-08-16 修复过的误判);removed_count=0,最终页面 HTML 照常带回。
|
||||
-1(2026-08-16 修复过的误判);removed_count=0,最终页面 HTML 与截图照常带回。
|
||||
"""
|
||||
site = _build_clear_cart_site(
|
||||
delete_buttons=0, count_body=_EMPTY_COUNT_BODY, html="<html>empty cart</html>",
|
||||
@@ -1335,7 +1341,12 @@ async def test_clear_cart_empty_cart_returns_success_without_clicks():
|
||||
|
||||
result = await site.clear_cart()
|
||||
|
||||
assert result == {"removed_count": 0, "cart_count": 0, "html": "<html>empty cart</html>"}
|
||||
assert result == {
|
||||
"removed_count": 0,
|
||||
"cart_count": 0,
|
||||
"html": "<html>empty cart</html>",
|
||||
"screenshot": b"fake-png",
|
||||
}
|
||||
assert site._context.page.closed is True # 页面必须关,不能泄漏
|
||||
|
||||
|
||||
@@ -1343,7 +1354,7 @@ async def test_clear_cart_with_items_clicks_until_no_button_left():
|
||||
"""场景 2:购物车有商品 —— 循环点第一个「削除」,点一个少一个,直到按钮消失
|
||||
|
||||
每次循环都重新查 selector(避免索引漂移),3 件商品应点 3 次;清空后 count
|
||||
API 回 status=101 → cart_count=0,最终页面 HTML 进返回值。
|
||||
API 回 status=101 → cart_count=0,最终页面 HTML 与截图进返回值。
|
||||
"""
|
||||
site = _build_clear_cart_site(
|
||||
delete_buttons=3, count_body=_EMPTY_COUNT_BODY, html="<html>cleared cart</html>",
|
||||
@@ -1354,11 +1365,12 @@ async def test_clear_cart_with_items_clicks_until_no_button_left():
|
||||
assert result["removed_count"] == 3
|
||||
assert result["cart_count"] == 0
|
||||
assert result["html"] == "<html>cleared cart</html>"
|
||||
assert result["screenshot"] == b"fake-png"
|
||||
assert site._context.page.closed is True
|
||||
|
||||
|
||||
async def test_clear_cart_html_capture_failure_keeps_clear_result():
|
||||
"""最终页面 HTML 抓取失败(如页面异常)不掩盖清理结果本身:html 记空串"""
|
||||
"""最终页面 HTML / 截图抓取失败(如页面异常)不掩盖清理结果本身:两者记空"""
|
||||
site = _build_clear_cart_site(
|
||||
delete_buttons=0, count_body=_EMPTY_COUNT_BODY, html="",
|
||||
)
|
||||
@@ -1366,11 +1378,15 @@ async def test_clear_cart_html_capture_failure_keeps_clear_result():
|
||||
async def _broken_content() -> str:
|
||||
raise RuntimeError("page already closed")
|
||||
|
||||
async def _broken_screenshot(*, full_page: bool = True) -> bytes:
|
||||
raise RuntimeError("page already closed")
|
||||
|
||||
site._context.page.content = _broken_content # type: ignore[assignment]
|
||||
site._context.page.screenshot = _broken_screenshot # type: ignore[assignment]
|
||||
|
||||
result = await site.clear_cart()
|
||||
|
||||
assert result == {"removed_count": 0, "cart_count": 0, "html": ""}
|
||||
assert result == {"removed_count": 0, "cart_count": 0, "html": "", "screenshot": b""}
|
||||
|
||||
|
||||
# ---- helper ----
|
||||
|
||||
+105
-12
@@ -27,7 +27,13 @@ from app.trading.worker.evidence import EvidenceStore
|
||||
from app.trading.worker.local_db import LocalDB
|
||||
from app.trading.worker.models import LeaseTask
|
||||
from app.trading.worker.runner import WorkerRunner
|
||||
from app.trading.worker.site_interact import CheckoutSummary, OrderStatusSnapshot, SiteInteractor
|
||||
from app.trading.worker.site_interact import (
|
||||
CheckoutSummary,
|
||||
OrderStatusSnapshot,
|
||||
PageSnapshot,
|
||||
SiteInteractor,
|
||||
SubmitOutcome,
|
||||
)
|
||||
|
||||
|
||||
# ---- 桩:网关客户端 ----
|
||||
@@ -296,7 +302,7 @@ async def test_unimplemented_site_interaction_becomes_needs_human(
|
||||
return None
|
||||
|
||||
async def _checkout_html(task): # noqa: ANN001
|
||||
return "<html>checkout</html>"
|
||||
return PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
|
||||
|
||||
async def _unimplemented(html): # noqa: ANN001
|
||||
raise NotImplementedError("模拟:假装 parse_checkout 还没实现")
|
||||
@@ -375,7 +381,7 @@ async def test_amount_guard_blocks_when_payable_exceeds_limit(
|
||||
call_log.append(task.task_id + ":step")
|
||||
|
||||
async def _checkout_html(task): # noqa: ANN001
|
||||
return "<html>checkout</html>"
|
||||
return PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
|
||||
|
||||
async def _parse(html: str):
|
||||
return CheckoutSummary(payable_yen=50000)
|
||||
@@ -385,7 +391,10 @@ async def test_amount_guard_blocks_when_payable_exceeds_limit(
|
||||
async def _submit(task): # noqa: ANN001
|
||||
nonlocal submit_called
|
||||
submit_called = True
|
||||
return "ord-1"
|
||||
return SubmitOutcome(
|
||||
site_order_id="ord-1",
|
||||
evidence=PageSnapshot(html="<html>complete</html>", screenshot=b"fake-submit-png"),
|
||||
)
|
||||
|
||||
runner._site.add_to_cart = _ok # type: ignore[assignment]
|
||||
runner._site.verify_cart = _ok # type: ignore[assignment]
|
||||
@@ -415,7 +424,7 @@ async def test_amount_guard_honors_intent_max_total_yen(
|
||||
return None
|
||||
|
||||
async def _checkout_html(task): # noqa: ANN001
|
||||
return "<html>checkout</html>"
|
||||
return PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
|
||||
|
||||
async def _parse(html: str):
|
||||
return CheckoutSummary(payable_yen=8000)
|
||||
@@ -469,13 +478,16 @@ async def test_execute_clears_cart_before_adding(
|
||||
return None
|
||||
|
||||
async def _checkout_html(task): # noqa: ANN001
|
||||
return "<html>checkout</html>"
|
||||
return PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
|
||||
|
||||
async def _parse(html: str):
|
||||
return CheckoutSummary(payable_yen=297)
|
||||
|
||||
async def _submit(task): # noqa: ANN001
|
||||
return "ord-1"
|
||||
return SubmitOutcome(
|
||||
site_order_id="ord-1",
|
||||
evidence=PageSnapshot(html="<html>complete</html>", screenshot=b"fake-submit-png"),
|
||||
)
|
||||
|
||||
async def _pay(task, site_order_id): # noqa: ANN001
|
||||
return None
|
||||
@@ -570,7 +582,12 @@ async def test_clear_cart_step_writes_evidence_but_no_gateway_report(
|
||||
这一步的 evidence_ref。
|
||||
"""
|
||||
async def _clear(): # noqa: ANN001
|
||||
return {"removed_count": 1, "cart_count": 0, "html": "<html>cart</html>"}
|
||||
return {
|
||||
"removed_count": 1,
|
||||
"cart_count": 0,
|
||||
"html": "<html>cart</html>",
|
||||
"screenshot": b"fake-clear-png",
|
||||
}
|
||||
|
||||
async def _noop(task): # noqa: ANN001
|
||||
return None
|
||||
@@ -589,6 +606,7 @@ async def test_clear_cart_step_writes_evidence_but_no_gateway_report(
|
||||
step_dir = evidence.step_dir("t1")
|
||||
html_text = (step_dir / "00-cart-clear.html").read_text(encoding="utf-8")
|
||||
assert html_text == "<html>cart</html>"
|
||||
assert (step_dir / "00-cart-clear.png").read_bytes() == b"fake-clear-png"
|
||||
meta = json.loads((step_dir / "00-cart-clear.meta.json").read_text(encoding="utf-8"))
|
||||
assert meta == {"step": "cart-clear", "removed_count": 1, "cart_count": 0}
|
||||
# 本机卫生步骤:gateway 的任何 report 都不携带 step-0 的 evidence_ref
|
||||
@@ -604,7 +622,12 @@ async def test_clear_cart_evidence_written_when_guard_blocks(
|
||||
被拦转 needs_human 时这份现场就是排查依据——证据先于闸门判断落盘。
|
||||
"""
|
||||
async def _clear(): # noqa: ANN001
|
||||
return {"removed_count": 2, "cart_count": 2, "html": "<html>leftover</html>"}
|
||||
return {
|
||||
"removed_count": 2,
|
||||
"cart_count": 2,
|
||||
"html": "<html>leftover</html>",
|
||||
"screenshot": b"fake-leftover-png",
|
||||
}
|
||||
|
||||
runner._site.clear_cart = _clear # type: ignore[assignment]
|
||||
|
||||
@@ -612,10 +635,77 @@ async def test_clear_cart_evidence_written_when_guard_blocks(
|
||||
|
||||
step_dir = evidence.step_dir("t1")
|
||||
assert (step_dir / "00-cart-clear.html").exists()
|
||||
assert (step_dir / "00-cart-clear.png").read_bytes() == b"fake-leftover-png"
|
||||
meta = json.loads((step_dir / "00-cart-clear.meta.json").read_text(encoding="utf-8"))
|
||||
assert meta["cart_count"] == 2
|
||||
|
||||
|
||||
async def test_every_order_step_writes_html_png_and_meta(
|
||||
runner: WorkerRunner, evidence: EvidenceStore
|
||||
):
|
||||
"""下单流程每一步(step 0 清车 + step 1-5)都落 html + png + meta 三件套
|
||||
|
||||
排查问题时每一步都要有现场可看:页面 HTML、整页截图、步骤 meta 缺一不可。
|
||||
"""
|
||||
async def _clear(): # noqa: ANN001
|
||||
return {
|
||||
"removed_count": 0,
|
||||
"cart_count": 0,
|
||||
"html": "<html>cart</html>",
|
||||
"screenshot": b"png-0",
|
||||
}
|
||||
|
||||
async def _add(task): # noqa: ANN001
|
||||
return PageSnapshot(html="<html>added</html>", screenshot=b"png-1")
|
||||
|
||||
async def _verify(task): # noqa: ANN001
|
||||
return PageSnapshot(html="<html>verified</html>", screenshot=b"png-2")
|
||||
|
||||
async def _checkout(task): # noqa: ANN001
|
||||
return PageSnapshot(html="<html>checkout</html>", screenshot=b"png-3")
|
||||
|
||||
async def _parse(html: str):
|
||||
return CheckoutSummary(payable_yen=297)
|
||||
|
||||
async def _submit(task): # noqa: ANN001
|
||||
return SubmitOutcome(
|
||||
site_order_id="ord-1",
|
||||
evidence=PageSnapshot(html="<html>done</html>", screenshot=b"png-4"),
|
||||
)
|
||||
|
||||
async def _pay(task, site_order_id): # noqa: ANN001
|
||||
return PageSnapshot(html="<html>paid</html>", screenshot=b"png-5")
|
||||
|
||||
async def _unchanged(order_id: str): # noqa: ANN001
|
||||
# 监控不污染断言:found=True 但无状态变化,轮询到上限自行退出
|
||||
return OrderStatusSnapshot(found=True, order_state=None)
|
||||
|
||||
runner._site.clear_cart = _clear # type: ignore[assignment]
|
||||
runner._site.add_to_cart = _add # type: ignore[assignment]
|
||||
runner._site.verify_cart = _verify # type: ignore[assignment]
|
||||
runner._site.enter_checkout = _checkout # type: ignore[assignment]
|
||||
runner._site.parse_checkout = _parse # type: ignore[assignment]
|
||||
runner._site.submit_order = _submit # type: ignore[assignment]
|
||||
runner._site.pay = _pay # type: ignore[assignment]
|
||||
runner._site.check_order_status = _unchanged # type: ignore[assignment]
|
||||
|
||||
await runner.handle(_make_task(task_id="t1"))
|
||||
|
||||
step_dir = evidence.step_dir("t1")
|
||||
expected = {
|
||||
"00-cart-clear": b"png-0",
|
||||
"01-cart-add": b"png-1",
|
||||
"02-cart-check": b"png-2",
|
||||
"03-order-confirm": b"png-3",
|
||||
"04-order-submit": b"png-4",
|
||||
"05-payment": b"png-5",
|
||||
}
|
||||
for stem, png in expected.items():
|
||||
assert (step_dir / f"{stem}.png").read_bytes() == png, f"{stem} 缺整页截图"
|
||||
assert (step_dir / f"{stem}.html").exists(), f"{stem} 缺页面 HTML"
|
||||
assert (step_dir / f"{stem}.meta.json").exists(), f"{stem} 缺 meta"
|
||||
|
||||
|
||||
# ---- 证据在 report 之前落盘(§9 第 11 条)----
|
||||
|
||||
|
||||
@@ -647,7 +737,7 @@ async def test_evidence_files_exist_before_each_report(
|
||||
return None
|
||||
|
||||
async def _checkout_html(task): # noqa: ANN001
|
||||
return "<html>checkout</html>"
|
||||
return PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
|
||||
|
||||
async def _unimplemented(html): # noqa: ANN001
|
||||
raise NotImplementedError("模拟:假装 parse_checkout 还没实现")
|
||||
@@ -682,13 +772,16 @@ async def test_successful_order_spawns_monitor_task_and_cancel_monitors_cleans_u
|
||||
return None
|
||||
|
||||
async def _checkout_html(task): # noqa: ANN001
|
||||
return "<html>checkout</html>"
|
||||
return PageSnapshot(html="<html>checkout</html>", screenshot=b"fake-checkout-png")
|
||||
|
||||
async def _parse(html: str):
|
||||
return CheckoutSummary(payable_yen=297)
|
||||
|
||||
async def _submit(task): # noqa: ANN001
|
||||
return "306087-20260813-0863947697"
|
||||
return SubmitOutcome(
|
||||
site_order_id="306087-20260813-0863947697",
|
||||
evidence=PageSnapshot(html="<html>complete</html>", screenshot=b"fake-submit-png"),
|
||||
)
|
||||
|
||||
async def _pay(task, site_order_id): # noqa: ANN001
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user