496 lines
17 KiB
Dart
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);
|
|
}
|
|
},
|
|
);
|
|
}
|