diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 3a9736c35b47..bc2a775db1f8 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,3 +1,8 @@ +## NEXT + +- Add `against-pub` flag for version-check, which allows the command to check version with pub. +- Add `machine` flag for publish-check, which replaces outputs to something parsable by machines. + ## 0.1.1 - Update the allowed third-party licenses for flutter/packages. diff --git a/script/tool/lib/src/common.dart b/script/tool/lib/src/common.dart index a63d606b35ca..1e864fcbc38a 100644 --- a/script/tool/lib/src/common.dart +++ b/script/tool/lib/src/common.dart @@ -11,6 +11,7 @@ import 'package:args/command_runner.dart'; import 'package:colorize/colorize.dart'; import 'package:file/file.dart'; import 'package:git/git.dart'; +import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.dart'; @@ -563,6 +564,98 @@ class ProcessRunner { } } +/// Finding version of [package] that is published on pub. +class PubVersionFinder { + /// Constructor. + /// + /// Note: you should manually close the [httpClient] when done using the finder. + PubVersionFinder({this.pubHost = defaultPubHost, @required this.httpClient}); + + /// The default pub host to use. + static const String defaultPubHost = 'https://pub.dev'; + + /// The pub host url, defaults to `https://pub.dev`. + final String pubHost; + + /// The http client. + /// + /// You should manually close this client when done using this finder. + final http.Client httpClient; + + /// Get the package version on pub. + Future getPackageVersion( + {@required String package}) async { + assert(package != null && package.isNotEmpty); + final Uri pubHostUri = Uri.parse(pubHost); + final Uri url = pubHostUri.replace(path: '/packages/$package.json'); + final http.Response response = await httpClient.get(url); + + if (response.statusCode == 404) { + return PubVersionFinderResponse( + versions: null, + result: PubVersionFinderResult.noPackageFound, + httpResponse: response); + } else if (response.statusCode != 200) { + return PubVersionFinderResponse( + versions: null, + result: PubVersionFinderResult.fail, + httpResponse: response); + } + final List versions = + (json.decode(response.body)['versions'] as List) + .map((final dynamic versionString) => + Version.parse(versionString as String)) + .toList(); + + return PubVersionFinderResponse( + versions: versions, + result: PubVersionFinderResult.success, + httpResponse: response); + } +} + +/// Represents a response for [PubVersionFinder]. +class PubVersionFinderResponse { + /// Constructor. + PubVersionFinderResponse({this.versions, this.result, this.httpResponse}) { + if (versions != null && versions.isNotEmpty) { + versions.sort((Version a, Version b) { + // TODO(cyanglaz): Think about how to handle pre-release version with [Version.prioritize]. + // https://github.com/flutter/flutter/issues/82222 + return b.compareTo(a); + }); + } + } + + /// The versions found in [PubVersionFinder]. + /// + /// This is sorted by largest to smallest, so the first element in the list is the largest version. + /// Might be `null` if the [result] is not [PubVersionFinderResult.success]. + final List versions; + + /// The result of the version finder. + final PubVersionFinderResult result; + + /// The response object of the http request. + final http.Response httpResponse; +} + +/// An enum representing the result of [PubVersionFinder]. +enum PubVersionFinderResult { + /// The version finder successfully found a version. + success, + + /// The version finder failed to find a valid version. + /// + /// This might due to http connection errors or user errors. + fail, + + /// The version finder failed to locate the package. + /// + /// This indicates the package is new. + noPackageFound, +} + /// Finding diffs based on `baseGitDir` and `baseSha`. class GitVersionFinder { /// Constructor diff --git a/script/tool/lib/src/publish_check_command.dart b/script/tool/lib/src/publish_check_command.dart index 0fb9dbad60a8..84503f4540c6 100644 --- a/script/tool/lib/src/publish_check_command.dart +++ b/script/tool/lib/src/publish_check_command.dart @@ -3,10 +3,14 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'dart:io' as io; import 'package:colorize/colorize.dart'; import 'package:file/file.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; import 'common.dart'; @@ -18,7 +22,10 @@ class PublishCheckCommand extends PluginCommand { Directory packagesDir, FileSystem fileSystem, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, fileSystem, processRunner: processRunner) { + this.httpClient, + }) : _pubVersionFinder = + PubVersionFinder(httpClient: httpClient ?? http.Client()), + super(packagesDir, fileSystem, processRunner: processRunner) { argParser.addFlag( _allowPrereleaseFlag, help: 'Allows the pre-release SDK warning to pass.\n' @@ -26,9 +33,29 @@ class PublishCheckCommand extends PluginCommand { 'the SDK constraint is a pre-release version, is ignored.', defaultsTo: false, ); + argParser.addFlag(_machineFlag, + help: 'Switch outputs to a machine readable JSON. \n' + 'The JSON contains a "status" field indicating the final status of the command, the possible values are:\n' + ' $_statusNeedsPublish: There is at least one package need to be published. They also passed all publish checks.\n' + ' $_statusMessageNoPublish: There are no packages needs to be published. Either no pubspec change detected or all versions have already been published.\n' + ' $_statusMessageError: Some error has occurred.', + defaultsTo: false, + negatable: true); } static const String _allowPrereleaseFlag = 'allow-pre-release'; + static const String _machineFlag = 'machine'; + static const String _statusNeedsPublish = 'needs-publish'; + static const String _statusMessageNoPublish = 'no-publish'; + static const String _statusMessageError = 'error'; + static const String _statusKey = 'status'; + static const String _humanMessageKey = 'humanMessage'; + + final List _validStatus = [ + _statusNeedsPublish, + _statusMessageNoPublish, + _statusMessageError + ]; @override final String name = 'publish-check'; @@ -37,31 +64,74 @@ class PublishCheckCommand extends PluginCommand { final String description = 'Checks to make sure that a plugin *could* be published.'; + /// The custom http client used to query versions on pub. + final http.Client httpClient; + + final PubVersionFinder _pubVersionFinder; + + // The output JSON when the _machineFlag is on. + final Map _machineOutput = {}; + + final List _humanMessages = []; + @override Future run() async { + final ZoneSpecification logSwitchSpecification = ZoneSpecification( + print: (Zone self, ZoneDelegate parent, Zone zone, String message) { + final bool logMachineMessage = argResults[_machineFlag] as bool; + if (logMachineMessage && message != _prettyJson(_machineOutput)) { + _humanMessages.add(message); + } else { + parent.print(zone, message); + } + }); + + await runZoned(_runCommand, zoneSpecification: logSwitchSpecification); + } + + Future _runCommand() async { final List failedPackages = []; + String status = _statusMessageNoPublish; await for (final Directory plugin in getPlugins()) { - if (!(await _passesPublishCheck(plugin))) { - failedPackages.add(plugin); + final _PublishCheckResult result = await _passesPublishCheck(plugin); + switch (result) { + case _PublishCheckResult._notPublished: + if (failedPackages.isEmpty) { + status = _statusNeedsPublish; + } + break; + case _PublishCheckResult._published: + break; + case _PublishCheckResult._error: + failedPackages.add(plugin); + status = _statusMessageError; + break; } } + _pubVersionFinder.httpClient.close(); if (failedPackages.isNotEmpty) { final String error = - 'FAIL: The following ${failedPackages.length} package(s) failed the ' + 'The following ${failedPackages.length} package(s) failed the ' 'publishing check:'; final String joinedFailedPackages = failedPackages.join('\n'); + _printImportantStatusMessage('$error\n$joinedFailedPackages', + isError: true); + } else { + _printImportantStatusMessage('All packages passed publish check!', + isError: false); + } - final Colorize colorizedError = Colorize('$error\n$joinedFailedPackages') - ..red(); - print(colorizedError); - throw ToolExit(1); + if (argResults[_machineFlag] as bool) { + _setStatus(status); + _machineOutput[_humanMessageKey] = _humanMessages; + print(_prettyJson(_machineOutput)); } - final Colorize passedMessage = - Colorize('All packages passed publish check!')..green(); - print(passedMessage); + if (failedPackages.isNotEmpty) { + throw ToolExit(1); + } } Pubspec _tryParsePubspec(Directory package) { @@ -89,8 +159,11 @@ class PublishCheckCommand extends PluginCommand { final Completer stdOutCompleter = Completer(); process.stdout.listen( (List event) { - io.stdout.add(event); - outputBuffer.write(String.fromCharCodes(event)); + final String output = String.fromCharCodes(event); + if (output.isNotEmpty) { + print(output); + outputBuffer.write(output); + } }, onDone: () => stdOutCompleter.complete(), ); @@ -98,8 +171,11 @@ class PublishCheckCommand extends PluginCommand { final Completer stdInCompleter = Completer(); process.stderr.listen( (List event) { - io.stderr.add(event); - outputBuffer.write(String.fromCharCodes(event)); + final String output = String.fromCharCodes(event); + if (output.isNotEmpty) { + _printImportantStatusMessage(output, isError: true); + outputBuffer.write(output); + } }, onDone: () => stdInCompleter.complete(), ); @@ -121,24 +197,97 @@ class PublishCheckCommand extends PluginCommand { 'Packages with an SDK constraint on a pre-release of the Dart SDK should themselves be published as a pre-release version.'); } - Future _passesPublishCheck(Directory package) async { + Future<_PublishCheckResult> _passesPublishCheck(Directory package) async { final String packageName = package.basename; print('Checking that $packageName can be published.'); final Pubspec pubspec = _tryParsePubspec(package); if (pubspec == null) { - return false; + print('no pubspec'); + return _PublishCheckResult._error; } else if (pubspec.publishTo == 'none') { print('Package $packageName is marked as unpublishable. Skipping.'); - return true; + return _PublishCheckResult._published; + } + + final Version version = pubspec.version; + final _PublishCheckResult alreadyPublishedResult = + await _checkIfAlreadyPublished( + packageName: packageName, version: version); + if (alreadyPublishedResult == _PublishCheckResult._published) { + print( + 'Package $packageName version: $version has already be published on pub.'); + return alreadyPublishedResult; + } else if (alreadyPublishedResult == _PublishCheckResult._error) { + print('Check pub version failed $packageName'); + return _PublishCheckResult._error; } if (await _hasValidPublishCheckRun(package)) { print('Package $packageName is able to be published.'); - return true; + return _PublishCheckResult._notPublished; } else { print('Unable to publish $packageName'); - return false; + return _PublishCheckResult._error; + } + } + + // Check if `packageName` already has `version` published on pub. + Future<_PublishCheckResult> _checkIfAlreadyPublished( + {String packageName, Version version}) async { + final PubVersionFinderResponse pubVersionFinderResponse = + await _pubVersionFinder.getPackageVersion(package: packageName); + _PublishCheckResult result; + switch (pubVersionFinderResponse.result) { + case PubVersionFinderResult.success: + result = pubVersionFinderResponse.versions.contains(version) + ? _PublishCheckResult._published + : _PublishCheckResult._notPublished; + break; + case PubVersionFinderResult.fail: + print(''' +Error fetching version on pub for $packageName. +HTTP Status ${pubVersionFinderResponse.httpResponse.statusCode} +HTTP response: ${pubVersionFinderResponse.httpResponse.body} +'''); + result = _PublishCheckResult._error; + break; + case PubVersionFinderResult.noPackageFound: + result = _PublishCheckResult._notPublished; + break; } + return result; + } + + void _setStatus(String status) { + assert(_validStatus.contains(status)); + _machineOutput[_statusKey] = status; + } + + String _prettyJson(Map map) { + return const JsonEncoder.withIndent(' ').convert(_machineOutput); } + + void _printImportantStatusMessage(String message, {@required bool isError}) { + final String statusMessage = '${isError ? 'ERROR' : 'SUCCESS'}: $message'; + if (argResults[_machineFlag] as bool) { + print(statusMessage); + } else { + final Colorize colorizedMessage = Colorize(statusMessage); + if (isError) { + colorizedMessage.red(); + } else { + colorizedMessage.green(); + } + print(colorizedMessage); + } + } +} + +enum _PublishCheckResult { + _notPublished, + + _published, + + _error, } diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart index db69f4ead865..475caf5d285d 100644 --- a/script/tool/lib/src/version_check_command.dart +++ b/script/tool/lib/src/version_check_command.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:file/file.dart'; import 'package:git/git.dart'; +import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; @@ -74,8 +75,21 @@ class VersionCheckCommand extends PluginCommand { FileSystem fileSystem, { ProcessRunner processRunner = const ProcessRunner(), GitDir gitDir, - }) : super(packagesDir, fileSystem, - processRunner: processRunner, gitDir: gitDir); + this.httpClient, + }) : _pubVersionFinder = + PubVersionFinder(httpClient: httpClient ?? http.Client()), + super(packagesDir, fileSystem, + processRunner: processRunner, gitDir: gitDir) { + argParser.addFlag( + _againstPubFlag, + help: 'Whether the version check should run against the version on pub.\n' + 'Defaults to false, which means the version check only run against the previous version in code.', + defaultsTo: false, + negatable: true, + ); + } + + static const String _againstPubFlag = 'against-pub'; @override final String name = 'version-check'; @@ -86,6 +100,11 @@ class VersionCheckCommand extends PluginCommand { 'Also checks if the latest version in CHANGELOG matches the version in pubspec.\n\n' 'This command requires "pub" and "flutter" to be in your path.'; + /// The http client used to query pub server. + final http.Client httpClient; + + final PubVersionFinder _pubVersionFinder; + @override Future run() async { final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); @@ -115,29 +134,61 @@ class VersionCheckCommand extends PluginCommand { 'intentionally has no version should be marked ' '"publish_to: none".'); } - final Version masterVersion = - await gitVersionFinder.getPackageVersion(pubspecPath); - if (masterVersion == null) { - print('${indentation}Unable to find pubspec in master. ' - 'Safe to ignore if the project is new.'); + Version sourceVersion; + if (argResults[_againstPubFlag] as bool) { + final String packageName = pubspecFile.parent.basename; + final PubVersionFinderResponse pubVersionFinderResponse = + await _pubVersionFinder.getPackageVersion(package: packageName); + switch (pubVersionFinderResponse.result) { + case PubVersionFinderResult.success: + sourceVersion = pubVersionFinderResponse.versions.first; + print( + '$indentation$packageName: Current largest version on pub: $sourceVersion'); + break; + case PubVersionFinderResult.fail: + printErrorAndExit(errorMessage: ''' +${indentation}Error fetching version on pub for $packageName. +${indentation}HTTP Status ${pubVersionFinderResponse.httpResponse.statusCode} +${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} +'''); + break; + case PubVersionFinderResult.noPackageFound: + sourceVersion = null; + break; + } + } else { + sourceVersion = await gitVersionFinder.getPackageVersion(pubspecPath); + } + if (sourceVersion == null) { + String safeToIgnoreMessage; + if (argResults[_againstPubFlag] as bool) { + safeToIgnoreMessage = + '${indentation}Unable to find package on pub server.'; + } else { + safeToIgnoreMessage = + '${indentation}Unable to find pubspec in master.'; + } + print('$safeToIgnoreMessage Safe to ignore if the project is new.'); continue; } - if (masterVersion == headVersion) { + if (sourceVersion == headVersion) { print('${indentation}No version change.'); continue; } final Map allowedNextVersions = - getAllowedNextVersions(masterVersion, headVersion); + getAllowedNextVersions(sourceVersion, headVersion); if (!allowedNextVersions.containsKey(headVersion)) { + final String source = + (argResults[_againstPubFlag] as bool) ? 'pub' : 'master'; final String error = '${indentation}Incorrectly updated version.\n' - '${indentation}HEAD: $headVersion, master: $masterVersion.\n' + '${indentation}HEAD: $headVersion, $source: $sourceVersion.\n' '${indentation}Allowed versions: $allowedNextVersions'; printErrorAndExit(errorMessage: error); } else { - print('$indentation$headVersion -> $masterVersion'); + print('$indentation$headVersion -> $sourceVersion'); } final bool isPlatformInterface = @@ -153,6 +204,7 @@ class VersionCheckCommand extends PluginCommand { await for (final Directory plugin in getPlugins()) { await _checkVersionsMatch(plugin); } + _pubVersionFinder.httpClient.close(); print('No version check errors found!'); } @@ -224,7 +276,7 @@ The first version listed in CHANGELOG.md is $fromChangeLog. printErrorAndExit(errorMessage: ''' When bumping the version for release, the NEXT section should be incorporated into the new version's release notes. - '''); +'''); } } diff --git a/script/tool/test/common_test.dart b/script/tool/test/common_test.dart index 3ae46ffc15d9..d6ac449e7fd3 100644 --- a/script/tool/test/common_test.dart +++ b/script/tool/test/common_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:convert'; import 'dart:io'; import 'package:args/command_runner.dart'; @@ -9,7 +10,10 @@ import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common.dart'; import 'package:git/git.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:mockito/mockito.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:test/test.dart'; import 'util.dart'; @@ -334,6 +338,80 @@ file2/file2.cc .runCommand(['diff', '--name-only', customBaseSha, 'HEAD'])); }); }); + + group('$PubVersionFinder', () { + test('Package does not exist.', () async { + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response('', 404); + }); + final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); + final PubVersionFinderResponse response = + await finder.getPackageVersion(package: 'some_package'); + + expect(response.versions, isNull); + expect(response.result, PubVersionFinderResult.noPackageFound); + expect(response.httpResponse.statusCode, 404); + expect(response.httpResponse.body, ''); + }); + + test('HTTP error when getting versions from pub', () async { + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response('', 400); + }); + final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); + final PubVersionFinderResponse response = + await finder.getPackageVersion(package: 'some_package'); + + expect(response.versions, isNull); + expect(response.result, PubVersionFinderResult.fail); + expect(response.httpResponse.statusCode, 400); + expect(response.httpResponse.body, ''); + }); + + test('Get a correct list of versions when http response is OK.', () async { + const Map httpResponse = { + 'name': 'some_package', + 'versions': [ + '0.0.1', + '0.0.2', + '0.0.2+2', + '0.1.1', + '0.0.1+1', + '0.1.0', + '0.2.0', + '0.1.0+1', + '0.0.2+1', + '2.0.0', + '1.2.0', + '1.0.0', + ], + }; + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response(json.encode(httpResponse), 200); + }); + final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); + final PubVersionFinderResponse response = + await finder.getPackageVersion(package: 'some_package'); + + expect(response.versions, [ + Version.parse('2.0.0'), + Version.parse('1.2.0'), + Version.parse('1.0.0'), + Version.parse('0.2.0'), + Version.parse('0.1.1'), + Version.parse('0.1.0+1'), + Version.parse('0.1.0'), + Version.parse('0.0.2+2'), + Version.parse('0.0.2+1'), + Version.parse('0.0.2'), + Version.parse('0.0.1+1'), + Version.parse('0.0.1'), + ]); + expect(response.result, PubVersionFinderResult.success); + expect(response.httpResponse.statusCode, 200); + expect(response.httpResponse.body, json.encode(httpResponse)); + }); + }); } class SamplePluginCommand extends PluginCommand { diff --git a/script/tool/test/publish_check_command_test.dart b/script/tool/test/publish_check_command_test.dart index 0a9d36f2ea6f..eca7caf53403 100644 --- a/script/tool/test/publish_check_command_test.dart +++ b/script/tool/test/publish_check_command_test.dart @@ -3,12 +3,15 @@ // found in the LICENSE file. import 'dart:collection'; +import 'dart:convert'; import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:flutter_plugin_tools/src/common.dart'; import 'package:flutter_plugin_tools/src/publish_check_command.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:test/test.dart'; import 'mocks.dart'; @@ -126,6 +129,222 @@ void main() { expect(runner.run(['publish-check']), throwsA(isA())); }); + + test( + '--machine: Log JSON with status:no-publish and correct human message, if there are no packages need to be published. ', + () async { + const Map httpResponseA = { + 'name': 'a', + 'versions': [ + '0.0.1', + '0.1.0', + ], + }; + + const Map httpResponseB = { + 'name': 'b', + 'versions': [ + '0.0.1', + '0.1.0', + '0.2.0', + ], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'no_publish_a.json') { + return http.Response(json.encode(httpResponseA), 200); + } else if (request.url.pathSegments.last == 'no_publish_b.json') { + return http.Response(json.encode(httpResponseB), 200); + } + return null; + }); + final PublishCheckCommand command = PublishCheckCommand( + mockPackagesDir, mockFileSystem, + processRunner: processRunner, httpClient: mockClient); + + runner = CommandRunner( + 'publish_check_command', + 'Test for publish-check command.', + ); + runner.addCommand(command); + + final Directory plugin1Dir = + createFakePlugin('no_publish_a', includeVersion: true); + final Directory plugin2Dir = + createFakePlugin('no_publish_b', includeVersion: true); + + createFakePubspec(plugin1Dir, + name: 'no_publish_a', includeVersion: true, version: '0.1.0'); + createFakePubspec(plugin2Dir, + name: 'no_publish_b', includeVersion: true, version: '0.2.0'); + + processRunner.processesToReturn.add( + MockProcess()..exitCodeCompleter.complete(0), + ); + final List output = await runCapturingPrint( + runner, ['publish-check', '--machine']); + + // ignore: use_raw_strings + expect(output.first, ''' +{ + "status": "no-publish", + "humanMessage": [ + "Checking that no_publish_a can be published.", + "Package no_publish_a version: 0.1.0 has already be published on pub.", + "Checking that no_publish_b can be published.", + "Package no_publish_b version: 0.2.0 has already be published on pub.", + "SUCCESS: All packages passed publish check!" + ] +}'''); + }); + + test( + '--machine: Log JSON with status:needs-publish and correct human message, if there is at least 1 plugin needs to be published.', + () async { + const Map httpResponseA = { + 'name': 'a', + 'versions': [ + '0.0.1', + '0.1.0', + ], + }; + + const Map httpResponseB = { + 'name': 'b', + 'versions': [ + '0.0.1', + '0.1.0', + ], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'no_publish_a.json') { + return http.Response(json.encode(httpResponseA), 200); + } else if (request.url.pathSegments.last == 'no_publish_b.json') { + return http.Response(json.encode(httpResponseB), 200); + } + return null; + }); + final PublishCheckCommand command = PublishCheckCommand( + mockPackagesDir, mockFileSystem, + processRunner: processRunner, httpClient: mockClient); + + runner = CommandRunner( + 'publish_check_command', + 'Test for publish-check command.', + ); + runner.addCommand(command); + + final Directory plugin1Dir = + createFakePlugin('no_publish_a', includeVersion: true); + final Directory plugin2Dir = + createFakePlugin('no_publish_b', includeVersion: true); + + createFakePubspec(plugin1Dir, + name: 'no_publish_a', includeVersion: true, version: '0.1.0'); + createFakePubspec(plugin2Dir, + name: 'no_publish_b', includeVersion: true, version: '0.2.0'); + + processRunner.processesToReturn.add( + MockProcess()..exitCodeCompleter.complete(0), + ); + + final List output = await runCapturingPrint( + runner, ['publish-check', '--machine']); + + // ignore: use_raw_strings + expect(output.first, ''' +{ + "status": "needs-publish", + "humanMessage": [ + "Checking that no_publish_a can be published.", + "Package no_publish_a version: 0.1.0 has already be published on pub.", + "Checking that no_publish_b can be published.", + "Package no_publish_b is able to be published.", + "SUCCESS: All packages passed publish check!" + ] +}'''); + }); + + test( + '--machine: Log correct JSON, if there is at least 1 plugin contains error.', + () async { + const Map httpResponseA = { + 'name': 'a', + 'versions': [ + '0.0.1', + '0.1.0', + ], + }; + + const Map httpResponseB = { + 'name': 'b', + 'versions': [ + '0.0.1', + '0.1.0', + ], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + print('url ${request.url}'); + print(request.url.pathSegments.last); + if (request.url.pathSegments.last == 'no_publish_a.json') { + return http.Response(json.encode(httpResponseA), 200); + } else if (request.url.pathSegments.last == 'no_publish_b.json') { + return http.Response(json.encode(httpResponseB), 200); + } + return null; + }); + final PublishCheckCommand command = PublishCheckCommand( + mockPackagesDir, mockFileSystem, + processRunner: processRunner, httpClient: mockClient); + + runner = CommandRunner( + 'publish_check_command', + 'Test for publish-check command.', + ); + runner.addCommand(command); + + final Directory plugin1Dir = + createFakePlugin('no_publish_a', includeVersion: true); + final Directory plugin2Dir = + createFakePlugin('no_publish_b', includeVersion: true); + + createFakePubspec(plugin1Dir, + name: 'no_publish_a', includeVersion: true, version: '0.1.0'); + createFakePubspec(plugin2Dir, + name: 'no_publish_b', includeVersion: true, version: '0.2.0'); + await plugin1Dir.childFile('pubspec.yaml').writeAsString('bad-yaml'); + + processRunner.processesToReturn.add( + MockProcess()..exitCodeCompleter.complete(0), + ); + + bool hasError = false; + final List output = await runCapturingPrint( + runner, ['publish-check', '--machine'], + errorHandler: (Error error) { + expect(error, isA()); + hasError = true; + }); + expect(hasError, isTrue); + + // ignore: use_raw_strings + expect(output.first, ''' +{ + "status": "error", + "humanMessage": [ + "Checking that no_publish_a can be published.", + "Failed to parse `pubspec.yaml` at /packages/no_publish_a/pubspec.yaml: ParsedYamlException: line 1, column 1: Not a map\\n ╷\\n1 │ bad-yaml\\n │ ^^^^^^^^\\n ╵}", + "no pubspec", + "Checking that no_publish_b can be published.", + "url https://pub.dev/packages/no_publish_b.json", + "no_publish_b.json", + "Package no_publish_b is able to be published.", + "ERROR: The following 1 package(s) failed the publishing check:\\nMemoryDirectory: '/packages/no_publish_a'" + ] +}'''); + }); }); } diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index 25b248b00af5..d708dc71bef7 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -205,19 +205,29 @@ void cleanupPackages() { }); } +typedef _ErrorHandler = void Function(Error error); + /// Run the command [runner] with the given [args] and return /// what was printed. +/// A custom [errorHandler] can be used to handle the runner error as desired without throwing. Future> runCapturingPrint( - CommandRunner runner, List args) async { + CommandRunner runner, List args, {_ErrorHandler errorHandler}) async { final List prints = []; final ZoneSpecification spec = ZoneSpecification( print: (_, __, ___, String message) { prints.add(message); }, ); - await Zone.current + try { + await Zone.current .fork(specification: spec) .run>(() => runner.run(args)); + } on Error catch (e) { + if (errorHandler == null) { + rethrow; + } + errorHandler(e); + } return prints; } diff --git a/script/tool/test/version_check_test.dart b/script/tool/test/version_check_test.dart index ea1a82ae7445..d67103f716a1 100644 --- a/script/tool/test/version_check_test.dart +++ b/script/tool/test/version_check_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'dart:io' as io; import 'package:args/command_runner.dart'; @@ -10,6 +11,8 @@ import 'package:file/file.dart'; import 'package:flutter_plugin_tools/src/common.dart'; import 'package:flutter_plugin_tools/src/version_check_command.dart'; import 'package:git/git.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:mockito/mockito.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:test/test.dart'; @@ -39,19 +42,29 @@ class MockGitDir extends Mock implements GitDir {} class MockProcessResult extends Mock implements io.ProcessResult {} +const String _redColorMessagePrefix = '\x1B[31m'; +const String _redColorMessagePostfix = '\x1B[0m'; + +// Some error message was printed in a "Colorized" red message. So `\x1B[31m` and `\x1B[0m` needs to be included. +String _redColorString(String string) { + return '$_redColorMessagePrefix$string$_redColorMessagePostfix'; +} + void main() { + const String indentation = ' '; group('$VersionCheckCommand', () { CommandRunner runner; RecordingProcessRunner processRunner; List> gitDirCommands; String gitDiffResponse; Map gitShowResponses; + MockGitDir gitDir; setUp(() { gitDirCommands = >[]; gitDiffResponse = ''; gitShowResponses = {}; - final MockGitDir gitDir = MockGitDir(); + gitDir = MockGitDir(); when(gitDir.runCommand(any)).thenAnswer((Invocation invocation) { gitDirCommands.add(invocation.positionalArguments[0] as List); final MockProcessResult mockProcessResult = MockProcessResult(); @@ -165,6 +178,7 @@ void main() { expect( output, containsAllInOrder([ + '${indentation}Unable to find pubspec in master. Safe to ignore if the project is new.', 'No version check errors found!', ]), ); @@ -321,25 +335,27 @@ void main() { * Some changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); - final Future> output = runCapturingPrint( - runner, ['version-check', '--base-sha=master']); - await expectLater( + bool hasError = false; + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--against-pub' + ], errorHandler: (Error e) { + expect(e, isA()); + hasError = true; + }); + expect(hasError, isTrue); + + expect( output, - throwsA(const TypeMatcher()), + containsAllInOrder([ + _redColorString(''' +versions for plugin in CHANGELOG.md and pubspec.yaml do not match. +The version in pubspec.yaml is 1.0.1. +The first version listed in CHANGELOG.md is 1.0.2. +'''), + ]), ); - try { - final List outputValue = await output; - await expectLater( - outputValue, - containsAllInOrder([ - ''' - versions for plugin in CHANGELOG.md and pubspec.yaml do not match. - The version in pubspec.yaml is 1.0.1. - The first version listed in CHANGELOG.md is 1.0.2. - ''', - ]), - ); - } on ToolExit catch (_) {} }); test('Success if CHANGELOG and pubspec versions match', () async { @@ -388,25 +404,29 @@ void main() { * Some other changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); - final Future> output = runCapturingPrint( - runner, ['version-check', '--base-sha=master']); - await expectLater( + bool hasError = false; + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--against-pub' + ], errorHandler: (Error e) { + expect(e, isA()); + hasError = true; + }); + expect(hasError, isTrue); + + expect( output, - throwsA(const TypeMatcher()), - ); - try { - final List outputValue = await output; - await expectLater( - outputValue, - containsAllInOrder([ + containsAllInOrder([ + _redColorString( ''' - versions for plugin in CHANGELOG.md and pubspec.yaml do not match. - The version in pubspec.yaml is 1.0.0. - The first version listed in CHANGELOG.md is 1.0.1. - ''', - ]), - ); - } on ToolExit catch (_) {} +versions for plugin in CHANGELOG.md and pubspec.yaml do not match. +The version in pubspec.yaml is 1.0.0. +The first version listed in CHANGELOG.md is 1.0.1. +''', + ) + ]), + ); }); test('Allow NEXT as a placeholder for gathering CHANGELOG entries', @@ -463,25 +483,28 @@ void main() { * Some other changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); - final Future> output = runCapturingPrint( - runner, ['version-check', '--base-sha=master']); - await expectLater( + bool hasError = false; + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--against-pub' + ], errorHandler: (Error e) { + expect(e, isA()); + hasError = true; + }); + expect(hasError, isTrue); + + expect( output, - throwsA(const TypeMatcher()), - ); - try { - final List outputValue = await output; - await expectLater( - outputValue, - containsAllInOrder([ + containsAllInOrder([ + _redColorString( ''' - versions for plugin in CHANGELOG.md and pubspec.yaml do not match. - The version in pubspec.yaml is 1.0.0. - The first version listed in CHANGELOG.md is 1.0.1. - ''', - ]), - ); - } on ToolExit catch (_) {} +When bumping the version for release, the NEXT section should be incorporated +into the new version's release notes. +''', + ) + ]), + ); }); test('Fail if the version changes without replacing NEXT', () async { @@ -502,25 +525,194 @@ void main() { * Some other changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); - final Future> output = runCapturingPrint( - runner, ['version-check', '--base-sha=master']); - await expectLater( + bool hasError = false; + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--against-pub' + ], errorHandler: (Error e) { + expect(e, isA()); + hasError = true; + }); + expect(hasError, isTrue); + + expect( output, - throwsA(const TypeMatcher()), + containsAllInOrder([ + 'Found NEXT; validating next version in the CHANGELOG.', + _redColorString( + ''' +versions for plugin in CHANGELOG.md and pubspec.yaml do not match. +The version in pubspec.yaml is 1.0.1. +The first version listed in CHANGELOG.md is 1.0.0. +''', + ) + ]), + ); + }); + + test('allows valid against pub', () async { + const Map httpResponse = { + 'name': 'some_package', + 'versions': [ + '0.0.1', + '0.0.2', + '1.0.0', + ], + }; + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response(json.encode(httpResponse), 200); + }); + final VersionCheckCommand command = VersionCheckCommand( + mockPackagesDir, mockFileSystem, + processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); + + runner = CommandRunner( + 'version_check_command', 'Test for $VersionCheckCommand'); + runner.addCommand(command); + + createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + gitDiffResponse = 'packages/plugin/pubspec.yaml'; + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: 2.0.0', + }; + final List output = await runCapturingPrint(runner, + ['version-check', '--base-sha=master', '--against-pub']); + + expect( + output, + containsAllInOrder([ + '${indentation}plugin: Current largest version on pub: 1.0.0', + 'No version check errors found!', + ]), ); - try { - final List outputValue = await output; - await expectLater( - outputValue, - containsAllInOrder([ + }); + + test('denies invalid against pub', () async { + const Map httpResponse = { + 'name': 'some_package', + 'versions': [ + '0.0.1', + '0.0.2', + ], + }; + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response(json.encode(httpResponse), 200); + }); + final VersionCheckCommand command = VersionCheckCommand( + mockPackagesDir, mockFileSystem, + processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); + + runner = CommandRunner( + 'version_check_command', 'Test for $VersionCheckCommand'); + runner.addCommand(command); + + createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + gitDiffResponse = 'packages/plugin/pubspec.yaml'; + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: 2.0.0', + }; + + bool hasError = false; + final List result = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--against-pub' + ], errorHandler: (Error e) { + expect(e, isA()); + hasError = true; + }); + expect(hasError, isTrue); + + expect( + result, + containsAllInOrder([ + _redColorString( + ''' +${indentation}Incorrectly updated version. +${indentation}HEAD: 2.0.0, pub: 0.0.2. +${indentation}Allowed versions: {1.0.0: NextVersionType.BREAKING_MAJOR, 0.1.0: NextVersionType.MINOR, 0.0.3: NextVersionType.PATCH}''', + ) + ]), + ); + }); + + test( + 'throw and print error message if http request failed when checking against pub', + () async { + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response('xx', 400); + }); + final VersionCheckCommand command = VersionCheckCommand( + mockPackagesDir, mockFileSystem, + processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); + + runner = CommandRunner( + 'version_check_command', 'Test for $VersionCheckCommand'); + runner.addCommand(command); + + createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + gitDiffResponse = 'packages/plugin/pubspec.yaml'; + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: 2.0.0', + }; + bool hasError = false; + final List result = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--against-pub' + ], errorHandler: (Error e) { + expect(e, isA()); + hasError = true; + }); + expect(hasError, isTrue); + + expect( + result, + containsAllInOrder([ + _redColorString( ''' - versions for plugin in CHANGELOG.md and pubspec.yaml do not match. - The version in pubspec.yaml is 1.0.0. - The first version listed in CHANGELOG.md is 1.0.1. - ''', - ]), - ); - } on ToolExit catch (_) {} +${indentation}Error fetching version on pub for plugin. +${indentation}HTTP Status 400 +${indentation}HTTP response: xx +''', + ) + ]), + ); + }); + + test('when checking against pub, allow any version if http status is 404.', + () async { + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response('xx', 404); + }); + final VersionCheckCommand command = VersionCheckCommand( + mockPackagesDir, mockFileSystem, + processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); + + runner = CommandRunner( + 'version_check_command', 'Test for $VersionCheckCommand'); + runner.addCommand(command); + + createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + gitDiffResponse = 'packages/plugin/pubspec.yaml'; + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: 2.0.0', + }; + final List result = await runCapturingPrint(runner, + ['version-check', '--base-sha=master', '--against-pub']); + + expect( + result, + containsAllInOrder([ + '${indentation}Unable to find package on pub server. Safe to ignore if the project is new.', + 'No version check errors found!', + ]), + ); }); });