diff --git a/apps/mobile/android/app/src/debug/AndroidManifest.xml b/apps/mobile/android/app/src/debug/AndroidManifest.xml index 399f698..87eeba2 100644 --- a/apps/mobile/android/app/src/debug/AndroidManifest.xml +++ b/apps/mobile/android/app/src/debug/AndroidManifest.xml @@ -1,7 +1,12 @@ - + to allow setting breakpoints, to run hot reload, etc. --> + + + diff --git a/apps/mobile/android/app/src/debug/res/xml/network_security_config.xml b/apps/mobile/android/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..058f594 --- /dev/null +++ b/apps/mobile/android/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,12 @@ + + + + + 10.0.2.2 + 127.0.0.1 + localhost + + diff --git a/apps/mobile/integration_test/helpers/app_boot.dart b/apps/mobile/integration_test/helpers/app_boot.dart new file mode 100644 index 0000000..88396b3 --- /dev/null +++ b/apps/mobile/integration_test/helpers/app_boot.dart @@ -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 _staged = const []; + + /// 暂存下一批返回路径。 + void stage(List paths) => _staged = paths; + + List consume() { + final paths = _staged; + _staged = const []; + 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 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(); + if (list.isNotEmpty) return dir; + } + throw StateError( + '设备上找不到测试图片(已尝试 ${candidates.join('、')})——' + '请先运行 scripts/run-mobile-it.sh(负责 adb push + run-as 拷贝)', + ); +} diff --git a/apps/mobile/integration_test/helpers/fault_gate.dart b/apps/mobile/integration_test/helpers/fault_gate.dart new file mode 100644 index 0000000..a070aab --- /dev/null +++ b/apps/mobile/integration_test/helpers/fault_gate.dart @@ -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 fetch( + RequestOptions options, + Stream? requestStream, + Future? 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); +} diff --git a/apps/mobile/integration_test/helpers/it_config.dart b/apps/mobile/integration_test/helpers/it_config.dart new file mode 100644 index 0000000..5177f5a --- /dev/null +++ b/apps/mobile/integration_test/helpers/it_config.dart @@ -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 = [ + 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'; +} diff --git a/apps/mobile/integration_test/helpers/ui_steps.dart b/apps/mobile/integration_test/helpers/ui_steps.dart new file mode 100644 index 0000000..4319952 --- /dev/null +++ b/apps/mobile/integration_test/helpers/ui_steps.dart @@ -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 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.delayed(const Duration(milliseconds: 200)); + } + await tester.pump(); + if (finder.evaluate().isNotEmpty) return; + fail('等待超时($timeout)未见到「$because」:$finder'); +} + +/// 轮询直到谓词为真(store/网络等非 UI 条件)。 +Future pollUntil( + WidgetTester tester, + Future 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.delayed(const Duration(milliseconds: 500)); + latest = await probe(); + } + if (!done(latest)) { + fail('等待超时($timeout)「$because」未达成,最后状态:$latest'); + } + return latest; +} + +/// Material 日期/时间选择器的确认按钮(zh=确定;回退 OK 防本地化差异)。 +Future 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> queueSnapshot( + ItAppHarness harness, + String account, +) async { + final store = harness.container.read(taskStoreProvider); + final tasks = await store.listByAccount(account); + final counts = {}; + for (final task in tasks) { + counts[task.state] = (counts[task.state] ?? 0) + 1; + } + return counts; +} + +/// 等待账号下任务数达到 [total] 且全部 completed。 +Future waitAllCompleted( + WidgetTester tester, + ItAppHarness harness, + String account, + int total, +) { + return pollUntil>( + tester, + () => queueSnapshot(harness, account), + (counts) => + (counts[UploadTaskState.completed] ?? 0) >= total && + counts.length == 1, + timeout: const Duration(minutes: 3), + because: '$total 个任务全部 completed', + ); +} + +// ── 登录 ─────────────────────────────────────────────────────────────────── + +/// 登录表单填写提交并等待进入首页(FAB「新建相册」可见)。 +Future 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 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 openAlbumDetail(WidgetTester tester, String zhTitle) async { + await tester.tap(find.text(zhTitle)); + await pumpUntil( + tester, + find.text('成员管理'), + because: '相册详情页动作行', + ); +} + +/// 新增分类(详情页对话框)。 +Future 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 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.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 enqueueViaGallery( + WidgetTester tester, + ItAppHarness harness, + List 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 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 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>( + '/v1/public/albums/$publicToken/photos', + queryParameters: {'limit': 50}, + ); + final items = (resp.data?['items'] as List? ?? const []); + last = items.length; + if (last >= expected) return last; + } on DioException { + // 相册/照片尚未同步到 ov:继续轮询。 + } + await Future.delayed(const Duration(seconds: 2)); + } + fail( + 'ov 公开相册照片数未达到 $expected(最后 $last)——' + '检查 dev:worker / dev:sync 是否在运行', + ); + } finally { + dio.close(); + } +} + +/// 隐藏一张照片(管理动作;App 照片页为 Task 4 占位无操作按钮——登记 +/// 偏差:经 App 同一 ApiClient 走真实契约端点,随后回 UI 断言状态同步)。 +Future 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> resolveSmallImages() async { + final support = await getApplicationSupportDirectory(); + final dir = await resolveImageDir(support.path); + final list = Directory(dir) + .listSync() + .whereType() + .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; +} diff --git a/apps/mobile/integration_test/p3_background_test.dart b/apps/mobile/integration_test/p3_background_test.dart new file mode 100644 index 0000000..3925379 --- /dev/null +++ b/apps/mobile/integration_test/p3_background_test.dart @@ -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>( + 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); +} diff --git a/apps/mobile/integration_test/p3_flow_test.dart b/apps/mobile/integration_test/p3_flow_test.dart new file mode 100644 index 0000000..1779f13 --- /dev/null +++ b/apps/mobile/integration_test/p3_flow_test.dart @@ -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 []; + + // ── 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>( + 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); +} diff --git a/apps/mobile/integration_test/testdata/it_a.jpg b/apps/mobile/integration_test/testdata/it_a.jpg new file mode 100644 index 0000000..6022971 Binary files /dev/null and b/apps/mobile/integration_test/testdata/it_a.jpg differ diff --git a/apps/mobile/integration_test/testdata/it_b.jpg b/apps/mobile/integration_test/testdata/it_b.jpg new file mode 100644 index 0000000..8a1993c Binary files /dev/null and b/apps/mobile/integration_test/testdata/it_b.jpg differ diff --git a/apps/mobile/integration_test/testdata/it_c.jpg b/apps/mobile/integration_test/testdata/it_c.jpg new file mode 100644 index 0000000..b2c8c7f Binary files /dev/null and b/apps/mobile/integration_test/testdata/it_c.jpg differ diff --git a/apps/mobile/integration_test/testdata/it_large_a.jpg b/apps/mobile/integration_test/testdata/it_large_a.jpg new file mode 100644 index 0000000..7a4c3ed Binary files /dev/null and b/apps/mobile/integration_test/testdata/it_large_a.jpg differ diff --git a/apps/mobile/integration_test/testdata/it_large_b.jpg b/apps/mobile/integration_test/testdata/it_large_b.jpg new file mode 100644 index 0000000..2e0d986 Binary files /dev/null and b/apps/mobile/integration_test/testdata/it_large_b.jpg differ diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index e380701..866338d 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -326,6 +326,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -413,6 +418,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -525,6 +535,11 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.2.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -757,6 +772,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted 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: dependency: transitive description: @@ -970,6 +993,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted 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: dependency: transitive description: @@ -1058,6 +1089,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted 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: dependency: transitive description: diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index b86d087..83657a1 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -39,6 +39,9 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + # 集成测试(P3 Task 11:MuMu + 真实 cn-api/MinIO/ov 全链路) + integration_test: + sdk: flutter flutter_lints: ^6.0.0 build_runner: ^2.15.1 drift_dev: ^2.35.0 diff --git a/scripts/run-mobile-it.sh b/scripts/run-mobile-it.sh new file mode 100644 index 0000000..70349a4 --- /dev/null +++ b/scripts/run-mobile-it.sh @@ -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 "$@" diff --git a/services/api/package.json b/services/api/package.json index 37e4075..9e80232 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -9,6 +9,7 @@ "start": "tsc -p tsconfig.build.json && node dist/main-cn.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:it": "tsc -p tsconfig.build.json && node dist/scripts/seed-it-users.js", "typecheck": "tsc --noEmit", "lint": "eslint .", "test": "jest", diff --git a/services/api/src/scripts/seed-it-users.ts b/services/api/src/scripts/seed-it-users.ts new file mode 100644 index 0000000..e56b64e --- /dev/null +++ b/services/api/src/scripts/seed-it-users.ts @@ -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> { + const result = new Map(); + 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 { + 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; + }); +}