diff --git a/agent/config.sample.yaml b/agent/config.sample.yaml index 621253d2f3..434f01bb52 100644 --- a/agent/config.sample.yaml +++ b/agent/config.sample.yaml @@ -33,8 +33,5 @@ auth_token: blahblahblah # get a Firebase token. firebase_flutter_dashboard_token: blahblahblah -# Whether the device tests on Android (true by default) -tests_android: true - -# Whether the device tests on iOS (false by default) -tests_ios: false +# The device operating system. Possible values: android, ios. +device_os: android diff --git a/agent/lib/src/adb.dart b/agent/lib/src/adb.dart index 578fff213b..6188ce6db6 100644 --- a/agent/lib/src/adb.dart +++ b/agent/lib/src/adb.dart @@ -5,95 +5,126 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:meta/meta.dart'; + import 'utils.dart'; -typedef Future AdbGetter(); +/// The root of the API for controlling devices. +DeviceDiscovery get devices => new DeviceDiscovery(); + +/// Operating system on the devices that this agent is configured to test. +enum DeviceOperatingSystem { android, ios } + +/// Discovers available devices and chooses one to work with. +abstract class DeviceDiscovery { + factory DeviceDiscovery() { + switch(config.deviceOperatingSystem) { + case DeviceOperatingSystem.android: + return new AndroidDeviceDiscovery(); + case DeviceOperatingSystem.ios: + return new IosDeviceDiscovery(); + default: + throw new StateError('Unsupported device operating system: {config.deviceOperatingSystem}'); + } + } -/// Get an instance of [Adb]. -/// -/// See [realAdbGetter] for signature. This can be overwritten for testing. -AdbGetter adb = realAdbGetter; + /// Selects a device to work with, load-balancing between devices if more than + /// one are available. + /// + /// A task performing multiple actions on a device would call this method + /// first then follow up by calling [workingDevice]. Calling [workingDevice] + /// repeatedly must guarantee to return the same device. + Future chooseWorkingDevice(); -Adb _currentDevice; + /// Returns current working device. + /// + /// Must be called _after_ a successful call to [chooseWorkingDevice]. May be + /// called repeatedly to perform multiple operations on one specific device; + /// guarantees to return the same device until the next time + /// [chooseWorkingDevice] is called. + Device get workingDevice; -/// Picks a random Android device out of connected devices and sets it as -/// [_currentDevice]. -Future pickNextDevice() async { - List allDevices = (await Adb.deviceIds) - .map((String id) => new Adb(deviceId: id)) - .toList(); + /// Lists all available devices' IDs. + Future> discoverDevices(); - if (allDevices.length == 0) - throw 'No Android devices detected'; + /// Checks the health of the available devices. + Future> checkDevices(); - // TODO(yjbanov): filter out and warn about those with low battery level - _currentDevice = allDevices[new math.Random().nextInt(allDevices.length)]; + /// Prepares the system to run tasks. + Future performPreflightTasks(); } -Future realAdbGetter() async { - if (_currentDevice == null) - await pickNextDevice(); - return _currentDevice; -} +/// A proxy for one specific device. +abstract class Device { + /// A unique device identifier. + String get deviceId; -/// Gets the ID of an unlocked device, unlocking it if necessary. -// TODO(yjbanov): abstract away iOS from Android. -Future getUnlockedDeviceId({ bool ios: false }) async { - if (ios) { - // We currently do not have a way to lock/unlock iOS devices, or even to - // pick one out of many. So we pick the first random iPhone and assume it's - // already unlocked. For now we'll just keep them at minimum screen - // brightness so they don't drain battery too fast. - List iosDeviceIds = grep('UniqueDeviceID', from: await eval('ideviceinfo', [])) - .map((String line) => line.split(' ').last).toList(); + /// Whether the device is awake. + Future isAwake(); - if (iosDeviceIds.isEmpty) - throw 'No connected iOS devices found.'; + /// Whether the device is asleep. + Future isAsleep(); - return iosDeviceIds.first; - } + /// Wake up the device if it is not awake. + Future wakeUp(); - Adb device = await adb(); - device.unlock(); - return device.deviceId; -} + /// Send the device to sleep mode. + Future sendToSleep(); -class Adb { - Adb({String this.deviceId}); + /// Emulates pressing the power button, toggling the device's on/off state. + Future togglePower(); - final String deviceId; + /// Unlocks the device. + /// + /// Assumes the device doesn't have a secure unlock pattern. + Future unlock(); +} +class AndroidDeviceDiscovery implements DeviceDiscovery { // Parses information about a device. Example: // // 015d172c98400a03 device usb:340787200X product:nakasi model:Nexus_7 device:grouper static final RegExp _kDeviceRegex = new RegExp(r'^(\S+)\s+(\S+)(.*)'); - static Future> checkDevices() async { - Map results = {}; - for (String deviceId in await deviceIds) { - try { - Adb device = new Adb(deviceId: deviceId); - // Just a smoke test that we can read wakefulness state - // TODO(yjbanov): check battery level - await device._getWakefulness(); - results['android-device-$deviceId'] = new HealthCheckResult.success(); - } catch(e, s) { - results['android-device-$deviceId'] = new HealthCheckResult.error(e, s); - } + static AndroidDeviceDiscovery _instance; + + factory AndroidDeviceDiscovery() { + return _instance ??= new AndroidDeviceDiscovery._(); + } + + AndroidDeviceDiscovery._(); + + AndroidDevice _workingDevice; + + @override + AndroidDevice get workingDevice { + if (_workingDevice == null) { + throw new StateError( + 'No working device chosen. Call `chooseWorkingDevice` prior to calling ' + 'the `workingDevice` getter.', + ); } - return results; + + return _workingDevice; } - /// Kills the `adb` server causing it to start a new instance upon next - /// command. - /// - /// Restarting `adb` helps with keeping device connections alive. When `adb` - /// runs non-stop for too long it loses connections to devices. - static Future restart() async { - await exec(config.adbPath, ['kill-server'], canFail: false); + /// Picks a random Android device out of connected devices and sets it as + /// [workingDevice]. + @override + Future chooseWorkingDevice() async { + List allDevices = (await discoverDevices()) + .map((String id) => new AndroidDevice(deviceId: id)) + .toList(); + + if (allDevices.isEmpty) + throw 'No Android devices detected'; + + // TODO(yjbanov): filter out and warn about those with low battery level + _workingDevice = allDevices[new math.Random().nextInt(allDevices.length)]; } - static Future> get deviceIds async { + @override + Future> discoverDevices() async { List output = (await eval(config.adbPath, ['devices', '-l'], canFail: false)) .trim().split('\n'); List results = []; @@ -122,23 +153,62 @@ class Adb { return results; } + @override + Future> checkDevices() async { + Map results = {}; + for (String deviceId in await discoverDevices()) { + try { + AndroidDevice device = new AndroidDevice(deviceId: deviceId); + // Just a smoke test that we can read wakefulness state + // TODO(yjbanov): check battery level + await device._getWakefulness(); + results['android-device-$deviceId'] = new HealthCheckResult.success(); + } catch(e, s) { + results['android-device-$deviceId'] = new HealthCheckResult.error(e, s); + } + } + return results; + } + + @override + Future performPreflightTasks() async { + // Kills the `adb` server causing it to start a new instance upon next + // command. + // + // Restarting `adb` helps with keeping device connections alive. When `adb` + // runs non-stop for too long it loses connections to devices. There may be + // a better method, but so far that's the best one I've found. + await exec(config.adbPath, ['kill-server'], canFail: false); + } +} + +class AndroidDevice implements Device { + AndroidDevice({@required String this.deviceId}); + + @override + final String deviceId; + /// Whether the device is awake. + @override Future isAwake() async { return await _getWakefulness() == 'Awake'; } /// Whether the device is asleep. + @override Future isAsleep() async { return await _getWakefulness() == 'Asleep'; } /// Wake up the device if it is not awake using [togglePower]. + @override Future wakeUp() async { if (!(await isAwake())) await togglePower(); } /// Send the device to sleep mode if it is not asleep using [togglePower]. + @override Future sendToSleep() async { if (!(await isAsleep())) await togglePower(); @@ -146,6 +216,7 @@ class Adb { /// Sends `KEYCODE_POWER` (26), which causes the device to toggle its mode /// between awake and asleep. + @override Future togglePower() async { await shellExec('input', const ['keyevent', '26']); } @@ -153,6 +224,7 @@ class Adb { /// Unlocks the device by sending `KEYCODE_MENU` (82). /// /// This only works when the device doesn't have a secure unlock pattern. + @override Future unlock() async { await wakeUp(); await shellExec('input', const ['keyevent', '82']); @@ -177,3 +249,91 @@ class Adb { return eval(config.adbPath, ['shell', command]..addAll(arguments), env: env, canFail: false); } } + +class IosDeviceDiscovery implements DeviceDiscovery { + + static IosDeviceDiscovery _instance; + + factory IosDeviceDiscovery() { + return _instance ??= new IosDeviceDiscovery._(); + } + + IosDeviceDiscovery._(); + + IosDevice _workingDevice; + + @override + IosDevice get workingDevice { + if (_workingDevice == null) { + throw new StateError( + 'No working device chosen. Call `chooseWorkingDevice` prior to calling ' + 'the `workingDevice` getter.', + ); + } + + return _workingDevice; + } + + /// Picks a random iOS device out of connected devices and sets it as + /// [workingDevice]. + @override + Future chooseWorkingDevice() async { + List allDevices = (await discoverDevices()) + .map((String id) => new IosDevice(deviceId: id)) + .toList(); + + if (allDevices.length == 0) + throw 'No iOS devices detected'; + + // TODO(yjbanov): filter out and warn about those with low battery level + _workingDevice = allDevices[new math.Random().nextInt(allDevices.length)]; + } + + @override + Future> discoverDevices() async { + // TODO: use the -k UniqueDeviceID option, which requires much less parsing. + List iosDeviceIds = grep('UniqueDeviceID', from: await eval('ideviceinfo', [])) + .map((String line) => line.split(' ').last).toList(); + + if (iosDeviceIds.isEmpty) + throw 'No connected iOS devices found.'; + + return iosDeviceIds; + } + + @override + Future> checkDevices() async { + Map results = {}; + for (String deviceId in await discoverDevices()) { + // TODO: do a more meaningful connectivity check than just recording the ID + results['ios-device-$deviceId'] = new HealthCheckResult.success(); + } + return results; + } + + @override + Future performPreflightTasks() async { + // Currently we do not have preflight tasks for iOS. + return null; + } +} + +/// iOS device. +class IosDevice implements Device { + const IosDevice({ @required String this.deviceId }); + + @override + final String deviceId; + + // The methods below are stubs for now. They will need to be expanded. + // We currently do not have a way to lock/unlock iOS devices. So we assume the + // devices are already unlocked. For now we'll just keep them at minimum + // screen brightness so they don't drain battery too fast. + + Future isAwake() async => true; + Future isAsleep() async => false; + Future wakeUp() async {} + Future sendToSleep() async {} + Future togglePower() async {} + Future unlock() async {} +} diff --git a/agent/lib/src/agent.dart b/agent/lib/src/agent.dart index f0b704c90e..0aafb91d04 100644 --- a/agent/lib/src/agent.dart +++ b/agent/lib/src/agent.dart @@ -62,7 +62,7 @@ class Agent { Future _screenOff() async { try { - await (await adb()).sendToSleep(); + await devices.workingDevice.sendToSleep(); } catch(error, stackTrace) { print('Failed to turn off screen: $error\n$stackTrace'); } diff --git a/agent/lib/src/commands/ci.dart b/agent/lib/src/commands/ci.dart index 35b3014066..2554a6de94 100644 --- a/agent/lib/src/commands/ci.dart +++ b/agent/lib/src/commands/ci.dart @@ -47,9 +47,7 @@ class ContinuousIntegrationCommand extends Command { _listenToShutdownSignals(); while(!_exiting) { try { - // This increases the likelihood of obtaining a healthy connection to - // the device. - Adb.restart(); + await devices.performPreflightTasks(); // Check health before requesting a new task. health = await _performHealthChecks(); @@ -106,31 +104,16 @@ class ContinuousIntegrationCommand extends Command { try { results['firebase-connection'] = await checkFirebaseConnection(); - if (config.testsIos) { - String deviceIds = await eval('ideviceinfo', ['-k', 'DeviceClass'], canFail: true); - results['has-healthy-ios-devices'] = deviceIds.contains('iPhone') - ? new HealthCheckResult.success('Found an iPhone') - : new HealthCheckResult.failure( - 'This agent is configured to test on iOS devices. However, no ' - 'attached iOS devices were found.', - ); - } - - if (config.testsAndroid) { - Map deviceChecks = await Adb.checkDevices(); - results.addAll(deviceChecks); + Map deviceChecks = await devices.checkDevices(); + results.addAll(deviceChecks); - int healthyDeviceCount = deviceChecks.values - .where((HealthCheckResult r) => r.succeeded) - .length; + bool hasHealthyDevices = deviceChecks.values + .where((HealthCheckResult r) => r.succeeded) + .isNotEmpty; - results['has-healthy-android-devices'] = healthyDeviceCount > 0 - ? new HealthCheckResult.success('Found ${deviceChecks.length} healthy devices') - : new HealthCheckResult.failure( - 'This agent is configured to test on Android devices. However, no ' - 'attached Android devices were found.', - ); - } + results['has-healthy-devices'] = hasHealthyDevices + ? new HealthCheckResult.success('Found ${deviceChecks.length} healthy devices') + : new HealthCheckResult.failure('No attached devices were found.'); try { String authStatus = await agent.getAuthenticationStatus(); diff --git a/agent/lib/src/commands/run.dart b/agent/lib/src/commands/run.dart index cac6909319..6192a5175c 100644 --- a/agent/lib/src/commands/run.dart +++ b/agent/lib/src/commands/run.dart @@ -57,7 +57,7 @@ class RunCommand extends Command { try { await runAndCaptureAsyncStacks(() async { // Load-balance tests across attached devices - await pickNextDevice(); + await devices.chooseWorkingDevice(); BuildResult result = await agent.performTask(task); if (task.key != null) { diff --git a/agent/lib/src/gallery.dart b/agent/lib/src/gallery.dart index 7a33070fa4..a03c594ff6 100644 --- a/agent/lib/src/gallery.dart +++ b/agent/lib/src/gallery.dart @@ -21,7 +21,7 @@ class GalleryTransitionTest extends Task { @override Future run() async { - String deviceId = await getUnlockedDeviceId(ios: ios); + String deviceId = devices.workingDevice.deviceId; Directory galleryDirectory = dir('${config.flutterDirectory.path}/examples/flutter_gallery'); await inDirectory(galleryDirectory, () async { await flutter('packages', options: ['get']); diff --git a/agent/lib/src/hot_dev_cycle.dart b/agent/lib/src/hot_dev_cycle.dart index 2081eccd9a..b0208afd2d 100644 --- a/agent/lib/src/hot_dev_cycle.dart +++ b/agent/lib/src/hot_dev_cycle.dart @@ -8,7 +8,6 @@ import 'dart:io'; import 'package:path/path.dart' as path; import 'adb.dart'; -import 'benchmarks.dart'; import 'framework.dart'; import 'utils.dart'; @@ -41,7 +40,7 @@ class HotDevCycleTask extends Task { @override Future run() async { - Adb device = await adb(); + Device device = devices.workingDevice; device.unlock(); rm(benchmarkFile); await inDirectory(appDir, () async { diff --git a/agent/lib/src/perf_tests.dart b/agent/lib/src/perf_tests.dart index c61d634889..d2e86f2098 100644 --- a/agent/lib/src/perf_tests.dart +++ b/agent/lib/src/perf_tests.dart @@ -54,7 +54,7 @@ class StartupTest extends Task { Future run() async { return await inDirectory(testDirectory, () async { - String deviceId = await getUnlockedDeviceId(ios: ios); + String deviceId = devices.workingDevice.deviceId; await flutter('packages', options: ['get']); if (ios) { @@ -92,7 +92,7 @@ class PerfTest extends Task { @override Future run() { return inDirectory(testDirectory, () async { - String deviceId = await getUnlockedDeviceId(ios: ios); + String deviceId = devices.workingDevice.deviceId; await flutter('packages', options: ['get']); if (ios) { @@ -127,7 +127,7 @@ class BuildTest extends Task { Future run() async { return await inDirectory(testDirectory, () async { - Adb device = await adb(); + Device device = devices.workingDevice; device.unlock(); await flutter('packages', options: ['get']); diff --git a/agent/lib/src/refresh.dart b/agent/lib/src/refresh.dart index e0d8c6a068..a34592d601 100644 --- a/agent/lib/src/refresh.dart +++ b/agent/lib/src/refresh.dart @@ -29,7 +29,7 @@ class EditRefreshTask extends Task { @override Future run() async { - Adb device = await adb(); + Device device = devices.workingDevice; device.unlock(); Benchmark benchmark = new EditRefreshBenchmark(commit, timestamp); section(benchmark.name); @@ -59,7 +59,7 @@ class EditRefreshBenchmark extends Benchmark { @override Future run() async { - Adb device = await adb(); + Device device = devices.workingDevice; rm(benchmarkFile); int exitCode = await inDirectory(megaDir, () async { return await flutter( diff --git a/agent/lib/src/utils.dart b/agent/lib/src/utils.dart index 87276baddb..12853e01d5 100644 --- a/agent/lib/src/utils.dart +++ b/agent/lib/src/utils.dart @@ -12,6 +12,8 @@ import 'package:path/path.dart' as path; import 'package:stack_trace/stack_trace.dart'; import 'package:yaml/yaml.dart'; +import 'adb.dart'; + /// Virtual current working directory, which affect functions, such as [exec]. String cwd = Directory.current.path; @@ -268,8 +270,7 @@ class Config { @required this.authToken, @required this.flutterDirectory, @required this.runTaskFile, - @required this.testsAndroid, - @required this.testsIos, + @required this.deviceOperatingSystem, }); static void initialize(ArgResults args) { @@ -293,6 +294,18 @@ class Config { Directory flutterDirectory = dir('${Platform.environment['HOME']}/.cocoon/flutter'); mkdirs(flutterDirectory); + DeviceOperatingSystem deviceOperatingSystem; + switch(agentConfig['device_os']) { + case 'android': + deviceOperatingSystem = DeviceOperatingSystem.android; + break; + case 'ios': + deviceOperatingSystem = DeviceOperatingSystem.ios; + break; + default: + throw new BuildFailedError('Unrecognized device_os value: ${agentConfig['device_os']}'); + } + _config = new Config( baseCocoonUrl: baseCocoonUrl, agentId: agentId, @@ -300,8 +313,7 @@ class Config { authToken: authToken, flutterDirectory: flutterDirectory, runTaskFile: runTaskFile, - testsAndroid: agentConfig['tests_android'] ?? true, - testsIos: agentConfig['tests_ios'] ?? false, + deviceOperatingSystem: deviceOperatingSystem, ); } @@ -311,8 +323,7 @@ class Config { final String authToken; final Directory flutterDirectory; final File runTaskFile; - final bool testsAndroid; - final bool testsIos; + final DeviceOperatingSystem deviceOperatingSystem; String get adbPath { String androidHome = Platform.environment['ANDROID_HOME']; @@ -336,9 +347,8 @@ baseCocoonUrl: $baseCocoonUrl agentId: $agentId flutterDirectory: $flutterDirectory runTaskFile: $runTaskFile -adbPath: $adbPath -testsAndroid: $testsAndroid -testsIos: $testsIos +adbPath: ${deviceOperatingSystem == DeviceOperatingSystem.android ? adbPath : 'N/A'} +deviceOperatingSystem: $deviceOperatingSystem '''.trim(); } diff --git a/agent/test/src/adb_test.dart b/agent/test/src/adb_test.dart index 65118210d5..6859bf6fe4 100644 --- a/agent/test/src/adb_test.dart +++ b/agent/test/src/adb_test.dart @@ -9,29 +9,28 @@ import 'package:collection/collection.dart'; import 'package:cocoon_agent/src/adb.dart'; -main() { - group('adb', () { - Adb device; +void main() { + group('device', () { + Device device; setUp(() { - FakeAdb.resetLog(); - adb = null; - device = new FakeAdb(); + FakeDevice.resetLog(); + device = null; + device = new FakeDevice(); }); tearDown(() { - adb = realAdbGetter; }); group('isAwake/isAsleep', () { test('reads Awake', () async { - FakeAdb.pretendAwake(); + FakeDevice.pretendAwake(); expect(await device.isAwake(), isTrue); expect(await device.isAsleep(), isFalse); }); test('reads Asleep', () async { - FakeAdb.pretendAsleep(); + FakeDevice.pretendAsleep(); expect(await device.isAwake(), isFalse); expect(await device.isAsleep(), isTrue); }); @@ -48,7 +47,7 @@ main() { group('wakeUp', () { test('when awake', () async { - FakeAdb.pretendAwake(); + FakeDevice.pretendAwake(); await device.wakeUp(); expectLog([ cmd(command: 'dumpsys', arguments: ['power']), @@ -56,7 +55,7 @@ main() { }); test('when asleep', () async { - FakeAdb.pretendAsleep(); + FakeDevice.pretendAsleep(); await device.wakeUp(); expectLog([ cmd(command: 'dumpsys', arguments: ['power']), @@ -67,7 +66,7 @@ main() { group('sendToSleep', () { test('when asleep', () async { - FakeAdb.pretendAsleep(); + FakeDevice.pretendAsleep(); await device.sendToSleep(); expectLog([ cmd(command: 'dumpsys', arguments: ['power']), @@ -75,7 +74,7 @@ main() { }); test('when awake', () async { - FakeAdb.pretendAwake(); + FakeDevice.pretendAwake(); await device.sendToSleep(); expectLog([ cmd(command: 'dumpsys', arguments: ['power']), @@ -86,7 +85,7 @@ main() { group('unlock', () { test('sends unlock event', () async { - FakeAdb.pretendAwake(); + FakeDevice.pretendAwake(); await device.unlock(); expectLog([ cmd(command: 'dumpsys', arguments: ['power']), @@ -98,7 +97,7 @@ main() { } void expectLog(List log) { - expect(FakeAdb.commandLog, log); + expect(FakeDevice.commandLog, log); } CommandArgs cmd({String command, List arguments, Map env}) => new CommandArgs( @@ -142,8 +141,8 @@ class CommandArgs { : null.hashCode; } -class FakeAdb extends Adb { - FakeAdb({String deviceId: null}) : super(deviceId: deviceId); +class FakeDevice extends AndroidDevice { + FakeDevice({String deviceId: null}) : super(deviceId: deviceId); static String output = ''; static ExitErrorFactory exitErrorFactory = () => null; diff --git a/commands/refresh_github_commits.go b/commands/refresh_github_commits.go index 87c4dc98e1..4e4f3d44d3 100644 --- a/commands/refresh_github_commits.go +++ b/commands/refresh_github_commits.go @@ -169,5 +169,5 @@ type ManifestTask struct { Name string Description string Stage string - RequiredAgentCapabilities []string + RequiredAgentCapabilities []string `yaml:"required_agent_capabilities"` }