appsok/test/widget_test.dart
toki 64f9fb3403 feat(install): 설치 lifecycle cleanup을 처리한다
ADB 설치 종료 후 staged APK를 정리하고 결과 표시를 유지해야 한다. Artifact handoff 완료 상태와 USB 설치 cleanup 책임도 로드맵에 반영한다.
2026-06-10 19:58:23 +09:00

783 lines
26 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appsok/src/app.dart';
import 'package:appsok/src/features/app_shell.dart';
import 'package:appsok/src/models/adb_device.dart';
import 'package:appsok/src/models/jenkins_build.dart';
import 'package:appsok/src/models/pending_install.dart';
import 'package:appsok/src/features/builds/builds_page.dart' show ArtifactCleaner;
import 'package:appsok/src/services/adb_service.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';
void main() {
test('keeps the AppSok work app theme baseline', () {
final theme = AppTheme.light();
final colorScheme = theme.colorScheme;
expect(theme.useMaterial3, isTrue);
expect(colorScheme.primary, const Color(0xFF0B6E69));
expect(colorScheme.secondary, const Color(0xFFD66B3D));
expect(colorScheme.tertiary, const Color(0xFF6655A8));
expect(theme.scaffoldBackgroundColor, const Color(0xFFFAFBF7));
});
testWidgets('renders the app shell', (WidgetTester tester) async {
await tester.pumpWidget(const AppSokApp());
expect(find.text('AppSok'), findsOneWidget);
expect(find.text('빌드'), findsWidgets);
expect(find.text('디바이스'), findsOneWidget);
expect(find.text('콘솔'), findsOneWidget);
expect(find.text('설정'), findsOneWidget);
});
testWidgets('renders shell pages without overflow at compact viewport', (
WidgetTester tester,
) async {
tester.view.physicalSize = const Size(600, 400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(const AppSokApp());
final navTargets = ['빌드', '디바이스', '콘솔', '설정'];
for (final label in navTargets) {
final navLabel = find.descendant(
of: find.byType(NavigationRail),
matching: find.text(label),
);
await tester.tap(navLabel);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
}
});
// ── INSTALL_HANDOFF-2: Shell handoff routing ─────────────────────
testWidgets('routes verified apk handoff to devices page', (tester) async {
const apk = BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/app-release.apk',
);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AppSokShell(
jobLoader: () async => [
JenkinsJob(
name: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/'),
),
],
buildLoader: (_) async => [
JenkinsBuild(
number: 7,
jobName: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/7/'),
startedAt: null,
result: 'SUCCESS',
artifacts: const [apk],
requestedBy: 'toki',
),
],
artifactDownloader: (build, artifact) {
var received = 0;
final events = (() async* {
final chunk = List<int>.filled(1024, 1);
received += chunk.length;
yield DownloadProgressEvent(
receivedBytes: received,
totalBytes: 1024,
chunkBytes: chunk,
isComplete: true,
);
})();
return DownloadTask.fromStream(events);
},
artifactStager:
({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>();
return const StagedApk(
path: '/tmp/staged/app-release.apk',
sizeBytes: 1024,
);
},
),
),
);
await tester.pumpAndSettle();
// Navigate to builds, select job, select build
await tester.tap(find.text('android-app').first);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('build-row-7')));
await tester.pump();
// Start 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);
// Tap install in verified state — shell should switch to devices page
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
// Devices page is now active and shows pending install banner
expect(
find.byKey(const ValueKey('pending-install-banner')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('pending-install-filename')),
findsOneWidget,
);
expect(find.text('app-release.apk'), findsOneWidget);
});
// ── INSTALL_HANDOFF-3: Device page pending install UI ────────────
testWidgets('shows pending apk install summary on devices page', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AppSokShell(
jobLoader: () async => [
JenkinsJob(
name: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/'),
),
],
buildLoader: (_) async => [
JenkinsBuild(
number: 7,
jobName: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/7/'),
startedAt: null,
result: 'SUCCESS',
artifacts: const [
BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/app-release.apk',
),
],
requestedBy: 'toki',
),
],
artifactDownloader: (build, artifact) {
var received = 0;
final events = (() async* {
final chunk = List<int>.filled(2 * 1024 * 1024, 1);
received += chunk.length;
yield DownloadProgressEvent(
receivedBytes: received,
totalBytes: 2 * 1024 * 1024,
chunkBytes: chunk,
isComplete: true,
);
})();
return DownloadTask.fromStream(events);
},
artifactStager:
({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>();
return const StagedApk(
path: '/tmp/staged/app-release.apk',
sizeBytes: 2 * 1024 * 1024,
);
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app').first);
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.pumpAndSettle();
expect(
find.byKey(const ValueKey('pending-install-banner')),
findsOneWidget,
);
expect(find.text('app-release.apk'), findsOneWidget);
// Size label should show MB
expect(find.textContaining('MB'), findsOneWidget);
// Local APK path must be visible
expect(find.byKey(const ValueKey('pending-install-path')), findsOneWidget);
expect(find.text('/tmp/staged/app-release.apk'), findsOneWidget);
});
testWidgets('cancels pending install and cleans staged apk', (tester) async {
final cleanedPaths = <String>[];
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AppSokShell(
jobLoader: () async => [
JenkinsJob(
name: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/'),
),
],
buildLoader: (_) async => [
JenkinsBuild(
number: 7,
jobName: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/7/'),
startedAt: null,
result: 'SUCCESS',
artifacts: const [
BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/app-release.apk',
),
],
requestedBy: 'toki',
),
],
artifactDownloader: (build, artifact) {
var received = 0;
final events = (() async* {
final chunk = List<int>.filled(1024, 1);
received += chunk.length;
yield DownloadProgressEvent(
receivedBytes: received,
totalBytes: 1024,
chunkBytes: chunk,
isComplete: true,
);
})();
return DownloadTask.fromStream(events);
},
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').first);
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.pumpAndSettle();
expect(
find.byKey(const ValueKey('pending-install-banner')),
findsOneWidget,
);
await tester.tap(
find.byKey(const ValueKey('pending-install-cancel-button')),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('pending-install-banner')), findsNothing);
expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
});
testWidgets(
'keeps install button disabled when shell has no installer',
(tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AppSokShell(
jobLoader: () async => [
JenkinsJob(
name: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/'),
),
],
buildLoader: (_) async => [
JenkinsBuild(
number: 7,
jobName: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/7/'),
startedAt: null,
result: 'SUCCESS',
artifacts: const [
BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/app-release.apk',
),
],
requestedBy: 'toki',
),
],
artifactDownloader: (build, artifact) {
var received = 0;
final events = (() async* {
final chunk = List<int>.filled(1024, 1);
received += chunk.length;
yield DownloadProgressEvent(
receivedBytes: received,
totalBytes: 1024,
chunkBytes: chunk,
isComplete: true,
);
})();
return DownloadTask.fromStream(events);
},
artifactStager:
({
required build,
required artifact,
required byteStream,
}) async {
await byteStream.drain<void>();
return const StagedApk(
path: '/tmp/staged/app-release.apk',
sizeBytes: 1024,
);
},
// No installer — install buttons must be disabled
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app').first);
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.pumpAndSettle();
expect(
find.byKey(const ValueKey('pending-install-banner')),
findsOneWidget,
);
final installButtons = tester.widgetList<IconButton>(
find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치'),
);
for (final btn in installButtons) {
expect(btn.onPressed, isNull);
}
},
);
// ── UILC: Install lifecycle regression ──────────────────────────
Future<void> navigateToDevicesWithPending(WidgetTester tester) async {
await tester.pumpAndSettle();
await tester.tap(find.text('android-app').first);
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.pumpAndSettle();
}
Widget shellWithInstaller({
required Future<AdbInstallResult> Function(AdbDevice, PendingInstall)
installerFn,
ArtifactCleaner? artifactCleaner,
}) {
return MaterialApp(
theme: AppTheme.light(),
home: AppSokShell(
jobLoader: () async => [
JenkinsJob(
name: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/'),
),
],
buildLoader: (_) async => [
JenkinsBuild(
number: 7,
jobName: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/7/'),
startedAt: null,
result: 'SUCCESS',
artifacts: const [
BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/app-release.apk',
),
],
requestedBy: 'toki',
),
],
artifactDownloader: (build, artifact) {
var received = 0;
final events = (() async* {
final chunk = List<int>.filled(1024, 1);
received += chunk.length;
yield DownloadProgressEvent(
receivedBytes: received,
totalBytes: 1024,
chunkBytes: chunk,
isComplete: true,
);
})();
return DownloadTask.fromStream(events);
},
artifactStager:
({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>();
return const StagedApk(
path: '/tmp/staged/app-release.apk',
sizeBytes: 1024,
);
},
installer: installerFn,
artifactCleaner: artifactCleaner,
),
);
}
testWidgets('non-ready device keeps install button disabled', (tester) async {
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) async => const AdbInstallResult(
exitCode: 0,
stdout: 'Success',
stderr: '',
),
),
);
await navigateToDevicesWithPending(tester);
// ZX1G22K7Q9 is unauthorized — its install button must be disabled
final allInstallButtons = tester.widgetList<IconButton>(
find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치'),
).toList();
// 3 devices: 2 ready (enabled), 1 unauthorized (disabled)
expect(allInstallButtons.where((b) => b.onPressed == null), hasLength(1));
});
testWidgets('ready device runs installer and shows success output', (
tester,
) async {
final cleanedPaths = <String>[];
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) async => const AdbInstallResult(
exitCode: 0,
stdout: 'Performing Streamed Install\nSuccess',
stderr: '',
),
artifactCleaner: (path) async => cleanedPaths.add(path),
),
);
await navigateToDevicesWithPending(tester);
final installButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == '설치',
);
await tester.tap(installButtons.first);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget);
expect(find.byKey(const ValueKey('install-result-output')), findsOneWidget);
expect(find.textContaining('Success'), findsWidgets);
});
testWidgets('cleans staged apk once after successful install result', (
tester,
) async {
final cleanedPaths = <String>[];
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) async => const AdbInstallResult(
exitCode: 0,
stdout: 'Success',
stderr: '',
),
artifactCleaner: (path) async => cleanedPaths.add(path),
),
);
await navigateToDevicesWithPending(tester);
final installButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == '설치',
);
await tester.tap(installButtons.first);
await tester.pumpAndSettle();
expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
// Result banner must survive after cleanup
expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget);
// Pending banner must be gone
expect(find.byKey(const ValueKey('pending-install-banner')), findsNothing);
});
testWidgets(
'cleans staged apk once after failed install result while keeping stderr visible',
(tester) async {
final cleanedPaths = <String>[];
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) async => const AdbInstallResult(
exitCode: 1,
stdout: '',
stderr: 'INSTALL_FAILED_VERSION_DOWNGRADE',
),
artifactCleaner: (path) async => cleanedPaths.add(path),
),
);
await navigateToDevicesWithPending(tester);
final installButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == '설치',
);
await tester.tap(installButtons.first);
await tester.pumpAndSettle();
expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget);
expect(
find.textContaining('INSTALL_FAILED_VERSION_DOWNGRADE'),
findsOneWidget,
);
},
);
testWidgets('does not clean staged apk before install future completes', (
tester,
) async {
final cleanedPaths = <String>[];
final completer = Completer<AdbInstallResult>();
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) => completer.future,
artifactCleaner: (path) async => cleanedPaths.add(path),
),
);
await navigateToDevicesWithPending(tester);
final installButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == '설치',
);
await tester.tap(installButtons.first);
await tester.pump(); // start install, do not settle
// Cleanup must NOT have been called yet
expect(cleanedPaths, isEmpty);
completer.complete(
const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''),
);
await tester.pumpAndSettle();
expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
});
testWidgets(
'keeps device page compact viewport free of overflow with pending apk',
(tester) async {
tester.view.physicalSize = const Size(600, 400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AppSokShell(
jobLoader: () async => [
JenkinsJob(
name: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/'),
),
],
buildLoader: (_) async => [
JenkinsBuild(
number: 7,
jobName: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/7/'),
startedAt: null,
result: 'SUCCESS',
artifacts: const [
BuildArtifact(
fileName: 'very-long-artifact-name-for-overflow-test.apk',
relativePath:
'outputs/very-long-artifact-name-for-overflow-test.apk',
),
],
requestedBy: 'toki',
),
],
artifactDownloader: (build, artifact) {
var received = 0;
final events = (() async* {
final chunk = List<int>.filled(1024, 1);
received += chunk.length;
yield DownloadProgressEvent(
receivedBytes: received,
totalBytes: 1024,
chunkBytes: chunk,
isComplete: true,
);
})();
return DownloadTask.fromStream(events);
},
artifactStager:
({
required build,
required artifact,
required byteStream,
}) async {
await byteStream.drain<void>();
return const StagedApk(
path: '/tmp/staged/app.apk',
sizeBytes: 1024,
);
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app').first);
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.pumpAndSettle();
expect(
find.byKey(const ValueKey('pending-install-banner')),
findsOneWidget,
);
expect(tester.takeException(), isNull);
},
);
// ── REVIEW_UILC: lifecycle edge regression ──────────────────────
testWidgets(
'cleans staged apk when install completes after leaving devices page',
(tester) async {
final cleanedPaths = <String>[];
final completer = Completer<AdbInstallResult>();
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) => completer.future,
artifactCleaner: (path) async => cleanedPaths.add(path),
),
);
await navigateToDevicesWithPending(tester);
// Tap install on first ready device
final installButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == '설치',
);
await tester.tap(installButtons.first);
await tester.pump(); // start install, future pending
// Navigate away from devices page while install is in flight
final buildsNav = find.descendant(
of: find.byType(NavigationRail),
matching: find.text('빌드'),
);
await tester.tap(buildsNav);
await tester.pump();
// Complete the install future after DevicesPage is no longer visible
completer.complete(
const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''),
);
await tester.pumpAndSettle();
// Cleanup must have been called exactly once — no Flutter exception
expect(tester.takeException(), isNull);
expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
},
);
testWidgets('shows failure and cleans staged apk when installer throws', (
tester,
) async {
final cleanedPaths = <String>[];
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) async =>
throw Exception('adb missing'),
artifactCleaner: (path) async => cleanedPaths.add(path),
),
);
await navigateToDevicesWithPending(tester);
final installButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == '설치',
);
await tester.tap(installButtons.first);
await tester.pumpAndSettle();
// No Flutter exception propagated
expect(tester.takeException(), isNull);
// Cleanup called once
expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
// Failure result banner shown with exception text
expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget);
expect(find.textContaining('adb missing'), findsOneWidget);
// Install buttons no longer stuck — hourglass_top icon must not appear
expect(
find.byWidgetPredicate(
(w) => w is Icon && w.icon == Icons.hourglass_top,
),
findsNothing,
);
});
testWidgets(
'switches between all shell destinations with status placeholders',
(WidgetTester tester) async {
await tester.pumpWidget(const AppSokApp());
final navTargets = <Map<String, String>>[
{'label': '빌드', 'marker': 'Jenkins 연결 필요'},
{'label': '디바이스', 'marker': 'R5CT90A1B2C'},
{'label': '콘솔', 'marker': '패키지 또는 태그'},
{'label': '설정', 'marker': 'Base URL'},
];
for (final target in navTargets) {
final navLabel = find.descendant(
of: find.byType(NavigationRail),
matching: find.text(target['label']!),
);
await tester.tap(navLabel);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('status-jenkins')), findsOneWidget);
expect(find.byKey(const ValueKey('status-adb')), findsOneWidget);
expect(find.text('Jenkins 대기'), findsOneWidget);
expect(find.text('ADB 대기'), findsOneWidget);
expect(find.text(target['marker']!), findsOneWidget);
}
},
);
}