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:
parent
f99a089324
commit
56422bc063
6 changed files with 897 additions and 34 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
103
lib/src/services/artifact_staging_service.dart
Normal file
103
lib/src/services/artifact_staging_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}';
|
||||
|
||||
|
|
|
|||
148
test/artifact_staging_service_test.dart
Normal file
148
test/artifact_staging_service_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue