diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index d83ab14b23a0..000000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "script/plugin_tools"] - path = script/plugin_tools - url = https://github.com/flutter/plugin_tools.git diff --git a/script/common.sh b/script/common.sh index 4c8aff9822f6..28c37540af88 100644 --- a/script/common.sh +++ b/script/common.sh @@ -48,6 +48,6 @@ function check_changed_packages() { # Runs the plugin tools from the plugin_tools git submodule. function plugin_tools() { - (pushd "$REPO_DIR/script/plugin_tools" && dart pub get && popd) >/dev/null - dart run "$REPO_DIR/script/plugin_tools/lib/src/main.dart" "$@" + (pushd "$REPO_DIR/script/tool" && dart pub get && popd) >/dev/null + dart run "$REPO_DIR/script/tool/lib/src/main.dart" "$@" } diff --git a/script/plugin_tools b/script/plugin_tools deleted file mode 160000 index 432c56da3588..000000000000 --- a/script/plugin_tools +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 432c56da35880e95f6cbb02c40e9da0361771f48 diff --git a/script/tool/README.md b/script/tool/README.md new file mode 100644 index 000000000000..162ca0d98a74 --- /dev/null +++ b/script/tool/README.md @@ -0,0 +1,8 @@ +# Flutter Plugin Tools + +To run the tool: + +```sh +dart pub get +dart run lib/src/main.dart +``` diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart new file mode 100644 index 000000000000..8cd57fa0b338 --- /dev/null +++ b/script/tool/lib/src/analyze_command.dart @@ -0,0 +1,94 @@ +// Copyright 2017 The Chromium 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:async'; + +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; + +import 'common.dart'; + +class AnalyzeCommand extends PluginCommand { + AnalyzeCommand( + Directory packagesDir, + FileSystem fileSystem, { + ProcessRunner processRunner = const ProcessRunner(), + }) : super(packagesDir, fileSystem, processRunner: processRunner) { + argParser.addMultiOption(_customAnalysisFlag, + help: + 'Directories (comma seperated) that are allowed to have their own analysis options.', + defaultsTo: []); + } + + static const String _customAnalysisFlag = 'custom-analysis'; + + @override + final String name = 'analyze'; + + @override + final String description = 'Analyzes all packages using package:tuneup.\n\n' + 'This command requires "pub" and "flutter" to be in your path.'; + + @override + Future run() async { + checkSharding(); + + print('Verifying analysis settings...'); + final List files = packagesDir.listSync(recursive: true); + for (final FileSystemEntity file in files) { + if (file.basename != 'analysis_options.yaml' && + file.basename != '.analysis_options') { + continue; + } + + final bool whitelisted = argResults[_customAnalysisFlag].any( + (String directory) => + p.isWithin(p.join(packagesDir.path, directory), file.path)); + if (whitelisted) { + continue; + } + + print('Found an extra analysis_options.yaml in ${file.absolute.path}.'); + print( + 'If this was deliberate, pass the package to the analyze command with the --$_customAnalysisFlag flag and try again.'); + throw ToolExit(1); + } + + print('Activating tuneup package...'); + await processRunner.runAndStream( + 'pub', ['global', 'activate', 'tuneup'], + workingDir: packagesDir, exitOnError: true); + + await for (Directory package in getPackages()) { + if (isFlutterPackage(package, fileSystem)) { + await processRunner.runAndStream('flutter', ['packages', 'get'], + workingDir: package, exitOnError: true); + } else { + await processRunner.runAndStream('pub', ['get'], + workingDir: package, exitOnError: true); + } + } + + final List failingPackages = []; + await for (Directory package in getPlugins()) { + final int exitCode = await processRunner.runAndStream( + 'pub', ['global', 'run', 'tuneup', 'check'], + workingDir: package); + if (exitCode != 0) { + failingPackages.add(p.basename(package.path)); + } + } + + print('\n\n'); + if (failingPackages.isNotEmpty) { + print('The following packages have analyzer errors (see above):'); + failingPackages.forEach((String package) { + print(' * $package'); + }); + throw ToolExit(1); + } + + print('No analyzer errors found!'); + } +} diff --git a/script/tool/lib/src/build_examples_command.dart b/script/tool/lib/src/build_examples_command.dart new file mode 100644 index 000000000000..53da9086abaa --- /dev/null +++ b/script/tool/lib/src/build_examples_command.dart @@ -0,0 +1,188 @@ +// Copyright 2017 The Chromium 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:async'; +import 'dart:io' as io; + +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; + +import 'common.dart'; + +class BuildExamplesCommand extends PluginCommand { + BuildExamplesCommand( + Directory packagesDir, + FileSystem fileSystem, { + ProcessRunner processRunner = const ProcessRunner(), + }) : super(packagesDir, fileSystem, processRunner: processRunner) { + argParser.addFlag(kLinux, defaultsTo: false); + argParser.addFlag(kMacos, defaultsTo: false); + argParser.addFlag(kWindows, defaultsTo: false); + argParser.addFlag(kIpa, defaultsTo: io.Platform.isMacOS); + argParser.addFlag(kApk); + argParser.addOption( + kEnableExperiment, + defaultsTo: '', + help: 'Enables the given Dart SDK experiments.', + ); + } + + @override + final String name = 'build-examples'; + + @override + final String description = + 'Builds all example apps (IPA for iOS and APK for Android).\n\n' + 'This command requires "flutter" to be in your path.'; + + @override + Future run() async { + if (!argResults[kIpa] && + !argResults[kApk] && + !argResults[kLinux] && + !argResults[kMacos] && + !argResults[kWindows]) { + print( + 'None of --linux, --macos, --windows, --apk nor --ipa were specified, ' + 'so not building anything.'); + return; + } + final String flutterCommand = + LocalPlatform().isWindows ? 'flutter.bat' : 'flutter'; + + final String enableExperiment = argResults[kEnableExperiment]; + + checkSharding(); + final List failingPackages = []; + await for (Directory plugin in getPlugins()) { + for (Directory example in getExamplesForPlugin(plugin)) { + final String packageName = + p.relative(example.path, from: packagesDir.path); + + if (argResults[kLinux]) { + print('\nBUILDING Linux for $packageName'); + if (isLinuxPlugin(plugin, fileSystem)) { + int buildExitCode = await processRunner.runAndStream( + flutterCommand, + [ + 'build', + kLinux, + if (enableExperiment.isNotEmpty) + '--enable-experiment=$enableExperiment', + ], + workingDir: example); + if (buildExitCode != 0) { + failingPackages.add('$packageName (linux)'); + } + } else { + print('Linux is not supported by this plugin'); + } + } + + if (argResults[kMacos]) { + print('\nBUILDING macOS for $packageName'); + if (isMacOsPlugin(plugin, fileSystem)) { + // TODO(https://github.com/flutter/flutter/issues/46236): + // Builing macos without running flutter pub get first results + // in an error. + int exitCode = await processRunner.runAndStream( + flutterCommand, ['pub', 'get'], + workingDir: example); + if (exitCode != 0) { + failingPackages.add('$packageName (macos)'); + } else { + exitCode = await processRunner.runAndStream( + flutterCommand, + [ + 'build', + kMacos, + if (enableExperiment.isNotEmpty) + '--enable-experiment=$enableExperiment', + ], + workingDir: example); + if (exitCode != 0) { + failingPackages.add('$packageName (macos)'); + } + } + } else { + print('macOS is not supported by this plugin'); + } + } + + if (argResults[kWindows]) { + print('\nBUILDING Windows for $packageName'); + if (isWindowsPlugin(plugin, fileSystem)) { + int buildExitCode = await processRunner.runAndStream( + flutterCommand, + [ + 'build', + kWindows, + if (enableExperiment.isNotEmpty) + '--enable-experiment=$enableExperiment', + ], + workingDir: example); + if (buildExitCode != 0) { + failingPackages.add('$packageName (windows)'); + } + } else { + print('Windows is not supported by this plugin'); + } + } + + if (argResults[kIpa]) { + print('\nBUILDING IPA for $packageName'); + if (isIosPlugin(plugin, fileSystem)) { + final int exitCode = await processRunner.runAndStream( + flutterCommand, + [ + 'build', + 'ios', + '--no-codesign', + if (enableExperiment.isNotEmpty) + '--enable-experiment=$enableExperiment', + ], + workingDir: example); + if (exitCode != 0) { + failingPackages.add('$packageName (ipa)'); + } + } else { + print('iOS is not supported by this plugin'); + } + } + + if (argResults[kApk]) { + print('\nBUILDING APK for $packageName'); + if (isAndroidPlugin(plugin, fileSystem)) { + final int exitCode = await processRunner.runAndStream( + flutterCommand, + [ + 'build', + 'apk', + if (enableExperiment.isNotEmpty) + '--enable-experiment=$enableExperiment', + ], + workingDir: example); + if (exitCode != 0) { + failingPackages.add('$packageName (apk)'); + } + } else { + print('Android is not supported by this plugin'); + } + } + } + } + print('\n\n'); + + if (failingPackages.isNotEmpty) { + print('The following build are failing (see above for details):'); + for (String package in failingPackages) { + print(' * $package'); + } + throw ToolExit(1); + } + + print('All builds successful!'); + } +} diff --git a/script/tool/lib/src/common.dart b/script/tool/lib/src/common.dart new file mode 100644 index 000000000000..78b91ee8a75b --- /dev/null +++ b/script/tool/lib/src/common.dart @@ -0,0 +1,466 @@ +// Copyright 2017 The Chromium 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:async'; +import 'dart:io' as io; +import 'dart:math'; + +import 'package:args/command_runner.dart'; +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +typedef void Print(Object object); + +/// Key for windows platform. +const String kWindows = 'windows'; + +/// Key for macos platform. +const String kMacos = 'macos'; + +/// Key for linux platform. +const String kLinux = 'linux'; + +/// Key for IPA (iOS) platform. +const String kIos = 'ios'; + +/// Key for APK (Android) platform. +const String kAndroid = 'android'; + +/// Key for Web platform. +const String kWeb = 'web'; + +/// Key for IPA. +const String kIpa = 'ipa'; + +/// Key for APK. +const String kApk = 'apk'; + +/// Key for enable experiment. +const String kEnableExperiment = 'enable-experiment'; + +/// Returns whether the given directory contains a Flutter package. +bool isFlutterPackage(FileSystemEntity entity, FileSystem fileSystem) { + if (entity == null || entity is! Directory) { + return false; + } + + try { + final File pubspecFile = + fileSystem.file(p.join(entity.path, 'pubspec.yaml')); + final YamlMap pubspecYaml = loadYaml(pubspecFile.readAsStringSync()); + final YamlMap dependencies = pubspecYaml['dependencies']; + if (dependencies == null) { + return false; + } + return dependencies.containsKey('flutter'); + } on FileSystemException { + return false; + } on YamlException { + return false; + } +} + +/// Returns whether the given directory contains a Flutter [platform] plugin. +/// +/// It checks this by looking for the following pattern in the pubspec: +/// +/// flutter: +/// plugin: +/// platforms: +/// [platform]: +bool pluginSupportsPlatform( + String platform, FileSystemEntity entity, FileSystem fileSystem) { + assert(platform == kIos || + platform == kAndroid || + platform == kWeb || + platform == kMacos || + platform == kWindows || + platform == kLinux); + if (entity == null || entity is! Directory) { + return false; + } + + try { + final File pubspecFile = + fileSystem.file(p.join(entity.path, 'pubspec.yaml')); + final YamlMap pubspecYaml = loadYaml(pubspecFile.readAsStringSync()); + final YamlMap flutterSection = pubspecYaml['flutter']; + if (flutterSection == null) { + return false; + } + final YamlMap pluginSection = flutterSection['plugin']; + if (pluginSection == null) { + return false; + } + final YamlMap platforms = pluginSection['platforms']; + if (platforms == null) { + // Legacy plugin specs are assumed to support iOS and Android. + if (!pluginSection.containsKey('platforms')) { + return platform == kIos || platform == kAndroid; + } + return false; + } + return platforms.containsKey(platform); + } on FileSystemException { + return false; + } on YamlException { + return false; + } +} + +/// Returns whether the given directory contains a Flutter Android plugin. +bool isAndroidPlugin(FileSystemEntity entity, FileSystem fileSystem) { + return pluginSupportsPlatform(kAndroid, entity, fileSystem); +} + +/// Returns whether the given directory contains a Flutter iOS plugin. +bool isIosPlugin(FileSystemEntity entity, FileSystem fileSystem) { + return pluginSupportsPlatform(kIos, entity, fileSystem); +} + +/// Returns whether the given directory contains a Flutter web plugin. +bool isWebPlugin(FileSystemEntity entity, FileSystem fileSystem) { + return pluginSupportsPlatform(kWeb, entity, fileSystem); +} + +/// Returns whether the given directory contains a Flutter Windows plugin. +bool isWindowsPlugin(FileSystemEntity entity, FileSystem fileSystem) { + return pluginSupportsPlatform(kWindows, entity, fileSystem); +} + +/// Returns whether the given directory contains a Flutter macOS plugin. +bool isMacOsPlugin(FileSystemEntity entity, FileSystem fileSystem) { + return pluginSupportsPlatform(kMacos, entity, fileSystem); +} + +/// Returns whether the given directory contains a Flutter linux plugin. +bool isLinuxPlugin(FileSystemEntity entity, FileSystem fileSystem) { + return pluginSupportsPlatform(kLinux, entity, fileSystem); +} + +/// Error thrown when a command needs to exit with a non-zero exit code. +class ToolExit extends Error { + ToolExit(this.exitCode); + + final int exitCode; +} + +abstract class PluginCommand extends Command { + PluginCommand( + this.packagesDir, + this.fileSystem, { + this.processRunner = const ProcessRunner(), + }) { + argParser.addMultiOption( + _pluginsArg, + splitCommas: true, + help: + 'Specifies which plugins the command should run on (before sharding).', + valueHelp: 'plugin1,plugin2,...', + ); + argParser.addOption( + _shardIndexArg, + help: 'Specifies the zero-based index of the shard to ' + 'which the command applies.', + valueHelp: 'i', + defaultsTo: '0', + ); + argParser.addOption( + _shardCountArg, + help: 'Specifies the number of shards into which plugins are divided.', + valueHelp: 'n', + defaultsTo: '1', + ); + argParser.addMultiOption( + _excludeArg, + abbr: 'e', + help: 'Exclude packages from this command.', + defaultsTo: [], + ); + } + + static const String _pluginsArg = 'plugins'; + static const String _shardIndexArg = 'shardIndex'; + static const String _shardCountArg = 'shardCount'; + static const String _excludeArg = 'exclude'; + + /// The directory containing the plugin packages. + final Directory packagesDir; + + /// The file system. + /// + /// This can be overridden for testing. + final FileSystem fileSystem; + + /// The process runner. + /// + /// This can be overridden for testing. + final ProcessRunner processRunner; + + int _shardIndex; + int _shardCount; + + int get shardIndex { + if (_shardIndex == null) { + checkSharding(); + } + return _shardIndex; + } + + int get shardCount { + if (_shardCount == null) { + checkSharding(); + } + return _shardCount; + } + + void checkSharding() { + final int shardIndex = int.tryParse(argResults[_shardIndexArg]); + final int shardCount = int.tryParse(argResults[_shardCountArg]); + if (shardIndex == null) { + usageException('$_shardIndexArg must be an integer'); + } + if (shardCount == null) { + usageException('$_shardCountArg must be an integer'); + } + if (shardCount < 1) { + usageException('$_shardCountArg must be positive'); + } + if (shardIndex < 0 || shardCount <= shardIndex) { + usageException( + '$_shardIndexArg must be in the half-open range [0..$shardCount['); + } + _shardIndex = shardIndex; + _shardCount = shardCount; + } + + /// Returns the root Dart package folders of the plugins involved in this + /// command execution. + Stream getPlugins() async* { + // To avoid assuming consistency of `Directory.list` across command + // invocations, we collect and sort the plugin folders before sharding. + // This is considered an implementation detail which is why the API still + // uses streams. + final List allPlugins = await _getAllPlugins().toList(); + allPlugins.sort((Directory d1, Directory d2) => d1.path.compareTo(d2.path)); + // Sharding 10 elements into 3 shards should yield shard sizes 4, 4, 2. + // Sharding 9 elements into 3 shards should yield shard sizes 3, 3, 3. + // Sharding 2 elements into 3 shards should yield shard sizes 1, 1, 0. + final int shardSize = allPlugins.length ~/ shardCount + + (allPlugins.length % shardCount == 0 ? 0 : 1); + final int start = min(shardIndex * shardSize, allPlugins.length); + final int end = min(start + shardSize, allPlugins.length); + + for (Directory plugin in allPlugins.sublist(start, end)) { + yield plugin; + } + } + + /// Returns the root Dart package folders of the plugins involved in this + /// command execution, assuming there is only one shard. + /// + /// Plugin packages can exist in one of two places relative to the packages + /// directory. + /// + /// 1. As a Dart package in a directory which is a direct child of the + /// packages directory. This is a plugin where all of the implementations + /// exist in a single Dart package. + /// 2. Several plugin packages may live in a directory which is a direct + /// child of the packages directory. This directory groups several Dart + /// packages which implement a single plugin. This directory contains a + /// "client library" package, which declares the API for the plugin, as + /// well as one or more platform-specific implementations. + Stream _getAllPlugins() async* { + final Set plugins = Set.from(argResults[_pluginsArg]); + final Set excludedPlugins = + Set.from(argResults[_excludeArg]); + + await for (FileSystemEntity entity + in packagesDir.list(followLinks: false)) { + // A top-level Dart package is a plugin package. + if (_isDartPackage(entity)) { + if (!excludedPlugins.contains(entity.basename) && + (plugins.isEmpty || plugins.contains(p.basename(entity.path)))) { + yield entity; + } + } else if (entity is Directory) { + // Look for Dart packages under this top-level directory. + await for (FileSystemEntity subdir in entity.list(followLinks: false)) { + if (_isDartPackage(subdir)) { + // If --plugin=my_plugin is passed, then match all federated + // plugins under 'my_plugin'. Also match if the exact plugin is + // passed. + final String relativePath = + p.relative(subdir.path, from: packagesDir.path); + final String basenamePath = p.basename(entity.path); + if (!excludedPlugins.contains(basenamePath) && + !excludedPlugins.contains(relativePath) && + (plugins.isEmpty || + plugins.contains(relativePath) || + plugins.contains(basenamePath))) { + yield subdir; + } + } + } + } + } + } + + /// Returns the example Dart package folders of the plugins involved in this + /// command execution. + Stream getExamples() => + getPlugins().expand(getExamplesForPlugin); + + /// Returns all Dart package folders (typically, plugin + example) of the + /// plugins involved in this command execution. + Stream getPackages() async* { + await for (Directory plugin in getPlugins()) { + yield plugin; + yield* plugin + .list(recursive: true, followLinks: false) + .where(_isDartPackage) + .cast(); + } + } + + /// Returns the files contained, recursively, within the plugins + /// involved in this command execution. + Stream getFiles() { + return getPlugins().asyncExpand((Directory folder) => folder + .list(recursive: true, followLinks: false) + .where((FileSystemEntity entity) => entity is File) + .cast()); + } + + /// Returns whether the specified entity is a directory containing a + /// `pubspec.yaml` file. + bool _isDartPackage(FileSystemEntity entity) { + return entity is Directory && + fileSystem.file(p.join(entity.path, 'pubspec.yaml')).existsSync(); + } + + /// Returns the example Dart packages contained in the specified plugin, or + /// an empty List, if the plugin has no examples. + Iterable getExamplesForPlugin(Directory plugin) { + final Directory exampleFolder = + fileSystem.directory(p.join(plugin.path, 'example')); + if (!exampleFolder.existsSync()) { + return []; + } + if (isFlutterPackage(exampleFolder, fileSystem)) { + return [exampleFolder]; + } + // Only look at the subdirectories of the example directory if the example + // directory itself is not a Dart package, and only look one level below the + // example directory for other dart packages. + return exampleFolder + .listSync() + .where( + (FileSystemEntity entity) => isFlutterPackage(entity, fileSystem)) + .cast(); + } +} + +/// A class used to run processes. +/// +/// We use this instead of directly running the process so it can be overridden +/// in tests. +class ProcessRunner { + const ProcessRunner(); + + /// Run the [executable] with [args] and stream output to stderr and stdout. + /// + /// The current working directory of [executable] can be overridden by + /// passing [workingDir]. + /// + /// If [exitOnError] is set to `true`, then this will throw an error if + /// the [executable] terminates with a non-zero exit code. + /// + /// Returns the exit code of the [executable]. + Future runAndStream( + String executable, + List args, { + Directory workingDir, + bool exitOnError = false, + }) async { + print( + 'Running command: "$executable ${args.join(' ')}" in ${workingDir?.path ?? io.Directory.current.path}'); + final io.Process process = await io.Process.start(executable, args, + workingDirectory: workingDir?.path); + await io.stdout.addStream(process.stdout); + await io.stderr.addStream(process.stderr); + if (exitOnError && await process.exitCode != 0) { + final String error = + _getErrorString(executable, args, workingDir: workingDir); + print('$error See above for details.'); + throw ToolExit(await process.exitCode); + } + return process.exitCode; + } + + /// Run the [executable] with [args]. + /// + /// The current working directory of [executable] can be overridden by + /// passing [workingDir]. + /// + /// If [exitOnError] is set to `true`, then this will throw an error if + /// the [executable] terminates with a non-zero exit code. + /// + /// Returns the [io.ProcessResult] of the [executable]. + Future run(String executable, List args, + {Directory workingDir, + bool exitOnError = false, + stdoutEncoding = io.systemEncoding, + stderrEncoding = io.systemEncoding}) async { + return io.Process.run(executable, args, + workingDirectory: workingDir?.path, + stdoutEncoding: stdoutEncoding, + stderrEncoding: stderrEncoding); + } + + /// Starts the [executable] with [args]. + /// + /// The current working directory of [executable] can be overridden by + /// passing [workingDir]. + /// + /// Returns the started [io.Process]. + Future start(String executable, List args, + {Directory workingDirectory}) async { + final io.Process process = await io.Process.start(executable, args, + workingDirectory: workingDirectory?.path); + return process; + } + + /// Run the [executable] with [args], throwing an error on non-zero exit code. + /// + /// Unlike [runAndStream], this does not stream the process output to stdout. + /// It also unconditionally throws an error on a non-zero exit code. + /// + /// The current working directory of [executable] can be overridden by + /// passing [workingDir]. + /// + /// Returns the [io.ProcessResult] of running the [executable]. + Future runAndExitOnError( + String executable, + List args, { + Directory workingDir, + }) async { + final io.ProcessResult result = await io.Process.run(executable, args, + workingDirectory: workingDir?.path); + if (result.exitCode != 0) { + final String error = + _getErrorString(executable, args, workingDir: workingDir); + print('$error Stderr:\n${result.stdout}'); + throw ToolExit(result.exitCode); + } + return result; + } + + String _getErrorString(String executable, List args, + {Directory workingDir}) { + final String workdir = workingDir == null ? '' : ' in ${workingDir.path}'; + return 'ERROR: Unable to execute "$executable ${args.join(' ')}"$workdir.'; + } +} diff --git a/script/tool/lib/src/create_all_plugins_app_command.dart b/script/tool/lib/src/create_all_plugins_app_command.dart new file mode 100644 index 000000000000..0f1431c5aee0 --- /dev/null +++ b/script/tool/lib/src/create_all_plugins_app_command.dart @@ -0,0 +1,200 @@ +// Copyright 2019 The Chromium 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:async'; +import 'dart:io' as io; + +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; +import 'package:pub_semver/pub_semver.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; + +import 'common.dart'; + +// TODO(cyanglaz): Add tests for this command. +// https://github.com/flutter/flutter/issues/61049 +class CreateAllPluginsAppCommand extends PluginCommand { + CreateAllPluginsAppCommand(Directory packagesDir, FileSystem fileSystem) + : super(packagesDir, fileSystem); + + @override + String get description => + 'Generate Flutter app that includes all plugins in packages.'; + + @override + String get name => 'all-plugins-app'; + + @override + Future run() async { + final int exitCode = await _createPlugin(); + if (exitCode != 0) { + throw ToolExit(exitCode); + } + + await Future.wait(>[ + _genPubspecWithAllPlugins(), + _updateAppGradle(), + _updateManifest(), + ]); + } + + Future _createPlugin() async { + final io.ProcessResult result = io.Process.runSync( + 'flutter', + [ + 'create', + '--template=app', + '--project-name=all_plugins', + '--android-language=java', + './all_plugins', + ], + ); + + print(result.stdout); + print(result.stderr); + return result.exitCode; + } + + Future _updateAppGradle() async { + final File gradleFile = fileSystem.file(p.join( + 'all_plugins', + 'android', + 'app', + 'build.gradle', + )); + if (!gradleFile.existsSync()) { + throw ToolExit(64); + } + + final StringBuffer newGradle = StringBuffer(); + for (String line in gradleFile.readAsLinesSync()) { + newGradle.writeln(line); + if (line.contains('defaultConfig {')) { + newGradle.writeln(' multiDexEnabled true'); + } else if (line.contains('dependencies {')) { + newGradle.writeln( + ' implementation \'com.google.guava:guava:27.0.1-android\'\n', + ); + // Tests for https://github.com/flutter/flutter/issues/43383 + newGradle.writeln( + " implementation 'androidx.lifecycle:lifecycle-runtime:2.2.0-rc01'\n", + ); + } + } + gradleFile.writeAsStringSync(newGradle.toString()); + } + + Future _updateManifest() async { + final File manifestFile = fileSystem.file(p.join( + 'all_plugins', + 'android', + 'app', + 'src', + 'main', + 'AndroidManifest.xml', + )); + if (!manifestFile.existsSync()) { + throw ToolExit(64); + } + + final StringBuffer newManifest = StringBuffer(); + for (String line in manifestFile.readAsLinesSync()) { + if (line.contains('package="com.example.all_plugins"')) { + newManifest + ..writeln('package="com.example.all_plugins"') + ..writeln('xmlns:tools="http://schemas.android.com/tools">') + ..writeln() + ..writeln( + '', + ); + } else { + newManifest.writeln(line); + } + } + manifestFile.writeAsStringSync(newManifest.toString()); + } + + Future _genPubspecWithAllPlugins() async { + final Map pluginDeps = + await _getValidPathDependencies(); + final Pubspec pubspec = Pubspec( + 'all_plugins', + description: 'Flutter app containing all 1st party plugins.', + version: Version.parse('1.0.0+1'), + environment: { + 'sdk': VersionConstraint.compatibleWith( + Version.parse('2.0.0'), + ), + }, + dependencies: { + 'flutter': SdkDependency('flutter'), + }..addAll(pluginDeps), + devDependencies: { + 'flutter_test': SdkDependency('flutter'), + }, + dependencyOverrides: pluginDeps, + ); + final File pubspecFile = + fileSystem.file(p.join('all_plugins', 'pubspec.yaml')); + pubspecFile.writeAsStringSync(_pubspecToString(pubspec)); + } + + Future> _getValidPathDependencies() async { + final Map pathDependencies = + {}; + + await for (Directory package in getPlugins()) { + final String pluginName = package.path.split('/').last; + final File pubspecFile = + fileSystem.file(p.join(package.path, 'pubspec.yaml')); + final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); + + if (pubspec.publishTo != 'none') { + pathDependencies[pluginName] = PathDependency(package.path); + } + } + return pathDependencies; + } + + String _pubspecToString(Pubspec pubspec) { + return ''' +### Generated file. Do not edit. Run `pub global run flutter_plugin_tools gen-pubspec` to update. +name: ${pubspec.name} +description: ${pubspec.description} + +version: ${pubspec.version} + +environment:${_pubspecMapString(pubspec.environment)} + +dependencies:${_pubspecMapString(pubspec.dependencies)} + +dependency_overrides:${_pubspecMapString(pubspec.dependencyOverrides)} + +dev_dependencies:${_pubspecMapString(pubspec.devDependencies)} +###'''; + } + + String _pubspecMapString(Map values) { + final StringBuffer buffer = StringBuffer(); + + for (MapEntry entry in values.entries) { + buffer.writeln(); + if (entry.value is VersionConstraint) { + buffer.write(' ${entry.key}: ${entry.value}'); + } else if (entry.value is SdkDependency) { + final SdkDependency dep = entry.value; + buffer.write(' ${entry.key}: \n sdk: ${dep.sdk}'); + } else if (entry.value is PathDependency) { + final PathDependency dep = entry.value; + buffer.write(' ${entry.key}: \n path: ${dep.path}'); + } else { + throw UnimplementedError( + 'Not available for type: ${entry.value.runtimeType}', + ); + } + } + + return buffer.toString(); + } +} diff --git a/script/tool/lib/src/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart new file mode 100644 index 000000000000..59c642265bae --- /dev/null +++ b/script/tool/lib/src/drive_examples_command.dart @@ -0,0 +1,210 @@ +// Copyright 2019 The Chromium 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:async'; +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; +import 'common.dart'; + +class DriveExamplesCommand extends PluginCommand { + DriveExamplesCommand( + Directory packagesDir, + FileSystem fileSystem, { + ProcessRunner processRunner = const ProcessRunner(), + }) : super(packagesDir, fileSystem, processRunner: processRunner) { + argParser.addFlag(kLinux, + help: 'Runs the Linux implementation of the examples'); + argParser.addFlag(kMacos, + help: 'Runs the macOS implementation of the examples'); + argParser.addFlag(kWindows, + help: 'Runs the Windows implementation of the examples'); + argParser.addFlag(kIos, + help: 'Runs the iOS implementation of the examples'); + argParser.addFlag(kAndroid, + help: 'Runs the Android 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 plugin example apps.\n\n' + 'For each *_test.dart in test_driver/ it drives an application with a ' + 'corresponding name in the test/ or test_driver/ directories.\n\n' + 'For example, test_driver/app_test.dart would match test/app.dart.\n\n' + 'This command requires "flutter" to be in your path.\n\n' + 'If a file with a corresponding name cannot be found, this driver file' + 'will be used to drive the tests that match ' + 'integration_test/*_test.dart.'; + + @override + Future run() async { + checkSharding(); + final List failingTests = []; + final bool isLinux = argResults[kLinux]; + final bool isMacos = argResults[kMacos]; + final bool isWindows = argResults[kWindows]; + await for (Directory plugin in getPlugins()) { + final String flutterCommand = + LocalPlatform().isWindows ? 'flutter.bat' : 'flutter'; + for (Directory example in getExamplesForPlugin(plugin)) { + final String packageName = + p.relative(example.path, from: packagesDir.path); + if (!(await pluginSupportedOnCurrentPlatform(plugin, fileSystem))) { + continue; + } + final Directory driverTests = + fileSystem.directory(p.join(example.path, 'test_driver')); + if (!driverTests.existsSync()) { + // No driver tests available for this example + continue; + } + // Look for driver tests ending in _test.dart in test_driver/ + await for (FileSystemEntity test in driverTests.list()) { + final String driverTestName = + p.relative(test.path, from: driverTests.path); + if (!driverTestName.endsWith('_test.dart')) { + continue; + } + // Try to find a matching app to drive without the _test.dart + final String deviceTestName = driverTestName.replaceAll( + RegExp(r'_test.dart$'), + '.dart', + ); + String deviceTestPath = p.join('test', deviceTestName); + if (!fileSystem + .file(p.join(example.path, deviceTestPath)) + .existsSync()) { + // If the app isn't in test/ folder, look in test_driver/ instead. + deviceTestPath = p.join('test_driver', deviceTestName); + } + + final List targetPaths = []; + if (fileSystem + .file(p.join(example.path, deviceTestPath)) + .existsSync()) { + targetPaths.add(deviceTestPath); + } else { + final Directory integrationTests = + fileSystem.directory(p.join(example.path, 'integration_test')); + + if (await integrationTests.exists()) { + await for (FileSystemEntity integration_test + in integrationTests.list()) { + if (!integration_test.basename.endsWith('_test.dart')) { + continue; + } + targetPaths + .add(p.relative(integration_test.path, from: example.path)); + } + } + + if (targetPaths.isEmpty) { + print(''' +Unable to infer a target application for $driverTestName to drive. +Tried searching for the following: +1. test/$deviceTestName +2. test_driver/$deviceTestName +3. test_driver/*_test.dart +'''); + failingTests.add(p.relative(test.path, from: example.path)); + continue; + } + } + + final List driveArgs = ['drive']; + + final String enableExperiment = argResults[kEnableExperiment]; + if (enableExperiment.isNotEmpty) { + driveArgs.add('--enable-experiment=$enableExperiment'); + } + + if (isLinux && isLinuxPlugin(plugin, fileSystem)) { + driveArgs.addAll([ + '-d', + 'linux', + ]); + } + if (isMacos && isMacOsPlugin(plugin, fileSystem)) { + driveArgs.addAll([ + '-d', + 'macos', + ]); + } + if (isWindows && isWindowsPlugin(plugin, fileSystem)) { + driveArgs.addAll([ + '-d', + 'windows', + ]); + } + + for (final targetPath in targetPaths) { + final int exitCode = await processRunner.runAndStream( + flutterCommand, + [ + ...driveArgs, + '--driver', + p.join('test_driver', driverTestName), + '--target', + targetPath, + ], + workingDir: example, + exitOnError: true); + if (exitCode != 0) { + failingTests.add(p.join(packageName, deviceTestPath)); + } + } + } + } + } + print('\n\n'); + + if (failingTests.isNotEmpty) { + print('The following driver tests are failing (see above for details):'); + for (String test in failingTests) { + print(' * $test'); + } + throw ToolExit(1); + } + + print('All driver tests successful!'); + } + + Future pluginSupportedOnCurrentPlatform( + FileSystemEntity plugin, FileSystem fileSystem) async { + final bool isLinux = argResults[kLinux]; + final bool isMacos = argResults[kMacos]; + final bool isWindows = argResults[kWindows]; + final bool isIOS = argResults[kIos]; + final bool isAndroid = argResults[kAndroid]; + if (isLinux) { + return isLinuxPlugin(plugin, fileSystem); + } + if (isMacos) { + return isMacOsPlugin(plugin, fileSystem); + } + if (isWindows) { + return isWindowsPlugin(plugin, fileSystem); + } + if (isIOS) { + return isIosPlugin(plugin, fileSystem); + } + if (isAndroid) { + return (isAndroidPlugin(plugin, fileSystem)); + } + // When we are here, no flags are specified. Only return true if the plugin supports mobile for legacy command support. + // TODO(cyanglaz): Make mobile platforms flags also required like other platforms (breaking change). + // https://github.com/flutter/flutter/issues/58285 + final bool isMobilePlugin = + isIosPlugin(plugin, fileSystem) || isAndroidPlugin(plugin, fileSystem); + return isMobilePlugin; + } +} diff --git a/script/tool/lib/src/firebase_test_lab_command.dart b/script/tool/lib/src/firebase_test_lab_command.dart new file mode 100644 index 000000000000..0b4b2a471dbc --- /dev/null +++ b/script/tool/lib/src/firebase_test_lab_command.dart @@ -0,0 +1,264 @@ +// Copyright 2018 The Chromium 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:async'; +import 'dart:io' as io; + +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; +import 'package:uuid/uuid.dart'; + +import 'common.dart'; + +class FirebaseTestLabCommand extends PluginCommand { + FirebaseTestLabCommand( + Directory packagesDir, + FileSystem fileSystem, { + ProcessRunner processRunner = const ProcessRunner(), + Print print = print, + }) : _print = print, + super(packagesDir, fileSystem, processRunner: processRunner) { + argParser.addOption( + 'project', + defaultsTo: 'flutter-infra', + help: 'The Firebase project name.', + ); + argParser.addOption('service-key', + defaultsTo: + p.join(io.Platform.environment['HOME'], 'gcloud-service-key.json')); + argParser.addOption('test-run-id', + defaultsTo: Uuid().v4(), + help: + 'Optional string to append to the results path, to avoid conflicts. ' + 'Randomly chosen on each invocation if none is provided. ' + 'The default shown here is just an example.'); + argParser.addMultiOption('device', + splitCommas: false, + defaultsTo: [ + 'model=walleye,version=26', + 'model=flame,version=29' + ], + help: + 'Device model(s) to test. See https://cloud.google.com/sdk/gcloud/reference/firebase/test/android/run for more info'); + argParser.addOption('results-bucket', + defaultsTo: 'gs://flutter_firebase_testlab'); + argParser.addOption( + kEnableExperiment, + defaultsTo: '', + help: 'Enables the given Dart SDK experiments.', + ); + } + + @override + final String name = 'firebase-test-lab'; + + @override + final String description = 'Runs the instrumentation tests of the example ' + 'apps on Firebase Test Lab.\n\n' + 'Runs tests in test_instrumentation folder using the ' + 'instrumentation_test package.'; + + static const String _gradleWrapper = 'gradlew'; + + final Print _print; + + Completer _firebaseProjectConfigured; + + Future _configureFirebaseProject() async { + if (_firebaseProjectConfigured != null) { + return _firebaseProjectConfigured.future; + } else { + _firebaseProjectConfigured = Completer(); + } + await processRunner.runAndExitOnError('gcloud', [ + 'auth', + 'activate-service-account', + '--key-file=${argResults['service-key']}', + ]); + int exitCode = await processRunner.runAndStream('gcloud', [ + 'config', + 'set', + 'project', + argResults['project'], + ]); + if (exitCode == 0) { + _print('\nFirebase project configured.'); + return; + } else { + _print( + '\nWarning: gcloud config set returned a non-zero exit code. Continuing anyway.'); + } + _firebaseProjectConfigured.complete(null); + } + + @override + Future run() async { + checkSharding(); + final Stream packagesWithTests = getPackages().where( + (Directory d) => + isFlutterPackage(d, fileSystem) && + fileSystem + .directory(p.join( + d.path, 'example', 'android', 'app', 'src', 'androidTest')) + .existsSync()); + + final List failingPackages = []; + final List missingFlutterBuild = []; + int resultsCounter = + 0; // We use a unique GCS bucket for each Firebase Test Lab run + await for (Directory package in packagesWithTests) { + // See https://github.com/flutter/flutter/issues/38983 + + final Directory exampleDirectory = + fileSystem.directory(p.join(package.path, 'example')); + final String packageName = + p.relative(package.path, from: packagesDir.path); + _print('\nRUNNING FIREBASE TEST LAB TESTS for $packageName'); + + final Directory androidDirectory = + fileSystem.directory(p.join(exampleDirectory.path, 'android')); + + final String enableExperiment = argResults[kEnableExperiment]; + final String encodedEnableExperiment = + Uri.encodeComponent('--enable-experiment=$enableExperiment'); + + // Ensures that gradle wrapper exists + if (!fileSystem + .file(p.join(androidDirectory.path, _gradleWrapper)) + .existsSync()) { + final int exitCode = await processRunner.runAndStream( + 'flutter', + [ + 'build', + 'apk', + if (enableExperiment.isNotEmpty) + '--enable-experiment=$enableExperiment', + ], + workingDir: androidDirectory); + + if (exitCode != 0) { + failingPackages.add(packageName); + continue; + } + continue; + } + + await _configureFirebaseProject(); + + int exitCode = await processRunner.runAndStream( + p.join(androidDirectory.path, _gradleWrapper), + [ + 'app:assembleAndroidTest', + '-Pverbose=true', + if (enableExperiment.isNotEmpty) + '-Pextra-front-end-options=$encodedEnableExperiment', + if (enableExperiment.isNotEmpty) + '-Pextra-gen-snapshot-options=$encodedEnableExperiment', + ], + workingDir: androidDirectory); + + if (exitCode != 0) { + failingPackages.add(packageName); + continue; + } + + // Look for tests recursively in folders that start with 'test' and that + // live in the root or example folders. + bool isTestDir(FileSystemEntity dir) { + return p.basename(dir.path).startsWith('test') || + p.basename(dir.path) == 'integration_test'; + } + + final List testDirs = + package.listSync().where(isTestDir).toList(); + final Directory example = + fileSystem.directory(p.join(package.path, 'example')); + testDirs.addAll(example.listSync().where(isTestDir).toList()); + for (Directory testDir in testDirs) { + bool isE2ETest(FileSystemEntity file) { + return file.path.endsWith('_e2e.dart') || + (file.parent.basename == 'integration_test' && + file.path.endsWith('_test.dart')); + } + + final List testFiles = testDir + .listSync(recursive: true, followLinks: true) + .where(isE2ETest) + .toList(); + for (FileSystemEntity test in testFiles) { + exitCode = await processRunner.runAndStream( + p.join(androidDirectory.path, _gradleWrapper), + [ + 'app:assembleDebug', + '-Pverbose=true', + '-Ptarget=${test.path}', + if (enableExperiment.isNotEmpty) + '-Pextra-front-end-options=$encodedEnableExperiment', + if (enableExperiment.isNotEmpty) + '-Pextra-gen-snapshot-options=$encodedEnableExperiment', + ], + workingDir: androidDirectory); + + if (exitCode != 0) { + failingPackages.add(packageName); + continue; + } + final String buildId = io.Platform.environment['CIRRUS_BUILD_ID']; + final String testRunId = argResults['test-run-id']; + final String resultsDir = + 'plugins_android_test/$packageName/$buildId/$testRunId/${resultsCounter++}/'; + final List args = [ + 'firebase', + 'test', + 'android', + 'run', + '--type', + 'instrumentation', + '--app', + 'build/app/outputs/apk/debug/app-debug.apk', + '--test', + 'build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk', + '--timeout', + '5m', + '--results-bucket=${argResults['results-bucket']}', + '--results-dir=${resultsDir}', + ]; + for (String device in argResults['device']) { + args.addAll(['--device', device]); + } + exitCode = await processRunner.runAndStream('gcloud', args, + workingDir: exampleDirectory); + + if (exitCode != 0) { + failingPackages.add(packageName); + continue; + } + } + } + } + + _print('\n\n'); + if (failingPackages.isNotEmpty) { + _print( + 'The instrumentation tests for the following packages are failing (see above for' + 'details):'); + for (String package in failingPackages) { + _print(' * $package'); + } + } + if (missingFlutterBuild.isNotEmpty) { + _print('Run "pub global run flutter_plugin_tools build-examples --apk" on' + 'the following packages before executing tests again:'); + for (String package in missingFlutterBuild) { + _print(' * $package'); + } + } + + if (failingPackages.isNotEmpty || missingFlutterBuild.isNotEmpty) { + throw ToolExit(1); + } + + _print('All Firebase Test Lab tests successful!'); + } +} diff --git a/script/tool/lib/src/format_command.dart b/script/tool/lib/src/format_command.dart new file mode 100644 index 000000000000..ec326b96c1f9 --- /dev/null +++ b/script/tool/lib/src/format_command.dart @@ -0,0 +1,147 @@ +// Copyright 2017 The Chromium 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:async'; +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:file/file.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:quiver/iterables.dart'; + +import 'common.dart'; + +const String _googleFormatterUrl = + 'https://github.com/google/google-java-format/releases/download/google-java-format-1.3/google-java-format-1.3-all-deps.jar'; + +class FormatCommand extends PluginCommand { + FormatCommand( + Directory packagesDir, + FileSystem fileSystem, { + ProcessRunner processRunner = const ProcessRunner(), + }) : super(packagesDir, fileSystem, processRunner: processRunner) { + argParser.addFlag('travis', hide: true); + argParser.addOption('clang-format', + defaultsTo: 'clang-format', + help: 'Path to executable of clang-format v5.'); + } + + @override + final String name = 'format'; + + @override + final String description = + 'Formats the code of all packages (Java, Objective-C, C++, and Dart).\n\n' + 'This command requires "git", "flutter" and "clang-format" v5 to be in ' + 'your path.'; + + @override + Future run() async { + checkSharding(); + final String googleFormatterPath = await _getGoogleFormatterPath(); + + await _formatDart(); + await _formatJava(googleFormatterPath); + await _formatCppAndObjectiveC(); + + if (argResults['travis']) { + final bool modified = await _didModifyAnything(); + if (modified) { + throw ToolExit(1); + } + } + } + + Future _didModifyAnything() async { + final io.ProcessResult modifiedFiles = await processRunner + .runAndExitOnError('git', ['ls-files', '--modified'], + workingDir: packagesDir); + + print('\n\n'); + + if (modifiedFiles.stdout.isEmpty) { + print('All files formatted correctly.'); + return false; + } + + print('These files are not formatted correctly (see diff below):'); + LineSplitter.split(modifiedFiles.stdout) + .map((String line) => ' $line') + .forEach(print); + + print('\nTo fix run "pub global activate flutter_plugin_tools && ' + 'pub global run flutter_plugin_tools format" or copy-paste ' + 'this command into your terminal:'); + + print('patch -p1 <['diff'], workingDir: packagesDir); + print(diff.stdout); + print('DONE'); + return true; + } + + Future _formatCppAndObjectiveC() async { + print('Formatting all .cc, .cpp, .mm, .m, and .h files...'); + final Iterable allFiles = [] + ..addAll(await _getFilesWithExtension('.h')) + ..addAll(await _getFilesWithExtension('.m')) + ..addAll(await _getFilesWithExtension('.mm')) + ..addAll(await _getFilesWithExtension('.cc')) + ..addAll(await _getFilesWithExtension('.cpp')); + // Split this into multiple invocations to avoid a + // 'ProcessException: Argument list too long'. + final Iterable> batches = partition(allFiles, 100); + for (List batch in batches) { + await processRunner.runAndStream(argResults['clang-format'], + ['-i', '--style=Google']..addAll(batch), + workingDir: packagesDir, exitOnError: true); + } + } + + Future _formatJava(String googleFormatterPath) async { + print('Formatting all .java files...'); + final Iterable javaFiles = await _getFilesWithExtension('.java'); + await processRunner.runAndStream('java', + ['-jar', googleFormatterPath, '--replace']..addAll(javaFiles), + workingDir: packagesDir, exitOnError: true); + } + + Future _formatDart() async { + // This actually should be fine for non-Flutter Dart projects, no need to + // specifically shell out to dartfmt -w in that case. + print('Formatting all .dart files...'); + final Iterable dartFiles = await _getFilesWithExtension('.dart'); + if (dartFiles.isEmpty) { + print( + 'No .dart files to format. If you set the `--exclude` flag, most likey they were skipped'); + } else { + await processRunner.runAndStream( + 'flutter', ['format']..addAll(dartFiles), + workingDir: packagesDir, exitOnError: true); + } + } + + Future> _getFilesWithExtension(String extension) async => + getFiles() + .where((File file) => p.extension(file.path) == extension) + .map((File file) => file.path) + .toList(); + + Future _getGoogleFormatterPath() async { + final String javaFormatterPath = p.join( + p.dirname(p.fromUri(io.Platform.script)), + 'google-java-format-1.3-all-deps.jar'); + final File javaFormatterFile = fileSystem.file(javaFormatterPath); + + if (!javaFormatterFile.existsSync()) { + print('Downloading Google Java Format...'); + final http.Response response = await http.get(_googleFormatterUrl); + javaFormatterFile.writeAsBytesSync(response.bodyBytes); + } + + return javaFormatterPath; + } +} diff --git a/script/tool/lib/src/java_test_command.dart b/script/tool/lib/src/java_test_command.dart new file mode 100644 index 000000000000..cf605bfc5ce2 --- /dev/null +++ b/script/tool/lib/src/java_test_command.dart @@ -0,0 +1,89 @@ +// Copyright 2018 The Chromium 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:async'; + +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; + +import 'common.dart'; + +class JavaTestCommand extends PluginCommand { + JavaTestCommand( + Directory packagesDir, + FileSystem fileSystem, { + ProcessRunner processRunner = const ProcessRunner(), + }) : super(packagesDir, fileSystem, processRunner: processRunner); + + @override + final String name = 'java-test'; + + @override + final String description = 'Runs the Java tests of the example apps.\n\n' + 'Building the apks of the example apps is required before executing this' + 'command.'; + + static const String _gradleWrapper = 'gradlew'; + + @override + Future run() async { + checkSharding(); + final Stream examplesWithTests = getExamples().where( + (Directory d) => + isFlutterPackage(d, fileSystem) && + fileSystem + .directory(p.join(d.path, 'android', 'app', 'src', 'test')) + .existsSync()); + + final List failingPackages = []; + final List missingFlutterBuild = []; + await for (Directory example in examplesWithTests) { + final String packageName = + p.relative(example.path, from: packagesDir.path); + print('\nRUNNING JAVA TESTS for $packageName'); + + final Directory androidDirectory = + fileSystem.directory(p.join(example.path, 'android')); + if (!fileSystem + .file(p.join(androidDirectory.path, _gradleWrapper)) + .existsSync()) { + print('ERROR: Run "flutter build apk" on example app of $packageName' + 'before executing tests.'); + missingFlutterBuild.add(packageName); + continue; + } + + final int exitCode = await processRunner.runAndStream( + p.join(androidDirectory.path, _gradleWrapper), + ['testDebugUnitTest', '--info'], + workingDir: androidDirectory); + if (exitCode != 0) { + failingPackages.add(packageName); + } + } + + print('\n\n'); + if (failingPackages.isNotEmpty) { + print( + 'The Java tests for the following packages are failing (see above for' + 'details):'); + for (String package in failingPackages) { + print(' * $package'); + } + } + if (missingFlutterBuild.isNotEmpty) { + print('Run "pub global run flutter_plugin_tools build-examples --apk" on' + 'the following packages before executing tests again:'); + for (String package in missingFlutterBuild) { + print(' * $package'); + } + } + + if (failingPackages.isNotEmpty || missingFlutterBuild.isNotEmpty) { + throw ToolExit(1); + } + + print('All Java tests successful!'); + } +} diff --git a/script/tool/lib/src/lint_podspecs_command.dart b/script/tool/lib/src/lint_podspecs_command.dart new file mode 100644 index 000000000000..68fd4b61dd66 --- /dev/null +++ b/script/tool/lib/src/lint_podspecs_command.dart @@ -0,0 +1,146 @@ +// Copyright 2017 The Chromium 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:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; + +import 'common.dart'; + +typedef void Print(Object object); + +/// Lint the CocoaPod podspecs, run the static analyzer on iOS/macOS plugin +/// platform code, and run unit tests. +/// +/// See https://guides.cocoapods.org/terminal/commands.html#pod_lib_lint. +class LintPodspecsCommand extends PluginCommand { + LintPodspecsCommand( + Directory packagesDir, + FileSystem fileSystem, { + ProcessRunner processRunner = const ProcessRunner(), + this.platform = const LocalPlatform(), + Print print = print, + }) : _print = print, + super(packagesDir, fileSystem, processRunner: processRunner) { + argParser.addMultiOption('skip', + help: + 'Skip all linting for podspecs with this basename (example: federated plugins with placeholder podspecs)', + valueHelp: 'podspec_file_name'); + argParser.addMultiOption('ignore-warnings', + help: + 'Do not pass --allow-warnings flag to "pod lib lint" for podspecs with this basename (example: plugins with known warnings)', + valueHelp: 'podspec_file_name'); + argParser.addMultiOption('no-analyze', + help: + 'Do not pass --analyze flag to "pod lib lint" for podspecs with this basename (example: plugins with known analyzer warnings)', + valueHelp: 'podspec_file_name'); + } + + @override + final String name = 'podspecs'; + + @override + List get aliases => ['podspec']; + + @override + final String description = + 'Runs "pod lib lint" on all iOS and macOS plugin podspecs.\n\n' + 'This command requires "pod" and "flutter" to be in your path. Runs on macOS only.'; + + final Platform platform; + + final Print _print; + + @override + Future run() async { + if (!platform.isMacOS) { + _print('Detected platform is not macOS, skipping podspec lint'); + return; + } + + checkSharding(); + + await processRunner.runAndExitOnError('which', ['pod'], + workingDir: packagesDir); + + _print('Starting podspec lint test'); + + final List failingPlugins = []; + for (File podspec in await _podspecsToLint()) { + if (!await _lintPodspec(podspec)) { + failingPlugins.add(p.basenameWithoutExtension(podspec.path)); + } + } + + _print('\n\n'); + if (failingPlugins.isNotEmpty) { + _print('The following plugins have podspec errors (see above):'); + failingPlugins.forEach((String plugin) { + _print(' * $plugin'); + }); + throw ToolExit(1); + } + } + + Future> _podspecsToLint() async { + final List podspecs = await getFiles().where((File entity) { + final String filePath = entity.path; + return p.extension(filePath) == '.podspec' && + !argResults['skip'].contains(p.basenameWithoutExtension(filePath)); + }).toList(); + + podspecs.sort( + (File a, File b) => p.basename(a.path).compareTo(p.basename(b.path))); + return podspecs; + } + + Future _lintPodspec(File podspec) async { + // Do not run the static analyzer on plugins with known analyzer issues. + final String podspecPath = podspec.path; + final bool runAnalyzer = !argResults['no-analyze'] + .contains(p.basenameWithoutExtension(podspecPath)); + + final String podspecBasename = p.basename(podspecPath); + if (runAnalyzer) { + _print('Linting and analyzing $podspecBasename'); + } else { + _print('Linting $podspecBasename'); + } + + // Lint plugin as framework (use_frameworks!). + final ProcessResult frameworkResult = await _runPodLint(podspecPath, + runAnalyzer: runAnalyzer, libraryLint: true); + _print(frameworkResult.stdout); + _print(frameworkResult.stderr); + + // Lint plugin as library. + final ProcessResult libraryResult = await _runPodLint(podspecPath, + runAnalyzer: runAnalyzer, libraryLint: false); + _print(libraryResult.stdout); + _print(libraryResult.stderr); + + return frameworkResult.exitCode == 0 && libraryResult.exitCode == 0; + } + + Future _runPodLint(String podspecPath, + {bool runAnalyzer, bool libraryLint}) async { + final bool allowWarnings = argResults['ignore-warnings'] + .contains(p.basenameWithoutExtension(podspecPath)); + final List arguments = [ + 'lib', + 'lint', + podspecPath, + if (allowWarnings) '--allow-warnings', + if (runAnalyzer) '--analyze', + if (libraryLint) '--use-libraries' + ]; + + return processRunner.run('pod', arguments, + workingDir: packagesDir, stdoutEncoding: utf8, stderrEncoding: utf8); + } +} diff --git a/script/tool/lib/src/list_command.dart b/script/tool/lib/src/list_command.dart new file mode 100644 index 000000000000..7f94daac7096 --- /dev/null +++ b/script/tool/lib/src/list_command.dart @@ -0,0 +1,60 @@ +// Copyright 2018 The Chromium 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:async'; + +import 'package:file/file.dart'; + +import 'common.dart'; + +class ListCommand extends PluginCommand { + ListCommand(Directory packagesDir, FileSystem fileSystem) + : super(packagesDir, fileSystem) { + argParser.addOption( + _type, + defaultsTo: _plugin, + allowed: [_plugin, _example, _package, _file], + help: 'What type of file system content to list.', + ); + } + + static const String _type = 'type'; + static const String _plugin = 'plugin'; + static const String _example = 'example'; + static const String _package = 'package'; + static const String _file = 'file'; + + @override + final String name = 'list'; + + @override + final String description = 'Lists packages or files'; + + @override + Future run() async { + checkSharding(); + switch (argResults[_type]) { + case _plugin: + await for (Directory package in getPlugins()) { + print(package.path); + } + break; + case _example: + await for (Directory package in getExamples()) { + print(package.path); + } + break; + case _package: + await for (Directory package in getPackages()) { + print(package.path); + } + break; + case _file: + await for (File file in getFiles()) { + print(file.path); + } + break; + } + } +} diff --git a/script/tool/lib/src/main.dart b/script/tool/lib/src/main.dart new file mode 100644 index 000000000000..bb3f67c0a9e1 --- /dev/null +++ b/script/tool/lib/src/main.dart @@ -0,0 +1,63 @@ +// Copyright 2017 The Chromium 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/command_runner.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:flutter_plugin_tools/src/publish_plugin_command.dart'; +import 'package:path/path.dart' as p; + +import 'analyze_command.dart'; +import 'build_examples_command.dart'; +import 'common.dart'; +import 'create_all_plugins_app_command.dart'; +import 'drive_examples_command.dart'; +import 'firebase_test_lab_command.dart'; +import 'format_command.dart'; +import 'java_test_command.dart'; +import 'lint_podspecs_command.dart'; +import 'list_command.dart'; +import 'test_command.dart'; +import 'version_check_command.dart'; +import 'xctest_command.dart'; + +void main(List args) { + final FileSystem fileSystem = const LocalFileSystem(); + + Directory packagesDir = fileSystem + .directory(p.join(fileSystem.currentDirectory.path, 'packages')); + + if (!packagesDir.existsSync()) { + if (p.basename(fileSystem.currentDirectory.path) == 'packages') { + packagesDir = fileSystem.currentDirectory; + } else { + print('Error: Cannot find a "packages" sub-directory'); + io.exit(1); + } + } + + final CommandRunner commandRunner = CommandRunner( + 'pub global run flutter_plugin_tools', + 'Productivity utils for hosting multiple plugins within one repository.') + ..addCommand(AnalyzeCommand(packagesDir, fileSystem)) + ..addCommand(BuildExamplesCommand(packagesDir, fileSystem)) + ..addCommand(CreateAllPluginsAppCommand(packagesDir, fileSystem)) + ..addCommand(DriveExamplesCommand(packagesDir, fileSystem)) + ..addCommand(FirebaseTestLabCommand(packagesDir, fileSystem)) + ..addCommand(FormatCommand(packagesDir, fileSystem)) + ..addCommand(JavaTestCommand(packagesDir, fileSystem)) + ..addCommand(LintPodspecsCommand(packagesDir, fileSystem)) + ..addCommand(ListCommand(packagesDir, fileSystem)) + ..addCommand(PublishPluginCommand(packagesDir, fileSystem)) + ..addCommand(TestCommand(packagesDir, fileSystem)) + ..addCommand(VersionCheckCommand(packagesDir, fileSystem)) + ..addCommand(XCTestCommand(packagesDir, fileSystem)); + + commandRunner.run(args).catchError((Object e) { + final ToolExit toolExit = e; + io.exit(toolExit.exitCode); + }, test: (Object e) => e is ToolExit); +} diff --git a/script/tool/lib/src/publish_plugin_command.dart b/script/tool/lib/src/publish_plugin_command.dart new file mode 100644 index 000000000000..f7e3b5deeecf --- /dev/null +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -0,0 +1,223 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:file/file.dart'; +import 'package:git/git.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +import 'common.dart'; + +/// Wraps pub publish with a few niceties used by the flutter/plugin team. +/// +/// 1. Checks for any modified files in git and refuses to publish if there's an +/// issue. +/// 2. Tags the release with the format -v. +/// 3. Pushes the release to a remote. +/// +/// Both 2 and 3 are optional, see `plugin_tools help publish-plugin` for full +/// usage information. +/// +/// [processRunner], [print], and [stdin] can be overriden for easier testing. +class PublishPluginCommand extends PluginCommand { + PublishPluginCommand( + Directory packagesDir, + FileSystem fileSystem, { + ProcessRunner processRunner = const ProcessRunner(), + Print print = print, + Stdin stdinput, + }) : _print = print, + _stdin = stdinput ?? stdin, + super(packagesDir, fileSystem, processRunner: processRunner) { + argParser.addOption( + _packageOption, + help: 'The package to publish.' + 'If the package directory name is different than its pubspec.yaml name, then this should specify the directory.', + ); + argParser.addMultiOption(_pubFlagsOption, + help: + 'A list of options that will be forwarded on to pub. Separate multiple flags with commas.'); + argParser.addFlag( + _tagReleaseOption, + help: 'Whether or not to tag the release.', + defaultsTo: true, + negatable: true, + ); + argParser.addFlag( + _pushTagsOption, + help: + 'Whether or not tags should be pushed to a remote after creation. Ignored if tag-release is false.', + defaultsTo: true, + negatable: true, + ); + argParser.addOption( + _remoteOption, + help: + 'The name of the remote to push the tags to. Ignored if push-tags or tag-release is false.', + // Flutter convention is to use "upstream" for the single source of truth, and "origin" for personal forks. + defaultsTo: 'upstream', + ); + } + + static const String _packageOption = 'package'; + static const String _tagReleaseOption = 'tag-release'; + static const String _pushTagsOption = 'push-tags'; + static const String _pubFlagsOption = 'pub-publish-flags'; + static const String _remoteOption = 'remote'; + + // Version tags should follow -v. For example, + // `flutter_plugin_tools-v0.0.24`. + static const String _tagFormat = '%PACKAGE%-v%VERSION%'; + + @override + final String name = 'publish-plugin'; + + @override + final String description = + 'Attempts to publish the given plugin and tag its release on GitHub.'; + + final Print _print; + final Stdin _stdin; + // The directory of the actual package that we are publishing. + Directory _packageDir; + StreamSubscription _stdinSubscription; + + @override + Future run() async { + checkSharding(); + _print('Checking local repo...'); + _packageDir = _checkPackageDir(); + await _checkGitStatus(); + final bool shouldPushTag = argResults[_pushTagsOption]; + final String remote = argResults[_remoteOption]; + String remoteUrl; + if (shouldPushTag) { + remoteUrl = await _verifyRemote(remote); + } + _print('Local repo is ready!'); + + await _publish(); + _print('Package published!'); + if (!argResults[_tagReleaseOption]) { + return await _finishSuccesfully(); + } + + _print('Tagging release...'); + final String tag = _getTag(); + await processRunner.runAndExitOnError('git', ['tag', tag], + workingDir: _packageDir); + if (!shouldPushTag) { + return await _finishSuccesfully(); + } + + _print('Pushing tag to $remote...'); + await _pushTagToRemote(remote: remote, tag: tag, remoteUrl: remoteUrl); + await _finishSuccesfully(); + } + + Future _finishSuccesfully() async { + await _stdinSubscription.cancel(); + _print('Done!'); + } + + Directory _checkPackageDir() { + final String package = argResults[_packageOption]; + if (package == null) { + _print( + 'Must specify a package to publish. See `plugin_tools help publish-plugin`.'); + throw ToolExit(1); + } + final Directory _packageDir = packagesDir.childDirectory(package); + if (!_packageDir.existsSync()) { + _print('${_packageDir.absolute.path} does not exist.'); + throw ToolExit(1); + } + return _packageDir; + } + + Future _checkGitStatus() async { + if (!await GitDir.isGitDir(packagesDir.path)) { + _print('$packagesDir is not a valid Git repository.'); + throw ToolExit(1); + } + + final ProcessResult statusResult = await processRunner.runAndExitOnError( + 'git', + [ + 'status', + '--porcelain', + '--ignored', + _packageDir.absolute.path + ], + workingDir: _packageDir); + final String statusOutput = statusResult.stdout; + if (statusOutput.isNotEmpty) { + _print( + "There are files in the package directory that haven't been saved in git. Refusing to publish these files:\n\n" + '$statusOutput\n' + 'If the directory should be clean, you can run `git clean -xdf && git reset --hard HEAD` to wipe all local changes.'); + throw ToolExit(1); + } + } + + Future _verifyRemote(String remote) async { + final ProcessResult remoteInfo = await processRunner.runAndExitOnError( + 'git', ['remote', 'get-url', remote], + workingDir: _packageDir); + return remoteInfo.stdout; + } + + Future _publish() async { + final List publishFlags = argResults[_pubFlagsOption]; + _print( + 'Running `pub publish ${publishFlags.join(' ')}` in ${_packageDir.absolute.path}...\n'); + final Process publish = await processRunner.start( + 'flutter', ['pub', 'publish'] + publishFlags, + workingDirectory: _packageDir); + publish.stdout + .transform(utf8.decoder) + .listen((String data) => _print(data)); + publish.stderr + .transform(utf8.decoder) + .listen((String data) => _print(data)); + _stdinSubscription = _stdin + .transform(utf8.decoder) + .listen((String data) => publish.stdin.writeln(data)); + final int result = await publish.exitCode; + if (result != 0) { + _print('Publish failed. Exiting.'); + throw ToolExit(result); + } + } + + String _getTag() { + final File pubspecFile = + fileSystem.file(p.join(_packageDir.path, 'pubspec.yaml')); + final YamlMap pubspecYaml = loadYaml(pubspecFile.readAsStringSync()); + final String name = pubspecYaml['name']; + final String version = pubspecYaml['version']; + // We should have failed to publish if these were unset. + assert(name.isNotEmpty && version.isNotEmpty); + return _tagFormat + .replaceAll('%PACKAGE%', name) + .replaceAll('%VERSION%', version); + } + + Future _pushTagToRemote( + {@required String remote, + @required String tag, + @required String remoteUrl}) async { + assert(remote != null && tag != null && remoteUrl != null); + _print('Ready to push $tag to $remoteUrl (y/n)?'); + final String input = _stdin.readLineSync(); + if (input.toLowerCase() != 'y') { + _print('Tag push canceled.'); + throw ToolExit(1); + } + + await processRunner.runAndExitOnError('git', ['push', remote, tag], + workingDir: packagesDir); + } +} diff --git a/script/tool/lib/src/test_command.dart b/script/tool/lib/src/test_command.dart new file mode 100644 index 000000000000..e938168cfa89 --- /dev/null +++ b/script/tool/lib/src/test_command.dart @@ -0,0 +1,101 @@ +// Copyright 2017 The Chromium 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:async'; + +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; + +import 'common.dart'; + +class TestCommand extends PluginCommand { + TestCommand( + Directory packagesDir, + FileSystem fileSystem, { + ProcessRunner processRunner = const ProcessRunner(), + }) : super(packagesDir, fileSystem, processRunner: processRunner) { + argParser.addOption( + kEnableExperiment, + defaultsTo: '', + help: 'Runs the tests in Dart VM with the given experiments enabled.', + ); + } + + @override + final String name = 'test'; + + @override + final String description = 'Runs the Dart tests for all packages.\n\n' + 'This command requires "flutter" to be in your path.'; + + @override + Future run() async { + checkSharding(); + final List failingPackages = []; + await for (Directory packageDir in getPackages()) { + final String packageName = + p.relative(packageDir.path, from: packagesDir.path); + if (!fileSystem.directory(p.join(packageDir.path, 'test')).existsSync()) { + print('SKIPPING $packageName - no test subdirectory'); + continue; + } + + print('RUNNING $packageName tests...'); + + final String enableExperiment = argResults[kEnableExperiment]; + + // `flutter test` automatically gets packages. `pub run test` does not. :( + int exitCode = 0; + if (isFlutterPackage(packageDir, fileSystem)) { + final List args = [ + 'test', + '--color', + if (enableExperiment.isNotEmpty) + '--enable-experiment=$enableExperiment', + ]; + + if (isWebPlugin(packageDir, fileSystem)) { + args.add('--platform=chrome'); + } + exitCode = await processRunner.runAndStream( + 'flutter', + args, + workingDir: packageDir, + ); + } else { + exitCode = await processRunner.runAndStream( + 'pub', + ['get'], + workingDir: packageDir, + ); + if (exitCode == 0) { + exitCode = await processRunner.runAndStream( + 'pub', + [ + 'run', + if (enableExperiment.isNotEmpty) + '--enable-experiment=$enableExperiment', + 'test', + ], + workingDir: packageDir, + ); + } + } + if (exitCode != 0) { + failingPackages.add(packageName); + } + } + + print('\n\n'); + if (failingPackages.isNotEmpty) { + print('Tests for the following packages are failing (see above):'); + failingPackages.forEach((String package) { + print(' * $package'); + }); + throw ToolExit(1); + } + + print('All tests are passing!'); + } +} diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart new file mode 100644 index 000000000000..2c6b92bbcb7a --- /dev/null +++ b/script/tool/lib/src/version_check_command.dart @@ -0,0 +1,220 @@ +// Copyright 2017 The Chromium 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:async'; +import 'dart:io' as io; + +import 'package:meta/meta.dart'; +import 'package:colorize/colorize.dart'; +import 'package:file/file.dart'; +import 'package:git/git.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; +import 'package:yaml/yaml.dart'; + +import 'common.dart'; + +const String _kBaseSha = 'base_sha'; + +class GitVersionFinder { + GitVersionFinder(this.baseGitDir, this.baseSha); + + final GitDir baseGitDir; + final String baseSha; + + static bool isPubspec(String file) { + return file.trim().endsWith('pubspec.yaml'); + } + + Future> getChangedPubSpecs() async { + final io.ProcessResult changedFilesCommand = await baseGitDir + .runCommand(['diff', '--name-only', '$baseSha', 'HEAD']); + final List changedFiles = + changedFilesCommand.stdout.toString().split('\n'); + return changedFiles.where(isPubspec).toList(); + } + + Future getPackageVersion(String pubspecPath, String gitRef) async { + final io.ProcessResult gitShow = + await baseGitDir.runCommand(['show', '$gitRef:$pubspecPath']); + final String fileContent = gitShow.stdout; + final String versionString = loadYaml(fileContent)['version']; + return versionString == null ? null : Version.parse(versionString); + } +} + +enum NextVersionType { + BREAKING_MAJOR, + MAJOR_NULLSAFETY_PRE_RELEASE, + MINOR_NULLSAFETY_PRE_RELEASE, + MINOR, + PATCH, + RELEASE, +} + +Version getNextNullSafetyPreRelease(Version current, Version next) { + String nextNullsafetyPrerelease = 'nullsafety'; + if (current.isPreRelease && + current.preRelease.first is String && + current.preRelease.first == 'nullsafety') { + if (current.preRelease.length == 1) { + nextNullsafetyPrerelease = 'nullsafety.1'; + } else if (current.preRelease.length == 2 && + current.preRelease.last is int) { + nextNullsafetyPrerelease = 'nullsafety.${current.preRelease.last + 1}'; + } + } + return Version( + next.major, + next.minor, + next.patch, + pre: nextNullsafetyPrerelease, + ); +} + +@visibleForTesting +Map getAllowedNextVersions( + Version masterVersion, Version headVersion) { + final Version nextNullSafetyMajor = + getNextNullSafetyPreRelease(masterVersion, masterVersion.nextMajor); + final Version nextNullSafetyMinor = + getNextNullSafetyPreRelease(masterVersion, masterVersion.nextMinor); + final Map allowedNextVersions = + { + masterVersion.nextMajor: NextVersionType.BREAKING_MAJOR, + nextNullSafetyMajor: NextVersionType.MAJOR_NULLSAFETY_PRE_RELEASE, + nextNullSafetyMinor: NextVersionType.MINOR_NULLSAFETY_PRE_RELEASE, + masterVersion.nextMinor: NextVersionType.MINOR, + masterVersion.nextPatch: NextVersionType.PATCH, + }; + + if (masterVersion.major < 1 && headVersion.major < 1) { + int nextBuildNumber = -1; + if (masterVersion.build.isEmpty) { + nextBuildNumber = 1; + } else { + final int currentBuildNumber = masterVersion.build.first; + nextBuildNumber = currentBuildNumber + 1; + } + final Version preReleaseVersion = Version( + masterVersion.major, + masterVersion.minor, + masterVersion.patch, + build: nextBuildNumber.toString(), + ); + allowedNextVersions.clear(); + allowedNextVersions[masterVersion.nextMajor] = NextVersionType.RELEASE; + allowedNextVersions[masterVersion.nextMinor] = + NextVersionType.BREAKING_MAJOR; + allowedNextVersions[masterVersion.nextPatch] = NextVersionType.MINOR; + allowedNextVersions[preReleaseVersion] = NextVersionType.PATCH; + + final Version nextNullSafetyMajor = + getNextNullSafetyPreRelease(masterVersion, masterVersion.nextMinor); + final Version nextNullSafetyMinor = + getNextNullSafetyPreRelease(masterVersion, masterVersion.nextPatch); + + allowedNextVersions[nextNullSafetyMajor] = + NextVersionType.MAJOR_NULLSAFETY_PRE_RELEASE; + allowedNextVersions[nextNullSafetyMinor] = + NextVersionType.MINOR_NULLSAFETY_PRE_RELEASE; + } + return allowedNextVersions; +} + +class VersionCheckCommand extends PluginCommand { + VersionCheckCommand( + Directory packagesDir, + FileSystem fileSystem, { + ProcessRunner processRunner = const ProcessRunner(), + this.gitDir, + }) : super(packagesDir, fileSystem, processRunner: processRunner) { + argParser.addOption(_kBaseSha); + } + + /// The git directory to use. By default it uses the parent directory. + /// + /// This can be mocked for testing. + final GitDir gitDir; + + @override + final String name = 'version-check'; + + @override + final String description = + 'Checks if the versions of the plugins have been incremented per pub specification.\n\n' + 'This command requires "pub" and "flutter" to be in your path.'; + + @override + Future run() async { + checkSharding(); + + final String rootDir = packagesDir.parent.absolute.path; + final String baseSha = argResults[_kBaseSha]; + + GitDir baseGitDir = gitDir; + if (baseGitDir == null) { + if (!await GitDir.isGitDir(rootDir)) { + print('$rootDir is not a valid Git repository.'); + throw ToolExit(2); + } + baseGitDir = await GitDir.fromExisting(rootDir); + } + + final GitVersionFinder gitVersionFinder = + GitVersionFinder(baseGitDir, baseSha); + + final List changedPubspecs = + await gitVersionFinder.getChangedPubSpecs(); + + for (final String pubspecPath in changedPubspecs) { + try { + final File pubspecFile = fileSystem.file(pubspecPath); + if (!pubspecFile.existsSync()) { + continue; + } + final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); + if (pubspec.publishTo == 'none') { + continue; + } + + final Version masterVersion = + await gitVersionFinder.getPackageVersion(pubspecPath, baseSha); + final Version headVersion = + await gitVersionFinder.getPackageVersion(pubspecPath, 'HEAD'); + if (headVersion == null) { + continue; // Example apps don't have versions + } + + final Map allowedNextVersions = + getAllowedNextVersions(masterVersion, headVersion); + + if (!allowedNextVersions.containsKey(headVersion)) { + final String error = '$pubspecPath incorrectly updated version.\n' + 'HEAD: $headVersion, master: $masterVersion.\n' + 'Allowed versions: $allowedNextVersions'; + final Colorize redError = Colorize(error)..red(); + print(redError); + throw ToolExit(1); + } + + bool isPlatformInterface = pubspec.name.endsWith("_platform_interface"); + if (isPlatformInterface && + allowedNextVersions[headVersion] == + NextVersionType.BREAKING_MAJOR) { + final String error = '$pubspecPath breaking change detected.\n' + 'Breaking changes to platform interfaces are strongly discouraged.\n'; + final Colorize redError = Colorize(error)..red(); + print(redError); + throw ToolExit(1); + } + } on io.ProcessException { + print('Unable to find pubspec in master for $pubspecPath.' + ' Safe to ignore if the project is new.'); + } + } + + print('No version check errors found!'); + } +} diff --git a/script/tool/lib/src/xctest_command.dart b/script/tool/lib/src/xctest_command.dart new file mode 100644 index 000000000000..d90b7a8fbfea --- /dev/null +++ b/script/tool/lib/src/xctest_command.dart @@ -0,0 +1,216 @@ +// Copyright 2017 The Chromium 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:async'; +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; + +import 'common.dart'; + +const String _kiOSDestination = 'ios-destination'; +const String _kTarget = 'target'; +const String _kSkip = 'skip'; +const String _kXcodeBuildCommand = 'xcodebuild'; +const String _kXCRunCommand = 'xcrun'; +const String _kFoundNoSimulatorsMessage = + 'Cannot find any available simulators, tests failed'; + +/// The command to run iOS' XCTests in plugins, this should work for both XCUnitTest and XCUITest targets. +/// The tests target have to be added to the xcode project of the example app. Usually at "example/ios/Runner.xcodeproj". +/// The command takes a "-target" argument which has to match the target of the test target. +/// For information on how to add test target in an xcode project, see https://developer.apple.com/library/archive/documentation/ToolsLanguages/Conceptual/Xcode_Overview/UnitTesting.html +class XCTestCommand extends PluginCommand { + XCTestCommand( + Directory packagesDir, + FileSystem fileSystem, { + ProcessRunner processRunner = const ProcessRunner(), + }) : super(packagesDir, fileSystem, processRunner: processRunner) { + argParser.addOption( + _kiOSDestination, + help: + 'Specify the destination when running the test, used for -destination flag for xcodebuild command.\n' + 'this is passed to the `-destination` argument in xcodebuild command.\n' + 'See https://developer.apple.com/library/archive/technotes/tn2339/_index.html#//apple_ref/doc/uid/DTS40014588-CH1-UNIT for details on how to specify the destination.', + ); + argParser.addOption(_kTarget, + help: 'The test target.\n' + 'This is the xcode project test target. This is passed to the `-scheme` argument in the xcodebuild command. \n' + 'See https://developer.apple.com/library/archive/technotes/tn2339/_index.html#//apple_ref/doc/uid/DTS40014588-CH1-UNIT for details on how to specify the scheme'); + argParser.addMultiOption(_kSkip, + help: 'Plugins to skip while running this command. \n'); + } + + @override + final String name = 'xctest'; + + @override + final String description = 'Runs the xctests in the iOS example apps.\n\n' + 'This command requires "flutter" to be in your path.'; + + @override + Future run() async { + if (argResults[_kTarget] == null) { + // TODO(cyanglaz): Automatically find all the available testing schemes if this argument is not specified. + // https://github.com/flutter/flutter/issues/68419 + print('--$_kTarget must be specified'); + throw ToolExit(1); + } + + String destination = argResults[_kiOSDestination]; + if (destination == null) { + String simulatorId = await _findAvailableIphoneSimulator(); + if (simulatorId == null) { + print(_kFoundNoSimulatorsMessage); + throw ToolExit(1); + } + destination = 'id=$simulatorId'; + } + + checkSharding(); + + final String target = argResults[_kTarget]; + final List skipped = argResults[_kSkip]; + + List failingPackages = []; + await for (Directory plugin in getPlugins()) { + // Start running for package. + final String packageName = + p.relative(plugin.path, from: packagesDir.path); + print('Start running for $packageName ...'); + if (!isIosPlugin(plugin, fileSystem)) { + print('iOS is not supported by this plugin.'); + print('\n\n'); + continue; + } + if (skipped.contains(packageName)) { + print('$packageName was skipped with the --skip flag.'); + print('\n\n'); + continue; + } + for (Directory example in getExamplesForPlugin(plugin)) { + // Look for the test scheme in the example app. + print('Look for target named: $_kTarget ...'); + final List findSchemeArgs = [ + '-project', + 'ios/Runner.xcodeproj', + '-list', + '-json' + ]; + final String completeFindSchemeCommand = + '$_kXcodeBuildCommand ${findSchemeArgs.join(' ')}'; + print(completeFindSchemeCommand); + final io.ProcessResult xcodeprojListResult = await processRunner + .run(_kXcodeBuildCommand, findSchemeArgs, workingDir: example); + if (xcodeprojListResult.exitCode != 0) { + print('Error occurred while running "$completeFindSchemeCommand":\n' + '${xcodeprojListResult.stderr}'); + failingPackages.add(packageName); + print('\n\n'); + continue; + } + + final String xcodeprojListOutput = xcodeprojListResult.stdout; + Map xcodeprojListOutputJson = + jsonDecode(xcodeprojListOutput); + if (!xcodeprojListOutputJson['project']['targets'].contains(target)) { + failingPackages.add(packageName); + print('$target not configured for $packageName, test failed.'); + print( + 'Please check the scheme for the test target if it matches the name $target.\n' + 'If this plugin does not have an XCTest target, use the $_kSkip flag in the $name command to skip the plugin.'); + print('\n\n'); + continue; + } + // Found the scheme, running tests + print('Running XCTests:$target for $packageName ...'); + final List xctestArgs = [ + 'test', + '-workspace', + 'ios/Runner.xcworkspace', + '-scheme', + target, + '-destination', + destination, + 'CODE_SIGN_IDENTITY=""', + 'CODE_SIGNING_REQUIRED=NO' + ]; + final String completeTestCommand = + '$_kXcodeBuildCommand ${xctestArgs.join(' ')}'; + print(completeTestCommand); + final int exitCode = await processRunner + .runAndStream(_kXcodeBuildCommand, xctestArgs, workingDir: example); + if (exitCode == 0) { + print('Successfully ran xctest for $packageName'); + } else { + failingPackages.add(packageName); + } + } + } + + // Command end, print reports. + if (failingPackages.isEmpty) { + print("All XCTests have passed!"); + } else { + print( + 'The following packages are failing XCTests (see above for details):'); + for (String package in failingPackages) { + print(' * $package'); + } + throw ToolExit(1); + } + } + + Future _findAvailableIphoneSimulator() async { + // Find the first available destination if not specified. + final List findSimulatorsArguments = [ + 'simctl', + 'list', + '--json' + ]; + final String findSimulatorCompleteCommand = + '$_kXCRunCommand ${findSimulatorsArguments.join(' ')}'; + print('Looking for available simulators...'); + print(findSimulatorCompleteCommand); + final io.ProcessResult findSimulatorsResult = + await processRunner.run(_kXCRunCommand, findSimulatorsArguments); + if (findSimulatorsResult.exitCode != 0) { + print('Error occurred while running "$findSimulatorCompleteCommand":\n' + '${findSimulatorsResult.stderr}'); + throw ToolExit(1); + } + final Map simulatorListJson = + jsonDecode(findSimulatorsResult.stdout); + final List runtimes = simulatorListJson['runtimes']; + final Map devices = simulatorListJson['devices']; + if (runtimes.isEmpty || devices.isEmpty) { + return null; + } + String id; + // Looking for runtimes, trying to find one with highest OS version. + for (Map runtimeMap in runtimes.reversed) { + if (!runtimeMap['name'].contains('iOS')) { + continue; + } + final String runtimeID = runtimeMap['identifier']; + final List devicesForRuntime = devices[runtimeID]; + if (devicesForRuntime.isEmpty) { + continue; + } + // Looking for runtimes, trying to find latest version of device. + for (Map device in devicesForRuntime.reversed) { + if (device['availabilityError'] != null || + (device['isAvailable'] as bool == false)) { + continue; + } + id = device['udid']; + print('device selected: $device'); + return id; + } + } + return null; + } +} diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml new file mode 100644 index 000000000000..d9fce4ad26a7 --- /dev/null +++ b/script/tool/pubspec.yaml @@ -0,0 +1,25 @@ +name: flutter_plugin_tools +description: Productivity utils for hosting multiple plugins within one repository. +publish_to: 'none' + +dependencies: + args: "^1.4.3" + path: "^1.6.1" + http: "^0.12.1" + async: "^2.0.7" + yaml: "^2.1.15" + quiver: "^2.0.2" + pub_semver: ^1.4.2 + colorize: ^2.0.0 + git: ^1.0.0 + platform: ^2.2.0 + pubspec_parse: "^0.1.4" + test: ^1.6.4 + meta: ^1.1.7 + file: ^5.0.10 + uuid: ^2.0.4 + http_multi_server: ^2.2.0 + collection: 1.14.13 + +environment: + sdk: ">=2.3.0 <3.0.0"