Move plugin tools code (#3544)

This commit is contained in:
Emmanuel Garcia
2021-02-12 13:46:44 -08:00
committed by GitHub
commit d3b50950ba
17 changed files with 2720 additions and 0 deletions

View File

@ -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: <String>[]);
}
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<Null> run() async {
checkSharding();
print('Verifying analysis settings...');
final List<FileSystemEntity> 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', <String>['global', 'activate', 'tuneup'],
workingDir: packagesDir, exitOnError: true);
await for (Directory package in getPackages()) {
if (isFlutterPackage(package, fileSystem)) {
await processRunner.runAndStream('flutter', <String>['packages', 'get'],
workingDir: package, exitOnError: true);
} else {
await processRunner.runAndStream('pub', <String>['get'],
workingDir: package, exitOnError: true);
}
}
final List<String> failingPackages = <String>[];
await for (Directory package in getPlugins()) {
final int exitCode = await processRunner.runAndStream(
'pub', <String>['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!');
}
}

View File

@ -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<Null> 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<String> failingPackages = <String>[];
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,
<String>[
'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, <String>['pub', 'get'],
workingDir: example);
if (exitCode != 0) {
failingPackages.add('$packageName (macos)');
} else {
exitCode = await processRunner.runAndStream(
flutterCommand,
<String>[
'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,
<String>[
'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,
<String>[
'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,
<String>[
'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!');
}
}

View File

@ -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<Null> {
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: <String>[],
);
}
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<Directory> 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<Directory> 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<Directory> _getAllPlugins() async* {
final Set<String> plugins = Set<String>.from(argResults[_pluginsArg]);
final Set<String> excludedPlugins =
Set<String>.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<Directory> getExamples() =>
getPlugins().expand<Directory>(getExamplesForPlugin);
/// Returns all Dart package folders (typically, plugin + example) of the
/// plugins involved in this command execution.
Stream<Directory> getPackages() async* {
await for (Directory plugin in getPlugins()) {
yield plugin;
yield* plugin
.list(recursive: true, followLinks: false)
.where(_isDartPackage)
.cast<Directory>();
}
}
/// Returns the files contained, recursively, within the plugins
/// involved in this command execution.
Stream<File> getFiles() {
return getPlugins().asyncExpand<File>((Directory folder) => folder
.list(recursive: true, followLinks: false)
.where((FileSystemEntity entity) => entity is File)
.cast<File>());
}
/// 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<Directory> getExamplesForPlugin(Directory plugin) {
final Directory exampleFolder =
fileSystem.directory(p.join(plugin.path, 'example'));
if (!exampleFolder.existsSync()) {
return <Directory>[];
}
if (isFlutterPackage(exampleFolder, fileSystem)) {
return <Directory>[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<Directory>();
}
}
/// 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<int> runAndStream(
String executable,
List<String> 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<io.ProcessResult> run(String executable, List<String> 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<io.Process> start(String executable, List<String> 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<io.ProcessResult> runAndExitOnError(
String executable,
List<String> 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<String> args,
{Directory workingDir}) {
final String workdir = workingDir == null ? '' : ' in ${workingDir.path}';
return 'ERROR: Unable to execute "$executable ${args.join(' ')}"$workdir.';
}
}

View File

@ -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<Null> run() async {
final int exitCode = await _createPlugin();
if (exitCode != 0) {
throw ToolExit(exitCode);
}
await Future.wait(<Future<void>>[
_genPubspecWithAllPlugins(),
_updateAppGradle(),
_updateManifest(),
]);
}
Future<int> _createPlugin() async {
final io.ProcessResult result = io.Process.runSync(
'flutter',
<String>[
'create',
'--template=app',
'--project-name=all_plugins',
'--android-language=java',
'./all_plugins',
],
);
print(result.stdout);
print(result.stderr);
return result.exitCode;
}
Future<void> _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<void> _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(
'<uses-sdk tools:overrideLibrary="io.flutter.plugins.camera"/>',
);
} else {
newManifest.writeln(line);
}
}
manifestFile.writeAsStringSync(newManifest.toString());
}
Future<void> _genPubspecWithAllPlugins() async {
final Map<String, PathDependency> 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: <String, VersionConstraint>{
'sdk': VersionConstraint.compatibleWith(
Version.parse('2.0.0'),
),
},
dependencies: <String, Dependency>{
'flutter': SdkDependency('flutter'),
}..addAll(pluginDeps),
devDependencies: <String, Dependency>{
'flutter_test': SdkDependency('flutter'),
},
dependencyOverrides: pluginDeps,
);
final File pubspecFile =
fileSystem.file(p.join('all_plugins', 'pubspec.yaml'));
pubspecFile.writeAsStringSync(_pubspecToString(pubspec));
}
Future<Map<String, PathDependency>> _getValidPathDependencies() async {
final Map<String, PathDependency> pathDependencies =
<String, PathDependency>{};
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<String, dynamic> values) {
final StringBuffer buffer = StringBuffer();
for (MapEntry<String, dynamic> 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();
}
}

