| // Copyright 2013 The Flutter Authors | |
| // 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:pub_semver/pub_semver.dart'; | |
| import 'core.dart'; | |
| import 'file_utils.dart'; | |
| import 'git_version_finder.dart'; | |
| import 'output_utils.dart'; | |
| import 'package_command.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(super.packagesDir, {super.processRunner, super.platform, super.gitDir}) { | |
| argParser.addOption( | |
| _skipByFlutterVersionArg, | |
| help: | |
| 'Skip any packages that require a Flutter version newer than ' | |
| 'the provided version, or a Dart version newer than the ' | |
| 'corresponding Dart 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; | |
| /// When running against a merge base, this is called before [initializeRun] | |
| /// for every changed file, to see if that file is a file that is guaranteed | |
| /// *not* to require running this command. | |
| /// | |
| /// If every changed file returns true, then the command will be skipped. | |
| /// Because this causes tests not to run, subclasses should be very | |
| /// consevative about what returns true; for anything borderline it is much | |
| /// better to err on the side of running tests unnecessarily than to risk | |
| /// losing test coverage. | |
| /// | |
| /// [path] is a POSIX-style path regardless of the host platforrm, and is | |
| /// relative to the git repo root. | |
| bool shouldIgnoreFile(String path) => false; | |
| /// 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); | |
| 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), | |
| ), | |
| ); | |
| } | |
| case PackageLoopingType.includeAllSubpackages: | |
| yield* getTargetPackagesAndSubpackages(filterExcluded: false); | |
| } | |
| } | |
| /// 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) { | |
| printWarning(warningMessage); | |
| 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}) => | |
| relativePosixPath(entity, from: from, platformContext: path); | |
| /// The suggested indentation for printed output. | |
| String get indentation => hasLongOutput ? '' : ' '; | |
| /// The base SHA used to calculate changed files. | |
| /// | |
| /// This is guaranteed to be populated before [initializeRun] is called. | |
| late String baseSha; | |
| /// The repo-relative paths (using Posix separators) of all files changed | |
| /// relative to [baseSha]. | |
| /// | |
| /// This is guaranteed to be populated before [initializeRun] is called. | |
| late List<String> changedFiles; | |
| // ---------------------------------------- | |
| @override | |
| Future<void> run() async { | |
| bool succeeded; | |
| if (captureOutput) { | |
| final output = <String>[]; | |
| final 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); | |
| // Use the explicit Dart min if provided, otherwise fall back to the | |
| // version corresponding to the Flutter min if that was provided. | |
| final String minDartVersionArg = getStringArg(_skipByDartVersionArg); | |
| final Version? minDartVersion = minDartVersionArg.isNotEmpty | |
| ? Version.parse(minDartVersionArg) | |
| : (minFlutterVersion == null ? null : getDartSdkForFlutterSdk(minFlutterVersion)); | |
| final runStart = DateTime.now(); | |
| // Populate the list of changed files for subclasses to use. | |
| if (getBoolArg(PackageCommand.runOnDirtyPackagesArg)) { | |
| baseSha = 'HEAD'; | |
| final gitVersionFinder = GitVersionFinder(await gitDir, baseSha: baseSha); | |
| changedFiles = await gitVersionFinder.getChangedFiles(includeUncommitted: true); | |
| } else if (getBoolArg(PackageCommand.runOnStagedPackagesArg)) { | |
| baseSha = 'HEAD'; | |
| final gitVersionFinder = GitVersionFinder(await gitDir, baseSha: baseSha); | |
| changedFiles = await gitVersionFinder.getStagedFiles(); | |
| } else { | |
| final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); | |
| baseSha = await gitVersionFinder.getBaseSha(); | |
| changedFiles = await gitVersionFinder.getChangedFiles(); | |
| } | |
| // Check whether the command needs to run. | |
| if (changedFiles.isNotEmpty && changedFiles.every(shouldIgnoreFile)) { | |
| _printColorized( | |
| 'SKIPPING ALL PACKAGES: No changed files affect this command', | |
| Styles.DARK_GRAY, | |
| ); | |
| return true; | |
| } | |
| await initializeRun(); | |
| final List<PackageEnumerationEntry> targetPackages = await getPackagesToProcess().toList(); | |
| final results = <PackageEnumerationEntry, PackageResult>{}; | |
| for (final entry in targetPackages) { | |
| final 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; | |
| await 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; | |
| var 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 runWarningSummary = runWarningCount > 0 ? ' ($runWarningCount with warnings)' : ''; | |
| final 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 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 = colorizeString(summary, style); | |
| } | |
| 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 indentation = ' '; | |
| _printError(failureListHeader); | |
| for (final entry in packageEnumeration) { | |
| final PackageResult result = results[entry]!; | |
| if (result.state == RunState.failed) { | |
| final String errorIndentation = indentation * 2; | |
| var 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(colorizeString(message, 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')}'; | |
| } | |
| } |