Improve controller behavior and update project requirements
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# Android 可重复测试流程
|
||||
|
||||
**先读 [按影响选测](按影响选测.md)。以下矩阵列出可选覆盖,不是每轮必跑清单;历史版本专项仅在相关影响存在时执行。**
|
||||
|
||||
本文件维护步骤与合格标准,不写一次性通过结果。每轮测试前阅读 [常见问题与排障](常见问题与排障.md),优先沿用已验证方法。结果记录到 `../测试结果归档/`。真机先检查 `测试设备登记/prepare_test_device.py`,多机精确选择,日志仅使用代号。源码与 UI 尚未实现的测试不得写成通过。
|
||||
|
||||
远端联调脚本在电脑端使用 `requirements-host.txt` 的 Paramiko;它不属于核桃派离线安装依赖,不能加入设备端 `requirements-dev.txt` 或 OTA wheelhouse。
|
||||
@@ -24,6 +26,7 @@
|
||||
| ANDROID-TEST-BLE | DISCOVERY、CONNECTION、SECURITY | 主测试机真实扫描、握手、断开、后台释放、返回重连、设备服务重启、异常断线,全部无需系统配对。 |
|
||||
| ANDROID-TEST-WIFI | WIFI、WIFI-SECRET | 核桃派扫描、隐藏 SSID 输入、字段校验、立即生效与确认取消、错误密码、切网后 BLE 状态仍可查;无密码回读/日志泄露。实际网络变更保留恢复配置。 |
|
||||
| ANDROID-TEST-CONTROL | LIBRARY、PLAYBACK、DEFAULT、PREVIEW | 播放/默认/动画控制的返回值、状态与逻辑帧一致;预览单在途;用专用内容验证后恢复此前内容,不启动视觉程序。 |
|
||||
| ANDROID-TEST-LIBRARY-CACHE | MOBILE-LIBRARY-CACHE、DEVICE-MOBILE-LIBRARY-CACHE | 16×16 先于 64×64、设备修订变化清理两端失效图、重连复用、清空与损坏保护、旧设备回退;真实 BLE 仅只读,不切换显示内容。 |
|
||||
| ANDROID-TEST-WEB | WEB-COEXIST | 网页修改手机可见,手机修改网页可见;连接短提示与常驻名称正确;初次加载不伪造连接事件;昵称 HTML 不执行。 |
|
||||
| ANDROID-TEST-TWO-PHONES | CONNECTION | 提供 APK 给用户装第二台;A 连接 B 不能抢占,A 断开 B 可连接,A 异常退出可释放;真实双方结果分别记录。 |
|
||||
| ANDROID-TEST-PERF | PREVIEW、STATUS | 广播、预览、WiFi 扫描与动画并发,记录实际刷新/截止丢失/CPU/内存,与启用前基线及既有标准比较,不要求新增目测。 |
|
||||
@@ -32,13 +35,11 @@
|
||||
|
||||
## 3. 执行顺序
|
||||
|
||||
1. 测试登记脚本与仓库卫生检查。
|
||||
2. 共享逻辑、设备业务、协议、现有本机回归;失败先修复。
|
||||
3. 模拟设备的 Android UI 与权限测试。
|
||||
4. 主测试机 BLE/控制/网络/网页联调,再做性能和生命周期。
|
||||
5. 首次双手机验收由用户安装交付 APK 协助;日常只用主机,连接仲裁/广播/协议变化才重测。
|
||||
6. 隔离重建、最终 debug APK 和脱敏证据归档。
|
||||
7. 复盘本轮失败与调整:将已验证解法补到排障指南及对应流程,单次证据保留归档;最终反馈说明更新了哪些经验文档、哪些问题仍未解决。
|
||||
1. 记录变更和共享影响,按选测规则选择编号、场景、配置;跳过无影响项。
|
||||
2. 执行相关宿主逻辑与构建检查;UI 改动按需运行独立模拟器场景。
|
||||
3. 只有必要的真机场景才检查登记、ADB/root 和所需板端凭据;不因测试手机不在线阻塞纯逻辑/UI 工作。
|
||||
4. 根据选中场景执行定向联调,恢复所有本轮改动;失败不得跳过必要证据。
|
||||
5. 只有构建/交付需要才隔离重建、打包。归档实际覆盖及未验证边界,沉淀已验证经验。
|
||||
|
||||
当前可以执行的登记单元测试(从仓库根目录):
|
||||
|
||||
@@ -46,20 +47,31 @@
|
||||
python -X utf8 -m unittest discover -s "移动端相关内容/安卓app/如何安卓测试/测试设备登记" -p "test_*.py"
|
||||
```
|
||||
|
||||
工程完成后,本文件必须补上经过验证的 Gradle task 与端到端脚本入口;不能先编造不存在的可执行命令。测试脚本应读取私有登记并在进程内传递目标,避免原始 ADB 输出进入报告。
|
||||
Gradle task 与按场景执行命令见 [按影响选测](按影响选测.md)。测试脚本应读取私有登记并在进程内传递目标,避免原始 ADB 输出进入报告。
|
||||
|
||||
### 已提供的真机测试入口
|
||||
|
||||
用构建脚本生成 `:androidApp:assembleDebug` 和 `:androidApp:assembleDebugAndroidTest` 后:
|
||||
|
||||
```text
|
||||
python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --adb "<已有ADB路径>" --build "<本轮隔离工程目录>"
|
||||
python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --scenario connection --adb "<已有ADB路径>" --build "<本轮隔离工程目录>"
|
||||
```
|
||||
|
||||
脚本先验证 android-test-a 的 USB 登记,从凭据门禁后的目标核桃派读取广播短名,仅扫描该板,安装覆盖两个 debug APK,再执行 `BleIntegrationTest`。不把临时广播身份写入公开报告。此测试只读状态、设置、内容、帧与 WiFi,不播放内容,不切网;断开后再连接核对持久 ID。运行器先验证 root 并为本 App 授予必要权限;所有点击经 root 按实时语义位置执行。权限未稳定生效必须停止等待用户,不使用普通注入或无障碍备用路径。
|
||||
脚本先验证 android-test-a 的 USB 登记,从凭据门禁后的目标核桃派读取广播短名,仅扫描该板,安装覆盖两个 debug APK,再执行所选 `BleIntegrationTest` 场景。不把临时广播身份写入公开报告。connection 仅握手、断开再连接核对持久 ID;其它读取、轮询、WiFi 扫描和后台恢复分别显式选择。运行器先验证 root 并为本 App 授予必要权限;所有点击经 root 按实时语义位置执行。权限未稳定生效必须停止等待用户,不使用普通注入或无障碍备用路径。
|
||||
|
||||
脚本只有明确 `OK (1 test)` 才返回成功;测试入口存在不代表真实验收通过。结果单独归档。
|
||||
|
||||
### “显示内容”两档图片与缓存
|
||||
|
||||
先用 `:sharedCore:jvmTest` 检查两轮顺序、修订变化、断线取消和旧设备回退;用独立模拟器的 `content`、`settings` 场景检查预览替换和清空确认。需要验证 Android 文件缓存时选 `cache-storage`,只访问登记手机;需要验证真实板端图片读取和未变化重连时再选 `library-cache`,先运行仓库的板端凭据准备门禁。两种真机场景都使用隔离构建目录中的产品 debug APK 与 androidTest APK:
|
||||
|
||||
```text
|
||||
python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --scenario cache-storage --adb "<已有ADB路径>" --build "<本轮隔离工程目录>"
|
||||
python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --scenario library-cache --adb "<已有ADB路径>" --build "<本轮隔离工程目录>"
|
||||
```
|
||||
|
||||
合格标准:板端摘要的目录修订能反映条目或顺序变化,16×16 与 64×64 PNG 均可解码,失效小图被清理;App 首轮小图全部尝试后再读完整图,单图失败仍继续;目录未变时复用有效缓存且第二次图片请求为零,目录变化时删去旧修订,跨设备隔离,损坏文件按未命中处理,清空只影响内容缓存。记录首次和重连的图片请求数及 PNG 负载字节数,不将负载当作无线链路总字节数。真实 BLE 测试只读,不选择新显示内容;“当前设备画面”仍走原帧接口。
|
||||
|
||||
## 4. 证据与恢复
|
||||
|
||||
开发部署的真实失败恢复测试使用 `deploy_mobile_dev.py --deploy --test-rollback`,必须先结束手机控制会话。它和普通部署一样先保存可用程序副本,然后在安装文件及 systemd 配置完成后故意返回 97,触发同一 EXIT 回滚路径。脚本比较测试前后被部署程序文件、config.json、wifi_config.json 和持久身份的摘要,并确认真实主服务 active;随后仍需执行 BLE/生命周期恢复检查。此选项会短暂停止服务,但不主动切换测试图案,也不等同正式 OTA 或断电回滚测试。恢复副本保留供排查,不能把故意失败当作普通部署完成。
|
||||
@@ -71,9 +83,9 @@ python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --ad
|
||||
|
||||
`ControllerRecoveryTest` 的测试专用 SimulatedPlatform/SimulatedLink 使用同一分片与 RPC 路径,替换加密为显式测试直通实现,覆盖断线清缓存并重连、未完成请求取消、新旧扫描回调隔离。只在 commonTest 编译,生产扫描与 APK 不包含模拟入口;加密正确性由独立跨语言黄金向量测试覆盖。
|
||||
|
||||
默认 BLE 回归额外执行真实 `wifi.scan`,断言返回有界且不含密码,不输出网络名称;提交故意过期的设置修订,要求返回 CONFLICT 并继续 ping 成功。不会实际改配置。
|
||||
显式选择 `--scenario wifi-scan` 才执行真实 `wifi.scan`,断言返回有界且不含密码,不输出网络名称;另选 `--scenario conflict` 提交故意过期的设置修订,要求返回 CONFLICT 并继续 ping 成功。不会实际改配置。
|
||||
|
||||
真实网络激活验证可显式加 `--reapply-current-wifi`:先确认保存配置与当前已连网络相同,保留密码并将该原配置立即应用;轮询任务到成功,核对恢复连接及保存字段不变。可能短暂影响板端 WiFi,执行前说明;不替代不同 SSID、替换密码、DHCP/静态地址互换的单独验收。默认不启用,不能拿重新应用原网络当作所有网络场景都通过。
|
||||
真实网络激活验证显式选择 `--scenario wifi-reapply`:先确认保存配置与当前已连网络相同,保留密码并将该原配置立即应用;轮询任务到成功,核对恢复连接及保存字段不变。可能短暂影响板端 WiFi,执行前说明;不替代不同 SSID、替换密码、DHCP/静态地址互换的单独验收。默认不启用,不能拿重新应用原网络当作所有网络场景都通过。
|
||||
|
||||
`ANDROID-TEST-WIFI` 的双网络专项入口是 `run_wifi_switch_test.py --adb <已有ADB路径> --build <本轮隔离工程目录>`,需要先构建 `:androidApp:assembleDebugAndroidTest`。脚本通过 Windows 当前移动热点接口在进程内读取第二网络参数;App 仪器测试经短时私有文件读取密码,不把密码放在 ADB 参数或报告中。错误密码会令唯一受管网络暂时失联,必须单独加 `--wrong-only` 运行;该分支经 App 提交、BLE 任务失败状态与无密码日志核验,并用短时恢复计时器回到原网络。确认此分支后再加 `--skip-wrong`,依次验证热点 DHCP、静态 IPv4、回到 DHCP、立即恢复原网络和原网络复核。两种模式都先执行字段校验与 App 扫描,并在板端保存受保护的原 NetworkManager 配置;结束时检查恢复副本和定时器的清理结果。失败时若 SSH 因切网中断,等待原网络恢复并核验后再清理,不能把临时断链当成恢复失败或直接删除备份。
|
||||
|
||||
@@ -81,42 +93,42 @@ python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --ad
|
||||
|
||||
`--validate-only` 只执行表单与无效 IPv4 校验,不切网;`--final-check` 只复核原网络的 App、BLE、板端状态及恢复材料清理。所有模式均遵守相同的凭据门禁和手机登记检查。
|
||||
|
||||
`--verify-current-controls` 用当前修订重新提交现有亮度,要求回读不变;若设备已有活动动画,暂停、提交当前暂停位置和原倍速,finally 恢复原暂停状态。最后核对开机默认未改变。它不选择新内容或测试图案,也不代替改变亮度值、选择其他内容/默认内容的完整验收。无活动动画时明确记录该子项不适用。
|
||||
`--scenario controls` 用当前修订重新提交现有亮度,要求回读不变;若设备已有活动动画,暂停、提交当前暂停位置和原倍速,finally 恢复原暂停状态。最后核对开机默认未改变。它不选择新内容或测试图案,也不代替改变亮度值、选择其他内容/默认内容的完整验收。无活动动画时明确记录该子项不适用。
|
||||
|
||||
部分厂商在覆盖安装返回成功后异步重置权限;已授权 `--grant-root` 时脚本最多三次授予本 App 蓝牙权限,每次等待 3 秒再复核,未稳定授予就停止。不得以此授权修改其他系统安全开关。
|
||||
部分厂商在覆盖安装返回成功后异步重置权限;选中需要 BLE 的真机场景时脚本最多三次授予本 App 蓝牙权限,每次等待 3 秒再复核,未稳定授予就停止。不得以此授权修改其他系统安全开关。
|
||||
|
||||
性能观察使用 `sample_mobile_performance.py --seconds 150 --output <新的报告JSON路径>`:仅保存刷新率、帧计数、截止丢失、OE 故障、CPU、内存、连接/动画布尔状态;不保存 SSID、标识、密码或完整状态。可与 BLE 回归同时运行。该采样属于运行观察,蓝牙始终开启时不能宣称已完成启用前后的因果对照实验。
|
||||
|
||||
真实服务生命周期检查:主测试手机先主动断开,运行 `python "移动端相关内容/安卓app/如何安卓测试/check_mobile_lifecycle.py" --restart-service`。脚本经过板端凭据门禁,实际 systemctl stop/start 主服务,以临时 sentinel 验证 RuntimeDirectory 在显式停服及启动后均保留;核对 config.json、wifi_config.json、mobile/identity.json 内容摘要不变,只输出布尔结果;最后删除自身 sentinel。停止后无论中间断言如何都尝试启动服务;不能用这项烟雾测试代替完整部署故障回滚、OTA 或物理显示验收。
|
||||
|
||||
可使用同一运行器 `--test activity` 执行 `AppLifecycleTest`:通过 UI Automator 语义节点操作实际 Activity,核验四栏入口、后台超过 30 秒再返回自动重连及主动断开。此测试不截图、不启动摄像头、不改变设备显示内容;与 Controller 直测分开记录。
|
||||
可使用同一运行器 `--scenario activity-background` 执行 `AppLifecycleTest`:通过 UI Automator 语义节点操作实际 Activity,核验后台超过 30 秒再返回自动重连及主动断开;四栏菜单另选 navigation。此测试不截图、不启动摄像头、不改变设备显示内容;与 Controller 直测分开记录。
|
||||
|
||||
若 MIUI 拒绝后台启动,测试通过 shell 显式启动本 App;若再拒绝输入注入,不直接修改系统权限。本软件测试默认启用 `--root-input`:根据语义查找到的本 App 控件当前位置发送 root input tap;后台测试仅发送 Home 键。运行器默认启用 root 输入,不提供普通输入备用分支。
|
||||
若 MIUI 拒绝后台启动,测试通过 shell 显式启动本 App;若再拒绝输入注入,不直接修改系统权限。登记真机的 Activity 场景默认启用 root 输入:根据语义查找到的本 App 控件当前位置发送 root input tap;后台测试仅发送 Home 键。运行器默认启用 root 输入,不提供普通输入备用分支。
|
||||
|
||||
同时已获得 root 切换手机蓝牙授权时,可再传 `--toggle-bluetooth`:在已连接页面关闭蓝牙,等待连接状态清除、扫描入口恢复,再打开蓝牙并通过 App 扫描和连接原设备;之后继续四栏及后台测试。此参数必须与 `--test activity --root-input` 同用。测试与主机运行器都在 finally 尝试恢复蓝牙,错误不能把手机留在测试关闭状态。
|
||||
同时已获得 root 切换手机蓝牙授权时,显式选择 `--scenario bluetooth-toggle`:在已连接页面关闭蓝牙,等待连接状态清除、扫描入口恢复,再打开蓝牙并通过 App 扫描和连接原设备;该场景只验证蓝牙开关恢复,不捆绑四栏及后台测试。测试与主机运行器都在 finally 尝试恢复蓝牙,错误不能把手机留在测试关闭状态。
|
||||
|
||||
`run_ble_test.py` 使用登记手机和凭据门禁指定的开发板;覆盖安装 debug App 与测试 APK。运行器默认启用 `--grant-root`,用于覆盖安装后重新授予此 App 的蓝牙扫描/连接权限。脚本不输出真实手机编号和开发板广播编号。
|
||||
`run_ble_test.py` 使用登记手机和凭据门禁指定的开发板;覆盖安装 debug App 与测试 APK。运行器在 BLE 场景中使用既有 root 授权,用于覆盖安装后重新授予此 App 的蓝牙扫描/连接权限。脚本不输出真实手机编号和开发板广播编号。
|
||||
|
||||
`BleIntegrationTest` 先完成真实加密只读会话,读取状态、容量、设置、内容目录、当前帧和不含密码的网络信息;连续 60 轮间隔 2 秒读取状态与帧,主动断开后再次握手并核对持久设备 ID。随后使用生产 Controller 与真实 BLE 验证四栏数据读取、缩略图队列、短暂后台后返回取消释放、后台 30 秒释放、返回前台仅重连上次设备。测试不修改网络或显示内容。会话故障须保留具体阶段与错误,不把模拟测试结果标为实机通过。
|
||||
历史完整 BLE 验收的覆盖如下(现已拆分为明确场景,不能单跑一项声称全部通过):`BleIntegrationTest` 先完成真实加密只读会话,读取状态、容量、设置、内容目录、当前帧和不含密码的网络信息;连续 60 轮间隔 2 秒读取状态与帧,主动断开后再次握手并核对持久设备 ID。随后使用生产 Controller 与真实 BLE 验证四栏数据读取、缩略图队列、短暂后台后返回取消释放、后台 30 秒释放、返回前台仅重连上次设备。测试不修改网络或显示内容。会话故障须保留具体阶段与错误,不把模拟测试结果标为实机通过。
|
||||
|
||||
`RpcTimeoutTest` 验证内部请求超时关闭传输、返回普通通信异常并拒绝继续使用旧 RPC;这与用户主动取消协程不同,后者不自动重试。
|
||||
|
||||
## 2026-09-26 用户最新 root 测试授权(替代前述按用途重复询问规则)
|
||||
本软件调试所需各种测试权限可直接通过登记测试手机的 ADB root 获取,无需询问。所有自动测试点击一律使用 root,根据本 App 实时语义控件位置定位;滚动同样使用 root。不得通过无障碍或普通注入绕过 MIUI。测试前验证登记、ADB 和 root;权限不足或特殊情况必须停止聊天等待用户,不继续不完整测试。授权仅限调试本软件所需操作,不扩大到其他应用数据,视觉与物理操作门禁继续有效。
|
||||
本软件调试所需各种测试权限可直接通过登记测试手机的 ADB root 获取,无需询问。登记真机的自动测试点击和滚动一律使用 root,根据本 App 实时语义控件位置定位;独立模拟器使用标准 Compose 测试 API。不得通过无障碍或普通注入绕过 MIUI。测试前验证登记、ADB 和 root;权限不足或特殊情况必须停止聊天等待用户,不继续不完整测试。授权仅限调试本软件所需操作,不扩大到其他应用数据,视觉与物理操作门禁继续有效。
|
||||
ANDROID-TEST-UI 增加四栏、三点菜单、昵称、长按保存设备、WiFi折叠/DHCP/确认;ANDROID-TEST-STATE 增加记忆迁移、忘记、连接中扫描、切换;ANDROID-TEST-WIFI 增加结构化失败文案;ANDROID-TEST-CONTROL 增加设备混合内容顺序。
|
||||
|
||||
### 四栏版专项入口
|
||||
|
||||
- `run_ble_test.py` 默认验证 root 并授予本 App 蓝牙权限;Activity 所有输入均为 root。`--test activity` 覆盖四栏、菜单、真实重命名和重连核验、恢复原昵称、忘记和再次手动连接。BLE 测试将设备网页顺序摘要与完整 BLE 分页列表摘要比较,不输出条目 ID。
|
||||
- `run_ble_test.py` 执行选中真机场景时验证 root;仅 BLE 场景授予本 App 蓝牙权限;Activity 所有输入均为 root。navigation、activity-background、rename、remember 分别覆盖导航、后台恢复、昵称恢复和忘记重连,不相互捆绑。library-order 场景将设备网页顺序摘要与完整 BLE 分页列表摘要比较,不输出条目 ID。
|
||||
- `run_wifi_switch_test.py --validate-only` 不需要电脑热点,验证空 SSID、确认弹窗、静态 IP 校验;`--wrong-only` 使用当前受管网络及故意错误测试密码,先备份并建立定时恢复,最后核验原网络和清理。只有跨 SSID 的 DHCP/静态往返才需要电脑热点,按前述 `--skip-wrong` 流程运行。
|
||||
- `run_ui_layout_test.py --adb <已有ADB路径> --build <本轮隔离工程目录>` 在登记手机上临时测试约 340dp 宽度、1.3 倍字体和横屏。输入通过 root,语义窗口只用于定位和检查,不执行无障碍动作;电脑端 finally 恢复密度、字体和方向,字体/方向回读核验;若额外改变 USB 保持唤醒等设置,也必须保存、恢复并核验。权限不足停止;安全锁屏不能正常解锁时等待用户,不绕过密码。
|
||||
- `run_ui_layout_test.py --scenario layout --adb <已有ADB路径> --build <本轮隔离工程目录>` 在登记手机上临时测试约 340dp 宽度、1.3 倍字体和横屏。输入通过 root,语义窗口只用于定位和检查,不执行无障碍动作;电脑端 finally 恢复密度、字体和方向,字体/方向回读核验;若额外改变 USB 保持唤醒等设置,也必须保存、恢复并核验。权限不足停止;安全锁屏不能正常解锁时等待用户,不绕过密码。
|
||||
- root 辅助脚本保持 LF 换行;文本通过 App 内临时剪贴板和 root 按键输入,结束清理,不把密码放在命令参数中。
|
||||
|
||||
排障指南按症状记录 root 命令解析、LF 换行、布局进程重建、语义定位、表单输入、安装权限、BLE 阶段释放及 WiFi 恢复。遇到这些情况先查 [对应解法](常见问题与排障.md),不得重复试用已知不可靠的输入或转储方法。
|
||||
|
||||
### 0.2.1 顶栏、连接条目与居中验收
|
||||
|
||||
ANDROID-TEST-STATE:共享逻辑验证自动/手动连接后关联、重新扫描保留、旧回调隔离、切换和失败/异常清理。ANDROID-TEST-UI:运行 run_ui_layout_test.py,在每组字体/方向下连接真实目标,按稳定语义资源标签检查连接条目断开、再次扫描保留、实际断开和再连;两页预览图片与标题中心距卡片中心不超过3物理像素,刷新位于右侧且无重叠。所有输入通过 root;测试末尾主动断开,运行器恢复布局设置。
|
||||
ANDROID-TEST-STATE:共享逻辑验证自动/手动连接后关联、重新扫描保留、旧回调隔离、切换和失败/异常清理。ANDROID-TEST-UI:仅需厂商真机布局补证时运行 run_ui_layout_test.py --scenario layout,普通布局改动优先独立模拟器。真机专项在每组字体/方向下连接真实目标,按稳定语义资源标签检查连接条目断开、再次扫描保留、实际断开和再连;两页预览图片与标题中心距卡片中心不超过3物理像素,刷新位于右侧且无重叠。所有输入通过 root;测试末尾主动断开,运行器恢复布局设置。
|
||||
|
||||
顶栏保留完整文字语义,菜单位于固定区域;长文本循环参数由共享实现及编译核验,语义边界不证明文字实际运动,不记为目视验收。未取得本次视觉指示时不截图、不启动摄像头。纯手机界面调整的实屏视觉检查不适用,不需切网、显示内容切换或驱动测试。
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Run registered BLE tests; WiFi reapplication requires its explicit option."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / '测试设备登记'))
|
||||
from prepare_test_device import load_registration, check_connected
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--scenario', required=True)
|
||||
parser.add_argument('--adb', required=True)
|
||||
parser.add_argument('--build', type=Path, required=True)
|
||||
parser.add_argument('--poll-cycles', type=int, default=60)
|
||||
parser.add_argument('--test', choices=('ble', 'activity', 'layout'), default='ble')
|
||||
parser.add_argument('--root-input', action='store_true', help='Compatibility flag; activity tests always use authorized root input')
|
||||
parser.add_argument('--reapply-current-wifi', action='store_true', help='Explicitly reapply the already active saved WiFi profile over BLE; may briefly reconnect WiFi')
|
||||
parser.add_argument('--verify-current-controls', action='store_true', help='Reapply current brightness; briefly pause active animation and restore without selecting new content')
|
||||
parser.add_argument('--toggle-bluetooth', action='store_true', help='Requires explicit root Bluetooth-switch authorization plus activity root input')
|
||||
parser.add_argument('--grant-root', action='store_true', help='Compatibility flag; runner always verifies and grants authorized App permissions')
|
||||
args = parser.parse_args(argv)
|
||||
from run_ble_test import SCENARIOS
|
||||
from run_emulator_ui_test import passed
|
||||
if args.scenario not in SCENARIOS or args.test != SCENARIOS[args.scenario][0]:
|
||||
parser.error('Scenario and instrumentation family do not match')
|
||||
if args.reapply_current_wifi != (args.scenario == 'wifi-reapply') or args.verify_current_controls != (args.scenario == 'controls') or args.toggle_bluetooth != (args.scenario == 'bluetooth-toggle'):
|
||||
parser.error('Mutation flags must match the explicit scenario')
|
||||
if args.poll_cycles and args.scenario != 'polling': parser.error('Polling requires polling scenario')
|
||||
args.grant_root = True
|
||||
if args.test in ("activity", "layout"): args.root_input = True
|
||||
if not 0 <= args.poll_cycles <= 60:
|
||||
parser.error('poll-cycles must be between 0 and 60')
|
||||
if args.root_input and args.test == 'ble':
|
||||
parser.error('root-input only applies to activity tests')
|
||||
if args.reapply_current_wifi and args.test != 'ble':
|
||||
parser.error('reapply-current-wifi only applies to BLE tests')
|
||||
if args.verify_current_controls and args.test != 'ble':
|
||||
parser.error('verify-current-controls only applies to BLE tests')
|
||||
if args.toggle_bluetooth and not (args.test == 'activity' and args.root_input):
|
||||
parser.error('toggle-bluetooth requires activity and root-input')
|
||||
device = next(d for d in load_registration() if d['alias'] == 'android-test-a')
|
||||
check_connected(args.adb, device)
|
||||
root_check = subprocess.run([args.adb, '-s', device['serial'], 'shell', 'su', '-c', 'id -u'], capture_output=True, text=True, timeout=15)
|
||||
if root_check.returncode or root_check.stdout.strip() != '0':
|
||||
raise RuntimeError('测试手机 root 权限不可用;停止测试,等待用户处理,不使用备用注入方式')
|
||||
board_id = board_name = library_hash = ""
|
||||
if SCENARIOS[args.scenario][1]:
|
||||
from remote_support import connect, execute
|
||||
client, fields = connect()
|
||||
try:
|
||||
code, _, _ = execute(client, "command -v python3 >/dev/null && python3 --version", password=fields['密码'])
|
||||
if code: raise RuntimeError('Remote Python unavailable')
|
||||
code, output, error = execute(client, "python3 -c \"import json; p=json.load(open('/var/lib/matrix-screen-controller/mobile/identity.json')); print(json.dumps({'device_id':p['device_id']}))\"", password=fields['密码'])
|
||||
if code:
|
||||
raise RuntimeError('Cannot read authorized board identity')
|
||||
board_id = json.loads(output)['device_id']
|
||||
from uuid import UUID
|
||||
UUID(board_id)
|
||||
board_name = 'QMS-' + board_id.replace('-', '')[:8]
|
||||
library_hash = ""
|
||||
if args.scenario == "library-order":
|
||||
code, output, _ = execute(client, "python3 -c \"import urllib.request,json,hashlib; d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/library/order',timeout=5)); print(hashlib.sha256('\\n'.join(i['type']+'|'+i['id'] for i in d['items']).encode()).hexdigest())\"", password=fields['密码'])
|
||||
library_hash = output.strip()
|
||||
if code or len(library_hash) != 64 or any(c not in '0123456789abcdef' for c in library_hash):
|
||||
raise RuntimeError('Cannot read device library order digest')
|
||||
if not board_name.startswith('QMS-') or len(board_name) != 12:
|
||||
raise RuntimeError('Invalid board identity')
|
||||
finally:
|
||||
client.close()
|
||||
def adb(*command, timeout=120, quiet=False):
|
||||
try:
|
||||
result = subprocess.run([args.adb, '-s', device['serial'], *command], capture_output=True,
|
||||
text=True, encoding='utf-8', errors='replace', timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
# ADB's host timeout does not stop Android instrumentation itself.
|
||||
if command[:3] == ('shell', 'am', 'instrument'):
|
||||
subprocess.run([args.adb, '-s', device['serial'], 'shell', 'am', 'force-stop',
|
||||
'org.qimiaoscreen.controller'], capture_output=True, timeout=15)
|
||||
raise RuntimeError('ADB operation timed out; device identifiers omitted') from None
|
||||
output = result.stdout + result.stderr
|
||||
for secret, alias in ((device['serial'], '[test-phone]'), (board_name, '[test-board]'), (board_id, '[test-board-id]')):
|
||||
if secret: output = output.replace(secret, alias)
|
||||
if not quiet:
|
||||
print(output, flush=True)
|
||||
if result.returncode:
|
||||
raise RuntimeError('ADB command failed')
|
||||
return output
|
||||
api = int(adb('shell', 'getprop', 'ro.build.version.sdk', quiet=True).strip())
|
||||
permissions = ('BLUETOOTH_SCAN', 'BLUETOOTH_CONNECT') if api >= 31 else ('ACCESS_FINE_LOCATION',)
|
||||
permission_original = {}
|
||||
if args.scenario == 'navigation':
|
||||
existing = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True)
|
||||
permission_original = {p: 'android.permission.' + p + ': granted=true' in existing for p in permissions}
|
||||
layout_original = None
|
||||
layout_density = None
|
||||
try:
|
||||
for relative in ('androidApp/build/outputs/apk/debug/androidApp-debug.apk',
|
||||
'androidApp/build/outputs/apk/androidTest/debug/androidApp-debug-androidTest.apk'):
|
||||
apk = args.build / relative
|
||||
if not apk.is_file():
|
||||
raise RuntimeError('APK missing')
|
||||
if 'Success' not in adb('install', '-r', str(apk)):
|
||||
raise RuntimeError('APK installation did not succeed')
|
||||
adb('shell', 'am', 'force-stop', 'org.qimiaoscreen.controller')
|
||||
if args.grant_root and args.scenario != 'navigation':
|
||||
# Some vendor package managers asynchronously reset permissions after
|
||||
# install succeeds. Verify after a settling window, not just at grant.
|
||||
for attempt in range(3):
|
||||
for permission in permissions:
|
||||
adb('shell', 'su', '-c', 'pm grant org.qimiaoscreen.controller android.permission.' + permission)
|
||||
time.sleep(3)
|
||||
verified = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True)
|
||||
if all('android.permission.' + p + ': granted=true' in verified for p in permissions):
|
||||
break
|
||||
else:
|
||||
raise RuntimeError('Bluetooth permission did not remain granted after installation')
|
||||
if args.scenario == 'navigation':
|
||||
for permission in permissions:
|
||||
adb('shell', 'su', '-c', 'pm revoke org.qimiaoscreen.controller android.permission.' + permission, quiet=True)
|
||||
current = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True)
|
||||
if any('android.permission.' + p + ': granted=true' in current for p in permissions):
|
||||
raise RuntimeError('Navigation must not retain permission to auto-connect')
|
||||
if args.root_input:
|
||||
adb('push', str(Path(__file__).with_name('authorized_app_input.sh')), '/data/local/tmp/qms-authorized-input.sh', quiet=True)
|
||||
if args.scenario == "connection-entry":
|
||||
adb("push", str(Path(__file__).with_name("authorized_root_command.sh")), "/data/local/tmp/qms-authorized-root.sh", quiet=True)
|
||||
if args.scenario == "layout":
|
||||
adb("push", str(Path(__file__).with_name("authorized_root_command.sh")), "/data/local/tmp/qms-authorized-root.sh", quiet=True)
|
||||
def root(command):
|
||||
return adb("shell", "su", "-c", command, quiet=True).strip()
|
||||
import re
|
||||
layout_original = {key: root("settings get system " + key) for key in ("font_scale", "accelerometer_rotation", "user_rotation")}
|
||||
layout_density = re.search(r"Override density: (\d+)", root("wm density"))
|
||||
layout_density = layout_density[1] if layout_density else "reset"
|
||||
dimensions = re.findall(r"(\d+)x(\d+)", root("wm size"))[-1]
|
||||
narrow = min(map(int, dimensions)) * 160 // 340
|
||||
for font, rotation in (("1.0", "0"), ("1.3", "0"), ("1.3", "1")) if args.scenario == "layout" else ((None, None),):
|
||||
if layout_original:
|
||||
root("am force-stop org.qimiaoscreen.controller")
|
||||
root("wm density " + str(narrow))
|
||||
root("settings put system font_scale " + font)
|
||||
root("settings put system accelerometer_rotation 0")
|
||||
root("settings put system user_rotation " + rotation)
|
||||
root("input keyevent KEYCODE_WAKEUP")
|
||||
root("wm dismiss-keyguard")
|
||||
root("am start -W -n org.qimiaoscreen.controller/.MainActivity")
|
||||
board_args = ['-e', 'device_name', board_name] if board_name else []
|
||||
if library_hash: board_args += ['-e', 'library_order_hash', library_hash]
|
||||
output = adb('shell', 'am', 'instrument', '-w', '-r', *board_args,
|
||||
'-e', 'scenario', args.scenario,
|
||||
'-e', 'poll_cycles', str(args.poll_cycles),
|
||||
'-e', 'root_input', str(args.root_input).lower(),
|
||||
'-e', 'reapply_current_wifi', str(args.reapply_current_wifi).lower(),
|
||||
'-e', 'verify_current_controls', str(args.verify_current_controls).lower(),
|
||||
'-e', 'toggle_bluetooth', str(args.toggle_bluetooth).lower(),
|
||||
'-e', 'class', 'org.qimiaoscreen.controller.' + ('LibraryCacheTest' if args.scenario == 'cache-storage' else
|
||||
'LibraryBleCacheTest' if args.scenario == 'library-cache' else
|
||||
{'ble': 'BleIntegrationTest', 'activity': 'AppLifecycleTest', 'layout': 'LayoutTest'}[args.test]),
|
||||
'org.qimiaoscreen.controller.test/androidx.test.runner.AndroidJUnitRunner', timeout=360)
|
||||
if not passed(output):
|
||||
raise RuntimeError("Instrumentation did not pass")
|
||||
if layout_original:
|
||||
print("Layout passed: font=" + font + ", rotation=" + rotation)
|
||||
finally:
|
||||
errors = []
|
||||
def cleanup(label, action):
|
||||
try: action()
|
||||
except Exception: errors.append(label)
|
||||
cleanup('stop-test', lambda: adb('shell', 'am', 'force-stop', 'org.qimiaoscreen.controller', quiet=True))
|
||||
if layout_original and layout_density is not None:
|
||||
cleanup('stop', lambda: root('am force-stop org.qimiaoscreen.controller'))
|
||||
cleanup('density', lambda: root('wm density ' + layout_density))
|
||||
def verify_density():
|
||||
value = re.search(r'Override density: (\d+)', root('wm density'))
|
||||
if (value[1] if value else 'reset') != layout_density:
|
||||
raise RuntimeError('density restore failed')
|
||||
cleanup('density verification', verify_density)
|
||||
for key, value in layout_original.items():
|
||||
def restore(key=key, value=value):
|
||||
root('settings delete system ' + key if value == 'null' else 'settings put system ' + key + ' ' + value)
|
||||
if root('settings get system ' + key) != value: raise RuntimeError('settings restore failed')
|
||||
cleanup(key, restore)
|
||||
for permission, granted in permission_original.items():
|
||||
def restore_permission(permission=permission, granted=granted):
|
||||
adb('shell', 'su', '-c', 'pm ' + ('grant' if granted else 'revoke') + ' org.qimiaoscreen.controller android.permission.' + permission, quiet=True)
|
||||
actual = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True)
|
||||
if ('android.permission.' + permission + ': granted=true' in actual) != granted:
|
||||
raise RuntimeError('Permission restore failed')
|
||||
cleanup('permission ' + permission, restore_permission)
|
||||
if args.toggle_bluetooth:
|
||||
cleanup('bluetooth', lambda: adb('shell', 'su', '-c', 'cmd bluetooth_manager enable', quiet=True))
|
||||
for helper in ('qms-authorized-root.sh', 'qms-authorized-input.sh'):
|
||||
cleanup(helper, lambda helper=helper: adb('shell', 'rm', '-f', '/data/local/tmp/' + helper, quiet=True))
|
||||
if errors:
|
||||
raise RuntimeError('Cleanup failed: ' + ', '.join(errors))
|
||||
print('Cleanup verified', flush=True)
|
||||
if not passed(output):
|
||||
raise RuntimeError('BLE instrumentation did not pass')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,154 +1,68 @@
|
||||
"""Run registered BLE tests; WiFi reapplication requires its explicit option."""
|
||||
"""Explicit, side-effect-free planning followed by selected registered-device scenarios."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / '测试设备登记'))
|
||||
from prepare_test_device import load_registration, check_connected
|
||||
from remote_support import connect, execute
|
||||
# name: (instrumentation family, board required, side effect)
|
||||
SCENARIOS = {
|
||||
'connection': ('ble', True, 'scan, handshake, disconnect and reconnect'),
|
||||
'data': ('ble', True, 'read status/settings/storage/library/frame/wifi'),
|
||||
'library-order': ('ble', True, 'read BLE pages and board order digest'),
|
||||
'library-cache': ('ble', True, 'read two image tiers, then verify unchanged reconnect reuses app cache'),
|
||||
'cache-storage': ('ble', False, 'verify private image cache isolation, corruption handling and clear'),
|
||||
'conflict': ('ble', True, 'submit deliberately stale revision; expect rejection'),
|
||||
'polling': ('ble', True, 'up to 60 status/frame polls'),
|
||||
'controller-background': ('ble', True, 'controller background release and reconnect'),
|
||||
'wifi-scan': ('ble', True, 'scan board WiFi without switching'),
|
||||
'controls': ('ble', True, 'write current values; temporarily pause active animation and restore'),
|
||||
'wifi-reapply': ('ble', True, 'reapply current network; brief network interruption'),
|
||||
'navigation': ('activity', False, 'navigate disconnected App and menus; no board access'),
|
||||
'activity-background': ('activity', True, 'Home, background release and reconnect'),
|
||||
'rename': ('activity', True, 'rename device then restore name'),
|
||||
'remember': ('activity', True, 'forget and reconnect test device'),
|
||||
'bluetooth-toggle': ('activity', True, 'disable and restore phone Bluetooth'),
|
||||
'connection-entry': ('layout', True, 'connected entry, rescan and reconnect; layout restore'),
|
||||
'layout': ('layout', True, 'three layout configurations and frame bounds; layout restore'),
|
||||
}
|
||||
COMMON = ('connection', 'data', 'library-order', 'navigation')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--adb', required=True)
|
||||
parser.add_argument('--build', type=Path, required=True)
|
||||
parser.add_argument('--poll-cycles', type=int, default=60)
|
||||
parser.add_argument('--test', choices=('ble', 'activity', 'layout'), default='ble')
|
||||
parser.add_argument('--root-input', action='store_true', help='Compatibility flag; activity tests always use authorized root input')
|
||||
parser.add_argument('--reapply-current-wifi', action='store_true', help='Explicitly reapply the already active saved WiFi profile over BLE; may briefly reconnect WiFi')
|
||||
parser.add_argument('--verify-current-controls', action='store_true', help='Reapply current brightness; briefly pause active animation and restore without selecting new content')
|
||||
parser.add_argument('--toggle-bluetooth', action='store_true', help='Requires explicit root Bluetooth-switch authorization plus activity root input')
|
||||
parser.add_argument('--grant-root', action='store_true', help='Compatibility flag; runner always verifies and grants authorized App permissions')
|
||||
args = parser.parse_args()
|
||||
args.grant_root = True
|
||||
if args.test in ("activity", "layout"): args.root_input = True
|
||||
if not 0 <= args.poll_cycles <= 60:
|
||||
parser.error('poll-cycles must be between 0 and 60')
|
||||
if args.root_input and args.test == 'ble':
|
||||
parser.error('root-input only applies to activity tests')
|
||||
if args.reapply_current_wifi and args.test != 'ble':
|
||||
parser.error('reapply-current-wifi only applies to BLE tests')
|
||||
if args.verify_current_controls and args.test != 'ble':
|
||||
parser.error('verify-current-controls only applies to BLE tests')
|
||||
if args.toggle_bluetooth and not (args.test == 'activity' and args.root_input):
|
||||
parser.error('toggle-bluetooth requires activity and root-input')
|
||||
device = next(d for d in load_registration() if d['alias'] == 'android-test-a')
|
||||
check_connected(args.adb, device)
|
||||
root_check = subprocess.run([args.adb, '-s', device['serial'], 'shell', 'su', '-c', 'id -u'], capture_output=True, text=True, timeout=15)
|
||||
if root_check.returncode or root_check.stdout.strip() != '0':
|
||||
raise RuntimeError('测试手机 root 权限不可用;停止测试,等待用户处理,不使用备用注入方式')
|
||||
client, fields = connect()
|
||||
try:
|
||||
code, output, error = execute(client, "python3 -c \"import json; p=json.load(open('/var/lib/matrix-screen-controller/mobile/identity.json')); print(json.dumps({'device_id':p['device_id']}))\"", password=fields['密码'])
|
||||
if code:
|
||||
raise RuntimeError('Cannot read authorized board identity')
|
||||
board_id = json.loads(output)['device_id']
|
||||
from uuid import UUID
|
||||
UUID(board_id)
|
||||
board_name = 'QMS-' + board_id.replace('-', '')[:8]
|
||||
code, output, _ = execute(client, "python3 -c \"import urllib.request,json,hashlib; d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/library/order',timeout=5)); print(hashlib.sha256('\\n'.join(i['type']+'|'+i['id'] for i in d['items']).encode()).hexdigest())\"", password=fields['密码'])
|
||||
library_hash = output.strip()
|
||||
if code or len(library_hash) != 64 or any(c not in '0123456789abcdef' for c in library_hash):
|
||||
raise RuntimeError('Cannot read device library order digest')
|
||||
if not board_name.startswith('QMS-') or len(board_name) != 12:
|
||||
raise RuntimeError('Invalid board identity')
|
||||
finally:
|
||||
client.close()
|
||||
def adb(*command, timeout=120, quiet=False):
|
||||
try:
|
||||
result = subprocess.run([args.adb, '-s', device['serial'], *command], capture_output=True,
|
||||
text=True, encoding='utf-8', errors='replace', timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
# ADB's host timeout does not stop Android instrumentation itself.
|
||||
if command[:3] == ('shell', 'am', 'instrument'):
|
||||
subprocess.run([args.adb, '-s', device['serial'], 'shell', 'am', 'force-stop',
|
||||
'org.qimiaoscreen.controller'], capture_output=True, timeout=15)
|
||||
raise RuntimeError('ADB operation timed out; device identifiers omitted') from None
|
||||
output = (result.stdout + result.stderr).replace(device['serial'], '[test-phone]').replace(board_name, '[test-board]').replace(board_id, '[test-board-id]')
|
||||
if not quiet:
|
||||
print(output, flush=True)
|
||||
if result.returncode:
|
||||
raise RuntimeError('ADB command failed')
|
||||
return output
|
||||
for relative in ('androidApp/build/outputs/apk/debug/androidApp-debug.apk',
|
||||
'androidApp/build/outputs/apk/androidTest/debug/androidApp-debug-androidTest.apk'):
|
||||
apk = args.build / relative
|
||||
if not apk.is_file():
|
||||
raise RuntimeError('APK missing')
|
||||
adb('install', '-r', str(apk))
|
||||
adb('shell', 'am', 'force-stop', 'org.qimiaoscreen.controller')
|
||||
if args.grant_root:
|
||||
# Some vendor package managers asynchronously reset permissions after
|
||||
# install succeeds. Verify after a settling window, not just at grant.
|
||||
for attempt in range(3):
|
||||
for permission in ('BLUETOOTH_SCAN', 'BLUETOOTH_CONNECT'):
|
||||
adb('shell', 'su', '-c', 'pm grant org.qimiaoscreen.controller android.permission.' + permission)
|
||||
time.sleep(3)
|
||||
verified = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True)
|
||||
if all('android.permission.' + p + ': granted=true' in verified for p in ('BLUETOOTH_SCAN', 'BLUETOOTH_CONNECT')):
|
||||
break
|
||||
else:
|
||||
raise RuntimeError('Bluetooth permission did not remain granted after installation')
|
||||
if args.root_input:
|
||||
adb('push', str(Path(__file__).with_name('authorized_app_input.sh')), '/data/local/tmp/qms-authorized-input.sh', quiet=True)
|
||||
layout_original = None
|
||||
if args.test == "layout":
|
||||
adb("push", str(Path(__file__).with_name("authorized_root_command.sh")), "/data/local/tmp/qms-authorized-root.sh", quiet=True)
|
||||
def root(command):
|
||||
return adb("shell", "su", "-c", command, quiet=True).strip()
|
||||
import re
|
||||
layout_original = {key: root("settings get system " + key) for key in ("font_scale", "accelerometer_rotation", "user_rotation")}
|
||||
layout_density = re.search(r"Override density: (\d+)", root("wm density"))
|
||||
layout_density = layout_density[1] if layout_density else "reset"
|
||||
dimensions = re.findall(r"(\d+)x(\d+)", root("wm size"))[-1]
|
||||
narrow = min(map(int, dimensions)) * 160 // 340
|
||||
try:
|
||||
for font, rotation in (("1.0", "0"), ("1.3", "0"), ("1.3", "1")) if args.test == "layout" else ((None, None),):
|
||||
if layout_original:
|
||||
root("am force-stop org.qimiaoscreen.controller")
|
||||
root("wm density " + str(narrow))
|
||||
root("settings put system font_scale " + font)
|
||||
root("settings put system accelerometer_rotation 0")
|
||||
root("settings put system user_rotation " + rotation)
|
||||
root("input keyevent KEYCODE_WAKEUP")
|
||||
root("wm dismiss-keyguard")
|
||||
root("am start -W -n org.qimiaoscreen.controller/.MainActivity")
|
||||
output = adb('shell', 'am', 'instrument', '-w', '-r', '-e', 'device_name', board_name,
|
||||
'-e', 'poll_cycles', str(args.poll_cycles),
|
||||
'-e', 'library_order_hash', library_hash,
|
||||
'-e', 'root_input', str(args.root_input).lower(),
|
||||
'-e', 'reapply_current_wifi', str(args.reapply_current_wifi).lower(),
|
||||
'-e', 'verify_current_controls', str(args.verify_current_controls).lower(),
|
||||
'-e', 'toggle_bluetooth', str(args.toggle_bluetooth).lower(),
|
||||
'-e', 'class', 'org.qimiaoscreen.controller.' + {'ble': 'BleIntegrationTest', 'activity': 'AppLifecycleTest', 'layout': 'LayoutTest'}[args.test],
|
||||
'org.qimiaoscreen.controller.test/androidx.test.runner.AndroidJUnitRunner', timeout=360)
|
||||
if "OK (1 test)" not in output:
|
||||
raise RuntimeError("Instrumentation did not pass")
|
||||
if layout_original:
|
||||
print("Layout passed: font=" + font + ", rotation=" + rotation)
|
||||
finally:
|
||||
if layout_original:
|
||||
root("am force-stop org.qimiaoscreen.controller")
|
||||
root("wm density " + layout_density)
|
||||
restored_density = re.search(r"Override density: (\d+)", root("wm density"))
|
||||
restored_density = restored_density[1] if restored_density else "reset"
|
||||
if restored_density != layout_density: raise RuntimeError("Layout density restore failed")
|
||||
for key, value in layout_original.items():
|
||||
root("settings delete system " + key if value == "null" else "settings put system " + key + " " + value)
|
||||
if root("settings get system " + key) != value: raise RuntimeError("Layout settings restore failed")
|
||||
print("Layout settings restored and verified", flush=True)
|
||||
if args.test == "layout":
|
||||
adb("shell", "rm", "-f", "/data/local/tmp/qms-authorized-root.sh", quiet=True)
|
||||
if args.toggle_bluetooth:
|
||||
adb('shell', 'su', '-c', 'cmd bluetooth_manager enable', quiet=True)
|
||||
if args.root_input:
|
||||
adb('shell', 'rm', '-f', '/data/local/tmp/qms-authorized-input.sh', quiet=True)
|
||||
if 'OK (1 test)' not in output:
|
||||
raise RuntimeError('BLE instrumentation did not pass')
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--list-scenarios', action='store_true')
|
||||
p.add_argument('--dry-run', action='store_true')
|
||||
choose = p.add_mutually_exclusive_group()
|
||||
choose.add_argument('--scenario', action='append', choices=SCENARIOS)
|
||||
choose.add_argument('--full-common', action='store_true', help='Explicit common regression; excludes mutation, long polling and recovery tests')
|
||||
p.add_argument('--adb')
|
||||
p.add_argument('--build', type=Path)
|
||||
p.add_argument('--poll-cycles', type=int, default=60)
|
||||
args = p.parse_args(argv)
|
||||
if args.list_scenarios:
|
||||
for name, (family, board, effect) in SCENARIOS.items():
|
||||
print(f'{name}: family={family}; board={board}; {effect}')
|
||||
return 0
|
||||
selected = list(COMMON) if args.full_common else list(dict.fromkeys(args.scenario or []))
|
||||
if not selected:
|
||||
p.print_help(); return 2
|
||||
if not 1 <= args.poll_cycles <= 60: p.error('poll-cycles must be 1..60')
|
||||
plan = [{'scenario': n, 'phone': 'registered root phone', 'board': SCENARIOS[n][1], 'effect': SCENARIOS[n][2]} for n in selected]
|
||||
print(json.dumps({'selected': plan, 'unselected': [n for n in SCENARIOS if n not in selected]}, ensure_ascii=False), flush=True)
|
||||
if args.dry_run: return 0
|
||||
if not args.adb or not args.build: p.error('--adb and --build required for execution')
|
||||
from device_test_runner import main as execute
|
||||
for name in selected:
|
||||
flags = ['--adb', args.adb, '--build', str(args.build), '--scenario', name,
|
||||
'--test', SCENARIOS[name][0], '--poll-cycles', str(args.poll_cycles if name == 'polling' else 0)]
|
||||
flags += {'controls': ['--verify-current-controls'], 'wifi-reapply': ['--reapply-current-wifi'],
|
||||
'bluetooth-toggle': ['--toggle-bluetooth']}.get(name, [])
|
||||
try: execute(flags)
|
||||
except Exception:
|
||||
print(json.dumps({'scenario': name, 'result': 'failed', 'remaining': 'not run'}), flush=True)
|
||||
raise
|
||||
print(json.dumps({'scenario': name, 'result': 'passed'}), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Run selected isolated Compose UI tests on an explicitly named Android Studio AVD."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
SCENARIOS = ('connection', 'status', 'content', 'settings', 'navigation', 'layout')
|
||||
CONFIGS = {'baseline': (None, '1.0', '0'), 'narrow': (340, '1.0', '0'),
|
||||
'large-font': (340, '1.3', '0'), 'landscape': (None, '1.3', '1')}
|
||||
PACKAGE = 'org.qimiaoscreen.uitest'
|
||||
|
||||
|
||||
def passed(output):
|
||||
return bool(re.search(r'^OK \(1 test\)\s*$', output, re.M)) and not any(
|
||||
marker in output for marker in ('FAILURES!!!', 'INSTRUMENTATION_FAILED', 'Process crashed'))
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--list-scenarios', action='store_true')
|
||||
p.add_argument('--dry-run', action='store_true')
|
||||
choice = p.add_mutually_exclusive_group()
|
||||
choice.add_argument('--scenario', action='append', choices=SCENARIOS)
|
||||
choice.add_argument('--all-ui', action='store_true')
|
||||
p.add_argument('--configuration', action='append', choices=CONFIGS)
|
||||
p.add_argument('--avd')
|
||||
p.add_argument('--adb')
|
||||
p.add_argument('--emulator')
|
||||
p.add_argument('--sysdir', type=Path, help='Optional system image directory for a separately stored SDK image')
|
||||
p.add_argument('--port', type=int, default=5580)
|
||||
p.add_argument('--build', type=Path)
|
||||
p.add_argument('--report', type=Path)
|
||||
args = p.parse_args(argv)
|
||||
if args.list_scenarios:
|
||||
print(json.dumps({'scenarios': SCENARIOS, 'configurations': list(CONFIGS)})); return 0
|
||||
selected = list(SCENARIOS) if args.all_ui else list(dict.fromkeys(args.scenario or []))
|
||||
if not selected: p.print_help(); return 2
|
||||
configs = list(dict.fromkeys(args.configuration or ['baseline']))
|
||||
plan = [{'scenario': s, 'configuration': c, 'result': 'not run'} for c in configs for s in selected]
|
||||
print(json.dumps({'selected': plan, 'physical_phone': False, 'board': False, 'root': False}), flush=True)
|
||||
if args.dry_run: return 0
|
||||
if not args.adb or not args.avd or not args.build: p.error('--adb, --avd and --build required')
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.-]+', args.avd): p.error('Invalid AVD name')
|
||||
if args.port % 2 or not 5554 <= args.port <= 5682: p.error('Expected an even emulator console port')
|
||||
apk = args.build / 'uiTest/build/outputs/apk/debug/uiTest-debug.apk'
|
||||
if not apk.is_file(): p.error('Build :uiTest:assembleDebug first')
|
||||
if args.report and args.report.exists(): p.error('Report already exists; choose a new path')
|
||||
serial = 'emulator-' + str(args.port)
|
||||
started = False
|
||||
verified_target = False
|
||||
original = {}
|
||||
cleanup_errors = []
|
||||
process = None
|
||||
def adb(*command, timeout=30):
|
||||
try:
|
||||
result = subprocess.run([args.adb, '-s', serial, *command], capture_output=True,
|
||||
text=True, encoding='utf-8', errors='replace', timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError('Emulator operation timed out') from None
|
||||
if result.returncode: raise RuntimeError('Emulator operation failed')
|
||||
return result.stdout + result.stderr
|
||||
def cleanup(name, action):
|
||||
try: action()
|
||||
except Exception: cleanup_errors.append(name)
|
||||
def restore_setting(key, value):
|
||||
adb('shell', 'settings', 'delete', 'system', key) if value == 'null' else adb('shell', 'settings', 'put', 'system', key, value)
|
||||
if adb('shell', 'settings', 'get', 'system', key).strip() != value: raise RuntimeError('Restore failed')
|
||||
failure = None
|
||||
try:
|
||||
# An occupied port must not cause selection of another device.
|
||||
state = subprocess.run([args.adb, '-s', serial, 'get-state'], capture_output=True, timeout=15)
|
||||
if state.returncode:
|
||||
if not args.emulator: raise RuntimeError('AVD is not running; supply --emulator to start it')
|
||||
available = subprocess.run([args.emulator, '-list-avds'], capture_output=True, text=True, timeout=20)
|
||||
if args.avd not in available.stdout.splitlines(): raise RuntimeError('Named AVD does not exist; create it in Android Studio Device Manager')
|
||||
command = [args.emulator, '-avd', args.avd, '-port', str(args.port), '-no-window', '-no-audio', '-no-snapshot', '-no-boot-anim']
|
||||
if args.sysdir: command += ['-sysdir', str(args.sysdir)]
|
||||
process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
|
||||
started = True
|
||||
deadline = time.monotonic() + 180
|
||||
while True:
|
||||
if process is not None and process.poll() is not None: raise RuntimeError('Emulator exited before boot')
|
||||
try:
|
||||
if adb('shell', 'getprop', 'sys.boot_completed').strip() == '1': break
|
||||
except RuntimeError: pass
|
||||
if time.monotonic() >= deadline: raise RuntimeError('AVD boot timeout')
|
||||
time.sleep(2)
|
||||
if adb('shell', 'getprop', 'ro.kernel.qemu').strip() != '1': raise RuntimeError('Refusing a physical device')
|
||||
if adb('emu', 'avd', 'name').splitlines()[0].strip() != args.avd: raise RuntimeError('AVD name does not match requested target')
|
||||
verified_target = True
|
||||
original = {k: adb('shell', 'settings', 'get', 'system', k).strip()
|
||||
for k in ('font_scale', 'accelerometer_rotation', 'user_rotation')}
|
||||
density = re.search(r'Override density: (\d+)', adb('shell', 'wm', 'density'))
|
||||
original['density'] = density[1] if density else 'reset'
|
||||
dims = re.findall(r'(\d+)x(\d+)', adb('shell', 'wm', 'size'))[-1]
|
||||
result = adb('install', '-r', str(apk), timeout=120)
|
||||
if 'Success' not in result: raise RuntimeError('Test APK installation failed')
|
||||
for item in plan:
|
||||
name, config = item['scenario'], item['configuration']
|
||||
width, font, rotation = CONFIGS[config]
|
||||
adb('shell', 'am', 'force-stop', PACKAGE)
|
||||
adb('shell', 'wm', 'density', str(min(map(int, dims)) * 160 // width) if width else original['density'])
|
||||
for key, value in [('font_scale', font), ('accelerometer_rotation', '0'), ('user_rotation', rotation)]:
|
||||
adb('shell', 'settings', 'put', 'system', key, value)
|
||||
try:
|
||||
output = adb('shell', 'am', 'instrument', '-w', '-r', '-e', 'class',
|
||||
PACKAGE + '.IsolatedUiTest#' + name,
|
||||
PACKAGE + '/androidx.test.runner.AndroidJUnitRunner', timeout=120)
|
||||
if not passed(output):
|
||||
# Isolated fixtures contain no private data. Keep assertion diagnostics for repair.
|
||||
item["diagnostic"] = output[:2500]
|
||||
print(output[:2500] + ("\n[remaining diagnostics omitted]" if len(output) > 2500 else ""), flush=True)
|
||||
raise RuntimeError('Selected Compose scenario failed')
|
||||
item['result'] = 'passed'
|
||||
except Exception:
|
||||
item['result'] = 'failed'; raise
|
||||
print(json.dumps(item), flush=True)
|
||||
except Exception as e:
|
||||
failure = str(e)
|
||||
finally:
|
||||
if original:
|
||||
cleanup('stop-test', lambda: adb('shell', 'am', 'force-stop', PACKAGE))
|
||||
for key, value in original.items():
|
||||
if key != 'density': cleanup(key, lambda k=key, v=value: restore_setting(k, v))
|
||||
def density_restore():
|
||||
adb('shell', 'wm', 'density', original['density'])
|
||||
current = re.search(r'Override density: (\d+)', adb('shell', 'wm', 'density'))
|
||||
if (current[1] if current else 'reset') != original['density']: raise RuntimeError('Density restore failed')
|
||||
cleanup('density', density_restore)
|
||||
if started and process is not None and process.poll() is None:
|
||||
if verified_target:
|
||||
cleanup('stop-owned-emulator', lambda: adb('emu', 'kill'))
|
||||
else:
|
||||
process.terminate()
|
||||
if process is not None:
|
||||
try: process.wait(timeout=20)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.terminate(); cleanup_errors.append('emulator-exit')
|
||||
report = {'tests': plan, 'failure': failure, 'cleanup_errors': cleanup_errors,
|
||||
'not_selected': [s for s in SCENARIOS if s not in selected]}
|
||||
if args.report:
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
with args.report.open('x', encoding='utf-8') as f: json.dump(report, f, ensure_ascii=False, indent=2)
|
||||
print(json.dumps(report, ensure_ascii=False), flush=True)
|
||||
return 1 if failure or cleanup_errors else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -1,18 +1,5 @@
|
||||
"""Use the persistent Android semantic test with root input and restored settings."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import subprocess
|
||||
"""Compatibility entry; explicitly select layout or connection-entry."""
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--adb', required=True)
|
||||
parser.add_argument('--build', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
return subprocess.run([sys.executable, str(Path(__file__).with_name('run_ble_test.py')),
|
||||
'--adb', args.adb, '--build', str(args.build), '--test', 'layout']).returncode
|
||||
|
||||
|
||||
from run_ble_test import main
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
|
||||
@@ -18,7 +18,7 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from remote_support import connect, execute
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / "测试设备登记"))
|
||||
from prepare_test_device import check_connected, load_registration
|
||||
@@ -259,8 +259,10 @@ def wifi_snapshot(client, fields: dict) -> dict:
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--adb", type=Path, required=True)
|
||||
parser.add_argument("--build", type=Path, required=True)
|
||||
parser.add_argument("--adb", type=Path)
|
||||
parser.add_argument("--list-scenarios", action="store_true")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--build", type=Path)
|
||||
parser.add_argument("--resume", action="store_true", help="Continue after the saved recovery profile is active on the computer hotspot")
|
||||
parser.add_argument("--root-swipe", action="store_true", help="Requires explicit root permission for scrolling only the foreground App settings list")
|
||||
parser.add_argument("--skip-wrong", action="store_true", help="Use only when the wrong-password branch has been separately observed and archived")
|
||||
@@ -268,6 +270,19 @@ def main() -> int:
|
||||
parser.add_argument("--validate-only", action="store_true", help="Verify form and invalid IPv4 without switching networks")
|
||||
parser.add_argument("--final-check", action="store_true", help="Verify App, BLE, board, and recovery cleanup on the original network")
|
||||
args = parser.parse_args()
|
||||
modes = ['validate_only', 'wrong_only', 'skip_wrong', 'final_check']
|
||||
if args.list_scenarios:
|
||||
print('validate-only: form only; wrong-only: failed activation with timed restore; skip-wrong: cross-network DHCP/static roundtrip; final-check: restored state checks')
|
||||
return 0
|
||||
selected = [m for m in modes if getattr(args, m)]
|
||||
if len(selected) != 1: parser.error('Select exactly one explicit WiFi mode')
|
||||
if args.resume and not args.skip_wrong: parser.error('--resume requires --skip-wrong')
|
||||
if args.dry_run:
|
||||
print(json.dumps({'scenario': selected[0], 'root_phone': True, 'board': True, 'network_change': selected[0] in ('wrong_only', 'skip_wrong')}))
|
||||
return 0
|
||||
if not args.adb or not args.build: parser.error('--adb and --build required')
|
||||
global connect, execute
|
||||
from remote_support import connect, execute
|
||||
args.root_swipe = True
|
||||
if not (args.wrong_only or args.skip_wrong or args.resume or args.validate_only or args.final_check):
|
||||
raise RuntimeError("请选择 --validate-only、--wrong-only、--final-check 或已完成错误密码单测后的 --skip-wrong")
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Host-only safety tests: selection must never imply device or network access."""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import run_ble_test as device
|
||||
import run_emulator_ui_test as emulator
|
||||
|
||||
class SelectionTests(unittest.TestCase):
|
||||
def quiet(self): return contextlib.redirect_stdout(io.StringIO())
|
||||
def test_previews_need_no_device_or_dependencies(self):
|
||||
with patch('subprocess.run', side_effect=AssertionError('unexpected device access')), self.quiet():
|
||||
for runner, name in ((device, 'navigation'), (emulator, 'settings')):
|
||||
self.assertEqual(runner.main(['--list-scenarios']), 0)
|
||||
self.assertEqual(runner.main(['--scenario', name, '--dry-run']), 0)
|
||||
self.assertEqual(runner.main([]), 2)
|
||||
def test_one_scenario_cannot_expand_to_ble_or_network(self):
|
||||
calls = []
|
||||
fake = types.SimpleNamespace(main=lambda args: calls.append(args))
|
||||
with patch.dict(sys.modules, {'device_test_runner': fake}), self.quiet():
|
||||
self.assertEqual(device.main(['--scenario', 'navigation', '--adb', 'fake', '--build', 'fake']), 0)
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(calls[0][calls[0].index('--scenario') + 1], 'navigation')
|
||||
self.assertNotIn('--reapply-current-wifi', calls[0])
|
||||
self.assertEqual(calls[0][calls[0].index('--poll-cycles') + 1], '0')
|
||||
def test_failure_stops_later_scenarios(self):
|
||||
calls = []
|
||||
def fail(args):
|
||||
calls.append(args); raise RuntimeError('expected')
|
||||
with patch.dict(sys.modules, {'device_test_runner': types.SimpleNamespace(main=fail)}), self.quiet():
|
||||
with self.assertRaises(RuntimeError):
|
||||
device.main(['--scenario', 'connection', '--scenario', 'data', '--adb', 'fake', '--build', 'fake'])
|
||||
self.assertEqual(len(calls), 1)
|
||||
def test_common_does_not_include_disruptive_scenarios(self):
|
||||
self.assertFalse(set(device.COMMON) & {'wifi-reapply', 'controls', 'polling', 'bluetooth-toggle', 'rename', 'remember'})
|
||||
def test_zero_tests_or_crash_is_not_success(self):
|
||||
self.assertTrue(emulator.passed('OK (1 test)\n'))
|
||||
for text in ('OK (0 tests)', 'OK (2 tests)', 'FAILURES!!!\nOK (1 test)', 'Process crashed\nOK (1 test)'):
|
||||
self.assertFalse(emulator.passed(text))
|
||||
def test_failed_instrumentation_restores_each_setting_and_records_failure(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
b = Path(tmp); apk = b / 'uiTest/build/outputs/apk/debug/uiTest-debug.apk'
|
||||
apk.parent.mkdir(parents=True); apk.write_bytes(b'fixture')
|
||||
state = {'font_scale': '1.0', 'accelerometer_rotation': '1', 'user_rotation': '0'}
|
||||
history = []
|
||||
def run(cmd, **kwargs):
|
||||
history.append(cmd)
|
||||
args = cmd[3:]
|
||||
out = ''
|
||||
if args == ['get-state']: out = 'device'
|
||||
elif args[:2] == ['shell', 'getprop']: out = '1'
|
||||
elif args == ['emu', 'avd', 'name']: out = 'fixture\nOK'
|
||||
elif args[:3] == ['shell', 'settings', 'get']: out = state[args[4]]
|
||||
elif args[:3] == ['shell', 'settings', 'put']: state[args[4]] = args[5]
|
||||
elif args == ['shell', 'wm', 'size']: out = 'Physical size: 1080x1920'
|
||||
elif args == ['shell', 'wm', 'density']: out = 'Physical density: 420'
|
||||
elif args[:1] == ['install']: out = 'Success'
|
||||
elif args[:3] == ['shell', 'am', 'instrument']: raise subprocess.TimeoutExpired(cmd, 120)
|
||||
return subprocess.CompletedProcess(cmd, 0, out, '')
|
||||
report = b / 'report.json'
|
||||
with patch('subprocess.run', side_effect=run), self.quiet():
|
||||
result = emulator.main(['--scenario', 'navigation', '--avd', 'fixture', '--adb', 'fake', '--build', str(b), '--report', str(report)])
|
||||
self.assertEqual(result, 1)
|
||||
self.assertEqual(state, {'font_scale': '1.0', 'accelerometer_rotation': '1', 'user_rotation': '0'})
|
||||
evidence = json.loads(report.read_text(encoding='utf-8'))
|
||||
self.assertEqual(evidence['tests'][0]['result'], 'failed')
|
||||
self.assertIn('timed out', evidence['failure'])
|
||||
self.assertFalse(evidence['cleanup_errors'])
|
||||
self.assertTrue(any(cmd[3:6] == ['shell', 'am', 'force-stop'] for cmd in history))
|
||||
def test_wifi_modes_are_explicit_and_dry_run_has_no_remote_import(self):
|
||||
import run_wifi_switch_test as wifi
|
||||
for argv in (['run_wifi_switch_test.py', '--list-scenarios'],
|
||||
['run_wifi_switch_test.py', '--validate-only', '--dry-run']):
|
||||
with patch.object(sys, 'argv', argv), patch('subprocess.run', side_effect=AssertionError('unexpected access')), patch.dict(sys.modules, {'remote_support': None}), self.quiet():
|
||||
self.assertEqual(wifi.main(), 0)
|
||||
for argv in (['run_wifi_switch_test.py'], ['run_wifi_switch_test.py', '--wrong-only', '--skip-wrong', '--dry-run']):
|
||||
with patch.object(sys, 'argv', argv), self.quiet(), contextlib.redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit): wifi.main()
|
||||
|
||||
def test_real_navigation_adapter_has_no_board_access_and_restores_permissions(self):
|
||||
import device_test_runner as runner
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base = Path(tmp)
|
||||
for name in ('debug/androidApp-debug.apk', 'androidTest/debug/androidApp-debug-androidTest.apk'):
|
||||
apk = base / 'androidApp/build/outputs/apk' / name
|
||||
apk.parent.mkdir(parents=True, exist_ok=True); apk.write_bytes(b'fixture')
|
||||
grants = {'BLUETOOTH_SCAN': True, 'BLUETOOTH_CONNECT': False}
|
||||
calls = []
|
||||
def execute(command, **kwargs):
|
||||
calls.append(command)
|
||||
args = command[3:]; out = ''
|
||||
if args == ['shell', 'su', '-c', 'id -u']: out = '0'
|
||||
elif args == ['shell', 'getprop', 'ro.build.version.sdk']: out = '35'
|
||||
elif args == ['shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller']:
|
||||
out = '\n'.join('android.permission.' + k + ': granted=' + str(v).lower() for k, v in grants.items())
|
||||
elif args[:3] == ['shell', 'su', '-c'] and args[3].startswith('pm '):
|
||||
operation = args[3].split(); grants[operation[-1].split('.')[-1]] = operation[1] == 'grant'
|
||||
elif args[:3] == ['shell', 'am', 'instrument']:
|
||||
self.assertFalse(any(grants.values()))
|
||||
self.assertNotIn('device_name', args)
|
||||
self.assertNotIn('library_order_hash', args)
|
||||
out = 'OK (1 test)\n'
|
||||
elif args[:1] == ['install']: out = 'Success'
|
||||
return subprocess.CompletedProcess(command, 0, out, '')
|
||||
with patch.object(runner, 'load_registration', return_value=[{'alias': 'android-test-a', 'serial': 'fixture-serial'}]), patch.object(runner, 'check_connected'), patch('subprocess.run', side_effect=execute), patch.dict(sys.modules, {'remote_support': None}), self.quiet():
|
||||
runner.main(['--scenario', 'navigation', '--test', 'activity', '--poll-cycles', '0', '--adb', 'fake', '--build', str(base)])
|
||||
self.assertEqual(grants, {'BLUETOOTH_SCAN': True, 'BLUETOOTH_CONNECT': False})
|
||||
self.assertTrue(any(cmd[3:6] == ['shell', 'am', 'force-stop'] for cmd in calls))
|
||||
|
||||
def test_invalid_scenario_rejected_before_any_io(self):
|
||||
with patch('subprocess.run', side_effect=AssertionError('unexpected access')), self.quiet(), contextlib.redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit): device.main(['--scenario', 'everything'])
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -69,3 +69,19 @@ Compose 的禁用语义可能位于父节点,文字节点自身仍 enabled。
|
||||
滑动后通过两次间隔400毫秒的实时边界相等检查确认目标停稳(最多5秒),然后仅发送一次 root 点击;不因未看到中间状态而重复点击连接。记录初次连接、连接中扫描、两处预览、条目断开、手动重连等阶段。短暂“连接中”未被观测不代表未发起请求,终态以认证连接和条目状态核验为准。
|
||||
|
||||
本轮失败、恢复与最终结果见 `../测试结果归档/2026-09-27_界面细节优化/`;重测通过不证明此前连接未出现的唯一根因。字体、旋转和密度恢复后均回读核验。
|
||||
|
||||
## 13. 按影响选测与独立模拟器(2026-09-29)
|
||||
|
||||
- 先读 [按影响选测](按影响选测.md),选中场景后才执行环境门禁。不能因为手机登记/网络不可用而阻塞不依赖它们的 JVM、模拟器或文档检查。需要真机但环境不可用时仍是“未验证”。
|
||||
- `--list-scenarios` / `--dry-run` 不导入远端联调依赖、不访问凭据或 ADB。电脑系统 Python 未装 Paramiko 时,优先复用本机测试环境的 `.venv` 并检查 import;不要给核桃派添加主机依赖。
|
||||
- com.android.test 插件已由同版本 AGP 放入 classpath 时,uiTest 使用无版本 `id("com.android.test")`;显式重复版本可能导致 unknown version 冲突。uiTest 的 Compose 测试库与现有 Compose Multiplatform 锁定版本一致。
|
||||
- 独立 uiTest 使用 self-instrumenting APK 和实际 Controller,不从反射篡改内部状态。fake RPC 必须遵循真实字段契约:内容条目需要 revision,缺失会在 thumbnailKey 中失败。此类夹具错误应修夹具,不弱化产品断言。
|
||||
- 真机 navigation 临时撤销扫描/连接权限,防止已有记忆触发自动重连;finally 恢复原权限并回读,不访问板端身份。不将此场景用于权限允许/拒绝的产品验收。
|
||||
- AVD 镜像 ZIP 手动解压不等于 SDK 包已登记。单独存放 SDK 时,avdmanager 必须定位同一 SDK 根;镜像 package.xml 保留官方 type-details/版本/许可证,复用已有 Emulator。优先使用 Android Studio SDK Manager 正常安装的包,不能靠反复下载解决根目录不一致。
|
||||
- 模拟器测试出现失败,保留场景与配置结果,独立恢复字体、旋转、密度;只重跑受影响/未运行场景。失败不能记成全量通过。标准 Compose 输入仅适用于模拟器,登记真机仍用 root。
|
||||
|
||||
## 14. 内容预览在真实动画条目返回 BAD_REQUEST(2026-09-29)
|
||||
|
||||
真实用户动画的首帧可能是 `path` 指向持久图片,演示动画则可能是内嵌 `image`。新增 BLE 图片接口时要先检查 `animations.playback_snapshot` 的实际首帧结构,并沿设备既有路径验证与解码规则读取两种形式;不能把 `path` 当作无图或错误参数。空动画仍应按不可播放条目处理。复测须覆盖用户动画和演示动画,并做全库图片只读读取;本轮修正和实测见 [内容缓存验收](../测试结果归档/2026-09-29_内容缓存/README.md)。
|
||||
|
||||
模拟器 AVD 名称若存在但启动提示系统目录不存在,先核对该名称的配置指向和镜像登记。保留旧配置,使用已安装镜像新建专用 AVD 再测试;不要因名称存在就反复启动,也不要擅自清空原 AVD。真机 instrumentation 前同时确认产品 APK 与 androidTest APK 已构建,只有测试 APK 时先补产品构建,再运行选中场景。
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Android 按变更影响选测(当前规则)
|
||||
|
||||
适用 MOBILE-TEST-SELECTION、TEST-MOBILE-SELECTION、TEST-MOBILE-INTEGRATION。本文优先于历史版本专项验收中的固定执行顺序。授权决定怎样操作设备,不代表每轮必须使用设备。
|
||||
|
||||
## 选择范围
|
||||
|
||||
先列变更、直接调用与共享依赖影响,再选择能提供足够证据的最低层级。不能仅以页面文件未修改判定无影响,也不能以新功能提交/打包为由自动重跑所有页面。
|
||||
|
||||
| 变更 | 默认验证 | 升级为定向真机的条件 |
|
||||
|---|---|---|
|
||||
| 文档、映射 | 文档一致性、仓库卫生 | 无 |
|
||||
| 解析、校验、业务状态 | sharedCore JVM / 双端协议测试 | 实际平台交互改变或模拟无法解释故障 |
|
||||
| 文案、布局、表单、导航 | 受影响 uiTest 场景,baseline 配置 | 厂商/实际设备特有问题或模拟证据不足 |
|
||||
| BLE/GATT/握手/重连 | 单元测试和对应真机连接场景 | 必须覆盖实际改变的通信路径 |
|
||||
| 权限、Activity/后台 | 相应 Android 平台测试;必要真机 | 真实蓝牙权限、MIUI、后台限制改变 |
|
||||
| WiFi | 校验和模拟器 settings | 激活、切换、恢复实现改变才真正切网 |
|
||||
| 构建、SDK、依赖 | 构建及受影响兼容性 | 覆盖安装、关键通信或平台风险 |
|
||||
| systemd、部署、驱动 | 设备侧既有流程 | 不因普通手机界面修改触发 |
|
||||
|
||||
共享导航/主题影响多个页面时覆盖相关页面;局部修改不测无影响页面。模拟器同样按需:默认单一 baseline;布局适配才增加 narrow/large-font/landscape;API/权限兼容变化才增加相关 API 的 AVD。不能用 API 35 的模拟界面结果冒充 API 26/30/31+ 权限验收。
|
||||
|
||||
全量仅在明确完整验收或影响无法可靠收敛时使用,写明原因。`--full-common` 是明确定义的四项公共回归,不等于全量验收;切网、写入、长轮询和异常恢复始终单独选择。发布候选仍按发布风险选择关键链路,打包成功不代表验收成功。
|
||||
|
||||
已运行且相关的低成本回归通过后,不无理由重复运行。必要的测试因环境缺失没做,记录“未验证”;无影响的测试记录“按影响跳过”。没有自动将失败改成跳过的机制。
|
||||
|
||||
## 场景和稳定编号映射
|
||||
|
||||
| 层级/入口 | 场景 | 对应编号 / 依赖与副作用 |
|
||||
|---|---|---|
|
||||
| sharedCore:jvmTest | 现有协议、状态、恢复、超时、加密 | ANDROID-TEST-CONTRACT / STATE;无设备 |
|
||||
| uiTest 模拟器 | connection/status/content/settings/navigation/layout | ANDROID-TEST-UI;测试专用假平台,实际界面与 Controller |
|
||||
| run_ble_test.py | connection | ANDROID-TEST-BLE;真实扫描/握手/断开/重新扫描重连 |
|
||||
| 同上 | data / library-order | ANDROID-TEST-BLE / CONTROL;只读,只有 order 读取板端目录摘要 |
|
||||
| 同上 | library-cache | ANDROID-TEST-LIBRARY-CACHE;登记手机与核桃派只读,两轮图片后重新连接,报告图片请求数与 PNG 负载字节数,不改变显示内容 |
|
||||
| 同上 | cache-storage | ANDROID-TEST-LIBRARY-CACHE;登记手机的隔离缓存目录验证跨设备隔离、损坏保护与清空,不访问核桃派 |
|
||||
| 同上 | conflict / wifi-scan | ANDROID-TEST-CONTRACT / WIFI;过期修订拒绝 / 板端扫描;不切网 |
|
||||
| 同上 | polling | ANDROID-TEST-PERF;最多 60 轮状态/帧读取,不替代性能因果对照 |
|
||||
| 同上 | controller-background / activity-background | ANDROID-TEST-STATE / BLE;后台释放与重连 |
|
||||
| 同上 | navigation | ANDROID-TEST-UI;登记真机 root 导航;临时撤销并恢复蓝牙权限防止旧记忆自动连接,不访问板端 |
|
||||
| 同上 | rename / remember | ANDROID-TEST-UI / STATE;昵称恢复 / 忘记后重新连接 |
|
||||
| 同上 | bluetooth-toggle | ANDROID-TEST-PERMISSION;真实关闭并恢复蓝牙 |
|
||||
| 同上 | connection-entry / layout | ANDROID-TEST-UI;条目重扫/断开/再连;layout 另加三组布局和帧边界 |
|
||||
| 同上 | controls / wifi-reapply | ANDROID-TEST-CONTROL / WIFI;当前值写入和动画恢复 / 当前网络重新激活,须明确选择 |
|
||||
| run_wifi_switch_test.py | --validate-only / --wrong-only / --skip-wrong / --final-check | ANDROID-TEST-WIFI;四者必须且只能选一个,后两类切网专项按原恢复规则 |
|
||||
|
||||
每个真机场景单独建立/释放会话。普通 UI 首选模拟器;真机 navigation 是厂商输入等特定问题的补证入口。`connection-entry` 不改变密度/字体/方向;`layout` 才做原有三组配置。其它设备生命周期、双手机、性能采样仍按各自编号的触发条件独立执行。
|
||||
|
||||
## 可复现命令
|
||||
|
||||
先阅读排障指南。命令从仓库根执行,路径参数使用本机已安装工具的真实路径,不把路径写进仓库。电脑远端联调使用已有安装 Paramiko 的环境;依赖见 requirements-host.txt,不加入核桃派依赖。
|
||||
|
||||
```text
|
||||
python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --list-scenarios
|
||||
python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --scenario connection --dry-run
|
||||
python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --scenario connection --adb "<ADB路径>" --build "<隔离工程目录>"
|
||||
python "移动端相关内容/安卓app/如何安卓测试/run_emulator_ui_test.py" --scenario settings --dry-run
|
||||
python "移动端相关内容/安卓app/如何安卓测试/run_emulator_ui_test.py" --scenario settings --avd "<AVD名称>" --adb "<ADB路径>" --emulator "<已安装Emulator路径>" --build "<隔离工程目录>" --report "<新的结果JSON路径>"
|
||||
python "移动端相关内容/安卓app/如何安卓测试/run_wifi_switch_test.py" --validate-only --dry-run
|
||||
```
|
||||
|
||||
构建沿用 build_android.py,任务按需指定:`:sharedCore:jvmTest`、`:androidApp:assembleDebug`、`:androidApp:assembleDebugAndroidTest`、`:uiTest:assembleDebug`。uiTest 是 self-instrumenting 测试 APK;只安装到明确选择且核验过的 AVD,不装到登记真机,不安装产品 APK。fake platform/transport、测试 Activity 均只存在于 uiTest;不调用真实 BLE、SSH、摄像头,不声明真实权限验收。
|
||||
|
||||
模拟器入口支持重复 `--scenario` 和 `--configuration`;默认 baseline,只在明确完整 UI 验收时用 `--all-ui`。复用 Android Studio Device Manager 的 AVD;缺少时用 SDK avdmanager 创建专用 AVD,使用官方系统镜像。`--port` 默认 5580,启动前指定 AVD、连接后核对名称和 ro.kernel.qemu,拒绝物理手机或不匹配目标。可用 `--sysdir` 指向独立存储的系统镜像。只关闭本次启动的模拟器;字体、方向、密度恢复并核验。不能擅自 wipe-data 或覆盖已有 AVD。
|
||||
|
||||
所有运行器无场景时只显示用法;列表/预览不访问登记、凭据、ADB、SSH。非法组合在设备访问前拒绝。旧 `--test activity`、无参数默认 BLE 的命令已取消:分别改用明确的 `--scenario`。run_ui_layout_test.py 保留转发入口,也必须显式选 layout 或 connection-entry(推荐统一用 run_ble_test.py)。
|
||||
|
||||
## 证据与边界
|
||||
|
||||
每轮归档填写:
|
||||
|
||||
```text
|
||||
变更及共享影响:
|
||||
选中编号 / 场景 / 环境 / 配置 / 选择理由:
|
||||
源码摘要、App/设备/协议版本(只记与本轮相关者):
|
||||
结果:通过 / 失败 / 按影响跳过 / 必要但未验证
|
||||
跳过项及理由:
|
||||
失败、清理、恢复核验、重测及未解决边界:
|
||||
经验更新位置:
|
||||
```
|
||||
|
||||
宿主调度检查:`python -m unittest discover -s "移动端相关内容/安卓app/如何安卓测试" -p "test_test_selection.py"`。这是运行器选测/失败恢复测试,不代表 BLE/模拟器已经通过。
|
||||
|
||||
模拟 UI 已覆盖空状态、扫描错误恢复、导航、WiFi 空名称禁用及确认取消、内容读入和帧布局。没有实现的 OS 权限矩阵和完整 Activity 状态恢复不冒充已覆盖;今后相关功能变化时补相应测试。语义/边界断言不证明文字实际运动或视觉质量。视觉和电气门禁不变。
|
||||
|
||||
## 官方依据(2026-09-29 核对)
|
||||
|
||||
- [Android 测试策略](https://developer.android.com/training/testing/fundamentals/strategies):选择最低有效层级,并非每个功能都在所有层级重复验证。
|
||||
- [Compose 独立测试](https://developer.android.com/develop/ui/compose/testing/common-patterns):隔离界面输入,使用测试 API 验证行为。
|
||||
- [Android 测试基础](https://developer.android.com/training/testing/fundamentals):instrumentation 可运行于真机或模拟器。
|
||||
- [模拟器网络能力](https://developer.android.com/studio/run/emulator-networking):新版本支持部分 Bluetooth 模拟,不能笼统写“模拟器完全不支持 BLE”;本项目 fake 测试仍不等于物理射频、实际核桃派或厂商兼容验证。
|
||||
|
||||
以上是制定项目选测规则的依据,具体场景划分是本项目的工程选择,不是官方强制清单。
|
||||
Reference in New Issue
Block a user