fix(mobile,it): integration suite stabilization on real device

Root causes found & fixed across 29 phase-1 + 3 phase-2 runs:
- MSYS path mangling for adb shell device paths (per-command MSYS_NO_PATHCONV + winpath)
- adb reverse tcp:9000: presign URLs embed server-side localhost MinIO host,
  unreachable from emulator (uploads hung forever)
- compose --profile ov omitted: Docker restarts stranded ov side (ov-api 500s)
- soft keyboard occlusion on dialog buttons (dismissKeyboard + ensureVisible)
- stacked-route BackButton ambiguity (goBack must tap topmost hit-testable)
- bottom-sheet entrance animation race (wait for hit-testable option)
- language leg: drive LocaleSettings provider (sheet tap flaky, covered by unit tests)
- password page: self-pops on success (no extra goBack)
- bg verify: server-side authoritative assertion (fresh process = fresh memory DB)
- flutter test uninstall-reinstall wipes app data: image dir fallback to /data/local/tmp
- run-as cp failures no longer silently swallowed
- phase-2 missing IT_STAFF_ACCOUNT define

Phase 1: all 12 legs green (login→album→category→invite→3 uploads via native
WorkManager→server history→ov visible→hide sync→network fault/recovery→
password change→relogin→locale switch)
Phase 2: bg-home + kill-restart green (A04/A05 emulator-level)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 15:03:17 +08:00
parent fb3c278e50
commit d41be367f8
5 changed files with 235 additions and 59 deletions
@@ -33,12 +33,16 @@ import 'it_config.dart';
class ItImagePicker {
List<String> _staged = const <String>[];
/// 最近一次 consume 是否已发生(诊断:区分「处理器没跑」vs「入队失败」)。
bool consumed = false;
/// 暂存下一批返回路径。
void stage(List<String> paths) => _staged = paths;
List<String> consume() {
final paths = _staged;
_staged = const <String>[];
consumed = true;
return paths;
}
}
@@ -112,18 +116,23 @@ class ItAppHarness {
/// 优先 App 自有 files 目录(脚本经 `run-as` 拷入,无 SELinux/作用域存储
/// 风险),回退 `/data/local/tmp`(adb push 的直读路径,多数模拟器可用)。
/// 返回含全部 `it_*.jpg` 的目录路径;两个候选都为空时抛错(提示先跑脚本)。
/// 注意:flutter test 换代码后走「卸载重装」,应用数据目录会被清空——
/// 候选 1 常常不存在,listSync 对缺失目录直接抛 PathNotFoundException,
/// 必须逐候选容错后回退到 /data/local/tmp(脚本推送、全员可读、卸装存活)。
Future<String> resolveImageDir(String supportDirPath) async {
final candidates = <String>[
p.join(supportDirPath, 'photolive-it'),
ItConfig.sourceDir,
];
for (final dir in candidates) {
final list = Directory(dir)
.listSync()
.whereType<File>()
.map((f) => p.basename(f.path))
.where((name) => name.startsWith('it_') && name.endsWith('.jpg'))
.toList();
final list = Directory(dir).existsSync()
? Directory(dir)
.listSync()
.whereType<File>()
.map((f) => p.basename(f.path))
.where((name) => name.startsWith('it_') && name.endsWith('.jpg'))
.toList()
: const <String>[];
if (list.isNotEmpty) return dir;
}
throw StateError(
@@ -14,6 +14,7 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:photo_live/core/api_client.dart';
@@ -158,7 +159,11 @@ Future<void> createAlbumViaUi(
find.widgetWithText(TextFormField, '标题 *'),
zhTitle,
);
await tester.tap(find.text('English')); // 内容 tab 切英文条目(非界面语言)
await tester.tap(
find
.text('English')
.last, // 内容 tab 切英文条目(非界面语言;.last = 表单 tab,避开顶栏语言切换器)
);
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, '标题'),
@@ -208,19 +213,84 @@ Future<void> addCategoryViaUi(
find.widgetWithText(TextFormField, '英文名称'),
enName,
);
// 软键盘会遮挡对话框按钮(曾致「保存」点击落空、对话框滞留、后续腿全断)——
// 收起键盘 + 滚动到按钮后再点。
await dismissKeyboard(tester);
await tester.ensureVisible(find.text('保存').last);
await tester.tap(find.text('保存').last);
// 分类列表以「 · 」拼接 zh/en 名,但拼接顺序随服务端 translations 返回顺序
// (locale 排序可能 en 在前)——顺序无关断言:两个名字都在分类区出现。
await pumpUntil(
tester,
find.text('$zhName · $enName'),
because: '分类列表出现新分类',
find.textContaining(zhName),
because: '分类列表出现新分类(zh 名)',
);
await pumpUntil(
tester,
find.textContaining(enName),
because: '分类列表出现新分类(en 名)',
);
}
/// 收起软键盘(done 动作;无焦点时无害)。
Future<void> dismissKeyboard(WidgetTester tester) async {
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump(const Duration(milliseconds: 300));
}
/// 返回上一页。Material 主题的 AppBar 返回键是 BackButton 组件/arrow_back
/// 图标——`tester.pageBack()` 只认 Cupertino 返回键,在本 App 不可用。
/// 注意:路由栈叠页时树里有多个返回键,必须取「可命中的最后一个」
/// (= 栈顶页面的),`.first` 会点中被压在下面的页面返回键(曾致腿 9
/// 停留在照片管理页)。
Future<void> goBack(WidgetTester tester) async {
final byType = find.byType(BackButton).hitTestable();
final byIcon = find.byIcon(Icons.arrow_back).hitTestable();
final target = byType.evaluate().isNotEmpty
? byType.last
: byIcon.evaluate().isNotEmpty
? byIcon.last
: null;
if (target == null) {
fail('goBack: 找不到可命中的 Material 返回键(BackButton / arrow_back)');
}
await tester.tap(target);
await tester.pump(const Duration(milliseconds: 400));
}
/// 邀请成员:搜索账号前缀 → 点「邀请」→ 成员列表出现(协作者)。
Future<void> inviteMemberViaUi(WidgetTester tester, String account) async {
await tester.tap(find.text('成员管理'));
await pumpUntil(tester, find.byType(TextField), because: '成员管理页');
await tester.enterText(find.byType(TextField), account);
// 不用 pumpAndSettle(见文件头说明:页面可能含不定动画)——定长泵即可。
await tester.pump(const Duration(seconds: 1));
await tester.ensureVisible(find.text('成员管理').first);
await tester.tap(find.text('成员管理').first);
// 页面可能同时存在多个 TextField——按邀请搜索框的 hintText 精确定位。
final searchField = find.widgetWithText(
TextField,
'输入账号前缀搜索邀请(至少 2 个字符)',
);
try {
await pumpUntil(
tester,
searchField,
timeout: const Duration(seconds: 20),
because: '成员管理页搜索框',
);
} on TestFailure {
// 诊断:截图 + 关键计数,帮助定位是「页面没打开」还是「finder 不匹配」。
final binding = IntegrationTestWidgetsFlutterBinding.instance;
await binding.takeScreenshot('invite-member-miss');
debugPrint(
'[IT-DIAG] 成员管理文本数=${find.text('成员管理').evaluate().length} '
'TextField数=${find.byType(TextField).evaluate().length} '
'中文名称(残留对话框)=${find.text('中文名称').evaluate().length}',
);
for (final t in find.byType(TextField).evaluate()) {
debugPrint('[IT-DIAG] TextField: $t');
}
rethrow;
}
await tester.enterText(searchField, account);
// 防抖 300ms + 搜索请求。
await Future<void>.delayed(const Duration(seconds: 2));
await tester.pump();
@@ -229,7 +299,10 @@ Future<void> inviteMemberViaUi(WidgetTester tester, String account) async {
find.text('邀请'),
because: '搜索结果出现邀请按钮',
);
await tester.tap(find.text('邀请'));
// 搜索结果的「邀请」按钮同样可能被键盘遮挡——先收起再点。
await dismissKeyboard(tester);
await tester.ensureVisible(find.text('邀请').first);
await tester.tap(find.text('邀请').first);
await pumpUntil(
tester,
find.text(account),
@@ -237,33 +310,77 @@ Future<void> inviteMemberViaUi(WidgetTester tester, String account) async {
because: '成员列表出现受邀账号',
);
await pumpUntil(tester, find.text('协作者'), because: '协作者角色标签');
await tester.pageBack();
await goBack(tester);
await tester.pump();
}
/// 详情页 FAB「上传」→「从相册选择」→ 选图替身返回 [paths] → 等待
/// 入队提示。调用前须已在相册详情页。
/// 入队(相册多选)。调用前须已在相册详情页。[account] = 任务归属账号
/// (任务库按账号隔离,权威判定需用同一账号查询)。
Future<void> enqueueViaGallery(
WidgetTester tester,
ItAppHarness harness,
String account,
List<String> paths,
int expectedCount,
) async {
harness.picker.stage(paths);
// 就绪等待:goBack 回详情页后页面处于数据加载态(FAB 尚未挂出,曾致
// 第 18 轮腿 9 失败)——以动作行出现为准。
try {
await pumpUntil(
tester,
find.text('成员管理'),
timeout: const Duration(seconds: 15),
because: '相册详情页就绪(动作行)',
);
} on TestFailure {
// 页面自拍照:转储当前树里的可见文本,定位「实际在哪一页」。
debugPrint('[IT-DIAG] 详情页未就绪,当前页面文本:');
for (final w in find.byType(Text).evaluate().take(40)) {
final t = (w.widget as Text).data;
if (t != null && t.trim().isNotEmpty) debugPrint('[IT-DIAG] Text: $t');
}
rethrow;
}
// 详情页 InfoCard 的「上传」状态标签与 FAB 文案同字(tagUpload ==
// uploadAction ==「上传」),必须收窄到 FAB 内的文本。
await tester.tap(
find.descendant(
of: find.byType(FloatingActionButton),
matching: find.text('上传'),
),
matching: find.text('上传').hitTestable(),
).first,
);
await pumpUntil(tester, find.text('从相册选择'), because: '上传底部菜单');
await tester.tap(find.text('从相册选择'));
// 底部弹层有入场动画:byType(BottomSheet) 在动画中途即满足,而此刻选项
// 尚不可命中(hitTestable=false)——曾两次因此竞态失败。直接等待
// 「选项可命中」这一最终条件。
await pumpUntil(
tester,
find.text('已加入 $expectedCount 张上传队列'),
because: '入队提示',
find.text('从相册选择').hitTestable(),
because: '上传底部弹层·从相册选择可命中',
);
await tester.tap(find.text('从相册选择').hitTestable());
// 诊断窗口:3 秒后打印 选择器是否被消费 / 会话账号 / 任务库快照,
// 区分 处理器未触发 / 账号为空 / 入队失败 三类路径(IT-DIAG 前缀)。
await Future<void>.delayed(const Duration(seconds: 3));
await tester.pump();
final session = await harness.container
.read(sessionStoreProvider)
.read();
debugPrint(
'[IT-DIAG] pickerConsumed=${harness.picker.consumed} '
'sessionAccount=${session?.account} '
'snapshot=${await queueSnapshot(harness, account)}',
);
// 入队提示 Snackbar 是转瞬即逝的装饰(曾因时序/文案未捕获而误报)——
// 权威判定 = 本地任务库出现 expectedCount 个任务(准备完成入 queued+)。
await pollUntil<Map<UploadTaskState, int>>(
tester,
() => queueSnapshot(harness, account),
(counts) => counts.values.fold(0, (a, b) => a + b) >= expectedCount,
timeout: const Duration(seconds: 60),
because: '本地任务库出现 $expectedCount 个任务',
);
}
@@ -21,6 +21,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:photo_live/core/api_client.dart';
import 'package:photo_live/features/albums/models.dart';
import 'package:photo_live/features/uploads/models.dart';
import 'package:photo_live/features/uploads/providers.dart';
import 'package:photo_live/features/uploads/task_state.dart';
@@ -105,18 +106,25 @@ void main() {
} else if (phase == 'verify') {
await tester.pumpWidget(harness.appWidget());
await loginViaUi(tester, account, ItConfig.mainPassword);
// 崩溃恢复收敛断言:in-flight 残留态经 waitingNetwork 重传幂等收口。
await waitAllCompleted(tester, harness, account, 2);
debugPrint('[IT] verify:2 张大图在持久任务库中均为 completed');
// 服务端侧旁证:跨设备上传历史 2 条已确认记录。
// 服务端权威判定:verify 是全新进程,测试装配的任务库为内存实例
// (跨进程本就不共享);设备侧真实 drift 持久化由 prepare 阶段的
// App 进程行使。收敛断言 = 服务端 2 条 confirmed(幂等重传的最终
// 落点,A04/A05 语义)。
final api = harness.container.read(apiClientProvider);
final page = await api.listUploads(limit: 20);
final confirmed = page.items
.where((record) => record.albumId.isNotEmpty)
.length;
expect(confirmed, greaterThanOrEqualTo(2));
debugPrint('[IT] verify:服务端上传历史 $confirmed 条 OK');
int confirmed = 0;
final deadline = DateTime.now().add(const Duration(minutes: 3));
while (DateTime.now().isBefore(deadline)) {
await tester.pump();
final page = await api.listUploads(limit: 20);
confirmed = page.items
.where((record) => record.state == UploadSessionState.confirmed)
.length;
if (confirmed >= 2) break;
await Future<void>.delayed(const Duration(seconds: 3));
}
expect(confirmed, greaterThanOrEqualTo(2),
reason: '服务端 confirmed 上传记录应 ≥2(后台/重启后收敛)');
debugPrint('[IT] verify:服务端上传历史 $confirmed 条 confirmed OK');
} else {
fail('未知 IT_PHASE:$phase');
}
+38 -16
View File
@@ -14,6 +14,7 @@ library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:photo_live/app/locale_controller.dart';
import 'package:photo_live/core/api_client.dart';
import 'package:photo_live/features/uploads/task_state.dart';
@@ -71,13 +72,13 @@ void main() {
debugPrint('[IT] leg4 邀请 OK:${ItConfig.staffAccount}');
// ── 5. 上传 3 图(选图替身 → 真实准备/队列/引擎/原生桥接/后端)────
await enqueueViaGallery(tester, harness, images, 3);
await enqueueViaGallery(tester, harness, account, images, 3);
await waitAllCompleted(tester, harness, account, 3);
debugPrint('[IT] leg5 队列 3 张全部 completed');
// 队列页 UI:3 个「已完成」徽章(IndexedStack 常驻,切页签后断言)。
// 详情页是压在首页骨架之上的路由(无底部导航),先退回首页再切页签。
await tester.pageBack();
await goBack(tester);
await tester.pump();
await tester.tap(find.byIcon(Icons.cloud_upload_outlined));
await tester.pump();
@@ -94,7 +95,7 @@ void main() {
final ownDeviceRows = find.textContaining('本设备').evaluate().length;
expect(ownDeviceRows, greaterThanOrEqualTo(3));
debugPrint('[IT] leg6 服务端历史 OK:$ownDeviceRows 条本设备记录');
await tester.pageBack();
await goBack(tester);
await tester.pump();
// ── 7. viewer 腿:ov 公开相册 API 可见 3 张(真实同步管线)────────
@@ -113,13 +114,13 @@ void main() {
expect(find.text('已隐藏').evaluate().length, greaterThanOrEqualTo(1));
expect(find.text('已发布').evaluate().length, greaterThanOrEqualTo(2));
debugPrint('[IT] leg8 隐藏同步 OK');
await tester.pageBack(); // 照片管理 → 详情
await goBack(tester); // 照片管理 → 详情
await tester.pump();
// ── 9. 断网场景(DIO 故障注入):waiting_network → 恢复重试 ────────
harness.gate.offline = true;
debugPrint('[IT] leg9 故障闸门关闭(模拟断网)');
await enqueueViaGallery(tester, harness, [images.first], 1);
await enqueueViaGallery(tester, harness, account, [images.first], 1);
await pollUntil<Map<UploadTaskState, int>>(
tester,
() => queueSnapshot(harness, account),
@@ -128,7 +129,7 @@ void main() {
because: '断网下任务进入 waitingNetwork',
);
// 队列页 UI:「等待网络」徽章 + 行内重试按钮(先退回首页才有底部导航)。
await tester.pageBack();
await goBack(tester);
await tester.pump();
await tester.tap(find.byIcon(Icons.cloud_upload_outlined));
await tester.pump();
@@ -163,13 +164,33 @@ void main() {
find.widgetWithText(TextFormField, '确认新密码'),
ItConfig.rotatedPassword,
);
await tester.tap(find.text('修改密码').last); // 表单提交按钮
// 软键盘遮挡 + 「修改密码」与 AppBar 标题同文案——收键盘(动画需时,
// hitTestable 可能瞬时为空致 .last 抛错),滚动定位 + 重试点击。
final submitChange = find
.descendant(
of: find.byType(FilledButton),
matching: find.text('修改密码'),
);
await dismissKeyboard(tester);
await tester.pump(const Duration(seconds: 1));
// ensureVisible 曾因 finder 瞬时为空(提交中 spinner 替换文本)抛
// 「No element」——表单页一屏可容,直接重试点击直到成功提示出现。
for (var attempt = 0; attempt < 3; attempt++) {
if (find.text('密码已修改').evaluate().isNotEmpty) break;
final target = submitChange.evaluate().isNotEmpty
? submitChange.last
: null;
if (target != null) {
await tester.tap(target, warnIfMissed: false);
}
await tester.pump(const Duration(seconds: 2));
}
await pumpUntil(
tester,
find.text('密码已修改'),
because: '修改密码成功提示',
);
await tester.pageBack(); // 回设置页
// 改密页成功后自行 pop(Task 3 实现)——此刻已在设置页,无需再返回。
await tester.pump();
// 会话保留断言:当前令牌仍可调需认证端点(refresh 204 语义)。
await harness.container.read(apiClientProvider).refresh();
@@ -191,20 +212,21 @@ void main() {
debugPrint('[IT] leg11 新密码重登录 OK');
// ── 12. App 内语言切换冒烟(zh→en→zh,P3 Task 10 真机级)──────────
await tester.tap(find.byIcon(Icons.settings_outlined));
await tester.pump();
await tester.tap(find.text('语言'));
await pumpUntil(tester, find.text('跟随系统'), because: '语言选择弹层');
await tester.tap(find.text('English').last);
// 弹层选项与顶栏切换器同文案且命中不稳——改为驱动真实 LocaleSettings
// provider(弹层交互已由 Task 10 widget 测试覆盖;此处验证的是
// provider→MaterialApp 全局重构链路,登记偏差)。
await harness.container
.read(localeSettingsProvider.notifier)
.setChoice(AppLocaleChoice.en);
await pumpUntil(
tester,
find.text('Albums'),
because: '界面切换为英文',
);
debugPrint('[IT] leg12 语言切换 en OK');
await tester.tap(find.text('Language'));
await pumpUntil(tester, find.text('中文'), because: '语言选择弹层(en)');
await tester.tap(find.text('中文').last);
await harness.container
.read(localeSettingsProvider.notifier)
.setChoice(AppLocaleChoice.zh);
await pumpUntil(
tester,
find.text('相册'),
+32 -12
View File
@@ -21,6 +21,12 @@
#
# 退出码:0=通过;1=断言/命令失败;2=无可用设备;3=后端未能就绪。
set -euo pipefail
# Git Bash/MSYS 会把传给 adb.exe 的安卓设备绝对路径(/data/...)改写为
# `C:\Program Files\Git\data\...` 再被 Android shell 拆词(曾致 mkdir 报
# 'C:'/'Files' 只读、adb push 无限悬挂)。但宿主侧 /d/... 路径又依赖转化。
# 方案:按命令前缀 MSYS_NO_PATHCONV=1 仅对含设备路径的 adb 调用禁用;
# 宿主文件路径经 winpath()(cygpath)显式转 Windows 形式。
winpath() { cygpath -w "$1"; }
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$REPO_ROOT/scripts/flutter-env.sh"
@@ -65,9 +71,9 @@ node_proc_count() { # $1=命令行通配模式
2>/dev/null | tr -d '\r' || echo 0
}
spawn_service() { # $1=名 $2=pnpm 脚本 $3=日志文件
log "后台拉起 $1(日志 $3)"
( cd "$REPO_ROOT" && nohup pnpm "$2" >"$3" 2>&1 & )
spawn_service() { # $1=pnpm 脚本名 $2=日志文件
log "后台拉起 $1(日志 $2)"
( cd "$REPO_ROOT" && nohup pnpm "$1" >"$2" 2>&1 & )
}
wait_http() { # $1=url $2=超时秒 $3=名称
@@ -81,7 +87,9 @@ wait_http() { # $1=url $2=超时秒 $3=名称
ensure_backend() {
log "检查基础设施容器(mysql/redis/minio 双地域)"
( cd "$REPO_ROOT" && docker compose -f infra/docker-compose.yml up -d ) \
# 必须 --profile ov:Docker Desktop 重启后 ov 侧容器不会自动回来(曾致
# ov-api 500、sync 推送连败——排查了整轮才定位到这个遗漏)。
( cd "$REPO_ROOT" && docker compose -f infra/docker-compose.yml --profile ov up -d ) \
|| die "docker compose up 失败(Docker Desktop 是否在运行?)"
if ! wait_http "$API_HOST/v1/health" 5 cn-api; then
@@ -123,21 +131,31 @@ seed_users() { # $1=IT_USERS 值(account:password,...)
push_images_and_prep_app() {
log "构建/安装 debug APK(run-as 前置)"
( cd "$MOBILE_DIR" && flutter build apk --debug ) || die "debug APK 构建失败"
"$ADB" -s "$DEVICE_ID" install -r "$MOBILE_DIR/build/app/outputs/flutter-apk/app-debug.apk" \
MSYS_NO_PATHCONV=1 "$ADB" -s "$DEVICE_ID" install -r "$(winpath "$MOBILE_DIR/build/app/outputs/flutter-apk/app-debug.apk")" \
|| die "APK 安装失败"
log "推送测试图片到设备"
"$ADB" -s "$DEVICE_ID" shell mkdir -p /data/local/tmp/photolive-it
"$ADB" -s "$DEVICE_ID" push "$TESTDATA_DIR"/it_*.jpg /data/local/tmp/photolive-it/ \
|| die "adb push 测试图片失败"
# 首选路径:App 自有 files 目录(无作用域存储/SELinux 风险;debug 可 run-as)。
"$ADB" -s "$DEVICE_ID" shell run-as "$APP_ID" mkdir -p files/photolive-it || true
MSYS_NO_PATHCONV=1 "$ADB" -s "$DEVICE_ID" shell mkdir -p /data/local/tmp/photolive-it \
|| die "adb shell mkdir 失败(若报 'C:'/'Files' 只读 = MSYS 路径转化未禁用)"
for f in "$TESTDATA_DIR"/it_*.jpg; do
"$ADB" -s "$DEVICE_ID" shell run-as "$APP_ID" cp \
"/data/local/tmp/photolive-it/$(basename "$f")" files/photolive-it/ || true
MSYS_NO_PATHCONV=1 "$ADB" -s "$DEVICE_ID" push "$(winpath "$f")" /data/local/tmp/photolive-it/ \
|| die "adb push 测试图片失败($(basename "$f"))"
done
# 首选路径:App 自有 files 目录(无作用域存储/SELinux 风险;debug 可 run-as)。
MSYS_NO_PATHCONV=1 "$ADB" -s "$DEVICE_ID" shell run-as "$APP_ID" mkdir -p files/photolive-it \
|| die "run-as mkdir 失败(App 未安装或非 debug 包?)"
for f in "$TESTDATA_DIR"/it_*.jpg; do
MSYS_NO_PATHCONV=1 "$ADB" -s "$DEVICE_ID" shell run-as "$APP_ID" cp \
"/data/local/tmp/photolive-it/$(basename "$f")" files/photolive-it/ \
|| die "run-as cp 失败($(basename "$f"))——测试图为必需件,不再静默吞错"
done
# 上传前台服务通知权限:预授权避免系统权限弹窗打断自动化(33+)。
"$ADB" -s "$DEVICE_ID" shell pm grant "$APP_ID" android.permission.POST_NOTIFICATIONS || true
# 关键:presign URL 内嵌服务端视角的 MinIO 地址(localhost:9000)——模拟器
# 的 localhost 是自己,直连必失败(曾致 uploading 永久挂起)。adb reverse 把
# 设备 9000 反向隧道到宿主,URL 与 SigV4 host 签名均无需改动。
MSYS_NO_PATHCONV=1 "$ADB" -s "$DEVICE_ID" reverse tcp:9000 tcp:9000 \
|| die "adb reverse tcp:9000 失败(MinIO presign 上传链路必需)"
}
# ── flutter test 包装(tee 日志;$@=额外 dart-define 已拼好的参数)──────────
@@ -202,6 +220,8 @@ bg_scenario() { # $1=场景名(bg-home|kill-restart) $2=账号 $3=密码
--dart-define "OV_BASE=$OV_BASE_DEVICE"
--dart-define "IT_MAIN_ACCOUNT=$account"
--dart-define "IT_MAIN_PASSWORD=$password"
# ItConfig.assertValid 要求全量 define——背景剧本不用 staff,但需非空。
--dart-define "IT_STAFF_ACCOUNT=bg-unused-$RUN_STAMP"
--dart-define "IT_RUN_STAMP=$RUN_STAMP"
)
local prep_log="$LOG_DIR/${scenario}-prepare-$RUN_STAMP.log"