// 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. // @dart = 2.6 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 runTests() async { if (validateIfTestsShouldRun()) { await _driverManager.prepareDriver(); return await _runTests(); } else { return false; } } Future _runPubGet(String workingDirectory) async { if (!_useSystemFlutter) { await _cloneFlutterRepo(); await _enableWeb(workingDirectory); } await runFlutter(workingDirectory, ['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 _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, [ environment.engineDartToolDir.path, ], workingDirectory: environment.webUiRootDir.path, ); if (exitCode != 0) { throw ToolException('ERROR: Failed to clone flutter repo. Exited with ' 'exit code $exitCode'); } } Future _enableWeb(String workingDirectory) async { await runFlutter(workingDirectory, ['config', '--enable-web'], useSystemFlutter: _useSystemFlutter); } /// Runs all the web tests under e2e_tests/web. Future _runTests() async { // Only list the files under e2e_tests/web. final List 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 _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 _runTestsInDirectory(io.Directory directory) async { final io.Directory testDirectory = io.Directory(pathlib.join(directory.path, 'test_driver')); final List entities = testDirectory .listSync(followLinks: false) .whereType() .toList(); final List e2eTestsToRun = []; final List blockedTests = blockedTestsListsMap[getBlockedTestsListMapKey(_browser)] ?? []; // 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 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 _runTestsTarget( io.Directory directory, String target, Set buildModes) async { final Set 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 _runTestsInMode(io.Directory directory, String testName, {String mode = 'profile', String webRenderer = 'html'}) async { String executable = _useSystemFlutter ? 'flutter' : environment.flutterCommand.path; Map enviroment = Map(); if (_doUpdateScreenshotGoldens) { enviroment['UPDATE_GOLDENS'] = 'true'; } final IntegrationArguments arguments = IntegrationArguments.fromBrowser(_browser); final int exitCode = await runProcess( executable, arguments.getTestArguments(testName, mode, webRenderer), workingDirectory: directory.path, environment: enviroment, ); 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 _getRenderingBackends() { Set renderingBackends; if (_renderingBackendSelected) { final String mode = IntegrationTestsArgumentParser.instance.webRenderer; renderingBackends = {mode}; } else { // TODO(nurhan): Enable `auto` when recipe is sharded. renderingBackends = {'html', 'canvaskit'}; } return renderingBackends; } Set _getBuildModes() { Set 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 = {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 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 directories = testDirectory.listSync(followLinks: false).whereType(); if (directories.length > 0) { throw StateError('${testDirectory.path} directory should not contain ' 'any sub-directories'); } final Iterable entities = testDirectory.listSync(followLinks: false).whereType(); final Set expectedDriverFileNames = Set(); final Set foundDriverFileNames = Set(); 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 setup 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 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 getTestArguments( String testName, String mode, String webRenderer) { return [ '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 getTestArguments( String testName, String mode, String webRenderer) { return [ '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 getTestArguments( String testName, String mode, String webRenderer) { return [ '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. 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. 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. 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> blockedTestsListsMap = >{ '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> blockedTestsListsMapForModes = >{ '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> blockedTestsListsMapForRenderBackends = >{ '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', ], };