同步移动端工程、设备控制改进与发布资料

This commit is contained in:
2026-09-26 19:21:12 +08:00
parent 912ba432cf
commit 98eadc5c8b
137 changed files with 9274 additions and 85 deletions
@@ -0,0 +1,19 @@
# 测试设备登记
`测试设备.example.json` 是可提交的空白模板;`测试设备.local.json` 是被根 .gitignore 精确排除的真实登记,不提交、不回显、不自动覆盖。手机的真实序列号仅保存在后者,报告使用 `android-test-a`。
主测试手机已获用户专用测试授权:可自动安装覆盖本 App、清理本 App 测试数据、操作权限与蓝牙;常规测试不需要 root。不包括自动启动视觉检查。摄像头、DroidCam、采集和视觉测试必须另行询问如何启动并等待指示。
从仓库根运行,ADB 路径使用已安装工具的实际位置,不写入仓库配置:
```powershell
python -X utf8 "移动端相关内容/安卓app/如何安卓测试/测试设备登记/prepare_test_device.py" --adb "<ADB可执行文件>" check
```
首次在用户明确授权且仅连接一台已授权 USB 手机时,使用 `register-connected` 代替 `check` 自动读取型号、系统和设备标识并独占创建真实登记。脚本不执行 root、不安装程序、不启用摄像头、不修改手机设置。已有文件一律不覆盖;需要修正时由维护者只编辑真实登记。
无参数的 `check` 仅验证文件;带 `--adb` 同时核验在线授权、型号和 API。无登记时复制空白示例并停止,填写完成后重新检查;不能将空白模板当成真实设备使用。`--alias` 默认 android-test-a,后续多个登记必须明确代号,不自动取首台。
JSON 格式:schema_version 固定 1,devices 为列表。每项包含 alias、serial、model、android_release、api_level、connection(当前 usb)、dedicated_test_device、automation_allowed 和 visual_check_requires_user_instruction。最后两个授权布尔必须与用户实际授权一致,视觉门禁必须为 true;本脚本仅接受已授权的专用测试机。
不得将 `adb devices` 原始输出或异常完整命令放入提交报告。脚本失败只报告原因或字段名。登记不包含 WiFi 密码、root 密码、账户或其他应用数据。第二台手机可以由用户手动安装并辅助验收,无需自动登记其身份。
@@ -0,0 +1,174 @@
from __future__ import annotations
import argparse
import json
import re
import subprocess
from pathlib import Path
DIRECTORY = Path(__file__).resolve().parent
PRIVATE_NAME = "测试设备.local.json"
EXAMPLE_NAME = "测试设备.example.json"
class RegistrationError(RuntimeError):
"""A sanitized registration error; never include device values."""
def validate_document(document: object) -> list[dict]:
if not isinstance(document, dict) or document.get("schema_version") != 1:
raise RegistrationError("schema_version 必须为 1。")
devices = document.get("devices")
if not isinstance(devices, list) or not devices:
raise RegistrationError("devices 必须为非空列表。")
aliases: set[str] = set()
serials: set[str] = set()
for device in devices:
if not isinstance(device, dict):
raise RegistrationError("devices 条目格式错误。")
for field in ("alias", "serial", "model", "android_release"):
value = device.get(field)
if not isinstance(value, str) or not value.strip() or any(ord(c) < 32 for c in value):
raise RegistrationError(f"字段缺失或格式错误:{field}")
if "<" in value or ">" in value or "待填写" in value:
raise RegistrationError(f"字段仍为占位内容:{field}")
if not re.fullmatch(r"android-test-[a-z0-9-]+", device["alias"]):
raise RegistrationError("alias 必须使用 android-test- 开头的 ASCII 测试代号。")
if device["serial"] != device["serial"].strip() or any(c.isspace() for c in device["serial"]):
raise RegistrationError("serial 格式错误。")
api = device.get("api_level")
if type(api) is not int or api < 26:
raise RegistrationError("api_level 必须为不小于 26 的整数。")
if device.get("connection") != "usb":
raise RegistrationError("当前自动登记仅支持 usb。")
for field in ("dedicated_test_device", "automation_allowed", "visual_check_requires_user_instruction"):
if device.get(field) is not True:
raise RegistrationError(f"授权或门禁字段未明确启用:{field}")
if device["alias"] in aliases or device["serial"] in serials:
raise RegistrationError("登记存在重复 alias 或 serial。")
aliases.add(device["alias"])
serials.add(device["serial"])
return devices
def load_registration(directory: Path = DIRECTORY) -> list[dict]:
path = directory / PRIVATE_NAME
if not path.exists():
try:
with path.open("xb") as stream:
stream.write((directory / EXAMPLE_NAME).read_bytes())
except FileExistsError:
pass
raise RegistrationError("已准备空白私有登记;请填写真实登记后重新检查,当前停止真机步骤。")
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
raise RegistrationError("真实登记无法读取或不是有效 UTF-8 JSON;原文件未改动。") from None
return validate_document(document)
def run_adb(adb: str, arguments: list[str]) -> str:
try:
result = subprocess.run(
[adb, *arguments], capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=20,
)
except (OSError, subprocess.TimeoutExpired):
raise RegistrationError("ADB 不可执行或操作超时;未输出命令及设备标识。") from None
if result.returncode != 0:
raise RegistrationError("ADB 操作失败;未输出原始设备日志。")
return result.stdout.strip()
def list_devices(adb: str) -> list[tuple[str, str, bool]]:
rows = []
# Windows ADB often omits usb: from devices -l and returns unknown for
# get-devpath. -d selects a USB transport only; ambiguity must fail closed.
try:
single_usb_serial = run_adb(adb, ["-d", "get-serialno"])
except RegistrationError:
single_usb_serial = ""
for line in run_adb(adb, ["devices", "-l"]).splitlines():
fields = line.split()
if len(fields) >= 2 and fields[1] in {"device", "offline", "unauthorized", "no"}:
usb = any(v.startswith("usb:") for v in fields[2:]) or fields[0] == single_usb_serial
rows.append((fields[0], fields[1], usb))
return rows
def read_identity(adb: str, serial: str) -> dict:
def prop(name: str) -> str:
return run_adb(adb, ["-s", serial, "shell", "getprop", name])
try:
api = int(prop("ro.build.version.sdk"))
except ValueError:
raise RegistrationError("无法读取有效的 Android API 版本。") from None
return {"serial": serial, "model": prop("ro.product.model"),
"android_release": prop("ro.build.version.release"), "api_level": api}
def register_connected(adb: str, alias: str, directory: Path = DIRECTORY) -> None:
path = directory / PRIVATE_NAME
if path.exists():
raise RegistrationError("真实登记已存在,不覆盖、不重建;请使用 check。")
rows = list_devices(adb)
if len(rows) != 1:
raise RegistrationError("首次自动登记要求仅接入一台手机;当前数量不符合。")
serial, status, usb = rows[0]
if status != "device":
raise RegistrationError("手机未授权或离线;请完成 USB 调试授权。")
if not usb:
raise RegistrationError("未确认 USB 传输,拒绝将无线设备自动登记为 USB。")
device = {"alias": alias, **read_identity(adb, serial), "connection": "usb",
"dedicated_test_device": True, "automation_allowed": True,
"visual_check_requires_user_instruction": True}
document = {"schema_version": 1, "devices": [device]}
validate_document(document)
try:
with path.open("x", encoding="utf-8", newline="\n") as stream:
json.dump(document, stream, ensure_ascii=False, indent=2)
stream.write("\n")
except FileExistsError:
raise RegistrationError("真实登记已被创建;拒绝覆盖。") from None
def check_connected(adb: str, device: dict) -> None:
row = next((r for r in list_devices(adb) if r[0] == device["serial"]), None)
if row is None or row[1] != "device" or not row[2]:
raise RegistrationError("登记手机未通过已授权 USB 连接检查;不改用其他手机。")
current = read_identity(adb, device["serial"])
if any(current[key] != device[key] for key in ("model", "android_release", "api_level")):
raise RegistrationError("手机系统或型号与登记不一致;请核实登记,不自动更新。")
def main() -> int:
parser = argparse.ArgumentParser(description="测试手机私有登记门禁;不输出真实标识")
parser.add_argument("--adb", help="已有 adb 可执行文件")
parser.add_argument("--alias", default="android-test-a")
parser.add_argument("action", choices=("check", "register-connected"), default="check", nargs="?")
args = parser.parse_args()
try:
if args.action == "register-connected":
if not args.adb:
raise RegistrationError("自动登记需要 --adb,且必须已有用户专用测试授权。")
register_connected(args.adb, args.alias)
devices = load_registration()
device = next((item for item in devices if item["alias"] == args.alias), None)
if device is None:
raise RegistrationError("未找到指定测试代号;不自动选择其他设备。")
if args.adb:
check_connected(args.adb, device)
print(f"测试登记通过:{device['alias']};未输出设备标识,视觉检查仍须单独询问。")
return 0
except RegistrationError as error:
print(f"测试登记未通过:{error}")
return 2
except OSError:
print("测试登记读写失败;未输出路径、设备标识或原始异常。")
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,101 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
SPEC = importlib.util.spec_from_file_location("registration", Path(__file__).with_name("prepare_test_device.py"))
registration = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(registration)
def valid_device():
return {"alias": "android-test-a", "serial": "FAKE-UNIT-SERIAL", "model": "Fixture",
"android_release": "13", "api_level": 33, "connection": "usb",
"dedicated_test_device": True, "automation_allowed": True,
"visual_check_requires_user_instruction": True}
class RegistrationTests(unittest.TestCase):
def test_windows_usb_without_listing_marker(self):
with patch.object(registration, "run_adb", side_effect=["FAKE-USB", "List of devices attached\nFAKE-USB device product:test transport_id:1\n"]):
self.assertEqual(registration.list_devices("adb"), [("FAKE-USB", "device", True)])
def test_ambiguous_usb_does_not_guess_from_serial_shape(self):
with patch.object(registration, "run_adb", side_effect=[registration.RegistrationError("ambiguous"), "List of devices attached\nFAKE-USB device transport_id:1\n"]):
self.assertEqual(registration.list_devices("adb"), [("FAKE-USB", "device", False)])
def test_blank_copy_blocks_and_preserves_existing(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary) / "中文 空格"
directory.mkdir()
payload = Path(__file__).with_name(registration.EXAMPLE_NAME).read_bytes()
(directory / registration.EXAMPLE_NAME).write_bytes(payload)
with self.assertRaises(registration.RegistrationError):
registration.load_registration(directory)
private = directory / registration.PRIVATE_NAME
self.assertEqual(private.read_bytes(), payload)
with self.assertRaises(registration.RegistrationError):
registration.load_registration(directory)
self.assertEqual(private.read_bytes(), payload)
def test_existing_file_never_overwritten_even_if_corrupt(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
private = directory / registration.PRIVATE_NAME
private.write_bytes(b"not-json")
with patch.object(registration, "list_devices") as listing:
with self.assertRaises(registration.RegistrationError):
registration.register_connected("adb", "android-test-a", directory)
listing.assert_not_called()
self.assertEqual(private.read_bytes(), b"not-json")
def test_ambiguous_unauthorized_and_wireless_registration_refused(self):
for rows in ([], [("A", "device", True), ("B", "device", True)],
[("A", "unauthorized", True)], [("A", "device", False)]):
with self.subTest(rows=rows), tempfile.TemporaryDirectory() as temporary:
with patch.object(registration, "list_devices", return_value=rows):
with self.assertRaises(registration.RegistrationError):
registration.register_connected("adb", "android-test-a", Path(temporary))
self.assertFalse((Path(temporary) / registration.PRIVATE_NAME).exists())
def test_private_creation_and_no_device_values_in_errors(self):
device = valid_device()
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
with patch.object(registration, "list_devices", return_value=[(device["serial"], "device", True)]), \
patch.object(registration, "read_identity", return_value={k: device[k] for k in ("serial", "model", "android_release", "api_level")}):
registration.register_connected("adb", device["alias"], directory)
self.assertEqual(registration.load_registration(directory), [device])
with patch.object(registration, "list_devices", return_value=[("DIFFERENT-PRIVATE-SERIAL", "device", True)]):
with self.assertRaises(registration.RegistrationError) as caught:
registration.check_connected("adb", device)
self.assertNotIn(device["serial"], str(caught.exception))
self.assertNotIn("DIFFERENT-PRIVATE-SERIAL", str(caught.exception))
def test_authorization_visual_gate_duplicates_and_placeholders(self):
for key, value in (("automation_allowed", False), ("visual_check_requires_user_instruction", False),
("serial", "<fill>"), ("api_level", True)):
device = valid_device()
device[key] = value
with self.subTest(key=key), self.assertRaises(registration.RegistrationError):
registration.validate_document({"schema_version": 1, "devices": [device]})
with self.assertRaises(registration.RegistrationError):
registration.validate_document({"schema_version": 1, "devices": [valid_device(), valid_device()]})
def test_system_change_blocks_without_modifying_document(self):
device = valid_device()
before = json.dumps(device)
with patch.object(registration, "list_devices", return_value=[(device["serial"], "device", True)]), \
patch.object(registration, "read_identity", return_value={**device, "api_level": 34}):
with self.assertRaises(registration.RegistrationError):
registration.check_connected("adb", device)
self.assertEqual(json.dumps(device), before)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,16 @@
{
"schema_version": 1,
"devices": [
{
"alias": "android-test-a",
"serial": "",
"model": "",
"android_release": "",
"api_level": null,
"connection": "usb",
"dedicated_test_device": false,
"automation_allowed": false,
"visual_check_requires_user_instruction": true
}
]
}