appsok/test/widget_test.dart
toki 9c9f4cc217 feat: install attempt result model 및 UI 적용
- install_attempt_result.dart 모델 추가
- devices_page에 설치 결과 UI 적용
- app_shell.dart 및 widget_test.dart 업데이트
- agent-roadmap 및 agent-task 관련 파일 동기화
2026-06-16 13:43:20 +09:00

1659 lines
51 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
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<bool> restore() async => true;
@override
Future<JenkinsSessionRestoreResult> restoreDetailed() async =>
const JenkinsSessionRestoreResult(
kind: JenkinsSessionRestoreKind.restored,
);
@override
Future<List<JenkinsJob>> loadJobs() async => [
JenkinsJob(
name: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/'),
),
];
@override
Future<List<JenkinsBuild>> 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<DownloadProgressEvent>.fromIterable(const [
DownloadProgressEvent(
receivedBytes: 4,
totalBytes: 4,
chunkBytes: [1, 2, 3, 4],
),
DownloadProgressEvent(
receivedBytes: 4,
totalBytes: 4,
isComplete: true,
),
]),
);
}
@override
Future<StagedApk> stageApk({
required JenkinsBuild build,
required BuildArtifact artifact,
required Stream<List<int>> byteStream,
}) async {
await byteStream.drain<void>();
return const StagedApk(path: '/tmp/staged/app-release.apk', sizeBytes: 4);
}
@override
Future<void> 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<int>.filled(1024, 1);
received += chunk.length;
yield DownloadProgressEvent(
receivedBytes: received,
totalBytes: 1024,
chunkBytes: chunk,
isComplete: true,
);
})();
return DownloadTask.fromStream(events);
},
artifactStager:
({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>();
return const StagedApk(
path: '/tmp/staged/app-release.apk',
sizeBytes: 1024,
);
},
),
),
);
await tester.pumpAndSettle();
// Navigate to builds, select job, select build
await tester.tap(find.text('android-app').first);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('build-row-7')));
await tester.pump();
// Start download and wait for verified state
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verified-label')), findsOneWidget);
// Tap install in verified state — shell should switch to devices page
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
// Devices page is now active and shows pending install banner
expect(
find.byKey(const ValueKey('pending-install-banner')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('pending-install-filename')),
findsOneWidget,
);
expect(find.text('app-release.apk'), findsOneWidget);
});
// ── INSTALL_HANDOFF-3: Device page pending install UI ────────────
testWidgets('shows pending apk install summary on devices page', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AppSokShell(
jobLoader: () async => [
JenkinsJob(
name: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/'),
),
],
buildLoader: (_) async => [
JenkinsBuild(
number: 7,
jobName: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/7/'),
startedAt: null,
result: 'SUCCESS',
artifacts: const [
BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/app-release.apk',
),
],
requestedBy: 'toki',
),
],
artifactDownloader: (build, artifact) {
var received = 0;
final events = (() async* {
final chunk = List<int>.filled(2 * 1024 * 1024, 1);
received += chunk.length;
yield DownloadProgressEvent(
receivedBytes: received,
totalBytes: 2 * 1024 * 1024,
chunkBytes: chunk,
isComplete: true,
);
})();
return DownloadTask.fromStream(events);
},
artifactStager:
({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>();
return const StagedApk(
path: '/tmp/staged/app-release.apk',
sizeBytes: 2 * 1024 * 1024,
);
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app').first);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('build-row-7')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('pending-install-banner')),
findsOneWidget,
);
expect(find.text('app-release.apk'), findsOneWidget);
// Size label should show MB
expect(find.textContaining('MB'), findsOneWidget);
// Local APK path must be visible
expect(find.byKey(const ValueKey('pending-install-path')), findsOneWidget);
expect(find.text('/tmp/staged/app-release.apk'), findsOneWidget);
});
testWidgets('cancels pending install and cleans staged apk', (tester) async {
final cleanedPaths = <String>[];
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AppSokShell(
jobLoader: () async => [
JenkinsJob(
name: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/'),
),
],
buildLoader: (_) async => [
JenkinsBuild(
number: 7,
jobName: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/7/'),
startedAt: null,
result: 'SUCCESS',
artifacts: const [
BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/app-release.apk',
),
],
requestedBy: 'toki',
),
],
artifactDownloader: (build, artifact) {
var received = 0;
final events = (() async* {
final chunk = List<int>.filled(1024, 1);
received += chunk.length;
yield DownloadProgressEvent(
receivedBytes: received,
totalBytes: 1024,
chunkBytes: chunk,
isComplete: true,
);
})();
return DownloadTask.fromStream(events);
},
artifactStager:
({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>();
return const StagedApk(
path: '/tmp/staged/app-release.apk',
sizeBytes: 1024,
);
},
artifactCleaner: (path) async => cleanedPaths.add(path),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app').first);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('build-row-7')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('pending-install-banner')),
findsOneWidget,
);
await tester.tap(
find.byKey(const ValueKey('pending-install-cancel-button')),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('pending-install-banner')), findsNothing);
expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
});
testWidgets('keeps install button disabled when shell has no installer', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AppSokShell(
jobLoader: () async => [
JenkinsJob(
name: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/'),
),
],
buildLoader: (_) async => [
JenkinsBuild(
number: 7,
jobName: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/7/'),
startedAt: null,
result: 'SUCCESS',
artifacts: const [
BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/app-release.apk',
),
],
requestedBy: 'toki',
),
],
artifactDownloader: (build, artifact) {
var received = 0;
final events = (() async* {
final chunk = List<int>.filled(1024, 1);
received += chunk.length;
yield DownloadProgressEvent(
receivedBytes: received,
totalBytes: 1024,
chunkBytes: chunk,
isComplete: true,
);
})();
return DownloadTask.fromStream(events);
},
artifactStager:
({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>();
return const StagedApk(
path: '/tmp/staged/app-release.apk',
sizeBytes: 1024,
);
},
// No installer — install buttons must be disabled
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app').first);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('build-row-7')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('pending-install-banner')),
findsOneWidget,
);
final installButtons = tester.widgetList<IconButton>(
find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치'),
);
for (final btn in installButtons) {
expect(btn.onPressed, isNull);
}
});
// ── UILC: Install lifecycle regression ──────────────────────────
Future<void> navigateToDevicesWithPending(WidgetTester tester) async {
await tester.pumpAndSettle();
await tester.tap(find.text('android-app').first);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('build-row-7')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
}
Widget shellWithInstaller({
required Future<AdbInstallResult> Function(AdbDevice, PendingInstall)
installerFn,
ArtifactCleaner? artifactCleaner,
}) {
return MaterialApp(
theme: AppTheme.light(),
home: AppSokShell(
jobLoader: () async => [
JenkinsJob(
name: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/'),
),
],
buildLoader: (_) async => [
JenkinsBuild(
number: 7,
jobName: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/7/'),
startedAt: null,
result: 'SUCCESS',
artifacts: const [
BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/app-release.apk',
),
],
requestedBy: 'toki',
),
],
artifactDownloader: (build, artifact) {
var received = 0;
final events = (() async* {
final chunk = List<int>.filled(1024, 1);
received += chunk.length;
yield DownloadProgressEvent(
receivedBytes: received,
totalBytes: 1024,
chunkBytes: chunk,
isComplete: true,
);
})();
return DownloadTask.fromStream(events);
},
artifactStager:
({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>();
return const StagedApk(
path: '/tmp/staged/app-release.apk',
sizeBytes: 1024,
);
},
installer: installerFn,
artifactCleaner: artifactCleaner,
),
);
}
testWidgets('non-ready device keeps install button disabled', (tester) async {
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) async =>
const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''),
),
);
await navigateToDevicesWithPending(tester);
// ZX1G22K7Q9 is unauthorized — its install button must be disabled
final allInstallButtons = tester
.widgetList<IconButton>(
find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치'),
)
.toList();
// 3 devices: 2 ready (enabled), 1 unauthorized (disabled)
expect(allInstallButtons.where((b) => b.onPressed == null), hasLength(1));
});
testWidgets(
'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 = <String>[];
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) async => const AdbInstallResult(
exitCode: 0,
stdout: 'Performing Streamed Install\nSuccess',
stderr: '',
),
artifactCleaner: (path) async => cleanedPaths.add(path),
),
);
await navigateToDevicesWithPending(tester);
final installButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == '설치',
);
await tester.tap(installButtons.first);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget);
expect(find.byKey(const ValueKey('install-result-output')), findsOneWidget);
expect(find.textContaining('Success'), findsWidgets);
});
testWidgets('cleans staged apk once after successful install result', (
tester,
) async {
final cleanedPaths = <String>[];
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) async =>
const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''),
artifactCleaner: (path) async => cleanedPaths.add(path),
),
);
await navigateToDevicesWithPending(tester);
final installButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == '설치',
);
await tester.tap(installButtons.first);
await tester.pumpAndSettle();
expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
// Result banner must survive after cleanup
expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget);
// Pending banner must be gone
expect(find.byKey(const ValueKey('pending-install-banner')), findsNothing);
});
testWidgets(
'cleans staged apk once after failed install result while keeping stderr visible',
(tester) async {
final cleanedPaths = <String>[];
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) async => const AdbInstallResult(
exitCode: 1,
stdout: '',
stderr: 'INSTALL_FAILED_VERSION_DOWNGRADE',
),
artifactCleaner: (path) async => cleanedPaths.add(path),
),
);
await navigateToDevicesWithPending(tester);
final installButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == '설치',
);
await tester.tap(installButtons.first);
await tester.pumpAndSettle();
expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
expect(
find.byKey(const ValueKey('install-result-banner')),
findsOneWidget,
);
expect(
find.textContaining('INSTALL_FAILED_VERSION_DOWNGRADE'),
findsOneWidget,
);
},
);
testWidgets('does not clean staged apk before install future completes', (
tester,
) async {
final cleanedPaths = <String>[];
final completer = Completer<AdbInstallResult>();
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) => completer.future,
artifactCleaner: (path) async => cleanedPaths.add(path),
),
);
await navigateToDevicesWithPending(tester);
final installButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == '설치',
);
await tester.tap(installButtons.first);
await tester.pump(); // start install, do not settle
// Cleanup must NOT have been called yet
expect(cleanedPaths, isEmpty);
completer.complete(
const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''),
);
await tester.pumpAndSettle();
expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
});
testWidgets(
'keeps device page compact viewport free of overflow with pending apk',
(tester) async {
tester.view.physicalSize = const Size(600, 400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AppSokShell(
jobLoader: () async => [
JenkinsJob(
name: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/'),
),
],
buildLoader: (_) async => [
JenkinsBuild(
number: 7,
jobName: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/7/'),
startedAt: null,
result: 'SUCCESS',
artifacts: const [
BuildArtifact(
fileName: 'very-long-artifact-name-for-overflow-test.apk',
relativePath:
'outputs/very-long-artifact-name-for-overflow-test.apk',
),
],
requestedBy: 'toki',
),
],
artifactDownloader: (build, artifact) {
var received = 0;
final events = (() async* {
final chunk = List<int>.filled(1024, 1);
received += chunk.length;
yield DownloadProgressEvent(
receivedBytes: received,
totalBytes: 1024,
chunkBytes: chunk,
isComplete: true,
);
})();
return DownloadTask.fromStream(events);
},
artifactStager:
({
required build,
required artifact,
required byteStream,
}) async {
await byteStream.drain<void>();
return const StagedApk(
path: '/tmp/staged/app.apk',
sizeBytes: 1024,
);
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app').first);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('build-row-7')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('pending-install-banner')),
findsOneWidget,
);
expect(tester.takeException(), isNull);
},
);
// ── REVIEW_UILC: lifecycle edge regression ──────────────────────
testWidgets(
'cleans staged apk when install completes after leaving devices page',
(tester) async {
final cleanedPaths = <String>[];
final completer = Completer<AdbInstallResult>();
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) => completer.future,
artifactCleaner: (path) async => cleanedPaths.add(path),
),
);
await navigateToDevicesWithPending(tester);
// Tap install on first ready device
final installButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == '설치',
);
await tester.tap(installButtons.first);
await tester.pump(); // start install, future pending
// Navigate away from devices page while install is in flight
final buildsNav = find.descendant(
of: find.byType(NavigationRail),
matching: find.text('빌드'),
);
await tester.tap(buildsNav);
await tester.pump();
// Complete the install future after DevicesPage is no longer visible
completer.complete(
const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''),
);
await tester.pumpAndSettle();
// Cleanup must have been called exactly once — no Flutter exception
expect(tester.takeException(), isNull);
expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
},
);
testWidgets('shows failure and cleans staged apk when installer throws', (
tester,
) async {
final cleanedPaths = <String>[];
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) async => throw Exception('adb missing'),
artifactCleaner: (path) async => cleanedPaths.add(path),
),
);
await navigateToDevicesWithPending(tester);
final installButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == '설치',
);
await tester.tap(installButtons.first);
await tester.pumpAndSettle();
// No Flutter exception propagated
expect(tester.takeException(), isNull);
// Cleanup called once
expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
// Failure result banner shown with exception text
expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget);
expect(find.textContaining('adb missing'), findsOneWidget);
// Install buttons no longer stuck — hourglass_top icon must not appear
expect(
find.byWidgetPredicate((w) => w is Icon && w.icon == Icons.hourglass_top),
findsNothing,
);
});
testWidgets('keeps install attempt metadata with install result banner', (
tester,
) async {
const fileName = 'app-release.apk';
const jobName = 'android-app';
const buildNumber = 7;
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) async {
expect(pending.fileName, fileName);
expect(pending.jobName, jobName);
expect(pending.buildNumber, buildNumber);
return 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();
final attemptKey = const ValueKey(
'install-result-attempt:$jobName:$buildNumber:$fileName',
);
expect(find.byKey(attemptKey), findsOneWidget);
});
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 = <Map<String, String>>[
{'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 = <String>[];
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(<String>['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',
apiToken: 'issued-token',
),
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);
});
testWidgets('ready device logcat button opens console for that device', (
tester,
) async {
final fakeProcess = _FakeProcess();
final session = AdbLogcatSession(fakeProcess);
var startCalls = 0;
String? startedSerial;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AppSokShell(
deviceLoader: () async => const [
AdbDevice(
serial: 'device-ready-logcat',
state: 'device',
model: 'Pixel_8',
product: 'shiba',
),
],
logcatStarter: ({serial}) async {
startCalls++;
startedSerial = serial;
return session;
},
),
),
);
await tester.pumpAndSettle();
// Navigate to 디바이스
await tester.tap(
find.descendant(
of: find.byType(NavigationRail),
matching: find.text('디바이스'),
),
);
await tester.pumpAndSettle();
expect(find.text('Pixel 8'), findsOneWidget);
// Tap logcat button on the ready device
final logcatButton = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == 'logcat',
);
await tester.tap(logcatButton.first);
await tester.pumpAndSettle();
// Check we navigated to 콘솔
expect(startCalls, 1);
expect(startedSerial, 'device-ready-logcat');
expect(find.text('콘솔'), findsWidgets);
// Verify selected device identity is displayed on console
expect(find.textContaining('Pixel 8'), findsOneWidget);
expect(find.textContaining('device-ready-logcat'), findsOneWidget);
});
testWidgets('navigation away or device change stops logcat session', (
tester,
) async {
final fakeProcess1 = _FakeProcess();
final session1 = AdbLogcatSession(fakeProcess1);
final fakeProcess2 = _FakeProcess();
final session2 = AdbLogcatSession(fakeProcess2);
var startCalls = 0;
final sessions = [session1, session2];
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AppSokShell(
deviceLoader: () async => const [
AdbDevice(
serial: 'device-1',
state: 'device',
model: 'Pixel_8',
product: 'shiba',
),
AdbDevice(
serial: 'device-2',
state: 'device',
model: 'Galaxy_S23',
product: 'dm3q',
),
],
logcatStarter: ({serial}) async {
final s = sessions[startCalls];
startCalls++;
return s;
},
),
),
);
await tester.pumpAndSettle();
// Navigate to 디바이스
await tester.tap(
find.descendant(
of: find.byType(NavigationRail),
matching: find.text('디바이스'),
),
);
await tester.pumpAndSettle();
// Tap logcat button on device-1
final logcatButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == 'logcat',
);
await tester.tap(logcatButtons.at(0));
await tester.pumpAndSettle();
expect(startCalls, 1);
expect(fakeProcess1.killCalled, isFalse);
// Navigate to 디바이스 again (leaving ConsolePage)
await tester.tap(
find.descendant(
of: find.byType(NavigationRail),
matching: find.text('디바이스'),
),
);
await tester.pumpAndSettle();
expect(fakeProcess1.killCalled, isTrue);
// Tap logcat button on device-2
await tester.tap(logcatButtons.at(1));
await tester.pumpAndSettle();
expect(startCalls, 2);
expect(fakeProcess2.killCalled, isFalse);
// Change selected console device by tapping logcat on device-1
await tester.tap(
find.descendant(
of: find.byType(NavigationRail),
matching: find.text('디바이스'),
),
);
await tester.pumpAndSettle();
// Tap device-1 logcat
await tester.tap(logcatButtons.at(0));
await tester.pumpAndSettle();
// Now session 2 (device-2) must be stopped because device changed
expect(fakeProcess2.killCalled, isTrue);
});
}
class _FakeProcess implements Process {
final _stdout = StreamController<List<int>>();
final _stderr = StreamController<List<int>>();
final _exitCode = Completer<int>();
bool killCalled = false;
void emitStdout(String text) => _stdout.add(utf8.encode(text));
void emitStderr(String text) => _stderr.add(utf8.encode(text));
@override
Stream<List<int>> get stdout => _stdout.stream;
@override
Stream<List<int>> get stderr => _stderr.stream;
@override
Future<int> get exitCode => _exitCode.future;
@override
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
killCalled = true;
if (!_exitCode.isCompleted) _exitCode.complete(-15);
_stdout.close();
_stderr.close();
return true;
}
@override
int get pid => 4242;
@override
IOSink get stdin => throw UnimplementedError();
}