| // 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')}'; |
| } |
| } |