appsok/test/adb_service_test.dart

397 lines
13 KiB
Dart

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<String> 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<String> 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 = <String>[];
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<void>.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<void>.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<String> 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<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();
}