866 lines
29 KiB
Dart
866 lines
29 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
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_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('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('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(
|
|
'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);
|
|
}
|
|
},
|
|
);
|
|
|
|
// ── 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 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('Jenkins 연결 필요'), findsOneWidget);
|
|
});
|
|
}
|