test(mobile): integration suite against real backend on mumu

P3 Task 11 (WRITE & COMPILE phase; device run pending MuMu availability).

- integration_test/p3_flow_test.dart: login -> album (zh+en) -> category
  -> invite -> enqueue 3 real JPEGs -> queue completed -> server history
  (sourceDeviceId) -> ov public API sees 3 -> hide via API + UI sync ->
  DIO fault-gate offline (waitingNetwork -> retry -> 4 completed) ->
  password change (session kept) -> relogin -> in-app locale smoke.
- integration_test/p3_background_test.dart: IT_PHASE=prepare/verify spec
  for HOME-background continuation and force-stop kill-restart recovery
  (2 x ~14MB noise JPEGs give a deterministic mid-flight window).
- helpers: dart-define config, fault-injecting DIO adapter, app harness
  (picker/notification/locale provider overrides), UI step library with
  live-binding polling (no pumpAndSettle).
- scripts/run-mobile-it.sh: device detect (exit 2), backend ensure
  (compose + api/ov/worker/sync, exit 3), idempotent per-run user seed
  (no DB reset), APK build/install + adb push of testdata, phase 1 /
  --background phase 2 orchestration with server-side confirmed-count
  truth via curl.
- services/api seed:it script (create-if-absent staff accounts).
- debug-only network security config: cleartext HTTP permitted to
  10.0.2.2/127.0.0.1/localhost only (release unaffected).
