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.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(); 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.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(); 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 = []; 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.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(); 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.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(); 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( find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치'), ); for (final btn in installButtons) { expect(btn.onPressed, isNull); } }, ); // ── UILC: Install lifecycle regression ────────────────────────── Future 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 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.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(); 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( 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 = []; 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 = []; 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 = []; 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 = []; final completer = Completer(); 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.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(); 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 = []; final completer = Completer(); 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 = []; 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 = >[ {'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); } }, ); }