fix(client): expose safe T-103 failure stages
This commit is contained in:
@@ -15,7 +15,7 @@ from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, Subproce
|
||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
|
||||
from cmbuyer_client.pdd.sku_selection import EXPECTED_GOODS_ID, SkuSelectionError, TASK_TO_UI_SELECTION
|
||||
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, SkuSelectionRunner
|
||||
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, SkuSelectionRunner, safe_failure_stage
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
@@ -66,7 +66,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
result = runner.run(arguments.serial, arguments.url, arguments.color, arguments.size, arguments.output_dir)
|
||||
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError) as error:
|
||||
# Flow 可能来自测试替身或未来实现;CLI 不回显任何异常正文,避免泄露节点树或页面文本。
|
||||
print("规格恢复失败:已停止,未发布本地证据目录。", file=sys.stderr)
|
||||
print(
|
||||
f"规格恢复失败:stage={safe_failure_stage(error)};已停止,未发布本地证据目录。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
except OSError:
|
||||
print("规格恢复失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||
|
||||
@@ -23,7 +23,7 @@ from uiautomator2.exceptions import HTTPTimeoutError
|
||||
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
||||
from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .product_url import ProductUrl, parse_product_url
|
||||
from .product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||
from .sku_selection import (
|
||||
EXPECTED_GOODS_ID,
|
||||
EXPECTED_UNIT_PRICE,
|
||||
@@ -39,6 +39,24 @@ EXPECTED_DEVICE_MODEL = "PKG110"
|
||||
EXPECTED_ANDROID_VERSION = "16"
|
||||
EXPECTED_SCREEN_SIZE = (1080, 2376)
|
||||
|
||||
# CLI 只允许输出这些固定阶段码。阶段码描述运行器自己的控制流,不包含页面
|
||||
# 文本、节点属性、serial、路径或第三方异常;未知/伪造值统一降级为 unknown。
|
||||
_FAILURE_STAGES = frozenset(
|
||||
(
|
||||
"precheck",
|
||||
"device_inspection",
|
||||
"device_session",
|
||||
"product_open",
|
||||
"sku_entry",
|
||||
"sku_selection",
|
||||
"price_verification",
|
||||
"screenshot_capture",
|
||||
"screenshot_reverify",
|
||||
"safe_exit",
|
||||
"publish",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SkuSelectionRunError(RuntimeError):
|
||||
"""T-103 运行未完整完成;错误文本不携带设备或页面原文。"""
|
||||
@@ -60,6 +78,29 @@ class SkuSelectionDeviceAdapterError(SkuSelectionRunError):
|
||||
"""第三方设备接口失败的脱敏映射。"""
|
||||
|
||||
|
||||
def safe_failure_stage(error: BaseException) -> str:
|
||||
"""返回允许公开的固定阶段码,绝不回显异常正文。"""
|
||||
|
||||
try:
|
||||
stage = getattr(error, "_cmbuyer_failure_stage", None)
|
||||
# exact str 避免恶意 str 子类在 hash/eq 中执行任意异常;诊断路径
|
||||
# 自己也必须失败闭合,不能让异常正文越过 CLI 的统一脱敏出口。
|
||||
return stage if type(stage) is str and stage in _FAILURE_STAGES else "unknown"
|
||||
except BaseException:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _annotate_failure(error: BaseException, stage: str) -> None:
|
||||
"""只给本次异常附加白名单控制流事实;原异常文本仍不对外输出。"""
|
||||
|
||||
safe_stage = stage if stage in _FAILURE_STAGES else "unknown"
|
||||
try:
|
||||
setattr(error, "_cmbuyer_failure_stage", safe_stage)
|
||||
except BaseException:
|
||||
# 极端第三方异常不允许写属性时仍保持原失败闭合语义。
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuSelectionRunResult:
|
||||
"""已发布的截图和无页面正文 manifest 摘要。"""
|
||||
@@ -183,38 +224,52 @@ class SkuSelectionRunner:
|
||||
task_size: str,
|
||||
output_directory: Path,
|
||||
) -> SkuSelectionRunResult:
|
||||
link = parse_product_url(product_url)
|
||||
if link.goods_id != EXPECTED_GOODS_ID:
|
||||
raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
|
||||
selection = resolve_task_selection(task_color, task_size)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
|
||||
stage = "precheck"
|
||||
staging: Path | None = None
|
||||
adapter: UiautomatorSkuPanelAdapter | None = None
|
||||
flow: SkuSelectionFlow | None = None
|
||||
staging = _prepare_staging(target)
|
||||
deadline = self._monotonic_clock() + self._timeout_seconds
|
||||
try:
|
||||
link = parse_product_url(product_url)
|
||||
if link.goods_id != EXPECTED_GOODS_ID:
|
||||
raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
|
||||
selection = resolve_task_selection(task_color, task_size)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
staging = _prepare_staging(target)
|
||||
deadline = self._monotonic_clock() + self._timeout_seconds
|
||||
|
||||
stage = "device_inspection"
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
|
||||
stage = "device_session"
|
||||
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise SkuSelectionRunError("设备不是已取证的竖屏坐标空间,已停止操作。")
|
||||
pre_intent_hierarchy = adapter.dump_window_hierarchy()
|
||||
|
||||
# 固定 ACTION_VIEW、固定 PDD package 和 canonical goods_id;不接受任意 URL 或 shell。
|
||||
stage = "product_open"
|
||||
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
||||
|
||||
remaining = deadline - self._monotonic_clock()
|
||||
if remaining <= 0:
|
||||
raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。")
|
||||
|
||||
stage = "sku_entry"
|
||||
flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining)
|
||||
flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy)
|
||||
|
||||
stage = "sku_selection"
|
||||
flow.select_sku_options(selection)
|
||||
|
||||
stage = "price_verification"
|
||||
unit_price = flow.verify_target_selection_and_read_price(selection)
|
||||
if unit_price != EXPECTED_UNIT_PRICE:
|
||||
raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。")
|
||||
|
||||
stage = "screenshot_capture"
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
try:
|
||||
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
||||
@@ -226,11 +281,15 @@ class SkuSelectionRunner:
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
# 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。
|
||||
stage = "screenshot_reverify"
|
||||
final_price = flow.verify_target_selection_and_read_price(selection)
|
||||
if final_price != EXPECTED_UNIT_PRICE:
|
||||
raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。")
|
||||
# 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。
|
||||
stage = "safe_exit"
|
||||
flow.exit_sku_panel_safely()
|
||||
|
||||
stage = "publish"
|
||||
manifest_path.write_text(
|
||||
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
@@ -238,18 +297,25 @@ class SkuSelectionRunner:
|
||||
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError):
|
||||
except (DeviceConnectionError, ProductUrlError, SkuSelectionRunError, SkuSelectionError) as error:
|
||||
_clean_staging(staging)
|
||||
_annotate_failure(error, stage)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。") from error
|
||||
mapped = SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。")
|
||||
_annotate_failure(mapped, stage)
|
||||
raise mapped from error
|
||||
except OSError as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。") from error
|
||||
mapped = SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。")
|
||||
_annotate_failure(mapped, stage)
|
||||
raise mapped from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。") from error
|
||||
mapped = SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。")
|
||||
_annotate_failure(mapped, stage)
|
||||
raise mapped from error
|
||||
finally:
|
||||
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
|
||||
if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel:
|
||||
|
||||
@@ -15,7 +15,7 @@ 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, DeviceInspection
|
||||
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, resolve_task_selection
|
||||
from cmbuyer_client.pdd.sku_selection_runner import (
|
||||
@@ -23,6 +23,7 @@ from cmbuyer_client.pdd.sku_selection_runner import (
|
||||
SkuSelectionRunError,
|
||||
SkuSelectionScreenshotError,
|
||||
UiautomatorSkuPanelAdapter,
|
||||
safe_failure_stage,
|
||||
)
|
||||
|
||||
|
||||
@@ -383,6 +384,38 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
self.assertFalse((target / "hierarchy.xml").exists())
|
||||
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
||||
|
||||
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)
|
||||
|
||||
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised:
|
||||
self._runner(_FakeAdb(), NoPanelAfterEntry()).run(
|
||||
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
|
||||
)
|
||||
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry")
|
||||
|
||||
forged = SkuSelectionError("<hierarchy>private</hierarchy>")
|
||||
setattr(forged, "_cmbuyer_failure_stage", "<private-stage>")
|
||||
self.assertEqual(safe_failure_stage(forged), "unknown")
|
||||
|
||||
class HostileSetterError(DeviceConnectionError):
|
||||
def __setattr__(self, name: str, value: object) -> None:
|
||||
raise KeyboardInterrupt("SERIAL=private <hierarchy>secret</hierarchy>")
|
||||
|
||||
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_target_created_during_publish_is_preserved_without_staging_residue(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "result"
|
||||
@@ -401,8 +434,9 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
def test_bad_screenshot_or_existing_target_never_publishes_manifest(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "result"
|
||||
with self.assertRaises(SkuSelectionScreenshotError):
|
||||
with 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-*")), [])
|
||||
|
||||
@@ -418,8 +452,9 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
target.mkdir()
|
||||
sentinel = target / "keep"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
with self.assertRaises(SkuSelectionRunError):
|
||||
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")
|
||||
@@ -654,25 +689,43 @@ class SkuSelectionCliTests(unittest.TestCase):
|
||||
def test_cli_main_catches_flow_error_without_traceback_or_page_body(self) -> None:
|
||||
script = _load_runner_script()
|
||||
|
||||
class FlowFailingRunner:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None: pass
|
||||
def run(self, *args: object, **kwargs: object) -> object:
|
||||
raise SkuSelectionError("<hierarchy>page-body</hierarchy>")
|
||||
secret = "SERIAL=192.168.0.173:5555 PATH=C:/private <hierarchy>page-body</hierarchy>"
|
||||
|
||||
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.assertNotIn("Traceback", output)
|
||||
self.assertNotIn("page-body", output)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user