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
@@ -1,7 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically, <!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to run hot reload, etc. -->
--> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.INTERNET"/> <!-- P3 Task 11:debug 联调允许到宿主回环(10.0.2.2)的明文 HTTP——
本地 cn-api(8080)/MinIO(9000)/ov(8081) 均无 TLS。范围收紧见
res/xml/network_security_config.xml(仅回环域,release 不合并)。 -->
<application
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true" />
</manifest> </manifest>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- P3 Task 11:debug 构建专用网络策略。
targetSdk>=28 默认禁明文 HTTP,而本地联调后端/MinIO 观众端全部是
http://10.0.2.2(模拟器宿主回环)——release 不受影响(本文件只在
src/debug 变体参与合并)。仅放行宿主回环与本机回环,不放开全域。 -->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>
@@ -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;
}
@@ -0,0 +1,128 @@
/// P3 Task 11 集成测试 · 后台延续与杀进程恢复(A04/A05,模拟器级)。
///
/// 由 `scripts/run-mobile-it.sh --background` 编排,两阶段经
/// `--dart-define IT_PHASE=` 区分(App 无法自己 HOME/杀自己——设备动作
/// 由脚本在见到 MARKER 后执行,属计划明示的 adb 编排场景):
///
/// - **prepare**:登录(本次运行专属账号)→ API 建相册 → 直接经准备器
/// 入队 2 张大图(~15MB,保证传输有可被中断的时间窗)→ 等任务进入
/// uploading → 打 `MARKER:UPLOADING`(脚本此刻 HOME 或 force-stop)→
/// 就地等待全部 completed(HOME 场景:WorkManager 后台续传完成后本
/// 测试自然通过;KILL 场景:进程被杀,本测试非零退出是预期)。
/// - **verify**:全新进程登录同账号 → 断言 2 张全部 completed(KILL 场景
/// 覆盖 A05:drift 任务库持久 + 崩溃恢复 uploading→waitingNetwork→
/// 幂等重传)→ 服务端历史可见。
library;
import 'package:flutter/foundation.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';
import 'package:photo_live/features/albums/models.dart';
import 'package:photo_live/features/uploads/providers.dart';
import 'package:photo_live/features/uploads/task_state.dart';
import 'helpers/app_boot.dart';
import 'helpers/it_config.dart';
import 'helpers/ui_steps.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final phase = const String.fromEnvironment('IT_PHASE', defaultValue: '');
if (phase.isEmpty) {
fail('缺少 --dart-define IT_PHASE=prepare|verify(经 run-mobile-it.sh 运行)');
}
final harness = ItAppHarness.create();
testWidgets(
'P3 后台延续/杀进程恢复 [$phase]',
(tester) async {
final account = ItConfig.mainAccount;
if (phase == 'prepare') {
await tester.pumpWidget(harness.appWidget());
await loginViaUi(tester, account, ItConfig.mainPassword);
// 相册走 API(建相册的 UI 驱动已在主流程覆盖;本场景主题是传输)。
final api = harness.container.read(apiClientProvider);
final album = await api.createAlbum(
AlbumCreateRequest(
translations: [
AlbumTranslation(
locale: 'zh-CN',
title: '后台测试相册 ${ItConfig.runStamp}',
),
AlbumTranslation(
locale: 'en',
title: 'BG Album ${ItConfig.runStamp}',
),
],
defaultLocale: 'zh-CN',
startAt: DateTime.now().toUtc(),
),
);
debugPrint('[IT] 后台场景相册:${album.id}');
// 大图经准备器直接入队(选图 UI 已在主流程覆盖)。
final support = await getApplicationSupportDirectory();
final dir = await resolveImageDir(support.path);
final largeFiles = [ItConfig.largeFileA, ItConfig.largeFileB]
.map((name) => p.join(dir, name))
.toList();
for (final file in largeFiles) {
final task = await harness
.container
.read(uploadPreparerProvider)
.prepare(
accountId: account,
albumId: album.id,
sourcePath: file,
);
expect(task.state, UploadTaskState.queued, reason: '入队 $file');
}
debugPrint('[IT] 2 张大图已入队:$largeFiles');
// 等首个任务进入 uploading(引擎已领取、原生桥接已注册)。
await pollUntil<Map<UploadTaskState, int>>(
tester,
() => queueSnapshot(harness, account),
(counts) =>
(counts[UploadTaskState.uploading] ?? 0) >= 1 ||
(counts[UploadTaskState.uploadedConfirming] ?? 0) >= 1 ||
(counts[UploadTaskState.completed] ?? 0) >= 1,
timeout: const Duration(seconds: 60),
because: '任务进入传输中(脚本随后执行 HOME/force-stop)',
);
// 脚本协调点:轮询本行出现在 flutter test 输出后立即动作。
debugPrint('[IT] MARKER:UPLOADING');
// HOME 场景:后台完成 → 本断言通过;KILL 场景:进程死于此循环
// (非零退出是预期,脚本以 || true 容忍并以 verify 阶段收口)。
await waitAllCompleted(tester, harness, account, 2);
debugPrint('[IT] MARKER:BG_COMPLETED');
} 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 条已确认记录。
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');
} else {
fail('未知 IT_PHASE:$phase');
}
},
timeout: const Timeout(Duration(minutes: 10)),
);
tearDownAll(harness.container.dispose);
}
@@ -0,0 +1,219 @@
/// P3 Task 11 集成测试 · 主流程(MuMu + 真实 cn-api/MinIO/worker/sync/ov)。
///
/// 覆盖(对照计划 Task 11 第一条):
/// 登录 → 建相册(zh+en,Material 日期+时间选择器)→ 加分类 → 邀成员 →
/// 传 3 图 → 队列全 completed → 服务端历史可见(sourceDeviceId=本设备)→
/// viewer 腿(ov 公开相册 API 见 3 张,替代网页断言——登记偏差)→
/// 隐藏一张 → App 管理列表状态同步 → 断网(DIO 故障注入)waiting_network →
/// 恢复重试 → 修改密码(会话保留)→ 退出重登录 → App 内语言切换冒烟。
///
/// 运行方式(勿直接 flutter test——账号/图片由脚本预置):
/// `bash scripts/run-mobile-it.sh`
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:photo_live/core/api_client.dart';
import 'package:photo_live/features/uploads/task_state.dart';
import 'helpers/app_boot.dart';
import 'helpers/it_config.dart';
import 'helpers/ui_steps.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final harness = ItAppHarness.create();
// 单一大步骤串行全流程:会话/队列状态跨腿复用,顺序即语义。
testWidgets(
'P3 全流程:真实后端 登录→相册→邀请→上传→历史→ov→隐藏→断网→改密→重登录',
(tester) async {
final account = ItConfig.mainAccount;
var images = const <String>[];
// ── 0. 启动与资源定位 ─────────────────────────────────────────────
await tester.pumpWidget(harness.appWidget());
images = await resolveSmallImages();
debugPrint('[IT] 使用测试图片:$images');
// ── 1. 登录(本次运行专属账号)────────────────────────────────────
await loginViaUi(tester, account, ItConfig.mainPassword);
debugPrint('[IT] leg1 登录 OK:$account');
// ── 2. 建相册(zh+en 双语标题 + 必填开始时间)──────────────────────
await createAlbumViaUi(
tester,
ItConfig.albumTitleZh,
ItConfig.albumTitleEn,
);
debugPrint('[IT] leg2 建相册 OK:${ItConfig.albumTitleZh}');
// 相册 id / 公开令牌(后续 API 断言与 ov 检查用)。
final api = harness.container.read(apiClientProvider);
final albumsPage = await api.listAlbums(limit: 50);
final album = albumsPage.items.firstWhere(
(a) => a.translations.any(
(t) => t.locale == 'zh-CN' && t.title == ItConfig.albumTitleZh,
),
);
final albumId = album.id;
final publicToken = album.publicToken;
debugPrint('[IT] albumId=$albumId publicToken=$publicToken');
// ── 3. 详情 + 分类 ────────────────────────────────────────────────
await openAlbumDetail(tester, ItConfig.albumTitleZh);
await addCategoryViaUi(tester, '全程连拍', 'Burst');
debugPrint('[IT] leg3 分类 OK');
// ── 4. 邀请成员(staff 账号由脚本预建)────────────────────────────
await inviteMemberViaUi(tester, ItConfig.staffAccount);
debugPrint('[IT] leg4 邀请 OK:${ItConfig.staffAccount}');
// ── 5. 上传 3 图(选图替身 → 真实准备/队列/引擎/原生桥接/后端)────
await enqueueViaGallery(tester, harness, images, 3);
await waitAllCompleted(tester, harness, account, 3);
debugPrint('[IT] leg5 队列 3 张全部 completed');
// 队列页 UI:3 个「已完成」徽章(IndexedStack 常驻,切页签后断言)。
// 详情页是压在首页骨架之上的路由(无底部导航),先退回首页再切页签。
await tester.pageBack();
await tester.pump();
await tester.tap(find.byIcon(Icons.cloud_upload_outlined));
await tester.pump();
expect(find.text('已完成').evaluate().length, greaterThanOrEqualTo(3));
// ── 6. 服务端上传历史(跨设备记录 + 本设备标记,A19)───────────────
await tester.tap(find.byTooltip('查看服务端上传历史'));
await pumpUntil(
tester,
find.textContaining('本设备'),
timeout: const Duration(seconds: 60),
because: '服务端历史出现本设备记录',
);
final ownDeviceRows = find.textContaining('本设备').evaluate().length;
expect(ownDeviceRows, greaterThanOrEqualTo(3));
debugPrint('[IT] leg6 服务端历史 OK:$ownDeviceRows 条本设备记录');
await tester.pageBack();
await tester.pump();
// ── 7. viewer 腿:ov 公开相册 API 可见 3 张(真实同步管线)────────
final ovCount = await waitOvPublicPhotoCount(publicToken, 3);
expect(ovCount, greaterThanOrEqualTo(3));
debugPrint('[IT] leg7 ov 公开相册 OK:$ovCount 张');
// ── 8. 隐藏一张 → App 管理列表同步「已隐藏」───────────────────────
// (App 照片页为 Task 4 占位无操作按钮——经同一 ApiClient 调真实
// 契约端点隐藏,再回 UI 断言状态同步;登记偏差。)
await hideFirstPhotoViaApi(harness, albumId);
await tester.tap(find.byIcon(Icons.photo_library_outlined));
await tester.pump();
await openAlbumDetail(tester, ItConfig.albumTitleZh);
await openPhotosPageAndWait(tester, 2);
expect(find.text('已隐藏').evaluate().length, greaterThanOrEqualTo(1));
expect(find.text('已发布').evaluate().length, greaterThanOrEqualTo(2));
debugPrint('[IT] leg8 隐藏同步 OK');
await tester.pageBack(); // 照片管理 → 详情
await tester.pump();
// ── 9. 断网场景(DIO 故障注入):waiting_network → 恢复重试 ────────
harness.gate.offline = true;
debugPrint('[IT] leg9 故障闸门关闭(模拟断网)');
await enqueueViaGallery(tester, harness, [images.first], 1);
await pollUntil<Map<UploadTaskState, int>>(
tester,
() => queueSnapshot(harness, account),
(counts) => (counts[UploadTaskState.waitingNetwork] ?? 0) >= 1,
timeout: const Duration(seconds: 90),
because: '断网下任务进入 waitingNetwork',
);
// 队列页 UI:「等待网络」徽章 + 行内重试按钮(先退回首页才有底部导航)。
await tester.pageBack();
await tester.pump();
await tester.tap(find.byIcon(Icons.cloud_upload_outlined));
await tester.pump();
expect(find.text('等待网络').evaluate().length, greaterThanOrEqualTo(1));
debugPrint('[IT] leg9 waitingNetwork 断言 OK(UI+store)');
harness.gate.offline = false;
final retryButton = find.byTooltip('重试');
await tester.ensureVisible(retryButton);
await tester.tap(retryButton);
await waitAllCompleted(tester, harness, account, 4);
debugPrint('[IT] leg9 网络恢复重试 OK:4 张全部 completed');
// ── 10. 修改密码(服务端保留当前会话)→ 会话仍有效 ────────────────
await tester.tap(find.byIcon(Icons.settings_outlined));
await tester.pump();
await tester.tap(find.text('修改密码')); // 设置页入口(此时唯一)
await pumpUntil(
tester,
find.widgetWithText(TextFormField, '当前密码'),
because: '修改密码表单',
);
await tester.enterText(
find.widgetWithText(TextFormField, '当前密码'),
ItConfig.mainPassword,
);
await tester.enterText(
find.widgetWithText(TextFormField, '新密码'),
ItConfig.rotatedPassword,
);
await tester.enterText(
find.widgetWithText(TextFormField, '确认新密码'),
ItConfig.rotatedPassword,
);
await tester.tap(find.text('修改密码').last); // 表单提交按钮
await pumpUntil(
tester,
find.text('密码已修改'),
because: '修改密码成功提示',
);
await tester.pageBack(); // 回设置页
await tester.pump();
// 会话保留断言:当前令牌仍可调需认证端点(refresh 204 语义)。
await harness.container.read(apiClientProvider).refresh();
debugPrint('[IT] leg10 修改密码 OK(会话保留,refresh 成功)');
// ── 11. 退出登录 → 新密码重登录 ───────────────────────────────────
await pumpUntil(
tester,
find.text('退出登录'),
because: '设置页退出入口',
);
await tester.tap(find.text('退出登录'));
await pumpUntil(
tester,
find.widgetWithText(TextFormField, '账号'),
because: '回到登录页',
);
await loginViaUi(tester, account, ItConfig.rotatedPassword);
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);
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 pumpUntil(
tester,
find.text('相册'),
because: '界面切回中文',
);
debugPrint('[IT] leg12 语言切回 zh OK —— 全流程完成');
},
timeout: const Timeout(Duration(minutes: 15)),
);
tearDownAll(harness.container.dispose);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

