同步移动端工程、设备控制改进与发布资料
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user