import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:appsok/src/services/adb_service.dart'; void main() { group('AdbRuntimeConfig', () { test('prefers bundled adb when it exists', () { final tempDir = Directory.systemTemp.createTempSync( 'appsok-adb-runtime-', ); addTearDown(() => tempDir.deleteSync(recursive: true)); final bundledAdb = File('${tempDir.path}/adb')..writeAsStringSync(''); final runtime = AdbRuntimeConfig( bundledAdbPath: bundledAdb.path, ).resolve(); expect(runtime.executablePath, bundledAdb.path); expect(runtime.source, AdbExecutableSource.bundled); expect(runtime.bundledExists, isTrue); expect(runtime.fallbackUsed, isFalse); expect(runtime.serverPort, AdbRuntimeConfig.defaultAppSokServerPort); }); test('falls back to system adb when bundled adb is missing', () { final runtime = const AdbRuntimeConfig( bundledAdbPath: '/tmp/appsok-missing-adb', ).resolve(); expect(runtime.executablePath, AdbRuntimeConfig.systemAdbPath); expect(runtime.source, AdbExecutableSource.system); expect(runtime.bundledExists, isFalse); expect(runtime.fallbackUsed, isTrue); }); test('uses custom adb as an explicit override', () { final tempDir = Directory.systemTemp.createTempSync( 'appsok-adb-runtime-', ); addTearDown(() => tempDir.deleteSync(recursive: true)); final bundledAdb = File('${tempDir.path}/adb')..writeAsStringSync(''); final runtime = AdbRuntimeConfig( bundledAdbPath: bundledAdb.path, customAdbPath: '/custom/platform-tools/adb', ).resolve(); expect(runtime.executablePath, '/custom/platform-tools/adb'); expect(runtime.source, AdbExecutableSource.custom); expect(runtime.fallbackUsed, isFalse); }); test('uses shared ADB server port when requested', () { final runtime = const AdbRuntimeConfig( serverMode: AdbServerMode.shared, ).resolve(); expect(runtime.serverPort, AdbRuntimeConfig.sharedServerPort); expect(runtime.environment, {'ADB_SERVER_PORT': '5037'}); }); }); group('AdbService.collectDiagnostics', () { test('captures adb version on success', () async { const versionOutput = 'Android Debug Bridge version 1.0.41\n' 'Version 35.0.2-12147458\n'; final service = AdbService( runtimeConfig: const AdbRuntimeConfig(customAdbPath: '/opt/appsok/adb'), processRunner: (executable, args, {environment}) async { expect(args, ['version']); return ProcessResult(42, 0, versionOutput, ''); }, ); final diag = await service.collectDiagnostics(); expect(diag.version, versionOutput.trim()); expect(diag.versionError, isNull); expect(diag.runtime.executablePath, '/opt/appsok/adb'); }); test('records versionError on non-zero exit', () async { final service = AdbService( runtimeConfig: const AdbRuntimeConfig(customAdbPath: '/opt/appsok/adb'), processRunner: (_, args, {environment}) async { return ProcessResult(42, 1, '', 'cannot run adb'); }, ); final diag = await service.collectDiagnostics(); expect(diag.version, isNull); expect(diag.versionError, 'cannot run adb'); }); test('records versionError with exit code when stderr is empty', () async { final service = AdbService( runtimeConfig: const AdbRuntimeConfig(customAdbPath: '/opt/appsok/adb'), processRunner: (_, args, {environment}) async { return ProcessResult(42, 127, '', ''); }, ); final diag = await service.collectDiagnostics(); expect(diag.version, isNull); expect(diag.versionError, 'exit code 127'); }); test('records versionError when process throws', () async { final service = AdbService( runtimeConfig: const AdbRuntimeConfig(customAdbPath: '/not/found/adb'), processRunner: (_, args, {environment}) async { throw const OSError('No such file or directory', 2); }, ); final diag = await service.collectDiagnostics(); expect(diag.version, isNull); expect(diag.versionError, isNotNull); }); test('computes stable sha256 for a temp executable', () async { final tempDir = Directory.systemTemp.createTempSync('appsok-diag-'); addTearDown(() => tempDir.deleteSync(recursive: true)); final fakeAdb = File('${tempDir.path}/adb') ..writeAsBytesSync([0x7f, 0x45, 0x4c, 0x46]); final service = AdbService( runtimeConfig: AdbRuntimeConfig(bundledAdbPath: fakeAdb.path), processRunner: (_, args, {environment}) async => ProcessResult(0, 0, 'Android Debug Bridge version 1.0.0', ''), ); final diag1 = await service.collectDiagnostics(); final diag2 = await service.collectDiagnostics(); expect(diag1.sha256, isNotNull); expect(diag1.sha256, diag2.sha256); expect(diag1.sha256, hasLength(64)); }); test('sha256 is null when executable does not exist', () async { final service = AdbService( runtimeConfig: const AdbRuntimeConfig(customAdbPath: '/no/such/adb'), processRunner: (_, args, {environment}) async => ProcessResult(0, 0, '', ''), ); final diag = await service.collectDiagnostics(); expect(diag.sha256, isNull); }); }); group('AdbService', () { test( 'runs adb devices with the resolved executable and server port', () async { final calls = <({String executable, List args, String? port})>[]; final service = AdbService( runtimeConfig: const AdbRuntimeConfig( customAdbPath: '/opt/appsok/adb', serverPort: 15037, ), processRunner: (executable, args, {environment}) async { calls.add(( executable: executable, args: args, port: environment?['ADB_SERVER_PORT'], )); return ProcessResult( 42, 0, 'List of devices attached\n' 'emulator-5554 device product:emu64a ' 'model:sdk_gphone64_arm64 device:emu64a\n', '', ); }, ); final devices = await service.listDevices(); expect(calls, hasLength(1)); expect(calls.single.executable, '/opt/appsok/adb'); expect(calls.single.args, ['devices', '-l']); expect(calls.single.port, '15037'); expect(devices.single.serial, 'emulator-5554'); expect(devices.single.state, 'device'); expect(devices.single.model, 'sdk_gphone64_arm64'); }, ); test( 'parses device, unauthorized, and offline adb devices fixtures', () async { final service = AdbService( runtimeConfig: const AdbRuntimeConfig( customAdbPath: '/opt/appsok/adb', ), processRunner: (_, args, {environment}) async { expect(args, ['devices', '-l']); return ProcessResult( 42, 0, 'List of devices attached\n' 'R5CT90A1B2C device product:dm3q ' 'model:SM_S918N device:dm3q transport_id:1\n' 'ZX1G22K7Q9 unauthorized usb:336592896X ' 'product:shiba model:Pixel_8 device:shiba\n' 'emulator-5554 offline product:sdk_gphone64_arm64 ' 'model:sdk_gphone64_arm64 device:emu64a\n', '', ); }, ); final devices = await service.listDevices(); expect(devices, hasLength(3)); expect(devices[0].serial, 'R5CT90A1B2C'); expect(devices[0].state, 'device'); expect(devices[0].isReady, isTrue); expect(devices[0].product, 'dm3q'); expect(devices[0].model, 'SM_S918N'); expect(devices[1].serial, 'ZX1G22K7Q9'); expect(devices[1].state, 'unauthorized'); expect(devices[1].needsAuthorization, isTrue); expect(devices[1].model, 'Pixel_8'); expect(devices[2].serial, 'emulator-5554'); expect(devices[2].state, 'offline'); expect(devices[2].isOffline, isTrue); expect(devices[2].product, 'sdk_gphone64_arm64'); }, ); test('startLogcat runs adb logcat for the requested serial', () async { final calls = <({String executable, List args, String? port})>[]; final fake = _FakeProcess(); final service = AdbService( runtimeConfig: const AdbRuntimeConfig( customAdbPath: '/opt/appsok/adb', serverPort: 15037, ), processStarter: (executable, args, {environment}) async { calls.add(( executable: executable, args: args, port: environment?['ADB_SERVER_PORT'], )); return fake; }, ); final session = await service.startLogcat(serial: 'R5CT90A1B2C'); addTearDown(session.stop); expect(calls, hasLength(1)); expect(calls.single.executable, '/opt/appsok/adb'); expect(calls.single.args, ['-s', 'R5CT90A1B2C', 'logcat']); expect(calls.single.port, '15037'); }); test('logcat session merges stdout and stderr lines', () async { final fake = _FakeProcess(); final service = AdbService( runtimeConfig: const AdbRuntimeConfig(customAdbPath: '/opt/appsok/adb'), processStarter: (_, args, {environment}) async => fake, ); final session = await service.startLogcat(); final received = []; final sub = session.lines.listen(received.add); fake.emitStdout('I/out: first\n'); fake.emitStderr('E/err: oops\n'); fake.emitStdout('I/out: second\n'); await Future.delayed(Duration.zero); expect(received, [ 'I/out: first', 'E/err: oops', 'I/out: second', ]); await sub.cancel(); await session.stop(); }); test('logcat session stop kills the adb process', () async { final fake = _FakeProcess(); final service = AdbService( runtimeConfig: const AdbRuntimeConfig(customAdbPath: '/opt/appsok/adb'), processStarter: (_, args, {environment}) async => fake, ); final session = await service.startLogcat(); expect(fake.killCalled, isFalse); await session.stop(); expect(fake.killCalled, isTrue); }); test('logcat stream cancellation kills the adb process', () async { final fake = _FakeProcess(); final service = AdbService( runtimeConfig: const AdbRuntimeConfig(customAdbPath: '/opt/appsok/adb'), processStarter: (_, args, {environment}) async => fake, ); final sub = service.logcat().listen((_) {}); await Future.delayed(Duration.zero); expect(fake.killCalled, isFalse); await sub.cancel(); expect(fake.killCalled, isTrue); }); test('runs adb install with the shared server port', () async { final calls = <({List args, String? port})>[]; final service = AdbService( runtimeConfig: const AdbRuntimeConfig( customAdbPath: '/opt/appsok/adb', serverMode: AdbServerMode.shared, ), processRunner: (_, args, {environment}) async { calls.add((args: args, port: environment?['ADB_SERVER_PORT'])); return ProcessResult(42, 0, 'Success', ''); }, ); final result = await service.installApk( serial: 'R5CT90A1B2C', apkPath: '/tmp/app.apk', ); expect(result.succeeded, isTrue); expect(calls.single.args, [ '-s', 'R5CT90A1B2C', 'install', '-r', '/tmp/app.apk', ]); expect(calls.single.port, '5037'); }); }); } /// Minimal in-memory [Process] stand-in for logcat tests. Only the members the /// logcat session touches (stdout, stderr, kill, exitCode) are implemented. class _FakeProcess implements Process { final _stdout = StreamController>(); final _stderr = StreamController>(); final _exitCode = Completer(); bool killCalled = false; void emitStdout(String text) => _stdout.add(utf8.encode(text)); void emitStderr(String text) => _stderr.add(utf8.encode(text)); @override Stream> get stdout => _stdout.stream; @override Stream> get stderr => _stderr.stream; @override Future 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(); }