| // 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:convert'; |
| import 'dart:io'; |
| |
| import 'package:file/file.dart'; |
| |
| import 'common/core.dart'; |
| import 'common/output_utils.dart'; |
| import 'common/package_looping_command.dart'; |
| import 'common/plugin_utils.dart'; |
| import 'common/repository_package.dart'; |
| |
| const int _exitNoPlatformFlags = 2; |
| const int _exitNoAvailableDevice = 3; |
| |
| // From https://docs.flutter.dev/testing/integration-tests#running-in-a-browser |
| const int _chromeDriverPort = 4444; |
| |
| /// A command to run the integration tests for a package's example applications. |
| class DriveExamplesCommand extends PackageLoopingCommand { |
| /// Creates an instance of the drive command. |
| DriveExamplesCommand( |
| super.packagesDir, { |
| super.processRunner, |
| super.platform, |
| }) { |
| argParser.addFlag(platformAndroid, |
| help: 'Runs the Android implementation of the examples', |
| aliases: const <String>[platformAndroidAlias]); |
| argParser.addFlag(platformIOS, |
| help: 'Runs the iOS implementation of the examples'); |
| argParser.addFlag(platformLinux, |
| help: 'Runs the Linux implementation of the examples'); |
| argParser.addFlag(platformMacOS, |
| help: 'Runs the macOS implementation of the examples'); |
| argParser.addFlag(platformWeb, |
| help: 'Runs the web implementation of the examples'); |
| argParser.addFlag(platformWindows, |
| help: 'Runs the Windows implementation of the examples'); |
| argParser.addOption( |
| kEnableExperiment, |
| defaultsTo: '', |
| help: |
| 'Runs the driver tests in Dart VM with the given experiments enabled.', |
| ); |
| argParser.addFlag(_chromeDriverFlag, |
| help: 'Runs chromedriver for the duration of the test.\n\n' |
| 'Requires the correct version of chromedriver to be in your path.'); |
| } |
| |
| static const String _chromeDriverFlag = 'run-chromedriver'; |
| |
| @override |
| final String name = 'drive-examples'; |
| |
| @override |
| final String description = 'Runs Dart integration tests for example apps.\n\n' |
| "This runs all tests in each example's integration_test directory, " |
| 'via "flutter test" on most platforms, and "flutter drive" on web.\n\n' |
| 'This command requires "flutter" to be in your path.'; |
| |
| Map<String, List<String>> _targetDeviceFlags = const <String, List<String>>{}; |
| |
| @override |
| Future<void> initializeRun() async { |
| final List<String> platformSwitches = <String>[ |
| platformAndroid, |
| platformIOS, |
| platformLinux, |
| platformMacOS, |
| platformWeb, |
| platformWindows, |
| ]; |
| final int platformCount = platformSwitches |
| .where((String platform) => getBoolArg(platform)) |
| .length; |
| // The flutter tool currently doesn't accept multiple device arguments: |
| // https://github.com/flutter/flutter/issues/35733 |
| // If that is implemented, this check can be relaxed. |
| if (platformCount != 1) { |
| printError( |
| 'Exactly one of ${platformSwitches.map((String platform) => '--$platform').join(', ')} ' |
| 'must be specified.'); |
| throw ToolExit(_exitNoPlatformFlags); |
| } |
| |
| String? androidDevice; |
| if (getBoolArg(platformAndroid)) { |
| final List<String> devices = await _getDevicesForPlatform('android'); |
| if (devices.isEmpty) { |
| printError('No Android devices available'); |
| throw ToolExit(_exitNoAvailableDevice); |
| } |
| androidDevice = devices.first; |
| } |
| |
| String? iOSDevice; |
| if (getBoolArg(platformIOS)) { |
| final List<String> devices = await _getDevicesForPlatform('ios'); |
| if (devices.isEmpty) { |
| printError('No iOS devices available'); |
| throw ToolExit(_exitNoAvailableDevice); |
| } |
| iOSDevice = devices.first; |
| } |
| |
| _targetDeviceFlags = <String, List<String>>{ |
| if (getBoolArg(platformAndroid)) |
| platformAndroid: <String>['-d', androidDevice!], |
| if (getBoolArg(platformIOS)) platformIOS: <String>['-d', iOSDevice!], |
| if (getBoolArg(platformLinux)) platformLinux: <String>['-d', 'linux'], |
| if (getBoolArg(platformMacOS)) platformMacOS: <String>['-d', 'macos'], |
| if (getBoolArg(platformWeb)) |
| platformWeb: <String>[ |
| '-d', |
| 'web-server', |
| '--web-port=7357', |
| '--browser-name=chrome', |
| if (platform.environment.containsKey('CHROME_EXECUTABLE')) |
| '--chrome-binary=${platform.environment['CHROME_EXECUTABLE']}', |
| ], |
| if (getBoolArg(platformWindows)) |
| platformWindows: <String>['-d', 'windows'], |
| }; |
| } |
| |
| @override |
| Future<PackageResult> runForPackage(RepositoryPackage package) async { |
| final bool isPlugin = isFlutterPlugin(package); |
| |
| if (package.isPlatformInterface && package.getExamples().isEmpty) { |
| // Platform interface packages generally aren't intended to have |
| // examples, and don't need integration tests, so skip rather than fail. |
| return PackageResult.skip( |
| 'Platform interfaces are not expected to have integration tests.'); |
| } |
| |
| // For plugin packages, skip if the plugin itself doesn't support any |
| // requested platform(s). |
| if (isPlugin) { |
| final Iterable<String> requestedPlatforms = _targetDeviceFlags.keys; |
| final Iterable<String> unsupportedPlatforms = requestedPlatforms.where( |
| (String platform) => !pluginSupportsPlatform(platform, package)); |
| for (final String platform in unsupportedPlatforms) { |
| print('Skipping unsupported platform $platform...'); |
| } |
| if (unsupportedPlatforms.length == requestedPlatforms.length) { |
| return PackageResult.skip( |
| '${package.displayName} does not support any requested platform.'); |
| } |
| } |
| |
| int examplesFound = 0; |
| int supportedExamplesFound = 0; |
| bool testsRan = false; |
| final List<String> errors = <String>[]; |
| for (final RepositoryPackage example in package.getExamples()) { |
| ++examplesFound; |
| final String exampleName = |
| getRelativePosixPath(example.directory, from: packagesDir); |
| |
| // Skip examples that don't support any requested platform(s). |
| final List<String> deviceFlags = _deviceFlagsForExample(example); |
| if (deviceFlags.isEmpty) { |
| print( |
| 'Skipping $exampleName; does not support any requested platforms.'); |
| continue; |
| } |
| |
| ++supportedExamplesFound; |
| |
| final List<File> testTargets = await _getIntegrationTests(example); |
| if (testTargets.isEmpty) { |
| print('No integration_test/*.dart files found for $exampleName.'); |
| continue; |
| } |
| |
| // Check files for known problematic patterns. |
| testTargets |
| .where((File file) => !_validateIntegrationTest(file)) |
| .forEach((File file) { |
| // Report the issue, but continue with the test as the validation |
| // errors don't prevent running. |
| errors.add('${file.basename} failed validation'); |
| }); |
| |
| // `flutter test` doesn't yet support web integration tests, so fall back |
| // to `flutter drive`. |
| final bool useFlutterDrive = getBoolArg(platformWeb); |
| |
| final List<File> drivers; |
| if (useFlutterDrive) { |
| drivers = await _getDrivers(example); |
| if (drivers.isEmpty) { |
| print('No driver found for $exampleName'); |
| continue; |
| } |
| } else { |
| drivers = <File>[]; |
| } |
| |
| testsRan = true; |
| if (useFlutterDrive) { |
| Process? chromedriver; |
| if (getBoolArg(_chromeDriverFlag)) { |
| print('Starting chromedriver on port $_chromeDriverPort'); |
| chromedriver = await processRunner |
| .start('chromedriver', <String>['--port=$_chromeDriverPort']); |
| } |
| for (final File driver in drivers) { |
| final List<File> failingTargets = await _driveTests( |
| example, driver, testTargets, |
| deviceFlags: deviceFlags); |
| for (final File failingTarget in failingTargets) { |
| errors.add( |
| getRelativePosixPath(failingTarget, from: package.directory)); |
| } |
| } |
| if (chromedriver != null) { |
| print('Stopping chromedriver'); |
| chromedriver.kill(); |
| } |
| } else { |
| if (!await _runTests(example, deviceFlags: deviceFlags)) { |
| errors.add('Integration tests failed.'); |
| } |
| } |
| } |
| if (!testsRan) { |
| // It is an error for a plugin not to have integration tests, because that |
| // is the only way to test the method channel communication. |
| if (isPlugin) { |
| printError( |
| 'No driver tests were run ($examplesFound example(s) found).'); |
| errors.add('No tests ran (use --exclude if this is intentional).'); |
| } else { |
| return PackageResult.skip(supportedExamplesFound == 0 |
| ? 'No example supports requested platform(s).' |
| : 'No example is configured for integration tests.'); |
| } |
| } |
| return errors.isEmpty |
| ? PackageResult.success() |
| : PackageResult.fail(errors); |
| } |
| |
| /// Returns the device flags for the intersection of the requested platforms |
| /// and the platforms supported by [example]. |
| List<String> _deviceFlagsForExample(RepositoryPackage example) { |
| final List<String> deviceFlags = <String>[]; |
| for (final MapEntry<String, List<String>> entry |
| in _targetDeviceFlags.entries) { |
| final String platform = entry.key; |
| if (example.appSupportsPlatform(getPlatformByName(platform))) { |
| deviceFlags.addAll(entry.value); |
| } else { |
| final String exampleName = |
| getRelativePosixPath(example.directory, from: packagesDir); |
| print('Skipping unsupported platform $platform for $exampleName'); |
| } |
| } |
| return deviceFlags; |
| } |
| |
| Future<List<String>> _getDevicesForPlatform(String platform) async { |
| final List<String> deviceIds = <String>[]; |
| |
| final ProcessResult result = await processRunner.run( |
| flutterCommand, <String>['devices', '--machine'], |
| stdoutEncoding: utf8); |
| if (result.exitCode != 0) { |
| return deviceIds; |
| } |
| |
| String output = result.stdout as String; |
| // --machine doesn't currently prevent the tool from printing banners; |
| // see https://github.com/flutter/flutter/issues/86055. This workaround |
| // can be removed once that is fixed. |
| output = output.substring(output.indexOf('[')); |
| |
| final List<Map<String, dynamic>> devices = |
| (jsonDecode(output) as List<dynamic>).cast<Map<String, dynamic>>(); |
| for (final Map<String, dynamic> deviceInfo in devices) { |
| final String targetPlatform = |
| (deviceInfo['targetPlatform'] as String?) ?? ''; |
| if (targetPlatform.startsWith(platform)) { |
| final String? deviceId = deviceInfo['id'] as String?; |
| if (deviceId != null) { |
| deviceIds.add(deviceId); |
| } |
| } |
| } |
| return deviceIds; |
| } |
| |
| Future<List<File>> _getDrivers(RepositoryPackage example) async { |
| final List<File> drivers = <File>[]; |
| |
| final Directory driverDir = example.directory.childDirectory('test_driver'); |
| if (driverDir.existsSync()) { |
| await for (final FileSystemEntity driver in driverDir.list()) { |
| if (driver is File && driver.basename.endsWith('_test.dart')) { |
| drivers.add(driver); |
| } |
| } |
| } |
| return drivers; |
| } |
| |
| Future<List<File>> _getIntegrationTests(RepositoryPackage example) async { |
| final List<File> tests = <File>[]; |
| final Directory integrationTestDir = |
| example.directory.childDirectory('integration_test'); |
| |
| if (integrationTestDir.existsSync()) { |
| await for (final FileSystemEntity file in integrationTestDir.list()) { |
| if (file is File && file.basename.endsWith('_test.dart')) { |
| tests.add(file); |
| } |
| } |
| } |
| return tests; |
| } |
| |
| /// Checks [testFile] for known bad patterns in integration tests, logging |
| /// any issues. |
| /// |
| /// Returns true if the file passes validation without issues. |
| bool _validateIntegrationTest(File testFile) { |
| final List<String> lines = testFile.readAsLinesSync(); |
| |
| final RegExp badTestPattern = RegExp(r'\s*test\('); |
| if (lines.any((String line) => line.startsWith(badTestPattern))) { |
| final String filename = testFile.basename; |
| printError( |
| '$filename uses "test", which will not report failures correctly. ' |
| 'Use testWidgets instead.'); |
| return false; |
| } |
| |
| return true; |
| } |
| |
| /// For each file in [targets], uses |
| /// `flutter drive --driver [driver] --target <target>` |
| /// to drive [example], returning a list of any failing test targets. |
| /// |
| /// [deviceFlags] should contain the flags to run the test on a specific |
| /// target device (plus any supporting device-specific flags). E.g.: |
| /// - `['-d', 'macos']` for driving for macOS. |
| /// - `['-d', 'web-server', '--web-port=<port>', '--browser-name=<browser>]` |
| /// for web |
| Future<List<File>> _driveTests( |
| RepositoryPackage example, |
| File driver, |
| List<File> targets, { |
| required List<String> deviceFlags, |
| }) async { |
| final List<File> failures = <File>[]; |
| |
| final String enableExperiment = getStringArg(kEnableExperiment); |
| |
| for (final File target in targets) { |
| final int exitCode = await processRunner.runAndStream( |
| flutterCommand, |
| <String>[ |
| 'drive', |
| ...deviceFlags, |
| if (enableExperiment.isNotEmpty) |
| '--enable-experiment=$enableExperiment', |
| '--driver', |
| getRelativePosixPath(driver, from: example.directory), |
| '--target', |
| getRelativePosixPath(target, from: example.directory), |
| ], |
| workingDir: example.directory); |
| if (exitCode != 0) { |
| failures.add(target); |
| } |
| } |
| return failures; |
| } |
| |
| /// Uses `flutter test integration_test` to run [example], returning the |
| /// success of the test run. |
| /// |
| /// [deviceFlags] should contain the flags to run the test on a specific |
| /// target device (plus any supporting device-specific flags). E.g.: |
| /// - `['-d', 'macos']` for driving for macOS. |
| /// - `['-d', 'web-server', '--web-port=<port>', '--browser-name=<browser>]` |
| /// for web |
| Future<bool> _runTests( |
| RepositoryPackage example, { |
| required List<String> deviceFlags, |
| }) async { |
| final String enableExperiment = getStringArg(kEnableExperiment); |
| |
| final int exitCode = await processRunner.runAndStream( |
| flutterCommand, |
| <String>[ |
| 'test', |
| ...deviceFlags, |
| if (enableExperiment.isNotEmpty) |
| '--enable-experiment=$enableExperiment', |
| 'integration_test', |
| ], |
| workingDir: example.directory); |
| return exitCode == 0; |
| } |
| } |