- install_attempt_result.dart 모델 추가 - devices_page에 설치 결과 UI 적용 - app_shell.dart 및 widget_test.dart 업데이트 - agent-roadmap 및 agent-task 관련 파일 동기화
1659 lines
51 KiB
Dart
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();
|
|
}
|