599 lines
17 KiB
Dart
599 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:appsok/src/features/console/console_page.dart';
|
|
import 'package:appsok/src/models/adb_device.dart';
|
|
import 'package:appsok/src/services/adb_service.dart';
|
|
import 'package:appsok/src/theme/app_theme.dart';
|
|
|
|
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();
|
|
}
|
|
|
|
void main() {
|
|
testWidgets('colors sample logcat rows by parsed level', (tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: const Scaffold(body: ConsolePage()),
|
|
),
|
|
);
|
|
|
|
const infoLine =
|
|
'06-08 10:41:22.121 1832 2110 I ActivityTaskManager: START u0 com.toki.app/.MainActivity';
|
|
const debugLine =
|
|
'06-08 10:41:22.339 5521 5521 D AppSokInstall: install session committed';
|
|
const warnLine =
|
|
'06-08 10:41:22.921 5521 5540 W NetworkMonitor: vpn route not available, using usb install cache';
|
|
const errorLine =
|
|
'06-08 10:41:23.411 5521 5521 E Sample: placeholder error row for filter styling';
|
|
|
|
expect(_textColor(tester, infoLine), const Color(0xFFBDEFA8));
|
|
expect(_textColor(tester, debugLine), const Color(0xFF8DE1D7));
|
|
expect(_textColor(tester, warnLine), const Color(0xFFFFD28A));
|
|
expect(_textColor(tester, errorLine), const Color(0xFFFFA3A3));
|
|
});
|
|
|
|
testWidgets('filters visible rows by package tag or text query', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: const Scaffold(body: ConsolePage()),
|
|
),
|
|
);
|
|
|
|
const matchingLine =
|
|
'06-08 10:41:23.105 5521 5540 I ArtifactStore: app-debug.apk verified';
|
|
const hiddenLine =
|
|
'06-08 10:41:22.121 1832 2110 I ActivityTaskManager: START u0 com.toki.app/.MainActivity';
|
|
|
|
await tester.enterText(find.byType(TextField), 'artifactstore');
|
|
await tester.pump();
|
|
|
|
expect(find.text(matchingLine), findsOneWidget);
|
|
expect(find.text(hiddenLine), findsNothing);
|
|
|
|
await tester.tap(find.byTooltip('필터 초기화'));
|
|
await tester.pump();
|
|
|
|
expect(find.text(hiddenLine), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('filters visible rows by log level segment', (tester) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: const Scaffold(body: ConsolePage()),
|
|
),
|
|
);
|
|
|
|
const debugLine =
|
|
'06-08 10:41:22.339 5521 5521 D AppSokInstall: install session committed';
|
|
const errorLine =
|
|
'06-08 10:41:23.411 5521 5521 E Sample: placeholder error row for filter styling';
|
|
|
|
await tester.tap(find.text('ERROR'));
|
|
await tester.pump();
|
|
|
|
expect(find.text(errorLine), findsOneWidget);
|
|
expect(find.text(debugLine), findsNothing);
|
|
|
|
await tester.tap(find.text('DEBUG'));
|
|
await tester.pump();
|
|
|
|
expect(find.text(debugLine), findsOneWidget);
|
|
expect(find.text(errorLine), findsNothing);
|
|
});
|
|
|
|
testWidgets('filters live brief-format rows by log level segment', (
|
|
tester,
|
|
) async {
|
|
final fakeProcess = _FakeProcess();
|
|
final session = AdbLogcatSession(fakeProcess);
|
|
const device = AdbDevice(
|
|
serial: 'R5CT90A1B2C',
|
|
state: 'device',
|
|
model: 'SM_S918N',
|
|
product: 'dm3q',
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: ConsolePage(
|
|
device: device,
|
|
logcatStarter: ({serial}) async => session,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
fakeProcess.emitStdout('I/Keep: keep this line\n');
|
|
fakeProcess.emitStdout('E/Crash: crash this line\n');
|
|
await tester.pump(Duration.zero);
|
|
|
|
await tester.tap(find.text('INFO'));
|
|
await tester.pump();
|
|
|
|
expect(find.text('I/Keep: keep this line'), findsOneWidget);
|
|
expect(find.text('E/Crash: crash this line'), findsNothing);
|
|
|
|
await tester.tap(find.text('ERROR'));
|
|
await tester.pump();
|
|
|
|
expect(find.text('I/Keep: keep this line'), findsNothing);
|
|
expect(find.text('E/Crash: crash this line'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('appends live stream lines and stops session on dispose', (
|
|
tester,
|
|
) async {
|
|
final fakeProcess = _FakeProcess();
|
|
final session = AdbLogcatSession(fakeProcess);
|
|
|
|
const device = AdbDevice(
|
|
serial: 'R5CT90A1B2C',
|
|
state: 'device',
|
|
model: 'SM_S918N',
|
|
product: 'dm3q',
|
|
);
|
|
|
|
var startCalls = 0;
|
|
Future<AdbLogcatSession> fakeStarter({String? serial}) async {
|
|
startCalls++;
|
|
expect(serial, device.serial);
|
|
return session;
|
|
}
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: ConsolePage(device: device, logcatStarter: fakeStarter),
|
|
),
|
|
),
|
|
);
|
|
|
|
// Initial pump to run initState/startSession
|
|
await tester.pump();
|
|
|
|
expect(startCalls, 1);
|
|
|
|
// Emit live log lines
|
|
fakeProcess.emitStdout('I/test: hello live line\n');
|
|
await tester.pump(Duration.zero);
|
|
|
|
expect(find.text('I/test: hello live line'), findsOneWidget);
|
|
|
|
// Check dispose kills process
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: const Scaffold(body: SizedBox.shrink()),
|
|
),
|
|
);
|
|
|
|
expect(fakeProcess.killCalled, isTrue);
|
|
});
|
|
|
|
testWidgets('discards stale session when device changes during async start', (
|
|
tester,
|
|
) async {
|
|
final completerA = Completer<AdbLogcatSession>();
|
|
final completerB = Completer<AdbLogcatSession>();
|
|
|
|
final fakeProcessA = _FakeProcess();
|
|
final sessionA = AdbLogcatSession(fakeProcessA);
|
|
final fakeProcessB = _FakeProcess();
|
|
final sessionB = AdbLogcatSession(fakeProcessB);
|
|
|
|
const deviceA = AdbDevice(
|
|
serial: 'device-A',
|
|
state: 'device',
|
|
model: 'Pixel_8',
|
|
product: 'shiba',
|
|
);
|
|
const deviceB = AdbDevice(
|
|
serial: 'device-B',
|
|
state: 'device',
|
|
model: 'Galaxy_S23',
|
|
product: 'dm3q',
|
|
);
|
|
|
|
var startCalls = 0;
|
|
Future<AdbLogcatSession> fakeStarter({String? serial}) async {
|
|
startCalls++;
|
|
if (serial == 'device-A') {
|
|
return completerA.future;
|
|
} else if (serial == 'device-B') {
|
|
return completerB.future;
|
|
}
|
|
throw Exception('unexpected serial');
|
|
}
|
|
|
|
// 1. Render ConsolePage with deviceA, starter completerA is pending
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: ConsolePage(device: deviceA, logcatStarter: fakeStarter),
|
|
),
|
|
),
|
|
);
|
|
|
|
// Initial pump to trigger initState
|
|
await tester.pump();
|
|
expect(startCalls, 1);
|
|
|
|
// 2. Update widget to deviceB, starter completerB is pending
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: ConsolePage(device: deviceB, logcatStarter: fakeStarter),
|
|
),
|
|
),
|
|
);
|
|
// didUpdateWidget triggers start for deviceB
|
|
await tester.pump();
|
|
expect(startCalls, 2);
|
|
|
|
// 3. Complete starter B first
|
|
completerB.complete(sessionB);
|
|
await tester.pump(); // let completerB resolve
|
|
await tester.pump(Duration.zero); // let stream listen trigger
|
|
|
|
// Emit line from B
|
|
fakeProcessB.emitStdout('I/test: hello from B\n');
|
|
await tester.pump(Duration.zero);
|
|
expect(find.text('I/test: hello from B'), findsOneWidget);
|
|
|
|
// 4. Now complete starter A (which is stale)
|
|
completerA.complete(sessionA);
|
|
await tester.pump(); // let completerA resolve
|
|
await tester.pump(Duration.zero);
|
|
|
|
// A session must be stopped immediately
|
|
expect(fakeProcessA.killCalled, isTrue);
|
|
});
|
|
|
|
testWidgets(
|
|
'clears loading state when pending live session becomes sample mode',
|
|
(tester) async {
|
|
final completer = Completer<AdbLogcatSession>();
|
|
final fakeProcess = _FakeProcess();
|
|
final session = AdbLogcatSession(fakeProcess);
|
|
const device = AdbDevice(
|
|
serial: 'device-pending',
|
|
state: 'device',
|
|
model: 'Pixel_8',
|
|
product: 'shiba',
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: ConsolePage(
|
|
device: device,
|
|
logcatStarter: ({serial}) => completer.future,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: const Scaffold(body: ConsolePage()),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
|
expect(find.text('device 미선택'), findsOneWidget);
|
|
expect(find.textContaining('ActivityTaskManager'), findsOneWidget);
|
|
|
|
completer.complete(session);
|
|
await tester.pump();
|
|
|
|
expect(fakeProcess.killCalled, isTrue);
|
|
},
|
|
);
|
|
|
|
testWidgets('auto-scrolls to the newest log while running', (tester) async {
|
|
final fakeProcess = _FakeProcess();
|
|
final session = AdbLogcatSession(fakeProcess);
|
|
const device = AdbDevice(
|
|
serial: 'R5CT90A1B2C',
|
|
state: 'device',
|
|
model: 'SM_S918N',
|
|
product: 'dm3q',
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: ConsolePage(
|
|
device: device,
|
|
logcatStarter: ({serial}) async => session,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
// Emit enough lines to make the list scrollable (e.g., 50 lines)
|
|
for (var i = 0; i < 50; i++) {
|
|
fakeProcess.emitStdout('I/test: line $i\n');
|
|
}
|
|
await tester.pump(Duration.zero);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Verify it auto-scrolled to the bottom
|
|
final listView = tester.widget<ListView>(find.byType(ListView));
|
|
final controller = listView.controller;
|
|
expect(controller, isNotNull);
|
|
expect(
|
|
controller!.position.pixels,
|
|
equals(controller.position.maxScrollExtent),
|
|
);
|
|
});
|
|
|
|
testWidgets('keeps user scroll position while paused', (tester) async {
|
|
final fakeProcess = _FakeProcess();
|
|
final session = AdbLogcatSession(fakeProcess);
|
|
const device = AdbDevice(
|
|
serial: 'R5CT90A1B2C',
|
|
state: 'device',
|
|
model: 'SM_S918N',
|
|
product: 'dm3q',
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: ConsolePage(
|
|
device: device,
|
|
logcatStarter: ({serial}) async => session,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
// 1. Emit enough lines to scroll
|
|
for (var i = 0; i < 50; i++) {
|
|
fakeProcess.emitStdout('I/test: line $i\n');
|
|
}
|
|
await tester.pump(Duration.zero);
|
|
await tester.pumpAndSettle();
|
|
|
|
final listView = tester.widget<ListView>(find.byType(ListView));
|
|
final controller = listView.controller!;
|
|
final maxScrollExtentBeforePause = controller.position.maxScrollExtent;
|
|
expect(controller.position.pixels, equals(maxScrollExtentBeforePause));
|
|
|
|
// 2. Press pause button
|
|
final pauseButtonFinder = find.byTooltip('일시정지');
|
|
expect(pauseButtonFinder, findsOneWidget);
|
|
await tester.tap(pauseButtonFinder);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Verify tooltip and icon changed
|
|
expect(find.byTooltip('재개'), findsOneWidget);
|
|
|
|
// 3. User scrolls up slightly to some offset (e.g., 100.0)
|
|
controller.jumpTo(100.0);
|
|
await tester.pumpAndSettle();
|
|
expect(controller.position.pixels, equals(100.0));
|
|
|
|
// 4. Emit more lines while paused
|
|
for (var i = 50; i < 60; i++) {
|
|
fakeProcess.emitStdout('I/test: line $i\n');
|
|
}
|
|
await tester.pump(Duration.zero);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Verify offset remains at 100.0 (does not scroll to bottom)
|
|
expect(controller.position.pixels, equals(100.0));
|
|
|
|
// 5. Tap resume (tooltip '재개')
|
|
final resumeButtonFinder = find.byTooltip('재개');
|
|
await tester.tap(resumeButtonFinder);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Verify tooltip is '일시정지' again and it scrolled to bottom
|
|
expect(find.byTooltip('일시정지'), findsOneWidget);
|
|
expect(
|
|
controller.position.pixels,
|
|
equals(controller.position.maxScrollExtent),
|
|
);
|
|
});
|
|
|
|
testWidgets('clear button removes captured live log lines', (tester) async {
|
|
final fakeProcess = _FakeProcess();
|
|
final session = AdbLogcatSession(fakeProcess);
|
|
const device = AdbDevice(
|
|
serial: 'R5CT90A1B2C',
|
|
state: 'device',
|
|
model: 'SM_S918N',
|
|
product: 'dm3q',
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: ConsolePage(
|
|
device: device,
|
|
logcatStarter: ({serial}) async => session,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
fakeProcess.emitStdout('I/test: line before clear\n');
|
|
await tester.pump(Duration.zero);
|
|
expect(find.text('I/test: line before clear'), findsOneWidget);
|
|
|
|
await tester.tap(find.byTooltip('지우기'));
|
|
await tester.pump();
|
|
|
|
expect(find.text('I/test: line before clear'), findsNothing);
|
|
});
|
|
|
|
testWidgets('copies currently visible live log lines to clipboard', (
|
|
tester,
|
|
) async {
|
|
final copiedTexts = <String>[];
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
|
if (call.method == 'Clipboard.setData') {
|
|
copiedTexts.add(
|
|
(call.arguments as Map<Object?, Object?>)['text']! as String,
|
|
);
|
|
}
|
|
return null;
|
|
});
|
|
addTearDown(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(SystemChannels.platform, null);
|
|
});
|
|
|
|
final fakeProcess = _FakeProcess();
|
|
final session = AdbLogcatSession(fakeProcess);
|
|
const device = AdbDevice(
|
|
serial: 'R5CT90A1B2C',
|
|
state: 'device',
|
|
model: 'SM_S918N',
|
|
product: 'dm3q',
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: ConsolePage(
|
|
device: device,
|
|
logcatStarter: ({serial}) async => session,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
fakeProcess.emitStdout('I/Keep: keep this line\n');
|
|
fakeProcess.emitStdout('I/Drop: drop this line\n');
|
|
await tester.pump(Duration.zero);
|
|
|
|
// Apply a query filter so only one line is visible.
|
|
await tester.enterText(find.byType(TextField), 'keep');
|
|
await tester.pump();
|
|
|
|
await tester.tap(find.byTooltip('로그 복사'));
|
|
await tester.pump();
|
|
|
|
expect(copiedTexts, ['I/Keep: keep this line']);
|
|
});
|
|
|
|
testWidgets('keeps export disabled without live log lines', (tester) async {
|
|
// No device / sample placeholder state: export button is disabled.
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: const Scaffold(body: ConsolePage()),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
final sampleCopyButton = tester.widget<IconButton>(
|
|
find.ancestor(
|
|
of: find.byIcon(Icons.content_copy),
|
|
matching: find.byType(IconButton),
|
|
),
|
|
);
|
|
expect(sampleCopyButton.onPressed, isNull);
|
|
|
|
// Live mode but no captured lines yet: still disabled.
|
|
final fakeProcess = _FakeProcess();
|
|
final session = AdbLogcatSession(fakeProcess);
|
|
const device = AdbDevice(
|
|
serial: 'R5CT90A1B2C',
|
|
state: 'device',
|
|
model: 'SM_S918N',
|
|
product: 'dm3q',
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: ConsolePage(
|
|
device: device,
|
|
logcatStarter: ({serial}) async => session,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
final emptyLiveCopyButton = tester.widget<IconButton>(
|
|
find.ancestor(
|
|
of: find.byIcon(Icons.content_copy),
|
|
matching: find.byType(IconButton),
|
|
),
|
|
);
|
|
expect(emptyLiveCopyButton.onPressed, isNull);
|
|
});
|
|
}
|
|
|
|
Color? _textColor(WidgetTester tester, String text) {
|
|
return tester.widget<Text>(find.text(text)).style?.color;
|
|
}
|