diff --git a/agent-roadmap/phase/workflow-integration/PHASE.md b/agent-roadmap/phase/workflow-integration/PHASE.md index d5ed8bc..44dd8ce 100644 --- a/agent-roadmap/phase/workflow-integration/PHASE.md +++ b/agent-roadmap/phase/workflow-integration/PHASE.md @@ -17,7 +17,7 @@ MVP 설치 흐름을 Teams/Jenkins 알림과 연결하고, 설치 실패 대응 - [완료] macOS 빌드 CI와 인증 빌드 - 경로: `agent-roadmap/archive/phase/workflow-integration/milestones/macos-build-ci.md` - 요약: Teams deep link와 오류 리포트 작업 전에 원격 Mac build/CI baseline과 Developer ID notarized 인증 빌드 절차를 확정한다. -- [진행중] 설치 오류 표시와 복사 리포트 +- [검토중] 설치 오류 표시와 복사 리포트 - 경로: `agent-roadmap/phase/workflow-integration/milestones/install-session-history.md` - 요약: 1차 MVP에서는 설치 이력을 저장하지 않고, 현재 설치 실패 원인과 masking된 복사 리포트만 제공한다. - [보류] Teams deep link 설치 진입 diff --git a/agent-roadmap/phase/workflow-integration/milestones/install-session-history.md b/agent-roadmap/phase/workflow-integration/milestones/install-session-history.md index 98725a5..01c41b7 100644 --- a/agent-roadmap/phase/workflow-integration/milestones/install-session-history.md +++ b/agent-roadmap/phase/workflow-integration/milestones/install-session-history.md @@ -11,7 +11,7 @@ ## 상태 -[진행중] +[검토중] ## 승격 조건 @@ -39,29 +39,32 @@ 설치 이력을 누적 저장하지 않고 현재 실행의 설치 결과만 앱 안에서 확인할 수 있게 한다. -- [ ] [session-model] 현재 설치 시도 결과를 화면 상태로 표현할 최소 모델을 정의한다. +- [x] [session-model] 현재 설치 시도 결과를 화면 상태로 표현할 최소 모델을 정의한다. - [x] [local-store] 1차 MVP에서 설치 결과를 로컬 저장소에 영구 저장하지 않도록 한다. -- [ ] [recent-list] 최근 설치 목록 대신 현재 설치 결과와 실패 요약을 표시한다. +- [x] [recent-list] 최근 설치 목록 대신 현재 설치 결과와 실패 요약을 표시한다. - [x] [rerun] 저장된 최근 세션 재실행은 제공하지 않고 사용자가 현재 선택한 build/device로 다시 실행한다. ### Epic: [report] 오류 리포트 실패 원인을 사용자와 개발자가 빠르게 공유할 수 있게 한다. -- [ ] [error-summary] install stderr/stdout의 주요 실패 원인을 요약 표시한다. -- [ ] [copy-report] 현재 build/device/install result를 포함한 masking된 텍스트 리포트를 복사할 수 있다. -- [ ] [masking] token, private URL, device serial masking 규칙을 적용한다. -- [ ] [retention] 보관 기간과 삭제 버튼 없이 현재 화면 상태만 초기화할 수 있게 한다. +- [x] [error-summary] install stderr/stdout의 주요 실패 원인을 요약 표시한다. +- [x] [copy-report] 현재 build/device/install result를 포함한 masking된 텍스트 리포트를 복사할 수 있다. +- [x] [masking] token, private URL, device serial masking 규칙을 적용한다. +- [x] [retention] 보관 기간과 삭제 버튼 없이 현재 화면 상태만 초기화할 수 있게 한다. ## 완료 리뷰 -- 상태: 없음 -- 요청일: 없음 -- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증 충족 후 기록한다. +- 상태: 요청됨 +- 요청일: 2026-06-16 +- 완료 근거: + - 현재 설치 결과 모델과 단일 결과 배너로 history Epic을 충족했다. + - masking된 설치 리포트 복사, 실패 요약 표시, 현재 결과 초기화로 report Epic을 충족했다. + - local 및 remote Mac runner에서 `flutter analyze`, `flutter test`가 통과했다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 -- 리뷰 코멘트: 없음 +- 리뷰 코멘트: 모든 기능 Task가 완료되어 사용자 최종 확인과 archive 승인 대기 상태다. ## 범위 제외 diff --git a/lib/src/features/app_shell.dart b/lib/src/features/app_shell.dart index 9914e0c..7c6e2fe 100644 --- a/lib/src/features/app_shell.dart +++ b/lib/src/features/app_shell.dart @@ -158,6 +158,11 @@ class _AppSokShellState extends State { _cleanupPendingInstall(pending); } + void _handleInstallAttemptCleared() { + if (_lastInstallAttempt == null) return; + setState(() => _lastInstallAttempt = null); + } + void _cleanupPendingInstall(PendingInstall pending) { _cleanupPath(pending.apkPath); } @@ -208,6 +213,7 @@ class _AppSokShellState extends State { deviceLoader: widget.deviceLoader, isInstalling: _isInstalling, lastInstallAttempt: _lastInstallAttempt, + onInstallAttemptCleared: _handleInstallAttemptCleared, onLogcatRequested: _handleLogcatRequested, ), ), diff --git a/lib/src/features/devices/devices_page.dart b/lib/src/features/devices/devices_page.dart index e1a33a9..03a5b3d 100644 --- a/lib/src/features/devices/devices_page.dart +++ b/lib/src/features/devices/devices_page.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../models/adb_device.dart'; import '../../models/install_attempt_result.dart'; import '../../models/pending_install.dart'; -import '../../services/adb_service.dart'; typedef PendingInstallRunner = Future Function(AdbDevice device, PendingInstall pending); @@ -20,6 +20,7 @@ class DevicesPage extends StatefulWidget { this.deviceLoader, this.isInstalling = false, this.lastInstallAttempt, + this.onInstallAttemptCleared, this.onLogcatRequested, }); @@ -29,6 +30,7 @@ class DevicesPage extends StatefulWidget { final AdbDeviceLoader? deviceLoader; final bool isInstalling; final InstallAttemptResult? lastInstallAttempt; + final VoidCallback? onInstallAttemptCleared; final ValueChanged? onLogcatRequested; @override @@ -128,7 +130,10 @@ class _DevicesPageState extends State { const SizedBox(height: 12), ], if (widget.lastInstallAttempt case final attempt?) ...[ - _InstallResultBanner(attempt: attempt), + _InstallResultBanner( + attempt: attempt, + onClear: widget.onInstallAttemptCleared, + ), const SizedBox(height: 12), ], Wrap( @@ -593,16 +598,17 @@ class _PendingInstallBanner extends StatelessWidget { } class _InstallResultBanner extends StatelessWidget { - const _InstallResultBanner({required this.attempt}); + const _InstallResultBanner({required this.attempt, this.onClear}); final InstallAttemptResult attempt; + final VoidCallback? onClear; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final isSuccess = attempt.result.succeeded; + final isSuccess = attempt.succeeded; final color = isSuccess ? colorScheme.primary : colorScheme.error; - final output = _resultOutputSummary(attempt.result); + final output = attempt.maskedOutputSummary; final metadataKey = ValueKey( 'install-result-attempt:${attempt.pendingInstall.jobName}:' '${attempt.pendingInstall.buildNumber}:${attempt.pendingInstall.fileName}', @@ -630,6 +636,17 @@ class _InstallResultBanner extends StatelessWidget { key: metadataKey, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + key: const ValueKey('current-install-result-title'), + '현재 설치 결과', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), Text( key: const ValueKey('install-result-label'), isSuccess @@ -643,7 +660,7 @@ class _InstallResultBanner extends StatelessWidget { overflow: TextOverflow.ellipsis, ), Text( - '#${attempt.pendingInstall.buildNumber} · ${attempt.pendingInstall.jobName} · ${attempt.pendingInstall.fileName}', + attempt.artifactLabel, key: const ValueKey('install-result-metadata'), style: TextStyle( color: colorScheme.onSurfaceVariant, @@ -668,36 +685,46 @@ class _InstallResultBanner extends StatelessWidget { ], ), ), + const SizedBox(width: 8), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + key: const ValueKey('install-report-copy-button'), + tooltip: '리포트 복사', + constraints: const BoxConstraints.tightFor( + width: 36, + height: 36, + ), + padding: EdgeInsets.zero, + onPressed: () => _copyReport(context), + icon: const Icon(Icons.content_copy, size: 20), + ), + IconButton( + key: const ValueKey('install-result-clear-button'), + tooltip: '현재 결과 초기화', + constraints: const BoxConstraints.tightFor( + width: 36, + height: 36, + ), + padding: EdgeInsets.zero, + onPressed: onClear, + icon: const Icon(Icons.close, size: 20), + ), + ], + ), ], ), ), ); } - String _resultOutputSummary(AdbInstallResult result) { - final stdout = result.stdout.trim(); - final stderr = result.stderr.trim(); - if (result.succeeded) { - return [stdout, stderr].where((it) => it.isNotEmpty).join('\n'); - } - - if (stderr.isNotEmpty) { - return _firstNonEmptyLine(stderr); - } - - if (stdout.isNotEmpty) { - return _firstNonEmptyLine(stdout); - } - - return '설치 실패'; - } - - String _firstNonEmptyLine(String text) { - final lines = text - .split('\n') - .map((line) => line.trim()) - .where((line) => line.isNotEmpty); - return lines.isEmpty ? '' : lines.first; + Future _copyReport(BuildContext context) async { + await Clipboard.setData(ClipboardData(text: attempt.maskedReport)); + if (!context.mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('리포트 복사됨'))); } } diff --git a/lib/src/models/install_attempt_result.dart b/lib/src/models/install_attempt_result.dart index c7ea811..b84a1f0 100644 --- a/lib/src/models/install_attempt_result.dart +++ b/lib/src/models/install_attempt_result.dart @@ -2,6 +2,8 @@ import '../services/adb_service.dart'; import 'adb_device.dart'; import 'pending_install.dart'; +enum InstallAttemptOutcome { success, failure } + class InstallAttemptResult { const InstallAttemptResult({ required this.pendingInstall, @@ -12,4 +14,84 @@ class InstallAttemptResult { final PendingInstall pendingInstall; final AdbDevice device; final AdbInstallResult result; + + InstallAttemptOutcome get outcome => result.succeeded + ? InstallAttemptOutcome.success + : InstallAttemptOutcome.failure; + + bool get succeeded => outcome == InstallAttemptOutcome.success; + + bool get failed => outcome == InstallAttemptOutcome.failure; + + String get artifactLabel => + '#${pendingInstall.buildNumber} · ${pendingInstall.jobName} · ' + '${pendingInstall.fileName}'; + + String get outputSummary { + final stdout = result.stdout.trim(); + final stderr = result.stderr.trim(); + if (succeeded) { + return [stdout, stderr].where((it) => it.isNotEmpty).join('\n'); + } + + if (stderr.isNotEmpty) return _firstNonEmptyLine(stderr); + if (stdout.isNotEmpty) return _firstNonEmptyLine(stdout); + return '설치 실패'; + } + + String get maskedOutputSummary => _maskSensitive(outputSummary); + + String get maskedDeviceSerial => _maskSerial(device.serial); + + String get maskedReport { + final lines = [ + 'AppSok 설치 리포트', + '결과: ${succeeded ? '성공' : '실패'}', + 'exitCode: ${result.exitCode}', + 'Build: $artifactLabel', + 'Device: ${device.displayName} ($maskedDeviceSerial)', + if (maskedOutputSummary.isNotEmpty) '요약: $maskedOutputSummary', + if (result.stdout.trim().isNotEmpty) + 'stdout:\n${_maskSensitive(result.stdout.trim())}', + if (result.stderr.trim().isNotEmpty) + 'stderr:\n${_maskSensitive(result.stderr.trim())}', + ]; + return lines.join('\n'); + } + + String _firstNonEmptyLine(String text) { + final lines = text + .split('\n') + .map((line) => line.trim()) + .where((line) => line.isNotEmpty); + return lines.isEmpty ? '' : lines.first; + } + + String _maskSensitive(String value) { + return value + .replaceAll(RegExp(r'https?:\/\/[^\s]+'), '[masked-url]') + .replaceAllMapped( + RegExp( + r'\b(authorization)\s*[:=]\s*bearer\s+[A-Za-z0-9._~+/=-]+', + caseSensitive: false, + ), + (match) => '${match.group(1)}: [masked]', + ) + .replaceAllMapped( + RegExp( + r'\b(api[_-]?token|token|password|passwd|secret)\s*[:=]\s*[^\s&]+', + caseSensitive: false, + ), + (match) => '${match.group(1)}=[masked]', + ) + .replaceAll(device.serial, maskedDeviceSerial); + } + + String _maskSerial(String serial) { + if (serial.isEmpty) return '[masked-serial]'; + if (serial.length <= 4) return '*' * serial.length; + final prefix = serial.substring(0, 2); + final suffix = serial.substring(serial.length - 2); + return '$prefix${'*' * (serial.length - 4)}$suffix'; + } } diff --git a/test/widget_test.dart b/test/widget_test.dart index 81615a3..73ef4e1 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -11,6 +12,7 @@ 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/install_attempt_result.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' @@ -111,6 +113,72 @@ void main() { expect(theme.scaffoldBackgroundColor, const Color(0xFFFAFBF7)); }); + test('InstallAttemptResult exposes the current attempt state', () { + const attempt = InstallAttemptResult( + pendingInstall: PendingInstall( + apkPath: '/tmp/staged/app-release.apk', + fileName: 'app-release.apk', + buildNumber: 7, + jobName: 'android-app', + sizeBytes: 1024, + ), + device: AdbDevice( + serial: 'R5CT90A1B2C', + state: 'device', + model: 'SM_S918N', + product: 'dm3q', + ), + result: AdbInstallResult( + exitCode: 1, + stdout: 'Performing Streamed Install', + stderr: 'INSTALL_FAILED_VERSION_DOWNGRADE\nDetailed adb output', + ), + ); + + expect(attempt.outcome, InstallAttemptOutcome.failure); + expect(attempt.succeeded, isFalse); + expect(attempt.failed, isTrue); + expect(attempt.artifactLabel, '#7 · android-app · app-release.apk'); + expect(attempt.outputSummary, 'INSTALL_FAILED_VERSION_DOWNGRADE'); + }); + + test('InstallAttemptResult builds a masked copy report', () { + const attempt = InstallAttemptResult( + pendingInstall: PendingInstall( + apkPath: '/tmp/staged/app-release.apk', + fileName: 'app-release.apk', + buildNumber: 7, + jobName: 'android-app', + sizeBytes: 1024, + ), + device: AdbDevice( + serial: 'R5CT90A1B2C', + state: 'device', + model: 'SM_S918N', + product: 'dm3q', + ), + result: AdbInstallResult( + exitCode: 1, + stdout: 'Install from https://jenkins.private/job/app/7/?token=raw', + stderr: + 'INSTALL_FAILED_VERSION_DOWNGRADE\n' + 'apiToken=secret-token\n' + 'device R5CT90A1B2C', + ), + ); + + final report = attempt.maskedReport; + + expect(report, contains('AppSok 설치 리포트')); + expect(report, contains('Build: #7 · android-app · app-release.apk')); + expect(report, contains('Device: SM S918N (R5*******2C)')); + expect(report, contains('[masked-url]')); + expect(report, contains('apiToken=[masked]')); + expect(report, isNot(contains('jenkins.private'))); + expect(report, isNot(contains('secret-token'))); + expect(report, isNot(contains('R5CT90A1B2C'))); + }); + testWidgets('renders the app shell', (WidgetTester tester) async { await tester.pumpWidget(const AppSokApp()); @@ -730,8 +798,14 @@ void main() { expect(cleanedPaths, ['/tmp/staged/app-release.apk']); // Result banner must survive after cleanup expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget); + expect( + find.byKey(const ValueKey('current-install-result-title')), + findsOneWidget, + ); + expect(find.text('현재 설치 결과'), findsOneWidget); // Pending banner must be gone expect(find.byKey(const ValueKey('pending-install-banner')), findsNothing); + expect(find.textContaining('최근 설치'), findsNothing); }); testWidgets( @@ -810,6 +884,89 @@ void main() { expect(find.textContaining('Detailed adb output'), findsNothing); }); + testWidgets('copies masked install report from current result banner', ( + tester, + ) async { + String? copiedText; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + final args = Map.from(call.arguments as Map); + copiedText = args['text'] as String?; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + + await tester.pumpWidget( + shellWithInstaller( + installerFn: (device, pending) async => const AdbInstallResult( + exitCode: 1, + stdout: 'downloaded from https://jenkins.private/job/app/7/', + stderr: + 'INSTALL_FAILED_VERSION_DOWNGRADE\n' + 'token=secret-token\n' + 'serial R5CT90A1B2C', + ), + ), + ); + await navigateToDevicesWithPending(tester); + + final installButtons = find.byWidgetPredicate( + (w) => w is IconButton && w.tooltip == '설치', + ); + await tester.tap(installButtons.first); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('install-report-copy-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(copiedText, isNotNull); + expect(copiedText, contains('AppSok 설치 리포트')); + expect(copiedText, contains('INSTALL_FAILED_VERSION_DOWNGRADE')); + expect(copiedText, contains('[masked-url]')); + expect(copiedText, contains('token=[masked]')); + expect(copiedText, isNot(contains('jenkins.private'))); + expect(copiedText, isNot(contains('secret-token'))); + expect(copiedText, isNot(contains('R5CT90A1B2C'))); + expect(find.text('리포트 복사됨'), findsOneWidget); + }); + + testWidgets('clears only the current install result from devices page', ( + tester, + ) async { + await tester.pumpWidget( + shellWithInstaller( + installerFn: (device, pending) async => + const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''), + ), + ); + 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); + + await tester.tap(find.byKey(const ValueKey('install-result-clear-button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('install-result-banner')), findsNothing); + expect(find.byKey(const ValueKey('pending-install-banner')), findsNothing); + expect(find.textContaining('최근 설치'), findsNothing); + }); + testWidgets('does not clean staged apk before install future completes', ( tester, ) async {