decouple `flutter drive` from `flutter start`
flutter start's method of finding devices to run the app on is not suitable for flutter drive.
This commit also refactors several tool services to allow mocking in unit tests.
diff --git a/packages/flutter_tools/lib/src/android/android_device.dart b/packages/flutter_tools/lib/src/android/android_device.dart
index 45c5e21..fbd6138 100644
--- a/packages/flutter_tools/lib/src/android/android_device.dart
+++ b/packages/flutter_tools/lib/src/android/android_device.dart
@@ -54,6 +54,8 @@
bool _connected;
+ bool get isLocalEmulator => false;
+
List<String> adbCommandForDevice(List<String> args) {
return <String>[androidSdk.adbPath, '-s', id]..addAll(args);
}
diff --git a/packages/flutter_tools/lib/src/base/os.dart b/packages/flutter_tools/lib/src/base/os.dart
index 444991c..973b421 100644
--- a/packages/flutter_tools/lib/src/base/os.dart
+++ b/packages/flutter_tools/lib/src/base/os.dart
@@ -5,7 +5,10 @@
import 'dart:async';
import 'dart:io';
-final OperatingSystemUtils os = new OperatingSystemUtils._();
+import 'context.dart';
+
+/// Returns [OperatingSystemUtils] active in the current app context (i.e. zone).
+OperatingSystemUtils get os => context[OperatingSystemUtils] ?? (context[OperatingSystemUtils] = new OperatingSystemUtils._());
abstract class OperatingSystemUtils {
factory OperatingSystemUtils._() {
@@ -16,6 +19,14 @@
}
}
+ OperatingSystemUtils._private();
+
+ String get operatingSystem => Platform.operatingSystem;
+
+ bool get isMacOS => operatingSystem == 'macos';
+ bool get isWindows => operatingSystem == 'windows';
+ bool get isLinux => operatingSystem == 'linux';
+
/// Make the given file executable. This may be a no-op on some platforms.
ProcessResult makeExecutable(File file);
@@ -24,7 +35,9 @@
File which(String execName);
}
-class _PosixUtils implements OperatingSystemUtils {
+class _PosixUtils extends OperatingSystemUtils {
+ _PosixUtils() : super._private();
+
ProcessResult makeExecutable(File file) {
return Process.runSync('chmod', ['u+x', file.path]);
}
@@ -40,7 +53,9 @@
}
}
-class _WindowsUtils implements OperatingSystemUtils {
+class _WindowsUtils extends OperatingSystemUtils {
+ _WindowsUtils() : super._private();
+
// This is a no-op.
ProcessResult makeExecutable(File file) {
return new ProcessResult(0, 0, null, null);
diff --git a/packages/flutter_tools/lib/src/commands/apk.dart b/packages/flutter_tools/lib/src/commands/apk.dart
index e43686d..bd60d36 100644
--- a/packages/flutter_tools/lib/src/commands/apk.dart
+++ b/packages/flutter_tools/lib/src/commands/apk.dart
@@ -420,7 +420,7 @@
// TODO(mpcomplete): move this to Device?
/// This is currently Android specific.
-Future buildAll(
+Future<int> buildAll(
DeviceStore devices,
ApplicationPackageStore applicationPackages,
Toolchain toolchain,
@@ -434,31 +434,44 @@
continue;
// TODO(mpcomplete): Temporary hack. We only support the apk builder atm.
- if (package == applicationPackages.android) {
- // TODO(devoncarew): Remove this warning after a few releases.
- if (FileSystemEntity.isDirectorySync('apk') && !FileSystemEntity.isDirectorySync('android')) {
- // Tell people the android directory location changed.
- printStatus(
- "Warning: Flutter now looks for Android resources in the android/ directory; "
- "consider renaming your 'apk/' directory to 'android/'.");
- }
+ if (package != applicationPackages.android)
+ continue;
- if (!FileSystemEntity.isFileSync(_kDefaultAndroidManifestPath)) {
- printStatus('Using pre-built SkyShell.apk.');
- continue;
- }
-
- int result = await buildAndroid(
- toolchain: toolchain,
- configs: configs,
- enginePath: enginePath,
- force: false,
- target: target
- );
- if (result != 0)
- return result;
+ // TODO(devoncarew): Remove this warning after a few releases.
+ if (FileSystemEntity.isDirectorySync('apk') && !FileSystemEntity.isDirectorySync('android')) {
+ // Tell people the android directory location changed.
+ printStatus(
+ "Warning: Flutter now looks for Android resources in the android/ directory; "
+ "consider renaming your 'apk/' directory to 'android/'.");
}
+
+ int result = await build(toolchain, configs, enginePath: enginePath,
+ target: target);
+ if (result != 0)
+ return result;
}
return 0;
}
+
+Future<int> build(
+ Toolchain toolchain,
+ List<BuildConfiguration> configs, {
+ String enginePath,
+ String target: ''
+}) async {
+ if (!FileSystemEntity.isFileSync(_kDefaultAndroidManifestPath)) {
+ printStatus('Using pre-built SkyShell.apk.');
+ return 0;
+ }
+
+ int result = await buildAndroid(
+ toolchain: toolchain,
+ configs: configs,
+ enginePath: enginePath,
+ force: false,
+ target: target
+ );
+
+ return result;
+}
diff --git a/packages/flutter_tools/lib/src/commands/drive.dart b/packages/flutter_tools/lib/src/commands/drive.dart
index b099bc5..7a951f4 100644
--- a/packages/flutter_tools/lib/src/commands/drive.dart
+++ b/packages/flutter_tools/lib/src/commands/drive.dart
@@ -7,15 +7,18 @@
import 'package:path/path.dart' as path;
import 'package:test/src/executable.dart' as executable;
+import '../base/common.dart';
import '../base/file_system.dart';
+import '../base/os.dart';
+import '../device.dart';
import '../globals.dart';
+import '../ios/simulators.dart' show SimControl, IOSSimulatorUtils;
+import '../android/android_device.dart' show AndroidDevice;
+import '../application_package.dart';
+import 'apk.dart' as apk;
import 'run.dart';
import 'stop.dart';
-typedef Future<int> RunAppFunction();
-typedef Future<Null> RunTestsFunction(List<String> testArgs);
-typedef Future<int> StopAppFunction();
-
/// Runs integration (a.k.a. end-to-end) tests.
///
/// An integration test is a program that runs in a separate process from your
@@ -36,31 +39,8 @@
/// the application is stopped and the command exits. If all these steps are
/// successful the exit code will be `0`. Otherwise, you will see a non-zero
/// exit code.
-class DriveCommand extends RunCommand {
- final String name = 'drive';
- final String description = 'Runs Flutter Driver tests for the current project.';
- final List<String> aliases = <String>['driver'];
-
- RunAppFunction _runApp;
- RunTestsFunction _runTests;
- StopAppFunction _stopApp;
-
- /// Creates a drive command with custom process management functions.
- ///
- /// [runAppFn] starts a Flutter application.
- ///
- /// [runTestsFn] runs tests.
- ///
- /// [stopAppFn] stops the test app after tests are finished.
- DriveCommand.custom({
- RunAppFunction runAppFn,
- RunTestsFunction runTestsFn,
- StopAppFunction stopAppFn
- }) {
- _runApp = runAppFn ?? super.runInProject;
- _runTests = runTestsFn ?? executable.main;
- _stopApp = stopAppFn ?? this.stop;
-
+class DriveCommand extends RunCommandBase {
+ DriveCommand() {
argParser.addFlag(
'keep-app-running',
negatable: true,
@@ -79,19 +59,35 @@
'already running instance. This will also cause the driver to keep '
'the application running after tests are done.'
);
+
+ argParser.addOption('debug-port',
+ defaultsTo: observatoryDefaultPort.toString(),
+ help: 'Listen to the given port for a debug connection.');
}
- DriveCommand() : this.custom();
+ final String name = 'drive';
+ final String description = 'Runs Flutter Driver tests for the current project.';
+ final List<String> aliases = <String>['driver'];
- bool get requiresDevice => true;
+ Device _device;
+ Device get device => _device;
+
+ int get debugPort => int.parse(argResults['debug-port']);
@override
Future<int> runInProject() async {
+ await toolchainDownloader(this);
+
String testFile = _getTestFile();
if (testFile == null) {
return 1;
}
+ this._device = await targetDeviceFinder();
+ if (device == null) {
+ return 1;
+ }
+
if (await fs.type(testFile) != FileSystemEntityType.FILE) {
printError('Test file not found: $testFile');
return 1;
@@ -99,17 +95,17 @@
if (!argResults['use-existing-app']) {
printStatus('Starting application: ${argResults["target"]}');
- int result = await _runApp();
+ int result = await appStarter(this);
if (result != 0) {
printError('Application failed to start. Will not run test. Quitting.');
return result;
}
} else {
- printStatus('Will connect to already running application instance');
+ printStatus('Will connect to already running application instance.');
}
try {
- return await _runTests([testFile])
+ return await testRunner([testFile])
.then((_) => 0)
.catchError((error, stackTrace) {
printError('CAUGHT EXCEPTION: $error\n$stackTrace');
@@ -117,10 +113,15 @@
});
} finally {
if (!argResults['keep-app-running'] && !argResults['use-existing-app']) {
- printStatus('Stopping application instance');
- await _stopApp();
+ printStatus('Stopping application instance.');
+ try {
+ await appStopper(this);
+ } catch(error, stackTrace) {
+ // TODO(yjbanov): remove this guard when this bug is fixed: https://github.com/dart-lang/sdk/issues/25862
+ printStatus('Could not stop application: $error\n$stackTrace');
+ }
} else {
- printStatus('Leaving the application running');
+ printStatus('Leaving the application running.');
}
}
}
@@ -130,7 +131,7 @@
}
String _getTestFile() {
- String appFile = path.normalize(argResults['target']);
+ String appFile = path.normalize(target);
// This command extends `flutter start` and therefore CWD == package dir
String packageDir = getCurrentDirectory();
@@ -166,3 +167,159 @@
return '${pathWithNoExtension}_test${path.extension(appFile)}';
}
}
+
+/// Finds a device to test on. May launch a simulator, if necessary.
+typedef Future<Device> TargetDeviceFinder();
+TargetDeviceFinder targetDeviceFinder = findTargetDevice;
+void restoreTargetDeviceFinder() {
+ targetDeviceFinder = findTargetDevice;
+}
+
+Future<Device> findTargetDevice() async {
+ if (deviceManager.hasSpecifiedDeviceId) {
+ return deviceManager.getDeviceById(deviceManager.specifiedDeviceId);
+ }
+
+ List<Device> devices = await deviceManager.getAllConnectedDevices();
+
+ if (os.isMacOS) {
+ // On Mac we look for the iOS Simulator. If available, we use that. Then
+ // we look for an Android device. If there's one, we use that. Otherwise,
+ // we launch a new iOS Simulator.
+ Device reusableDevice = devices.firstWhere(
+ (d) => d.isLocalEmulator,
+ orElse: () {
+ return devices.firstWhere((d) => d is AndroidDevice,
+ orElse: () => null);
+ }
+ );
+
+ if (reusableDevice != null) {
+ printStatus('Found connected ${reusableDevice.isLocalEmulator ? "emulator" : "device"} "${reusableDevice.name}"; will reuse it.');
+ return reusableDevice;
+ }
+
+ // No running emulator found. Attempt to start one.
+ printStatus('Starting iOS Simulator, because did not find existing connected devices.');
+ bool started = await SimControl.instance.boot();
+ if (started) {
+ return IOSSimulatorUtils.instance.getAttachedDevices().first;
+ } else {
+ printError('Failed to start iOS Simulator.');
+ return null;
+ }
+ } else if (os.isLinux) {
+ // On Linux, for now, we just grab the first connected device we can find.
+ if (devices.isEmpty) {
+ printError('No devices found.');
+ return null;
+ } else if (devices.length > 1) {
+ printStatus('Found multiple connected devices:');
+ printStatus(devices.map((d) => ' - ${d.name}\n').join(''));
+ }
+ printStatus('Using device ${devices.first.name}.');
+ return devices.first;
+ } else if (os.isWindows) {
+ printError('Windows is not yet supported.');
+ return null;
+ } else {
+ printError('The operating system on this computer is not supported.');
+ return null;
+ }
+}
+
+/// Starts the application on the device given command configuration.
+typedef Future<int> AppStarter(DriveCommand command);
+AppStarter appStarter = startApp;
+void restoreAppStarter() {
+ appStarter = startApp;
+}
+
+Future<int> startApp(DriveCommand command) async {
+ String mainPath = findMainDartFile(command.target);
+ if (await fs.type(mainPath) != FileSystemEntityType.FILE) {
+ printError('Tried to run $mainPath, but that file does not exist.');
+ return 1;
+ }
+
+ if (command.device is AndroidDevice) {
+ printTrace('Building an APK.');
+ int result = await apk.build(command.toolchain, command.buildConfigurations,
+ enginePath: command.runner.enginePath, target: command.target);
+
+ if (result != 0)
+ return result;
+ }
+
+ printTrace('Stopping previously running application, if any.');
+ await appStopper(command);
+
+ printTrace('Installing application package.');
+ ApplicationPackage package = command.applicationPackages
+ .getPackageForPlatform(command.device.platform);
+ await command.device.installApp(package);
+
+ printTrace('Starting application.');
+ bool started = await command.device.startApp(
+ package,
+ command.toolchain,
+ mainPath: mainPath,
+ route: command.route,
+ checked: command.checked,
+ clearLogs: true,
+ startPaused: true,
+ debugPort: command.debugPort,
+ platformArgs: <String, dynamic>{
+ 'trace-startup': command.traceStartup,
+ }
+ );
+
+ if (command.device.supportsStartPaused) {
+ await delayUntilObservatoryAvailable('localhost', command.debugPort);
+ }
+
+ return started ? 0 : 2;
+}
+
+/// Runs driver tests.
+typedef Future<Null> TestRunner(List<String> testArgs);
+TestRunner testRunner = runTests;
+void restoreTestRunner() {
+ testRunner = runTests;
+}
+
+Future<Null> runTests(List<String> testArgs) {
+ printTrace('Running driver tests.');
+ return executable.main(testArgs);
+}
+
+
+/// Stops the application.
+typedef Future<int> AppStopper(DriveCommand command);
+AppStopper appStopper = stopApp;
+void restoreAppStopper() {
+ appStopper = stopApp;
+}
+
+Future<int> stopApp(DriveCommand command) async {
+ printTrace('Stopping application.');
+ ApplicationPackage package = command.applicationPackages
+ .getPackageForPlatform(command.device.platform);
+ bool stopped = await command.device.stopApp(package);
+ return stopped ? 0 : 1;
+}
+
+/// Downloads Flutter toolchain.
+typedef Future<Null> ToolchainDownloader(DriveCommand command);
+ToolchainDownloader toolchainDownloader = downloadToolchain;
+void restoreToolchainDownloader() {
+ toolchainDownloader = downloadToolchain;
+}
+
+Future<Null> downloadToolchain(DriveCommand command) async {
+ printTrace('Downloading toolchain.');
+ await Future.wait([
+ command.downloadToolchain(),
+ command.downloadApplicationPackagesAndConnectToDevices(),
+ ], eagerError: true);
+}
diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart
index d6d466c..6d863fa 100644
--- a/packages/flutter_tools/lib/src/commands/run.dart
+++ b/packages/flutter_tools/lib/src/commands/run.dart
@@ -51,6 +51,11 @@
argParser.addOption('route',
help: 'Which route to load when starting the app.');
}
+
+ bool get checked => argResults['checked'];
+ bool get traceStartup => argResults['trace-startup'];
+ String get target => argResults['target'];
+ String get route => argResults['route'];
}
class RunCommand extends RunCommandBase {
@@ -219,7 +224,7 @@
// wait for the observatory port to become available before returning from
// `startApp()`.
if (startPaused && device.supportsStartPaused) {
- await _delayUntilObservatoryAvailable('localhost', debugPort);
+ await delayUntilObservatoryAvailable('localhost', debugPort);
}
}
}
@@ -242,7 +247,7 @@
///
/// This does not fail if we're unable to connect, and times out after the given
/// [timeout].
-Future _delayUntilObservatoryAvailable(String host, int port, {
+Future delayUntilObservatoryAvailable(String host, int port, {
Duration timeout: const Duration(seconds: 10)
}) async {
Stopwatch stopwatch = new Stopwatch()..start();
diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart
index 8ad0635..520ddd2 100644
--- a/packages/flutter_tools/lib/src/device.dart
+++ b/packages/flutter_tools/lib/src/device.dart
@@ -130,6 +130,9 @@
bool get supportsStartPaused => true;
+ /// Whether it is an emulated device running on localhost.
+ bool get isLocalEmulator;
+
/// Install an app package on the current device
bool installApp(ApplicationPackage app);
@@ -259,7 +262,7 @@
break;
case TargetPlatform.iOSSimulator:
assert(iOSSimulator == null);
- iOSSimulator = _deviceForConfig(config, IOSSimulator.getAttachedDevices());
+ iOSSimulator = _deviceForConfig(config, IOSSimulatorUtils.instance.getAttachedDevices());
break;
case TargetPlatform.mac:
case TargetPlatform.linux:
diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart
index cf1a55a..e03169b 100644
--- a/packages/flutter_tools/lib/src/ios/devices.dart
+++ b/packages/flutter_tools/lib/src/ios/devices.dart
@@ -62,6 +62,8 @@
final String name;
+ bool get isLocalEmulator => false;
+
bool get supportsStartPaused => false;
static List<IOSDevice> getAttachedDevices([IOSDevice mockIOS]) {
diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart
index b157a25..1209942 100644
--- a/packages/flutter_tools/lib/src/ios/simulators.dart
+++ b/packages/flutter_tools/lib/src/ios/simulators.dart
@@ -10,6 +10,7 @@
import '../application_package.dart';
import '../base/common.dart';
+import '../base/context.dart';
import '../base/process.dart';
import '../build_configuration.dart';
import '../device.dart';
@@ -26,12 +27,29 @@
IOSSimulators() : super('IOSSimulators');
bool get supportsPlatform => Platform.isMacOS;
- List<Device> pollingGetDevices() => IOSSimulator.getAttachedDevices();
+ List<Device> pollingGetDevices() => IOSSimulatorUtils.instance.getAttachedDevices();
+}
+
+class IOSSimulatorUtils {
+ /// Returns [IOSSimulatorUtils] active in the current app context (i.e. zone).
+ static IOSSimulatorUtils get instance => context[IOSSimulatorUtils] ?? (context[IOSSimulatorUtils] = new IOSSimulatorUtils());
+
+ List<IOSSimulator> getAttachedDevices() {
+ if (!xcode.isInstalledAndMeetsVersionCheck)
+ return <IOSSimulator>[];
+
+ return SimControl.instance.getConnectedDevices().map((SimDevice device) {
+ return new IOSSimulator(device.udid, name: device.name);
+ }).toList();
+ }
}
/// A wrapper around the `simctl` command line tool.
class SimControl {
- static Future<bool> boot({String deviceId}) async {
+ /// Returns [SimControl] active in the current app context (i.e. zone).
+ static SimControl get instance => context[SimControl] ?? (context[SimControl] = new SimControl());
+
+ Future<bool> boot({String deviceId}) async {
if (_isAnyConnected())
return true;
@@ -65,7 +83,7 @@
}
/// Returns a list of all available devices, both potential and connected.
- static List<SimDevice> getDevices() {
+ List<SimDevice> getDevices() {
// {
// "devices" : {
// "com.apple.CoreSimulator.SimRuntime.iOS-8-2" : [
@@ -102,18 +120,18 @@
}
/// Returns all the connected simulator devices.
- static List<SimDevice> getConnectedDevices() {
+ List<SimDevice> getConnectedDevices() {
return getDevices().where((SimDevice device) => device.isBooted).toList();
}
- static StreamController<List<SimDevice>> _trackDevicesControler;
+ StreamController<List<SimDevice>> _trackDevicesControler;
/// Listens to changes in the set of connected devices. The implementation
/// currently uses polling. Callers should be careful to call cancel() on any
/// stream subscription when finished.
///
/// TODO(devoncarew): We could investigate using the usbmuxd protocol directly.
- static Stream<List<SimDevice>> trackDevices() {
+ Stream<List<SimDevice>> trackDevices() {
if (_trackDevicesControler == null) {
Timer timer;
Set<String> deviceIds = new Set<String>();
@@ -138,7 +156,7 @@
}
/// Update the cached set of device IDs and return whether there were any changes.
- static bool _updateDeviceIds(List<SimDevice> devices, Set<String> deviceIds) {
+ bool _updateDeviceIds(List<SimDevice> devices, Set<String> deviceIds) {
Set<String> newIds = new Set<String>.from(devices.map((SimDevice device) => device.udid));
bool changed = false;
@@ -159,13 +177,13 @@
return changed;
}
- static bool _isAnyConnected() => getConnectedDevices().isNotEmpty;
+ bool _isAnyConnected() => getConnectedDevices().isNotEmpty;
- static void install(String deviceId, String appPath) {
+ void install(String deviceId, String appPath) {
runCheckedSync([_xcrunPath, 'simctl', 'install', deviceId, appPath]);
}
- static void launch(String deviceId, String appIdentifier, [List<String> launchArgs]) {
+ void launch(String deviceId, String appIdentifier, [List<String> launchArgs]) {
List<String> args = [_xcrunPath, 'simctl', 'launch', deviceId, appIdentifier];
if (launchArgs != null)
args.addAll(launchArgs);
@@ -190,17 +208,10 @@
class IOSSimulator extends Device {
IOSSimulator(String id, { this.name }) : super(id);
- static List<IOSSimulator> getAttachedDevices() {
- if (!xcode.isInstalledAndMeetsVersionCheck)
- return <IOSSimulator>[];
-
- return SimControl.getConnectedDevices().map((SimDevice device) {
- return new IOSSimulator(device.udid, name: device.name);
- }).toList();
- }
-
final String name;
+ bool get isLocalEmulator => true;
+
String get xcrunPath => path.join('/usr', 'bin', 'xcrun');
String _getSimulatorPath() {
@@ -220,7 +231,7 @@
return false;
try {
- SimControl.install(id, app.localPath);
+ SimControl.instance.install(id, app.localPath);
return true;
} catch (e) {
return false;
@@ -231,7 +242,7 @@
bool isConnected() {
if (!Platform.isMacOS)
return false;
- return SimControl.getConnectedDevices().any((SimDevice device) => device.udid == id);
+ return SimControl.instance.getConnectedDevices().any((SimDevice device) => device.udid == id);
}
@override
@@ -333,7 +344,7 @@
}
// Step 3: Install the updated bundle to the simulator.
- SimControl.install(id, path.absolute(bundle.path));
+ SimControl.instance.install(id, path.absolute(bundle.path));
// Step 4: Prepare launch arguments.
List<String> args = <String>[];
@@ -349,7 +360,7 @@
// Step 5: Launch the updated application in the simulator.
try {
- SimControl.launch(id, app.id, args);
+ SimControl.instance.launch(id, app.id, args);
} catch (error) {
printError('$error');
return false;