feat: install session history and device page improvements
- Add install_attempt_result model with session tracking - Update devices page with install history UI - Extend app_shell with session management - Update workflow integration phase roadmap - Add widget tests for device features
This commit is contained in:
parent
c8ef7ec81f
commit
c1eb3a912c
6 changed files with 317 additions and 42 deletions
|
|
@ -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 설치 진입
|
||||
|
|
|
|||
|
|
@ -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 승인 대기 상태다.
|
||||
|
||||
## 범위 제외
|
||||
|
||||
|
|
|
|||
|
|
@ -158,6 +158,11 @@ class _AppSokShellState extends State<AppSokShell> {
|
|||
_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<AppSokShell> {
|
|||
deviceLoader: widget.deviceLoader,
|
||||
isInstalling: _isInstalling,
|
||||
lastInstallAttempt: _lastInstallAttempt,
|
||||
onInstallAttemptCleared: _handleInstallAttemptCleared,
|
||||
onLogcatRequested: _handleLogcatRequested,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<void> 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<AdbDevice>? onLogcatRequested;
|
||||
|
||||
@override
|
||||
|
|
@ -128,7 +130,10 @@ class _DevicesPageState extends State<DevicesPage> {
|
|||
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<void> _copyReport(BuildContext context) async {
|
||||
await Clipboard.setData(ClipboardData(text: attempt.maskedReport));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('리포트 복사됨')));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Object?, Object?>.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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue