Compare commits
3
Commits
025edaf273
...
42eac57824
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42eac57824 | ||
|
|
c9b39a4e05 | ||
|
|
870e5f861d |
@@ -15,7 +15,7 @@ from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, Subproce
|
|||||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||||
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
|
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 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:
|
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)
|
result = runner.run(arguments.serial, arguments.url, arguments.color, arguments.size, arguments.output_dir)
|
||||||
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError) as error:
|
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError) as error:
|
||||||
# Flow 可能来自测试替身或未来实现;CLI 不回显任何异常正文,避免泄露节点树或页面文本。
|
# Flow 可能来自测试替身或未来实现;CLI 不回显任何异常正文,避免泄露节点树或页面文本。
|
||||||
print("规格恢复失败:已停止,未发布本地证据目录。", file=sys.stderr)
|
print(
|
||||||
|
f"规格恢复失败:stage={safe_failure_stage(error)};已停止,未发布本地证据目录。",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return 1
|
return 1
|
||||||
except OSError:
|
except OSError:
|
||||||
print("规格恢复失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
print("规格恢复失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from uiautomator2.exceptions import HTTPTimeoutError
|
|||||||
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
||||||
from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file
|
from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file
|
||||||
from .product_open import EXPECTED_PDD_VERSION
|
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 (
|
from .sku_selection import (
|
||||||
EXPECTED_GOODS_ID,
|
EXPECTED_GOODS_ID,
|
||||||
EXPECTED_UNIT_PRICE,
|
EXPECTED_UNIT_PRICE,
|
||||||
@@ -39,6 +39,24 @@ EXPECTED_DEVICE_MODEL = "PKG110"
|
|||||||
EXPECTED_ANDROID_VERSION = "16"
|
EXPECTED_ANDROID_VERSION = "16"
|
||||||
EXPECTED_SCREEN_SIZE = (1080, 2376)
|
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):
|
class SkuSelectionRunError(RuntimeError):
|
||||||
"""T-103 运行未完整完成;错误文本不携带设备或页面原文。"""
|
"""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)
|
@dataclass(frozen=True)
|
||||||
class SkuSelectionRunResult:
|
class SkuSelectionRunResult:
|
||||||
"""已发布的截图和无页面正文 manifest 摘要。"""
|
"""已发布的截图和无页面正文 manifest 摘要。"""
|
||||||
@@ -183,38 +224,52 @@ class SkuSelectionRunner:
|
|||||||
task_size: str,
|
task_size: str,
|
||||||
output_directory: Path,
|
output_directory: Path,
|
||||||
) -> SkuSelectionRunResult:
|
) -> SkuSelectionRunResult:
|
||||||
link = parse_product_url(product_url)
|
stage = "precheck"
|
||||||
if link.goods_id != EXPECTED_GOODS_ID:
|
staging: Path | None = None
|
||||||
raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
|
|
||||||
selection = resolve_task_selection(task_color, task_size)
|
|
||||||
target = Path(output_directory)
|
|
||||||
_validate_new_target(target)
|
|
||||||
|
|
||||||
adapter: UiautomatorSkuPanelAdapter | None = None
|
adapter: UiautomatorSkuPanelAdapter | None = None
|
||||||
flow: SkuSelectionFlow | None = None
|
flow: SkuSelectionFlow | None = None
|
||||||
staging = _prepare_staging(target)
|
|
||||||
deadline = self._monotonic_clock() + self._timeout_seconds
|
|
||||||
try:
|
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)
|
inspection = self._adb_client.inspect(serial)
|
||||||
_require_expected_device(inspection)
|
_require_expected_device(inspection)
|
||||||
|
|
||||||
|
stage = "device_session"
|
||||||
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
|
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
|
||||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||||
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
||||||
raise SkuSelectionRunError("设备不是已取证的竖屏坐标空间,已停止操作。")
|
raise SkuSelectionRunError("设备不是已取证的竖屏坐标空间,已停止操作。")
|
||||||
pre_intent_hierarchy = adapter.dump_window_hierarchy()
|
pre_intent_hierarchy = adapter.dump_window_hierarchy()
|
||||||
|
|
||||||
# 固定 ACTION_VIEW、固定 PDD package 和 canonical goods_id;不接受任意 URL 或 shell。
|
# 固定 ACTION_VIEW、固定 PDD package 和 canonical goods_id;不接受任意 URL 或 shell。
|
||||||
|
stage = "product_open"
|
||||||
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
||||||
|
|
||||||
remaining = deadline - self._monotonic_clock()
|
remaining = deadline - self._monotonic_clock()
|
||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。")
|
raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。")
|
||||||
|
|
||||||
|
stage = "sku_entry"
|
||||||
flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining)
|
flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining)
|
||||||
flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy)
|
flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy)
|
||||||
|
|
||||||
|
stage = "sku_selection"
|
||||||
flow.select_sku_options(selection)
|
flow.select_sku_options(selection)
|
||||||
|
|
||||||
|
stage = "price_verification"
|
||||||
unit_price = flow.verify_target_selection_and_read_price(selection)
|
unit_price = flow.verify_target_selection_and_read_price(selection)
|
||||||
if unit_price != EXPECTED_UNIT_PRICE:
|
if unit_price != EXPECTED_UNIT_PRICE:
|
||||||
raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。")
|
raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。")
|
||||||
|
|
||||||
|
stage = "screenshot_capture"
|
||||||
screenshot_path = staging / "screenshot.png"
|
screenshot_path = staging / "screenshot.png"
|
||||||
try:
|
try:
|
||||||
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
||||||
@@ -226,11 +281,15 @@ class SkuSelectionRunner:
|
|||||||
|
|
||||||
manifest_path = staging / "manifest.json"
|
manifest_path = staging / "manifest.json"
|
||||||
# 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。
|
# 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。
|
||||||
|
stage = "screenshot_reverify"
|
||||||
final_price = flow.verify_target_selection_and_read_price(selection)
|
final_price = flow.verify_target_selection_and_read_price(selection)
|
||||||
if final_price != EXPECTED_UNIT_PRICE:
|
if final_price != EXPECTED_UNIT_PRICE:
|
||||||
raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。")
|
raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。")
|
||||||
# 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。
|
# 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。
|
||||||
|
stage = "safe_exit"
|
||||||
flow.exit_sku_panel_safely()
|
flow.exit_sku_panel_safely()
|
||||||
|
|
||||||
|
stage = "publish"
|
||||||
manifest_path.write_text(
|
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",
|
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
@@ -238,18 +297,25 @@ class SkuSelectionRunner:
|
|||||||
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
|
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
|
||||||
os.rename(staging, target)
|
os.rename(staging, target)
|
||||||
staging = None
|
staging = None
|
||||||
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError):
|
except (DeviceConnectionError, ProductUrlError, SkuSelectionRunError, SkuSelectionError) as error:
|
||||||
_clean_staging(staging)
|
_clean_staging(staging)
|
||||||
|
_annotate_failure(error, stage)
|
||||||
raise
|
raise
|
||||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||||
_clean_staging(staging)
|
_clean_staging(staging)
|
||||||
raise SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。") from error
|
mapped = SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。")
|
||||||
|
_annotate_failure(mapped, stage)
|
||||||
|
raise mapped from error
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
_clean_staging(staging)
|
_clean_staging(staging)
|
||||||
raise SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。") from error
|
mapped = SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。")
|
||||||
|
_annotate_failure(mapped, stage)
|
||||||
|
raise mapped from error
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
_clean_staging(staging)
|
_clean_staging(staging)
|
||||||
raise SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。") from error
|
mapped = SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。")
|
||||||
|
_annotate_failure(mapped, stage)
|
||||||
|
raise mapped from error
|
||||||
finally:
|
finally:
|
||||||
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
|
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
|
||||||
if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel:
|
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 as pdd
|
||||||
import cmbuyer_client.pdd.sku_selection_runner as runner_module
|
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 import SkuSelectionError, SkuSelectionFlow, SkuSelectionRunner
|
||||||
from cmbuyer_client.pdd.sku_selection import SkuPanelDevice, _action_bounds, resolve_task_selection
|
from cmbuyer_client.pdd.sku_selection import SkuPanelDevice, _action_bounds, resolve_task_selection
|
||||||
from cmbuyer_client.pdd.sku_selection_runner import (
|
from cmbuyer_client.pdd.sku_selection_runner import (
|
||||||
@@ -23,6 +23,7 @@ from cmbuyer_client.pdd.sku_selection_runner import (
|
|||||||
SkuSelectionRunError,
|
SkuSelectionRunError,
|
||||||
SkuSelectionScreenshotError,
|
SkuSelectionScreenshotError,
|
||||||
UiautomatorSkuPanelAdapter,
|
UiautomatorSkuPanelAdapter,
|
||||||
|
safe_failure_stage,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -383,6 +384,38 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
|||||||
self.assertFalse((target / "hierarchy.xml").exists())
|
self.assertFalse((target / "hierarchy.xml").exists())
|
||||||
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
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:
|
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"
|
||||||
@@ -401,8 +434,9 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
|||||||
def test_bad_screenshot_or_existing_target_never_publishes_manifest(self) -> None:
|
def test_bad_screenshot_or_existing_target_never_publishes_manifest(self) -> None:
|
||||||
with TemporaryDirectory() as temporary:
|
with TemporaryDirectory() as temporary:
|
||||||
target = Path(temporary) / "result"
|
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._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.assertFalse(target.exists())
|
||||||
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
|
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
|
||||||
|
|
||||||
@@ -418,8 +452,9 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
|||||||
target.mkdir()
|
target.mkdir()
|
||||||
sentinel = target / "keep"
|
sentinel = target / "keep"
|
||||||
sentinel.write_text("keep", encoding="utf-8")
|
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._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(adb.calls, [])
|
||||||
self.assertEqual(device.calls, [])
|
self.assertEqual(device.calls, [])
|
||||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
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:
|
def test_cli_main_catches_flow_error_without_traceback_or_page_body(self) -> None:
|
||||||
script = _load_runner_script()
|
script = _load_runner_script()
|
||||||
|
|
||||||
class FlowFailingRunner:
|
secret = "SERIAL=192.168.0.173:5555 PATH=C:/private <hierarchy>page-body</hierarchy>"
|
||||||
def __init__(self, *args: object, **kwargs: object) -> None: pass
|
|
||||||
def run(self, *args: object, **kwargs: object) -> object:
|
|
||||||
raise SkuSelectionError("<hierarchy>page-body</hierarchy>")
|
|
||||||
|
|
||||||
stderr = BytesIO()
|
class HostileGetterError(SkuSelectionError):
|
||||||
# TextIOWrapper keeps the assertion independent from host console encoding.
|
def __getattribute__(self, name: str) -> object:
|
||||||
import io
|
if name == "_cmbuyer_failure_stage":
|
||||||
text_stderr = io.TextIOWrapper(stderr, encoding="utf-8")
|
raise RuntimeError(secret)
|
||||||
with patch.object(script, "SkuSelectionRunner", FlowFailingRunner), redirect_stderr(text_stderr):
|
return super().__getattribute__(name)
|
||||||
status = script.main([
|
|
||||||
"--serial", "device-1", "--url", _TARGET_URL, "--color", _TASK_COLOR,
|
class HostileStage(str):
|
||||||
"--size", _TASK_SIZE, "--output-dir", "evidence",
|
def __hash__(self) -> int:
|
||||||
])
|
raise RuntimeError(secret)
|
||||||
text_stderr.flush()
|
|
||||||
output = stderr.getvalue().decode("utf-8")
|
hostile_string = SkuSelectionError(secret)
|
||||||
self.assertEqual(status, 1)
|
setattr(hostile_string, "_cmbuyer_failure_stage", HostileStage("sku_entry"))
|
||||||
self.assertNotIn("Traceback", output)
|
|
||||||
self.assertNotIn("page-body", output)
|
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:
|
def _load_runner_script() -> object:
|
||||||
|
|||||||
@@ -106,12 +106,15 @@ class FakeGateway:
|
|||||||
def __init__(self, outcomes: list[object] | None = None, gate: threading.Event | None = None) -> None:
|
def __init__(self, outcomes: list[object] | None = None, gate: threading.Event | None = None) -> None:
|
||||||
self.outcomes = list(outcomes or [None])
|
self.outcomes = list(outcomes or [None])
|
||||||
self.gate = gate
|
self.gate = gate
|
||||||
|
self.entered = threading.Event()
|
||||||
self.calls = 0
|
self.calls = 0
|
||||||
|
|
||||||
def claim_next(self, profile_id: str):
|
def claim_next(self, profile_id: str):
|
||||||
self.calls += 1
|
self.calls += 1
|
||||||
|
self.entered.set()
|
||||||
if self.gate is not None:
|
if self.gate is not None:
|
||||||
self.gate.wait(2)
|
if not self.gate.wait(5):
|
||||||
|
raise RuntimeError("test_gateway_release_timeout")
|
||||||
outcome = self.outcomes.pop(0) if self.outcomes else None
|
outcome = self.outcomes.pop(0) if self.outcomes else None
|
||||||
if isinstance(outcome, Exception):
|
if isinstance(outcome, Exception):
|
||||||
raise outcome
|
raise outcome
|
||||||
@@ -251,7 +254,9 @@ class PollingCoordinatorTests(unittest.TestCase):
|
|||||||
visible: list[object] = []
|
visible: list[object] = []
|
||||||
coordinator.claim_visible.connect(visible.append)
|
coordinator.claim_visible.connect(visible.append)
|
||||||
coordinator.start()
|
coordinator.start()
|
||||||
wait_until(lambda: coordinator.state == PollingState.CLAIMING)
|
# CLAIMING 状态先于 queued worker 实际进入 claim_next;必须等待
|
||||||
|
# worker 入口,避免与 FakeGateway 的超时边界竞争而产生套件级偶发失败。
|
||||||
|
wait_until(lambda: coordinator.state == PollingState.CLAIMING and gateway.entered.is_set())
|
||||||
coordinator._begin_claim(coordinator._epoch)
|
coordinator._begin_claim(coordinator._epoch)
|
||||||
self.assertEqual(gateway.calls, 1)
|
self.assertEqual(gateway.calls, 1)
|
||||||
coordinator.stop()
|
coordinator.stop()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
显式本地配置、停止/重启会话边界和安全轮询协调器已实现;未注入单趟执行 consumer 时保持零领取。
|
显式本地配置、停止/重启会话边界和安全轮询协调器已实现;未注入单趟执行 consumer 时保持零领取。
|
||||||
真机能力与桌面界面仍待最终人工验收,数量/确认页/提交仍未开放。
|
真机能力与桌面界面仍待最终人工验收,数量/确认页/提交仍未开放。
|
||||||
- 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态;
|
- 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态;
|
||||||
采购工具 232 项离线单元测试(全部 mock,不连接真机)。
|
采购工具 233 项离线单元测试(全部 mock,不连接真机)。
|
||||||
- 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发
|
- 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发
|
||||||
一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。T-211 已统一 title、SKU、
|
一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。T-211 已统一 title、SKU、
|
||||||
goods_id 与金额的双端 wire 上限,并证明最坏合法 claim 响应小于 32 KiB。仓库不含业务实例数据。
|
goods_id 与金额的双端 wire 上限,并证明最坏合法 claim 响应小于 32 KiB。仓库不含业务实例数据。
|
||||||
@@ -78,9 +78,11 @@
|
|||||||
- 已完成 T-110、T-111、T-203、T-204、T-301 与 T-302。服务端已能授权、认证设备、原子领取、
|
- 已完成 T-110、T-111、T-203、T-204、T-301 与 T-302。服务端已能授权、认证设备、原子领取、
|
||||||
稳定重放和续租;claim token 不是提交许可,尚无事件、提交围栏或结果 API。
|
稳定重放和续租;claim token 不是提交许可,尚无事件、提交围栏或结果 API。
|
||||||
- T-103 离线 `SkuSelectionFlow` 已实现并通过独立审计,仍为 `DOING` 等人工真机验收;T-104~T-107
|
- T-103 离线 `SkuSelectionFlow` 已实现并通过独立审计,仍为 `DOING` 等人工真机验收;T-104~T-107
|
||||||
按数量/确认页风险逐段开放。服务端后续顺序已收紧为 T-210 → T-205 → T-206 → T-207 → T-208。
|
按数量/确认页风险逐段开放。真机运行要求同一物理手机只保留一个 ADB 通道;CLI 已增加不回显页面
|
||||||
|
正文的固定白名单失败阶段码,用于继续定位最新的安全失败。服务端后续顺序已收紧为
|
||||||
|
T-210 → T-205 → T-206 → T-207 → T-208。
|
||||||
- T-303 已完成并通过三重审查;T-304 的轮询会话、配置页和固定双 Tab 主界面已通过两轮独立代码审计,
|
- T-303 已完成并通过三重审查;T-304 的轮询会话、配置页和固定双 Tab 主界面已通过两轮独立代码审计,
|
||||||
合并后 232 项客户端测试与根门禁通过,继续保持 `DOING` 等原生 Windows DPI、主题、高对比度、
|
合并后 233 项客户端测试与根门禁通过,继续保持 `DOING` 等原生 Windows DPI、主题、高对比度、
|
||||||
Narrator 和纯键盘人工验收。客户端后续按依赖推进 T-306/T-307/T-308,再与 T-305 汇合到
|
Narrator 和纯键盘人工验收。客户端后续按依赖推进 T-306/T-307/T-308,再与 T-305 汇合到
|
||||||
T-400 离线一次性提交安全编排。围栏后管理链为 T-403 → T-402;T-401 必须在新的真实单趟重新
|
T-400 离线一次性提交安全编排。围栏后管理链为 T-403 → T-402;T-401 必须在新的真实单趟重新
|
||||||
通过三闸门,首次点击后固定进入 `UNKNOWN`/人工调和,T-405 打包后由 T-404 只读验收。
|
通过三闸门,首次点击后固定进入 `UNKNOWN`/人工调和,T-405 打包后由 T-404 只读验收。
|
||||||
|
|||||||
+10
-1
@@ -22,7 +22,7 @@ write_paths:
|
|||||||
- docs/current-state.md
|
- docs/current-state.md
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T10:47:47Z sha256=5471bdfeed1fffe4f0abd25201b9d5bf7bf55fa9500c11aa297eb4c422da5f02 -->
|
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T01:23:53Z sha256=bdf9d253b198f67579cf9cd6e7c98a6dd4dd6657828dda14bebc8ea8eff2f605 -->
|
||||||
## 问题 / 背景
|
## 问题 / 背景
|
||||||
|
|
||||||
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:规格面板由详情页精确唯一的“快要抢光”打开;T-110 已把该证据/版本绑定入口批准为受控导航。面板刚打开时目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”自动选中。
|
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:规格面板由详情页精确唯一的“快要抢光”打开;T-110 已把该证据/版本绑定入口批准为受控导航。面板刚打开时目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”自动选中。
|
||||||
@@ -182,6 +182,15 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
|
|||||||
### 2026-08-04T10:47:38Z · ila
|
### 2026-08-04T10:47:38Z · ila
|
||||||
|
|
||||||
2026-08-04 T-103 入口父链收紧完成:提交 44586fe,合入主分支 ce9d6ca。入口只允许精确唯一“快要抢光”子节点及五层已取证可点击 PDD 祖先链,实际点击仍取子节点中心;“免拼购买”独立容器为硬拒绝。独立审计复算证据 hash、验证 focused 32 / full 112 tests 和完整 init 均 PASS。最终真机运行曾安全失败且未发布输出目录;随后只读诊断发现系统通知栏覆盖,app_current 虽仍报告 PDD,但节点树 64 个节点全部属于 SystemUI。下一次必须由人先完全收起通知栏并停在 PDD 首页再运行;T-103 保持 DOING,不把这次失败或入口截图确认误记为完整验收。
|
2026-08-04 T-103 入口父链收紧完成:提交 44586fe,合入主分支 ce9d6ca。入口只允许精确唯一“快要抢光”子节点及五层已取证可点击 PDD 祖先链,实际点击仍取子节点中心;“免拼购买”独立容器为硬拒绝。独立审计复算证据 hash、验证 focused 32 / full 112 tests 和完整 init 均 PASS。最终真机运行曾安全失败且未发布输出目录;随后只读诊断发现系统通知栏覆盖,app_current 虽仍报告 PDD,但节点树 64 个节点全部属于 SystemUI。下一次必须由人先完全收起通知栏并停在 PDD 首页再运行;T-103 保持 DOING,不把这次失败或入口截图确认误记为完整验收。
|
||||||
|
|
||||||
|
### 2026-08-05T01:19:13Z · ila
|
||||||
|
|
||||||
|
2026-08-05 T-103 人工复跑与失败阶段诊断收口:
|
||||||
|
|
||||||
|
- 人工复跑先因 NotificationShade 覆盖和 USB/Wi-Fi 双 ADB 通道同时在线而安全失败;收敛为仅保留 Wi-Fi ADB、拼多多前台为 HomeActivity 后,仍以 generic failure 安全停止且未发布证据目录。
|
||||||
|
- 主线提交 `870e5f8` 为真机 CLI 增加只来自固定白名单的 failure stage;异常正文、serial、路径与 XML 均不回显,hostile getter、`str` 子类和 setter 全部失败闭合为 `unknown`,原异常类型、finally 安全调和、单击不重试与失败不发布语义保持不变。
|
||||||
|
- 独立终审 PASS;T-103 33 tests PASS,client 全量 233 tests PASS,仓库根 `init.ps1` PASS。
|
||||||
|
- 尚待项目所有者使用新 CLI 再次真机复跑;T-103 保持 done=false / Doing,并继续保留 needs_device 与 needs_human_review 门禁。
|
||||||
<!-- END VIKUNJA EXPORT -->
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|||||||
+10
-1
@@ -23,7 +23,7 @@ write_paths:
|
|||||||
- client/tests/localstate/test_store.py
|
- client/tests/localstate/test_store.py
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- BEGIN VIKUNJA EXPORT id=37 synced=2026-08-04T18:02:28Z sha256=ad0773479601411e07157a5745bfd0644f756a8ebaec74e081a77fa5f6cace63 -->
|
<!-- BEGIN VIKUNJA EXPORT id=37 synced=2026-08-05T01:23:52Z sha256=7d7650322518979f6122417383d74ee2c399f3c93eacd6935c178c93dbb59e11 -->
|
||||||
## 问题 / 背景
|
## 问题 / 背景
|
||||||
|
|
||||||
T-006 已确认采购工具双 Tab 原型,T-303 将提供唯一的 HTTP、本地恢复、DPAPI 与单实例底座。T-304 把原型转换为可测试的原生 PySide6 主界面和定时轮询协调器,但在真实单趟执行器接入前不得领取真实任务,避免形成无人消费的 active claim。
|
T-006 已确认采购工具双 Tab 原型,T-303 将提供唯一的 HTTP、本地恢复、DPAPI 与单实例底座。T-304 把原型转换为可测试的原生 PySide6 主界面和定时轮询协调器,但在真实单趟执行器接入前不得领取真实任务,避免形成无人消费的 active claim。
|
||||||
@@ -89,6 +89,15 @@ T-006 已确认采购工具双 Tab 原型,T-303 将提供唯一的 HTTP、本
|
|||||||
合并后仓库根 init.ps1 PASS;client 全量 232 tests PASS。设置 QT_QPA_FONTDIR=C:\Windows\Fonts 后完成离线截图复核,中文字与整体结构均可读,主脑视觉结构复核结论为 PASS。
|
合并后仓库根 init.ps1 PASS;client 全量 232 tests PASS。设置 QT_QPA_FONTDIR=C:\Windows\Fonts 后完成离线截图复核,中文字与整体结构均可读,主脑视觉结构复核结论为 PASS。
|
||||||
|
|
||||||
当前仅完成自动化门禁与离线视觉结构复核。原生 Windows 下的 DPI、浅色/深色主题、高对比度、Narrator 以及纯键盘体验仍需人工验收,因此任务继续保持 DOING,Vikunja 保持 done=false 与 Doing。
|
当前仅完成自动化门禁与离线视觉结构复核。原生 Windows 下的 DPI、浅色/深色主题、高对比度、Narrator 以及纯键盘体验仍需人工验收,因此任务继续保持 DOING,Vikunja 保持 done=false 与 Doing。
|
||||||
|
|
||||||
|
### 2026-08-05T01:19:13Z · ila
|
||||||
|
|
||||||
|
2026-08-05 T-304 完整套件偶发测试修复:
|
||||||
|
|
||||||
|
- 完整测试套件暴露 `test_stop_during_claim_commits_then_requires_recovery_without_consumer_delivery` 与 FakeGateway 旧 2 秒等待位于同一时间边界的竞态;这是测试同步问题,不是生产轮询语义变化。
|
||||||
|
- 提交 `c9b39a4` 仅在测试替身增加 worker `entered` event,并把 gate 改为 5 秒有界、超时显式失败;未修改生产代码。测试继续验证重复 dispatch、Stop epoch/latch、旧 epoch 拒绝、consumer 零投递与 durable recovery。
|
||||||
|
- 独立审计 PASS;polling module 13 tests PASS,client 全量 233 tests PASS,仓库根 `init.ps1` PASS。
|
||||||
|
- 原生 Windows 人工 UI 验收仍未完成;T-304 保持 done=false / Doing 与 needs_human_review。
|
||||||
<!-- END VIKUNJA EXPORT -->
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|||||||
Reference in New Issue
Block a user