diff --git a/app/trading/worker/runner.py b/app/trading/worker/runner.py index 721fb3b..1c28867 100644 --- a/app/trading/worker/runner.py +++ b/app/trading/worker/runner.py @@ -35,7 +35,7 @@ from app.trading.worker.client import GatewayClient 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.site_interact import SiteInteractor +from app.trading.worker.site_interact import PageSnapshot, SiteInteractor if TYPE_CHECKING: from app.shared.config import Settings @@ -260,8 +260,8 @@ class WorkerRunner: 每一步的顺序:动作 → 落证据 → 写本地 SQLite → 回报 gateway。 开单前的「清购物车」是本机侧卫生步骤(step 0,见下方代码注释):落本地 - 步骤证据(页面 + meta)但不上报 gateway、不记状态事件;站点交互失败会转 - _execute_with_renewal 的 except 分支上报 needs_human / failed。 + 步骤证据(页面 + 截图 + meta)但不上报 gateway、不记状态事件;站点交互 + 失败会转 _execute_with_renewal 的 except 分支上报 needs_human / failed。 """ await self._db.ensure_started(task.task_id, task.site, task.intent) @@ -277,7 +277,7 @@ class WorkerRunner: # (包括进程中途崩溃,残留都没人清),这里都从空车起步。 # # 这条不在 gateway 上报、不记 order_events:它是本机侧的卫生操作,不是订单 - # 进度的状态迁移。但清理后的页面快照与 meta 要落本地证据并登记 + # 进度的状态迁移。但清理后的页面快照、整页截图与 meta 要落本地证据并登记 # evidence_index——清理没跑干净被下面的闸门拦下转 needs_human 时,这份现场 # 就是排查依据,所以证据必须先于闸门判断落盘。清理后 count 非 0(含 # count API 拿不到结果返回 -1)说明清理没跑干净,**不能**带着残留往下 @@ -287,6 +287,7 @@ class WorkerRunner: evidence_ref = self._evidence.write_step( task.task_id, 0, "cart-clear", html=cleared.get("html") or None, + png=cleared.get("screenshot") or None, meta={ "step": "cart-clear", "removed_count": cleared.get("removed_count"), @@ -319,15 +320,16 @@ class WorkerRunner: ) # 步骤 3:进入下单确认页 + 金额守卫 - checkout_html = await self._site.enter_checkout(task) - summary = await self._site.parse_checkout(checkout_html) + checkout = await self._site.enter_checkout(task) + summary = await self._site.parse_checkout(checkout.html) self._enforce_amount_guard(task, summary.payable_yen) await self._run_step( task, step_no=3, step_name="order-confirm", action=self._noop(), state=OrderState.CREATED, detail=f"下单确认页已解析:应付 {summary.payable_yen} 円", - html=checkout_html, + html=checkout.html or None, + png=checkout.screenshot or None, evidence_meta={ "payable_yen": summary.payable_yen, "site_order_id": summary.site_order_id, @@ -336,13 +338,16 @@ class WorkerRunner: ) # 步骤 4:提交下单 - site_order_id = await self._site.submit_order(task) + submit = await self._site.submit_order(task) + site_order_id = submit.site_order_id await self._run_step( task, step_no=4, step_name="order-submit", action=self._noop(), state=OrderState.ORDERED, site_order_id=site_order_id, detail=f"已提交下单,站点订单号 {site_order_id}", + html=submit.evidence.html or None, + png=submit.evidence.screenshot or None, ) # 步骤 5:付款 @@ -424,6 +429,7 @@ class WorkerRunner: evidence_ref = self._evidence.write_step( task.task_id, step_no, step_name, html=snapshot.html, + png=snapshot.screenshot or None, meta={ "step": step_name, "state": snapshot.order_state.value, @@ -485,15 +491,23 @@ class WorkerRunner: pay_deadline: str | None = None, evidence_meta: dict | None = None, html: str | None = None, + png: bytes | None = None, ) -> None: """单步执行:动作 → 落证据 → 写本地 → 回报 gateway - `action` 的返回值若是字符串,视为该步骤要落盘的页面 HTML(add_to_cart / - verify_cart 均已改为返回渲染后的页面);显式传入的 `html` 优先级更高, - 供 step3 等已经单独拿到页面 HTML 的调用点直接使用。 + `action` 返回 PageSnapshot(add_to_cart / verify_cart / pay 等站点方法的 + 证据载体)时,其 html / screenshot 即本步骤要落盘的页面与整页截图; + 显式传入的 `html` / `png` 优先级更高,供 step3/step4 等已经单独拿到 + 页面的调用点使用。 """ result = await action() - if html is None and isinstance(result, str): + if isinstance(result, PageSnapshot): + if html is None: + html = result.html or None + if png is None: + png = result.screenshot or None + elif html is None and isinstance(result, str): + # 旧契约兼容:站点方法返回字符串时视为页面 HTML html = result meta = { "step": step_name, @@ -501,7 +515,7 @@ class WorkerRunner: **(evidence_meta or {}), } evidence_ref = self._evidence.write_step( - task.task_id, step_no, step_name, html=html, meta=meta + task.task_id, step_no, step_name, html=html, png=png, meta=meta ) await self._db.index_evidence(task.task_id, step_no, step_name, evidence_ref) await self._db.record_event( diff --git a/app/trading/worker/site_interact.py b/app/trading/worker/site_interact.py index b342ab1..59dd104 100644 --- a/app/trading/worker/site_interact.py +++ b/app/trading/worker/site_interact.py @@ -529,6 +529,30 @@ def _accumulate_order_list_page( return new_acc, False +@dataclass(slots=True) +class PageSnapshot: + """一步站点交互带回的页面证据:渲染后 HTML + 整页截图 + + 供 runner 给每个步骤落证据(html → {step}.html,screenshot → {step}.png)。 + 两者都是 best-effort:页面抓取/截图失败时对应字段为空,不掩盖动作本身的结果。 + """ + + html: str = "" + screenshot: bytes = b"" + + +@dataclass(slots=True) +class SubmitOutcome: + """submit_order 的返回值:站点订单号 + 下单完成页证据 + + 完成页(「ご注文がショップに送信されました」)是「订单真的提交了」最直接的 + 现场,HTML 与截图随返回值带出,供 runner step 4 落证据。 + """ + + site_order_id: str + evidence: PageSnapshot + + @dataclass(slots=True) class CheckoutSummary: """下单确认页解析结果(待实测确认字段位置) @@ -565,6 +589,8 @@ class OrderStatusSnapshot: order_state: OrderState | None = None delivery_status: str | None = None html: str = "" + # 详情页整页截图(fetch_order_detail 填,best-effort),供监控步骤落证据 + screenshot: bytes = b"" @dataclass(slots=True) @@ -770,7 +796,7 @@ class SiteInteractor: # ---- 已实现:add_to_cart / verify_cart / cart_status ---- - async def add_to_cart(self, task: LeaseTask) -> str | None: + async def add_to_cart(self, task: LeaseTask) -> PageSnapshot: """加购(worker 入口):从 task.intent 取字段,调 _add_to_cart_with_fields 调用方需在 task.intent 提供: @@ -780,7 +806,7 @@ class SiteInteractor: - choice: 必填选项的取值列表;不传则每个必填选项用第一个候选值(站点不严格校验) Returns: - 加购成功后落地页(cart 页)的 HTML,供 runner 落证据;查不到时为 None。 + PageSnapshot:加购响应的落地页 HTML + 商品页整页截图,供 runner 落证据。 Raises: InvalidRequestError: intent.item_url 缺失 @@ -807,7 +833,10 @@ class SiteInteractor: "shop_bid": result["shop_bid"], "basket_domain": result["basket_domain"], } - return result.get("response_html") + return PageSnapshot( + html=result.get("response_html") or "", + screenshot=result.get("screenshot") or b"", + ) async def add_to_cart_payload( self, @@ -846,8 +875,9 @@ class SiteInteractor: ) -> dict: """加购核心逻辑(不持锁,由调用方包裹 self._lock) - 返回 dict:{item_id, shop_bid, basket_domain, cart_count} + 返回 dict:{item_id, shop_bid, basket_domain, cart_count, response_html, screenshot} cart_count 在加购成功后顺带查一次 cart count API,方便 HTTP 调用方一次性返回。 + response_html / screenshot 供 worker 入口包成 PageSnapshot 落证据。 """ await self._auth_session.require_logged_in("rakuten") await self._refresh_context_if_stale() @@ -928,6 +958,14 @@ class SiteInteractor: "加购成功:item_id=%s final=%s", fields["form_fields"].get("item_id"), final_url, ) + + # 商品页整页截图随结果带出(runner step 1 落证据用); + # 截图失败只记日志,不掩盖加购结果 + screenshot = b"" + try: + screenshot = await page.screenshot(full_page=True) + except Exception: + logger.warning("加购后截取商品页截图失败(不影响加购结果)", exc_info=True) finally: await page.close() @@ -944,9 +982,10 @@ class SiteInteractor: "basket_domain": fields["basket_domain"], "cart_count": cart_count, "response_html": body, + "screenshot": screenshot, } - async def verify_cart(self, task: LeaseTask) -> str | None: + async def verify_cart(self, task: LeaseTask) -> PageSnapshot: """校验购物车里有没有刚加的商品 策略(探针实测最稳的两步): @@ -955,7 +994,7 @@ class SiteInteractor: 2. 打开 cart 页等 SPA 渲染,在 HTML 里找 item_id Returns: - 渲染后的 cart 页 HTML,供 runner 落证据。 + PageSnapshot:渲染后的 cart 页 HTML + 整页截图,供 runner 落证据。 登录态失效抛 NotLoggedInError;找不到 item 抛 CartOperationError。 """ @@ -1010,11 +1049,11 @@ class SiteInteractor: - 若 SPA 把按钮渲染在 iframe 里,selector 失败需实测后调整 - Rakuten cart item 卡片无 data-testid,本方法不依赖 DOM 结构定位 - 返回 {removed_count, cart_count, html};cart_count=-1 表示末尾 count API - 调用失败(空车属正常结果返回 0,见 _query_cart_count 的 status=101 处理)。 - html 是清理结束后 cart 页的最终渲染结果,供调用方落证据排查——清理没跑干净 - 被 runner 闸门拦下时这份现场就是排查依据;HTTP /api/cart/clear 入口的响应 - 模型不携带它。 + 返回 {removed_count, cart_count, html, screenshot};cart_count=-1 表示末尾 + count API 调用失败(空车属正常结果返回 0,见 _query_cart_count 的 + status=101 处理)。html 与 screenshot 是清理结束后 cart 页的最终渲染结果 + 与整页截图,供调用方落证据排查——清理没跑干净被 runner 闸门拦下时这份现场 + 就是排查依据;HTTP /api/cart/clear 入口的响应模型不携带它们。 """ async with self._lock: await self._auth_session.require_logged_in("rakuten") @@ -1023,6 +1062,7 @@ class SiteInteractor: page = await self._context.new_page() removed = 0 html = "" + screenshot = b"" try: await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000) await self._wait_cart_rendered(page, label="clear_cart") @@ -1052,8 +1092,8 @@ class SiteInteractor: _CLEAR_CART_MAX_ITER, ) - # 清理结束后的最终页面留给调用方落证据(worker runner step 0); - # 抓取失败只记日志,不把排查用的副作用变成新的失败源 + # 清理结束后的最终页面与整页截图留给调用方落证据(worker runner + # step 0);抓取失败只记日志,不把排查用的副作用变成新的失败源 try: html = await page.content() except Exception: @@ -1061,6 +1101,13 @@ class SiteInteractor: "clear_cart:抓取最终页面 HTML 失败(不影响清理结果)", exc_info=True, ) + try: + screenshot = await page.screenshot(full_page=True) + except Exception: + logger.warning( + "clear_cart:截取最终页面截图失败(不影响清理结果)", + exc_info=True, + ) finally: await page.close() @@ -1072,7 +1119,12 @@ class SiteInteractor: cart_count = -1 logger.info("clear_cart 完成:removed=%s cart_count=%s", removed, cart_count) - return {"removed_count": removed, "cart_count": cart_count, "html": html} + return { + "removed_count": removed, + "cart_count": cart_count, + "html": html, + "screenshot": screenshot, + } async def remove_item(self, item_id: str) -> dict: """删除购物车里指定 item_id 的商品 @@ -1179,10 +1231,10 @@ class SiteInteractor: count = int(count_match.group(1)) if count_match and count_match.group(1) else 0 return raw_status, count - async def _verify_item_in_cart_html(self, item_id: str, *, label: str) -> str: + async def _verify_item_in_cart_html(self, item_id: str, *, label: str) -> PageSnapshot: """渲染 cart SPA,在 HTML 里找 item_id,确认商品确实进了购物车 - 返回渲染后的 cart 页 HTML(校验通过时),供调用方落证据。 + 返回渲染后的 cart 页 HTML + 整页截图(校验通过时),供调用方落证据。 """ page = await self._context.new_page() try: @@ -1201,7 +1253,13 @@ class SiteInteractor: f"购物车页未找到 item_id={item_id}(加购可能被服务端静默丢弃)" ) logger.info("cart 校验通过:%s item_id=%s in cart HTML", label, item_id) - return html + # 校验通过的 cart 页整页截图随结果带出;失败不掩盖校验结果 + screenshot = b"" + try: + screenshot = await page.screenshot(full_page=True) + except Exception: + logger.warning("cart 校验后截图失败(不影响校验结果)", exc_info=True) + return PageSnapshot(html=html, screenshot=screenshot) finally: await page.close() @@ -1269,7 +1327,7 @@ class SiteInteractor: # ---- 已实现:enter_checkout(到下单确认页,中间步骤未经真实 HTML 验证)---- - async def enter_checkout(self, task: LeaseTask) -> str: + async def enter_checkout(self, task: LeaseTask) -> PageSnapshot: """进入下单确认页:购物车 → 点「購入手続き」→ 依次处理中间步骤 → 落地确认页 流程: @@ -1297,7 +1355,8 @@ class SiteInteractor: 页面重开拿不回同一份订单草稿。 Returns: - 下单确认页(或目前所能到达的最后一页)的 HTML,供 runner 落证据。 + PageSnapshot:下单确认页(或目前所能到达的最后一页)的 HTML + 整页截图, + 供 runner 落证据。 Raises: NotLoggedInError: 登录态失效 @@ -1374,9 +1433,18 @@ class SiteInteractor: "enter_checkout 落地:task_id=%s url=%s", task.task_id, page.url ) html = await page.content() + # 落地页整页截图随结果带出(step 3 落证据用);页面要保持打开供 + # submit_order 复用,截图失败只记日志不掩盖落地结果 + screenshot = b"" + try: + screenshot = await page.screenshot(full_page=True) + except Exception: + logger.warning( + "enter_checkout 落地页截图失败(不影响落地结果)", exc_info=True, + ) self._checkout_pages[task.task_id] = page success = True - return html + return PageSnapshot(html=html, screenshot=screenshot) except Exception: await self._dump_debug_snapshot( page, task_id=task.task_id, label="enter_checkout-error" @@ -1770,7 +1838,7 @@ class SiteInteractor: """ return _parse_checkout_summary(html) - async def submit_order(self, task: LeaseTask) -> str: + async def submit_order(self, task: LeaseTask) -> SubmitOutcome: """点击下单确认页的最终确认按钮,提交订单 调用前必须已经过 enter_checkout(本方法复用它留存的 Page)与金额守卫; @@ -1778,7 +1846,8 @@ class SiteInteractor: **本会话内从未被真实执行过**。 Returns: - 从提交后页面解析出的站点订单号;解析不出时抛错,不返回猜测值。 + SubmitOutcome:从提交后页面解析出的站点订单号 + 完成页证据(HTML + + 整页截图);订单号解析不出时抛错,不返回猜测值。 Raises: OrderOperationError: 找不到 enter_checkout 留存的确认页会话 / 找不到 @@ -1849,12 +1918,24 @@ class SiteInteractor: "(可能已经下单成功,需要人工核对站点订单列表,不要按失败重试)" ) site_order_id = match.group(1) + # 完成页整页截图随返回值带出(step 4 落证据用);页面留着给 pay 复用, + # 截图失败只记日志不掩盖提交结果 + screenshot = b"" + try: + screenshot = await page.screenshot(full_page=True) + except Exception: + logger.warning( + "submit_order 完成页截图失败(不影响提交结果)", exc_info=True, + ) logger.info( "submit_order 完成:task_id=%s site_order_id=%s", task.task_id, site_order_id ) - return site_order_id + return SubmitOutcome( + site_order_id=site_order_id, + evidence=PageSnapshot(html=html, screenshot=screenshot), + ) - async def pay(self, task: LeaseTask, site_order_id: str) -> None: + async def pay(self, task: LeaseTask, site_order_id: str) -> PageSnapshot: """检查提交下单后是否已完成付款 / 是否触发了需要人工介入的验证环节 按 docs/order-gateway.md §10.1 既定方案实现:检测到常见 3DS/短信 OTP 迹象 @@ -1865,6 +1946,10 @@ class SiteInteractor: 本方法结束时无论成败都会关闭 self._checkout_pages 里留存的 Page (提交/付款环节已经走到这一步,没有再复用同一页面的后续步骤)。 + + Returns: + PageSnapshot:付款检查通过后的页面(通常仍是下单完成页)HTML + + 整页截图,供 runner step 5 落证据。 """ async with self._lock: page = self._checkout_pages.pop(task.task_id, None) @@ -1888,6 +1973,19 @@ class SiteInteractor: "假设):task_id=%s site_order_id=%s", task.task_id, site_order_id, ) + # 付款检查通过后的页面留证据(runner step 5):此时通常仍是下单 + # 完成页。抓取失败只记日志,不掩盖付款判定 + html = "" + screenshot = b"" + try: + html = await page.content() + except Exception: + logger.warning("pay 页面 HTML 抓取失败(不影响付款判定)", exc_info=True) + try: + screenshot = await page.screenshot(full_page=True) + except Exception: + logger.warning("pay 页面截图失败(不影响付款判定)", exc_info=True) + return PageSnapshot(html=html, screenshot=screenshot) finally: await page.close() @@ -1948,6 +2046,12 @@ class SiteInteractor: ) from exc html = await page.content() final_url = page.url + # 详情页整页截图随 snapshot 带出(监控步骤落证据用,best-effort) + screenshot = b"" + try: + screenshot = await page.screenshot(full_page=True) + except Exception: + logger.warning("订单详情页截图失败(不影响读取结果)", exc_info=True) finally: await page.close() @@ -1962,6 +2066,7 @@ class SiteInteractor: snapshot = _parse_order_detail_status(state, site_order_id) or _parse_order_status( html, site_order_id ) + snapshot.screenshot = screenshot if not snapshot.found and auth_site.looks_logged_out( "rakuten", final_url=final_url, body=html ): diff --git a/scripts/verify_cart_clear.py b/scripts/verify_cart_clear.py index 6f531f2..ac403fb 100644 --- a/scripts/verify_cart_clear.py +++ b/scripts/verify_cart_clear.py @@ -1,10 +1,13 @@ """真账号验证:clear_cart 两个场景——空车返回成功 / 有商品正确清除 场景 1(空车):clear_cart 应 removed_count=0、cart_count=0(status=101 是合法 -空车,不是获取失败),并带回清理后的页面 HTML(runner step 0 落证据用)。 +空车,不是获取失败),并带回清理后的页面 HTML 与整页截图(runner step 0 落 +证据用)。 场景 2(有商品):先加购 1 件商品,clear_cart 应 removed_count>=1、cart_count=0, 末尾再用 cart_status 独立复核确实是空车。 +两个场景的截图会写到 .probe/cart_clear/ 供人工查看。 + 前置:登录态有效(.auth/ 下有 storage_state);浏览器必须非无头(站点风控要求, 会弹出真实浏览器窗口)。商品 URL 与其他探针(probe_new_card 等)一致。 @@ -25,6 +28,13 @@ from app.trading.worker.site_interact import SiteInteractor # noqa: E402 ITEM_URL = "https://item.rakuten.co.jp/moccasin/ds001iwrgesaaa2/" +PROBE_DIR = Path(__file__).resolve().parent.parent / ".probe" / "cart_clear" +_PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + + +def _check_png(data: bytes, label: str) -> None: + assert data[:8] == _PNG_MAGIC, f"{label} 截图不是 PNG({len(data)}B)" + async def run() -> int: settings = get_settings() @@ -32,6 +42,7 @@ async def run() -> int: await auth.start() site = SiteInteractor(auth_session=auth, settings=settings) await site.start() + PROBE_DIR.mkdir(parents=True, exist_ok=True) try: # 前置:先清一次,保证从空车起步(顺便清掉上次验证可能残留的商品) pre = await site.clear_cart() @@ -42,27 +53,34 @@ async def run() -> int: empty = await site.clear_cart() print( f"场景1 空车 -> removed={empty['removed_count']} " - f"cart_count={empty['cart_count']} html={len(empty['html'])}B" + f"cart_count={empty['cart_count']} html={len(empty['html'])}B " + f"png={len(empty['screenshot'])}B" ) assert empty["removed_count"] == 0, f"空车不应有点击,实际 {empty['removed_count']}" assert empty["cart_count"] == 0, f"空车 cart_count 应为 0,实际 {empty['cart_count']}" assert empty["html"], "空车也应带回页面 HTML(runner 落证据用)" + _check_png(empty["screenshot"], "场景1 空车") + (PROBE_DIR / "scenario1-empty.png").write_bytes(empty["screenshot"]) # 场景 2:有商品 —— 先加购 1 件,再清,应真删掉 added = await site.add_to_cart_payload(item_url=ITEM_URL, quantity=1) print(f"加购 -> item_id={added['item_id']} cart_count={added['cart_count']}") assert added["cart_count"] >= 1, "加购后购物车应有商品" + _check_png(added["screenshot"], "加购后商品页") cleared = await site.clear_cart() print( f"场景2 有商品 -> removed={cleared['removed_count']} " - f"cart_count={cleared['cart_count']} html={len(cleared['html'])}B" + f"cart_count={cleared['cart_count']} html={len(cleared['html'])}B " + f"png={len(cleared['screenshot'])}B" ) assert cleared["removed_count"] >= 1, ( f"有商品时至少点 1 次「削除」,实际 {cleared['removed_count']}" ) assert cleared["cart_count"] == 0, f"清理后应为空车,实际 {cleared['cart_count']}" assert cleared["html"], "清理后应带回页面 HTML" + _check_png(cleared["screenshot"], "场景2 清理后") + (PROBE_DIR / "scenario2-cleared.png").write_bytes(cleared["screenshot"]) # 复核:独立的 status 查询确认确实是空车(不只信 clear 末尾的一次校验) status = await site.cart_status() @@ -71,7 +89,7 @@ async def run() -> int: f"复核应为空车(count=0, raw_status=101),实际 {status}" ) - print("\n验证通过:空车正确返回成功;有商品时正确清除并复核为空车") + print(f"\n验证通过:空车正确返回成功;有商品时正确清除并复核为空车。截图见 {PROBE_DIR}") return 0 finally: await site.close() diff --git a/tests/test_site_interact.py b/tests/test_site_interact.py index 902ae3f..eec812c 100644 --- a/tests/test_site_interact.py +++ b/tests/test_site_interact.py @@ -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="empty cart", @@ -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": "empty cart"} + assert result == { + "removed_count": 0, + "cart_count": 0, + "html": "empty cart", + "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="cleared cart", @@ -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"] == "cleared cart" + 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 ---- diff --git a/tests/test_worker_runner.py b/tests/test_worker_runner.py index e2fac6c..2abd64b 100644 --- a/tests/test_worker_runner.py +++ b/tests/test_worker_runner.py @@ -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 "checkout" + return PageSnapshot(html="checkout", 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 "checkout" + return PageSnapshot(html="checkout", 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="complete", 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 "checkout" + return PageSnapshot(html="checkout", 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 "checkout" + return PageSnapshot(html="checkout", 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="complete", 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": "cart"} + return { + "removed_count": 1, + "cart_count": 0, + "html": "cart", + "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 == "cart" + 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": "leftover"} + return { + "removed_count": 2, + "cart_count": 2, + "html": "leftover", + "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": "cart", + "screenshot": b"png-0", + } + + async def _add(task): # noqa: ANN001 + return PageSnapshot(html="added", screenshot=b"png-1") + + async def _verify(task): # noqa: ANN001 + return PageSnapshot(html="verified", screenshot=b"png-2") + + async def _checkout(task): # noqa: ANN001 + return PageSnapshot(html="checkout", 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="done", screenshot=b"png-4"), + ) + + async def _pay(task, site_order_id): # noqa: ANN001 + return PageSnapshot(html="paid", 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 "checkout" + return PageSnapshot(html="checkout", 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 "checkout" + return PageSnapshot(html="checkout", 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="complete", screenshot=b"fake-submit-png"), + ) async def _pay(task, site_order_id): # noqa: ANN001 return None