fix: 记录规格面板误超时诊断 (#115)
This commit is contained in:
@@ -6,14 +6,17 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Mapping, Optional
|
from typing import Any, Callable, Mapping, Optional
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from .db import data_dir
|
||||||
from .pdd_device_service import (
|
from .pdd_device_service import (
|
||||||
PDD_PACKAGE_NAME,
|
PDD_PACKAGE_NAME,
|
||||||
PddDeviceError,
|
PddDeviceError,
|
||||||
@@ -58,6 +61,19 @@ _PAYMENT_MARKERS = ("输入支付密码", "立即支付", "支付成功", "支
|
|||||||
_FINAL_SUBMIT_MARKERS = ("提交订单", "现在买,仅", "确认购买")
|
_FINAL_SUBMIT_MARKERS = ("提交订单", "现在买,仅", "确认购买")
|
||||||
_OUT_OF_STOCK_MARKERS = ("已售罄", "暂时缺货", "库存不足", "该商品已售罄")
|
_OUT_OF_STOCK_MARKERS = ("已售罄", "暂时缺货", "库存不足", "该商品已售罄")
|
||||||
_SUPPORTED_OPTION_KEYS = frozenset({"color", "size"})
|
_SUPPORTED_OPTION_KEYS = frozenset({"color", "size"})
|
||||||
|
_DIAGNOSTIC_MARKERS = (
|
||||||
|
"增加数量",
|
||||||
|
"减少数量",
|
||||||
|
"提交订单",
|
||||||
|
"现在买",
|
||||||
|
"确认购买",
|
||||||
|
"颜色分类",
|
||||||
|
"尺码",
|
||||||
|
"安全验证",
|
||||||
|
"手机号登录",
|
||||||
|
"操作频繁",
|
||||||
|
"网络不给力",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_xml(xml_data: str | bytes) -> ET.Element:
|
def _parse_xml(xml_data: str | bytes) -> ET.Element:
|
||||||
@@ -83,6 +99,20 @@ def _labels(root: ET.Element) -> list[str]:
|
|||||||
return [value for node in root.iter("node") if (value := _label(node))]
|
return [value for node in root.iter("node") if (value := _label(node))]
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_diagnostic_xml(xml_data: str) -> str:
|
||||||
|
"""只保留页面判断所需语义,删除商品、账号和收货相关文字。"""
|
||||||
|
|
||||||
|
root = ET.fromstring(xml_data)
|
||||||
|
for node in root.iter("node"):
|
||||||
|
for key in ("text", "content-desc", "hint"):
|
||||||
|
value = node.get(key, "").strip()
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
markers = [marker for marker in _DIAGNOSTIC_MARKERS if marker in value]
|
||||||
|
node.set(key, " ".join(markers) if markers else "[已脱敏]")
|
||||||
|
return ET.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
def _parse_bounds(value: str) -> Optional[Bounds]:
|
def _parse_bounds(value: str) -> Optional[Bounds]:
|
||||||
match = _BOUNDS_PATTERN.fullmatch((value or "").strip())
|
match = _BOUNDS_PATTERN.fullmatch((value or "").strip())
|
||||||
if not match:
|
if not match:
|
||||||
@@ -248,6 +278,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
panel_timeout: float = 10.0,
|
panel_timeout: float = 10.0,
|
||||||
select_color_fn: Callable[..., bool] = select_color,
|
select_color_fn: Callable[..., bool] = select_color,
|
||||||
select_size_fn: Callable[..., bool] = select_size,
|
select_size_fn: Callable[..., bool] = select_size,
|
||||||
|
artifact_directory: Optional[Path] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._device_address = str(device_address or "").strip()
|
self._device_address = str(device_address or "").strip()
|
||||||
self._device_service = device_service or PddDeviceService()
|
self._device_service = device_service or PddDeviceService()
|
||||||
@@ -258,6 +289,9 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
self._panel_timeout = panel_timeout
|
self._panel_timeout = panel_timeout
|
||||||
self._select_color = select_color_fn
|
self._select_color = select_color_fn
|
||||||
self._select_size = select_size_fn
|
self._select_size = select_size_fn
|
||||||
|
self._artifact_directory = artifact_directory
|
||||||
|
self._last_xml: Optional[str] = None
|
||||||
|
self._hierarchy_reads = 0
|
||||||
self._session = None
|
self._session = None
|
||||||
self._device = None
|
self._device = None
|
||||||
self._goods_id = ""
|
self._goods_id = ""
|
||||||
@@ -266,6 +300,8 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
|
|
||||||
def open_goods(self, goods_url: str) -> None:
|
def open_goods(self, goods_url: str) -> None:
|
||||||
self._goods_id = _goods_id_from_url(goods_url)
|
self._goods_id = _goods_id_from_url(goods_url)
|
||||||
|
self._last_xml = None
|
||||||
|
self._hierarchy_reads = 0
|
||||||
self._check_cancelled("purchase_open_goods")
|
self._check_cancelled("purchase_open_goods")
|
||||||
try:
|
try:
|
||||||
self._session = self._device_service.connect(self._device_address)
|
self._session = self._device_service.connect(self._device_address)
|
||||||
@@ -289,7 +325,12 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
stage = trace.stage("open_url") if trace else nullcontext()
|
stage = trace.stage("open_url") if trace else nullcontext()
|
||||||
with stage:
|
with stage:
|
||||||
self._device.open_url(goods_url)
|
self._device.open_url(goods_url)
|
||||||
self._wait_for_goods_page(goods_url, before_open)
|
package_hint = (
|
||||||
|
PDD_PACKAGE_NAME
|
||||||
|
if current.get("package") == PDD_PACKAGE_NAME
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
self._wait_for_goods_page(goods_url, before_open, package_hint)
|
||||||
except PddPurchaseError:
|
except PddPurchaseError:
|
||||||
raise
|
raise
|
||||||
except PddDeviceError as exc:
|
except PddDeviceError as exc:
|
||||||
@@ -451,6 +492,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
self,
|
self,
|
||||||
goods_url: str,
|
goods_url: str,
|
||||||
before_open: Optional[PddPageObservation],
|
before_open: Optional[PddPageObservation],
|
||||||
|
package_hint: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""确认本次深链进入新商品页;稳定首页时只重开一次。"""
|
"""确认本次深链进入新商品页;稳定首页时只重开一次。"""
|
||||||
|
|
||||||
@@ -466,7 +508,6 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
while self._monotonic() < deadline:
|
while self._monotonic() < deadline:
|
||||||
self._check_cancelled("purchase_open_goods")
|
self._check_cancelled("purchase_open_goods")
|
||||||
device = self._require_device()
|
device = self._require_device()
|
||||||
current = device.app_current()
|
|
||||||
if first_dump and trace is not None:
|
if first_dump and trace is not None:
|
||||||
with trace.stage("first_dump_hierarchy"):
|
with trace.stage("first_dump_hierarchy"):
|
||||||
xml_data = self._dump_hierarchy()
|
xml_data = self._dump_hierarchy()
|
||||||
@@ -474,9 +515,9 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
else:
|
else:
|
||||||
xml_data = self._dump_hierarchy()
|
xml_data = self._dump_hierarchy()
|
||||||
root = _parse_xml(xml_data)
|
root = _parse_xml(xml_data)
|
||||||
observation = classify_pdd_page(
|
# 不在轮询中调用 app_current()。某些设备会阻塞十秒并错误
|
||||||
root, str(current.get("package") or "")
|
# 报告设置页;最新控件树中的 PDD 包节点才是可靠依据。
|
||||||
)
|
observation = classify_pdd_page(root, package_hint)
|
||||||
last_kind = observation.kind
|
last_kind = observation.kind
|
||||||
if trace is not None and last_kind != last_recorded:
|
if trace is not None and last_kind != last_recorded:
|
||||||
trace.record(
|
trace.record(
|
||||||
@@ -564,33 +605,74 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
|
|
||||||
def _wait_for_confirmation_panel(self) -> str:
|
def _wait_for_confirmation_panel(self) -> str:
|
||||||
deadline = self._monotonic() + self._panel_timeout
|
deadline = self._monotonic() + self._panel_timeout
|
||||||
|
last_kind = "unknown"
|
||||||
|
panel_hierarchy_reads = 0
|
||||||
while self._monotonic() < deadline:
|
while self._monotonic() < deadline:
|
||||||
self._check_cancelled("purchase_select_options")
|
self._check_cancelled("purchase_select_options")
|
||||||
xml_data = self._dump_hierarchy()
|
xml_data = self._dump_hierarchy()
|
||||||
|
panel_hierarchy_reads += 1
|
||||||
root = _parse_xml(xml_data)
|
root = _parse_xml(xml_data)
|
||||||
current = self._require_device().app_current()
|
last_kind = _page_kind(root, "")
|
||||||
kind = _page_kind(root, str(current.get("package") or ""))
|
if last_kind == "order_confirmation" and any(
|
||||||
if kind == "order_confirmation" and any(
|
|
||||||
"增加数量" in label for label in _labels(root)
|
"增加数量" in label for label in _labels(root)
|
||||||
):
|
):
|
||||||
return xml_data
|
return xml_data
|
||||||
if kind in {"captcha", "login_required", "risk_control", "payment"}:
|
if last_kind in {
|
||||||
self._raise_special_page(kind)
|
"captcha",
|
||||||
|
"login_required",
|
||||||
|
"risk_control",
|
||||||
|
"payment",
|
||||||
|
}:
|
||||||
|
self._raise_special_page(last_kind)
|
||||||
self._sleep(0.2)
|
self._sleep(0.2)
|
||||||
|
diagnostics: dict[str, Any] = {
|
||||||
|
"page_kind": last_kind,
|
||||||
|
"hierarchy_reads": self._hierarchy_reads,
|
||||||
|
"panel_hierarchy_reads": panel_hierarchy_reads,
|
||||||
|
}
|
||||||
|
artifact = self._save_last_xml("purchase-panel-timeout")
|
||||||
|
if artifact is not None:
|
||||||
|
diagnostics["artifacts"] = [artifact]
|
||||||
raise PddPurchaseError(
|
raise PddPurchaseError(
|
||||||
"PURCHASE_PANEL_TIMEOUT",
|
"PURCHASE_PANEL_TIMEOUT",
|
||||||
"点击采购入口后没有到达可靠的提交前确认页",
|
"点击采购入口后没有到达可靠的提交前确认页",
|
||||||
step="purchase_select_options",
|
step="purchase_select_options",
|
||||||
retryable=True,
|
retryable=True,
|
||||||
|
diagnostics=diagnostics,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _dump_hierarchy(self) -> str:
|
def _dump_hierarchy(self) -> str:
|
||||||
raw = self._require_device().dump_hierarchy()
|
raw = self._require_device().dump_hierarchy()
|
||||||
return (
|
xml_data = (
|
||||||
raw.decode("utf-8", errors="replace")
|
raw.decode("utf-8", errors="replace")
|
||||||
if isinstance(raw, bytes)
|
if isinstance(raw, bytes)
|
||||||
else str(raw)
|
else str(raw)
|
||||||
)
|
)
|
||||||
|
self._last_xml = xml_data
|
||||||
|
self._hierarchy_reads += 1
|
||||||
|
return xml_data
|
||||||
|
|
||||||
|
def _save_last_xml(self, label: str) -> Optional[Mapping[str, Any]]:
|
||||||
|
"""保存脱敏控件树;没有配置目录或写入失败时只返回空。"""
|
||||||
|
|
||||||
|
if self._artifact_directory is None or not self._last_xml:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
sanitized = _sanitize_diagnostic_xml(self._last_xml)
|
||||||
|
digest = hashlib.sha256(sanitized.encode("utf-8")).hexdigest()
|
||||||
|
directory = self._artifact_directory / "purchase" / self._goods_id
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = directory / f"{label}-{digest[:12]}.xml"
|
||||||
|
if not path.exists():
|
||||||
|
path.write_text(sanitized, encoding="utf-8")
|
||||||
|
return {
|
||||||
|
"kind": "sanitized_accessibility_xml",
|
||||||
|
"path": str(path.resolve()),
|
||||||
|
"sha256": digest,
|
||||||
|
"hierarchy_reads": self._hierarchy_reads,
|
||||||
|
}
|
||||||
|
except (OSError, ET.ParseError):
|
||||||
|
return None
|
||||||
|
|
||||||
def _require_device(self) -> Any:
|
def _require_device(self) -> Any:
|
||||||
if self._device is None:
|
if self._device is None:
|
||||||
@@ -650,6 +732,7 @@ def create_u2_purchase_adapter(
|
|||||||
device_address,
|
device_address,
|
||||||
device_service=current_thread_device_service() or PddDeviceService(),
|
device_service=current_thread_device_service() or PddDeviceService(),
|
||||||
cancelled=cancelled,
|
cancelled=cancelled,
|
||||||
|
artifact_directory=data_dir() / "artifacts",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -704,4 +787,5 @@ def create_u2_live_purchase_adapter(
|
|||||||
device_address,
|
device_address,
|
||||||
device_service=current_thread_device_service() or PddDeviceService(),
|
device_service=current_thread_device_service() or PddDeviceService(),
|
||||||
cancelled=cancelled,
|
cancelled=cancelled,
|
||||||
|
artifact_directory=data_dir() / "artifacts",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""uiautomator2 采购演练 Adapter 测试;不连接真实手机。"""
|
"""uiautomator2 采购演练 Adapter 测试;不连接真实手机。"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from src.pdd_device_service import PddDeviceService
|
from src.pdd_device_service import PddDeviceService
|
||||||
@@ -27,9 +28,11 @@ class FakeClock:
|
|||||||
def home_xml() -> str:
|
def home_xml() -> str:
|
||||||
return """<hierarchy>
|
return """<hierarchy>
|
||||||
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2376]">
|
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2376]">
|
||||||
<node clickable="true" enabled="true" visible-to-user="true"
|
<node package="com.xunmeng.pinduoduo" clickable="true"
|
||||||
|
enabled="true" visible-to-user="true"
|
||||||
bounds="[500,2100][1080,2328]">
|
bounds="[500,2100][1080,2328]">
|
||||||
<node text="立即购买 ¥5.03" enabled="true" visible-to-user="true"
|
<node package="com.xunmeng.pinduoduo" text="立即购买 ¥5.03"
|
||||||
|
enabled="true" visible-to-user="true"
|
||||||
bounds="[600,2150][1000,2280]"/>
|
bounds="[600,2150][1000,2280]"/>
|
||||||
</node>
|
</node>
|
||||||
</node>
|
</node>
|
||||||
@@ -73,9 +76,11 @@ class FakeDevice:
|
|||||||
self.clicks = []
|
self.clicks = []
|
||||||
self.opened_urls = []
|
self.opened_urls = []
|
||||||
self.app_wait_calls = 0
|
self.app_wait_calls = 0
|
||||||
|
self.app_current_calls = 0
|
||||||
self.has_opened = False
|
self.has_opened = False
|
||||||
|
|
||||||
def app_current(self):
|
def app_current(self):
|
||||||
|
self.app_current_calls += 1
|
||||||
return {"package": "com.xunmeng.pinduoduo"}
|
return {"package": "com.xunmeng.pinduoduo"}
|
||||||
|
|
||||||
def app_start(self, _package):
|
def app_start(self, _package):
|
||||||
@@ -132,6 +137,24 @@ class StaleGoodsDevice(FakeDevice):
|
|||||||
return home_xml()
|
return home_xml()
|
||||||
|
|
||||||
|
|
||||||
|
class SlowSettingsFocusDevice(FakeDevice):
|
||||||
|
"""模拟真机:前台查询慢,而且错误报告为设置页。"""
|
||||||
|
|
||||||
|
def __init__(self, clock: FakeClock) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.clock = clock
|
||||||
|
|
||||||
|
def app_current(self):
|
||||||
|
self.app_current_calls += 1
|
||||||
|
self.clock.sleep(11.0)
|
||||||
|
return {"package": "com.android.settings"}
|
||||||
|
|
||||||
|
|
||||||
|
class PanelDoesNotOpenDevice(FakeDevice):
|
||||||
|
def click(self, x, y):
|
||||||
|
self.clicks.append((x, y))
|
||||||
|
|
||||||
|
|
||||||
class U2PddPurchaseAdapterTest(unittest.TestCase):
|
class U2PddPurchaseAdapterTest(unittest.TestCase):
|
||||||
def _adapter(self, device, calls):
|
def _adapter(self, device, calls):
|
||||||
def select_color_fn(_device, _xml, target, **_kwargs):
|
def select_color_fn(_device, _xml, target, **_kwargs):
|
||||||
@@ -253,6 +276,29 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
|
|||||||
self.assertEqual(2, len(device.opened_urls))
|
self.assertEqual(2, len(device.opened_urls))
|
||||||
self.assertEqual([], device.clicks)
|
self.assertEqual([], device.clicks)
|
||||||
|
|
||||||
|
def test_slow_wrong_focus_still_completes_home_reopen_state_machine(self):
|
||||||
|
clock = FakeClock()
|
||||||
|
device = SlowSettingsFocusDevice(clock)
|
||||||
|
device.mode = "special"
|
||||||
|
device.special_xml = (
|
||||||
|
FIXTURES / "pdd_home_page.xml"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
adapter = U2PddPurchaseAdapter(
|
||||||
|
"USB-001",
|
||||||
|
device_service=PddDeviceService(connector=lambda _serial: device),
|
||||||
|
sleeper=clock.sleep,
|
||||||
|
monotonic=clock.monotonic,
|
||||||
|
page_timeout=10.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(PddPurchaseError) as raised:
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
|
||||||
|
self.assertEqual("PDD_GOODS_UNAVAILABLE", raised.exception.code)
|
||||||
|
self.assertEqual(1, device.app_current_calls)
|
||||||
|
self.assertEqual(2, len(device.opened_urls))
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
def test_stale_goods_page_is_not_accepted_as_target(self):
|
def test_stale_goods_page_is_not_accepted_as_target(self):
|
||||||
device = StaleGoodsDevice()
|
device = StaleGoodsDevice()
|
||||||
clock = FakeClock()
|
clock = FakeClock()
|
||||||
@@ -271,6 +317,59 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
|
|||||||
self.assertEqual([], device.clicks)
|
self.assertEqual([], device.clicks)
|
||||||
adapter.close()
|
adapter.close()
|
||||||
|
|
||||||
|
def test_slow_wrong_focus_does_not_consume_panel_timeout(self):
|
||||||
|
clock = FakeClock()
|
||||||
|
device = SlowSettingsFocusDevice(clock)
|
||||||
|
adapter = U2PddPurchaseAdapter(
|
||||||
|
"USB-001",
|
||||||
|
device_service=PddDeviceService(connector=lambda _serial: device),
|
||||||
|
sleeper=clock.sleep,
|
||||||
|
monotonic=clock.monotonic,
|
||||||
|
panel_timeout=1.0,
|
||||||
|
select_color_fn=lambda *_args, **_kwargs: True,
|
||||||
|
select_size_fn=lambda *_args, **_kwargs: True,
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
adapter.select_options(
|
||||||
|
{"color": "黑色", "size": "3XL【140-165斤】"}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, device.app_current_calls)
|
||||||
|
self.assertEqual(11.0, clock.now)
|
||||||
|
self.assertEqual(1, len(device.clicks))
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
|
def test_panel_timeout_saves_sanitized_last_hierarchy(self):
|
||||||
|
clock = FakeClock()
|
||||||
|
device = PanelDoesNotOpenDevice()
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
adapter = U2PddPurchaseAdapter(
|
||||||
|
"USB-001",
|
||||||
|
device_service=PddDeviceService(
|
||||||
|
connector=lambda _serial: device
|
||||||
|
),
|
||||||
|
sleeper=clock.sleep,
|
||||||
|
monotonic=clock.monotonic,
|
||||||
|
panel_timeout=0.5,
|
||||||
|
artifact_directory=Path(directory),
|
||||||
|
)
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
|
||||||
|
with self.assertRaises(PddPurchaseError) as raised:
|
||||||
|
adapter.select_options({"color": "黑色"})
|
||||||
|
|
||||||
|
diagnostics = raised.exception.diagnostics
|
||||||
|
self.assertEqual("goods", diagnostics["page_kind"])
|
||||||
|
self.assertGreaterEqual(diagnostics["hierarchy_reads"], 2)
|
||||||
|
self.assertGreaterEqual(diagnostics["panel_hierarchy_reads"], 1)
|
||||||
|
artifact = diagnostics["artifacts"][0]
|
||||||
|
content = Path(artifact["path"]).read_text(encoding="utf-8")
|
||||||
|
self.assertNotIn("5.03", content)
|
||||||
|
self.assertNotIn("立即购买", content)
|
||||||
|
self.assertIn("[已脱敏]", content)
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
def test_invalid_non_pdd_url_is_rejected_before_connect(self):
|
def test_invalid_non_pdd_url_is_rejected_before_connect(self):
|
||||||
device = FakeDevice()
|
device = FakeDevice()
|
||||||
adapter = self._adapter(device, [])
|
adapter = self._adapter(device, [])
|
||||||
|
|||||||
Reference in New Issue
Block a user