Files
photo-live/apps/mobile/integration_test/helpers/app_boot.dart
T
浅念 d41be367f8 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>
2026-09-20 15:03:17 +08:00

143 lines
5.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 集成测试 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>[];
/// 最近一次 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;
}
}
/// 测试装配产物。
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` 的目录路径;两个候选都为空时抛错(提示先跑脚本)。
/// 注意: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).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(
'设备上找不到测试图片(已尝试 ${candidates.join('、')})——'
'请先运行 scripts/run-mobile-it.sh(负责 adb push + run-as 拷贝)',
);
}