| // 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 'package:platform/platform.dart'; | |
| import 'common/core.dart'; | |
| import 'common/package_looping_command.dart'; | |
| import 'common/plugin_utils.dart'; | |
| import 'common/process_runner.dart'; | |
| import 'common/repository_package.dart'; | |
| const int _exitNoPlatformFlags = 2; | |
| const int _exitNoAvailableDevice = 3; | |
| /// A command to run the example applications for packages via Flutter driver. | |
| class DriveExamplesCommand extends PackageLoopingCommand { | |
| /// Creates an instance of the drive command. | |
| DriveExamplesCommand( | |
| Directory packagesDir, { | |
| ProcessRunner processRunner = const ProcessRunner(), | |
| Platform platform = const LocalPlatform(), | |
| }) : super(packagesDir, processRunner: processRunner, platform: platform) { | |
| argParser.addFlag(platformAndroid, | |
| help: 'Runs the Android implementation of the examples'); | |
| 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.', | |
| ); | |
| } | |
| @override | |
| final String name = 'drive-examples'; | |
| @override | |
| final String description = 'Runs driver tests for package example apps.\n\n' | |
| 'For each *_test.dart in test_driver/ it drives an application with ' | |
| 'either the corresponding test in test_driver (for example, ' | |
| 'test_driver/app_test.dart would match test_driver/app.dart), or the ' | |
| '*_test.dart files in integration_test/.\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> drivers = await _getDrivers(example); | |
| if (drivers.isEmpty) { | |
| print('No driver tests found for $exampleName'); | |
| continue; | |
| } | |
| for (final File driver in drivers) { | |
| final List<File> testTargets = <File>[]; | |
| // Try to find a matching app to drive without the _test.dart | |
| // TODO(stuartmorgan): Migrate all remaining uses of this legacy | |
| // approach (currently only video_player) and remove support for it: | |
| // https://github.com/flutter/flutter/issues/85224. | |
| final File? legacyTestFile = _getLegacyTestFileForTestDriver(driver); | |
| if (legacyTestFile != null) { | |
| testTargets.add(legacyTestFile); | |
| } else { | |
| for (final File testFile in await _getIntegrationTests(example)) { | |
| // Check files for known problematic patterns. | |
| final bool passesValidation = _validateIntegrationTest(testFile); | |
| if (!passesValidation) { | |
| // Report the issue, but continue with the test as the validation | |
| // errors don't prevent running. | |
| errors.add('${testFile.basename} failed validation'); | |
| } | |
| testTargets.add(testFile); | |
| } | |
| } | |
| if (testTargets.isEmpty) { | |
| final String driverRelativePath = | |
| getRelativePosixPath(driver, from: package.directory); | |
| printError( | |
| 'Found $driverRelativePath, but no integration_test/*_test.dart files.'); | |
| errors.add('No test files for $driverRelativePath'); | |
| continue; | |
| } | |
| testsRan = true; | |
| 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 (!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 driver 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.directory.childDirectory(platform).existsSync()) { | |
| 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; | |
| } | |
| File? _getLegacyTestFileForTestDriver(File testDriver) { | |
| final String testName = testDriver.basename.replaceAll( | |
| RegExp(r'_test.dart$'), | |
| '.dart', | |
| ); | |
| final File testFile = testDriver.parent.childFile(testName); | |
| return testFile.existsSync() ? testFile : null; | |
| } | |
| 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; | |
| } | |
| } |