import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.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_artifact_session.dart'; import 'package:appsok/src/services/jenkins_client.dart'; import 'package:appsok/src/services/token_store.dart'; import 'package:appsok/src/theme/app_theme.dart'; class _FakeJenkinsArtifactSession extends JenkinsArtifactSession { _FakeJenkinsArtifactSession() : super( store: TokenStore(), client: JenkinsClient( client: MockClient((_) async => http.Response('{}', 200)), ), stager: ArtifactStagingService(), ); static const _artifact = BuildArtifact( fileName: 'app-release.apk', relativePath: 'outputs/app-release.apk', ); @override Future restore() async => true; @override Future restoreDetailed() async => const JenkinsSessionRestoreResult(kind: JenkinsSessionRestoreKind.restored); @override Future> loadJobs() async => [ JenkinsJob( name: 'android-app', url: Uri.parse('https://jenkins.example/job/android-app/'), ), ]; @override Future> loadBuilds(JenkinsJob job) async => [ JenkinsBuild( number: 7, jobName: job.name, url: Uri.parse('https://jenkins.example/job/android-app/7/'), startedAt: null, result: 'SUCCESS', artifacts: const [_artifact], requestedBy: 'toki', ), ]; @override DownloadTask downloadArtifact(JenkinsBuild build, BuildArtifact artifact) { return DownloadTask.fromStream( Stream.fromIterable(const [ DownloadProgressEvent( receivedBytes: 4, totalBytes: 4, chunkBytes: [1, 2, 3, 4], ), DownloadProgressEvent( receivedBytes: 4, totalBytes: 4, isComplete: true, ), ]), ); } @override Future stageApk({ required JenkinsBuild build, required BuildArtifact artifact, required Stream> byteStream, }) async { await byteStream.drain(); return const StagedApk(path: '/tmp/staged/app-release.apk', sizeBytes: 4); } @override Future cleanup(String path) async {} } 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( AppSokApp( adbDeviceLoader: () async => const [ AdbDevice( serial: 'R5CT90A1B2C', state: 'device', model: 'SM_S918N', product: 'dm3q', ), ], ), ); 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( 'loaded device states distinguish ready, unauthorized, and offline', (tester) async { await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), home: AppSokShell( deviceLoader: () async => const [ AdbDevice( serial: 'R5CT90A1B2C', state: 'device', model: 'SM_S918N', product: 'dm3q', device: 'dm3q', ), AdbDevice( serial: 'ZX1G22K7Q9', state: 'unauthorized', model: 'Pixel_8', product: 'shiba', device: 'shiba', ), AdbDevice( serial: 'emulator-5554', state: 'offline', model: 'sdk_gphone64_arm64', product: 'emu64a', device: 'emu64a', ), ], ), ), ); await tester.tap( find.descendant( of: find.byType(NavigationRail), matching: find.text('디바이스'), ), ); await tester.pumpAndSettle(); expect( find.byKey(const ValueKey('device-state-R5CT90A1B2C')), findsOneWidget, ); expect(find.text('ready'), findsOneWidget); expect( find.byKey(const ValueKey('device-state-ZX1G22K7Q9')), findsOneWidget, ); expect(find.text('승인 필요'), findsOneWidget); expect(find.text('폰에서 USB debugging 승인 필요'), findsOneWidget); expect( find.byKey(const ValueKey('device-state-emulator-5554')), findsOneWidget, ); expect(find.text('offline'), findsOneWidget); expect(find.text('ADB 연결 대기'), findsOneWidget); }, ); testWidgets('refresh button reloads the adb device list', (tester) async { var calls = 0; await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), home: AppSokShell( deviceLoader: () async { calls += 1; if (calls == 1) { return const [ AdbDevice( serial: 'first-device', state: 'device', model: 'Pixel_8', product: 'shiba', ), ]; } return const [ AdbDevice( serial: 'second-device', state: 'offline', model: 'SM_S918N', product: 'dm3q', ), ]; }, ), ), ); await tester.tap( find.descendant( of: find.byType(NavigationRail), matching: find.text('디바이스'), ), ); await tester.pumpAndSettle(); expect(find.text('Pixel 8'), findsOneWidget); expect(find.text('SM S918N'), findsNothing); await tester.tap(find.byKey(const ValueKey('device-refresh-button'))); await tester.pumpAndSettle(); expect(calls, 2); expect(find.text('Pixel 8'), findsNothing); expect(find.text('SM S918N'), findsOneWidget); expect(find.text('offline'), findsOneWidget); }); 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( AppSokApp( adbDeviceLoader: () async => const [ AdbDevice( serial: 'device-ready-1', state: 'device', model: 'Pixel_8', product: 'shiba', ), ], ), ); final navTargets = >[ {'label': '빌드', 'marker': 'Jenkins 연결 필요'}, {'label': '디바이스', 'marker': 'Pixel 8'}, {'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); } }, ); // ── AppSokApp stored-session wiring ────────────────────────────── testWidgets('AppSokApp wires stored Jenkins session into builds page', ( tester, ) async { final requestedPaths = []; FlutterSecureStorage.setMockInitialValues({ 'jenkins.baseUrl': 'https://jenkins.example', 'jenkins.username': 'user', 'jenkins.apiToken': 'token', }); await tester.pumpWidget( AppSokApp( jenkinsClient: JenkinsClient( client: MockClient((request) async { if (request.url.path.endsWith('whoAmI/api/json')) { requestedPaths.add('whoAmI'); return http.Response(jsonEncode({'id': 'user'}), 200); } if (request.url.path.endsWith('/api/json')) { requestedPaths.add('jobs'); return http.Response( jsonEncode({ 'jobs': [ { 'name': 'sample-job', 'fullName': 'sample-job', 'url': 'https://jenkins.example/job/sample-job/', }, ], }), 200, ); } return http.Response(jsonEncode({}), 200); }), ), ), ); await tester.pumpAndSettle(); expect(requestedPaths, containsAllInOrder(['whoAmI', 'jobs'])); expect(find.text('Jenkins 연결 필요'), findsNothing); expect(find.text('sample-job'), findsOneWidget); }); testWidgets('AppSokApp wires staged apk into adb installer', (tester) async { final installCalls = <({String serial, String apkPath})>[]; await tester.pumpWidget( AppSokApp( session: _FakeJenkinsArtifactSession(), adbDeviceLoader: () async => const [ AdbDevice( serial: 'R5CT90A1B2C', state: 'device', model: 'SM_S918N', product: 'dm3q', ), ], adbInstaller: (device, pending) async { installCalls.add((serial: device.serial, apkPath: pending.apkPath)); return const AdbInstallResult( exitCode: 0, stdout: 'Success', stderr: '', ); }, ), ); 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(); final verifiedLabel = find.byKey(const ValueKey('verified-label')); for (var i = 0; i < 20 && verifiedLabel.evaluate().isEmpty; i += 1) { await tester.pump(const Duration(milliseconds: 20)); } await tester.pumpAndSettle(); expect(verifiedLabel, findsOneWidget); await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pumpAndSettle(); expect( find.byKey(const ValueKey('pending-install-banner')), findsOneWidget, ); final installButtons = find.byWidgetPredicate( (w) => w is IconButton && w.tooltip == '설치', ); await tester.tap(installButtons.first); await tester.pumpAndSettle(); expect(installCalls, hasLength(1)); expect(installCalls.single.serial, 'R5CT90A1B2C'); expect(installCalls.single.apkPath, '/tmp/staged/app-release.apk'); expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget); expect(find.textContaining('Success'), findsWidgets); }); testWidgets( 'AppSokApp keeps Jenkins connection required state when no stored session exists', (tester) async { FlutterSecureStorage.setMockInitialValues({}); await tester.pumpWidget(const AppSokApp()); await tester.pumpAndSettle(); expect(find.text('Jenkins 연결 필요'), findsOneWidget); }, ); testWidgets('AppSokApp does not expose token text on auth failure', ( tester, ) async { const secretToken = 's3cr3t-api-t0k3n'; FlutterSecureStorage.setMockInitialValues({ 'jenkins.baseUrl': 'https://jenkins.example', 'jenkins.username': 'user', 'jenkins.apiToken': secretToken, }); await tester.pumpWidget( AppSokApp( jenkinsClient: JenkinsClient( client: MockClient((_) async => http.Response('Unauthorized', 401)), ), ), ); await tester.pumpAndSettle(); expect(find.textContaining(secretToken), findsNothing); expect(find.text('재로그인 필요'), findsOneWidget); }); // ── ADB diagnostics display ────────────────────────────────────── testWidgets('SettingsPage renders ADB diagnostics from fake loader', ( tester, ) async { final fakeRuntime = AdbRuntime( executablePath: '/opt/appsok/adb', source: AdbExecutableSource.bundled, serverPort: 5038, bundledExists: true, fallbackUsed: false, ); final fakeDiag = AdbRuntimeDiagnostics( runtime: fakeRuntime, version: 'Android Debug Bridge version 1.0.41', sha256: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', versionError: null, ); await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), home: Scaffold( body: AppSokShell(adbDiagnosticsLoader: () async => fakeDiag), ), ), ); // Navigate to 설정. await tester.tap( find.descendant( of: find.byType(NavigationRail), matching: find.text('설정'), ), ); await tester.pumpAndSettle(); // Trigger diagnostics load via refresh button. await tester.tap(find.byIcon(Icons.refresh)); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('diag-source')), findsOneWidget); expect(find.byKey(const ValueKey('diag-path')), findsOneWidget); expect(find.byKey(const ValueKey('diag-port')), findsOneWidget); expect(find.byKey(const ValueKey('diag-version')), findsOneWidget); expect(find.byKey(const ValueKey('diag-hash')), findsOneWidget); expect(find.text('bundled'), findsOneWidget); expect(find.text('5038'), findsOneWidget); }); testWidgets('SettingsPage shows version error row when versionError is set', ( tester, ) async { final fakeRuntime = AdbRuntime( executablePath: 'adb', source: AdbExecutableSource.system, serverPort: 5037, fallbackUsed: true, ); final fakeDiag = AdbRuntimeDiagnostics( runtime: fakeRuntime, version: null, sha256: null, versionError: 'exit code 127', ); await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), home: Scaffold( body: AppSokShell(adbDiagnosticsLoader: () async => fakeDiag), ), ), ); await tester.tap( find.descendant( of: find.byType(NavigationRail), matching: find.text('설정'), ), ); await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.refresh)); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('diag-version-error')), findsOneWidget); expect(find.byKey(const ValueKey('diag-version')), findsNothing); expect(find.text('system (fallback)'), findsOneWidget); }); testWidgets( 'SettingsPage ADB diagnostics has no overflow at compact viewport', (tester) async { tester.view.physicalSize = const Size(900, 600); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); final fakeRuntime = AdbRuntime( executablePath: '/very/long/path/to/the/adb/executable/that/will/overflow/if/not/ellipsized', source: AdbExecutableSource.custom, serverPort: 15037, ); final fakeDiag = AdbRuntimeDiagnostics( runtime: fakeRuntime, version: 'Android Debug Bridge version 1.0.41\nVersion 35.0.2-12147458', sha256: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', versionError: null, ); await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), home: Scaffold( body: AppSokShell(adbDiagnosticsLoader: () async => fakeDiag), ), ), ); await tester.tap( find.descendant( of: find.byType(NavigationRail), matching: find.text('설정'), ), ); await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.refresh)); await tester.pumpAndSettle(); // No RenderFlex overflow exception means test passes. expect(tester.takeException(), isNull); expect(find.byKey(const ValueKey('diag-path')), findsOneWidget); }, ); testWidgets( 'SettingsPage without diagnostics loader shows no diagnostics section', (tester) async { await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), home: const Scaffold(body: AppSokShell()), ), ); await tester.tap( find.descendant( of: find.byType(NavigationRail), matching: find.text('설정'), ), ); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('diag-source')), findsNothing); }, ); testWidgets( 'AppSokApp wires diagnostics loader and displays diagnostics in settings', (tester) async { const fakePath = '/opt/appsok/adb-bundled'; const fakeVersion = 'Android Debug Bridge version 1.0.41'; const fakeSha = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; const fakePort = 5038; final fakeRuntime = AdbRuntime( executablePath: fakePath, source: AdbExecutableSource.bundled, serverPort: fakePort, bundledExists: true, fallbackUsed: false, ); final fakeDiag = AdbRuntimeDiagnostics( runtime: fakeRuntime, version: fakeVersion, sha256: fakeSha, versionError: null, ); await tester.pumpWidget( AppSokApp(adbDiagnosticsLoader: () async => fakeDiag), ); await tester.pumpAndSettle(); await tester.tap( find.descendant( of: find.byType(NavigationRail), matching: find.text('설정'), ), ); await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.refresh)); await tester.pumpAndSettle(); expect(find.text('ADB 진단'), findsOneWidget); expect(find.text('bundled'), findsOneWidget); expect(find.text(fakePath), findsOneWidget); expect(find.text(fakeVersion), findsOneWidget); expect(find.text(fakeSha.substring(0, 12)), findsOneWidget); expect(find.text(fakePort.toString()), findsOneWidget); }, ); testWidgets( 'AppSokApp wires fallback diagnostics and shows system (fallback) source', (tester) async { final fakeRuntime = AdbRuntime( executablePath: 'adb', source: AdbExecutableSource.system, serverPort: 5037, fallbackUsed: true, ); final fakeDiag = AdbRuntimeDiagnostics( runtime: fakeRuntime, version: null, sha256: null, versionError: 'exit code 127', ); await tester.pumpWidget( AppSokApp(adbDiagnosticsLoader: () async => fakeDiag), ); await tester.pumpAndSettle(); await tester.tap( find.descendant( of: find.byType(NavigationRail), matching: find.text('설정'), ), ); await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.refresh)); await tester.pumpAndSettle(); expect(find.text('system (fallback)'), findsOneWidget); expect(find.byKey(const ValueKey('diag-version-error')), findsOneWidget); }, ); // ── AppSokApp web login surface wiring ────────────────────────── testWidgets('AppSokApp wires web login launcher into settings page', ( tester, ) async { JenkinsWebLoginResult? capturedResult; await tester.pumpWidget( AppSokApp( webLoginLauncher: (_) async => const JenkinsWebLoginResult( username: 'toki', displayName: 'Toki Lab', ), onWebLoginCompleted: (result) => capturedResult = result, ), ); await tester.pumpAndSettle(); // Navigate to Settings await tester.tap( find.descendant( of: find.byType(NavigationRail), matching: find.text('설정'), ), ); await tester.pumpAndSettle(); // Enter a valid Jenkins URL to enable the button await tester.enterText( find.byType(TextField).first, 'https://jenkins.example.com', ); await tester.pump(); // Tap Web Login — should call launcher and complete await tester.tap(find.byKey(const ValueKey('jenkins-web-login-button'))); await tester.pumpAndSettle(); // onWebLoginCompleted callback must have been called expect(capturedResult, isNotNull); expect(capturedResult!.username, 'toki'); // Result must be visible on screen expect(find.byKey(const ValueKey('web-login-result')), findsOneWidget); expect(find.textContaining('Toki Lab'), findsOneWidget); }); }