feat(artifact-browser): download staging - progress, temp file, verify

- 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 <noreply@anthropic.com>
This commit is contained in:
toki 2026-06-10 04:34:06 +09:00
parent f99a089324
commit 56422bc063
6 changed files with 897 additions and 34 deletions

View file

@ -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<List<JenkinsJob>> 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<List<int>> Function(JenkinsBuild build, BuildArtifact artifact);
typedef ArtifactStager =
Future<StagedApk> Function({
required JenkinsBuild build,
required BuildArtifact artifact,
required Stream<List<int>> 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<BuildsPage> createState() => _BuildsPageState();
@ -96,6 +116,8 @@ class _BuildsPageState extends State<BuildsPage> {
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<List<int>>? _downloadSub;
@override
void dispose() {
_downloadSub?.cancel();
super.dispose();
}
Future<void> _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<List<int>>();
_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 {

View file

@ -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<Directory> _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<StagedApk> stageApk({
required JenkinsBuild build,
required BuildArtifact artifact,
required Stream<List<int>> 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<StagedApk> 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<void> cleanup(String path) async {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
}

View file

@ -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<DownloadProgressEvent> 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<DownloadProgressEvent>();
var cancelled = false;
StreamSubscription<List<int>>? 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<String, String> _headers(JenkinsCredentials credentials) {
final raw = '${credentials.username}:${credentials.apiToken}';

View file

@ -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<int>.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<List<int>> errorStream() async* {
yield List<int>.filled(256, 1);
throw StateError('cancelled');
}
await expectLater(
service.stageApk(
build: _makeBuild(),
artifact: _apk,
byteStream: errorStream(),
),
throwsA(isA<StateError>()),
);
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<List<int>> errorStream() async* {
yield List<int>.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<ArtifactVerifyException>()),
);
});
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<ArtifactVerifyException>()),
);
});
test('verify rejects missing file', () async {
await expectLater(
service.verify('${tempDir.path}/nonexistent.apk'),
throwsA(isA<ArtifactVerifyException>()),
);
});
test('verify accepts valid apk file', () async {
final f = File('${tempDir.path}/app-release.apk');
await f.writeAsBytes(List<int>.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,
);
});
});
}

View file

@ -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<void> 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<void>();
await navigateToCtaWithDownloader(
tester,
downloader: (build, artifact) async* {
yield List<int>.filled(512, 1);
await pauseCompleter.future;
yield List<int>.filled(512, 2);
},
stager: ({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>();
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<void>();
await navigateToCtaWithDownloader(
tester,
downloader: (build, artifact) async* {
yield List<int>.filled(256, 1);
await pauseCompleter.future;
yield List<int>.filled(256, 2);
},
stager: ({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>();
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<int>.filled(1024, 1);
},
stager: ({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>();
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<int>.filled(512, 1);
},
stager: ({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>();
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 {

View file

@ -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<int>.filled(1024, 1);
final chunk2 = List<int>.filled(512, 2);
final client = JenkinsClient(
client: MockClient.streaming((request, _) async {
final controller = StreamController<List<int>>();
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<JenkinsClientException>().having(
(e) => e.statusCode,
'statusCode',
403,
),
),
);
});
test('downloadArtifactStream can be cancelled before completion', () async {
final firstChunkCompleter = Completer<void>();
final client = JenkinsClient(
client: MockClient.streaming((request, _) async {
final controller = StreamController<List<int>>();
controller.onListen = () async {
controller.add(List<int>.filled(512, 1));
await firstChunkCompleter.future;
if (!controller.isClosed) {
controller.add(List<int>.filled(512, 2));
await controller.close();
}
};
return http.StreamedResponse(controller.stream, 200);
}),
);
final task = client.downloadArtifactStream(
artifactUrl: artifactUrl,
credentials: credentials,
);
final received = <DownloadProgressEvent>[];
final doneCompleter = Completer<void>();
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',