- testdata: 3 x ~120KB + 2 x ~14MB generated JPEGs.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-19 05:35:16 +08:00
parent 8f3966d7b5
commit fb3c278e50
18 changed files with 1409 additions and 3 deletions
@@ -0,0 +1,133 @@
/// 集成测试 App 装配(P3 Task 11)。
///
/// 与生产 `lib/main.dart` 的差异【全部为有意注入点,登记为计划偏差】:
/// - **选图替身**:`multiImagePickerProvider`/`singleImagePickerProvider`
/// 返回 ADB 推送到设备的小图路径——image_picker 的系统相册 UI 无法在
/// instrumentation 下自动驱动(连拍经相机 UI 同理,统一走多选路径,
/// 入队后的 准备→队列→引擎→原生桥接→真实后端 链路完全真实);
/// - **通知权限 no-op**:避免首个入队动作弹出系统权限对话框卡住自动流
/// (脚本端另做 `pm grant` 双保险);
/// - **语言固定 zh**:MuMu 系统语言不确定,固定后文案查找器才稳定
/// (App 内切换的语义已由 Task 10 单测覆盖,本套件另做一次切换冒烟);
/// - **故障闸门**:ApiClient 的 dio adapter 包 [FaultGate](断网场景)。
library;
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import 'package:photo_live/app/app.dart';
import 'package:photo_live/app/locale_controller.dart';
import 'package:photo_live/core/api_client.dart';
import 'package:photo_live/core/config.dart';
import 'package:photo_live/core/notification_permission.dart';
import 'package:photo_live/features/uploads/enqueue.dart';
import 'fault_gate.dart';
import 'it_config.dart';
/// 选图替身:持有「下一次相册动作返回的路径」,消费后清空(未暂存时
/// 返回空=模拟用户取消,让调用方走取消分支而不是挂起)。
class ItImagePicker {
List<String> _staged = const <String>[];
/// 暂存下一批返回路径。
void stage(List<String> paths) => _staged = paths;
List<String> consume() {
final paths = _staged;
_staged = const <String>[];
return paths;
}
}
/// 测试装配产物。
class ItAppHarness {
ItAppHarness._(this.container, this.gate, this.picker);
/// App 根容器(持有全部覆写;测试经它读 taskStore/apiClient 等)。
final ProviderContainer container;
/// 断网故障闸门。
final FaultGate gate;
/// 选图替身。
final ItImagePicker picker;
/// 装配 App(不 runApp——由 testWidgets 内 pumpWidget 注入
/// UncontrolledProviderScope)。
static ItAppHarness create() {
ItConfig.assertValid();
final gate = FaultGate();
final picker = ItImagePicker();
final dio = Dio(
BaseOptions(
baseUrl: ApiConfig.baseUrl,
connectTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 60),
receiveTimeout: const Duration(seconds: 60),
),
);
dio.httpClientAdapter = FaultInjectingHttpClientAdapter(
gate: gate,
inner: dio.httpClientAdapter,
);
final container = ProviderContainer(
overrides: [
localeSettingsProvider.overrideWith(
() => LocaleSettings(initial: AppLocaleChoice.zh),
),
apiClientProvider.overrideWith((ref) {
final client = ApiClient(
sessionStore: ref.watch(sessionStoreProvider),
dio: dio,
);
ref.onDispose(() => client.close());
return client;
}),
multiImagePickerProvider.overrideWithValue(() async => picker.consume()),
singleImagePickerProvider.overrideWithValue(() async {
final paths = picker.consume();
return paths.isEmpty ? null : paths.first;
}),
notificationPermissionProvider.overrideWithValue(
NotificationPermission(isAndroid: false),
),
],
);
return ItAppHarness._(container, gate, picker);
}
/// 注入根组件(每个 testWidgets 开头调用)。
Widget appWidget() =>
UncontrolledProviderScope(container: container, child: const PhotoLiveApp());
}
/// 解析设备端可读的测试图片目录。
///
/// 优先 App 自有 files 目录(脚本经 `run-as` 拷入,无 SELinux/作用域存储
/// 风险),回退 `/data/local/tmp`(adb push 的直读路径,多数模拟器可用)。
/// 返回含全部 `it_*.jpg` 的目录路径;两个候选都为空时抛错(提示先跑脚本)。
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();
if (list.isNotEmpty) return dir;
}
throw StateError(
'设备上找不到测试图片(已尝试 ${candidates.join('、')})——'
'请先运行 scripts/run-mobile-it.sh(负责 adb push + run-as 拷贝)',
);
}
@@ -0,0 +1,50 @@
/// 断网场景注入(P3 Task 11,计划偏差登记项)。
///
/// 模拟器上断真实网络不便(后端就在宿主 10.0.2.2,断网=断后端不可分区
/// 观察),按计划改为 **DIO 层故障注入**:包装 App ApiClient 的
/// HttpClientAdapter,闸门关闭期间所有 cn-api 请求立即以 connectionError
/// 失败——引擎 initUpload/completeUpload 走该 dio,按 NETWORK_ERROR 分类
/// 进入 waitingNetwork 退避,恢复后重试成功,即覆盖「断网→等待网络→
/// 恢复→续传」语义。
///
/// 边界(有意,登记为偏差):presign PUT 在 Android 走原生 WorkManager
/// 桥接(不经 dio),本注入不覆盖该腿;waitingNetwork 判定由 initUpload
/// 失败驱动,足以驱动状态机目标分支。
library;
import 'dart:typed_data';
import 'package:dio/dio.dart';
/// 故障闸门:`offline=true` 期间所有经该 adapter 的请求立即失败。
class FaultGate {
bool offline = false;
}
/// 包装任意 [HttpClientAdapter] 的故障注入实现。
class FaultInjectingHttpClientAdapter implements HttpClientAdapter {
FaultInjectingHttpClientAdapter({required this.gate, required this.inner});
final FaultGate gate;
final HttpClientAdapter inner;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
if (gate.offline) {
// 与真实断网同型(connectionError → AppException NETWORK_ERROR),
// 立即抛出避免 10s 连接超时拖慢场景。
throw DioException.connectionError(
requestOptions: options,
reason: 'it fault gate: offline',
);
}
return inner.fetch(options, requestStream, cancelFuture);
}
@override
void close({bool force = false}) => inner.close(force: force);
}
@@ -0,0 +1,66 @@
/// P3 Task 11 集成测试配置(dart-define 注入)。
///
/// 所有值经 `--dart-define KEY=value` 由 `scripts/run-mobile-it.sh` 传入:
/// 账号由脚本端 `seed-it-users` 预置(每次运行全新账号,避免污染共享
/// dev 库——集成测试【不做 DB reset】,靠账号 + 相册名时间戳隔离)。
library;
/// 一次集成测试运行的全部外部输入。
class ItConfig {
const ItConfig._();
/// 主账号(本次运行专属,脚本经 seed-it-users 新建)。
static const String mainAccount = String.fromEnvironment('IT_MAIN_ACCOUNT');
/// 主账号密码。
static const String mainPassword = String.fromEnvironment(
'IT_MAIN_PASSWORD',
);
/// 受邀协作者账号(脚本预建;密码不需要——App 侧仅邀请不登录它)。
static const String staffAccount = String.fromEnvironment('IT_STAFF_ACCOUNT');
/// ov 公开端点(viewer 侧 API 检查用;默认模拟器宿主回环 8081)。
static const String ovBase = String.fromEnvironment(
'OV_BASE',
defaultValue: 'http://10.0.2.2:8081',
);
/// ADB 推送的小图目录(设备端绝对路径;见 run-mobile-it.sh 的 push 步骤)。
static const String sourceDir = String.fromEnvironment(
'IT_SOURCE_DIR',
defaultValue: '/data/local/tmp/photolive-it',
);
/// 大图文件名(后台/杀进程场景用,同一目录下)。
static const String largeFileA = 'it_large_a.jpg';
static const String largeFileB = 'it_large_b.jpg';
/// 本次运行戳(脚本生成;相册名/账号后缀,保证唯一)。
static const String runStamp = String.fromEnvironment('IT_RUN_STAMP');
/// 修改密码场景的新密码(每轮不同:旧密码 + 固定后缀,≥8 位)。
static String get rotatedPassword => '$mainPassword-rot8';
/// 断言入参齐备(缺 dart-define 时给出可读失败而非空账号静默跑挂)。
static void assertValid() {
final missing = <String>[
if (mainAccount.isEmpty) 'IT_MAIN_ACCOUNT',
if (mainPassword.isEmpty) 'IT_MAIN_PASSWORD',
if (staffAccount.isEmpty) 'IT_STAFF_ACCOUNT',
if (runStamp.isEmpty) 'IT_RUN_STAMP',
];
if (missing.isNotEmpty) {
throw StateError(
'缺少 dart-define:${missing.join(', ')}(请经 scripts/run-mobile-it.sh '
'运行,脚本负责生成账号与戳)',
);
}
}
/// 本次运行的相册标题(zh)。
static String get albumTitleZh => '集成测试相册 $runStamp';
/// 本次运行的相册标题(en)。
static String get albumTitleEn => 'IT Album $runStamp';
}
@@ -0,0 +1,371 @@
/// 集成测试共用 UI 步骤与轮询助手(P3 Task 11)。
///
/// 文案查找器固定中文(App 装配固定 zh,见 app_boot.dart)。
///
/// 轮询模式说明:IntegrationTestWidgetsFlutterBinding 是 live binding——
/// 真实时间流逝(引擎 15s 周期 drain、退避 timer、SnackBar 动画都在真
/// 时间轴上),因此等待 = `Future.delayed` 真等 + 定期 `tester.pump()`
/// 让帧重建。**刻意不用 pumpAndSettle**:队列页的进度条/加载指示器是不
/// 定动画,pumpAndSettle 会等到自身超时。
library;
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
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/photos/models.dart';
import 'package:photo_live/features/uploads/providers.dart';
import 'package:photo_live/features/uploads/task_state.dart';
import 'app_boot.dart';
import 'it_config.dart';
// ── 通用等待 ───────────────────────────────────────────────────────────────
/// 轮询直到 [finder] 可见;超时抛 Fail(附 [because] 上下文)。
Future<void> pumpUntil(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 30),
String because = 'finder 出现',
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
await tester.pump();
if (finder.evaluate().isNotEmpty) return;
await Future<void>.delayed(const Duration(milliseconds: 200));
}
await tester.pump();
if (finder.evaluate().isNotEmpty) return;
fail('等待超时($timeout)未见到「$because」:$finder');
}
/// 轮询直到谓词为真(store/网络等非 UI 条件)。
Future<T> pollUntil<T>(
WidgetTester tester,
Future<T> Function() probe,
bool Function(T) done, {
required Duration timeout,
String because = '条件满足',
}) async {
final deadline = DateTime.now().add(timeout);
T latest = await probe();
while (!done(latest) && DateTime.now().isBefore(deadline)) {
await tester.pump();
await Future<void>.delayed(const Duration(milliseconds: 500));
latest = await probe();
}
if (!done(latest)) {
fail('等待超时($timeout)「$because」未达成,最后状态:$latest');
}
return latest;
}
/// Material 日期/时间选择器的确认按钮(zh=确定;回退 OK 防本地化差异)。
Future<void> tapDialogOk(WidgetTester tester) async {
final ok = find
.text('确定')
.last
.evaluate()
.isNotEmpty
? find.text('确定').last
: find.text('OK').last;
await tester.tap(ok, warnIfMissed: false);
await tester.pump();
}
// ── 队列状态探针(drift 任务库直读——UI 徽章断言的权威旁证)──────────────
/// 当前账号任务的状态计数。
Future<Map<UploadTaskState, int>> queueSnapshot(
ItAppHarness harness,
String account,
) async {
final store = harness.container.read(taskStoreProvider);
final tasks = await store.listByAccount(account);
final counts = <UploadTaskState, int>{};
for (final task in tasks) {
counts[task.state] = (counts[task.state] ?? 0) + 1;
}
return counts;
}
/// 等待账号下任务数达到 [total] 且全部 completed。
Future<void> waitAllCompleted(
WidgetTester tester,
ItAppHarness harness,
String account,
int total,
) {
return pollUntil<Map<UploadTaskState, int>>(
tester,
() => queueSnapshot(harness, account),
(counts) =>
(counts[UploadTaskState.completed] ?? 0) >= total &&
counts.length == 1,
timeout: const Duration(minutes: 3),
because: '$total 个任务全部 completed',
);
}
// ── 登录 ───────────────────────────────────────────────────────────────────
/// 登录表单填写提交并等待进入首页(FAB「新建相册」可见)。
Future<void> loginViaUi(
WidgetTester tester,
String account,
String password,
) async {
await pumpUntil(
tester,
find.widgetWithText(TextFormField, '账号'),
because: '登录表单',
);
await tester.enterText(
find.widgetWithText(TextFormField, '账号'),
account,
);
await tester.enterText(
find.widgetWithText(TextFormField, '密码'),
password,
);
await tester.tap(find.text('登录'));
// 相册 tab 的 FAB 是首页就绪标志。
await pumpUntil(
tester,
find.text('新建相册'),
timeout: const Duration(seconds: 30),
because: '登录成功进入首页',
);
}
// ── 相册创建(含 Material 日期+时间选择器驱动)────────────────────────────
/// 新建双语相册:zh 标题 → English 标题 → 开始时间(选择器默认值确认)→
/// 创建 → 返回列表见到新卡片。
Future<void> createAlbumViaUi(
WidgetTester tester,
String zhTitle,
String enTitle,
) async {
await tester.tap(find.text('新建相册'));
await pumpUntil(tester, find.text('创建'), because: '相册表单');
await tester.enterText(
find.widgetWithText(TextFormField, '标题 *'),
zhTitle,
);
await tester.tap(find.text('English')); // 内容 tab 切英文条目(非界面语言)
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, '标题'),
enTitle,
);
// 开始时间(必填):占位文本「请选择开始时间」即入口。
await tester.tap(find.text('请选择开始时间'));
await pumpUntil(tester, find.text('确定'), because: '日期选择器');
await tapDialogOk(tester); // 日期:默认今天
await pumpUntil(tester, find.text('确定'), because: '时间选择器');
await tapDialogOk(tester); // 时间:默认当前时刻
await tester.pump();
await tester.ensureVisible(find.text('创建'));
await tester.tap(find.text('创建').last);
await pumpUntil(
tester,
find.text(zhTitle),
timeout: const Duration(seconds: 30),
because: '相册创建成功回到列表',
);
}
/// 打开相册详情(点击卡片标题)。
Future<void> openAlbumDetail(WidgetTester tester, String zhTitle) async {
await tester.tap(find.text(zhTitle));
await pumpUntil(
tester,
find.text('成员管理'),
because: '相册详情页动作行',
);
}
/// 新增分类(详情页对话框)。
Future<void> addCategoryViaUi(
WidgetTester tester,
String zhName,
String enName,
) async {
await tester.ensureVisible(find.byTooltip('新增分类'));
await tester.tap(find.byTooltip('新增分类'));
await pumpUntil(tester, find.text('中文名称'), because: '分类对话框');
await tester.enterText(
find.widgetWithText(TextFormField, '中文名称'),
zhName,
);
await tester.enterText(
find.widgetWithText(TextFormField, '英文名称'),
enName,
);
await tester.tap(find.text('保存').last);
await pumpUntil(
tester,
find.text('$zhName · $enName'),
because: '分类列表出现新分类',
);
}
/// 邀请成员:搜索账号前缀 → 点「邀请」→ 成员列表出现(协作者)。
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);
// 防抖 300ms + 搜索请求。
await Future<void>.delayed(const Duration(seconds: 2));
await tester.pump();
await pumpUntil(
tester,
find.text('邀请'),
because: '搜索结果出现邀请按钮',
);
await tester.tap(find.text('邀请'));
await pumpUntil(
tester,
find.text(account),
timeout: const Duration(seconds: 30),
because: '成员列表出现受邀账号',
);
await pumpUntil(tester, find.text('协作者'), because: '协作者角色标签');
await tester.pageBack();
await tester.pump();
}
/// 详情页 FAB「上传」→「从相册选择」→ 选图替身返回 [paths] → 等待
/// 入队提示。调用前须已在相册详情页。
Future<void> enqueueViaGallery(
WidgetTester tester,
ItAppHarness harness,
List<String> paths,
int expectedCount,
) async {
harness.picker.stage(paths);
// 详情页 InfoCard 的「上传」状态标签与 FAB 文案同字(tagUpload ==
// uploadAction ==「上传」),必须收窄到 FAB 内的文本。
await tester.tap(
find.descendant(
of: find.byType(FloatingActionButton),
matching: find.text('上传'),
),
);
await pumpUntil(tester, find.text('从相册选择'), because: '上传底部菜单');
await tester.tap(find.text('从相册选择'));
await pumpUntil(
tester,
find.text('已加入 $expectedCount 张上传队列'),
because: '入队提示',
);
}
// ── 照片管理 / 服务端历史 ──────────────────────────────────────────────────
/// 打开照片管理页并等待首屏行(含 [minCount] 个「已发布」)。
Future<void> openPhotosPageAndWait(
WidgetTester tester,
int minCount,
) async {
await tester.ensureVisible(find.text('照片管理'));
await tester.tap(find.text('照片管理'));
await pumpUntil(
tester,
find.text('已发布'),
timeout: const Duration(seconds: 60),
because: '照片管理列表出现已发布照片',
);
final published = find.text('已发布').evaluate().length;
if (published < minCount) {
fail('照片管理页已发布数量不足:$published < $minCount');
}
}
// ── ov 观众侧 API 检查(viewer 腿,替代网页断言——登记偏差)──────────────
/// 轮询 ov 公开相册照片数(经真实 cn→worker→outbox→sync→ov 管线)达到
/// [expected] 张。dev:sync 推送周期 2s + worker 处理,正常 <30s,给足 2min。
Future<int> waitOvPublicPhotoCount(String publicToken, int expected) async {
final dio = Dio(
BaseOptions(
baseUrl: ItConfig.ovBase,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
),
);
try {
final deadline = DateTime.now().add(const Duration(minutes: 2));
var last = -1;
while (DateTime.now().isBefore(deadline)) {
try {
final resp = await dio.get<Map<String, dynamic>>(
'/v1/public/albums/$publicToken/photos',
queryParameters: {'limit': 50},
);
final items = (resp.data?['items'] as List<dynamic>? ?? const <dynamic>[]);
last = items.length;
if (last >= expected) return last;
} on DioException {
// 相册/照片尚未同步到 ov:继续轮询。
}
await Future<void>.delayed(const Duration(seconds: 2));
}
fail(
'ov 公开相册照片数未达到 $expected(最后 $last)——'
'检查 dev:worker / dev:sync 是否在运行',
);
} finally {
dio.close();
}
}
/// 隐藏一张照片(管理动作;App 照片页为 Task 4 占位无操作按钮——登记
/// 偏差:经 App 同一 ApiClient 走真实契约端点,随后回 UI 断言状态同步)。
Future<void> hideFirstPhotoViaApi(
ItAppHarness harness,
String albumId,
) async {
final api = harness.container.read(apiClientProvider);
final page = await api.listAlbumPhotos(albumId, limit: 20);
final published = page.items
.where((photo) => photo.visibility == PhotoVisibility.published)
.toList();
if (published.isEmpty) {
fail('相册 $albumId 无已发布照片可隐藏');
}
await api.updatePhotoVisibility(
published.first.photoId,
PhotoVisibility.hidden,
);
}
// ── 测试图片定位 ───────────────────────────────────────────────────────────
/// 解析设备端测试图片并返回小图绝对路径列表(it_a/b/c... 顺序稳定)。
Future<List<String>> resolveSmallImages() async {
final support = await getApplicationSupportDirectory();
final dir = await resolveImageDir(support.path);
final list = Directory(dir)
.listSync()
.whereType<File>()
.map((file) => file.path)
.where((path) {
final name = p.basename(path);
return name.startsWith('it_') &&
name.endsWith('.jpg') &&
!name.startsWith('it_large');
})
.toList()
..sort();
if (list.length < 3) {
fail('测试图片不足 3 张($dir 下仅 ${list.length}):$list');
}
return list;
}