import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:appsok/src/features/builds/builds_page.dart'; import 'package:appsok/src/models/jenkins_build.dart'; import 'package:appsok/src/services/artifact_staging_service.dart'; import 'package:appsok/src/services/jenkins_client.dart'; import 'package:appsok/src/theme/app_theme.dart'; Widget _wrap(Widget child) => MaterialApp( theme: AppTheme.light(), home: Scaffold(body: child), ); List _sampleJobs() => [ JenkinsJob( name: 'android-app', fullName: 'team/android-app', url: Uri.parse('https://jenkins.example/job/android-app/'), ), JenkinsJob( name: 'ios-build', url: Uri.parse('https://jenkins.example/job/ios-build/'), ), ]; JenkinsBuild _makeBuild({ int number = 1, String? result = 'SUCCESS', String? requestedBy = 'toki', String? branch = 'main', String? flavor, List artifacts = const [], DateTime? startedAt, }) => JenkinsBuild( number: number, jobName: 'android-app', url: Uri.parse('https://jenkins.example/job/android-app/$number/'), startedAt: startedAt, result: result, artifacts: artifacts, requestedBy: requestedBy, branch: branch, flavor: flavor, ); void _addConnectionStatusTests() { group('BuildsPage connection status UI', () { testWidgets('BuildsPage renders reauth required state without job loader', ( tester, ) async { await tester.pumpWidget( _wrap( const BuildsPage( connectionStatus: JenkinsConnectionStatus.reauthRequired, ), ), ); expect(find.text('재로그인 필요'), findsOneWidget); expect( find.byKey(const ValueKey('job-status-action-button')), findsNothing, ); }); testWidgets( 'BuildsPage renders network retry state and calls retry callback', (tester) async { var retryCalled = false; await tester.pumpWidget( _wrap( BuildsPage( connectionStatus: JenkinsConnectionStatus.networkFailure, onRetryConnection: () => retryCalled = true, ), ), ); expect(find.text('Jenkins 연결 실패'), findsOneWidget); expect( find.byKey(const ValueKey('job-status-action-button')), findsOneWidget, ); await tester.tap( find.byKey(const ValueKey('job-status-action-button')), ); expect(retryCalled, isTrue); }, ); testWidgets('BuildsPage renders server failure retry state', ( tester, ) async { var retryCalled = false; await tester.pumpWidget( _wrap( BuildsPage( connectionStatus: JenkinsConnectionStatus.serverFailure, onRetryConnection: () => retryCalled = true, ), ), ); expect(find.text('Jenkins 응답 오류'), findsOneWidget); expect( find.byKey(const ValueKey('job-status-action-button')), findsOneWidget, ); await tester.tap( find.byKey(const ValueKey('job-status-action-button')), ); expect(retryCalled, isTrue); }); testWidgets( 'BuildsPage renders missing state without retry button when no callback', (tester) async { await tester.pumpWidget( _wrap( const BuildsPage( connectionStatus: JenkinsConnectionStatus.missing, ), ), ); expect(find.text('Jenkins 연결 필요'), findsOneWidget); expect( find.byKey(const ValueKey('job-status-action-button')), findsNothing, ); }, ); }); } void main() { _addConnectionStatusTests(); // ── Job 목록 (기존) ────────────────────────────────────────────── testWidgets('renders login required state without job loader', ( tester, ) async { await tester.pumpWidget(_wrap(const BuildsPage())); expect(find.text('Jenkins 연결 필요'), findsOneWidget); }); testWidgets('renders searchable Jenkins jobs', (tester) async { await tester.pumpWidget( _wrap(BuildsPage(jobLoader: () async => _sampleJobs())), ); await tester.pumpAndSettle(); expect(find.text('android-app'), findsOneWidget); expect(find.text('ios-build'), findsOneWidget); expect(find.byKey(const ValueKey('job-search-field')), findsOneWidget); }); testWidgets('filters jobs by name full name and url', (tester) async { await tester.pumpWidget( _wrap(BuildsPage(jobLoader: () async => _sampleJobs())), ); await tester.pumpAndSettle(); await tester.enterText( find.byKey(const ValueKey('job-search-field')), 'android', ); await tester.pump(); expect(find.text('android-app'), findsOneWidget); expect(find.text('ios-build'), findsNothing); }); testWidgets('shows empty state when no jobs are returned', (tester) async { await tester.pumpWidget(_wrap(BuildsPage(jobLoader: () async => []))); await tester.pumpAndSettle(); expect(find.text('접근 가능한 job 없음'), findsOneWidget); }); testWidgets('shows permission state for forbidden Jenkins response', ( tester, ) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => throw const JenkinsClientException( statusCode: 403, message: 'Forbidden', ), ), ), ); await tester.pumpAndSettle(); expect(find.text('권한 없음'), findsOneWidget); }); testWidgets('shows failure state for network error', (tester) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => throw const JenkinsClientException( statusCode: 500, message: 'Error', ), ), ), ); await tester.pumpAndSettle(); expect(find.text('Jenkins 조회 실패'), findsOneWidget); }); testWidgets('renders job selector without overflow at compact viewport', ( tester, ) async { tester.view.physicalSize = const Size(600, 400); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); await tester.pumpWidget( _wrap(BuildsPage(jobLoader: () async => _sampleJobs())), ); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); // ── UI-1: Build Loader State ───────────────────────────────────── testWidgets('selects a job and loads builds — placeholder replaced', ( tester, ) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [_makeBuild()], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('build-list-placeholder')), findsNothing); expect(find.byKey(const ValueKey('build-search-field')), findsOneWidget); }); testWidgets( 'shows artifact empty state when job selected without build loader', (tester) async { await tester.pumpWidget( _wrap(BuildsPage(jobLoader: () async => _sampleJobs())), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(find.text('APK artifact 없음'), findsOneWidget); }, ); testWidgets('reloads builds when back pressed and job reselected', ( tester, ) async { var loadCount = 0; await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async { loadCount++; return [_makeBuild()]; }, ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(loadCount, 1); await tester.tap(find.byIcon(Icons.arrow_back)); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(loadCount, 2); }); // ── UI-2: Build Rows, Badges, Filtering ────────────────────────── testWidgets('shows requestedBy as primary text in build row', (tester) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [_makeBuild(requestedBy: 'alice')], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(find.text('alice'), findsOneWidget); }); testWidgets('shows fallback text when requestedBy is null', (tester) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [_makeBuild(requestedBy: null)], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(find.text('알 수 없는 사용자'), findsOneWidget); }); testWidgets('shows success badge for successful build', (tester) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [_makeBuild(result: 'SUCCESS')], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(find.text('성공'), findsOneWidget); }); testWidgets('shows failure badge for failed build', (tester) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [_makeBuild(result: 'FAILURE')], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(find.text('실패'), findsOneWidget); }); testWidgets('shows running badge for in-progress build', (tester) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [_makeBuild(result: null)], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(find.text('실행 중'), findsOneWidget); }); testWidgets('shows aborted badge for aborted build', (tester) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [_makeBuild(result: 'ABORTED')], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(find.text('중단됨'), findsOneWidget); }); testWidgets('shows build number and branch in summary row', (tester) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [_makeBuild(number: 42, branch: 'release')], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(find.textContaining('#42'), findsOneWidget); expect(find.textContaining('release'), findsOneWidget); }); testWidgets('shows apk artifact name in summary row', (tester) async { const apk = BuildArtifact( fileName: 'app-release.apk', relativePath: 'outputs/apk/release/app-release.apk', ); await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [ _makeBuild(artifacts: const [apk]), ], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(find.textContaining('app-release.apk'), findsOneWidget); }); testWidgets('filters builds by requestedBy', (tester) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [ _makeBuild(number: 1, requestedBy: 'alice'), _makeBuild(number: 2, requestedBy: 'bob'), ], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); await tester.enterText( find.byKey(const ValueKey('build-search-field')), 'alice', ); await tester.pump(); // 'alice'는 TextField 입력값과 row 텍스트 양쪽에 나타나므로 findsWidgets 사용. expect(find.text('alice'), findsWidgets); expect(find.text('bob'), findsNothing); }); testWidgets('filters builds by build number', (tester) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [ _makeBuild(number: 42, requestedBy: 'alice'), _makeBuild(number: 99, requestedBy: 'bob'), ], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); await tester.enterText( find.byKey(const ValueKey('build-search-field')), '42', ); await tester.pump(); expect(find.text('alice'), findsOneWidget); expect(find.text('bob'), findsNothing); }); testWidgets('filters builds by artifact file name', (tester) async { const apk1 = BuildArtifact( fileName: 'app-staging.apk', relativePath: 'outputs/app-staging.apk', ); const apk2 = BuildArtifact( fileName: 'app-release.apk', relativePath: 'outputs/app-release.apk', ); await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [ _makeBuild( number: 1, requestedBy: 'alice', artifacts: const [apk1], ), _makeBuild(number: 2, requestedBy: 'bob', artifacts: const [apk2]), ], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); await tester.enterText( find.byKey(const ValueKey('build-search-field')), 'staging', ); await tester.pump(); expect(find.text('alice'), findsOneWidget); expect(find.text('bob'), findsNothing); }); testWidgets('shows search empty when no builds match query', (tester) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [_makeBuild(requestedBy: 'alice')], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); await tester.enterText( find.byKey(const ValueKey('build-search-field')), 'zzznomatch', ); await tester.pump(); expect(find.text('검색 결과 없음'), findsOneWidget); }); testWidgets('shows install CTA when apk build row is selected', ( tester, ) async { const apk = BuildArtifact( fileName: 'app-release.apk', relativePath: 'outputs/app-release.apk', ); await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [ _makeBuild(number: 7, artifacts: const [apk]), ], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('install-cta')), findsNothing); await tester.tap(find.byKey(const ValueKey('build-row-7'))); await tester.pump(); expect(find.byKey(const ValueKey('install-cta')), findsOneWidget); expect(find.text('설치 준비'), findsOneWidget); expect(find.text('설치'), findsOneWidget); expect(find.textContaining('app-release.apk'), findsWidgets); }); testWidgets('requests install with selected build and apk artifact', ( tester, ) async { const apk = BuildArtifact( fileName: 'app-release.apk', relativePath: 'outputs/app-release.apk', ); JenkinsBuild? requestedBuild; BuildArtifact? requestedArtifact; await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [ _makeBuild(number: 7, artifacts: const [apk]), ], onInstallRequested: (build, artifact, _) { requestedBuild = build; requestedArtifact = artifact; }, ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('build-row-7'))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('install-cta-button'))); expect(requestedBuild?.number, 7); expect(requestedArtifact?.fileName, 'app-release.apk'); expect(requestedArtifact?.relativePath, 'outputs/app-release.apk'); }); // ── UI-3: Build Empty And Error States ─────────────────────────── testWidgets('shows build forbidden state for 403', (tester) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => throw const JenkinsClientException( statusCode: 403, message: 'Forbidden', ), ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(find.text('build 조회 권한 없음'), findsOneWidget); }); testWidgets('shows build forbidden state for 401', (tester) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => throw const JenkinsClientException( statusCode: 401, message: 'Unauthorized', ), ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(find.text('build 조회 권한 없음'), findsOneWidget); }); testWidgets('shows build failure state for server error', (tester) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => throw const JenkinsClientException( statusCode: 500, message: 'Server Error', ), ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(find.text('build 조회 실패'), findsOneWidget); }); testWidgets('shows artifact empty state for empty build list', ( tester, ) async { await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(find.text('APK artifact 없음'), findsOneWidget); }); // ── UI-4: Download States ───────────────────────────────────────── // Converts a raw byte generator into a DownloadTask carrying progress events. ArtifactDownloader taskDownloader( Stream> Function() makeStream, { int? totalBytes, }) { return (build, artifact) { var received = 0; final events = makeStream().map((chunk) { received += chunk.length; return DownloadProgressEvent( receivedBytes: received, totalBytes: totalBytes, chunkBytes: chunk, ); }); // Append isComplete event after all chunks final withComplete = events.transform( StreamTransformer< DownloadProgressEvent, DownloadProgressEvent >.fromHandlers( handleData: (event, sink) => sink.add(event), handleDone: (sink) { sink.add( DownloadProgressEvent( receivedBytes: received, totalBytes: totalBytes, isComplete: true, ), ); sink.close(); }, ), ); return DownloadTask.fromStream(withComplete); }; } Future navigateToCtaWithDownloader( WidgetTester tester, { required ArtifactDownloader downloader, required ArtifactStager stager, }) async { const apk = BuildArtifact( fileName: 'app-release.apk', relativePath: 'outputs/app-release.apk', ); await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [ _makeBuild(number: 7, artifacts: const [apk]), ], artifactDownloader: downloader, artifactStager: stager, ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('build-row-7'))); await tester.pump(); } testWidgets('shows download progress after install CTA pressed', ( tester, ) async { final pauseCompleter = Completer(); await navigateToCtaWithDownloader( tester, downloader: taskDownloader(() async* { yield List.filled(512, 1); await pauseCompleter.future; yield List.filled(512, 2); }), stager: ({required build, required artifact, required byteStream}) async { await byteStream.drain(); return const StagedApk(path: '/tmp/app.apk', sizeBytes: 1024); }, ); await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pump(); await tester.pump(); expect(find.byKey(const ValueKey('download-progress-bar')), findsOneWidget); expect( find.byKey(const ValueKey('download-cancel-button')), findsOneWidget, ); pauseCompleter.complete(); }); testWidgets('shows determinate progress label when total bytes are known', ( tester, ) async { final pauseCompleter = Completer(); await navigateToCtaWithDownloader( tester, downloader: taskDownloader(() async* { yield List.filled(512, 1); await pauseCompleter.future; yield List.filled(512, 2); }, totalBytes: 1024), stager: ({required build, required artifact, required byteStream}) async { await byteStream.drain(); return const StagedApk(path: '/tmp/app.apk', sizeBytes: 1024); }, ); await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pump(); await tester.pump(); // Determinate label shows "received / total" format expect( find.byWidgetPredicate( (w) => w is Text && (w.key == const ValueKey('download-progress-label')) && (w.data?.contains(' / ') ?? false), ), findsOneWidget, ); pauseCompleter.complete(); }); testWidgets('cancels artifact download and hides partial state', ( tester, ) async { final pauseCompleter = Completer(); final stagerErrorCompleter = Completer(); await navigateToCtaWithDownloader( tester, downloader: taskDownloader(() async* { yield List.filled(256, 1); await pauseCompleter.future; yield List.filled(256, 2); }), stager: ({required build, required artifact, required byteStream}) async { try { await byteStream.drain(); } catch (e) { stagerErrorCompleter.complete(e); rethrow; } return const StagedApk(path: '/tmp/app.apk', sizeBytes: 512); }, ); await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pump(); await tester.pump(); expect( find.byKey(const ValueKey('download-cancel-button')), findsOneWidget, ); await tester.tap(find.byKey(const ValueKey('download-cancel-button'))); await tester.pump(); pauseCompleter.complete(); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('download-progress-bar')), findsNothing); expect(find.text('다운로드 취소됨'), findsOneWidget); expect( (await stagerErrorCompleter.future).toString(), contains('cancelled'), ); }); testWidgets('shows verified apk state after staging succeeds', ( tester, ) async { await navigateToCtaWithDownloader( tester, downloader: taskDownloader(() async* { yield List.filled(1024, 1); }), stager: ({required build, required artifact, required byteStream}) async { await byteStream.drain(); return const StagedApk(path: '/tmp/app.apk', sizeBytes: 1024); }, ); await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('verified-label')), findsOneWidget); expect(find.text('다운로드 완료'), findsOneWidget); }); testWidgets( 'install button in verified state passes staged apk to callback', (tester) async { StagedApk? receivedStaged; const apk = BuildArtifact( fileName: 'app-release.apk', relativePath: 'outputs/app-release.apk', ); await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [ _makeBuild(number: 7, artifacts: const [apk]), ], artifactDownloader: taskDownloader(() async* { yield List.filled(1024, 1); }), artifactStager: ({ required build, required artifact, required byteStream, }) async { await byteStream.drain(); return const StagedApk( path: '/tmp/app-release.apk', sizeBytes: 1024, ); }, onInstallRequested: (build, artifact, staged) { receivedStaged = staged; }, ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('build-row-7'))); await tester.pump(); // Trigger download and wait for verified state await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('verified-label')), findsOneWidget); // Install button in verified state must forward StagedApk to callback await tester.tap(find.byKey(const ValueKey('install-cta-button'))); expect(receivedStaged?.path, '/tmp/app-release.apk'); expect(receivedStaged?.sizeBytes, 1024); }, ); testWidgets('shows download failure state when staging fails', ( tester, ) async { await navigateToCtaWithDownloader( tester, downloader: taskDownloader(() async* { yield List.filled(512, 1); }), stager: ({required build, required artifact, required byteStream}) async { await byteStream.drain(); throw Exception('staging failed'); }, ); await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('download-error-label')), findsOneWidget); expect(find.text('다운로드 실패'), findsOneWidget); }); testWidgets('redacts credential-like values in download failure details', ( tester, ) async { final apiToken = ['api', 'token', 'value'].join('-'); final password = ['pass', 'word', 'value'].join('-'); await navigateToCtaWithDownloader( tester, downloader: taskDownloader(() async* { yield List.filled(512, 1); }), stager: ({required build, required artifact, required byteStream}) async { await byteStream.drain(); throw JenkinsClientException( statusCode: 500, message: 'apiToken=$apiToken password="$password"', ); }, ); await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('download-error-label')), findsOneWidget); expect(find.text('다운로드 실패'), findsOneWidget); expect(find.textContaining('[redacted]'), findsOneWidget); expect(find.textContaining(apiToken), findsNothing); expect(find.textContaining(password), findsNothing); }); testWidgets( 'disposes active download as cancellation and does not stage partial apk', (tester) async { final pauseCompleter = Completer(); final stagerErrorCompleter = Completer(); const apk = BuildArtifact( fileName: 'app-release.apk', relativePath: 'outputs/app-release.apk', ); await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [ _makeBuild(number: 7, artifacts: const [apk]), ], artifactDownloader: taskDownloader(() async* { yield List.filled(256, 1); await pauseCompleter.future; yield List.filled(256, 2); }), artifactStager: ({ required build, required artifact, required byteStream, }) async { try { await byteStream.drain(); } catch (e) { stagerErrorCompleter.complete(e); rethrow; } return const StagedApk(path: '/tmp/app.apk', sizeBytes: 512); }, ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('build-row-7'))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pump(); await tester.pump(); expect( find.byKey(const ValueKey('download-progress-bar')), findsOneWidget, ); // Navigate back — disposes _InstallCta while download is in progress await tester.tap(find.byIcon(Icons.arrow_back)); await tester.pump(); pauseCompleter.complete(); // unblock lingering async* generator await tester.pumpAndSettle(); expect( (await stagerErrorCompleter.future).toString(), contains('cancelled'), ); expect(find.byKey(const ValueKey('verified-label')), findsNothing); }, ); // ── INSTALL_HANDOFF-1: Verified APK handoff ─────────────────────── testWidgets('notifies verified apk handoff after download is verified', ( tester, ) async { StagedApk? handoffStaged; JenkinsBuild? handoffBuild; BuildArtifact? handoffArtifact; await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [ _makeBuild( number: 7, artifacts: const [ BuildArtifact( fileName: 'app-release.apk', relativePath: 'outputs/app-release.apk', ), ], ), ], artifactDownloader: taskDownloader(() async* { yield List.filled(1024, 1); }), artifactStager: ({required build, required artifact, required byteStream}) async { await byteStream.drain(); return const StagedApk( path: '/tmp/staged/app-release.apk', sizeBytes: 1024, ); }, onInstallRequested: (build, artifact, staged) { handoffBuild = build; handoffArtifact = artifact; handoffStaged = staged; }, ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('build-row-7'))); await tester.pump(); // Start download await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('verified-label')), findsOneWidget); // Tap install button in verified state — must fire callback with StagedApk await tester.tap(find.byKey(const ValueKey('install-cta-button'))); expect(handoffBuild?.number, 7); expect(handoffArtifact?.fileName, 'app-release.apk'); expect(handoffStaged?.path, '/tmp/staged/app-release.apk'); expect(handoffStaged?.sizeBytes, 1024); }); testWidgets('disables verified install handoff after first request', ( tester, ) async { var handoffCount = 0; await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [ _makeBuild( number: 7, artifacts: const [ BuildArtifact( fileName: 'app-release.apk', relativePath: 'outputs/app-release.apk', ), ], ), ], artifactDownloader: taskDownloader(() async* { yield List.filled(1024, 1); }), artifactStager: ({required build, required artifact, required byteStream}) async { await byteStream.drain(); return const StagedApk( path: '/tmp/staged/app-release.apk', sizeBytes: 1024, ); }, onInstallRequested: (_, _, _) => handoffCount++, ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('build-row-7'))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pump(); expect(handoffCount, 1); final button = tester.widget( find.byKey(const ValueKey('install-cta-button')), ); expect(button.onPressed, isNull); }); testWidgets('cleans verified staged apk when abandoned before handoff', ( tester, ) async { final cleanedPaths = []; await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [ _makeBuild( number: 7, artifacts: const [ BuildArtifact( fileName: 'app-release.apk', relativePath: 'outputs/app-release.apk', ), ], ), ], artifactDownloader: taskDownloader(() async* { yield List.filled(1024, 1); }), artifactStager: ({required build, required artifact, required byteStream}) async { await byteStream.drain(); return const StagedApk( path: '/tmp/staged/app-release.apk', sizeBytes: 1024, ); }, artifactCleaner: (path) async => cleanedPaths.add(path), ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('build-row-7'))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('verified-label')), findsOneWidget); await tester.tap(find.byIcon(Icons.arrow_back)); await tester.pumpAndSettle(); expect(cleanedPaths, ['/tmp/staged/app-release.apk']); }); testWidgets('keeps verified staged apk after handoff owns it', ( tester, ) async { final cleanedPaths = []; await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [ _makeBuild( number: 7, artifacts: const [ BuildArtifact( fileName: 'app-release.apk', relativePath: 'outputs/app-release.apk', ), ], ), ], artifactDownloader: taskDownloader(() async* { yield List.filled(1024, 1); }), artifactStager: ({required build, required artifact, required byteStream}) async { await byteStream.drain(); return const StagedApk( path: '/tmp/staged/app-release.apk', sizeBytes: 1024, ); }, artifactCleaner: (path) async => cleanedPaths.add(path), onInstallRequested: (_, _, _) {}, ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('build-row-7'))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pumpWidget(_wrap(const SizedBox.shrink())); await tester.pumpAndSettle(); expect(cleanedPaths, isEmpty); }); testWidgets( 'renders selected job view without overflow at compact viewport', (tester) async { tester.view.physicalSize = const Size(600, 400); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); const apk = BuildArtifact( fileName: 'very-long-artifact-name-that-might-overflow.apk', relativePath: 'outputs/release/very-long-artifact-name-that-might-overflow.apk', ); await tester.pumpWidget( _wrap( BuildsPage( jobLoader: () async => _sampleJobs(), buildLoader: (_) async => [ _makeBuild( requestedBy: 'very-long-user-name-that-could-overflow-the-row', branch: 'feature/very-long-branch-name-for-testing', flavor: 'productionReleaseFlavor', artifacts: const [apk], ), ], ), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('android-app')); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }, ); }