| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:io' as io; |
| import 'package:args/args.dart'; |
| import 'package:path/path.dart' as pathlib; |
| |
| import 'chrome_installer.dart'; |
| import 'driver_manager.dart'; |
| import 'environment.dart'; |
| import 'exceptions.dart'; |
| import 'common.dart'; |
| import 'utils.dart'; |
| |
| const String _unsupportedConfigurationWarning = 'WARNING: integration tests ' |
| 'are only supported on Chrome, Firefox and on Safari (running on macOS)'; |
| |
| class IntegrationTestsManager { |
| final String _browser; |
| |
| final bool _useSystemFlutter; |
| |
| final DriverManager _driverManager; |
| |
| final bool _doUpdateScreenshotGoldens; |
| |
| IntegrationTestsManager( |
| this._browser, this._useSystemFlutter, this._doUpdateScreenshotGoldens) |
| : _driverManager = DriverManager.chooseDriver(_browser); |
| |
| Future<bool> runTests() async { |
| if (validateIfTestsShouldRun()) { |
| await _driverManager.prepareDriver(); |
| return await _runTests(); |
| } else { |
| return false; |
| } |
| } |
| |
| Future<void> _runPubGet(String workingDirectory) async { |
| if (!_useSystemFlutter) { |
| await _cloneFlutterRepo(); |
| await _enableWeb(workingDirectory); |
| } |
| await runFlutter(workingDirectory, <String>['pub', 'get'], |
| useSystemFlutter: _useSystemFlutter); |
| } |
| |
| /// Clone flutter repository, use the youngest commit older than the engine |
| /// commit. |
| /// |
| /// Use engine/src/flutter/.dart_tools to clone the Flutter repo. |
| /// TODO(nurhan): Use git pull instead if repo exists. |
| Future<void> _cloneFlutterRepo() async { |
| // Delete directory if exists. |
| if (environment.engineDartToolDir.existsSync()) { |
| environment.engineDartToolDir.deleteSync(recursive: true); |
| } |
| environment.engineDartToolDir.createSync(); |
| |
| final int exitCode = await runProcess( |
| environment.cloneFlutterScript.path, |
| <String>[ |
| environment.engineDartToolDir.path, |
| ], |
| workingDirectory: environment.webUiRootDir.path, |
| ); |
| |
| if (exitCode != 0) { |
| throw ToolException('ERROR: Failed to clone flutter repo. Exited with ' |
| 'exit code $exitCode'); |
| } |
| } |
| |
| Future<void> _enableWeb(String workingDirectory) async { |
| await runFlutter(workingDirectory, <String>['config', '--enable-web'], |
| useSystemFlutter: _useSystemFlutter); |
| } |
| |
| /// Runs all the web tests under e2e_tests/web. |
| Future<bool> _runTests() async { |
| // Only list the files under e2e_tests/web. |
| final List<io.FileSystemEntity> entities = |
| environment.integrationTestsDir.listSync(followLinks: false); |
| |
| bool allTestsPassed = true; |
| for (io.FileSystemEntity e in entities) { |
| // The tests should be under this directories. |
| if (e is io.Directory) { |
| allTestsPassed = allTestsPassed && await _validateAndRunTests(e); |
| } |
| } |
| return allTestsPassed; |
| } |
| |
| /// Run tests in a single directory under: e2e_tests/web. |
| /// |
| /// Run `flutter pub get` as the first step. |
| /// |
| /// Validate the directory before running the tests. Each directory is |
| /// expected to be a test project which includes a `pubspec.yaml` file |
| /// and a `test_driver` directory. |
| Future<bool> _validateAndRunTests(io.Directory directory) async { |
| _validateTestDirectory(directory); |
| await _runPubGet(directory.path); |
| final bool testResults = await _runTestsInDirectory(directory); |
| return testResults; |
| } |
| |
| int _numberOfPassedTests = 0; |
| int _numberOfFailedTests = 0; |
| |
| Future<bool> _runTestsInDirectory(io.Directory directory) async { |
| final io.Directory testDirectory = |
| io.Directory(pathlib.join(directory.path, 'test_driver')); |
| final List<io.File> entities = testDirectory |
| .listSync(followLinks: false) |
| .whereType<io.File>() |
| .toList(); |
| |
| final List<String> e2eTestsToRun = <String>[]; |
| final List<String> blockedTests = |
| blockedTestsListsMap[getBlockedTestsListMapKey(_browser)] ?? <String>[]; |
| |
| // If no target is specified run all the tests. |
| if (_runAllTestTargets) { |
| // The following loops over the contents of the directory and saves an |
| // expected driver file name for each e2e test assuming any dart file |
| // not ending with `_test.dart` is an e2e test. |
| // Other files are not considered since developers can add files such as |
| // README. |
| for (io.File f in entities) { |
| final String basename = pathlib.basename(f.path); |
| if (!basename.contains('_test.dart') && basename.endsWith('.dart')) { |
| // Do not add the basename if it is in the `blockedTests`. |
| if (!blockedTests.contains(basename)) { |
| e2eTestsToRun.add(basename); |
| } else { |
| print('INFO: Test $basename is skipped since it is blocked for ' |
| '${getBlockedTestsListMapKey(_browser)}'); |
| } |
| } |
| } |
| if (isVerboseLoggingEnabled) { |
| print( |
| 'INFO: In project ${directory} ${e2eTestsToRun.length} tests to run.'); |
| } |
| } else { |
| // If a target is specified it will run regardless of if it's blocked or |
| // not. There will be an info note to warn the developer. |
| final String targetTest = |
| IntegrationTestsArgumentParser.instance.testTarget; |
| final io.File file = |
| entities.singleWhere((f) => pathlib.basename(f.path) == targetTest); |
| final String basename = pathlib.basename(file.path); |
| if (blockedTests.contains(basename) && isVerboseLoggingEnabled) { |
| print('INFO: Test $basename do not run on CI environments. Please ' |
| 'remove it from the blocked tests list if you want to enable this ' |
| 'test on CI.'); |
| } |
| e2eTestsToRun.add(basename); |
| } |
| |
| final Set<String> buildModes = _getBuildModes(); |
| |
| for (String fileName in e2eTestsToRun) { |
| await _runTestsTarget(directory, fileName, buildModes); |
| } |
| |
| final int numberOfTestsRun = _numberOfPassedTests + _numberOfFailedTests; |
| |
| print('INFO: ${numberOfTestsRun} tests run. ${_numberOfPassedTests} passed ' |
| 'and ${_numberOfFailedTests} failed.'); |
| return _numberOfFailedTests == 0; |
| } |
| |
| Future<void> _runTestsTarget( |
| io.Directory directory, String target, Set<String> buildModes) async { |
| final Set<String> renderingBackends = _getRenderingBackends(); |
| for (String renderingBackend in renderingBackends) { |
| for (String mode in buildModes) { |
| if (!blockedTestsListsMapForModes[mode]!.contains(target) && |
| !blockedTestsListsMapForRenderBackends[renderingBackend]! |
| .contains(target)) { |
| final bool result = await _runTestsInMode(directory, target, |
| mode: mode, webRenderer: renderingBackend); |
| if (result) { |
| _numberOfPassedTests++; |
| } else { |
| _numberOfFailedTests++; |
| } |
| } |
| } |
| } |
| } |
| |
| Future<bool> _runTestsInMode(io.Directory directory, String testName, |
| {String mode = 'profile', String webRenderer = 'html'}) async { |
| String executable = |
| _useSystemFlutter ? 'flutter' : environment.flutterCommand.path; |
| Map<String, String> flutterEnvironment = Map<String, String>(); |
| if (_doUpdateScreenshotGoldens) { |
| flutterEnvironment['UPDATE_GOLDENS'] = 'true'; |
| } |
| final IntegrationArguments arguments = |
| IntegrationArguments.fromBrowser(_browser); |
| final int exitCode = await runProcess( |
| executable, |
| arguments.getTestArguments(testName, mode, webRenderer), |
| workingDirectory: directory.path, |
| environment: flutterEnvironment, |
| ); |
| |
| if (exitCode != 0) { |
| final String command = |
| arguments.getCommandToRun(testName, mode, webRenderer); |
| io.stderr |
| .writeln('ERROR: Failed to run test. Exited with exit code $exitCode' |
| '. To run $testName locally use the following command:' |
| '\n\n$command'); |
| return false; |
| } else { |
| return true; |
| } |
| } |
| |
| Set<String> _getRenderingBackends() { |
| Set<String> renderingBackends; |
| if (_renderingBackendSelected) { |
| final String mode = IntegrationTestsArgumentParser.instance.webRenderer; |
| renderingBackends = <String>{mode}; |
| } else { |
| // TODO(nurhan): Enable `auto` when recipe is sharded. |
| renderingBackends = {'html', 'canvaskit'}; |
| } |
| return renderingBackends; |
| } |
| |
| Set<String> _getBuildModes() { |
| Set<String> buildModes; |
| if (_buildModeSelected) { |
| final String mode = IntegrationTestsArgumentParser.instance.buildMode; |
| if (mode == 'debug' && _browser != 'chrome') { |
| throw ToolException('Debug mode is only supported for Chrome.'); |
| } else { |
| buildModes = <String>{mode}; |
| } |
| } else { |
| buildModes = _browser == 'chrome' |
| ? {'debug', 'profile', 'release'} |
| : {'profile', 'release'}; |
| } |
| return buildModes; |
| } |
| |
| /// Validate the directory has a `pubspec.yaml` file and a `test_driver` |
| /// directory. |
| /// |
| /// Also check the validity of files under `test_driver` directory calling |
| /// [_checkE2ETestsValidity] method. |
| void _validateTestDirectory(io.Directory directory) { |
| final List<io.FileSystemEntity> entities = |
| directory.listSync(followLinks: false); |
| |
| // Whether the project has the pubspec.yaml file. |
| bool pubSpecFound = false; |
| // The test directory 'test_driver'. |
| io.Directory? testDirectory; |
| |
| for (io.FileSystemEntity e in entities) { |
| // The tests should be under this directories. |
| final String baseName = pathlib.basename(e.path); |
| if (e is io.Directory && baseName == 'test_driver') { |
| testDirectory = e; |
| } |
| if (e is io.File && baseName == 'pubspec.yaml') { |
| pubSpecFound = true; |
| } |
| } |
| if (!pubSpecFound) { |
| throw StateError('ERROR: pubspec.yaml file not found in the test project ' |
| 'in the directory ${directory.path}.'); |
| } |
| if (testDirectory == null) { |
| throw StateError( |
| 'ERROR: test_driver folder not found in the test project.' |
| 'in the directory ${directory.path}.'); |
| } else { |
| _checkE2ETestsValidity(testDirectory); |
| } |
| } |
| |
| /// Checks if each e2e test file in the directory has a driver test |
| /// file to run it. |
| /// |
| /// Prints informative message to the developer if an error has found. |
| /// For each e2e test which has name {name}.dart there will be a driver |
| /// file which drives it. The driver file should be named: |
| /// {name}_test.dart |
| void _checkE2ETestsValidity(io.Directory testDirectory) { |
| final Iterable<io.Directory> directories = |
| testDirectory.listSync(followLinks: false).whereType<io.Directory>(); |
| |
| if (directories.length > 0) { |
| throw StateError('${testDirectory.path} directory should not contain ' |
| 'any sub-directories'); |
| } |
| |
| final Iterable<io.File> entities = |
| testDirectory.listSync(followLinks: false).whereType<io.File>(); |
| |
| final Set<String> expectedDriverFileNames = Set<String>(); |
| final Set<String> foundDriverFileNames = Set<String>(); |
| int numberOfTests = 0; |
| |
| // The following loops over the contents of the directory and saves an |
| // expected driver file name for each e2e test assuming any file |
| // not ending with `_test.dart` is an e2e test. |
| for (io.File f in entities) { |
| final String basename = pathlib.basename(f.path); |
| if (basename.contains('_test.dart')) { |
| // First remove this from expectedSet if not there add to the foundSet. |
| if (!expectedDriverFileNames.remove(basename)) { |
| foundDriverFileNames.add(basename); |
| } |
| } else if (basename.contains('.dart')) { |
| // Only run on dart files. |
| final String e2efileName = pathlib.basenameWithoutExtension(f.path); |
| final String expectedDriverName = '${e2efileName}_test.dart'; |
| numberOfTests++; |
| // First remove this from foundSet if not there add to the expectedSet. |
| if (!foundDriverFileNames.remove(expectedDriverName)) { |
| expectedDriverFileNames.add(expectedDriverName); |
| } |
| } |
| } |
| |
| if (numberOfTests == 0) { |
| throw StateError( |
| 'WARNING: No tests to run in this directory ${testDirectory.path}'); |
| } |
| |
| // TODO(nurhan): In order to reduce the work required from team members, |
| // remove the need for driver file, by using the same template file. |
| // Some driver files are missing. |
| if (expectedDriverFileNames.length > 0) { |
| for (String expectedDriverName in expectedDriverFileNames) { |
| print('ERROR: Test driver file named has ${expectedDriverName} ' |
| 'not found under directory ${testDirectory.path}. Stopping the ' |
| 'integration tests. Please add ${expectedDriverName}. Check to ' |
| 'README file on more details on how to set up integration tests.'); |
| } |
| throw StateError('Error in test files. Check the logs for ' |
| 'further instructions'); |
| } |
| } |
| |
| bool get _buildModeSelected => |
| !IntegrationTestsArgumentParser.instance.buildMode.isEmpty; |
| |
| bool get _renderingBackendSelected => |
| !IntegrationTestsArgumentParser.instance.webRenderer.isEmpty; |
| |
| bool get _runAllTestTargets => |
| IntegrationTestsArgumentParser.instance.testTarget.isEmpty; |
| |
| /// Validate the given `browser`, `platform` combination is suitable for |
| /// integration tests to run. |
| bool validateIfTestsShouldRun() { |
| if (_buildModeSelected) { |
| final String mode = IntegrationTestsArgumentParser.instance.buildMode; |
| if (mode == 'debug' && _browser != 'chrome') { |
| throw ToolException('Debug mode is only supported for Chrome.'); |
| } |
| } |
| |
| // Chrome tests should run at all Platforms (Linux, macOS, Windows). |
| // They can also run successfully on CI and local. |
| if (_browser == 'chrome') { |
| return true; |
| } else if (_browser == 'firefox' && |
| (io.Platform.isLinux || io.Platform.isMacOS)) { |
| return true; |
| } else if (_browser == 'safari' && io.Platform.isMacOS && !isLuci) { |
| return true; |
| } else { |
| io.stderr.writeln(_unsupportedConfigurationWarning); |
| return false; |
| } |
| } |
| } |
| |
| /// Interface for collecting arguments to give `flutter drive` to run the |
| /// integration tests. |
| abstract class IntegrationArguments { |
| IntegrationArguments(); |
| |
| factory IntegrationArguments.fromBrowser(String browser) { |
| if (browser == 'chrome') { |
| return ChromeIntegrationArguments(); |
| } else if (browser == 'firefox') { |
| return FirefoxIntegrationArguments(); |
| } else if (browser == 'safari' && io.Platform.isMacOS) { |
| return SafariIntegrationArguments(); |
| } else { |
| throw StateError(_unsupportedConfigurationWarning); |
| } |
| } |
| |
| List<String> getTestArguments( |
| String testName, String mode, String webRenderer); |
| |
| String getCommandToRun(String testName, String mode, String webRenderer); |
| } |
| |
| /// Arguments to give `flutter drive` to run the integration tests on Chrome. |
| class ChromeIntegrationArguments extends IntegrationArguments { |
| List<String> getTestArguments( |
| String testName, String mode, String webRenderer) { |
| return <String>[ |
| 'drive', |
| '--target=test_driver/${testName}', |
| '-d', |
| 'web-server', |
| '--$mode', |
| '--browser-name=chrome', |
| if (isLuci) '--chrome-binary=${preinstalledChromeExecutable()}', |
| '--headless', |
| '--local-engine=host_debug_unopt', |
| '--web-renderer=$webRenderer', |
| ]; |
| } |
| |
| String getCommandToRun(String testName, String mode, String webRenderer) { |
| String statementToRun = 'flutter drive ' |
| '--target=test_driver/${testName} -d web-server --$mode ' |
| '--browser-name=chrome --local-engine=host_debug_unopt ' |
| '--web-renderer=$webRenderer'; |
| if (isLuci) { |
| statementToRun = '$statementToRun --chrome-binary=' |
| '${preinstalledChromeExecutable()}'; |
| } |
| return statementToRun; |
| } |
| } |
| |
| /// Arguments to give `flutter drive` to run the integration tests on Firefox. |
| class FirefoxIntegrationArguments extends IntegrationArguments { |
| List<String> getTestArguments( |
| String testName, String mode, String webRenderer) { |
| return <String>[ |
| 'drive', |
| '--target=test_driver/${testName}', |
| '-d', |
| 'web-server', |
| '--$mode', |
| '--browser-name=firefox', |
| '--headless', |
| '--local-engine=host_debug_unopt', |
| '--web-renderer=$webRenderer', |
| ]; |
| } |
| |
| String getCommandToRun(String testName, String mode, String webRenderer) { |
| final String arguments = |
| getTestArguments(testName, mode, webRenderer).join(' '); |
| return 'flutter $arguments'; |
| } |
| } |
| |
| /// Arguments to give `flutter drive` to run the integration tests on Safari. |
| class SafariIntegrationArguments extends IntegrationArguments { |
| SafariIntegrationArguments(); |
| |
| List<String> getTestArguments( |
| String testName, String mode, String webRenderer) { |
| return <String>[ |
| 'drive', |
| '--target=test_driver/${testName}', |
| '-d', |
| 'web-server', |
| '--$mode', |
| '--browser-name=safari', |
| '--local-engine=host_debug_unopt', |
| '--web-renderer=$webRenderer', |
| ]; |
| } |
| |
| String getCommandToRun(String testName, String mode, String webRenderer) { |
| final String arguments = |
| getTestArguments(testName, mode, webRenderer).join(' '); |
| return 'flutter $arguments'; |
| } |
| } |
| |
| /// Parses additional options that can be used when running integration tests. |
| class IntegrationTestsArgumentParser { |
| static final IntegrationTestsArgumentParser _singletonInstance = |
| IntegrationTestsArgumentParser._(); |
| |
| /// The [IntegrationTestsArgumentParser] singleton. |
| static IntegrationTestsArgumentParser get instance => _singletonInstance; |
| |
| IntegrationTestsArgumentParser._(); |
| |
| /// If target name is provided integration tests can run that one test |
| /// instead of running all the tests. |
| late final String testTarget; |
| |
| /// The build mode to run the integration tests. |
| /// |
| /// If not specified, these tests will run using 'debug, profile, release' |
| /// modes on Chrome and will run using 'profile, release' on other browsers. |
| /// |
| /// In order to skip a test for one of the modes, add the test to the |
| /// `blockedTestsListsMapForModes` list for the relevant compile mode. |
| late final String buildMode; |
| |
| /// Whether to use html, canvaskit or auto for web renderer. |
| /// |
| /// If not set all backends will be used one after another for integration |
| /// tests. If set only the provided option will be used. |
| late final String webRenderer; |
| |
| void populateOptions(ArgParser argParser) { |
| argParser |
| ..addOption( |
| 'target', |
| defaultsTo: '', |
| help: 'By default integration tests are run for all the tests under' |
| 'flutter/e2etests/web directory. If a test name is specified, that ' |
| 'only that test will run. The test name will be the name of the ' |
| 'integration test (e2e test) file. For example: ' |
| 'text_editing_integration.dart or ' |
| 'profile_diagnostics_integration.dart', |
| ) |
| ..addOption('build-mode', |
| defaultsTo: '', |
| help: 'Flutter supports three modes when building your app. This ' |
| 'option sets the build mode for the integration tests. ' |
| 'By default an integration test will sequentially run on ' |
| 'multiple modes. All three modes (debug, release, profile) are ' |
| 'used for Chrome. Only profile, release modes will be used for ' |
| 'other browsers. In other words, if a build mode is selected ' |
| 'tests will only be run using that mode. ' |
| 'See https://flutter.dev/docs/testing/build-modes for more ' |
| 'details on the build modes.') |
| ..addOption('web-renderer', |
| defaultsTo: '', |
| help: 'By default all three options (`html`, `canvaskit`, `auto`) ' |
| ' for rendering backends are tested when running integration ' |
| ' tests. If this option is set only the backend provided by this ' |
| ' option will be used. `auto`, `canvaskit` and `html`' |
| ' are the available options. '); |
| } |
| |
| /// Populate results of the arguments passed. |
| void parseOptions(ArgResults argResults) { |
| testTarget = argResults['target'] as String; |
| buildMode = argResults['build-mode'] as String; |
| if (!buildMode.isEmpty && |
| buildMode != 'debug' && |
| buildMode != 'profile' && |
| buildMode != 'release') { |
| throw ArgumentError('Unexpected build mode: $buildMode'); |
| } |
| webRenderer = argResults['web-renderer'] as String; |
| if (!webRenderer.isEmpty && |
| webRenderer != 'html' && |
| webRenderer != 'canvaskit' && |
| webRenderer != 'auto') { |
| throw ArgumentError('Unexpected rendering backend: $webRenderer'); |
| } |
| } |
| } |
| |
| /// Prepares a key for the [blackList] map. |
| /// |
| /// Uses the browser name and the operating system name. |
| String getBlockedTestsListMapKey(String browser) => |
| '${browser}-${io.Platform.operatingSystem}'; |
| |
| /// Tests that should be skipped run for a specific platform-browser |
| /// combination. |
| /// |
| /// These tests might be failing or might have been implemented for a specific |
| /// configuration. |
| /// |
| /// For example when adding a tests only intended for mobile browsers, it should |
| /// be added to [blockedTests] for `chrome-linux`, `safari-macos` and |
| /// `chrome-macos`. It will work on `chrome-android`, `safari-ios`. |
| /// |
| /// Note that integration tests are only running on chrome for now. |
| const Map<String, List<String>> blockedTestsListsMap = <String, List<String>>{ |
| 'chrome-linux': [ |
| 'target_platform_android_integration.dart', |
| 'target_platform_ios_integration.dart', |
| 'target_platform_macos_integration.dart', |
| ], |
| 'chrome-macos': [ |
| 'target_platform_ios_integration.dart', |
| 'target_platform_android_integration.dart', |
| ], |
| 'safari-macos': [ |
| 'target_platform_ios_integration.dart', |
| 'target_platform_android_integration.dart', |
| 'image_loading_integration.dart', |
| ], |
| 'firefox-linux': [ |
| 'target_platform_android_integration.dart', |
| 'target_platform_ios_integration.dart', |
| 'target_platform_macos_integration.dart', |
| ], |
| 'firefox-macos': [ |
| 'target_platform_android_integration.dart', |
| 'target_platform_ios_integration.dart', |
| ], |
| }; |
| |
| /// Tests blocked for one of the build modes. |
| /// |
| /// If a test is not supposed to run for one of the modes also add that test |
| /// to the corresponding list. |
| // TODO(nurhan): Remove the failing test after fixing. |
| const Map<String, List<String>> blockedTestsListsMapForModes = |
| <String, List<String>>{ |
| 'debug': [ |
| 'treeshaking_integration.dart', |
| 'text_editing_integration.dart', |
| 'url_strategy_integration.dart', |
| ], |
| 'profile': [], |
| 'release': [], |
| }; |
| |
| /// Tests blocked for one of the rendering backends. |
| /// |
| /// If a test is not suppose to run for one of the backends also add that test |
| /// to the corresponding list. |
| // TODO(nurhan): Remove the failing test after fixing. |
| const Map<String, List<String>> blockedTestsListsMapForRenderBackends = |
| <String, List<String>>{ |
| 'auto': [ |
| 'image_loading_integration.dart', |
| 'platform_messages_integration.dart', |
| 'profile_diagnostics_integration.dart', |
| 'scroll_wheel_integration.dart', |
| 'text_editing_integration.dart', |
| 'treeshaking_integration.dart', |
| 'url_strategy_integration.dart', |
| ], |
| 'html': [], |
| // This test failed on canvaskit on all three build modes. |
| 'canvaskit': [ |
| 'image_loading_integration.dart', |
| ], |
| }; |