appsok/test/builds_page_test.dart
toki 688e907574 feat: jenkins credential milestone - app wiring & session foundation
- Move artifact-browser milestone to archive
- Add Jenkins artifact session service
- Add token store tests
- Update app wiring for Jenkins credential feature
- Update builds page and Jenkins client
- Add code review and plan documents for G06
2026-06-11 09:01:26 +09:00

1204 lines
36 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 main() {
// ── 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(
'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);
},
);
}