fix(client): identify sku entry failure substage
This commit is contained in:
@@ -44,6 +44,43 @@ class SkuSelectionError(RuntimeError):
|
|||||||
"""已取证判据不成立时的脱敏停止。"""
|
"""已取证判据不成立时的脱敏停止。"""
|
||||||
|
|
||||||
|
|
||||||
|
_SKU_ENTRY_FAILURE_STAGES = frozenset(
|
||||||
|
(
|
||||||
|
"sku_entry_pre_intent",
|
||||||
|
"sku_entry_discovery",
|
||||||
|
"sku_entry_click",
|
||||||
|
"sku_entry_panel_verify",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_SKU_ENTRY_FAILURE_MARKER = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _annotate_sku_entry_failure(error: BaseException, stage: str) -> None:
|
||||||
|
"""把 Flow 实际经过的固定入口子阶段附到原异常,不改变异常类型。"""
|
||||||
|
|
||||||
|
if type(stage) is not str or stage not in _SKU_ENTRY_FAILURE_STAGES:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# marker 最后写入:若第三方异常拒绝任一属性写入,就不能形成可信诊断。
|
||||||
|
setattr(error, "_cmbuyer_failure_stage", stage)
|
||||||
|
setattr(error, "_cmbuyer_sku_entry_failure_marker", _SKU_ENTRY_FAILURE_MARKER)
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_sku_entry_failure_stage(error: BaseException) -> str | None:
|
||||||
|
"""只读取由本模块写入的入口子阶段;任意异常自报的值不可信。"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
marker = getattr(error, "_cmbuyer_sku_entry_failure_marker", None)
|
||||||
|
stage = getattr(error, "_cmbuyer_failure_stage", None)
|
||||||
|
if marker is not _SKU_ENTRY_FAILURE_MARKER or type(stage) is not str:
|
||||||
|
return None
|
||||||
|
return stage if stage in _SKU_ENTRY_FAILURE_STAGES else None
|
||||||
|
except BaseException:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class SkuPanelDevice(Protocol):
|
class SkuPanelDevice(Protocol):
|
||||||
def app_info(self, package_name: str) -> dict[str, Any]: ...
|
def app_info(self, package_name: str) -> dict[str, Any]: ...
|
||||||
def app_current(self) -> dict[str, Any]: ...
|
def app_current(self) -> dict[str, Any]: ...
|
||||||
@@ -89,6 +126,7 @@ class SkuSelectionFlow:
|
|||||||
self._pending: tuple[str, Callable[[list[_Node]], Any]] | None = None
|
self._pending: tuple[str, Callable[[list[_Node]], Any]] | None = None
|
||||||
|
|
||||||
def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
|
def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
|
||||||
|
try:
|
||||||
if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID:
|
if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID:
|
||||||
raise SkuSelectionError("商品不是已取证目标,已停止操作。")
|
raise SkuSelectionError("商品不是已取证目标,已停止操作。")
|
||||||
if pre_intent_hierarchy is not None:
|
if pre_intent_hierarchy is not None:
|
||||||
@@ -97,11 +135,29 @@ class SkuSelectionFlow:
|
|||||||
# 伪装成“不安全所以不存在”,否则 intent 后可能误把旧页当成新目标页。
|
# 伪装成“不安全所以不存在”,否则 intent 后可能误把旧页当成新目标页。
|
||||||
if _physical_entries(previous_nodes):
|
if _physical_entries(previous_nodes):
|
||||||
raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。")
|
raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。")
|
||||||
|
except BaseException as error:
|
||||||
|
_annotate_sku_entry_failure(error, "sku_entry_pre_intent")
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
entry, before = self._wait_for_entry(pre_intent_hierarchy)
|
entry, before = self._wait_for_entry(pre_intent_hierarchy)
|
||||||
_action_bounds(entry.bounds)
|
_action_bounds(entry.bounds)
|
||||||
|
except BaseException as error:
|
||||||
|
_annotate_sku_entry_failure(error, "sku_entry_discovery")
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
self._pending = (before, _panel)
|
self._pending = (before, _panel)
|
||||||
self._device.tap_sku_entry(entry.bounds)
|
self._device.tap_sku_entry(entry.bounds)
|
||||||
|
except BaseException as error:
|
||||||
|
_annotate_sku_entry_failure(error, "sku_entry_click")
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
self._wait_after_action(before, _panel)
|
self._wait_after_action(before, _panel)
|
||||||
|
except BaseException as error:
|
||||||
|
_annotate_sku_entry_failure(error, "sku_entry_panel_verify")
|
||||||
|
raise
|
||||||
|
|
||||||
def select_sku_options(self, selection: SkuSelection) -> None:
|
def select_sku_options(self, selection: SkuSelection) -> None:
|
||||||
if selection not in {SkuSelection(*item) for item in TASK_TO_UI_SELECTION.values()}:
|
if selection not in {SkuSelection(*item) for item in TASK_TO_UI_SELECTION.values()}:
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ from .sku_selection import (
|
|||||||
SkuPanelDevice,
|
SkuPanelDevice,
|
||||||
SkuSelectionError,
|
SkuSelectionError,
|
||||||
SkuSelectionFlow,
|
SkuSelectionFlow,
|
||||||
|
_SKU_ENTRY_FAILURE_STAGES,
|
||||||
_action_bounds,
|
_action_bounds,
|
||||||
|
_annotate_sku_entry_failure,
|
||||||
|
_safe_sku_entry_failure_stage,
|
||||||
resolve_task_selection,
|
resolve_task_selection,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,6 +51,7 @@ _FAILURE_STAGES = frozenset(
|
|||||||
"device_session",
|
"device_session",
|
||||||
"product_open",
|
"product_open",
|
||||||
"sku_entry",
|
"sku_entry",
|
||||||
|
*_SKU_ENTRY_FAILURE_STAGES,
|
||||||
"sku_selection",
|
"sku_selection",
|
||||||
"price_verification",
|
"price_verification",
|
||||||
"screenshot_capture",
|
"screenshot_capture",
|
||||||
@@ -85,7 +89,11 @@ def safe_failure_stage(error: BaseException) -> str:
|
|||||||
stage = getattr(error, "_cmbuyer_failure_stage", None)
|
stage = getattr(error, "_cmbuyer_failure_stage", None)
|
||||||
# exact str 避免恶意 str 子类在 hash/eq 中执行任意异常;诊断路径
|
# exact str 避免恶意 str 子类在 hash/eq 中执行任意异常;诊断路径
|
||||||
# 自己也必须失败闭合,不能让异常正文越过 CLI 的统一脱敏出口。
|
# 自己也必须失败闭合,不能让异常正文越过 CLI 的统一脱敏出口。
|
||||||
return stage if type(stage) is str and stage in _FAILURE_STAGES else "unknown"
|
if type(stage) is not str or stage not in _FAILURE_STAGES:
|
||||||
|
return "unknown"
|
||||||
|
if stage in _SKU_ENTRY_FAILURE_STAGES:
|
||||||
|
return stage if _safe_sku_entry_failure_stage(error) == stage else "unknown"
|
||||||
|
return stage
|
||||||
except BaseException:
|
except BaseException:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
@@ -101,6 +109,21 @@ def _annotate_failure(error: BaseException, stage: str) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _failure_stage_for(error: BaseException, runner_stage: str) -> str:
|
||||||
|
if runner_stage == "sku_entry":
|
||||||
|
flow_stage = _safe_sku_entry_failure_stage(error)
|
||||||
|
if flow_stage is not None:
|
||||||
|
return flow_stage
|
||||||
|
return runner_stage
|
||||||
|
|
||||||
|
|
||||||
|
def _annotate_mapped_failure(mapped: BaseException, source: BaseException, runner_stage: str) -> None:
|
||||||
|
stage = _failure_stage_for(source, runner_stage)
|
||||||
|
if stage in _SKU_ENTRY_FAILURE_STAGES:
|
||||||
|
_annotate_sku_entry_failure(mapped, stage)
|
||||||
|
_annotate_failure(mapped, stage)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SkuSelectionRunResult:
|
class SkuSelectionRunResult:
|
||||||
"""已发布的截图和无页面正文 manifest 摘要。"""
|
"""已发布的截图和无页面正文 manifest 摘要。"""
|
||||||
@@ -299,22 +322,22 @@ class SkuSelectionRunner:
|
|||||||
staging = None
|
staging = None
|
||||||
except (DeviceConnectionError, ProductUrlError, SkuSelectionRunError, SkuSelectionError) as error:
|
except (DeviceConnectionError, ProductUrlError, SkuSelectionRunError, SkuSelectionError) as error:
|
||||||
_clean_staging(staging)
|
_clean_staging(staging)
|
||||||
_annotate_failure(error, stage)
|
_annotate_failure(error, _failure_stage_for(error, stage))
|
||||||
raise
|
raise
|
||||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||||
_clean_staging(staging)
|
_clean_staging(staging)
|
||||||
mapped = SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。")
|
mapped = SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。")
|
||||||
_annotate_failure(mapped, stage)
|
_annotate_mapped_failure(mapped, error, stage)
|
||||||
raise mapped from error
|
raise mapped from error
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
_clean_staging(staging)
|
_clean_staging(staging)
|
||||||
mapped = SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。")
|
mapped = SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。")
|
||||||
_annotate_failure(mapped, stage)
|
_annotate_mapped_failure(mapped, error, stage)
|
||||||
raise mapped from error
|
raise mapped from error
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
_clean_staging(staging)
|
_clean_staging(staging)
|
||||||
mapped = SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。")
|
mapped = SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。")
|
||||||
_annotate_failure(mapped, stage)
|
_annotate_mapped_failure(mapped, error, stage)
|
||||||
raise mapped from error
|
raise mapped from error
|
||||||
finally:
|
finally:
|
||||||
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
|
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
|
||||||
|
|||||||
@@ -605,14 +605,17 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
|||||||
return ""
|
return ""
|
||||||
return super().jsonrpc_call(method, params, timeout)
|
return super().jsonrpc_call(method, params, timeout)
|
||||||
|
|
||||||
|
adb = _FakeAdb()
|
||||||
|
device = NoPanelAfterEntry("<hierarchy />")
|
||||||
|
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE)
|
||||||
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised:
|
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised:
|
||||||
self._runner(_FakeAdb(), NoPanelAfterEntry()).run(
|
SkuSelectionRunner(adb, lambda serial: device, 0.03).run(
|
||||||
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
|
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
|
||||||
)
|
)
|
||||||
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry")
|
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_panel_verify")
|
||||||
|
|
||||||
forged = SkuSelectionError("<hierarchy>private</hierarchy>")
|
forged = SkuSelectionError("<hierarchy>private</hierarchy>")
|
||||||
setattr(forged, "_cmbuyer_failure_stage", "<private-stage>")
|
setattr(forged, "_cmbuyer_failure_stage", "sku_entry_click")
|
||||||
self.assertEqual(safe_failure_stage(forged), "unknown")
|
self.assertEqual(safe_failure_stage(forged), "unknown")
|
||||||
|
|
||||||
class HostileSetterError(DeviceConnectionError):
|
class HostileSetterError(DeviceConnectionError):
|
||||||
@@ -629,6 +632,45 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(safe_failure_stage(hostile.exception), "unknown")
|
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("<hierarchy />")
|
||||||
|
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("<hierarchy />")
|
||||||
|
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:
|
def test_target_created_during_publish_is_preserved_without_staging_residue(self) -> None:
|
||||||
with TemporaryDirectory() as temporary:
|
with TemporaryDirectory() as temporary:
|
||||||
target = Path(temporary) / "result"
|
target = Path(temporary) / "result"
|
||||||
|
|||||||
Reference in New Issue
Block a user