appsok/test/widget_test.dart
toki c98f70737e feat(install): 검증된 APK 설치 대상을 전달한다
다운로드 검증 후 디바이스 화면에서 대기 중인 APK와 선택 장치를 연결할 수 있어야 한다.
2026-06-10 18:21:00 +09:00

496 lines
17 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appsok/src/app.dart';
import 'package:appsok/src/features/app_shell.dart';
import 'package:appsok/src/models/adb_device.dart';
import 'package:appsok/src/models/jenkins_build.dart';
import 'package:appsok/src/models/pending_install.dart';
import 'package:appsok/src/services/artifact_staging_service.dart';
import 'package:appsok/src/services/jenkins_client.dart';
import 'package:appsok/src/theme/app_theme.dart';
void main() {
test('keeps the AppSok work app theme baseline', () {
final theme = AppTheme.light();
final colorScheme = theme.colorScheme;
expect(theme.useMaterial3, isTrue);
expect(colorScheme.primary, const Color(0xFF0B6E69));
expect(colorScheme.secondary, const Color(0xFFD66B3D));
expect(colorScheme.tertiary, const Color(0xFF6655A8));
expect(theme.scaffoldBackgroundColor, const Color(0xFFFAFBF7));
});
testWidgets('renders the app shell', (WidgetTester tester) async {
await tester.pumpWidget(const AppSokApp());
expect(find.text('AppSok'), findsOneWidget);
expect(find.text('빌드'), findsWidgets);
expect(find.text('디바이스'), findsOneWidget);
expect(find.text('콘솔'), findsOneWidget);
expect(find.text('설정'), findsOneWidget);
});
testWidgets('renders shell pages without overflow at compact viewport', (
WidgetTester tester,
) async {
tester.view.physicalSize = const Size(600, 400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(const AppSokApp());
final navTargets = ['빌드', '디바이스', '콘솔', '설정'];
for (final label in navTargets) {
final navLabel = find.descendant(
of: find.byType(NavigationRail),
matching: find.text(label),
);
await tester.tap(navLabel);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
}
});
// ── INSTALL_HANDOFF-2: Shell handoff routing ─────────────────────
testWidgets('routes verified apk handoff to devices page', (tester) async {
const apk = BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/app-release.apk',
);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AppSokShell(
jobLoader: () async => [
JenkinsJob(
name: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/'),
),
],
buildLoader: (_) async => [
JenkinsBuild(
number: 7,
jobName: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/7/'),
startedAt: null,
result: 'SUCCESS',
artifacts: const [apk],
requestedBy: 'toki',
),
],
artifactDownloader: (build, artifact) {
var received = 0;
final events = (() async* {
final chunk = List<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('keeps install target disabled when shell has no target callback', (
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 onInstallTargetSelected — default shell 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();
// Devices page is active with pending install banner
expect(find.byKey(const ValueKey('pending-install-banner')), findsOneWidget);
// "설치 대상" button must be disabled (onPressed == null) when no callback
final installButtons = tester.widgetList<IconButton>(
find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치 대상'),
);
for (final btn in installButtons) {
expect(btn.onPressed, isNull);
}
});
testWidgets('passes selected device and pending apk to install target callback', (
tester,
) async {
AdbDevice? callbackDevice;
PendingInstall? callbackPending;
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,
);
},
onInstallTargetSelected: (device, pending) {
callbackDevice = device;
callbackPending = pending;
},
),
),
);
await tester.pumpAndSettle();
// Navigate builds → select job → select build → download → handoff to devices
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();
// Now on devices page — tap "설치 대상" on first ready device (R5CT90A1B2C)
final installButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == '설치 대상',
);
await tester.tap(installButtons.first);
expect(callbackDevice?.serial, 'R5CT90A1B2C');
expect(callbackPending?.apkPath, '/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);
},
);
testWidgets(
'switches between all shell destinations with status placeholders',
(WidgetTester tester) async {
await tester.pumpWidget(const AppSokApp());
final navTargets = <Map<String, String>>[
{'label': '빌드', 'marker': 'Jenkins 연결 필요'},
{'label': '디바이스', 'marker': 'R5CT90A1B2C'},
{'label': '콘솔', 'marker': '패키지 또는 태그'},
{'label': '설정', 'marker': 'Base URL'},
];
for (final target in navTargets) {
final navLabel = find.descendant(
of: find.byType(NavigationRail),
matching: find.text(target['label']!),
);
await tester.tap(navLabel);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('status-jenkins')), findsOneWidget);
expect(find.byKey(const ValueKey('status-adb')), findsOneWidget);
expect(find.text('Jenkins 대기'), findsOneWidget);
expect(find.text('ADB 대기'), findsOneWidget);
expect(find.text(target['marker']!), findsOneWidget);
}
},
);
}