| // Copyright 2013 The Flutter Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import 'dart:async'; | |
| import 'package:colorize/colorize.dart'; | |
| import 'package:file/file.dart'; | |
| import 'package:git/git.dart'; | |
| import 'package:path/path.dart' as p; | |
| import 'package:platform/platform.dart'; | |
| import 'package:pub_semver/pub_semver.dart'; | |
| import 'core.dart'; | |
| import 'package_command.dart'; | |
| import 'process_runner.dart'; | |
| import 'repository_package.dart'; | |
| /// Enumeration options for package looping commands. | |
| enum PackageLoopingType { | |
| /// Only enumerates the top level packages, without including any of their | |
| /// subpackages. | |
| topLevelOnly, | |
| /// Enumerates the top level packages and any example packages they contain. | |
| includeExamples, | |
| /// Enumerates all packages recursively, including both example and | |
| /// non-example subpackages. | |
| includeAllSubpackages, | |
| } | |
| /// Possible outcomes of a command run for a package. | |
| enum RunState { | |
| /// The command succeeded for the package. | |
| succeeded, | |
| /// The command was skipped for the package. | |
| skipped, | |
| /// The command was skipped for the package because it was explicitly excluded | |
| /// in the command arguments. | |
| excluded, | |
| /// The command failed for the package. | |
| failed, | |
| } | |
| /// The result of a [runForPackage] call. | |
| class PackageResult { | |
| /// A successful result. | |
| PackageResult.success() : this._(RunState.succeeded); | |
| /// A run that was skipped as explained in [reason]. | |
| PackageResult.skip(String reason) | |
| : this._(RunState.skipped, <String>[reason]); | |
| /// A run that was excluded by the command invocation. | |
| PackageResult.exclude() : this._(RunState.excluded); | |
| /// A run that failed. | |
| /// | |
| /// If [errors] are provided, they will be listed in the summary, otherwise | |
| /// the summary will simply show that the package failed. | |
| PackageResult.fail([List<String> errors = const <String>[]]) | |
| : this._(RunState.failed, errors); | |
| const PackageResult._(this.state, [this.details = const <String>[]]); | |
| /// The state the package run completed with. | |
| final RunState state; | |
| /// Information about the result: | |
| /// - For `succeeded`, this is empty. | |
| /// - For `skipped`, it contains a single entry describing why the run was | |
| /// skipped. | |
| /// - For `failed`, it contains zero or more specific error details to be | |
| /// shown in the summary. | |
| final List<String> details; | |
| } | |
| /// An abstract base class for a command that iterates over a set of packages | |
| /// controlled by a standard set of flags, running some actions on each package, | |
| /// and collecting and reporting the success/failure of those actions. | |
| abstract class PackageLoopingCommand extends PackageCommand { | |
| /// Creates a command to operate on [packagesDir] with the given environment. | |
| PackageLoopingCommand( | |
| Directory packagesDir, { | |
| ProcessRunner processRunner = const ProcessRunner(), | |
| Platform platform = const LocalPlatform(), | |
| GitDir? gitDir, | |
| }) : super(packagesDir, | |
| processRunner: processRunner, platform: platform, gitDir: gitDir) { | |
| argParser.addOption( | |
| _skipByFlutterVersionArg, | |
| help: 'Skip any packages that require a Flutter version newer than ' | |
| 'the provided version.', | |
| ); | |
| argParser.addOption( | |
| _skipByDartVersionArg, | |
| help: 'Skip any packages that require a Dart version newer than ' | |
| 'the provided version.', | |
| ); | |
| } | |
| static const String _skipByFlutterVersionArg = | |
| 'skip-if-not-supporting-flutter-version'; | |
| static const String _skipByDartVersionArg = | |
| 'skip-if-not-supporting-dart-version'; | |
| /// Packages that had at least one [logWarning] call. | |
| final Set<PackageEnumerationEntry> _packagesWithWarnings = | |
| <PackageEnumerationEntry>{}; | |
| /// Number of warnings that happened outside of a [runForPackage] call. | |
| int _otherWarningCount = 0; | |
| /// The package currently being run by [runForPackage]. | |
| PackageEnumerationEntry? _currentPackageEntry; | |
| /// Called during [run] before any calls to [runForPackage]. This provides an | |
| /// opportunity to fail early if the command can't be run (e.g., because the | |
| /// arguments are invalid), and to set up any run-level state. | |
| Future<void> initializeRun() async {} | |
| /// Returns the packages to process. By default, this returns the packages | |
| /// defined by the standard tooling flags and the [inculdeSubpackages] option, | |
| /// but can be overridden for custom package enumeration. | |
| /// | |
| /// Note: Consistent behavior across commands whenever possibel is a goal for | |
| /// this tool, so this should be overridden only in rare cases. | |
| Stream<PackageEnumerationEntry> getPackagesToProcess() async* { | |
| switch (packageLoopingType) { | |
| case PackageLoopingType.topLevelOnly: | |
| yield* getTargetPackages(filterExcluded: false); | |
| break; | |
| case PackageLoopingType.includeExamples: | |
| await for (final PackageEnumerationEntry packageEntry | |
| in getTargetPackages(filterExcluded: false)) { | |
| yield packageEntry; | |
| yield* Stream<PackageEnumerationEntry>.fromIterable(packageEntry | |
| .package | |
| .getExamples() | |
| .map((RepositoryPackage package) => PackageEnumerationEntry( | |
| package, | |
| excluded: packageEntry.excluded))); | |
| } | |
| break; | |
| case PackageLoopingType.includeAllSubpackages: | |
| yield* getTargetPackagesAndSubpackages(filterExcluded: false); | |
| break; | |
| } | |
| } | |
| /// Runs the command for [package], returning a list of errors. | |
| /// | |
| /// Errors may either be an empty string if there is no context that should | |
| /// be included in the final error summary (e.g., a command that only has a | |
| /// single failure mode), or strings that should be listed for that package | |
| /// in the final summary. An empty list indicates success. | |
| Future<PackageResult> runForPackage(RepositoryPackage package); | |
| /// Called during [run] after all calls to [runForPackage]. This provides an | |
| /// opportunity to do any cleanup of run-level state. | |
| Future<void> completeRun() async {} | |
| /// If [captureOutput], this is called just before exiting with all captured | |
| /// [output]. | |
| Future<void> handleCapturedOutput(List<String> output) async {} | |
| /// Whether or not the output (if any) of [runForPackage] is long, or short. | |
| /// | |
| /// This changes the logging that happens at the start of each package's | |
| /// run; long output gets a banner-style message to make it easier to find, | |
| /// while short output gets a single-line entry. | |
| /// | |
| /// When this is false, runForPackage output should be indented if possible, | |
| /// to make the output structure easier to follow. | |
| bool get hasLongOutput => true; | |
| /// Whether to loop over top-level packages only, or some or all of their | |
| /// sub-packages as well. | |
| PackageLoopingType get packageLoopingType => PackageLoopingType.topLevelOnly; | |
| /// The text to output at the start when reporting one or more failures. | |
| /// This will be followed by a list of packages that reported errors, with | |
| /// the per-package details if any. | |
| /// | |
| /// This only needs to be overridden if the summary should provide extra | |
| /// context. | |
| String get failureListHeader => 'The following packages had errors:'; | |
| /// The text to output at the end when reporting one or more failures. This | |
| /// will be printed immediately after the a list of packages that reported | |
| /// errors. | |
| /// | |
| /// This only needs to be overridden if the summary should provide extra | |
| /// context. | |
| String get failureListFooter => 'See above for full details.'; | |
| /// The summary string used for a successful run in the final overview output. | |
| String get successSummaryMessage => 'ran'; | |
| /// If true, all printing (including the summary) will be redirected to a | |
| /// buffer, and provided in a call to [handleCapturedOutput] at the end of | |
| /// the run. | |
| /// | |
| /// Capturing output will disable any colorizing of output from this base | |
| /// class. | |
| bool get captureOutput => false; | |
| // ---------------------------------------- | |
| /// Logs that a warning occurred, and prints `warningMessage` in yellow. | |
| /// | |
| /// Warnings are not surfaced in CI summaries, so this is only useful for | |
| /// highlighting something when someone is already looking though the log | |
| /// messages. DO NOT RELY on someone noticing a warning; instead, use it for | |
| /// things that might be useful to someone debugging an unexpected result. | |
| void logWarning(String warningMessage) { | |
| _printColorized(warningMessage, Styles.YELLOW); | |
| if (_currentPackageEntry != null) { | |
| _packagesWithWarnings.add(_currentPackageEntry!); | |
| } else { | |
| ++_otherWarningCount; | |
| } | |
| } | |
| /// Returns the relative path from [from] to [entity] in Posix style. | |
| /// | |
| /// This should be used when, for example, printing package-relative paths in | |
| /// status or error messages. | |
| String getRelativePosixPath( | |
| FileSystemEntity entity, { | |
| required Directory from, | |
| }) => | |
| p.posix.joinAll(path.split(path.relative(entity.path, from: from.path))); | |
| /// The suggested indentation for printed output. | |
| String get indentation => hasLongOutput ? '' : ' '; | |
| // ---------------------------------------- | |
| @override | |
| Future<void> run() async { | |
| bool succeeded; | |
| if (captureOutput) { | |
| final List<String> output = <String>[]; | |
| final ZoneSpecification logSwitchSpecification = ZoneSpecification( | |
| print: (Zone self, ZoneDelegate parent, Zone zone, String message) { | |
| output.add(message); | |
| }); | |
| succeeded = await runZoned<Future<bool>>(_runInternal, | |
| zoneSpecification: logSwitchSpecification); | |
| await handleCapturedOutput(output); | |
| } else { | |
| succeeded = await _runInternal(); | |
| } | |
| if (!succeeded) { | |
| throw ToolExit(exitCommandFoundErrors); | |
| } | |
| } | |
| Future<bool> _runInternal() async { | |
| _packagesWithWarnings.clear(); | |
| _otherWarningCount = 0; | |
| _currentPackageEntry = null; | |
| final String minFlutterVersionArg = getStringArg(_skipByFlutterVersionArg); | |
| final Version? minFlutterVersion = minFlutterVersionArg.isEmpty | |
| ? null | |
| : Version.parse(minFlutterVersionArg); | |
| final String minDartVersionArg = getStringArg(_skipByDartVersionArg); | |
| final Version? minDartVersion = | |
| minDartVersionArg.isEmpty ? null : Version.parse(minDartVersionArg); | |
| final DateTime runStart = DateTime.now(); | |
| await initializeRun(); | |
| final List<PackageEnumerationEntry> targetPackages = | |
| await getPackagesToProcess().toList(); | |
| final Map<PackageEnumerationEntry, PackageResult> results = | |
| <PackageEnumerationEntry, PackageResult>{}; | |
| for (final PackageEnumerationEntry entry in targetPackages) { | |
| final DateTime packageStart = DateTime.now(); | |
| _currentPackageEntry = entry; | |
| _printPackageHeading(entry, startTime: runStart); | |
| // Command implementations should never see excluded packages; they are | |
| // included at this level only for logging. | |
| if (entry.excluded) { | |
| results[entry] = PackageResult.exclude(); | |
| continue; | |
| } | |
| PackageResult result; | |
| try { | |
| result = await _runForPackageIfSupported(entry.package, | |
| minFlutterVersion: minFlutterVersion, | |
| minDartVersion: minDartVersion); | |
| } catch (e, stack) { | |
| printError(e.toString()); | |
| printError(stack.toString()); | |
| result = PackageResult.fail(<String>['Unhandled exception']); | |
| } | |
| if (result.state == RunState.skipped) { | |
| _printColorized('${indentation}SKIPPING: ${result.details.first}', | |
| Styles.DARK_GRAY); | |
| } | |
| results[entry] = result; | |
| // Only log an elapsed time for long output; for short output, comparing | |
| // the relative timestamps of successive entries should be trivial. | |
| if (shouldLogTiming && hasLongOutput) { | |
| final Duration elapsedTime = DateTime.now().difference(packageStart); | |
| _printColorized( | |
| '\n[${entry.package.displayName} completed in ' | |
| '${elapsedTime.inMinutes}m ${elapsedTime.inSeconds % 60}s]', | |
| Styles.DARK_GRAY); | |
| } | |
| } | |
| _currentPackageEntry = null; | |
| completeRun(); | |
| print('\n'); | |
| // If there were any errors reported, summarize them and exit. | |
| if (results.values | |
| .any((PackageResult result) => result.state == RunState.failed)) { | |
| _printFailureSummary(targetPackages, results); | |
| return false; | |
| } | |
| // Otherwise, print a summary of what ran for ease of auditing that all the | |
| // expected tests ran. | |
| _printRunSummary(targetPackages, results); | |
| print('\n'); | |
| _printSuccess('No issues found!'); | |
| return true; | |
| } | |
| /// Returns the result of running [runForPackage] if the package is supported | |
| /// by any run constraints, or a skip result if it is not. | |
| Future<PackageResult> _runForPackageIfSupported( | |
| RepositoryPackage package, { | |
| Version? minFlutterVersion, | |
| Version? minDartVersion, | |
| }) async { | |
| if (minFlutterVersion != null) { | |
| final Pubspec pubspec = package.parsePubspec(); | |
| final VersionConstraint? flutterConstraint = | |
| pubspec.environment?['flutter']; | |
| if (flutterConstraint != null && | |
| !flutterConstraint.allows(minFlutterVersion)) { | |
| return PackageResult.skip( | |
| 'Does not support Flutter $minFlutterVersion'); | |
| } | |
| } | |
| if (minDartVersion != null) { | |
| final Pubspec pubspec = package.parsePubspec(); | |
| final VersionConstraint? dartConstraint = pubspec.environment?['sdk']; | |
| if (dartConstraint != null && !dartConstraint.allows(minDartVersion)) { | |
| return PackageResult.skip('Does not support Dart $minDartVersion'); | |
| } | |
| } | |
| return runForPackage(package); | |
| } | |
| void _printSuccess(String message) { | |
| captureOutput ? print(message) : printSuccess(message); | |
| } | |
| void _printError(String message) { | |
| captureOutput ? print(message) : printError(message); | |
| } | |
| /// Prints the status message indicating that the command is being run for | |
| /// [package]. | |
| /// | |
| /// Something is always printed to make it easier to distinguish between | |
| /// a command running for a package and producing no output, and a command | |
| /// not having been run for a package. | |
| void _printPackageHeading(PackageEnumerationEntry entry, | |
| {required DateTime startTime}) { | |
| final String packageDisplayName = entry.package.displayName; | |
| String heading = entry.excluded | |
| ? 'Not running for $packageDisplayName; excluded' | |
| : 'Running for $packageDisplayName'; | |
| if (shouldLogTiming) { | |
| final Duration relativeTime = DateTime.now().difference(startTime); | |
| final String timeString = _formatDurationAsRelativeTime(relativeTime); | |
| heading = | |
| hasLongOutput ? '$heading [@$timeString]' : '[$timeString] $heading'; | |
| } | |
| if (hasLongOutput) { | |
| heading = ''' | |
| ============================================================ | |
| || $heading | |
| ============================================================ | |
| '''; | |
| } else if (!entry.excluded) { | |
| heading = '$heading...'; | |
| } | |
| _printColorized(heading, entry.excluded ? Styles.DARK_GRAY : Styles.CYAN); | |
| } | |
| /// Prints a summary of packges run, packages skipped, and warnings. | |
| void _printRunSummary(List<PackageEnumerationEntry> packages, | |
| Map<PackageEnumerationEntry, PackageResult> results) { | |
| final Set<PackageEnumerationEntry> skippedPackages = results.entries | |
| .where((MapEntry<PackageEnumerationEntry, PackageResult> entry) => | |
| entry.value.state == RunState.skipped) | |
| .map((MapEntry<PackageEnumerationEntry, PackageResult> entry) => | |
| entry.key) | |
| .toSet(); | |
| final int skipCount = skippedPackages.length + | |
| packages | |
| .where((PackageEnumerationEntry package) => package.excluded) | |
| .length; | |
| // Split the warnings into those from packages that ran, and those that | |
| // were skipped. | |
| final Set<PackageEnumerationEntry> skippedPackagesWithWarnings = | |
| _packagesWithWarnings.intersection(skippedPackages); | |
| final int skippedWarningCount = skippedPackagesWithWarnings.length; | |
| final int runWarningCount = | |
| _packagesWithWarnings.length - skippedWarningCount; | |
| final String runWarningSummary = | |
| runWarningCount > 0 ? ' ($runWarningCount with warnings)' : ''; | |
| final String skippedWarningSummary = | |
| runWarningCount > 0 ? ' ($skippedWarningCount with warnings)' : ''; | |
| print('------------------------------------------------------------'); | |
| if (hasLongOutput) { | |
| _printPerPackageRunOverview(packages, skipped: skippedPackages); | |
| } | |
| print( | |
| 'Ran for ${packages.length - skipCount} package(s)$runWarningSummary'); | |
| if (skipCount > 0) { | |
| print('Skipped $skipCount package(s)$skippedWarningSummary'); | |
| } | |
| if (_otherWarningCount > 0) { | |
| print('$_otherWarningCount warnings not associated with a package'); | |
| } | |
| } | |
| /// Prints a one-line-per-package overview of the run results for each | |
| /// package. | |
| void _printPerPackageRunOverview( | |
| List<PackageEnumerationEntry> packageEnumeration, | |
| {required Set<PackageEnumerationEntry> skipped}) { | |
| print('Run overview:'); | |
| for (final PackageEnumerationEntry entry in packageEnumeration) { | |
| final bool hadWarning = _packagesWithWarnings.contains(entry); | |
| Styles style; | |
| String summary; | |
| if (entry.excluded) { | |
| summary = 'excluded'; | |
| style = Styles.DARK_GRAY; | |
| } else if (skipped.contains(entry)) { | |
| summary = 'skipped'; | |
| style = hadWarning ? Styles.LIGHT_YELLOW : Styles.DARK_GRAY; | |
| } else { | |
| summary = successSummaryMessage; | |
| style = hadWarning ? Styles.YELLOW : Styles.GREEN; | |
| } | |
| if (hadWarning) { | |
| summary += ' (with warning)'; | |
| } | |
| if (!captureOutput) { | |
| summary = (Colorize(summary)..apply(style)).toString(); | |
| } | |
| print(' ${entry.package.displayName} - $summary'); | |
| } | |
| print(''); | |
| } | |
| /// Prints a summary of all of the failures from [results]. | |
| void _printFailureSummary(List<PackageEnumerationEntry> packageEnumeration, | |
| Map<PackageEnumerationEntry, PackageResult> results) { | |
| const String indentation = ' '; | |
| _printError(failureListHeader); | |
| for (final PackageEnumerationEntry entry in packageEnumeration) { | |
| final PackageResult result = results[entry]!; | |
| if (result.state == RunState.failed) { | |
| final String errorIndentation = indentation * 2; | |
| String errorDetails = ''; | |
| if (result.details.isNotEmpty) { | |
| errorDetails = | |
| ':\n$errorIndentation${result.details.join('\n$errorIndentation')}'; | |
| } | |
| _printError('$indentation${entry.package.displayName}$errorDetails'); | |
| } | |
| } | |
| _printError(failureListFooter); | |
| } | |
| /// Prints [message] in [color] unless [captureOutput] is set, in which case | |
| /// it is printed without color. | |
| void _printColorized(String message, Styles color) { | |
| if (captureOutput) { | |
| print(message); | |
| } else { | |
| print(Colorize(message)..apply(color)); | |
| } | |
| } | |
| /// Returns a duration [d] formatted as minutes:seconds. Does not use hours, | |
| /// since time logging is primarily intended for CI, where durations should | |
| /// always be less than an hour. | |
| String _formatDurationAsRelativeTime(Duration d) { | |
| return '${d.inMinutes}:${(d.inSeconds % 60).toString().padLeft(2, '0')}'; | |
| } | |
| } |