// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:async'; import 'dart:convert'; import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/local.dart'; import 'package:flutter_plugin_tools/src/publish_plugin_command.dart'; import 'package:flutter_plugin_tools/src/common.dart'; import 'package:git/git.dart'; import 'package:matcher/matcher.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; import 'mocks.dart'; import 'util.dart'; void main() { const String testPluginName = 'foo'; final List printedMessages = []; Directory parentDir; Directory pluginDir; GitDir gitDir; TestProcessRunner processRunner; CommandRunner commandRunner; MockStdin mockStdin; setUp(() async { // This test uses a local file system instead of an in memory one throughout // so that git actually works. In setup we initialize a mono repo of plugins // with one package and commit everything to Git. parentDir = const LocalFileSystem() .systemTempDirectory .createTempSync('publish_plugin_command_test-'); initializeFakePackages(parentDir: parentDir); pluginDir = createFakePlugin(testPluginName, withSingleExample: false); assert(pluginDir != null && pluginDir.existsSync()); createFakePubspec(pluginDir, includeVersion: true); io.Process.runSync('git', ['init'], workingDirectory: mockPackagesDir.path); gitDir = await GitDir.fromExisting(mockPackagesDir.path); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Initial commit']); processRunner = TestProcessRunner(); mockStdin = MockStdin(); commandRunner = CommandRunner('tester', '') ..addCommand(PublishPluginCommand( mockPackagesDir, const LocalFileSystem(), processRunner: processRunner, print: (Object message) => printedMessages.add(message.toString()), stdinput: mockStdin)); }); tearDown(() { parentDir.deleteSync(recursive: true); printedMessages.clear(); }); group('Initial validation', () { test('requires a package flag', () async { await expectLater(() => commandRunner.run(['publish-plugin']), throwsA(const TypeMatcher())); expect( printedMessages.last, contains("Must specify a package to publish.")); }); test('requires an existing flag', () async { await expectLater( () => commandRunner .run(['publish-plugin', '--package', 'iamerror']), throwsA(const TypeMatcher())); expect(printedMessages.last, contains('iamerror does not exist')); }); test('refuses to proceed with dirty files', () async { pluginDir.childFile('tmp').createSync(); await expectLater( () => commandRunner .run(['publish-plugin', '--package', testPluginName]), throwsA(const TypeMatcher())); expect( printedMessages.last, contains( "There are files in the package directory that haven't been saved in git.")); }); test('fails immediately if the remote doesn\'t exist', () async { await expectLater( () => commandRunner .run(['publish-plugin', '--package', testPluginName]), throwsA(const TypeMatcher())); expect(processRunner.results.last.stderr, contains("No such remote")); }); test("doesn't validate the remote if it's not pushing tags", () async { // Immediately return 0 when running `pub publish`. processRunner.mockPublishProcess.exitCodeCompleter.complete(0); await commandRunner.run([ 'publish-plugin', '--package', testPluginName, '--no-push-tags', '--no-tag-release' ]); expect(printedMessages.last, 'Done!'); }); test('can publish non-flutter package', () async { createFakePubspec(pluginDir, includeVersion: true, isFlutter: false); io.Process.runSync('git', ['init'], workingDirectory: mockPackagesDir.path); gitDir = await GitDir.fromExisting(mockPackagesDir.path); await gitDir.runCommand(['add', '-A']); await gitDir.runCommand(['commit', '-m', 'Initial commit']); // Immediately return 0 when running `pub publish`. processRunner.mockPublishProcess.exitCodeCompleter.complete(0); await commandRunner.run([ 'publish-plugin', '--package', testPluginName, '--no-push-tags', '--no-tag-release' ]); expect(printedMessages.last, 'Done!'); }); }); group('Publishes package', () { test('while showing all output from pub publish to the user', () async { final Future publishCommand = commandRunner.run([ 'publish-plugin', '--package', testPluginName, '--no-push-tags', '--no-tag-release' ]); processRunner.mockPublishProcess.stdoutController.add(utf8.encode('Foo')); processRunner.mockPublishProcess.stderrController.add(utf8.encode('Bar')); processRunner.mockPublishProcess.exitCodeCompleter.complete(0); await publishCommand; expect(printedMessages, contains('Foo')); expect(printedMessages, contains('Bar')); }); test('forwards input from the user to `pub publish`', () async { final Future publishCommand = commandRunner.run([ 'publish-plugin', '--package', testPluginName, '--no-push-tags', '--no-tag-release' ]); mockStdin.controller.add(utf8.encode('user input')); processRunner.mockPublishProcess.exitCodeCompleter.complete(0); await publishCommand; expect(processRunner.mockPublishProcess.stdinMock.lines, contains('user input')); }); test('forwards --pub-publish-flags to pub publish', () async { processRunner.mockPublishProcess.exitCodeCompleter.complete(0); await commandRunner.run([ 'publish-plugin', '--package', testPluginName, '--no-push-tags', '--no-tag-release', '--pub-publish-flags', '--dry-run,--server=foo' ]); expect(processRunner.mockPublishArgs.length, 4); expect(processRunner.mockPublishArgs[0], 'pub'); expect(processRunner.mockPublishArgs[1], 'publish'); expect(processRunner.mockPublishArgs[2], '--dry-run'); expect(processRunner.mockPublishArgs[3], '--server=foo'); }); test('throws if pub publish fails', () async { processRunner.mockPublishProcess.exitCodeCompleter.complete(128); await expectLater( () => commandRunner.run([ 'publish-plugin', '--package', testPluginName, '--no-push-tags', '--no-tag-release', ]), throwsA(const TypeMatcher())); expect(printedMessages, contains("Publish failed. Exiting.")); }); }); group('Tags release', () { test('with the version and name from the pubspec.yaml', () async { processRunner.mockPublishProcess.exitCodeCompleter.complete(0); await commandRunner.run([ 'publish-plugin', '--package', testPluginName, '--no-push-tags', ]); final String tag = (await gitDir.runCommand(['show-ref', 'fake_package-v0.0.1'])) .stdout; expect(tag, isNotEmpty); }); test('only if publishing succeeded', () async { processRunner.mockPublishProcess.exitCodeCompleter.complete(128); await expectLater( () => commandRunner.run([ 'publish-plugin', '--package', testPluginName, '--no-push-tags', ]), throwsA(const TypeMatcher())); expect(printedMessages, contains("Publish failed. Exiting.")); final String tag = (await gitDir.runCommand( ['show-ref', 'fake_package-v0.0.1'], throwOnError: false)) .stdout; expect(tag, isEmpty); }); }); group('Pushes tags', () { setUp(() async { await gitDir.runCommand( ['remote', 'add', 'upstream', 'http://localhost:8000']); }); test('requires user confirmation', () async { processRunner.mockPublishProcess.exitCodeCompleter.complete(0); mockStdin.readLineOutput = 'help'; await expectLater( () => commandRunner.run([ 'publish-plugin', '--package', testPluginName, ]), throwsA(const TypeMatcher())); expect(printedMessages, contains('Tag push canceled.')); }); test('to upstream by default', () async { await gitDir.runCommand(['tag', 'garbage']); processRunner.mockPublishProcess.exitCodeCompleter.complete(0); mockStdin.readLineOutput = 'y'; await commandRunner.run([ 'publish-plugin', '--package', testPluginName, ]); expect(processRunner.pushTagsArgs.isNotEmpty, isTrue); expect(processRunner.pushTagsArgs[1], 'upstream'); expect(processRunner.pushTagsArgs[2], 'fake_package-v0.0.1'); expect(printedMessages.last, 'Done!'); }); test('to different remotes based on a flag', () async { await gitDir.runCommand( ['remote', 'add', 'origin', 'http://localhost:8001']); processRunner.mockPublishProcess.exitCodeCompleter.complete(0); mockStdin.readLineOutput = 'y'; await commandRunner.run([ 'publish-plugin', '--package', testPluginName, '--remote', 'origin', ]); expect(processRunner.pushTagsArgs.isNotEmpty, isTrue); expect(processRunner.pushTagsArgs[1], 'origin'); expect(processRunner.pushTagsArgs[2], 'fake_package-v0.0.1'); expect(printedMessages.last, 'Done!'); }); test('only if tagging and pushing to remotes are both enabled', () async { processRunner.mockPublishProcess.exitCodeCompleter.complete(0); await commandRunner.run([ 'publish-plugin', '--package', testPluginName, '--no-tag-release', ]); expect(processRunner.pushTagsArgs.isEmpty, isTrue); expect(printedMessages.last, 'Done!'); }); }); } class TestProcessRunner extends ProcessRunner { final List results = []; final MockProcess mockPublishProcess = MockProcess(); final List mockPublishArgs = []; final MockProcessResult mockPushTagsResult = MockProcessResult(); final List pushTagsArgs = []; @override Future runAndExitOnError( String executable, List args, { Directory workingDir, }) async { // Don't ever really push tags. if (executable == 'git' && args.isNotEmpty && args[0] == 'push') { pushTagsArgs.addAll(args); return mockPushTagsResult; } final io.ProcessResult result = io.Process.runSync(executable, args, workingDirectory: workingDir?.path); results.add(result); if (result.exitCode != 0) { throw ToolExit(result.exitCode); } return result; } @override Future start(String executable, List args, {Directory workingDirectory}) async { /// Never actually publish anything. Start is always and only used for this /// since it returns something we can route stdin through. assert(executable == 'flutter' && args.isNotEmpty && args[0] == 'pub' && args[1] == 'publish'); mockPublishArgs.addAll(args); return mockPublishProcess; } } class MockStdin extends Mock implements io.Stdin { final StreamController> controller = StreamController>(); String readLineOutput; @override Stream transform(StreamTransformer streamTransformer) { return controller.stream.transform(streamTransformer); } @override StreamSubscription> listen(void onData(List event), {Function onError, void onDone(), bool cancelOnError}) { return controller.stream.listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError); } @override String readLineSync( {Encoding encoding = io.systemEncoding, bool retainNewlines = false}) => readLineOutput; } class MockProcessResult extends Mock implements io.ProcessResult {}