From d41be367f8bcb2022e61308ba3ee4ae5a7d47830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Sun, 20 Sep 2026 15:03:17 +0800 Subject: [PATCH] fix(mobile,it): integration suite stabilization on real device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../integration_test/helpers/app_boot.dart | 21 ++- .../integration_test/helpers/ui_steps.dart | 145 ++++++++++++++++-- .../integration_test/p3_background_test.dart | 30 ++-- .../mobile/integration_test/p3_flow_test.dart | 54 +++++-- scripts/run-mobile-it.sh | 44 ++++-- 5 files changed, 235 insertions(+), 59 deletions(-) diff --git a/apps/mobile/integration_test/helpers/app_boot.dart b/apps/mobile/integration_test/helpers/app_boot.dart index 88396b3..442ba78 100644 --- a/apps/mobile/integration_test/helpers/app_boot.dart +++ b/apps/mobile/integration_test/helpers/app_boot.dart @@ -33,12 +33,16 @@ import 'it_config.dart'; class ItImagePicker { List _staged = const []; + /// 最近一次 consume 是否已发生(诊断:区分「处理器没跑」vs「入队失败」)。 + bool consumed = false; + /// 暂存下一批返回路径。 void stage(List paths) => _staged = paths; List consume() { final paths = _staged; _staged = const []; + 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 resolveImageDir(String supportDirPath) async { final candidates = [ p.join(supportDirPath, 'photolive-it'), ItConfig.sourceDir, ]; for (final dir in candidates) { - final list = Directory(dir) - .listSync() - .whereType() - .map((f) => p.basename(f.path)) - .where((name) => name.startsWith('it_') && name.endsWith('.jpg')) - .toList(); + final list = Directory(dir).existsSync() + ? Directory(dir) + .listSync() + .whereType() + .map((f) => p.basename(f.path)) + .where((name) => name.startsWith('it_') && name.endsWith('.jpg')) + .toList() + : const []; if (list.isNotEmpty) return dir; } throw StateError( diff --git a/apps/mobile/integration_test/helpers/ui_steps.dart b/apps/mobile/integration_test/helpers/ui_steps.dart index 4319952..b719def 100644 --- a/apps/mobile/integration_test/helpers/ui_steps.dart +++ b/apps/mobile/integration_test/helpers/ui_steps.dart @@ -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 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 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 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 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 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.delayed(const Duration(seconds: 2)); await tester.pump(); @@ -229,7 +299,10 @@ Future 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 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 enqueueViaGallery( WidgetTester tester, ItAppHarness harness, + String account, List 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.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>( + tester, + () => queueSnapshot(harness, account), + (counts) => counts.values.fold(0, (a, b) => a + b) >= expectedCount, + timeout: const Duration(seconds: 60), + because: '本地任务库出现 $expectedCount 个任务', ); } diff --git a/apps/mobile/integration_test/p3_background_test.dart b/apps/mobile/integration_test/p3_background_test.dart index 3925379..360bfe7 100644 --- a/apps/mobile/integration_test/p3_background_test.dart +++ b/apps/mobile/integration_test/p3_background_test.dart @@ -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.delayed(const Duration(seconds: 3)); + } + expect(confirmed, greaterThanOrEqualTo(2), + reason: '服务端 confirmed 上传记录应 ≥2(后台/重启后收敛)'); + debugPrint('[IT] verify:服务端上传历史 $confirmed 条 confirmed OK'); } else { fail('未知 IT_PHASE:$phase'); } diff --git a/apps/mobile/integration_test/p3_flow_test.dart b/apps/mobile/integration_test/p3_flow_test.dart index 1779f13..591cec5 100644 --- a/apps/mobile/integration_test/p3_flow_test.dart +++ b/apps/mobile/integration_test/p3_flow_test.dart @@ -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>( 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('相册'), diff --git a/scripts/run-mobile-it.sh b/scripts/run-mobile-it.sh index 70349a4..7ddfbfa 100644 --- a/scripts/run-mobile-it.sh +++ b/scripts/run-mobile-it.sh @@ -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"