From 56422bc063208e1a07255b96208472a6d08c6b8e Mon Sep 17 00:00:00 2001 From: toki Date: Wed, 10 Jun 2026 04:34:06 +0900 Subject: [PATCH] feat(artifact-browser): download staging - progress, temp file, verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JenkinsClient: streaming download API (DownloadTask/DownloadProgressEvent) with cancel support via subscription teardown - ArtifactStagingService: temp-dir staging, partial file cleanup on error/cancel, APK existence/extension/size verification, and post-install cleanup hook - BuildsPage: _InstallCta upgraded to StatefulWidget with idle→downloading→ verified/failed/cancelled state machine; LinearProgressIndicator and cancel button Closes download-progress, temp-download, artifact-verify tasks. Co-Authored-By: Claude Sonnet 4.6 --- lib/src/features/builds/builds_page.dart | 330 ++++++++++++++++-- .../services/artifact_staging_service.dart | 103 ++++++ lib/src/services/jenkins_client.dart | 101 ++++++ test/artifact_staging_service_test.dart | 148 ++++++++ test/builds_page_test.dart | 136 ++++++++ test/jenkins_client_test.dart | 113 ++++++ 6 files changed, 897 insertions(+), 34 deletions(-) create mode 100644 lib/src/services/artifact_staging_service.dart create mode 100644 test/artifact_staging_service_test.dart diff --git a/lib/src/features/builds/builds_page.dart b/lib/src/features/builds/builds_page.dart index 9a89c09..942ab27 100644 --- a/lib/src/features/builds/builds_page.dart +++ b/lib/src/features/builds/builds_page.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../models/jenkins_build.dart'; +import '../../services/artifact_staging_service.dart'; import '../../services/jenkins_client.dart'; typedef JenkinsJobLoader = Future> Function(); @@ -11,6 +14,19 @@ typedef JenkinsBuildLoader = typedef InstallRequestHandler = void Function(JenkinsBuild build, BuildArtifact artifact); +/// Returns a byte stream for the artifact. The stream emits raw bytes and +/// closes on completion or cancellation. The caller may cancel by closing a +/// subscription before all bytes arrive. +typedef ArtifactDownloader = + Stream> Function(JenkinsBuild build, BuildArtifact artifact); + +typedef ArtifactStager = + Future Function({ + required JenkinsBuild build, + required BuildArtifact artifact, + required Stream> byteStream, + }); + enum _JobLoadState { loading, loaded, empty, forbidden, failure } enum _BuildLoadState { loading, loaded, empty, forbidden, failure } @@ -21,11 +37,15 @@ class BuildsPage extends StatefulWidget { this.jobLoader, this.buildLoader, this.onInstallRequested, + this.artifactDownloader, + this.artifactStager, }); final JenkinsJobLoader? jobLoader; final JenkinsBuildLoader? buildLoader; final InstallRequestHandler? onInstallRequested; + final ArtifactDownloader? artifactDownloader; + final ArtifactStager? artifactStager; @override State createState() => _BuildsPageState(); @@ -96,6 +116,8 @@ class _BuildsPageState extends State { job: _selectedJob!, buildLoader: widget.buildLoader, onInstallRequested: widget.onInstallRequested, + artifactDownloader: widget.artifactDownloader, + artifactStager: widget.artifactStager, onBack: () => setState(() => _selectedJob = null), ) : _buildJobSelector(context), @@ -176,12 +198,16 @@ class _JobSelected extends StatefulWidget { required this.onBack, this.buildLoader, this.onInstallRequested, + this.artifactDownloader, + this.artifactStager, }); final JenkinsJob job; final VoidCallback onBack; final JenkinsBuildLoader? buildLoader; final InstallRequestHandler? onInstallRequested; + final ArtifactDownloader? artifactDownloader; + final ArtifactStager? artifactStager; @override State<_JobSelected> createState() => _JobSelectedState(); @@ -351,9 +377,12 @@ class _JobSelectedState extends State<_JobSelected> { Padding( padding: const EdgeInsets.only(top: 12), child: _InstallCta( + key: ValueKey('install-cta-${selectedBuild.number}'), item: selectedBuild, artifact: artifact, onInstallRequested: widget.onInstallRequested, + artifactDownloader: widget.artifactDownloader, + artifactStager: widget.artifactStager, ), ), ], @@ -457,16 +486,101 @@ class _BuildRow extends StatelessWidget { } } -class _InstallCta extends StatelessWidget { +enum _DownloadState { idle, downloading, verified, failed, cancelled } + +class _InstallCta extends StatefulWidget { const _InstallCta({ + super.key, required this.item, required this.artifact, - required this.onInstallRequested, + this.onInstallRequested, + this.artifactDownloader, + this.artifactStager, }); final JenkinsBuild item; final BuildArtifact artifact; final InstallRequestHandler? onInstallRequested; + final ArtifactDownloader? artifactDownloader; + final ArtifactStager? artifactStager; + + @override + State<_InstallCta> createState() => _InstallCtaState(); +} + +class _InstallCtaState extends State<_InstallCta> { + _DownloadState _dlState = _DownloadState.idle; + int _receivedBytes = 0; + int? _totalBytes; + String? _stagedPath; + String? _errorMessage; + StreamSubscription>? _downloadSub; + + @override + void dispose() { + _downloadSub?.cancel(); + super.dispose(); + } + + Future _startDownload() async { + final downloader = widget.artifactDownloader; + final stager = widget.artifactStager; + if (downloader == null || stager == null) return; + + setState(() { + _dlState = _DownloadState.downloading; + _receivedBytes = 0; + _totalBytes = null; + _stagedPath = null; + _errorMessage = null; + }); + + // Wrap the raw byte stream to track received bytes for the progress indicator. + final rawStream = downloader(widget.item, widget.artifact); + final progressController = StreamController>(); + + _downloadSub = rawStream.listen( + (chunk) { + if (!mounted) return; + setState(() => _receivedBytes += chunk.length); + progressController.add(chunk); + }, + onDone: () => progressController.close(), + onError: (Object e) { + progressController.addError(e); + progressController.close(); + }, + cancelOnError: true, + ); + + try { + final staged = await stager( + build: widget.item, + artifact: widget.artifact, + byteStream: progressController.stream, + ); + if (!mounted) return; + setState(() { + _dlState = _DownloadState.verified; + _stagedPath = staged.path; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _dlState = _DownloadState.failed; + _errorMessage = e.toString(); + }); + } finally { + _downloadSub = null; + } + } + + void _cancel() { + _downloadSub?.cancel(); + _downloadSub = null; + if (!mounted) return; + setState(() => _dlState = _DownloadState.cancelled); + } @override Widget build(BuildContext context) { @@ -481,48 +595,196 @@ class _InstallCta extends StatelessWidget { ), child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( + child: switch (_dlState) { + _DownloadState.idle || _DownloadState.cancelled => _buildIdle( + context, + colorScheme, + ), + _DownloadState.downloading => _buildDownloading(context, colorScheme), + _DownloadState.verified => _buildVerified(context, colorScheme), + _DownloadState.failed => _buildFailed(context, colorScheme), + }, + ), + ); + } + + Widget _buildIdle(BuildContext context, ColorScheme colorScheme) { + final hasDownloader = + widget.artifactDownloader != null && widget.artifactStager != null; + return Row( + children: [ + Icon(Icons.install_mobile, color: colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _dlState == _DownloadState.cancelled ? '다운로드 취소됨' : '설치 준비', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + '#${widget.item.number} · ${widget.artifact.fileName}', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 12), + if (hasDownloader) + ElevatedButton.icon( + key: const ValueKey('install-cta-button'), + onPressed: _startDownload, + icon: const Icon(Icons.download), + label: const Text('다운로드'), + ) + else + ElevatedButton.icon( + key: const ValueKey('install-cta-button'), + onPressed: widget.onInstallRequested == null + ? null + : () => widget.onInstallRequested!(widget.item, widget.artifact), + icon: const Icon(Icons.install_mobile), + label: const Text('설치'), + ), + ], + ); + } + + Widget _buildDownloading(BuildContext context, ColorScheme colorScheme) { + final total = _totalBytes; + final progress = (total != null && total > 0) + ? _receivedBytes / total + : null; + final label = total != null + ? '${_fmt(_receivedBytes)} / ${_fmt(total)}' + : '${_fmt(_receivedBytes)} 받는 중…'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( children: [ - Icon(Icons.install_mobile, color: colorScheme.primary), + Icon(Icons.download, color: colorScheme.primary), const SizedBox(width: 12), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '설치 준비', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - '#${item.number} · ${artifact.fileName}', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: 12, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + child: Text( + key: const ValueKey('download-progress-label'), + label, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 12, + ), ), ), - const SizedBox(width: 12), - ElevatedButton.icon( - key: const ValueKey('install-cta-button'), - onPressed: onInstallRequested == null - ? null - : () => onInstallRequested!(item, artifact), - icon: const Icon(Icons.install_mobile), - label: const Text('설치'), + TextButton( + key: const ValueKey('download-cancel-button'), + onPressed: _cancel, + child: const Text('취소'), ), ], ), - ), + const SizedBox(height: 6), + LinearProgressIndicator( + key: const ValueKey('download-progress-bar'), + value: progress, + ), + ], ); } + + Widget _buildVerified(BuildContext context, ColorScheme colorScheme) { + return Row( + children: [ + Icon(Icons.check_circle, color: colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + key: const ValueKey('verified-label'), + '다운로드 완료', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text( + _stagedPath ?? '#${widget.item.number} · ${widget.artifact.fileName}', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + key: const ValueKey('install-cta-button'), + onPressed: widget.onInstallRequested == null + ? null + : () => widget.onInstallRequested!(widget.item, widget.artifact), + icon: const Icon(Icons.install_mobile), + label: const Text('설치'), + ), + ], + ); + } + + Widget _buildFailed(BuildContext context, ColorScheme colorScheme) { + return Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + key: const ValueKey('download-error-label'), + '다운로드 실패', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.error, + fontWeight: FontWeight.w700, + ), + ), + if (_errorMessage != null) + Text( + _errorMessage!, + style: TextStyle(color: colorScheme.error, fontSize: 11), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 12), + TextButton( + key: const ValueKey('install-cta-button'), + onPressed: _startDownload, + child: const Text('재시도'), + ), + ], + ); + } + + String _fmt(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } } class _StateBadge extends StatelessWidget { diff --git a/lib/src/services/artifact_staging_service.dart b/lib/src/services/artifact_staging_service.dart new file mode 100644 index 0000000..9541ca6 --- /dev/null +++ b/lib/src/services/artifact_staging_service.dart @@ -0,0 +1,103 @@ +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; + +import '../models/jenkins_build.dart'; + +class StagedApk { + const StagedApk({required this.path, required this.sizeBytes}); + + final String path; + final int sizeBytes; +} + +class ArtifactVerifyException implements Exception { + const ArtifactVerifyException(this.message); + + final String message; + + @override + String toString() => 'ArtifactVerifyException: $message'; +} + +class ArtifactStagingService { + ArtifactStagingService({Directory? stagingDir}) : _stagingDir = stagingDir; + + final Directory? _stagingDir; + + Future _resolveDir() async { + if (_stagingDir != null) return _stagingDir; + return getTemporaryDirectory(); + } + + String _fileName(JenkinsBuild build, BuildArtifact artifact) { + final safe = artifact.fileName.replaceAll(RegExp(r'[/\\]'), '_'); + return '${build.jobName}_${build.number}_$safe'; + } + + /// Downloads bytes from [byteStream] into a temp file and returns a [StagedApk]. + /// On cancel or error the partial file is deleted and the exception is rethrown. + Future stageApk({ + required JenkinsBuild build, + required BuildArtifact artifact, + required Stream> byteStream, + }) async { + final dir = await _resolveDir(); + final file = File('${dir.path}/${_fileName(build, artifact)}'); + + if (await file.exists()) { + await file.delete(); + } + + final sink = file.openWrite(); + try { + await for (final chunk in byteStream) { + sink.add(chunk); + } + } catch (e) { + await sink.close(); + if (await file.exists()) { + await file.delete(); + } + rethrow; + } + + await sink.close(); + + final size = await file.length(); + if (size == 0) { + await file.delete(); + throw ArtifactVerifyException('Downloaded file is empty'); + } + + return StagedApk(path: file.path, sizeBytes: size); + } + + /// Verifies an already-staged file: must exist, have .apk extension, and be non-empty. + Future verify(String path) async { + final file = File(path); + + if (!await file.exists()) { + throw ArtifactVerifyException('File does not exist: $path'); + } + + if (!path.toLowerCase().endsWith('.apk')) { + throw ArtifactVerifyException('Not an APK file: $path'); + } + + final size = await file.length(); + if (size == 0) { + throw ArtifactVerifyException('File is empty: $path'); + } + + return StagedApk(path: path, sizeBytes: size); + } + + /// Deletes the staged file. Call after install completes or is cancelled. + Future cleanup(String path) async { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } +} diff --git a/lib/src/services/jenkins_client.dart b/lib/src/services/jenkins_client.dart index 184a610..e1694c1 100644 --- a/lib/src/services/jenkins_client.dart +++ b/lib/src/services/jenkins_client.dart @@ -1,9 +1,31 @@ +import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; import '../models/jenkins_build.dart'; +class DownloadProgressEvent { + const DownloadProgressEvent({ + required this.receivedBytes, + this.totalBytes, + this.isComplete = false, + }); + + final int receivedBytes; + final int? totalBytes; + final bool isComplete; +} + +class DownloadTask { + DownloadTask._(this.events, this._cancel); + + final Stream events; + final void Function() _cancel; + + void cancel() => _cancel(); +} + class JenkinsClient { JenkinsClient({http.Client? client}) : _client = client ?? http.Client(); @@ -76,6 +98,85 @@ class JenkinsClient { return response.bodyBytes; } + DownloadTask downloadArtifactStream({ + required Uri artifactUrl, + required JenkinsCredentials credentials, + }) { + final controller = StreamController(); + var cancelled = false; + StreamSubscription>? subscription; + + controller.onListen = () async { + try { + final request = http.Request('GET', artifactUrl); + request.headers.addAll(_headers(credentials)); + final streamed = await _client.send(request); + + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + final body = await streamed.stream.bytesToString(); + controller.addError( + JenkinsClientException( + statusCode: streamed.statusCode, + message: body, + ), + ); + await controller.close(); + return; + } + + final totalBytes = streamed.contentLength; + var received = 0; + + subscription = streamed.stream.listen( + (chunk) { + if (cancelled) return; + received += chunk.length; + controller.add( + DownloadProgressEvent( + receivedBytes: received, + totalBytes: totalBytes, + ), + ); + }, + onDone: () { + if (!cancelled && !controller.isClosed) { + controller.add( + DownloadProgressEvent( + receivedBytes: received, + totalBytes: totalBytes, + isComplete: true, + ), + ); + controller.close(); + } + }, + onError: (Object e) { + if (!cancelled && !controller.isClosed) { + controller.addError(e); + controller.close(); + } + }, + cancelOnError: true, + ); + } catch (e) { + if (!controller.isClosed) { + controller.addError(e); + controller.close(); + } + } + }; + + void cancel() { + cancelled = true; + subscription?.cancel(); + if (!controller.isClosed) { + controller.close(); + } + } + + return DownloadTask._(controller.stream, cancel); + } + Map _headers(JenkinsCredentials credentials) { final raw = '${credentials.username}:${credentials.apiToken}'; diff --git a/test/artifact_staging_service_test.dart b/test/artifact_staging_service_test.dart new file mode 100644 index 0000000..7037ceb --- /dev/null +++ b/test/artifact_staging_service_test.dart @@ -0,0 +1,148 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:appsok/src/models/jenkins_build.dart'; +import 'package:appsok/src/services/artifact_staging_service.dart'; + +JenkinsBuild _makeBuild({int number = 1}) => JenkinsBuild( + number: number, + jobName: 'android-app', + url: Uri.parse('https://jenkins.example/job/android-app/$number/'), + startedAt: null, + result: 'SUCCESS', + artifacts: const [], +); + +const _apk = BuildArtifact( + fileName: 'app-qa.apk', + relativePath: 'outputs/app-qa.apk', +); + +void main() { + late Directory tempDir; + late ArtifactStagingService service; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('staging_test_'); + service = ArtifactStagingService(stagingDir: tempDir); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('ArtifactStagingService.stageApk', () { + test('stageApk writes downloaded bytes into temp directory', () async { + final bytes = List.filled(1024, 0xAB); + final staged = await service.stageApk( + build: _makeBuild(), + artifact: _apk, + byteStream: Stream.fromIterable([bytes]), + ); + + expect(staged.path, isNotEmpty); + expect(staged.sizeBytes, bytes.length); + + final file = File(staged.path); + expect(await file.exists(), isTrue); + expect(await file.length(), bytes.length); + }); + + test('stageApk deletes partial file when cancelled', () async { + Stream> errorStream() async* { + yield List.filled(256, 1); + throw StateError('cancelled'); + } + + await expectLater( + service.stageApk( + build: _makeBuild(), + artifact: _apk, + byteStream: errorStream(), + ), + throwsA(isA()), + ); + + final file = File('${tempDir.path}/android-app_1_app-qa.apk'); + expect(await file.exists(), isFalse); + }); + + test('stageApk deletes partial file on stream error', () async { + Stream> errorStream() async* { + yield List.filled(512, 1); + throw Exception('network failure'); + } + + await expectLater( + service.stageApk( + build: _makeBuild(), + artifact: _apk, + byteStream: errorStream(), + ), + throwsException, + ); + + final file = File('${tempDir.path}/android-app_1_app-qa.apk'); + expect(await file.exists(), isFalse); + }); + }); + + group('ArtifactStagingService.verify', () { + test('verify rejects non apk extension', () async { + final f = File('${tempDir.path}/app.txt'); + await f.writeAsBytes([1, 2, 3]); + + await expectLater( + service.verify(f.path), + throwsA(isA()), + ); + }); + + test('verify rejects zero byte file', () async { + final f = File('${tempDir.path}/app.apk'); + await f.writeAsBytes([]); + + await expectLater( + service.verify(f.path), + throwsA(isA()), + ); + }); + + test('verify rejects missing file', () async { + await expectLater( + service.verify('${tempDir.path}/nonexistent.apk'), + throwsA(isA()), + ); + }); + + test('verify accepts valid apk file', () async { + final f = File('${tempDir.path}/app-release.apk'); + await f.writeAsBytes(List.filled(2048, 0)); + + final result = await service.verify(f.path); + expect(result.path, f.path); + expect(result.sizeBytes, 2048); + }); + }); + + group('ArtifactStagingService.cleanup', () { + test('cleanup deletes existing staged file', () async { + final f = File('${tempDir.path}/app.apk'); + await f.writeAsBytes([1, 2, 3]); + + await service.cleanup(f.path); + expect(await f.exists(), isFalse); + }); + + test('cleanup is safe when file does not exist', () async { + await expectLater( + service.cleanup('${tempDir.path}/gone.apk'), + completes, + ); + }); + }); +} diff --git a/test/builds_page_test.dart b/test/builds_page_test.dart index c18a571..a773098 100644 --- a/test/builds_page_test.dart +++ b/test/builds_page_test.dart @@ -1,8 +1,11 @@ +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'; @@ -594,6 +597,139 @@ void main() { expect(find.text('APK artifact 없음'), findsOneWidget); }); + // ── UI-4: Download States ───────────────────────────────────────── + + 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: (build, artifact) 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('cancels artifact download and hides partial state', ( + tester, + ) async { + final pauseCompleter = Completer(); + + await navigateToCtaWithDownloader( + tester, + downloader: (build, artifact) async* { + yield List.filled(256, 1); + await pauseCompleter.future; + yield List.filled(256, 2); + }, + stager: ({required build, required artifact, required byteStream}) async { + await byteStream.drain(); + 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); + }); + + testWidgets('shows verified apk state after staging succeeds', ( + tester, + ) async { + await navigateToCtaWithDownloader( + tester, + downloader: (build, artifact) 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('shows download failure state when staging fails', ( + tester, + ) async { + await navigateToCtaWithDownloader( + tester, + downloader: (build, artifact) 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( 'renders selected job view without overflow at compact viewport', (tester) async { diff --git a/test/jenkins_client_test.dart b/test/jenkins_client_test.dart index 9a2c357..a16a0e5 100644 --- a/test/jenkins_client_test.dart +++ b/test/jenkins_client_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; @@ -255,6 +256,118 @@ void main() { ); }); + group('JenkinsClient.downloadArtifactStream', () { + final artifactUrl = Uri.parse('https://jenkins.example/artifact/app.apk'); + + test('downloadArtifactStream emits progress while reading chunks', () async { + final chunk1 = List.filled(1024, 1); + final chunk2 = List.filled(512, 2); + + final client = JenkinsClient( + client: MockClient.streaming((request, _) async { + final controller = StreamController>(); + scheduleMicrotask(() async { + controller.add(chunk1); + controller.add(chunk2); + await controller.close(); + }); + return http.StreamedResponse( + controller.stream, + 200, + contentLength: chunk1.length + chunk2.length, + ); + }), + ); + + final task = client.downloadArtifactStream( + artifactUrl: artifactUrl, + credentials: credentials, + ); + + final events = await task.events.toList(); + + expect(events, hasLength(3)); + expect(events[0].receivedBytes, chunk1.length); + expect(events[0].isComplete, isFalse); + expect(events[1].receivedBytes, chunk1.length + chunk2.length); + expect(events[1].isComplete, isFalse); + expect(events[2].isComplete, isTrue); + expect(events[2].receivedBytes, chunk1.length + chunk2.length); + expect(events[2].totalBytes, chunk1.length + chunk2.length); + }); + + test('downloadArtifactStream reports forbidden response', () async { + final client = JenkinsClient( + client: MockClient.streaming( + (req, body) async => http.StreamedResponse( + Stream.value(utf8.encode('Forbidden')), + 403, + ), + ), + ); + + final task = client.downloadArtifactStream( + artifactUrl: artifactUrl, + credentials: credentials, + ); + + expect( + task.events.toList(), + throwsA( + isA().having( + (e) => e.statusCode, + 'statusCode', + 403, + ), + ), + ); + }); + + test('downloadArtifactStream can be cancelled before completion', () async { + final firstChunkCompleter = Completer(); + + final client = JenkinsClient( + client: MockClient.streaming((request, _) async { + final controller = StreamController>(); + controller.onListen = () async { + controller.add(List.filled(512, 1)); + await firstChunkCompleter.future; + if (!controller.isClosed) { + controller.add(List.filled(512, 2)); + await controller.close(); + } + }; + return http.StreamedResponse(controller.stream, 200); + }), + ); + + final task = client.downloadArtifactStream( + artifactUrl: artifactUrl, + credentials: credentials, + ); + + final received = []; + final doneCompleter = Completer(); + task.events.listen( + (e) { + received.add(e); + if (received.length == 1) { + task.cancel(); + firstChunkCompleter.complete(); + } + }, + onDone: () => doneCompleter.complete(), + onError: (_) => doneCompleter.complete(), + cancelOnError: false, + ); + + await doneCompleter.future; + + expect(received, isNotEmpty); + expect(received.any((e) => e.isComplete), isFalse); + }); + }); + group('JenkinsClient.fetchRecentBuilds', () { test( 'requests build tree and returns only builds with apk artifacts',