feat(client): add T-106 confirmation evidence capture
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"""采集 T-106 人工准备的确认页四态证据;不执行页面操作。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
|
||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.order_confirm_spike import (
|
||||
Android16ForegroundReader,
|
||||
DECLARED_STATES,
|
||||
EXPECTED_GOODS_ID,
|
||||
OrderConfirmEvidenceCapturer,
|
||||
OrderConfirmEvidenceError,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="采集 T-106 人工准备的确认页四态本机证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
parser.add_argument("--goods-id", required=True, help="T-106 已批准的目标商品标识。")
|
||||
parser.add_argument(
|
||||
"--state",
|
||||
required=True,
|
||||
choices=DECLARED_STATES,
|
||||
help=(
|
||||
"人工声明状态:Gate2 后导航来源、确认页 Gate3、最终控件可见、"
|
||||
"人工一次返回后的安全页。"
|
||||
),
|
||||
)
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与只读设备 RPC 超时(秒)。")
|
||||
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||
if (
|
||||
type(arguments.serial) is not str
|
||||
or not arguments.serial.strip()
|
||||
or arguments.serial != arguments.serial.strip()
|
||||
):
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if type(arguments.goods_id) is not str or arguments.goods_id != EXPECTED_GOODS_ID:
|
||||
raise ValueError("--goods-id 不是 T-106 已批准目标。")
|
||||
if type(arguments.state) is not str or arguments.state not in DECLARED_STATES:
|
||||
raise ValueError("--state 必须是批准的人工声明状态。")
|
||||
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name:
|
||||
raise ValueError("--output-dir 必须是明确的全新目录。")
|
||||
if (
|
||||
not isinstance(arguments.timeout, (int, float))
|
||||
or isinstance(arguments.timeout, bool)
|
||||
or arguments.timeout <= 0
|
||||
or not isfinite(arguments.timeout)
|
||||
):
|
||||
raise ValueError("--timeout 必须是大于 0 的有限数值。")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
validate_arguments(arguments)
|
||||
except ValueError as error:
|
||||
print(f"失败:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
import adbutils
|
||||
import uiautomator2 as u2
|
||||
except ImportError:
|
||||
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
adb_runner = SubprocessAdbRunner(arguments.adb)
|
||||
capturer = OrderConfirmEvidenceCapturer(
|
||||
AdbClient(adb_runner, timeout_seconds=arguments.timeout),
|
||||
NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
),
|
||||
Android16ForegroundReader(adb_runner, arguments.timeout),
|
||||
timeout_seconds=arguments.timeout,
|
||||
)
|
||||
try:
|
||||
capturer.capture(
|
||||
arguments.serial,
|
||||
arguments.goods_id,
|
||||
arguments.state,
|
||||
arguments.output_dir,
|
||||
)
|
||||
except (DeviceConnectionError, OrderConfirmEvidenceError):
|
||||
# 第三方异常可能带设备、页面正文或本机目录,命令行只输出固定摘要。
|
||||
print("确认页四态只读取证失败:已停止,未发布本机证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
except OSError:
|
||||
print("确认页四态只读取证失败:无法发布本机证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("确认页四态只读取证完成。")
|
||||
print("人工复核:请在指定目录检查截图、XML、应用摘要和 manifest。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,345 @@
|
||||
"""T-106 确认页四态的纯只读取证;页面对应性只由人工确认。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from adbutils.errors import AdbTimeout
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from ..device.adb import AdbClient, CommandRunner, DeviceConnectionError, DeviceInspection
|
||||
from ..device.baseline import (
|
||||
HIERARCHY_PARAMS,
|
||||
PDD_PACKAGE,
|
||||
SCREENSHOT_PARAMS,
|
||||
_save_base64_screenshot,
|
||||
_sha256_file,
|
||||
_validate_hierarchy,
|
||||
)
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .product_url import parse_product_url
|
||||
|
||||
|
||||
EXPECTED_GOODS_ID = "937122477375"
|
||||
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||
EXPECTED_ANDROID_VERSION = "16"
|
||||
EXPECTED_SCREEN_SIZE = (1080, 2376)
|
||||
DECLARED_STATES = (
|
||||
"gate2-navigation-source",
|
||||
"confirm-gate3",
|
||||
"submit-control-visible",
|
||||
"returned-safe-page",
|
||||
)
|
||||
|
||||
|
||||
class OrderConfirmEvidenceError(RuntimeError):
|
||||
"""T-106 四态证据未形成完整原子产物。"""
|
||||
|
||||
|
||||
class OrderConfirmEvidenceTimeoutError(OrderConfirmEvidenceError):
|
||||
"""只读取证设备调用超时。"""
|
||||
|
||||
|
||||
class OrderConfirmReadDevice(Protocol):
|
||||
"""T-106 唯一设备边界;故意只有读取能力。"""
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]: ...
|
||||
|
||||
def window_size(self) -> tuple[int, int]: ...
|
||||
|
||||
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any: ...
|
||||
|
||||
|
||||
class OrderConfirmForegroundReader(Protocol):
|
||||
"""读取 Android 前台摘要,不暴露通用命令执行。"""
|
||||
|
||||
def read(self, serial: str) -> dict[str, str]: ...
|
||||
|
||||
|
||||
class Android16ForegroundReader:
|
||||
"""读取 Android 16 的唯一 top-resumed Activity。"""
|
||||
|
||||
_TOP_RESUMED_PATTERN = re.compile(
|
||||
r"(?m)^\s*topResumedActivity=ActivityRecord\{[^\r\n}]*?\s+u\d+\s+"
|
||||
r"(?P<package>[^/\s]+)/(?P<activity>[^\s}]+)\s+t\d+\}\s*$"
|
||||
)
|
||||
|
||||
def __init__(self, runner: CommandRunner, timeout_seconds: float) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._runner = runner
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
def read(self, serial: str) -> dict[str, str]:
|
||||
if type(serial) is not str or not serial.strip() or serial != serial.strip():
|
||||
raise OrderConfirmEvidenceError("必须显式提供非空设备通道。")
|
||||
result = self._runner.run(
|
||||
("-s", serial, "shell", "dumpsys", "activity", "activities"),
|
||||
self._timeout_seconds,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OrderConfirmEvidenceError("Android 前台摘要读取失败,未发布证据。")
|
||||
matches = list(self._TOP_RESUMED_PATTERN.finditer(result.stdout))
|
||||
if len(matches) != 1:
|
||||
raise OrderConfirmEvidenceError("Android 前台摘要不唯一,未发布证据。")
|
||||
match = matches[0]
|
||||
return {"package": match.group("package"), "activity": match.group("activity")}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrderConfirmEvidenceResult:
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
app_path: Path
|
||||
|
||||
|
||||
class OrderConfirmEvidenceCapturer:
|
||||
"""采集人工准备的单个稳定状态,不解析页面业务字段。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], OrderConfirmReadDevice],
|
||||
foreground_reader: OrderConfirmForegroundReader,
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._foreground_reader = foreground_reader
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._started = False
|
||||
|
||||
def capture(
|
||||
self,
|
||||
serial: str,
|
||||
goods_id: str,
|
||||
human_declared_state: str,
|
||||
output_directory: Path,
|
||||
) -> OrderConfirmEvidenceResult:
|
||||
if self._started:
|
||||
raise OrderConfirmEvidenceError("同一取证器不可重复调用。")
|
||||
self._started = True
|
||||
_validate_inputs(serial, goods_id, human_declared_state)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
|
||||
staging: Path | None = None
|
||||
try:
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
device = self._connector(serial)
|
||||
initial_app = _require_read_precondition(
|
||||
device,
|
||||
self._foreground_reader.read(serial),
|
||||
)
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
screenshot_payload = _read_rpc(
|
||||
device,
|
||||
"takeScreenshot",
|
||||
SCREENSHOT_PARAMS,
|
||||
self._timeout_seconds,
|
||||
)
|
||||
if not isinstance(screenshot_payload, str):
|
||||
raise OrderConfirmEvidenceError("页面截图无效,未发布证据。")
|
||||
_save_base64_screenshot(screenshot_payload, screenshot_path)
|
||||
_require_screenshot(screenshot_path)
|
||||
|
||||
hierarchy = _read_rpc(
|
||||
device,
|
||||
"dumpWindowHierarchy",
|
||||
HIERARCHY_PARAMS,
|
||||
self._timeout_seconds,
|
||||
)
|
||||
_validate_hierarchy(hierarchy)
|
||||
hierarchy_path = staging / "hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
final_app = _require_read_precondition(
|
||||
device,
|
||||
self._foreground_reader.read(serial),
|
||||
)
|
||||
if final_app != initial_app:
|
||||
raise OrderConfirmEvidenceError("取证期间前台页面漂移,未发布证据。")
|
||||
|
||||
app_path = staging / "app.json"
|
||||
app_path.write_text(
|
||||
json.dumps(final_app, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_manifest(
|
||||
inspection,
|
||||
serial,
|
||||
goods_id,
|
||||
human_declared_state,
|
||||
screenshot_path,
|
||||
hierarchy_path,
|
||||
app_path,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, OrderConfirmEvidenceError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise OrderConfirmEvidenceTimeoutError("确认页四态只读取证超时,未发布证据。") from error
|
||||
except (OSError, UnidentifiedImageError, ValueError) as error:
|
||||
_clean_staging(staging)
|
||||
raise OrderConfirmEvidenceError("确认页四态证据无法原子发布,未发布证据。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise OrderConfirmEvidenceError("确认页四态只读取证未完成,未发布证据。") from error
|
||||
|
||||
return OrderConfirmEvidenceResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
app_path=target / "app.json",
|
||||
)
|
||||
|
||||
|
||||
def _is_positive_finite(value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and value > 0
|
||||
and isfinite(value)
|
||||
)
|
||||
|
||||
|
||||
def _validate_inputs(serial: object, goods_id: object, state: object) -> None:
|
||||
if type(serial) is not str or not serial.strip() or serial != serial.strip():
|
||||
raise OrderConfirmEvidenceError("必须显式提供非空设备通道。")
|
||||
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
|
||||
raise OrderConfirmEvidenceError("商品不是 T-106 已批准取证目标。")
|
||||
parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}")
|
||||
if type(state) is not str or state not in DECLARED_STATES:
|
||||
raise OrderConfirmEvidenceError("人工声明状态无效。")
|
||||
|
||||
|
||||
def _validate_new_target(target: Path) -> None:
|
||||
if target.exists() or not target.name:
|
||||
raise OrderConfirmEvidenceError("输出目录必须是不存在的明确新目录。")
|
||||
|
||||
|
||||
def _require_expected_device(inspection: DeviceInspection) -> None:
|
||||
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
|
||||
raise OrderConfirmEvidenceError("设备不是已批准取证组合。")
|
||||
|
||||
|
||||
def _require_read_precondition(
|
||||
device: OrderConfirmReadDevice,
|
||||
current: object,
|
||||
) -> dict[str, str]:
|
||||
info = device.app_info(PDD_PACKAGE)
|
||||
version = (info.get("versionName") or info.get("version_name")) if isinstance(info, dict) else None
|
||||
if version != EXPECTED_PDD_VERSION:
|
||||
raise OrderConfirmEvidenceError("拼多多版本不是已批准取证版本。")
|
||||
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||
raise OrderConfirmEvidenceError("拼多多不在前台。")
|
||||
activity = current.get("activity")
|
||||
if not isinstance(activity, str) or not activity.strip():
|
||||
raise OrderConfirmEvidenceError("前台应用摘要不完整。")
|
||||
if device.window_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise OrderConfirmEvidenceError("屏幕坐标空间不是已批准尺寸。")
|
||||
return {"package": PDD_PACKAGE, "activity": activity, "pdd_version": EXPECTED_PDD_VERSION}
|
||||
|
||||
|
||||
def _read_rpc(
|
||||
device: OrderConfirmReadDevice,
|
||||
method: str,
|
||||
params: object,
|
||||
timeout_seconds: float,
|
||||
) -> object:
|
||||
return device.jsonrpc_call(method, params, timeout=timeout_seconds)
|
||||
|
||||
|
||||
def _require_screenshot(path: Path) -> None:
|
||||
with Image.open(path) as image:
|
||||
image.load()
|
||||
if image.size != EXPECTED_SCREEN_SIZE or image.format != "PNG":
|
||||
raise OrderConfirmEvidenceError("页面截图格式或尺寸无效。")
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
goods_id: str,
|
||||
state: str,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
app_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"operation": "t106-order-confirm-readonly-evidence",
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"product": {
|
||||
"goods_id": goods_id,
|
||||
"canonical_url": f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}",
|
||||
},
|
||||
"human_declared_state": state,
|
||||
"review_status": "human_review_required",
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
"model": inspection.model,
|
||||
"android_version": inspection.android_version,
|
||||
"pdd_package": PDD_PACKAGE,
|
||||
"pdd_version": EXPECTED_PDD_VERSION,
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"path": screenshot_path.name,
|
||||
"role": "human_prepared_state_raw_screenshot",
|
||||
"sha256": _sha256_file(screenshot_path),
|
||||
},
|
||||
{
|
||||
"path": hierarchy_path.name,
|
||||
"role": "human_prepared_state_raw_hierarchy_local_only",
|
||||
"sha256": _sha256_file(hierarchy_path),
|
||||
},
|
||||
{
|
||||
"path": app_path.name,
|
||||
"role": "human_prepared_state_app_identity",
|
||||
"sha256": _sha256_file(app_path),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
"""T-106 确认页四态纯只读取证的离线测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import base64
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from functools import lru_cache
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from io import BytesIO, StringIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbDevice, CommandResult, DeviceInspection
|
||||
from cmbuyer_client.pdd.order_confirm_spike import (
|
||||
Android16ForegroundReader,
|
||||
DECLARED_STATES,
|
||||
EXPECTED_GOODS_ID,
|
||||
OrderConfirmEvidenceCapturer,
|
||||
OrderConfirmEvidenceError,
|
||||
OrderConfirmEvidenceTimeoutError,
|
||||
OrderConfirmForegroundReader,
|
||||
OrderConfirmReadDevice,
|
||||
)
|
||||
|
||||
|
||||
SERIAL = "192.168.0.173:5555"
|
||||
HIERARCHY = (
|
||||
"<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'>"
|
||||
"<node text='local raw page' /></hierarchy>"
|
||||
)
|
||||
TOP_RESUMED = (
|
||||
" topResumedActivity=ActivityRecord{101034589 u0 "
|
||||
"com.xunmeng.pinduoduo/.activity.NewPageActivity t1816}\n"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _png_base64() -> str:
|
||||
raw = BytesIO()
|
||||
Image.new("RGB", (1080, 2376), color="white").save(raw, format="PNG")
|
||||
return base64.b64encode(raw.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
class FakeAdbClient:
|
||||
def __init__(self, *, model: str = "PKG110", android_version: str = "16") -> None:
|
||||
self.calls: list[str] = []
|
||||
self.inspection = DeviceInspection(
|
||||
device=AdbDevice(serial=SERIAL, state="device", model=model),
|
||||
model=model,
|
||||
android_version=android_version,
|
||||
)
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
self.calls.append(serial)
|
||||
return self.inspection
|
||||
|
||||
|
||||
class FakeReadDevice:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
version: str = "8.17.0",
|
||||
screen_size: tuple[int, int] = (1080, 2376),
|
||||
screenshot: object | None = None,
|
||||
hierarchy: object = HIERARCHY,
|
||||
) -> None:
|
||||
self.version = version
|
||||
self.screen_size = screen_size
|
||||
self.screenshot = _png_base64() if screenshot is None else screenshot
|
||||
self.hierarchy = hierarchy
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
self.app_info_reads = 0
|
||||
self.window_reads = 0
|
||||
self.post_version: str | None = None
|
||||
self.post_screen_size: tuple[int, int] | None = None
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.calls.append(("app_info", package_name))
|
||||
self.app_info_reads += 1
|
||||
version = self.post_version if self.app_info_reads > 1 and self.post_version else self.version
|
||||
return {"versionName": version}
|
||||
|
||||
def window_size(self) -> tuple[int, int]:
|
||||
self.calls.append(("window_size",))
|
||||
self.window_reads += 1
|
||||
if self.window_reads > 1 and self.post_screen_size is not None:
|
||||
return self.post_screen_size
|
||||
return self.screen_size
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> object:
|
||||
self.calls.append(("jsonrpc", method, params, timeout))
|
||||
if method == "takeScreenshot":
|
||||
return self.screenshot
|
||||
if method == "dumpWindowHierarchy":
|
||||
return self.hierarchy
|
||||
raise AssertionError("unexpected read RPC")
|
||||
|
||||
|
||||
class FakeForegroundReader:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
package: str = "com.xunmeng.pinduoduo",
|
||||
activity: str = ".activity.NewPageActivity",
|
||||
) -> None:
|
||||
self.package = package
|
||||
self.activity = activity
|
||||
self.post_package: str | None = None
|
||||
self.post_activity: str | None = None
|
||||
self.calls: list[str] = []
|
||||
|
||||
def read(self, serial: str) -> dict[str, str]:
|
||||
self.calls.append(serial)
|
||||
package = self.post_package if len(self.calls) > 1 and self.post_package else self.package
|
||||
activity = self.post_activity if len(self.calls) > 1 and self.post_activity else self.activity
|
||||
return {"package": package, "activity": activity}
|
||||
|
||||
|
||||
class FakeCommandRunner:
|
||||
def __init__(self, result: CommandResult) -> None:
|
||||
self.result = result
|
||||
self.calls: list[tuple[tuple[str, ...], float]] = []
|
||||
|
||||
def run(self, arguments: tuple[str, ...], timeout_seconds: float) -> CommandResult:
|
||||
self.calls.append((arguments, timeout_seconds))
|
||||
return self.result
|
||||
|
||||
|
||||
def _load_script() -> object:
|
||||
path = CLIENT_ROOT / "scripts" / "capture_order_confirm_spike.py"
|
||||
specification = spec_from_file_location("capture_order_confirm_spike_for_test", path)
|
||||
assert specification is not None and specification.loader is not None
|
||||
module = module_from_spec(specification)
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _namespace(**changes: object) -> argparse.Namespace:
|
||||
values: dict[str, object] = {
|
||||
"serial": SERIAL,
|
||||
"goods_id": EXPECTED_GOODS_ID,
|
||||
"state": DECLARED_STATES[0],
|
||||
"output_dir": Path("evidence"),
|
||||
"timeout": 10.0,
|
||||
"adb": "adb",
|
||||
}
|
||||
values.update(changes)
|
||||
return argparse.Namespace(**values)
|
||||
|
||||
|
||||
class Android16ForegroundReaderTests(unittest.TestCase):
|
||||
def test_reads_exact_unique_top_resumed_activity(self) -> None:
|
||||
runner = FakeCommandRunner(CommandResult(stdout=TOP_RESUMED))
|
||||
reader = Android16ForegroundReader(runner, timeout_seconds=7)
|
||||
|
||||
self.assertEqual(
|
||||
reader.read(SERIAL),
|
||||
{
|
||||
"package": "com.xunmeng.pinduoduo",
|
||||
"activity": ".activity.NewPageActivity",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
runner.calls,
|
||||
[
|
||||
(
|
||||
("-s", SERIAL, "shell", "dumpsys", "activity", "activities"),
|
||||
7,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_missing_legacy_duplicate_or_failed_summary_is_rejected(self) -> None:
|
||||
outputs = (
|
||||
"",
|
||||
"mResumedActivity: ActivityRecord{1 u0 com.xunmeng.pinduoduo/.Main t1}\n",
|
||||
TOP_RESUMED + TOP_RESUMED,
|
||||
"topResumedActivity=null\n",
|
||||
)
|
||||
for output in outputs:
|
||||
with self.subTest(output=output):
|
||||
reader = Android16ForegroundReader(
|
||||
FakeCommandRunner(CommandResult(stdout=output)),
|
||||
timeout_seconds=7,
|
||||
)
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
reader.read(SERIAL)
|
||||
|
||||
reader = Android16ForegroundReader(
|
||||
FakeCommandRunner(CommandResult(stdout=TOP_RESUMED, returncode=1)),
|
||||
timeout_seconds=7,
|
||||
)
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
reader.read(SERIAL)
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
reader.read(f" {SERIAL}")
|
||||
|
||||
|
||||
class OrderConfirmEvidenceTests(unittest.TestCase):
|
||||
def _capturer(
|
||||
self,
|
||||
adb: FakeAdbClient,
|
||||
device: FakeReadDevice,
|
||||
*,
|
||||
foreground: FakeForegroundReader | None = None,
|
||||
connector_calls: list[str] | None = None,
|
||||
) -> OrderConfirmEvidenceCapturer:
|
||||
def connect(serial: str) -> FakeReadDevice:
|
||||
if connector_calls is not None:
|
||||
connector_calls.append(serial)
|
||||
return device
|
||||
|
||||
return OrderConfirmEvidenceCapturer(
|
||||
adb, # type: ignore[arg-type]
|
||||
connect,
|
||||
foreground or FakeForegroundReader(),
|
||||
timeout_seconds=2,
|
||||
)
|
||||
|
||||
def test_all_four_human_states_publish_only_raw_read_evidence(self) -> None:
|
||||
self.assertEqual(len(DECLARED_STATES), 4)
|
||||
for state in DECLARED_STATES:
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeReadDevice()
|
||||
foreground = FakeForegroundReader()
|
||||
connector_calls: list[str] = []
|
||||
target = Path(temporary) / state
|
||||
|
||||
result = self._capturer(
|
||||
adb,
|
||||
device,
|
||||
foreground=foreground,
|
||||
connector_calls=connector_calls,
|
||||
).capture(SERIAL, EXPECTED_GOODS_ID, state, target)
|
||||
|
||||
manifest_text = result.manifest_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(manifest_text)
|
||||
app = json.loads(result.app_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(adb.calls, [SERIAL])
|
||||
self.assertEqual(connector_calls, [SERIAL])
|
||||
self.assertEqual(foreground.calls, [SERIAL, SERIAL])
|
||||
self.assertEqual(
|
||||
[call[1] for call in device.calls if call[0] == "jsonrpc"],
|
||||
["takeScreenshot", "dumpWindowHierarchy"],
|
||||
)
|
||||
self.assertEqual(manifest["operation"], "t106-order-confirm-readonly-evidence")
|
||||
self.assertEqual(manifest["human_declared_state"], state)
|
||||
self.assertEqual(manifest["review_status"], "human_review_required")
|
||||
self.assertEqual(manifest["product"]["goods_id"], EXPECTED_GOODS_ID)
|
||||
self.assertEqual(app["package"], "com.xunmeng.pinduoduo")
|
||||
self.assertEqual(
|
||||
{path.name for path in target.iterdir()},
|
||||
{"screenshot.png", "hierarchy.xml", "app.json", "manifest.json"},
|
||||
)
|
||||
self.assertNotIn(SERIAL, manifest_text)
|
||||
self.assertNotIn("NewPageActivity", manifest_text)
|
||||
self.assertNotIn("local raw page", manifest_text)
|
||||
for artifact in manifest["artifacts"]:
|
||||
self.assertEqual(len(artifact["sha256"]), 64)
|
||||
|
||||
def test_invalid_inputs_and_existing_target_stop_before_device_access(self) -> None:
|
||||
scenarios = (
|
||||
("", EXPECTED_GOODS_ID, DECLARED_STATES[0]),
|
||||
(f" {SERIAL}", EXPECTED_GOODS_ID, DECLARED_STATES[0]),
|
||||
(SERIAL, "958756616606", DECLARED_STATES[0]),
|
||||
(SERIAL, EXPECTED_GOODS_ID, "unknown"),
|
||||
)
|
||||
for serial, goods_id, state in scenarios:
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
adb = FakeAdbClient()
|
||||
connector_calls: list[str] = []
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
self._capturer(
|
||||
adb,
|
||||
FakeReadDevice(),
|
||||
connector_calls=connector_calls,
|
||||
).capture(serial, goods_id, state, Path(temporary) / "evidence")
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(connector_calls, [])
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
target.mkdir()
|
||||
sentinel = target / "sentinel.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
adb = FakeAdbClient()
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
self._capturer(adb, FakeReadDevice()).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
|
||||
)
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||
|
||||
def test_device_version_package_activity_and_screen_mismatch_fail_closed(self) -> None:
|
||||
scenarios = (
|
||||
(FakeAdbClient(model="OTHER"), FakeReadDevice(), FakeForegroundReader()),
|
||||
(FakeAdbClient(android_version="15"), FakeReadDevice(), FakeForegroundReader()),
|
||||
(FakeAdbClient(), FakeReadDevice(version="8.17.1"), FakeForegroundReader()),
|
||||
(FakeAdbClient(), FakeReadDevice(), FakeForegroundReader(package="com.example.other")),
|
||||
(FakeAdbClient(), FakeReadDevice(), FakeForegroundReader(activity="")),
|
||||
(FakeAdbClient(), FakeReadDevice(screen_size=(1080, 2400)), FakeForegroundReader()),
|
||||
)
|
||||
for adb, device, foreground in scenarios:
|
||||
with self.subTest(device=device.__dict__), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
self._capturer(adb, device, foreground=foreground).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
self.assertEqual([call for call in device.calls if call[0] == "jsonrpc"], [])
|
||||
|
||||
def test_invalid_screenshot_or_hierarchy_never_publishes(self) -> None:
|
||||
devices = (
|
||||
FakeReadDevice(screenshot="not base64"),
|
||||
FakeReadDevice(screenshot=123),
|
||||
FakeReadDevice(hierarchy="<not-hierarchy />"),
|
||||
FakeReadDevice(hierarchy=123),
|
||||
)
|
||||
for device in devices:
|
||||
with self.subTest(value=device.screenshot), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
self._capturer(FakeAdbClient(), device).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_timeout_and_unexpected_failure_are_redacted(self) -> None:
|
||||
class BrokenDevice(FakeReadDevice):
|
||||
def __init__(self, failure: BaseException) -> None:
|
||||
super().__init__()
|
||||
self.failure = failure
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> object:
|
||||
raise self.failure
|
||||
|
||||
failures = (
|
||||
(TimeoutError(f"secret {SERIAL} C:\\private\\raw.xml"), OrderConfirmEvidenceTimeoutError),
|
||||
(RuntimeError(f"secret {SERIAL} C:\\private\\raw.xml"), OrderConfirmEvidenceError),
|
||||
)
|
||||
for failure, expected in failures:
|
||||
with self.subTest(expected=expected), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(expected) as raised:
|
||||
self._capturer(FakeAdbClient(), BrokenDevice(failure)).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
|
||||
)
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertNotIn("private", str(raised.exception))
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_post_capture_identity_drift_never_publishes(self) -> None:
|
||||
mutators = (
|
||||
lambda device, foreground: setattr(device, "post_version", "8.17.1"),
|
||||
lambda device, foreground: setattr(foreground, "post_package", "com.example.other"),
|
||||
lambda device, foreground: setattr(foreground, "post_activity", "OtherActivity"),
|
||||
lambda device, foreground: setattr(device, "post_screen_size", (1080, 2400)),
|
||||
)
|
||||
for mutate in mutators:
|
||||
with TemporaryDirectory() as temporary:
|
||||
device = FakeReadDevice()
|
||||
foreground = FakeForegroundReader()
|
||||
mutate(device, foreground)
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
self._capturer(
|
||||
FakeAdbClient(), device, foreground=foreground
|
||||
).capture(SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_atomic_publish_failure_cleans_staging(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with (
|
||||
patch(
|
||||
"cmbuyer_client.pdd.order_confirm_spike.os.rename",
|
||||
side_effect=OSError(f"private {SERIAL}"),
|
||||
),
|
||||
self.assertRaises(OrderConfirmEvidenceError) as raised,
|
||||
):
|
||||
self._capturer(FakeAdbClient(), FakeReadDevice()).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
|
||||
)
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_capturer_is_one_shot_even_after_rejection(self) -> None:
|
||||
capturer = self._capturer(FakeAdbClient(), FakeReadDevice())
|
||||
with TemporaryDirectory() as temporary:
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
capturer.capture("", EXPECTED_GOODS_ID, DECLARED_STATES[0], Path(temporary) / "bad")
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
capturer.capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], Path(temporary) / "good"
|
||||
)
|
||||
|
||||
def test_public_boundaries_expose_reading_only(self) -> None:
|
||||
forbidden = {
|
||||
"click",
|
||||
"swipe",
|
||||
"press",
|
||||
"pressKey",
|
||||
"send_keys",
|
||||
"set_text",
|
||||
"start_pdd_view_intent",
|
||||
"open_product",
|
||||
"open_sku_panel",
|
||||
"set_quantity",
|
||||
"go_to_order_confirm",
|
||||
"submit_order_once",
|
||||
"pay",
|
||||
}
|
||||
self.assertTrue(forbidden.isdisjoint(OrderConfirmReadDevice.__dict__))
|
||||
self.assertTrue(forbidden.isdisjoint(OrderConfirmForegroundReader.__dict__))
|
||||
self.assertEqual(
|
||||
{name for name in Android16ForegroundReader.__dict__ if not name.startswith("_")},
|
||||
{"read"},
|
||||
)
|
||||
self.assertEqual(
|
||||
{name for name in OrderConfirmEvidenceCapturer.__dict__ if not name.startswith("_")},
|
||||
{"capture"},
|
||||
)
|
||||
|
||||
|
||||
class OrderConfirmCliAndStaticBoundaryTests(unittest.TestCase):
|
||||
def test_cli_parses_each_approved_state(self) -> None:
|
||||
script = _load_script()
|
||||
for state in DECLARED_STATES:
|
||||
arguments = script.parse_arguments( # type: ignore[attr-defined]
|
||||
[
|
||||
"--serial",
|
||||
SERIAL,
|
||||
"--goods-id",
|
||||
EXPECTED_GOODS_ID,
|
||||
"--state",
|
||||
state,
|
||||
"--output-dir",
|
||||
f"evidence-{state}",
|
||||
]
|
||||
)
|
||||
script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||
|
||||
def test_cli_validation_rejects_unapproved_values(self) -> None:
|
||||
script = _load_script()
|
||||
invalid = (
|
||||
_namespace(serial=""),
|
||||
_namespace(serial=f" {SERIAL}"),
|
||||
_namespace(goods_id="958756616606"),
|
||||
_namespace(state="unknown"),
|
||||
_namespace(output_dir=Path("")),
|
||||
_namespace(timeout=0),
|
||||
_namespace(timeout=float("inf")),
|
||||
)
|
||||
for arguments in invalid:
|
||||
with self.subTest(arguments=arguments), self.assertRaises(ValueError):
|
||||
script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||
|
||||
def test_cli_runtime_failure_never_echoes_sensitive_values(self) -> None:
|
||||
script = _load_script()
|
||||
fake_capturer = unittest.mock.Mock()
|
||||
fake_capturer.capture.side_effect = OrderConfirmEvidenceError(
|
||||
f"secret {SERIAL} C:\\private\\raw.xml body"
|
||||
)
|
||||
stderr = StringIO()
|
||||
with (
|
||||
patch.object(script, "OrderConfirmEvidenceCapturer", return_value=fake_capturer),
|
||||
patch.dict(
|
||||
sys.modules,
|
||||
{"adbutils": unittest.mock.Mock(), "uiautomator2": unittest.mock.Mock()},
|
||||
),
|
||||
redirect_stderr(stderr),
|
||||
):
|
||||
result = script.main( # type: ignore[attr-defined]
|
||||
[
|
||||
"--serial",
|
||||
SERIAL,
|
||||
"--goods-id",
|
||||
EXPECTED_GOODS_ID,
|
||||
"--state",
|
||||
DECLARED_STATES[0],
|
||||
"--output-dir",
|
||||
"C:\\private\\evidence",
|
||||
]
|
||||
)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertNotIn(SERIAL, stderr.getvalue())
|
||||
self.assertNotIn("private", stderr.getvalue())
|
||||
self.assertNotIn("body", stderr.getvalue())
|
||||
|
||||
def test_cli_success_does_not_echo_local_path_or_device(self) -> None:
|
||||
script = _load_script()
|
||||
fake_capturer = unittest.mock.Mock()
|
||||
stdout = StringIO()
|
||||
with (
|
||||
patch.object(script, "OrderConfirmEvidenceCapturer", return_value=fake_capturer),
|
||||
patch.dict(
|
||||
sys.modules,
|
||||
{"adbutils": unittest.mock.Mock(), "uiautomator2": unittest.mock.Mock()},
|
||||
),
|
||||
redirect_stdout(stdout),
|
||||
):
|
||||
result = script.main( # type: ignore[attr-defined]
|
||||
[
|
||||
"--serial",
|
||||
SERIAL,
|
||||
"--goods-id",
|
||||
EXPECTED_GOODS_ID,
|
||||
"--state",
|
||||
DECLARED_STATES[0],
|
||||
"--output-dir",
|
||||
"C:\\private\\evidence",
|
||||
]
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertNotIn(SERIAL, stdout.getvalue())
|
||||
self.assertNotIn("private", stdout.getvalue())
|
||||
fake_capturer.capture.assert_called_once()
|
||||
|
||||
def test_sources_have_only_approved_read_rpc_literals_and_no_ui_mutators(self) -> None:
|
||||
expected_by_source = {
|
||||
Path("src/cmbuyer_client/pdd/order_confirm_spike.py"): {
|
||||
"takeScreenshot",
|
||||
"dumpWindowHierarchy",
|
||||
},
|
||||
Path("scripts/capture_order_confirm_spike.py"): set(),
|
||||
}
|
||||
forbidden_attributes = {
|
||||
"click",
|
||||
"swipe",
|
||||
"press",
|
||||
"pressKey",
|
||||
"send_keys",
|
||||
"set_text",
|
||||
"start_pdd_view_intent",
|
||||
"open_product",
|
||||
"open_sku_panel",
|
||||
"set_quantity_and_readback",
|
||||
"go_to_order_confirm",
|
||||
"submit_order_once",
|
||||
}
|
||||
forbidden_import_fragments = {
|
||||
"quantity_gate2_runner",
|
||||
"sku_selection_runner",
|
||||
"submission",
|
||||
"payment",
|
||||
}
|
||||
for relative, expected_rpc in expected_by_source.items():
|
||||
source = (CLIENT_ROOT / relative).read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
rpc_literals = {
|
||||
node.args[1].value
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "_read_rpc"
|
||||
and len(node.args) > 1
|
||||
and isinstance(node.args[1], ast.Constant)
|
||||
and isinstance(node.args[1].value, str)
|
||||
}
|
||||
self.assertEqual(rpc_literals, expected_rpc)
|
||||
observed_attributes = {
|
||||
node.attr
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Attribute) and node.attr in forbidden_attributes
|
||||
}
|
||||
self.assertEqual(observed_attributes, set())
|
||||
imported_modules = {
|
||||
alias.name
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom))
|
||||
for alias in node.names
|
||||
}
|
||||
for fragment in forbidden_import_fragments:
|
||||
self.assertTrue(all(fragment not in module for module in imported_modules))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+25
-3
@@ -30,7 +30,7 @@
|
||||
已取证的固定尺码 reveal、精确 M 选择、规格面板单价 `12.88` 与一次 Back 已进入生产 Flow 并完成
|
||||
真机人工验收;T-104 已把同商品详情页安全退出收紧为版本绑定、连续两帧稳定判据,数量/确认页/提交仍未开放。
|
||||
- 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态;
|
||||
采购工具 330 项离线单元测试(全部 mock,不连接真机)。
|
||||
采购工具 346 项离线单元测试(全部 mock,不连接真机)。
|
||||
- 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发
|
||||
一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。T-211 已统一 title、SKU、
|
||||
goods_id 与金额的双端 wire 上限,并证明最坏合法 claim 响应小于 32 KiB。仓库不含业务实例数据。
|
||||
@@ -62,11 +62,11 @@
|
||||
| 路径 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
||||
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-211、T-301~T-308、T-400~T-405) | T-103~T-105、T-303、T-211 已完成;T-304 代码完成后保持 DOING 等人工 UI 复核;T-106 确认页只读取证依赖已解除;双端围栏、真实提交、调和、打包与只读验收均已落成任务 |
|
||||
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-211、T-301~T-308、T-400~T-405) | T-103~T-105、T-303、T-211 已完成;T-304 代码完成后保持 DOING 等人工 UI 复核;T-106 已领取并完成只读取证代码,等待四态真机人工验收;双端围栏、真实提交、调和、打包与只读验收均已落成任务 |
|
||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 |
|
||||
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、固定双 Tab 主界面、安全轮询、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/安全退出;数量、确认页和提交未开放 |
|
||||
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、固定双 Tab 主界面、安全轮询、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/数量 Gate2/安全退出;T-106 确认页四态仅开放纯只读取证,确认页生产导航和提交仍未开放 |
|
||||
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
||||
|
||||
## 任务状态
|
||||
@@ -225,6 +225,28 @@ T-105 阶段二生产验收已在人工确认的同一手机、拼多多 `8.17.0
|
||||
项目所有者已确认数量精确为 2、颜色尺码未变、Gate1/Gate2 分别为 `12.88/32.76`、Gate2 截图正确、
|
||||
一次 Back 回到同一商品详情,且未进入确认/提交/支付页、未创建订单。该命令只作已验运行记录,不再重跑。
|
||||
|
||||
T-106 已提供四态纯只读取证命令。每次运行都会重新核对设备、拼多多 `8.17.0`、前台包和
|
||||
1080×2376 屏幕,只调用截图与 `compressed=false` XML;不会打开页面、点击、返回、滑动、输入或识别
|
||||
确认页业务字段。四个输出目录必须均不存在,完整 XML 只留本机:
|
||||
|
||||
```powershell
|
||||
# 1. 人工准备:Gate2 已通过、尚未进入确认页;运行后再由人记录实际进入确认页的精确控件文本/容器/匹配数
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state gate2-navigation-source --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\gate2-navigation-source-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
|
||||
# 2. 人工只点击已核对的确认页入口并停在 Gate3 页面;绝不点击最终“提交订单”
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state confirm-gate3 --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\confirm-gate3-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
|
||||
# 3. 人工把最终“提交订单”控件准备为可见但不点击;记录精确文案、匹配数和启用态
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state submit-control-visible --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\submit-control-visible-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
|
||||
# 4. 人工只返回一次并停手;结果不明时不再返回,直接停止
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state returned-safe-page --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\returned-safe-page-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
四态都必须由项目所有者本地核对截图/XML:规格、数量、Gate3 应付总额、最终控件精确文案/匹配数/
|
||||
启用态、一次返回后的页面身份;同时确认未点击最终提交、未创建订单、未进入支付或安全验证页面。
|
||||
满足全部人工验收前 T-106 保持 `DOING`,这些人工观察也不能在 T-106 中直接变成生产 selector。
|
||||
|
||||
## 关键背景
|
||||
|
||||
本项目是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`
|
||||
|
||||
+6
-1
@@ -18,7 +18,7 @@ write_paths:
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=41 synced=2026-08-06T02:12:29Z sha256=2e8d4a77458dbe40fd3c659b896a7c6c821a88dd1cccec45985981653e8f24ee -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=41 synced=2026-08-06T02:20:32Z sha256=3e585159a2607c63f54d72b84d9779699cce3cc79805ebc7c2bbd08cb80b815f -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-105 验证规格面板数量与闸门二后,下一步会进入更高风险的订单确认页。T-106 只由人手工准备确认页、最终提交控件可见态和一次返回后的页面,再用完全只读脚本采集真机事实;本任务不自动导航、不写确认页判据。
|
||||
@@ -63,6 +63,11 @@ T-105 验证规格面板数量与闸门二后,下一步会进入更高风险
|
||||
|
||||
2026-08-06T02:12:16.724Z · Codex
|
||||
已领取 T-106。基线 init.ps1 通过(client 330 tests);按任务边界只实现四态纯只读取证,禁止 click/press/back/swipe/input/intent,代码完成后等待人工真机准备与验收。
|
||||
|
||||
### 2026-08-06T02:20:23Z · ila
|
||||
|
||||
2026-08-06T02:20:23.748Z · Codex
|
||||
T-106 四态纯只读取证代码已完成:新增 order_confirm_spike、capture_order_confirm_spike.py 与 16 项专项测试。静态 AST 证明生产模块/CLI 只允许 takeScreenshot、dumpWindowHierarchy,且不可达页面修改、确认页导航、提交、围栏或付款能力。client 全量 346 tests、compileall、validate_agent_context 与完整 init.ps1 均通过。任务保持 DOING,等待项目所有者依次人工准备并确认 Gate2 导航前、Gate3、最终控件可见、一次返回后四态;绝不点击最终提交。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
Reference in New Issue
Block a user