View File

@ -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<Null> run() async {
checkSharding();
final List<String> failingTests = <String>[];
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<String> targetPaths = <String>[];
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<String> driveArgs = <String>['drive'];
final String enableExperiment = argResults[kEnableExperiment];
if (enableExperiment.isNotEmpty) {
driveArgs.add('--enable-experiment=$enableExperiment');
}
if (isLinux && isLinuxPlugin(plugin, fileSystem)) {
driveArgs.addAll(<String>[
'-d',
'linux',
]);
}
if (isMacos && isMacOsPlugin(plugin, fileSystem)) {
driveArgs.addAll(<String>[
'-d',
'macos',
]);
}
if (isWindows && isWindowsPlugin(plugin, fileSystem)) {
driveArgs.addAll(<String>[
'-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<bool> 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;
}
}

View File

@ -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: <String>[
'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<void> _firebaseProjectConfigured;
Future<void> _configureFirebaseProject() async {
if (_firebaseProjectConfigured != null) {
return _firebaseProjectConfigured.future;
} else {
_firebaseProjectConfigured = Completer<void>();
}
await processRunner.runAndExitOnError('gcloud', <String>[
'auth',
'activate-service-account',
'--key-file=${argResults['service-key']}',
]);
int exitCode = await processRunner.runAndStream('gcloud', <String>[
'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<Null> run() async {
checkSharding();
final Stream<Directory> packagesWithTests = getPackages().where(
(Directory d) =>
isFlutterPackage(d, fileSystem) &&
fileSystem
.directory(p.join(
d.path, 'example', 'android', 'app', 'src', 'androidTest'))
.existsSync());
final List<String> failingPackages = <String>[];
final List<String> missingFlutterBuild = <String>[];
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',
<String>[
'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),
<String>[
'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<FileSystemEntity> 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<FileSystemEntity> testFiles = testDir
.listSync(recursive: true, followLinks: true)
.where(isE2ETest)
.toList();
for (FileSystemEntity test in testFiles) {
exitCode = await processRunner.runAndStream(
p.join(androidDirectory.path, _gradleWrapper),
<String>[
'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<String> args = <String>[
'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(<String>['--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!');
}
}

View File

@ -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<Null> 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<bool> _didModifyAnything() async {
final io.ProcessResult modifiedFiles = await processRunner
.runAndExitOnError('git', <String>['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 <<DONE');
final io.ProcessResult diff = await processRunner
.runAndExitOnError('git', <String>['diff'], workingDir: packagesDir);
print(diff.stdout);
print('DONE');
return true;
}
Future<Null> _formatCppAndObjectiveC() async {
print('Formatting all .cc, .cpp, .mm, .m, and .h files...');
final Iterable<String> allFiles = <String>[]
..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<List<String>> batches = partition(allFiles, 100);
for (List<String> batch in batches) {
await processRunner.runAndStream(argResults['clang-format'],
<String>['-i', '--style=Google']..addAll(batch),
workingDir: packagesDir, exitOnError: true);
}
}
Future<Null> _formatJava(String googleFormatterPath) async {
print('Formatting all .java files...');
final Iterable<String> javaFiles = await _getFilesWithExtension('.java');
await processRunner.runAndStream('java',
<String>['-jar', googleFormatterPath, '--replace']..addAll(javaFiles),
workingDir: packagesDir, exitOnError: true);
}
Future<Null> _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<String> 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', <String>['format']..addAll(dartFiles),
workingDir: packagesDir, exitOnError: true);
}
}
Future<List<String>> _getFilesWithExtension(String extension) async =>
getFiles()
.where((File file) => p.extension(file.path) == extension)
.map((File file) => file.path)
.toList();
Future<String> _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;
}
}

View File

@ -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<Null> run() async {
checkSharding();
final Stream<Directory> examplesWithTests = getExamples().where(
(Directory d) =>
isFlutterPackage(d, fileSystem) &&
fileSystem
.directory(p.join(d.path, 'android', 'app', 'src', 'test'))
.existsSync());
final List<String> failingPackages = <String>[];
final List<String> missingFlutterBuild = <String>[];
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),
<String>['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!');
}
}

View File

@ -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<String> get aliases => <String>['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<Null> run() async {
if (!platform.isMacOS) {
_print('Detected platform is not macOS, skipping podspec lint');
return;
}
checkSharding();
await processRunner.runAndExitOnError('which', <String>['pod'],
workingDir: packagesDir);
_print('Starting podspec lint test');
final List<String> failingPlugins = <String>[];
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<List<File>> _podspecsToLint() async {
final List<File> 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<bool> _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<ProcessResult> _runPodLint(String podspecPath,
{bool runAnalyzer, bool libraryLint}) async {
final bool allowWarnings = argResults['ignore-warnings']
.contains(p.basenameWithoutExtension(podspecPath));
final List<String> arguments = <String>[
'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);
}
}

View File

@ -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: <String>[_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<Null> 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;
}
}
}

View File

@ -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<String> 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<Null> commandRunner = CommandRunner<Null>(
'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);
}

View File

@ -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 <package-name>-v<package-version>.
/// 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 <package-name>-v<semantic-version>. 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<String> _stdinSubscription;
@override
Future<Null> 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', <String>['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<void> _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<void> _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',
<String>[
'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<String> _verifyRemote(String remote) async {
final ProcessResult remoteInfo = await processRunner.runAndExitOnError(
'git', <String>['remote', 'get-url', remote],
workingDir: _packageDir);
return remoteInfo.stdout;
}
Future<void> _publish() async {
final List<String> publishFlags = argResults[_pubFlagsOption];
_print(
'Running `pub publish ${publishFlags.join(' ')}` in ${_packageDir.absolute.path}...\n');
final Process publish = await processRunner.start(
'flutter', <String>['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<void> _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', <String>['push', remote, tag],
workingDir: packagesDir);
}
}

View File

@ -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<Null> run() async {
checkSharding();
final List<String> failingPackages = <String>[];
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<String> args = <String>[
'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',
<String>['get'],
workingDir: packageDir,
);
if (exitCode == 0) {
exitCode = await processRunner.runAndStream(
'pub',
<String>[
'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!');
}
}

View File

@ -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<List<String>> getChangedPubSpecs() async {
final io.ProcessResult changedFilesCommand = await baseGitDir
.runCommand(<String>['diff', '--name-only', '$baseSha', 'HEAD']);
final List<String> changedFiles =
changedFilesCommand.stdout.toString().split('\n');
return changedFiles.where(isPubspec).toList();
}
Future<Version> getPackageVersion(String pubspecPath, String gitRef) async {
final io.ProcessResult gitShow =
await baseGitDir.runCommand(<String>['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<Version, NextVersionType> getAllowedNextVersions(
Version masterVersion, Version headVersion) {
final Version nextNullSafetyMajor =
getNextNullSafetyPreRelease(masterVersion, masterVersion.nextMajor);
final Version nextNullSafetyMinor =
getNextNullSafetyPreRelease(masterVersion, masterVersion.nextMinor);
final Map<Version, NextVersionType> allowedNextVersions =
<Version, NextVersionType>{
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<Null> 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<String> 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<Version, NextVersionType> 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!');
}
}

View File

@ -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<Null> 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<String> skipped = argResults[_kSkip];
List<String> failingPackages = <String>[];
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<String> findSchemeArgs = <String>[
'-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<String, dynamic> 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<String> xctestArgs = <String>[
'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<String> _findAvailableIphoneSimulator() async {
// Find the first available destination if not specified.
final List<String> findSimulatorsArguments = <String>[
'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<String, dynamic> simulatorListJson =
jsonDecode(findSimulatorsResult.stdout);
final List<dynamic> runtimes = simulatorListJson['runtimes'];
final Map<String, dynamic> 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<String, dynamic> runtimeMap in runtimes.reversed) {
if (!runtimeMap['name'].contains('iOS')) {
continue;
}
final String runtimeID = runtimeMap['identifier'];
final List<dynamic> devicesForRuntime = devices[runtimeID];
if (devicesForRuntime.isEmpty) {
continue;
}
// Looking for runtimes, trying to find latest version of device.
for (Map<String, dynamic> 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;
}
}