from __future__ import annotations
import ast
import base64
from contextlib import redirect_stderr
from io import BytesIO
import importlib.util
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
from unittest.mock import patch
from xml.etree import ElementTree
from PIL import Image
import cmbuyer_client.pdd as pdd
import cmbuyer_client.pdd.sku_selection_runner as runner_module
from cmbuyer_client.device.adb import AdbDevice, DeviceConnectionError, DeviceInspection
from cmbuyer_client.pdd import SkuSelectionError, SkuSelectionFlow, SkuSelectionRunner
from cmbuyer_client.pdd.sku_selection import (
SkuPanelDevice,
_action_bounds,
_classify_panel,
_parse_nodes,
resolve_task_selection,
)
from cmbuyer_client.pdd.sku_selection_runner import (
SkuSelectionDeviceAdapterError,
SkuSelectionRunError,
SkuSelectionScreenshotError,
UiautomatorSkuPanelAdapter,
safe_failure_stage,
)
_FIXTURES = Path(__file__).with_name("fixtures")
_EMPTY_FIXTURE = _FIXTURES / "sku_panel_empty_8_17_0.xml"
_COLOR_FIXTURE = _FIXTURES / "sku_panel_color_selected_size_hidden_8_17_0.xml"
_S_FIXTURE = _FIXTURES / "sku_panel_size_s_selected_8_17_0.xml"
_M_FIXTURE = _FIXTURES / "sku_panel_size_m_restored_8_17_0.xml"
_FIXTURE = _M_FIXTURE
_ENTRY_FIXTURE = Path(__file__).with_name("fixtures") / "product_entry_8_17_0.xml"
_TARGET_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"
_TASK_COLOR = "黑色CHA(纯棉)"
_TASK_SIZE = "M(建议100-115)"
_PRODUCT_PAGE = _ENTRY_FIXTURE.read_text(encoding="utf-8")
def _png_base64() -> str:
image = Image.new("RGB", (1080, 2376), "white")
raw = BytesIO()
image.save(raw, format="PNG")
return base64.b64encode(raw.getvalue()).decode("ascii")
class _RawDevice:
def __init__(self, hierarchy: str = _PRODUCT_PAGE, screenshot: str | None = None) -> None:
self.hierarchy = hierarchy
self.panel_hierarchy = _EMPTY_FIXTURE.read_text(encoding="utf-8")
self.version = "8.17.0"
self.package = "com.xunmeng.pinduoduo"
self.screenshot = _png_base64() if screenshot is None else screenshot
self.calls: list[tuple[object, ...]] = []
self.fail_color_readback = False
def app_info(self, package_name: str) -> dict[str, str]:
self.calls.append(("app_info", package_name))
return {"versionName": self.version}
def app_current(self) -> dict[str, str]:
self.calls.append(("app_current",))
return {"package": self.package}
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
self.calls.append(("jsonrpc", method, params, timeout))
if method == "dumpWindowHierarchy":
return self.hierarchy
if method == "takeScreenshot":
return self.screenshot
if method == "pressKey":
self.hierarchy = ""
return ""
if method == "click":
if not isinstance(params, list) or len(params) != 2:
raise AssertionError(params)
self._apply_tap(int(params[0]), int(params[1]))
return ""
raise AssertionError(method)
def _apply_tap(self, x: int, y: int) -> None:
if (
(x, y) == (865, 2218)
and "快要抢光 ¥ 12.88" in self.hierarchy
and "[396,498][895,570]" not in self.hierarchy
):
self.hierarchy = self.panel_hierarchy
return
if (x, y) == (528, 1387):
self.hierarchy = (
_EMPTY_FIXTURE.read_text(encoding="utf-8")
if self.fail_color_readback
else _COLOR_FIXTURE.read_text(encoding="utf-8")
)
return
if (x, y) == (635, 1624):
self.hierarchy = _M_FIXTURE.read_text(encoding="utf-8")
return
raise AssertionError((x, y))
def window_size(self) -> tuple[int, int]:
self.calls.append(("window_size",))
return 1080, 2376
def select_alternates(self) -> None:
self.panel_hierarchy = _S_FIXTURE.read_text(encoding="utf-8")
if self.hierarchy != _PRODUCT_PAGE:
self.hierarchy = self.panel_hierarchy
def _center(bounds: str) -> tuple[int, int]:
left_top, right_bottom = bounds.split("][")
left, top = (int(value) for value in left_top.removeprefix("[").split(","))
right, bottom = (int(value) for value in right_bottom.removesuffix("]").split(","))
return left + (right - left) // 2, top + (bottom - top) // 2
def _mutate_unique_node(
hierarchy: str,
*,
attribute: str,
value: str | None,
text: str | None = None,
desc: str | None = None,
class_name: str | None = None,
bounds: str | None = None,
) -> str:
root = ElementTree.fromstring(hierarchy)
matches = [
node
for node in root.iter("node")
if (text is None or node.get("text") == text)
and (desc is None or node.get("content-desc") == desc)
and (class_name is None or node.get("class") == class_name)
and (bounds is None or node.get("bounds") == bounds)
]
if len(matches) != 1:
raise AssertionError(f"fixture node match count: {len(matches)}")
if value is None:
matches[0].attrib.pop(attribute, None)
else:
matches[0].set(attribute, value)
return ElementTree.tostring(root, encoding="unicode")
def _entry_chain(root: ElementTree.Element) -> list[ElementTree.Element]:
parents = {child: parent for parent in root.iter() for child in parent}
child = next(node for node in root.iter("node") if node.get("text") == "快要抢光 ¥ 12.88")
return [child, parents[child]]
def _entry_sibling(root: ElementTree.Element) -> ElementTree.Element:
return next(node for node in root.iter("node") if node.get("text") == "免拼购买")
def _mutate_entry(depth: int, attribute: str, value: str) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
_entry_chain(root)[depth].set(attribute, value)
return ElementTree.tostring(root, encoding="unicode")
def _without_entry() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.remove(_entry_chain(root)[1])
return ElementTree.tostring(root, encoding="unicode")
def _duplicate_entry() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
entry_root = _entry_chain(root)[1]
root.append(ElementTree.fromstring(ElementTree.tostring(entry_root, encoding="unicode")))
return ElementTree.tostring(root, encoding="unicode")
def _extra_entry_action_ancestor() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
action = _entry_chain(root)[1]
root.append(ElementTree.Element("node", dict(action.attrib)))
return ElementTree.tostring(root, encoding="unicode")
def _entry_with_panel_price_marker() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.append(ElementTree.Element("node", {"bounds": "[396,498][895,570]"}))
return ElementTree.tostring(root, encoding="unicode")
def _dynamic_product_page(value: str, action_desc: str = "") -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.set("dynamic-page-value", value)
_entry_chain(root)[1].set("content-desc", action_desc or "快要抢光¥12.88")
return ElementTree.tostring(root, encoding="unicode")
def _with_overlapping_clickable(package: str, class_name: str, bounds: str) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.append(
ElementTree.Element(
"node",
{
"package": package,
"class": class_name,
"bounds": bounds,
"clickable": "true",
"enabled": "true",
"visible-to-user": "true",
},
)
)
return ElementTree.tostring(root, encoding="unicode")
def _with_action_subtree_child(
text: str,
*,
bounds: str = "[500,2170][600,2200]",
) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
action = _entry_chain(root)[1]
ElementTree.SubElement(
action,
"node",
{
"text": text,
"package": "com.xunmeng.pinduoduo",
"class": "android.widget.TextView",
"bounds": bounds,
"clickable": "false",
"enabled": "true",
"visible-to-user": "true",
},
)
return ElementTree.tostring(root, encoding="unicode")
def _mutate_entry_sibling(attribute: str, value: str) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
_entry_sibling(root).set(attribute, value)
return ElementTree.tostring(root, encoding="unicode")
def _without_entry_sibling() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
_entry_chain(root)[1].remove(_entry_sibling(root))
return ElementTree.tostring(root, encoding="unicode")
def _entry_sibling_elsewhere() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.append(ElementTree.fromstring(ElementTree.tostring(_entry_sibling(root), encoding="unicode")))
return ElementTree.tostring(root, encoding="unicode")
def _home_with_unverified_entry_labels() -> str:
root = ElementTree.Element("hierarchy")
for index in range(2):
ElementTree.SubElement(
root,
"node",
{
"text": "快要抢光",
"package": "com.xunmeng.pinduoduo",
"class": "android.widget.TextView",
"clickable": "false",
"enabled": "true",
"visible-to-user": "true",
"bounds": f"[{index},{index}][{index + 1},{index + 1}]",
},
)
return ElementTree.tostring(root, encoding="unicode")
def _actions(device: _RawDevice, method: str) -> list[tuple[object, ...]]:
return [call for call in device.calls if call[0] == "jsonrpc" and call[1] == method]
def _tap_centers(device: _RawDevice) -> list[tuple[int, int]]:
return [tuple(call[2]) for call in _actions(device, "click")] # type: ignore[misc]
class _FakeAdb:
def __init__(self) -> None:
self.calls: list[tuple[object, ...]] = []
self.inspection = DeviceInspection(AdbDevice(serial="device-1", state="device"), "PKG110", "16")
self.on_intent: callable | None = None
def inspect(self, serial: str) -> DeviceInspection:
self.calls.append(("inspect", serial))
return self.inspection
def start_pdd_view_intent(self, serial: str, goods_id: str) -> object:
self.calls.append(("intent", serial, goods_id))
if self.on_intent is not None:
self.on_intent()
return object()
class SkuSelectionFlowTests(unittest.TestCase):
def _assert_entry_rejected_without_click(self, hierarchy: str) -> None:
now = [0.0]
device = _RawDevice(hierarchy)
flow = SkuSelectionFlow(
UiautomatorSkuPanelAdapter(device, 10),
0.01,
0.01,
lambda: now[0],
lambda seconds: now.__setitem__(0, now[0] + seconds),
)
with self.assertRaises(SkuSelectionError):
flow.open_sku_panel(_TARGET_URL)
self.assertEqual(_actions(device, "click"), [])
def test_target_mapping_stops_at_unproven_reveal_then_s_to_m_is_exact(self) -> None:
device = _RawDevice()
adapter = UiautomatorSkuPanelAdapter(device, 10)
flow = SkuSelectionFlow(adapter)
flow.open_sku_panel(_TARGET_URL)
with self.assertRaisesRegex(SkuSelectionError, "受控显示动作尚未取证"):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387)])
self.assertEqual(_actions(device, "pressKey"), [])
restored = _RawDevice(_S_FIXTURE.read_text(encoding="utf-8"))
restored_flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(restored, 10))
restored_flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(restored_flow.read_sku_unit_price(), "12.88")
restored_flow.exit_sku_panel_safely()
self.assertEqual(_tap_centers(restored), [(635, 1624)])
self.assertEqual(_actions(restored, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None:
device = _RawDevice()
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
self.assertEqual(_tap_centers(device), [(865, 2218)])
self.assertNotIn((763, 2247), _tap_centers(device)) # 父容器中心不是获准目标。
self.assertNotIn((772, 2280), _tap_centers(device)) # 第二行“免拼购买”永不点击。
def test_entry_stability_uses_verified_chain_projection_not_whole_xml(self) -> None:
class DynamicProductDevice(_RawDevice):
def __init__(self) -> None:
super().__init__()
self.frame = 0
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy" and "快要抢光" in self.hierarchy:
self.frame += 1
self.hierarchy = _dynamic_product_page(str(self.frame))
return super().jsonrpc_call(method, params, timeout)
now = [0.0]
device = DynamicProductDevice()
flow = SkuSelectionFlow(
UiautomatorSkuPanelAdapter(device, 10),
0.03,
0.01,
lambda: now[0],
lambda seconds: now.__setitem__(0, now[0] + seconds),
)
flow.open_sku_panel(_TARGET_URL, "")
self.assertEqual(device.frame, 2)
self.assertEqual(_tap_centers(device), [(865, 2218)])
def test_entry_action_description_must_be_stable_across_frames(self) -> None:
class ChangingActionDescriptionDevice(_RawDevice):
def __init__(self) -> None:
super().__init__()
self.frame = 0
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy":
self.frame += 1
self.hierarchy = _dynamic_product_page(
str(self.frame),
f"活动{self.frame}快要抢光",
)
return super().jsonrpc_call(method, params, timeout)
now = [0.0]
device = ChangingActionDescriptionDevice()
flow = SkuSelectionFlow(
UiautomatorSkuPanelAdapter(device, 10),
0.02,
0.01,
lambda: now[0],
lambda seconds: now.__setitem__(0, now[0] + seconds),
)
with self.assertRaises(SkuSelectionError):
flow.open_sku_panel(_TARGET_URL, "")
self.assertEqual(_actions(device, "click"), [])
def test_unverified_home_entry_labels_do_not_trigger_old_product_rejection(self) -> None:
device = _RawDevice()
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(
_TARGET_URL,
_home_with_unverified_entry_labels(),
)
self.assertEqual(_tap_centers(device), [(865, 2218)])
def test_entry_child_and_every_ancestor_attribute_drift_never_clicks(self) -> None:
expected_clickable = ("false", "true")
for depth in range(2):
changes = {
"package": "other.package",
"class": "android.widget.Button",
"bounds": "[1,1][2,2]",
"clickable": "true" if expected_clickable[depth] == "false" else "false",
"enabled": "false",
"visible-to-user": "false",
}
for attribute, value in changes.items():
with self.subTest(depth=depth, attribute=attribute):
self._assert_entry_rejected_without_click(_mutate_entry(depth, attribute, value))
def test_entry_chain_text_and_description_drift_never_clicks(self) -> None:
cases = [
_mutate_entry(1, "text", "祖先文字漂移")
] + [
_mutate_entry(0, "content-desc", "祖先描述漂移"),
_mutate_entry(1, "content-desc", "祖先描述漂移"),
] + [
_mutate_entry(1, "content-desc", forbidden + "快要抢光")
for forbidden in (
"免拼购买", "单独购买", "直接拼成", "提交订单", "支付", "先用后付", "0元下单",
"立即购买", "确认下单", "立即付款", "订单详情",
)
]
for hierarchy in cases:
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_any_live_clickable_covering_entry_center_blocks_click(self) -> None:
cases = (
_with_overlapping_clickable(
"com.xunmeng.pinduoduo",
"android.widget.Button",
"[850,2200][900,2230]",
),
_with_overlapping_clickable(
"com.android.systemui",
"android.view.ViewGroup",
"[800,2100][1000,2300]",
),
_with_overlapping_clickable(
"com.android.systemui",
"android.view.ViewGroup",
"not-a-bound",
),
)
for hierarchy in cases:
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_pre_intent_old_entry_is_rejected_even_with_extra_action_node(self) -> None:
for old_page in (
_extra_entry_action_ancestor(),
_entry_with_panel_price_marker(),
_mutate_entry(1, "content-desc", "立即购买快要抢光"),
):
with self.subTest():
device = _RawDevice()
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
with self.assertRaises(SkuSelectionError):
flow.open_sku_panel(_TARGET_URL, old_page)
self.assertEqual(_actions(device, "click"), [])
def test_action_subtree_dangerous_or_ambiguous_children_never_click(self) -> None:
cases = (
_with_action_subtree_child("提交订单"),
_with_action_subtree_child("免拼购买"),
_with_action_subtree_child("快要抢光"),
)
for hierarchy in cases:
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_duplicate_entry_and_forbidden_sibling_entry_never_click(self) -> None:
self._assert_entry_rejected_without_click(_duplicate_entry())
self._assert_entry_rejected_without_click(_extra_entry_action_ancestor())
self._assert_entry_rejected_without_click(_without_entry())
self._assert_entry_rejected_without_click(_mutate_entry(0, "clickable", "true"))
def test_header_promotion_label_alone_never_becomes_entry(self) -> None:
self._assert_entry_rejected_without_click(_without_entry())
def test_entry_leaf_and_parent_amount_identity_drift_never_clicks(self) -> None:
for hierarchy in (
_PRODUCT_PAGE.replace('text="快要抢光 ¥ 12.88"', 'text="快要抢光 ¥ 13.88"'),
_mutate_entry(1, "content-desc", "快要抢光¥13.88"),
):
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_entry_sibling_is_exact_inert_unique_and_in_same_parent(self) -> None:
cases = (
_without_entry_sibling(),
_mutate_entry_sibling("text", "免拼购买 "),
_mutate_entry_sibling("bounds", "[688,2255][856,2305]"),
_mutate_entry_sibling("clickable", "true"),
_with_action_subtree_child("免拼购买", bounds="[500,2260][650,2300]"),
_entry_sibling_elsewhere(),
)
for hierarchy in cases:
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_unknown_task_or_ui_variants_are_rejected_without_action(self) -> None:
for color, size in (("黑色 CHA (纯棉)", _TASK_SIZE), (_TASK_COLOR, "M(建议100-115)"), ("黑色CHA(纯棉)", _TASK_SIZE)):
with self.subTest(color=color, size=size), self.assertRaises(SkuSelectionError):
resolve_task_selection(color, size)
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8"))
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).select_sku_options(
resolve_task_selection(_TASK_COLOR, _TASK_SIZE).__class__("粉红", "L(建议115-130)")
)
self.assertEqual(_actions(device, "click"), [])
def test_option_selected_and_container_drift_fail_closed_before_click(self) -> None:
base = _FIXTURE.read_text(encoding="utf-8")
cases = (
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="selected", value=None),
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="selected", value="maybe"),
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="bounds", value="[1,1][20,20]"),
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="enabled", value="false"),
)
for hierarchy in cases:
with self.subTest(), self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(hierarchy), 10)).select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
def test_unknown_clickable_action_ancestor_is_rejected_by_profile(self) -> None:
root = ElementTree.fromstring(_EMPTY_FIXTURE.read_text(encoding="utf-8"))
parents = {child: parent for parent in root.iter() for child in parent}
target = next(
node
for node in root.iter("node")
if node.get("content-desc") == "黑色 CHA (纯棉)"
and node.get("class") == "android.view.ViewGroup"
)
region = parents[target]
region.remove(target)
unknown = ElementTree.SubElement(
region,
"node",
{
"package": "com.xunmeng.pinduoduo",
"class": "android.view.ViewGroup",
"bounds": "[0,1188][1080,2046]",
"clickable": "true",
"enabled": "true",
"visible-to-user": "true",
"selected": "false",
"scrollable": "false",
},
)
unknown.append(target)
with self.assertRaises(SkuSelectionError):
_classify_panel(_parse_nodes(ElementTree.tostring(root, encoding="unicode")))
def test_invalid_bounds_stop_before_action(self) -> None:
for bounds in ("", "[1,2][1,3]", "[1,2][3,2]", "[0,0][1081,1]", "[0,0][1,2377]", "[a,0][1,1]"):
with self.subTest(bounds=bounds), self.assertRaises(SkuSelectionError):
_action_bounds(bounds)
device = _RawDevice(_PRODUCT_PAGE.replace("[688,2184][1042,2253]", "[0,0][1081,1]"))
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
self.assertEqual(_actions(device, "click"), [])
def test_color_readback_failure_never_attempts_second_option(self) -> None:
device = _RawDevice()
device.fail_color_readback = True
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
flow.open_sku_panel(_TARGET_URL)
with self.assertRaises(SkuSelectionError):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387)])
def test_non_target_selection_restores_each_dimension_once(self) -> None:
device = _RawDevice()
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
flow.open_sku_panel(_TARGET_URL)
device.select_alternates()
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(
_tap_centers(device),
[(865, 2218), (635, 1624)],
)
def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None:
for replacement in ("券后 ¥12.88", "会员补贴 ¥12.88", "到手 ¥12.88", "实付 ¥12.88", "区间 ¥12.88", "原价 ¥12.88", "划线价 ¥12.88", "最低 ¥12.88", "低至 ¥12.88", "起价 ¥12.88", "快卖完 1 ¥12.88", "快卖完 ¥12.88 ¥11.88"):
with self.subTest(replacement=replacement):
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", replacement))
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).read_sku_unit_price()
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", "提交订单 ¥12.88"))
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).read_sku_unit_price()
clickable_parent = _mutate_unique_node(
_FIXTURE.read_text(encoding="utf-8"),
class_name="android.widget.LinearLayout",
bounds="[396,498][895,570]",
attribute="clickable",
value="true",
)
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(clickable_parent), 10)).read_sku_unit_price()
def test_public_api_and_protocol_have_no_broad_or_order_operations(self) -> None:
forbidden = {"quantity", "confirm", "authorization", "fence", "submit", "payment", "click"}
self.assertTrue(forbidden.isdisjoint(SkuSelectionFlow.__dict__))
self.assertTrue(forbidden.isdisjoint(SkuPanelDevice.__dict__))
self.assertTrue(forbidden.isdisjoint(pdd.__all__))
def test_static_ast_boundary_limits_flow_runner_adapter_and_cli(self) -> None:
root = Path(__file__).resolve().parents[2]
files = (
root / "src" / "cmbuyer_client" / "pdd" / "sku_selection.py",
root / "src" / "cmbuyer_client" / "pdd" / "sku_selection_runner.py",
root / "scripts" / "run_t103_sku_selection.py",
root / "src" / "cmbuyer_client" / "pdd" / "sku_reveal_spike.py",
root / "scripts" / "capture_sku_reveal_spike.py",
)
forbidden = ("quantity", "confirm", "authorization", "fence", "submit_order", "payment")
for path in files:
source = path.read_text(encoding="utf-8")
with self.subTest(path=path.name):
self.assertTrue(all(token not in source.lower() for token in forbidden))
tree = ast.parse(source)
self.assertFalse(any(isinstance(node, ast.ImportFrom) and node.module in {"selenium", "requests"} for node in ast.walk(tree)))
runner_tree = ast.parse(files[1].read_text(encoding="utf-8"))
click_calls = [node for node in ast.walk(runner_tree) if isinstance(node, ast.Constant) and node.value == "click"]
self.assertEqual(len(click_calls), 1)
def test_entry_wait_rejects_unchanged_or_duplicate_page_without_click(self) -> None:
now = [0.0]
device = _RawDevice()
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10), 0.01, 0.01, lambda: now[0], lambda seconds: now.__setitem__(0, now[0] + seconds))
with self.assertRaises(SkuSelectionError):
flow.open_sku_panel(_TARGET_URL, _PRODUCT_PAGE)
self.assertEqual(_actions(device, "click"), [])
def test_action_postcondition_wait_never_repeats_entry_click(self) -> None:
class NoPanelAfterEntry(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click":
self.calls.append(("jsonrpc", method, params, timeout))
return ""
return super().jsonrpc_call(method, params, timeout)
device = NoPanelAfterEntry()
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
self.assertEqual(_tap_centers(device), [(865, 2218)])
duplicate = _duplicate_entry()
device = _RawDevice(duplicate)
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
self.assertEqual(_actions(device, "click"), [])
def test_fixture_contains_no_address_phone_or_payment_credentials(self) -> None:
for fixture in (
_EMPTY_FIXTURE,
_COLOR_FIXTURE,
_S_FIXTURE,
_M_FIXTURE,
_ENTRY_FIXTURE,
):
content = fixture.read_text(encoding="utf-8")
with self.subTest(fixture=fixture.name):
self.assertNotRegex(content, r"1[3-9]\d{9}")
for forbidden in ("地址", "收货", "支付", "银行卡", "身份证"):
self.assertNotIn(forbidden, content)
self.assertNotIn("提交订单", content)
class _CompletedFlow:
"""仅隔离 runner 文件发布测试;生产 Flow 在 reveal 取证前仍必须停止。"""
def __init__(self, device: object, *args: object, **kwargs: object) -> None:
self.device = device
def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
return None
def select_sku_options(self, selection: object) -> None:
return None
def verify_target_selection_and_read_price(self, selection: object) -> str:
return "12.88"
def exit_sku_panel_safely(self) -> None:
return None
def reconcile_pending_action(self) -> None:
return None
class SkuSelectionRunnerTests(unittest.TestCase):
def _runner(self, adb: _FakeAdb, device: _RawDevice) -> SkuSelectionRunner:
device.hierarchy = ""
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("", ''))
return SkuSelectionRunner(adb, lambda serial: device, 0.03)
def test_runner_atomically_publishes_screenshot_and_redacted_manifest(self) -> None:
adb = _FakeAdb()
device = _RawDevice()
with TemporaryDirectory() as temporary:
target = Path(temporary) / "result"
with patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow):
result = self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertEqual(result.unit_price, "12.88")
manifest = result.manifest_path.read_text(encoding="utf-8")
self.assertTrue(result.screenshot_path.is_file())
self.assertNotIn("device-1", manifest)
self.assertNotIn("hierarchy", manifest)
self.assertNotIn("已选", manifest)
self.assertIn('"unit_price": "12.88"', manifest)
self.assertIn('"selection_status": "restored"', manifest)
self.assertIn('"panel_status": "verified"', manifest)
self.assertIn('"safe_exit": "completed"', manifest)
self.assertFalse((target / "hierarchy.xml").exists())
self.assertEqual(_actions(device, "pressKey"), [])
def test_failure_stage_is_fixed_control_flow_metadata_without_error_text(self) -> None:
class NoPanelAfterEntry(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click":
self.calls.append(("jsonrpc", method, params, timeout))
return ""
return super().jsonrpc_call(method, params, timeout)
adb = _FakeAdb()
device = NoPanelAfterEntry("")
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE)
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised:
SkuSelectionRunner(adb, lambda serial: device, 0.03).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_panel_verify")
forged = SkuSelectionError("private")
setattr(forged, "_cmbuyer_failure_stage", "sku_entry_click")
self.assertEqual(safe_failure_stage(forged), "unknown")
class HostileSetterError(DeviceConnectionError):
def __setattr__(self, name: str, value: object) -> None:
raise KeyboardInterrupt("SERIAL=private secret")
class FailingAdb(_FakeAdb):
def inspect(self, serial: str) -> DeviceInspection:
raise HostileSetterError("private")
with TemporaryDirectory() as temporary, self.assertRaises(HostileSetterError) as hostile:
SkuSelectionRunner(FailingAdb(), lambda serial: _RawDevice(), 10).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(hostile.exception), "unknown")
def test_failure_stage_identifies_sku_entry_pre_intent(self) -> None:
device = _RawDevice(_PRODUCT_PAGE)
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised:
SkuSelectionRunner(_FakeAdb(), lambda serial: device, 0.03).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_pre_intent")
self.assertEqual(_actions(device, "click"), [])
def test_failure_stage_identifies_sku_entry_discovery(self) -> None:
device = _RawDevice("")
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised:
SkuSelectionRunner(_FakeAdb(), lambda serial: device, 0.01).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_discovery")
self.assertEqual(_actions(device, "click"), [])
def test_failure_stage_identifies_sku_entry_click(self) -> None:
class ClickFailureDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click":
self.calls.append(("jsonrpc", method, params, timeout))
raise TimeoutError("private device detail")
return super().jsonrpc_call(method, params, timeout)
adb = _FakeAdb()
device = ClickFailureDevice("")
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE)
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError) as raised:
SkuSelectionRunner(adb, lambda serial: device, 0.03).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_click")
self.assertEqual(len(_actions(device, "click")), 1)
def test_target_created_during_publish_is_preserved_without_staging_residue(self) -> None:
with TemporaryDirectory() as temporary:
target = Path(temporary) / "result"
original_rename = runner_module.os.rename
def create_target_then_rename(source: str | Path, destination: str | Path) -> None:
Path(destination).mkdir()
(Path(destination) / "sentinel").write_text("keep", encoding="utf-8")
original_rename(source, destination)
with (
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
patch.object(runner_module.os, "rename", side_effect=create_target_then_rename),
self.assertRaises(SkuSelectionRunError),
):
self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertEqual((target / "sentinel").read_text(encoding="utf-8"), "keep")
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
def test_bad_screenshot_or_existing_target_never_publishes_manifest(self) -> None:
with TemporaryDirectory() as temporary:
target = Path(temporary) / "result"
with (
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
self.assertRaises(SkuSelectionScreenshotError) as screenshot_failure,
):
self._runner(_FakeAdb(), _RawDevice(screenshot="not-image")).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertEqual(safe_failure_stage(screenshot_failure.exception), "screenshot_capture")
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
target = Path(temporary) / "write-failure"
with (
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
patch.object(runner_module, "_save_base64_screenshot", side_effect=OSError("private path")),
):
with self.assertRaises(SkuSelectionScreenshotError):
self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".write-failure.staging-*")), [])
adb = _FakeAdb()
device = _RawDevice()
target.mkdir()
sentinel = target / "keep"
sentinel.write_text("keep", encoding="utf-8")
with self.assertRaises(SkuSelectionRunError) as existing_target_failure:
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertEqual(safe_failure_stage(existing_target_failure.exception), "precheck")
self.assertEqual(adb.calls, [])
self.assertEqual(device.calls, [])
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
def test_device_screen_and_output_preflight_fail_before_any_click(self) -> None:
with TemporaryDirectory() as temporary:
adb = _FakeAdb()
adb.inspection = DeviceInspection(AdbDevice(serial="device-1", state="device"), "wrong", "16")
device = _RawDevice()
with self.assertRaises(SkuSelectionRunError):
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
self.assertEqual(device.calls, [])
class WrongScreenDevice(_RawDevice):
def window_size(self) -> tuple[int, int]:
return 1080, 1920
device = WrongScreenDevice()
with self.assertRaises(SkuSelectionRunError):
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "screen")
self.assertEqual(_actions(device, "click"), [])
parent_file = Path(temporary) / "not-a-directory"
parent_file.write_text("x", encoding="utf-8")
adb = _FakeAdb()
device = _RawDevice()
with self.assertRaises(SkuSelectionRunError):
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, parent_file / "result")
self.assertEqual(adb.calls, [])
self.assertEqual(device.calls, [])
def test_small_but_valid_png_is_not_accepted(self) -> None:
image = Image.new("RGB", (1, 1), "white")
raw = BytesIO(); image.save(raw, format="PNG")
with (
TemporaryDirectory() as temporary,
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
self.assertRaises(SkuSelectionScreenshotError),
):
self._runner(_FakeAdb(), _RawDevice(screenshot=base64.b64encode(raw.getvalue()).decode("ascii"))).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
def test_failure_after_entry_attempts_one_safe_exit_and_hides_device_detail(self) -> None:
adb = _FakeAdb()
device = _RawDevice()
device.fail_color_readback = True
device.select_alternates()
with TemporaryDirectory() as temporary:
with self.assertRaises(SkuSelectionError):
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
self.assertEqual(_actions(device, "pressKey"), [])
class FailingRawDevice(_RawDevice):
def app_info(self, package_name: str) -> dict[str, str]:
raise RuntimeError("device-1 private")
with self.assertRaises(SkuSelectionDeviceAdapterError) as raised:
UiautomatorSkuPanelAdapter(FailingRawDevice(), 10).app_info("com.xunmeng.pinduoduo")
self.assertNotIn("device-1", str(raised.exception))
self.assertNotIn("private", str(raised.exception))
def test_unverified_failure_never_sends_blind_back(self) -> None:
class InvalidAfterOptionDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
value = super().jsonrpc_call(method, params, timeout)
if method == "click" and "[396,498][895,570]" in self.hierarchy:
self.hierarchy = ""
return value
device = InvalidAfterOptionDevice()
device.select_alternates()
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError):
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
self.assertEqual(_actions(device, "pressKey"), [])
def test_adapter_timeout_is_mapped_without_third_party_detail(self) -> None:
class TimeoutRawDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
raise TimeoutError("device-1 private")
with self.assertRaises(SkuSelectionRunError) as raised:
UiautomatorSkuPanelAdapter(TimeoutRawDevice(), 10).dump_window_hierarchy()
self.assertNotIn("device-1", str(raised.exception))
self.assertNotIn("private", str(raised.exception))
def test_entry_attempt_is_recorded_before_unconfirmed_click_and_not_retried(self) -> None:
class TimeoutTapDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
self.calls.append(("jsonrpc", method, params, timeout))
if method == "click":
raise TimeoutError("device detail")
return super().jsonrpc_call(method, params, timeout)
adapter = UiautomatorSkuPanelAdapter(TimeoutTapDevice(), 10)
with self.assertRaises(SkuSelectionRunError):
adapter.tap_sku_entry("[688,2184][1042,2253]")
self.assertTrue(adapter.entry_was_tapped)
self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [865, 2218], 10)])
def test_entry_stability_interruptions_never_click(self) -> None:
now = [0.0]
class SequenceDevice(_RawDevice):
def __init__(self) -> None:
super().__init__(); self.frames = [
_dynamic_product_page("first"),
"",
_dynamic_product_page("second"),
]
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy" and self.frames:
self.hierarchy = self.frames.pop(0)
return super().jsonrpc_call(method, params, timeout)
device = SequenceDevice()
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10), .02, .01, lambda: now[0], lambda x: now.__setitem__(0, now[0] + x))
with self.assertRaises(SkuSelectionError): flow.open_sku_panel(_TARGET_URL, "")
self.assertEqual(_actions(device, "click"), [])
def test_screenshot_drift_and_foreground_drift_publish_nothing_and_never_back(self) -> None:
for drift in ("color", "size", "price"):
class DriftDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
value = super().jsonrpc_call(method, params, timeout)
if method == "takeScreenshot":
if drift == "price":
self.hierarchy = self.hierarchy.replace("快卖完 ¥12.88", "快卖完 ¥13.88")
else:
root = ElementTree.fromstring(self.hierarchy)
if drift == "color":
for node in root.iter("node"):
if node.get("selected") is not None and ",1000]" in node.get("bounds", ""):
node.set("selected", "false")
next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
else:
for node in root.iter("node"):
if node.get("selected") is not None and ",1730]" in node.get("bounds", ""):
node.set("selected", "false")
next(node for node in root.iter("node") if node.get("text") == "L(建议115-130)").set("selected", "true")
self.hierarchy = ElementTree.tostring(root, encoding="unicode")
return value
with self.subTest(drift=drift), TemporaryDirectory() as temporary:
target = Path(temporary) / "out"
with self.assertRaises((SkuSelectionError, SkuSelectionRunError)):
self._runner(_FakeAdb(), DriftDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertFalse(target.exists())
self.assertFalse((target / "manifest.json").exists())
self.assertEqual(list(Path(temporary).glob(".out.staging-*")), [])
device = _RawDevice(); device.select_alternates()
device.package = "other"
with self.assertRaises(SkuSelectionError): SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).exit_sku_panel_safely()
self.assertEqual(_actions(device, "pressKey"), [])
def test_unproven_reveal_publishes_nothing_and_safely_exits_once(self) -> None:
device = _RawDevice()
with TemporaryDirectory() as temporary:
target = Path(temporary) / "out"
with self.assertRaises(SkuSelectionError):
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertFalse(target.exists())
self.assertFalse((target / "manifest.json").exists())
self.assertEqual(list(Path(temporary).glob(".out.staging-*")), [])
self.assertEqual(len(_actions(device, "pressKey")), 1)
def test_option_timeout_reconciliation_controls_back_once(self) -> None:
class OptionTimeoutDevice(_RawDevice):
def __init__(self, delivered: bool) -> None:
super().__init__(); self.delivered = delivered; self.clicks = 0
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click":
self.clicks += 1
if self.clicks == 2:
if self.delivered: super().jsonrpc_call(method, params, timeout)
else: self.calls.append(("jsonrpc", method, params, timeout))
raise TimeoutError("uncertain option")
return super().jsonrpc_call(method, params, timeout)
for delivered, expected_back in ((False, 0), (True, 1)):
with self.subTest(delivered=delivered), TemporaryDirectory() as temporary:
device = OptionTimeoutDevice(delivered)
adb = _FakeAdb(); device.hierarchy = ""
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("", ''))
runner = SkuSelectionRunner(adb, lambda serial: device, .03)
with self.assertRaises(SkuSelectionRunError):
runner.run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "out")
self.assertEqual(len(_actions(device, "click")), 2)
self.assertEqual(len(_actions(device, "pressKey")), expected_back)
def test_back_timeout_is_never_retried(self) -> None:
class BackTimeoutDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "pressKey":
super().jsonrpc_call(method, params, timeout)
raise TimeoutError("back uncertain")
return super().jsonrpc_call(method, params, timeout)
device = BackTimeoutDevice(_M_FIXTURE.read_text(encoding="utf-8"))
with self.assertRaises(SkuSelectionRunError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 0.03)).exit_sku_panel_safely()
self.assertEqual(len(_actions(device, "pressKey")), 1)
def test_entry_click_timeout_reconciles_only_through_verified_flow_exit(self) -> None:
class DeliveredThenTimeout(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click" and self.hierarchy != _FIXTURE.read_text(encoding="utf-8"):
super().jsonrpc_call(method, params, timeout)
raise TimeoutError("delivery uncertain")
return super().jsonrpc_call(method, params, timeout)
device = DeliveredThenTimeout()
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError):
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
self.assertEqual(len(_actions(device, "click")), 1)
self.assertEqual(len(_actions(device, "pressKey")), 1)
class SkuSelectionCliTests(unittest.TestCase):
def test_cli_accepts_only_target_url_and_task_values(self) -> None:
script = _load_runner_script()
valid = {
"serial": "device-1",
"url": _TARGET_URL,
"color": _TASK_COLOR,
"size": _TASK_SIZE,
"output_dir": Path("evidence"),
"timeout": 10.0,
"adb": "adb",
}
script.validate_arguments(type("Arguments", (), valid)())
for field, value in (("serial", ""), ("url", "https://mobile.yangkeduo.com/goods.html?goods_id=1"), ("color", "黑色 CHA (纯棉)"), ("size", "M(建议100-115)"), ("timeout", 0), ("timeout", float("inf"))):
with self.subTest(field=field, value=value), self.assertRaises((ValueError, SkuSelectionError)):
script.validate_arguments(type("Arguments", (), valid | {field: value})())
def test_cli_main_catches_flow_error_without_traceback_or_page_body(self) -> None:
script = _load_runner_script()
secret = "SERIAL=192.168.0.173:5555 PATH=C:/private page-body"
class HostileGetterError(SkuSelectionError):
def __getattribute__(self, name: str) -> object:
if name == "_cmbuyer_failure_stage":
raise RuntimeError(secret)
return super().__getattribute__(name)
class HostileStage(str):
def __hash__(self) -> int:
raise RuntimeError(secret)
hostile_string = SkuSelectionError(secret)
setattr(hostile_string, "_cmbuyer_failure_stage", HostileStage("sku_entry"))
for failure in (SkuSelectionError(secret), HostileGetterError(secret), hostile_string):
class FlowFailingRunner:
def __init__(self, *args: object, **kwargs: object) -> None: pass
def run(self, *args: object, **kwargs: object) -> object:
raise failure
stderr = BytesIO()
# TextIOWrapper keeps the assertion independent from host console encoding.
import io
text_stderr = io.TextIOWrapper(stderr, encoding="utf-8")
with patch.object(script, "SkuSelectionRunner", FlowFailingRunner), redirect_stderr(text_stderr):
status = script.main([
"--serial", "device-1", "--url", _TARGET_URL, "--color", _TASK_COLOR,
"--size", _TASK_SIZE, "--output-dir", "evidence",
])
text_stderr.flush()
output = stderr.getvalue().decode("utf-8")
self.assertEqual(status, 1)
self.assertIn("stage=unknown", output)
self.assertNotIn("Traceback", output)
self.assertNotIn(secret, output)
self.assertNotIn("page-body", output)
def _load_runner_script() -> object:
path = Path(__file__).resolve().parents[2] / "scripts" / "run_t103_sku_selection.py"
specification = importlib.util.spec_from_file_location("run_t103_sku_selection_test", path)
if specification is None or specification.loader is None:
raise RuntimeError("无法加载 T-103 运行脚本。")
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
return module
if __name__ == "__main__":
unittest.main()