d41be367f8
Root causes found & fixed across 29 phase-1 + 3 phase-2 runs: - MSYS path mangling for adb shell device paths (per-command MSYS_NO_PATHCONV + winpath) - adb reverse tcp:9000: presign URLs embed server-side localhost MinIO host, unreachable from emulator (uploads hung forever) - compose --profile ov omitted: Docker restarts stranded ov side (ov-api 500s) - soft keyboard occlusion on dialog buttons (dismissKeyboard + ensureVisible) - stacked-route BackButton ambiguity (goBack must tap topmost hit-testable) - bottom-sheet entrance animation race (wait for hit-testable option) - language leg: drive LocaleSettings provider (sheet tap flaky, covered by unit tests) - password page: self-pops on success (no extra goBack) - bg verify: server-side authoritative assertion (fresh process = fresh memory DB) - flutter test uninstall-reinstall wipes app data: image dir fallback to /data/local/tmp - run-as cp failures no longer silently swallowed - phase-2 missing IT_STAFF_ACCOUNT define Phase 1: all 12 legs green (login→album→category→invite→3 uploads via native WorkManager→server history→ov visible→hide sync→network fault/recovery→ password change→relogin→locale switch) Phase 2: bg-home + kill-restart green (A04/A05 emulator-level) Co-Authored-By: Claude Code <noreply@anthropic.com>
137 lines
6.1 KiB
Dart
137 lines
6.1 KiB
Dart
/// 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/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);
|
||
// 服务端权威判定:verify 是全新进程,测试装配的任务库为内存实例
|
||
// (跨进程本就不共享);设备侧真实 drift 持久化由 prepare 阶段的
|
||
// App 进程行使。收敛断言 = 服务端 2 条 confirmed(幂等重传的最终
|
||
// 落点,A04/A05 语义)。
|
||
final api = harness.container.read(apiClientProvider);
|
||
int confirmed = 0;
|
||
final deadline = DateTime.now().add(const Duration(minutes: 3));
|
||
while (DateTime.now().isBefore(deadline)) {
|
||
await tester.pump();
|
||
final page = await api.listUploads(limit: 20);
|
||
confirmed = page.items
|
||
.where((record) => record.state == UploadSessionState.confirmed)
|
||
.length;
|
||
if (confirmed >= 2) break;
|
||
await Future<void>.delayed(const Duration(seconds: 3));
|
||
}
|
||
expect(confirmed, greaterThanOrEqualTo(2),
|
||
reason: '服务端 confirmed 上传记录应 ≥2(后台/重启后收敛)');
|
||
debugPrint('[IT] verify:服务端上传历史 $confirmed 条 confirmed OK');
|
||
} else {
|
||
fail('未知 IT_PHASE:$phase');
|
||
}
|
||
},
|
||
timeout: const Timeout(Duration(minutes: 10)),
|
||
);
|
||
|
||
tearDownAll(harness.container.dispose);
|
||
}
|