"flutter create" can generate a basic driver test "flutter create" adds option `--with-driver-test` that adds dependencies to `flutter_driver` in `pubspec.yaml` and creates a basic driver test runnable via `flutter drive --target=test_driver/e2e.dart` "flutter drive" new options: - `--keep-app-running` tells the driver to not stop the app after tests are done - `--use-existing-app` tells the driver to not start a new app but use an already running instance
diff --git a/packages/flutter_tools/lib/src/base/file_system.dart b/packages/flutter_tools/lib/src/base/file_system.dart index 591124e..5d077ee 100644 --- a/packages/flutter_tools/lib/src/base/file_system.dart +++ b/packages/flutter_tools/lib/src/base/file_system.dart
@@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:io' as dart_io; import 'package:file/io.dart'; import 'package:file/sync_io.dart'; import 'package:path/path.dart' as path; @@ -16,16 +17,42 @@ FileSystem fs = new LocalFileSystem(); SyncFileSystem syncFs = new SyncLocalFileSystem(); +typedef String CurrentDirectoryGetter(); + +final CurrentDirectoryGetter _defaultCurrentDirectoryGetter = () { + return dart_io.Directory.current.path; +}; + +/// Points to the current working directory (like `pwd`). +CurrentDirectoryGetter getCurrentDirectory = _defaultCurrentDirectoryGetter; + +/// Exits the process with the given [exitCode]. +typedef void ExitFunction([int exitCode]); + +final ExitFunction _defaultExitFunction = ([int exitCode]) { + dart_io.exit(exitCode); +}; + +/// Exits the process. +ExitFunction exit = _defaultExitFunction; + /// Restores [fs] and [syncFs] to the default local disk-based implementation. void restoreFileSystem() { fs = new LocalFileSystem(); syncFs = new SyncLocalFileSystem(); + getCurrentDirectory = _defaultCurrentDirectoryGetter; + exit = _defaultExitFunction; } -void useInMemoryFileSystem() { +/// Uses in-memory replacments for `dart:io` functionality. Useful in tests. +void useInMemoryFileSystem({ cwd: '/', ExitFunction exitFunction }) { MemoryFileSystem memFs = new MemoryFileSystem(); fs = memFs; syncFs = new SyncMemoryFileSystem(backedBy: memFs.storage); + getCurrentDirectory = () => cwd; + exit = exitFunction ?? ([int exitCode]) { + throw new Exception('Exited with code $exitCode'); + }; } /// Create the ancestor directories of a file path if they do not already exist.
diff --git a/packages/flutter_tools/lib/src/commands/create.dart b/packages/flutter_tools/lib/src/commands/create.dart index 6ec70ae..8277248 100644 --- a/packages/flutter_tools/lib/src/commands/create.dart +++ b/packages/flutter_tools/lib/src/commands/create.dart
@@ -30,6 +30,12 @@ defaultsTo: true, help: 'Whether to run "pub get" after the project has been created.' ); + argParser.addFlag( + 'with-driver-test', + negatable: true, + defaultsTo: false, + help: 'Also add Flutter Driver dependencies and generate a sample driver test.' + ); } String get invocation => "${runner.executableName} $name <output directory>"; @@ -50,12 +56,19 @@ String flutterRoot = path.absolute(ArtifactStore.flutterRoot); - String flutterPackagePath = path.join(flutterRoot, 'packages', 'flutter'); + String flutterPackagesDirectory = path.join(flutterRoot, 'packages'); + String flutterPackagePath = path.join(flutterPackagesDirectory, 'flutter'); if (!FileSystemEntity.isFileSync(path.join(flutterPackagePath, 'pubspec.yaml'))) { printError('Unable to find package:flutter in $flutterPackagePath'); return 2; } + String flutterDriverPackagePath = path.join(flutterRoot, 'packages', 'flutter_driver'); + if (!FileSystemEntity.isFileSync(path.join(flutterDriverPackagePath, 'pubspec.yaml'))) { + printError('Unable to find package:flutter_driver in $flutterDriverPackagePath'); + return 2; + } + Directory out; if (argResults.wasParsed('out')) { @@ -64,7 +77,15 @@ out = new Directory(argResults.rest.first); } - new FlutterSimpleTemplate().generateInto(out, flutterPackagePath); + FlutterSimpleTemplate template = new FlutterSimpleTemplate(); + + if (argResults['with-driver-test']) + template.withDriverTest(); + + template.generateInto( + dir: out, + flutterPackagesDirectory: flutterPackagesDirectory + ); printStatus(''); @@ -90,19 +111,23 @@ abstract class Template { final String name; final String description; - - Map<String, String> files = <String, String>{}; + final Map<String, String> files = <String, String>{}; + final Map<String, dynamic> additionalTemplateVariables = <String, dynamic>{}; Template(this.name, this.description); - void generateInto(Directory dir, String flutterPackagePath) { + void generateInto({ + Directory dir, + String flutterPackagesDirectory + }) { String dirPath = path.normalize(dir.absolute.path); String projectName = _normalizeProjectName(path.basename(dirPath)); String projectIdentifier = _createProjectIdentifier(path.basename(dirPath)); printStatus('Creating ${path.basename(projectName)}...'); dir.createSync(recursive: true); - String relativeFlutterPackagePath = path.relative(flutterPackagePath, from: dirPath); + String relativeFlutterPackagesDirectory = + path.relative(flutterPackagesDirectory, from: dirPath); Iterable<String> paths = files.keys.toList()..sort(); for (String filePath in paths) { @@ -111,8 +136,9 @@ 'projectName': projectName, 'projectIdentifier': projectIdentifier, 'description': description, - 'flutterPackagePath': relativeFlutterPackagePath + 'flutterPackagesDirectory': relativeFlutterPackagesDirectory, }; + m.addAll(additionalTemplateVariables); contents = mustache.render(contents, m); filePath = filePath.replaceAll('/', Platform.pathSeparator); File file = new File(path.join(dir.path, filePath)); @@ -142,6 +168,12 @@ // iOS files. files.addAll(iosTemplateFiles); } + + void withDriverTest() { + additionalTemplateVariables['withDriverTest?'] = {}; + files['test_driver/e2e.dart'] = _e2eApp; + files['test_driver/e2e_test.dart'] = _e2eTest; + } } String _normalizeProjectName(String name) { @@ -194,7 +226,12 @@ description: {{description}} dependencies: flutter: - path: {{flutterPackagePath}} + path: {{flutterPackagesDirectory}}/flutter +{{#withDriverTest?}} +dev_dependencies: + flutter_driver: + path: {{flutterPackagesDirectory}}/flutter_driver +{{/withDriverTest?}} '''; const String _flutterYaml = r''' @@ -240,10 +277,14 @@ ), body: new Material( child: new Center( - child: new Text('Button tapped $_counter times.') + child: new Text( + 'Button tapped $_counter times.', + key: const ValueKey('counter') + ) ) ), floatingActionButton: new FloatingActionButton( + key: const ValueKey('fab'), child: new Icon( icon: 'content/add' ), @@ -254,6 +295,65 @@ } '''; +const String _e2eApp = ''' +// Starts the app with Flutter Driver extension enabled to allow Flutter Driver +// to test the app. +import 'package:{{projectName}}/main.dart' as app; +import 'package:flutter_driver/driver_extension.dart'; + +main() { + enableFlutterDriverExtension(); + app.main(); +} +'''; + +const String _e2eTest = ''' +// This is a basic Flutter Driver test for the application. A Flutter Driver +// test is an end-to-end test that "drives" your application from another +// process or even from another computer. If you are familiar with +// Selenium/WebDriver for web, Espresso for Android or UI Automation for iOS, +// this is simply Flutter's version of that. +// +// To start the test run the following command from the root of your application +// package: +// +// flutter drive --target=test_driver/e2e.dart +// +import 'package:flutter_driver/flutter_driver.dart'; +import 'package:test/test.dart'; + +main() { + group('end-to-end test', () { + FlutterDriver driver; + + setUpAll(() async { + // Connect to a running Flutter application instance. + driver = await FlutterDriver.connect(); + }); + + tearDownAll(() async { + if (driver != null) driver.close(); + }); + + test('find the floating action button by value key', () async { + ObjectRef elem = await driver.findByValueKey('fab'); + expect(elem, isNotNull); + expect(elem.objectReferenceKey, isNotNull); + }); + + test('tap on the floating action button; verify counter', () async { + ObjectRef fab = await driver.findByValueKey('fab'); + expect(fab, isNotNull); + await driver.tap(fab); + ObjectRef counter = await driver.findByValueKey('counter'); + expect(counter, isNotNull); + String text = await driver.getText(counter); + expect(text, contains("Button tapped 1 times.")); + }); + }); +} +'''; + final String _apkManifest = ''' <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="{{projectIdentifier}}"
diff --git a/packages/flutter_tools/lib/src/commands/drive.dart b/packages/flutter_tools/lib/src/commands/drive.dart index 8d338e3..4e5c849 100644 --- a/packages/flutter_tools/lib/src/commands/drive.dart +++ b/packages/flutter_tools/lib/src/commands/drive.dart
@@ -25,9 +25,10 @@ /// /// This command takes a target Flutter application that you would like to test /// as the `--target` option (defaults to `lib/main.dart`). It then looks for a -/// file with the same name but containing the `_test.dart` suffix. The -/// `_test.dart` file is expected to be a program that uses -/// `package:flutter_driver` that exercises your application. Most commonly it +/// corresponding test file within the `test_driver` directory. The test file is +/// expected to have the same name but contain the `_test.dart` suffix. The +/// `_test.dart` file would generall be a Dart program that uses +/// `package:flutter_driver` and exercises your application. Most commonly it /// is a test written using `package:test`, but you are free to use something /// else. /// @@ -59,6 +60,25 @@ _runApp = runAppFn ?? super.runInProject; _runTests = runTestsFn ?? executable.main; _stopApp = stopAppFn ?? this.stop; + + argParser.addFlag( + 'keep-app-running', + negatable: true, + defaultsTo: false, + help: + 'Will keep the Flutter application running when done testing. By ' + 'default Flutter Driver stops the application after tests are finished.' + ); + + argParser.addFlag( + 'use-existing-app', + negatable: true, + defaultsTo: false, + help: + 'Will not start a new Flutter application but connect to an ' + 'already running instance. This will also cause the driver to keep ' + 'the application running after tests are done.' + ); } DriveCommand() : this.custom(); @@ -66,27 +86,40 @@ @override Future<int> runInProject() async { String testFile = _getTestFile(); + if (testFile == null) { + return 1; + } if (await fs.type(testFile) != FileSystemEntityType.FILE) { printError('Test file not found: $testFile'); return 1; } - int result = await _runApp(); - if (result != 0) { - printError('Application failed to start. Will not run test. Quitting.'); - return result; + if (!argResults['use-existing-app']) { + printStatus('Starting application: ${argResults["target"]}'); + int result = await _runApp(); + if (result != 0) { + printError('Application failed to start. Will not run test. Quitting.'); + return result; + } + } else { + printStatus('Will connect to already running application instance'); } try { return await _runTests([testFile]) .then((_) => 0) .catchError((error, stackTrace) { - printError('ERROR: $error\n$stackTrace'); + printError('CAUGHT EXCEPTION: $error\n$stackTrace'); return 1; }); } finally { - await _stopApp(); + if (!argResults['keep-app-running'] && !argResults['use-existing-app']) { + printStatus('Stopping application instance'); + await _stopApp(); + } else { + printStatus('Leaving the application running'); + } } } @@ -95,9 +128,39 @@ } String _getTestFile() { - String appFile = argResults['target']; - String extension = path.extension(appFile); - String name = path.withoutExtension(appFile); - return '${name}_test$extension'; + String appFile = path.normalize(argResults['target']); + + // This command extends `flutter start` and therefore CWD == package dir + String packageDir = getCurrentDirectory(); + + // Make appFile path relative to package directory because we are looking + // for the corresponding test file relative to it. + if (!path.isRelative(appFile)) { + if (!path.isWithin(packageDir, appFile)) { + printError( + 'Application file $appFile is outside the package directory $packageDir' + ); + return null; + } + + appFile = path.relative(appFile, from: packageDir); + } + + List<String> parts = path.split(appFile); + + if (parts.length < 2) { + printError( + 'Application file $appFile must reside in one of the sub-directories ' + 'of the package structure, not in the root directory.' + ); + return null; + } + + // Look for the test file inside `test_driver/` matching the sub-path, e.g. + // if the application is `lib/foo/bar.dart`, the test file is expected to + // be `test_driver/foo/bar_test.dart`. + String pathWithNoExtension = path.withoutExtension(path.joinAll( + [packageDir, 'test_driver']..addAll(parts.skip(1)))); + return '${pathWithNoExtension}_test${path.extension(appFile)}'; } }
diff --git a/packages/flutter_tools/test/drive_test.dart b/packages/flutter_tools/test/drive_test.dart index 0a87b72..ca29bd6 100644 --- a/packages/flutter_tools/test/drive_test.dart +++ b/packages/flutter_tools/test/drive_test.dart
@@ -20,7 +20,7 @@ defineTests() { group('drive', () { setUp(() { - useInMemoryFileSystem(); + useInMemoryFileSystem(cwd: '/some/app'); }); tearDown(() { @@ -39,7 +39,7 @@ expect(code, equals(1)); BufferLogger buffer = logger; expect(buffer.errorText, - contains('Test file not found: /some/app/test/e2e_test.dart')); + contains('Test file not found: /some/app/test_driver/e2e_test.dart')); }); }); @@ -49,8 +49,8 @@ })); applyMocksToCommand(command); - String testApp = '/some/app/test/e2e.dart'; - String testFile = '/some/app/test/e2e_test.dart'; + String testApp = '/some/app/test_driver/e2e.dart'; + String testFile = '/some/app/test_driver/e2e_test.dart'; MemoryFileSystem memFs = fs; await memFs.file(testApp).writeAsString('main() {}'); @@ -69,9 +69,50 @@ }); }); + testUsingContext('returns 1 when app file is outside package', () async { + String packageDir = '/my/app'; + useInMemoryFileSystem(cwd: packageDir); + DriveCommand command = new DriveCommand(); + applyMocksToCommand(command); + + String appFile = '/not/in/my/app.dart'; + List<String> args = [ + 'drive', + '--target=$appFile', + ]; + return createTestCommandRunner(command).run(args).then((int code) { + expect(code, equals(1)); + BufferLogger buffer = logger; + expect(buffer.errorText, contains( + 'Application file $appFile is outside the package directory $packageDir' + )); + }); + }); + + testUsingContext('returns 1 when app file is in the root dir', () async { + String packageDir = '/my/app'; + useInMemoryFileSystem(cwd: packageDir); + DriveCommand command = new DriveCommand(); + applyMocksToCommand(command); + + String appFile = '/my/app/main.dart'; + List<String> args = [ + 'drive', + '--target=$appFile', + ]; + return createTestCommandRunner(command).run(args).then((int code) { + expect(code, equals(1)); + BufferLogger buffer = logger; + expect(buffer.errorText, contains( + 'Application file main.dart must reside in one of the ' + 'sub-directories of the package structure, not in the root directory.' + )); + }); + }); + testUsingContext('returns 0 when test ends successfully', () async { String testApp = '/some/app/test/e2e.dart'; - String testFile = '/some/app/test/e2e_test.dart'; + String testFile = '/some/app/test_driver/e2e_test.dart'; DriveCommand command = new DriveCommand.custom( runAppFn: expectAsync(() {