+39
View File
@@ -326,6 +326,11 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -413,6 +418,11 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob: glob:
dependency: transitive dependency: transitive
description: description:
@@ -525,6 +535,11 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.2.2" version: "0.2.2"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -757,6 +772,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.5.3" version: "1.5.3"
process:
dependency: transitive
description:
name: process
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.0.6"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@@ -970,6 +993,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.3.1"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@@ -1058,6 +1089,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "3.0.3" version: "3.0.3"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.0"
win32: win32:
dependency: transitive dependency: transitive
description: description:
+3
View File
@@ -39,6 +39,9 @@ dependencies:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
# 集成测试(P3 Task 11:MuMu + 真实 cn-api/MinIO/ov 全链路)
integration_test:
sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
build_runner: ^2.15.1 build_runner: ^2.15.1
drift_dev: ^2.35.0 drift_dev: ^2.35.0
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env bash
# P3 Task 11 集成测试编排(MuMu 模拟器 + 真实本地后端全栈)。
#
# 用法:
# bash scripts/run-mobile-it.sh # 阶段 1:主流程(登录→建相册→
# # 邀请→传图→历史→ov→隐藏→断网→
# # 改密→重登录→语言切换)
# bash scripts/run-mobile-it.sh --background # 阶段 2:后台延续(HOME 后
# # WorkManager 续传)+ 杀进程重启
# # 恢复(A04/A05 模拟器级)
#
# 前置:
# · MuMu 已启动(脚本【不会】尝试启动 MuMu;被更新弹窗挡住时请先处理)
# · Docker Desktop 运行中(infra compose:mysql/redis/minio 双地域)
# · 工具链经 scripts/flutter-env.sh 注入(本脚本自行 source)
#
# 数据隔离约定(集成测试【不做 DB reset】——dev 库共享):
# · 每次运行生成全新专属账号(itmain_/itstaff_/itbg1_/itbg2_ + 时间戳),
# 由 services/api seed:it 幂等预置;
# · 相册标题带同一时间戳后缀;密码修改只作用于本次专属账号。
#
# 退出码:0=通过;1=断言/命令失败;2=无可用设备;3=后端未能就绪。
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$REPO_ROOT/scripts/flutter-env.sh"
MOBILE_DIR="$REPO_ROOT/apps/mobile"
APP_ID="com.photolive.photo_live"
TESTDATA_DIR="$MOBILE_DIR/integration_test/testdata"
LOG_DIR="$MOBILE_DIR/build/it-logs"
RUN_STAMP="$(date +%Y%m%d-%H%M%S)"
API_HOST="http://127.0.0.1:8080"
OV_HOST="http://127.0.0.1:8081"
API_BASE_DEVICE="http://10.0.2.2:8080" # 模拟器内看到的宿主
OV_BASE_DEVICE="http://10.0.2.2:8081"
VIEWER_BASE_DEVICE="http://10.0.2.2:5175"
ADB="${ANDROID_HOME}/platform-tools/adb"
log() { echo "[run-mobile-it $RUN_STAMP] $*"; }
die() { log "错误:$1"; exit "${2:-1}"; }
mkdir -p "$LOG_DIR"
# ── 设备(不启动 MuMu,只探测)──────────────────────────────────────────────
connect_device() {
for port in 16384 7555 5555 21503; do
"$ADB" connect "127.0.0.1:$port" >/dev/null 2>&1 || true
done
DEVICE_ID="$("$ADB" devices | awk '$2 == "device" {print $1; exit}')"
if [[ -z "$DEVICE_ID" ]]; then
echo "未发现可用 Android 设备(MuMu 未启动或 adb 未连上)。" >&2
echo "请先启动 MuMu 模拟器(adb connect 127.0.0.1:16384),再重跑本脚本。" >&2
exit 2
fi
log "设备:$DEVICE_ID"
}
# ── 后端就绪(compose 基础设施 + 四个 dev 服务)────────────────────────────
# api/ov 以 health 端点为准;worker/sync 无 HTTP 端点,用 CIM 查 node 命令行
# 防重复拉起(即便重复:BullMQ 任务锁 + 同步 eventId 幂等,双实例安全——
# 仓库 sync 设计明示与常驻 dev:sync 并发安全)。
node_proc_count() { # $1=命令行通配模式
powershell.exe -NoProfile -Command \
"(Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { \$_.CommandLine -like '*$1*' }).Count" \
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 & )
}
wait_http() { # $1=url $2=超时秒 $3=名称
local i=0
until curl -sf "$1" >/dev/null 2>&1; do
i=$((i + 1))
if (( i >= $2 )); then return 1; fi
sleep 2
done
}
ensure_backend() {
log "检查基础设施容器(mysql/redis/minio 双地域)"
( cd "$REPO_ROOT" && docker compose -f infra/docker-compose.yml up -d ) \
|| die "docker compose up 失败(Docker Desktop 是否在运行?)"
if ! wait_http "$API_HOST/v1/health" 5 cn-api; then
spawn_service dev:api "$LOG_DIR/dev-api.log"
fi
if ! wait_http "$OV_HOST/v1/health" 5 ov-api; then
spawn_service dev:ov "$LOG_DIR/dev-ov.log"
fi
if ! wait_http "$API_HOST/v1/health" 90 cn-api; then
tail -n 40 "$LOG_DIR/dev-api.log" || true
die "cn-api(8080) 未能就绪" 3
fi
if ! wait_http "$OV_HOST/v1/health" 90 ov-api; then
tail -n 40 "$LOG_DIR/dev-ov.log" || true
die "ov-api(8081) 未能就绪" 3
fi
if [[ "$(node_proc_count '*worker*dist*main.js*')" == "0" ]]; then
spawn_service dev:worker "$LOG_DIR/dev-worker.log"
fi
if [[ "$(node_proc_count '*sync*dist*main.js*')" == "0" ]]; then
spawn_service dev:sync "$LOG_DIR/dev-sync.log"
fi
# worker/sync 启动缓冲(无探针可等,只保证进程活过冷启动期)。
sleep 3
log "后端就绪:cn-api(8080) ov-api(8081) worker sync"
}
# ── 账号种子(幂等;dev admin 确保 + 本次专属账号)──────────────────────────
seed_users() { # $1=IT_USERS 值(account:password,...)
log "种子用户(seed:it,已存在跳过不改写)"
( cd "$REPO_ROOT" && pnpm --filter @photo/api-cn seed ) \
|| die "seed-admin 失败(.env 的 ADMIN_ACCOUNT/ADMIN_PASSWORD 是否配置?)"
( cd "$REPO_ROOT" && IT_USERS="$1" pnpm --filter @photo/api-cn seed:it ) \
|| die "seed-it-users 失败"
}
# ── 测试素材上设备 + App 预安装(run-as 需要 debug 包已装)─────────────────
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" \
|| 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
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
done
# 上传前台服务通知权限:预授权避免系统权限弹窗打断自动化(33+)。
"$ADB" -s "$DEVICE_ID" shell pm grant "$APP_ID" android.permission.POST_NOTIFICATIONS || true
}
# ── flutter test 包装(tee 日志;$@=额外 dart-define 已拼好的参数)──────────
run_test() { # $1=测试文件 $2=日志名 $3...=--dart-define 串
local file="$1" logf="$2"; shift 2
( cd "$MOBILE_DIR" && flutter test "$file" -d "$DEVICE_ID" "$@" ) \
2>&1 | tee "$LOG_DIR/$logf"
return "${PIPESTATUS[0]}"
}
# ── 服务端旁证(阶段 2 主判定:后端侧确认上传完成)────────────────────────
server_login() { # $1=账号 $2=密码 → stdout token
curl -sf -X POST "$API_HOST/v1/auth/password" \
-H 'content-type: application/json' \
-d "{\"account\":\"$1\",\"password\":\"$2\"}" \
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const j=JSON.parse(s);process.stdout.write(j.sessionToken||'')})"
}
server_confirmed_count() { # $1=token
curl -sf "$API_HOST/v1/uploads?limit=20" -H "Authorization: Bearer $1" \
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const j=JSON.parse(s);process.stdout.write(String((j.items||[]).filter(i=>i.state==='confirmed').length))})"
}
wait_marker() { # $1=日志 $2=marker $3=超时秒
local i=0
until grep -q "$2" "$1" 2>/dev/null; do
i=$((i + 2))
if (( i >= $3 )); then return 1; fi
sleep 2
done
}
gen_password() { echo "ItPw-$(openssl rand -hex 6)"; }
# ═════════════════════════════ 阶段 1:主流程 ═════════════════════════════
phase1() {
local main="itmain_$RUN_STAMP" staff="itstaff_$RUN_STAMP"
local main_pw; main_pw="$(gen_password)"
seed_users "$main:$main_pw,$staff:$(gen_password)"
push_images_and_prep_app
log "阶段 1 运行主流程套件(登录→相册→邀请→上传→历史→ov→隐藏→断网→改密→重登录)"
run_test integration_test/p3_flow_test.dart "phase1-$RUN_STAMP.log" \
--dart-define "API_BASE=$API_BASE_DEVICE" \
--dart-define "VIEWER_BASE=$VIEWER_BASE_DEVICE" \
--dart-define "OV_BASE=$OV_BASE_DEVICE" \
--dart-define "IT_MAIN_ACCOUNT=$main" \
--dart-define "IT_MAIN_PASSWORD=$main_pw" \
--dart-define "IT_STAFF_ACCOUNT=$staff" \
--dart-define "IT_RUN_STAMP=$RUN_STAMP" \
|| die "阶段 1(主流程)失败——日志 $LOG_DIR/phase1-$RUN_STAMP.log"
log "阶段 1 通过 ✔"
}
# ═══════════════ 阶段 2:后台延续 + 杀进程恢复(A04/A05)═══════════════════
bg_scenario() { # $1=场景名(bg-home|kill-restart) $2=账号 $3=密码
local scenario="$1" account="$2" password="$3"
local define_common=(
--dart-define "API_BASE=$API_BASE_DEVICE"
--dart-define "VIEWER_BASE=$VIEWER_BASE_DEVICE"
--dart-define "OV_BASE=$OV_BASE_DEVICE"
--dart-define "IT_MAIN_ACCOUNT=$account"
--dart-define "IT_MAIN_PASSWORD=$password"
--dart-define "IT_RUN_STAMP=$RUN_STAMP"
)
local prep_log="$LOG_DIR/${scenario}-prepare-$RUN_STAMP.log"
log "$scenario:prepare 阶段启动(登录→建相册→入队 2 张大图→进入上传中)"
( cd "$MOBILE_DIR" && flutter test integration_test/p3_background_test.dart \
-d "$DEVICE_ID" "${define_common[@]}" --dart-define IT_PHASE=prepare ) \
>"$prep_log" 2>&1 &
local prep_pid=$!
if ! wait_marker "$prep_log" "MARKER:UPLOADING" 600; then
tail -n 60 "$prep_log" || true
die "$scenario:未等到 MARKER:UPLOADING(含构建安装最长 10 分钟)"
fi
if [[ "$scenario" == "bg-home" ]]; then
log "$scenario:HOME 键切后台(上传仍进行中)"
"$ADB" -s "$DEVICE_ID" shell input keyevent KEYCODE_HOME
local token; token="$(server_login "$account" "$password")"
[[ -n "$token" ]] || die "$scenario:服务端登录失败(旁证无法进行)"
local i=0 confirmed=0
until confirmed="$(server_confirmed_count "$token")" && (( confirmed >= 2 )); do
i=$((i + 5)); if (( i >= 300 )); then
die "$scenario:后台 5 分钟内未见 2 条 confirmed(最后 $confirmed)"
fi
sleep 5
done
log "$scenario:服务端旁证 OK——后台(WorkManager 路径)2 张 confirmed"
# prepare 测试自行收尾(后台 Dart 侧收到完成事件即退出);防冻结:至多等 2 分钟。
local w=0
while kill -0 "$prep_pid" 2>/dev/null && (( w < 120 )); do sleep 5; w=$((w + 5)); done
kill "$prep_pid" 2>/dev/null || true
else
log "$scenario:force-stop 杀进程(上传中)"
"$ADB" -s "$DEVICE_ID" shell am force-stop "$APP_ID"
wait "$prep_pid" || log "$scenario:prepare 被杀后 flutter test 非零退出(预期内)"
sleep 2
fi
log "$scenario:verify 阶段(新进程登录→队列恢复/收口→服务端历史)"
run_test integration_test/p3_background_test.dart \
"${scenario}-verify-$RUN_STAMP.log" \
"${define_common[@]}" --dart-define IT_PHASE=verify \
|| die "$scenario:verify 失败——日志 $LOG_DIR/${scenario}-verify-$RUN_STAMP.log"
log "$scenario 通过 ✔"
}
phase2() {
local bg1="itbg1_$RUN_STAMP" bg2="itbg2_$RUN_STAMP"
local pw1 pw2; pw1="$(gen_password)"; pw2="$(gen_password)"
seed_users "$bg1:$pw1,$bg2:$pw2"
push_images_and_prep_app
bg_scenario bg-home "$bg1" "$pw1"
bg_scenario kill-restart "$bg2" "$pw2"
log "阶段 2 全部通过 ✔(A04 后台延续 + A05 杀进程恢复,模拟器级)"
}
# ── 入口 ───────────────────────────────────────────────────────────────────
main() {
connect_device
ensure_backend
if [[ "${1:-}" == "--background" ]]; then
phase2
elif [[ -z "${1:-}" ]]; then
phase1
else
die "未知参数:$1(可用:--background)"
fi
}
main "$@"
+1
View File
@@ -9,6 +9,7 @@
"start": "tsc -p tsconfig.build.json && node dist/main-cn.js", "start": "tsc -p tsconfig.build.json && node dist/main-cn.js",
"start:ov": "tsc -p tsconfig.build.json && node dist/main-ov.js", "start:ov": "tsc -p tsconfig.build.json && node dist/main-ov.js",
"seed": "tsc -p tsconfig.build.json && node dist/scripts/seed-admin.js", "seed": "tsc -p tsconfig.build.json && node dist/scripts/seed-admin.js",
"seed:it": "tsc -p tsconfig.build.json && node dist/scripts/seed-it-users.js",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint .", "lint": "eslint .",
"test": "jest", "test": "jest",
+102
View File
@@ -0,0 +1,102 @@
// services/api/src/scripts/seed-it-users.ts —— P3 Task 11 集成测试专用
// 用户种子脚本(MuMu + 真实后端套件的预置步骤,由 scripts/run-mobile-it.sh
// 调用)。运行方式:`IT_USERS="a:pw1,b:pw2" pnpm --filter @photo/api-cn seed:it`
// (= tsc 构建后 node dist/scripts/seed-it-users.js)。
//
// 与共享 dev 库的隔离约定(集成测试【不做 DB reset】,见脚本头注释):
// - 每次运行由编排脚本生成【全新唯一账号】(时间戳后缀)传入本脚本;
// - 本脚本只做「不存在则建」,存在即跳过且【绝不改写密码/角色】——
// 与 seed-admin 同幂等语义,重复调用安全;
// - 角色固定 staff:相册创建/邀请查找在 cn 侧仅要求已认证(SessionGuard),
// 测试账号无需 admin 角色。
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { config } from 'dotenv';
import { PrismaClient } from '@photo/db-cn';
import { hashPassword } from '../auth/password';
/** 解析 `account:password,...` 列表(冒号冲突风险可接受:账号不含冒号)。 */
export function parseItUsers(raw: string): Array<{ account: string; password: string }> {
return raw
.split(',')
.map((pair) => pair.trim())
.filter((pair) => pair !== '')
.map((pair) => {
const sep = pair.indexOf(':');
if (sep <= 0) {
throw new Error(`seed-it-users: 非法条目 "${pair}"(应为 account:password)`);
}
const account = pair.slice(0, sep).trim();
const password = pair.slice(sep + 1);
if (account === '' || password === '') {
throw new Error(`seed-it-users: 空账号/密码(${pair})`);
}
return { account, password };
});
}
/** 种子核心(可测):逐个建号,已存在跳过。返回账号 → 是否新建。 */
export async function seedItUsers(
prisma: PrismaClient,
users: Array<{ account: string; password: string }>,
): Promise<Map<string, boolean>> {
const result = new Map<string, boolean>();
for (const { account, password } of users) {
const existing = await prisma.users.findUnique({ where: { account } });
if (existing !== null) {
result.set(account, false);
continue;
}
await prisma.users.create({
data: {
account,
passwordHash: await hashPassword(password),
displayName: account,
role: 'staff',
credentialStatus: 'active',
},
});
result.set(account, true);
}
return result;
}
/** 定位仓库根 .env(与 seed-admin 相同约定:不覆盖已有进程环境变量)。 */
function loadRootEnv(): void {
let dir = __dirname;
while (!existsSync(join(dir, 'pnpm-workspace.yaml'))) {
const parent = dirname(dir);
if (parent === dir) {
throw new Error('seed-it-users: pnpm-workspace.yaml not found');
}
dir = parent;
}
config({ path: join(dir, '.env') });
}
async function main(): Promise<void> {
loadRootEnv();
const raw = process.env.IT_USERS;
if (!raw) {
throw new Error('seed-it-users: IT_USERS 环境变量缺失(格式 account:password,...)');
}
const users = parseItUsers(raw);
const prisma = new PrismaClient();
try {
const created = await seedItUsers(prisma, users);
for (const [account, wasCreated] of created) {
console.log(
`seed-it-users: ${account} ${wasCreated ? 'created (staff)' : 'exists, skipped'}`,
);
}
} finally {
await prisma.$disconnect();
}
}
if (require.main === module) {
void main().catch((err: unknown) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}