feat(client): capture T-104 safe exit evidence
This commit is contained in:
@@ -2,9 +2,9 @@ from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import base64
|
||||
from contextlib import redirect_stderr
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from functools import lru_cache
|
||||
from io import BytesIO
|
||||
from io import BytesIO, StringIO
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -28,9 +28,12 @@ from cmbuyer_client.pdd.sku_selection import (
|
||||
resolve_task_selection,
|
||||
)
|
||||
from cmbuyer_client.pdd.sku_selection_runner import (
|
||||
SkuExitSpikeCapturer,
|
||||
SkuExitSpikeError,
|
||||
SkuSelectionDeviceAdapterError,
|
||||
SkuSelectionRunError,
|
||||
SkuSelectionScreenshotError,
|
||||
UiautomatorSkuExitAdapter,
|
||||
UiautomatorSkuPanelAdapter,
|
||||
safe_failure_stage,
|
||||
)
|
||||
@@ -1666,6 +1669,338 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
self.assertEqual(len(_actions(device, "pressKey")), 0)
|
||||
|
||||
|
||||
class _ExitEvidenceDevice(_RawDevice):
|
||||
def __init__(self, hierarchy: str | None = None) -> None:
|
||||
super().__init__(hierarchy or _M_FIXTURE.read_text(encoding="utf-8"))
|
||||
self.activity = "com.xunmeng.pinduoduo.activity.NewPageActivity"
|
||||
|
||||
def app_current(self) -> dict[str, str]:
|
||||
self.calls.append(("app_current",))
|
||||
return {"package": self.package, "activity": self.activity}
|
||||
|
||||
|
||||
class SkuExitSpikeCapturerTests(unittest.TestCase):
|
||||
def _capturer(self, adb: _FakeAdb, device: _ExitEvidenceDevice) -> SkuExitSpikeCapturer:
|
||||
return SkuExitSpikeCapturer(adb, lambda serial: device, 0.03)
|
||||
|
||||
def test_completed_back_atomically_publishes_human_review_only_evidence(self) -> None:
|
||||
adb = _FakeAdb()
|
||||
device = _ExitEvidenceDevice()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "exit-evidence"
|
||||
result = self._capturer(adb, device).capture("192.168.0.173:5555", target)
|
||||
manifest_text = result.manifest_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(manifest_text)
|
||||
|
||||
self.assertEqual(result.output_directory, target)
|
||||
self.assertEqual(manifest["product"], {"goods_id": "937122477375"})
|
||||
self.assertEqual(manifest["channel"], "wifi")
|
||||
self.assertEqual(manifest["back_attempts"], 1)
|
||||
self.assertEqual(manifest["rpc_outcome"], "completed")
|
||||
self.assertEqual(manifest["post_exit_status"], "human_review_required")
|
||||
self.assertNotIn("safe_exit", manifest_text)
|
||||
self.assertNotIn("SKU_PANEL_GATE_1", manifest_text)
|
||||
self.assertNotIn("192.168.0.173:5555", manifest_text)
|
||||
self.assertEqual(
|
||||
[item["path"] for item in manifest["artifacts"]],
|
||||
[
|
||||
"post_exit_screenshot.png",
|
||||
"post_exit_hierarchy.xml",
|
||||
"post_exit_app.json",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
manifest["artifacts"][0]["role"],
|
||||
"post_exit_human_review_only",
|
||||
)
|
||||
for artifact in manifest["artifacts"]:
|
||||
self.assertTrue((target / artifact["path"]).is_file())
|
||||
self.assertRegex(artifact["sha256"], r"^[0-9a-f]{64}$")
|
||||
self.assertEqual(
|
||||
json.loads((target / "post_exit_app.json").read_text(encoding="utf-8")),
|
||||
{
|
||||
"activity": "com.xunmeng.pinduoduo.activity.NewPageActivity",
|
||||
"package": "com.xunmeng.pinduoduo",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(adb.calls, [("inspect", "192.168.0.173:5555")])
|
||||
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||
self.assertEqual(len(_actions(device, "takeScreenshot")), 1)
|
||||
self.assertEqual(_actions(device, "click"), [])
|
||||
self.assertEqual(_actions(device, "swipe"), [])
|
||||
|
||||
def test_precondition_mismatch_or_drift_never_sends_back_or_leaves_artifacts(self) -> None:
|
||||
class WrongScreen(_ExitEvidenceDevice):
|
||||
def window_size(self) -> tuple[int, int]:
|
||||
self.calls.append(("window_size",))
|
||||
return 1080, 1920
|
||||
|
||||
class HierarchyDrift(_ExitEvidenceDevice):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.reads = 0
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "dumpWindowHierarchy":
|
||||
self.reads += 1
|
||||
if self.reads == 2:
|
||||
self.hierarchy = _S_FIXTURE.read_text(encoding="utf-8")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
cases: list[tuple[str, _ExitEvidenceDevice]] = []
|
||||
wrong_version = _ExitEvidenceDevice(); wrong_version.version = "8.18.0"
|
||||
wrong_foreground = _ExitEvidenceDevice(); wrong_foreground.package = "other.package"
|
||||
wrong_price = _ExitEvidenceDevice(); wrong_price.hierarchy = wrong_price.hierarchy.replace("快卖完 ¥12.88", "快卖完 ¥13.88")
|
||||
cases.extend(
|
||||
(
|
||||
("version", wrong_version),
|
||||
("foreground", wrong_foreground),
|
||||
("screen", WrongScreen()),
|
||||
("profile", _ExitEvidenceDevice(_S_FIXTURE.read_text(encoding="utf-8"))),
|
||||
("price", wrong_price),
|
||||
("drift", HierarchyDrift()),
|
||||
)
|
||||
)
|
||||
|
||||
for name, device in cases:
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "exit-evidence"
|
||||
with self.assertRaises((SkuSelectionError, SkuSelectionRunError)):
|
||||
self._capturer(_FakeAdb(), device).capture("device-1", target)
|
||||
self.assertEqual(_actions(device, "pressKey"), [])
|
||||
self.assertEqual(_actions(device, "takeScreenshot"), [])
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".exit-evidence.staging-*")), [])
|
||||
|
||||
def test_ambiguous_back_delivered_or_undelivered_publishes_once_for_human_review(self) -> None:
|
||||
class AmbiguousBack(_ExitEvidenceDevice):
|
||||
def __init__(self, delivered: bool) -> None:
|
||||
super().__init__()
|
||||
self.delivered = delivered
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "pressKey":
|
||||
self.calls.append(("jsonrpc", method, params, timeout))
|
||||
if self.delivered:
|
||||
self.hierarchy = "<hierarchy rotation=\"0\" />"
|
||||
raise TimeoutError("private RPC detail")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
for delivered in (True, False):
|
||||
with self.subTest(delivered=delivered), TemporaryDirectory() as temporary:
|
||||
device = AmbiguousBack(delivered)
|
||||
target = Path(temporary) / "exit-evidence"
|
||||
result = self._capturer(_FakeAdb(), device).capture("device-1", target)
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(manifest["back_attempts"], 1)
|
||||
self.assertEqual(manifest["rpc_outcome"], "ambiguous_reconciled")
|
||||
self.assertEqual(manifest["post_exit_status"], "human_review_required")
|
||||
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||
self.assertEqual(len(_actions(device, "takeScreenshot")), 1)
|
||||
|
||||
def test_non_pdd_post_app_is_rejected_before_screenshot_for_completed_and_ambiguous_back(self) -> None:
|
||||
class PostAppDrift(_ExitEvidenceDevice):
|
||||
def __init__(self, ambiguous: bool) -> None:
|
||||
super().__init__()
|
||||
self.ambiguous = ambiguous
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "pressKey":
|
||||
self.calls.append(("jsonrpc", method, params, timeout))
|
||||
self.package = "external.app"
|
||||
if self.ambiguous:
|
||||
raise TimeoutError("private RPC detail")
|
||||
return ""
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
for ambiguous in (False, True):
|
||||
with self.subTest(ambiguous=ambiguous), TemporaryDirectory() as temporary:
|
||||
device = PostAppDrift(ambiguous)
|
||||
target = Path(temporary) / "exit-evidence"
|
||||
with self.assertRaises(SkuExitSpikeError):
|
||||
self._capturer(_FakeAdb(), device).capture("device-1", target)
|
||||
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||
self.assertEqual(_actions(device, "takeScreenshot"), [])
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".exit-evidence.staging-*")), [])
|
||||
|
||||
class PostVersionDrift(_ExitEvidenceDevice):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.version_reads = 0
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.version_reads += 1
|
||||
if self.version_reads == 3:
|
||||
return {"versionName": "8.18.0"}
|
||||
return super().app_info(package_name)
|
||||
|
||||
class MissingPostActivity(_ExitEvidenceDevice):
|
||||
def app_current(self) -> dict[str, str]:
|
||||
value = super().app_current()
|
||||
if _actions(self, "pressKey"):
|
||||
value.pop("activity")
|
||||
return value
|
||||
|
||||
for name, device in (("version", PostVersionDrift()), ("app_summary", MissingPostActivity())):
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "exit-evidence"
|
||||
with self.assertRaises(SkuSelectionRunError):
|
||||
self._capturer(_FakeAdb(), device).capture("device-1", target)
|
||||
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||
self.assertEqual(_actions(device, "takeScreenshot"), [])
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".exit-evidence.staging-*")), [])
|
||||
|
||||
def test_post_capture_app_drift_or_read_failures_clean_every_artifact_without_retry(self) -> None:
|
||||
class AppDrift(_ExitEvidenceDevice):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.post_reads = 0
|
||||
|
||||
def app_current(self) -> dict[str, str]:
|
||||
value = super().app_current()
|
||||
if _actions(self, "pressKey"):
|
||||
self.post_reads += 1
|
||||
if self.post_reads == 2:
|
||||
value["activity"] = "com.xunmeng.pinduoduo.activity.OtherActivity"
|
||||
return value
|
||||
|
||||
class BadXml(_ExitEvidenceDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "dumpWindowHierarchy" and _actions(self, "pressKey"):
|
||||
self.calls.append(("jsonrpc", method, params, timeout))
|
||||
return "not-xml"
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
class ScreenshotTimeout(_ExitEvidenceDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "takeScreenshot":
|
||||
self.calls.append(("jsonrpc", method, params, timeout))
|
||||
raise TimeoutError("private RPC detail")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
bad_png = _ExitEvidenceDevice(); bad_png.screenshot = "not-image"
|
||||
for name, device in (
|
||||
("app_drift", AppDrift()),
|
||||
("bad_xml", BadXml()),
|
||||
("bad_png", bad_png),
|
||||
("screenshot_timeout", ScreenshotTimeout()),
|
||||
):
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "exit-evidence"
|
||||
with self.assertRaises(SkuSelectionRunError):
|
||||
self._capturer(_FakeAdb(), device).capture("device-1", target)
|
||||
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".exit-evidence.staging-*")), [])
|
||||
|
||||
def test_existing_target_publish_race_preseal_failure_and_repeat_are_fail_closed(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "existing"
|
||||
target.mkdir()
|
||||
sentinel = target / "keep"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
adb = _FakeAdb(); device = _ExitEvidenceDevice()
|
||||
with self.assertRaises(SkuSelectionRunError):
|
||||
self._capturer(adb, device).capture("device-1", target)
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(device.calls, [])
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||
|
||||
race_target = Path(temporary) / "race"
|
||||
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)
|
||||
|
||||
race_device = _ExitEvidenceDevice()
|
||||
with (
|
||||
patch.object(runner_module.os, "rename", side_effect=create_target_then_rename),
|
||||
self.assertRaises(SkuSelectionRunError),
|
||||
):
|
||||
self._capturer(_FakeAdb(), race_device).capture("device-1", race_target)
|
||||
self.assertEqual((race_target / "sentinel").read_text(encoding="utf-8"), "keep")
|
||||
self.assertEqual(list(Path(temporary).glob(".race.staging-*")), [])
|
||||
self.assertEqual(len(_actions(race_device, "pressKey")), 1)
|
||||
|
||||
preseal_target = Path(temporary) / "preseal"
|
||||
preseal_device = _ExitEvidenceDevice()
|
||||
with (
|
||||
patch.object(UiautomatorSkuExitAdapter, "leave_sku_panel", side_effect=SkuSelectionRunError("private")),
|
||||
self.assertRaises(SkuSelectionRunError),
|
||||
):
|
||||
self._capturer(_FakeAdb(), preseal_device).capture("device-1", preseal_target)
|
||||
self.assertEqual(_actions(preseal_device, "pressKey"), [])
|
||||
self.assertFalse(preseal_target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".preseal.staging-*")), [])
|
||||
|
||||
repeated_device = _ExitEvidenceDevice()
|
||||
capturer = self._capturer(_FakeAdb(), repeated_device)
|
||||
capturer.capture("device-1", Path(temporary) / "once")
|
||||
with self.assertRaises(SkuExitSpikeError):
|
||||
capturer.capture("device-1", Path(temporary) / "twice")
|
||||
self.assertEqual(len(_actions(repeated_device, "pressKey")), 1)
|
||||
|
||||
def test_static_t104_boundary_has_only_named_back_and_read_calls(self) -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
runner_path = root / "src" / "cmbuyer_client" / "pdd" / "sku_selection_runner.py"
|
||||
script_path = root / "scripts" / "capture_sku_exit_spike.py"
|
||||
runner_tree = ast.parse(runner_path.read_text(encoding="utf-8"))
|
||||
script_source = script_path.read_text(encoding="utf-8")
|
||||
script_tree = ast.parse(script_source)
|
||||
classes = {
|
||||
node.name: node
|
||||
for node in runner_tree.body
|
||||
if isinstance(node, ast.ClassDef)
|
||||
}
|
||||
selected = (classes["UiautomatorSkuExitAdapter"], classes["SkuExitSpikeCapturer"])
|
||||
called_attributes = {
|
||||
node.func.attr
|
||||
for selected_class in selected
|
||||
for node in ast.walk(selected_class)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||
}
|
||||
forbidden_calls = {
|
||||
"start_pdd_view_intent",
|
||||
"tap_sku_entry",
|
||||
"tap_sku_option",
|
||||
"reveal_size_options_once",
|
||||
"set_quantity_and_readback",
|
||||
"go_to_order_confirm",
|
||||
"create_submission_fence",
|
||||
"submit_order_once",
|
||||
}
|
||||
self.assertTrue(forbidden_calls.isdisjoint(called_attributes))
|
||||
self.assertTrue(forbidden_calls.isdisjoint(
|
||||
node.attr for node in ast.walk(script_tree) if isinstance(node, ast.Attribute)
|
||||
))
|
||||
self.assertTrue(
|
||||
{"click", "swipe", "scroll", "intent", "quantity", "confirm", "fence", "submit", "payment"}.isdisjoint(
|
||||
script_source.lower().split()
|
||||
)
|
||||
)
|
||||
public_adapter_api = {
|
||||
name for name in UiautomatorSkuExitAdapter.__dict__ if not name.startswith("_")
|
||||
}
|
||||
self.assertEqual(
|
||||
public_adapter_api,
|
||||
{
|
||||
"app_info",
|
||||
"app_current",
|
||||
"dump_window_hierarchy",
|
||||
"capture_screenshot",
|
||||
"display_size",
|
||||
"leave_sku_panel",
|
||||
"back_attempts",
|
||||
"back_rpc_outcome",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class SkuSelectionCliTests(unittest.TestCase):
|
||||
def test_cli_accepts_only_target_url_and_task_values(self) -> None:
|
||||
script = _load_runner_script()
|
||||
@@ -1725,6 +2060,98 @@ class SkuSelectionCliTests(unittest.TestCase):
|
||||
self.assertNotIn("page-body", output)
|
||||
|
||||
|
||||
class SkuExitSpikeCliTests(unittest.TestCase):
|
||||
def test_cli_accepts_only_explicit_device_output_timeout_and_adb(self) -> None:
|
||||
script = _load_exit_script()
|
||||
arguments = script.parse_arguments(
|
||||
[
|
||||
"--serial", "device-1",
|
||||
"--output-dir", "fresh-evidence",
|
||||
"--timeout", "10",
|
||||
"--adb", "adb.exe",
|
||||
]
|
||||
)
|
||||
script.validate_arguments(arguments)
|
||||
self.assertEqual(
|
||||
set(vars(arguments)),
|
||||
{"serial", "output_dir", "timeout", "adb"},
|
||||
)
|
||||
for field, value in (("serial", ""), ("timeout", 0), ("timeout", float("inf"))):
|
||||
with self.subTest(field=field), self.assertRaises(ValueError):
|
||||
script.validate_arguments(
|
||||
type(
|
||||
"Arguments",
|
||||
(),
|
||||
{
|
||||
"serial": "device-1",
|
||||
"output_dir": Path("fresh-evidence"),
|
||||
"timeout": 10.0,
|
||||
"adb": "adb",
|
||||
field: value,
|
||||
},
|
||||
)()
|
||||
)
|
||||
with redirect_stderr(StringIO()), self.assertRaises(SystemExit):
|
||||
script.parse_arguments(
|
||||
[
|
||||
"--serial", "device-1",
|
||||
"--output-dir", "fresh-evidence",
|
||||
"--goods-id", "937122477375",
|
||||
]
|
||||
)
|
||||
|
||||
def test_cli_never_echoes_serial_page_text_or_sensitive_path(self) -> None:
|
||||
script = _load_exit_script()
|
||||
secret = "SERIAL=192.168.0.173:5555 PATH=C:/Users/private <hierarchy>private</hierarchy>"
|
||||
|
||||
class FailingCapturer:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def capture(self, *args: object, **kwargs: object) -> object:
|
||||
raise SkuExitSpikeError(secret)
|
||||
|
||||
stderr = BytesIO()
|
||||
import io
|
||||
text_stderr = io.TextIOWrapper(stderr, encoding="utf-8")
|
||||
with patch.object(script, "SkuExitSpikeCapturer", FailingCapturer), redirect_stderr(text_stderr):
|
||||
status = script.main(
|
||||
[
|
||||
"--serial", "192.168.0.173:5555",
|
||||
"--output-dir", "C:/Users/private/evidence",
|
||||
]
|
||||
)
|
||||
text_stderr.flush()
|
||||
output = stderr.getvalue().decode("utf-8")
|
||||
self.assertEqual(status, 1)
|
||||
self.assertNotIn(secret, output)
|
||||
self.assertNotIn("192.168.0.173:5555", output)
|
||||
self.assertNotIn("C:/Users/private", output)
|
||||
self.assertNotIn("Traceback", output)
|
||||
|
||||
class SuccessfulCapturer:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def capture(self, *args: object, **kwargs: object) -> object:
|
||||
return object()
|
||||
|
||||
stdout = BytesIO()
|
||||
text_stdout = io.TextIOWrapper(stdout, encoding="utf-8")
|
||||
with patch.object(script, "SkuExitSpikeCapturer", SuccessfulCapturer), redirect_stdout(text_stdout):
|
||||
status = script.main(
|
||||
[
|
||||
"--serial", "192.168.0.173:5555",
|
||||
"--output-dir", "C:/Users/private/evidence",
|
||||
]
|
||||
)
|
||||
text_stdout.flush()
|
||||
output = stdout.getvalue().decode("utf-8")
|
||||
self.assertEqual(status, 0)
|
||||
self.assertNotIn("192.168.0.173:5555", output)
|
||||
self.assertNotIn("C:/Users/private", 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)
|
||||
@@ -1735,5 +2162,15 @@ def _load_runner_script() -> object:
|
||||
return module
|
||||
|
||||
|
||||
def _load_exit_script() -> object:
|
||||
path = Path(__file__).resolve().parents[2] / "scripts" / "capture_sku_exit_spike.py"
|
||||
specification = importlib.util.spec_from_file_location("capture_sku_exit_spike_test", path)
|
||||
if specification is None or specification.loader is None:
|
||||
raise RuntimeError("无法加载 T-104 阶段 A 脚本。")
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user