- AppSokApp uses restoreDetailed() and maps JenkinsSessionRestoreKind to JenkinsConnectionStatus; network/server failures expose a retry callback - AppSokShell passes connectionStatus + onRetryConnection through to BuildsPage - BuildsPage shows four distinct no-loader states: missing, reauthRequired, networkFailure (with retry), serverFailure (with retry) - SettingsPage clears all transient login/save/clear state on base URL change - pubspec.lock restored to meta 1.18.0 / test_api 0.7.11 (reverts container downgrade) - Regression tests added in app_auth_test, builds_page_test, settings_page_test; widget_test updated to expect 재로그인 필요 on 401 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1322 lines
40 KiB
Dart
1322 lines
40 KiB
Dart
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<JenkinsJob> _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<BuildArtifact> 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<List<int>> 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<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: taskDownloader(() 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('shows determinate progress label when total bytes are known', (
|
|
tester,
|
|
) async {
|
|
final pauseCompleter = Completer<void>();
|
|
|
|
await navigateToCtaWithDownloader(
|
|
tester,
|
|
downloader: taskDownloader(() async* {
|
|
yield List<int>.filled(512, 1);
|
|
await pauseCompleter.future;
|
|
yield List<int>.filled(512, 2);
|
|
}, totalBytes: 1024),
|
|
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();
|
|
|
|
// 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<void>();
|
|
final stagerErrorCompleter = Completer<Object>();
|
|
|
|
await navigateToCtaWithDownloader(
|
|
tester,
|
|
downloader: taskDownloader(() async* {
|
|
yield List<int>.filled(256, 1);
|
|
await pauseCompleter.future;
|
|
yield List<int>.filled(256, 2);
|
|
}),
|
|
stager: ({required build, required artifact, required byteStream}) async {
|
|
try {
|
|
await byteStream.drain<void>();
|
|
} 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<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(
|
|
'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<int>.filled(1024, 1);
|
|
}),
|
|
artifactStager:
|
|
({
|
|
required build,
|
|
required artifact,
|
|
required byteStream,
|
|
}) async {
|
|
await byteStream.drain<void>();
|
|
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<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('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<int>.filled(512, 1);
|
|
}),
|
|
stager: ({required build, required artifact, required byteStream}) async {
|
|
await byteStream.drain<void>();
|
|
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<void>();
|
|
final stagerErrorCompleter = Completer<Object>();
|
|
|
|
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<int>.filled(256, 1);
|
|
await pauseCompleter.future;
|
|
yield List<int>.filled(256, 2);
|
|
}),
|
|
artifactStager:
|
|
({
|
|
required build,
|
|
required artifact,
|
|
required byteStream,
|
|
}) async {
|
|
try {
|
|
await byteStream.drain<void>();
|
|
} 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<int>.filled(1024, 1);
|
|
}),
|
|
artifactStager:
|
|
({required build, required artifact, required byteStream}) async {
|
|
await byteStream.drain<void>();
|
|
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<int>.filled(1024, 1);
|
|
}),
|
|
artifactStager:
|
|
({required build, required artifact, required byteStream}) async {
|
|
await byteStream.drain<void>();
|
|
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<ElevatedButton>(
|
|
find.byKey(const ValueKey('install-cta-button')),
|
|
);
|
|
expect(button.onPressed, isNull);
|
|
});
|
|
|
|
testWidgets('cleans verified staged apk when abandoned before handoff', (
|
|
tester,
|
|
) async {
|
|
final cleanedPaths = <String>[];
|
|
|
|
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<int>.filled(1024, 1);
|
|
}),
|
|
artifactStager:
|
|
({required build, required artifact, required byteStream}) async {
|
|
await byteStream.drain<void>();
|
|
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 = <String>[];
|
|
|
|
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<int>.filled(1024, 1);
|
|
}),
|
|
artifactStager:
|
|
({required build, required artifact, required byteStream}) async {
|
|
await byteStream.drain<void>();
|
|
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);
|
|
},
|
|
);
|
|
}
|