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
@@ -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 个任务',
);
}