// 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 'dart:convert';
import 'dart:io' as io;

import 'package:args/command_runner.dart';
import 'package:file/file.dart';
import 'package:file/memory.dart';
import 'package:flutter_plugin_tools/src/common/core.dart';
import 'package:flutter_plugin_tools/src/publish_command.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';

import 'common/package_command_test.mocks.dart';
import 'mocks.dart';
import 'util.dart';

void main() {
  late MockPlatform platform;
  late Directory packagesDir;
  late MockGitDir gitDir;
  late TestProcessRunner processRunner;
  late PublishCommand command;
  late CommandRunner<void> commandRunner;
  late MockStdin mockStdin;
  late FileSystem fileSystem;
  // Map of package name to mock response.
  late Map<String, Map<String, dynamic>> mockHttpResponses;

  void createMockCredentialFile() {
    fileSystem.file(command.credentialsPath)
      ..createSync(recursive: true)
      ..writeAsStringSync('some credential');
  }

  setUp(() async {
    platform = MockPlatform(isLinux: true);
    platform.environment['HOME'] = '/home';
    fileSystem = MemoryFileSystem();
    packagesDir = createPackagesDirectory(fileSystem: fileSystem);
    processRunner = TestProcessRunner();

    mockHttpResponses = <String, Map<String, dynamic>>{};
    final MockClient mockClient = MockClient((http.Request request) async {
      final String packageName =
          request.url.pathSegments.last.replaceAll('.json', '');
      final Map<String, dynamic>? response = mockHttpResponses[packageName];
      if (response != null) {
        return http.Response(json.encode(response), 200);
      }
      // Default to simulating the plugin never having been published.
      return http.Response('', 404);
    });

    gitDir = MockGitDir();
    when(gitDir.path).thenReturn(packagesDir.parent.path);
    when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError')))
        .thenAnswer((Invocation invocation) {
      final List<String> arguments =
          invocation.positionalArguments[0]! as List<String>;
      // Route git calls through the process runner, to make mock output
      // consistent with outer processes. Attach the first argument to the
      // command to make targeting the mock results easier.
      final String gitCommand = arguments.removeAt(0);
      return processRunner.run('git-$gitCommand', arguments);
    });

    mockStdin = MockStdin();
    command = PublishCommand(
      packagesDir,
      platform: platform,
      processRunner: processRunner,
      stdinput: mockStdin,
      gitDir: gitDir,
      httpClient: mockClient,
    );
    commandRunner = CommandRunner<void>('tester', '')..addCommand(command);
  });

  group('Initial validation', () {
    test('refuses to proceed with dirty files', () async {
      final RepositoryPackage plugin =
          createFakePlugin('foo', packagesDir, examples: <String>[]);

      processRunner.mockProcessesForExecutable['git-status'] =
          <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(
            stdout: '?? ${plugin.directory.childFile('tmp').path}\n'))
      ];

      Error? commandError;
      final List<String> output =
          await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
      ], errorHandler: (Error e) {
        commandError = e;
      });

      expect(commandError, isA<ToolExit>());
      expect(
          output,
          containsAllInOrder(<Matcher>[
            contains("There are files in the package directory that haven't "
                'been saved in git. Refusing to publish these files:\n\n'
                '?? /packages/foo/tmp\n\n'
                'If the directory should be clean, you can run `git clean -xdf && '
                'git reset --hard HEAD` to wipe all local changes.'),
            contains('foo:\n'
                '    uncommitted changes'),
          ]));
    });

    test("fails immediately if the remote doesn't exist", () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      processRunner.mockProcessesForExecutable['git-remote'] =
          <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(exitCode: 1)),
      ];

      Error? commandError;
      final List<String> output = await runCapturingPrint(
          commandRunner, <String>['publish', '--packages=foo'],
          errorHandler: (Error e) {
        commandError = e;
      });

      expect(commandError, isA<ToolExit>());
      expect(
          output,
          containsAllInOrder(<Matcher>[
            contains(
                'Unable to find URL for remote upstream; cannot push tags'),
          ]));
    });
  });

  group('Publishes package', () {
    test('while showing all output from pub publish to the user', () async {
      createFakePlugin('plugin1', packagesDir, examples: <String>[]);
      createFakePlugin('plugin2', packagesDir, examples: <String>[]);

      processRunner.mockProcessesForExecutable['flutter'] = <FakeProcessInfo>[
        FakeProcessInfo(
            MockProcess(
                stdout: 'Foo',
                stderr: 'Bar',
                stdoutEncoding: utf8,
                stderrEncoding: utf8),
            <String>['pub', 'publish']), // publish for plugin1
        FakeProcessInfo(
            MockProcess(
                stdout: 'Baz', stdoutEncoding: utf8, stderrEncoding: utf8),
            <String>['pub', 'publish']), // publish for plugin2
      ];

      final List<String> output = await runCapturingPrint(
          commandRunner, <String>['publish', '--packages=plugin1,plugin2']);

      expect(
          output,
          containsAllInOrder(<Matcher>[
            contains('Running `pub publish ` in /packages/plugin1...'),
            contains('Foo'),
            contains('Bar'),
            contains('Package published!'),
            contains('Running `pub publish ` in /packages/plugin2...'),
            contains('Baz'),
            contains('Package published!'),
          ]));
    });

    test('forwards input from the user to `pub publish`', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      mockStdin.mockUserInputs.add(utf8.encode('user input'));

      await runCapturingPrint(
          commandRunner, <String>['publish', '--packages=foo']);

      expect(processRunner.mockPublishProcess.stdinMock.lines,
          contains('user input'));
    });

    test('forwards --pub-publish-flags to pub publish', () async {
      final RepositoryPackage plugin =
          createFakePlugin('foo', packagesDir, examples: <String>[]);

      await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
        '--pub-publish-flags',
        '--dry-run,--server=bar'
      ]);

      expect(
          processRunner.recordedCalls,
          contains(ProcessCall(
              'flutter',
              const <String>['pub', 'publish', '--dry-run', '--server=bar'],
              plugin.path)));
    });

    test(
        '--skip-confirmation flag automatically adds --force to --pub-publish-flags',
        () async {
      createMockCredentialFile();
      final RepositoryPackage plugin =
          createFakePlugin('foo', packagesDir, examples: <String>[]);

      await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
        '--skip-confirmation',
        '--pub-publish-flags',
        '--server=bar'
      ]);

      expect(
          processRunner.recordedCalls,
          contains(ProcessCall(
              'flutter',
              const <String>['pub', 'publish', '--server=bar', '--force'],
              plugin.path)));
    });

    test('--force is only added once, regardless of plugin count', () async {
      createMockCredentialFile();
      final RepositoryPackage plugin1 =
          createFakePlugin('plugin_a', packagesDir, examples: <String>[]);
      final RepositoryPackage plugin2 =
          createFakePlugin('plugin_b', packagesDir, examples: <String>[]);

      await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=plugin_a,plugin_b',
        '--skip-confirmation',
        '--pub-publish-flags',
        '--server=bar'
      ]);

      expect(
          processRunner.recordedCalls,
          containsAllInOrder(<ProcessCall>[
            ProcessCall(
                'flutter',
                const <String>['pub', 'publish', '--server=bar', '--force'],
                plugin1.path),
            ProcessCall(
                'flutter',
                const <String>['pub', 'publish', '--server=bar', '--force'],
                plugin2.path),
          ]));
    });

    test('creates credential file from envirnoment variable if necessary',
        () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);
      const String credentials = 'some credential';
      platform.environment['PUB_CREDENTIALS'] = credentials;

      await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
        '--skip-confirmation',
        '--pub-publish-flags',
        '--server=bar'
      ]);

      final File credentialFile = fileSystem.file(command.credentialsPath);
      expect(credentialFile.existsSync(), true);
      expect(credentialFile.readAsStringSync(), credentials);
    });

    test('throws if pub publish fails', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      processRunner.mockProcessesForExecutable['flutter'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(exitCode: 128), <String>['pub', 'publish'])
      ];

      Error? commandError;
      final List<String> output =
          await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
      ], errorHandler: (Error e) {
        commandError = e;
      });

      expect(commandError, isA<ToolExit>());
      expect(
          output,
          containsAllInOrder(<Matcher>[
            contains('Publishing foo failed.'),
          ]));
    });

    test('publish, dry run', () async {
      final RepositoryPackage plugin =
          createFakePlugin('foo', packagesDir, examples: <String>[]);

      final List<String> output =
          await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
        '--dry-run',
      ]);

      expect(
          processRunner.recordedCalls
              .map((ProcessCall call) => call.executable),
          isNot(contains('git-push')));
      expect(
          output,
          containsAllInOrder(<Matcher>[
            contains('=============== DRY RUN ==============='),
            contains('Running for foo'),
            contains('Running `pub publish ` in ${plugin.path}...'),
            contains('Tagging release foo-v0.0.1...'),
            contains('Pushing tag to upstream...'),
            contains('Published foo successfully!'),
          ]));
    });

    test('can publish non-flutter package', () async {
      const String packageName = 'a_package';
      createFakePackage(packageName, packagesDir);

      final List<String> output =
          await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=$packageName',
      ]);

      expect(
        output,
        containsAllInOrder(
          <Matcher>[
            contains('Running `pub publish ` in /packages/a_package...'),
            contains('Package published!'),
          ],
        ),
      );
    });

    test('skips publish with --tag-for-auto-publish', () async {
      const String packageName = 'a_package';
      createFakePackage(packageName, packagesDir);

      final List<String> output =
          await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=$packageName',
        '--tag-for-auto-publish',
      ]);

      // There should be no variant of any command containing "publish".
      expect(
          processRunner.recordedCalls
              .map((ProcessCall call) => call.toString()),
          isNot(contains(contains('publish'))));
      // The output should indicate that it was tagged, not published.
      expect(
        output,
        containsAllInOrder(
          <Matcher>[
            contains('Tagged a_package successfully!'),
          ],
        ),
      );
    });
  });

  group('Tags release', () {
    test('with the version and name from the pubspec.yaml', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);
      await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
      ]);

      expect(processRunner.recordedCalls,
          contains(const ProcessCall('git-tag', <String>['foo-v0.0.1'], null)));
    });

    test('only if publishing succeeded', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      processRunner.mockProcessesForExecutable['flutter'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(exitCode: 128), <String>['pub', 'publish']),
      ];

      Error? commandError;
      final List<String> output =
          await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
      ], errorHandler: (Error e) {
        commandError = e;
      });

      expect(commandError, isA<ToolExit>());
      expect(
          output,
          containsAllInOrder(<Matcher>[
            contains('Publishing foo failed.'),
          ]));
      expect(
          processRunner.recordedCalls,
          isNot(contains(
              const ProcessCall('git-tag', <String>['foo-v0.0.1'], null))));
    });

    test('when passed --tag-for-auto-publish', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);
      await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
        '--tag-for-auto-publish',
      ]);

      expect(processRunner.recordedCalls,
          contains(const ProcessCall('git-tag', <String>['foo-v0.0.1'], null)));
    });
  });

  group('Pushes tags', () {
    test('to upstream by default', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      mockStdin.readLineOutput = 'y';

      final List<String> output =
          await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
      ]);

      expect(
          processRunner.recordedCalls,
          contains(const ProcessCall(
              'git-push', <String>['upstream', 'foo-v0.0.1'], null)));
      expect(
          output,
          containsAllInOrder(<Matcher>[
            contains('Pushing tag to upstream...'),
            contains('Published foo successfully!'),
          ]));
    });

    test('does not ask for user input if the --skip-confirmation flag is on',
        () async {
      createMockCredentialFile();
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      final List<String> output =
          await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--skip-confirmation',
        '--packages=foo',
      ]);

      expect(
          processRunner.recordedCalls,
          contains(const ProcessCall(
              'git-push', <String>['upstream', 'foo-v0.0.1'], null)));
      expect(
          output,
          containsAllInOrder(<Matcher>[
            contains('Published foo successfully!'),
          ]));
    });

    test('when passed --tag-for-auto-publish', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);
      await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
        '--skip-confirmation',
        '--tag-for-auto-publish',
      ]);

      expect(
          processRunner.recordedCalls,
          contains(const ProcessCall(
              'git-push', <String>['upstream', 'foo-v0.0.1'], null)));
    });

    test('to upstream by default, dry run', () async {
      final RepositoryPackage plugin =
          createFakePlugin('foo', packagesDir, examples: <String>[]);

      mockStdin.readLineOutput = 'y';

      final List<String> output = await runCapturingPrint(
          commandRunner, <String>['publish', '--packages=foo', '--dry-run']);

      expect(
          processRunner.recordedCalls
              .map((ProcessCall call) => call.executable),
          isNot(contains('git-push')));
      expect(
          output,
          containsAllInOrder(<Matcher>[
            contains('=============== DRY RUN ==============='),
            contains('Running `pub publish ` in ${plugin.path}...'),
            contains('Tagging release foo-v0.0.1...'),
            contains('Pushing tag to upstream...'),
            contains('Published foo successfully!'),
          ]));
    });

    test('to different remotes based on a flag', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      mockStdin.readLineOutput = 'y';

      final List<String> output =
          await runCapturingPrint(commandRunner, <String>[
        'publish',
        '--packages=foo',
        '--remote',
        'origin',
      ]);

      expect(
          processRunner.recordedCalls,
          contains(const ProcessCall(
              'git-push', <String>['origin', 'foo-v0.0.1'], null)));
      expect(
          output,
          containsAllInOrder(<Matcher>[
            contains('Published foo successfully!'),
          ]));
    });
  });

  group('--already-tagged', () {
    test('passes when HEAD has the expected tag', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      processRunner.mockProcessesForExecutable['git-tag'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess()), // Skip the initializeRun call.
        FakeProcessInfo(MockProcess(stdout: 'foo-v0.0.1\n'),
            <String>['--points-at', 'HEAD'])
      ];

      await runCapturingPrint(commandRunner,
          <String>['publish', '--packages=foo', '--already-tagged']);
    });

    test('fails if HEAD does not have the expected tag', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      Error? commandError;
      final List<String> output = await runCapturingPrint(commandRunner,
          <String>['publish', '--packages=foo', '--already-tagged'],
          errorHandler: (Error e) {
        commandError = e;
      });

      expect(commandError, isA<ToolExit>());
      expect(
          output,
          containsAllInOrder(<Matcher>[
            contains('The current checkout is not already tagged "foo-v0.0.1"'),
            contains('missing tag'),
          ]));
    });

    test('does not create or push tags', () async {
      createFakePlugin('foo', packagesDir, examples: <String>[]);

      processRunner.mockProcessesForExecutable['git-tag'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess()), // Skip the initializeRun call.
        FakeProcessInfo(MockProcess(stdout: 'foo-v0.0.1\n'),
            <String>['--points-at', 'HEAD'])
      ];

      await runCapturingPrint(commandRunner,
          <String>['publish', '--packages=foo', '--already-tagged']);

      expect(
          processRunner.recordedCalls,
          isNot(contains(
              const ProcessCall('git-tag', <String>['foo-v0.0.1'], null))));
      expect(
          processRunner.recordedCalls
              .map((ProcessCall call) => call.executable),
          isNot(contains('git-push')));
    });
  });

  group('Auto release (all-changed flag)', () {
    test('can release newly created plugins', () async {
      mockHttpResponses['plugin1'] = <String, dynamic>{
        'name': 'plugin1',
        'versions': <String>[],
      };

      mockHttpResponses['plugin2'] = <String, dynamic>{
        'name': 'plugin2',
        'versions': <String>[],
      };

      // Non-federated
      final RepositoryPackage plugin1 =
          createFakePlugin('plugin1', packagesDir);
      // federated
      final RepositoryPackage plugin2 = createFakePlugin(
        'plugin2',
        packagesDir.childDirectory('plugin2'),
      );
      processRunner.mockProcessesForExecutable['git-diff'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(
            stdout: '${plugin1.pubspecFile.path}\n'
                '${plugin2.pubspecFile.path}\n'))
      ];
      mockStdin.readLineOutput = 'y';

      final List<String> output = await runCapturingPrint(commandRunner,
          <String>['publish', '--all-changed', '--base-sha=HEAD~']);

      expect(
          output,
          containsAllInOrder(<Matcher>[
            contains(
                'Publishing all packages that have changed relative to "HEAD~"'),
            contains('Running `pub publish ` in ${plugin1.path}...'),
            contains('Running `pub publish ` in ${plugin2.path}...'),
            contains('plugin1 - published'),
            contains('plugin2/plugin2 - published'),
          ]));
      expect(
          processRunner.recordedCalls,
          contains(const ProcessCall(
              'git-push', <String>['upstream', 'plugin1-v0.0.1'], null)));
      expect(
          processRunner.recordedCalls,
          contains(const ProcessCall(
              'git-push', <String>['upstream', 'plugin2-v0.0.1'], null)));
    });

    test('can release newly created plugins, while there are existing plugins',
        () async {
      mockHttpResponses['plugin0'] = <String, dynamic>{
        'name': 'plugin0',
        'versions': <String>['0.0.1'],
      };

      mockHttpResponses['plugin1'] = <String, dynamic>{
        'name': 'plugin1',
        'versions': <String>[],
      };

      mockHttpResponses['plugin2'] = <String, dynamic>{
        'name': 'plugin2',
        'versions': <String>[],
      };

      // The existing plugin.
      createFakePlugin('plugin0', packagesDir);
      // Non-federated
      final RepositoryPackage plugin1 =
          createFakePlugin('plugin1', packagesDir);
      // federated
      final RepositoryPackage plugin2 =
          createFakePlugin('plugin2', packagesDir.childDirectory('plugin2'));

      // Git results for plugin0 having been released already, and plugin1 and
      // plugin2 being new.
      processRunner.mockProcessesForExecutable['git-tag'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(stdout: 'plugin0-v0.0.1\n'))
      ];
      processRunner.mockProcessesForExecutable['git-diff'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(
            stdout: '${plugin1.pubspecFile.path}\n'
                '${plugin2.pubspecFile.path}\n'))
      ];

      mockStdin.readLineOutput = 'y';

      final List<String> output = await runCapturingPrint(commandRunner,
          <String>['publish', '--all-changed', '--base-sha=HEAD~']);

      expect(
          output,
          containsAllInOrder(<String>[
            'Running `pub publish ` in ${plugin1.path}...\n',
            'Running `pub publish ` in ${plugin2.path}...\n',
          ]));
      expect(
          processRunner.recordedCalls,
          contains(const ProcessCall(
              'git-push', <String>['upstream', 'plugin1-v0.0.1'], null)));
      expect(
          processRunner.recordedCalls,
          contains(const ProcessCall(
              'git-push', <String>['upstream', 'plugin2-v0.0.1'], null)));
    });

    test('can release newly created plugins, dry run', () async {
      mockHttpResponses['plugin1'] = <String, dynamic>{
        'name': 'plugin1',
        'versions': <String>[],
      };

      mockHttpResponses['plugin2'] = <String, dynamic>{
        'name': 'plugin2',
        'versions': <String>[],
      };

      // Non-federated
      final RepositoryPackage plugin1 =
          createFakePlugin('plugin1', packagesDir);
      // federated
      final RepositoryPackage plugin2 =
          createFakePlugin('plugin2', packagesDir.childDirectory('plugin2'));

      processRunner.mockProcessesForExecutable['git-diff'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(
            stdout: '${plugin1.pubspecFile.path}\n'
                '${plugin2.pubspecFile.path}\n'))
      ];
      mockStdin.readLineOutput = 'y';

      final List<String> output = await runCapturingPrint(
          commandRunner, <String>[
        'publish',
        '--all-changed',
        '--base-sha=HEAD~',
        '--dry-run'
      ]);

      expect(
          output,
          containsAllInOrder(<Matcher>[
            contains('=============== DRY RUN ==============='),
            contains('Running `pub publish ` in ${plugin1.path}...'),
            contains('Tagging release plugin1-v0.0.1...'),
            contains('Pushing tag to upstream...'),
            contains('Published plugin1 successfully!'),
            contains('Running `pub publish ` in ${plugin2.path}...'),
            contains('Tagging release plugin2-v0.0.1...'),
            contains('Pushing tag to upstream...'),
            contains('Published plugin2 successfully!'),
          ]));
      expect(
          processRunner.recordedCalls
              .map((ProcessCall call) => call.executable),
          isNot(contains('git-push')));
    });

    test('version change triggers releases.', () async {
      mockHttpResponses['plugin1'] = <String, dynamic>{
        'name': 'plugin1',
        'versions': <String>['0.0.1'],
      };

      mockHttpResponses['plugin2'] = <String, dynamic>{
        'name': 'plugin2',
        'versions': <String>['0.0.1'],
      };

      // Non-federated
      final RepositoryPackage plugin1 =
          createFakePlugin('plugin1', packagesDir, version: '0.0.2');
      // federated
      final RepositoryPackage plugin2 = createFakePlugin(
          'plugin2', packagesDir.childDirectory('plugin2'),
          version: '0.0.2');

      processRunner.mockProcessesForExecutable['git-diff'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(
            stdout: '${plugin1.pubspecFile.path}\n'
                '${plugin2.pubspecFile.path}\n'))
      ];

      mockStdin.readLineOutput = 'y';

      final List<String> output2 = await runCapturingPrint(commandRunner,
          <String>['publish', '--all-changed', '--base-sha=HEAD~']);
      expect(
          output2,
          containsAllInOrder(<Matcher>[
            contains('Running `pub publish ` in ${plugin1.path}...'),
            contains('Published plugin1 successfully!'),
            contains('Running `pub publish ` in ${plugin2.path}...'),
            contains('Published plugin2 successfully!'),
          ]));
      expect(
          processRunner.recordedCalls,
          contains(const ProcessCall(
              'git-push', <String>['upstream', 'plugin1-v0.0.2'], null)));
      expect(
          processRunner.recordedCalls,
          contains(const ProcessCall(
              'git-push', <String>['upstream', 'plugin2-v0.0.2'], null)));
    });

    test(
        'delete package will not trigger publish but exit the command successfully!',
        () async {
      mockHttpResponses['plugin1'] = <String, dynamic>{
        'name': 'plugin1',
        'versions': <String>['0.0.1'],
      };

      mockHttpResponses['plugin2'] = <String, dynamic>{
        'name': 'plugin2',
        'versions': <String>['0.0.1'],
      };

      // Non-federated
      final RepositoryPackage plugin1 =
          createFakePlugin('plugin1', packagesDir, version: '0.0.2');
      // federated
      final RepositoryPackage plugin2 =
          createFakePlugin('plugin2', packagesDir.childDirectory('plugin2'));
      plugin2.directory.deleteSync(recursive: true);

      processRunner.mockProcessesForExecutable['git-diff'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(
            stdout: '${plugin1.pubspecFile.path}\n'
                '${plugin2.pubspecFile.path}\n'))
      ];

      mockStdin.readLineOutput = 'y';

      final List<String> output2 = await runCapturingPrint(commandRunner,
          <String>['publish', '--all-changed', '--base-sha=HEAD~']);
      expect(
          output2,
          containsAllInOrder(<Matcher>[
            contains('Running `pub publish ` in ${plugin1.path}...'),
            contains('Published plugin1 successfully!'),
            contains(
                'The pubspec file for plugin2/plugin2 does not exist, so no publishing will happen.\nSafe to ignore if the package is deleted in this commit.\n'),
            contains('SKIPPING: package deleted'),
            contains('skipped (with warning)'),
          ]));
      expect(
          processRunner.recordedCalls,
          contains(const ProcessCall(
              'git-push', <String>['upstream', 'plugin1-v0.0.2'], null)));
    });

    test('Existing versions do not trigger release, also prints out message.',
        () async {
      mockHttpResponses['plugin1'] = <String, dynamic>{
        'name': 'plugin1',
        'versions': <String>['0.0.2'],
      };

      mockHttpResponses['plugin2'] = <String, dynamic>{
        'name': 'plugin2',
        'versions': <String>['0.0.2'],
      };

      // Non-federated
      final RepositoryPackage plugin1 =
          createFakePlugin('plugin1', packagesDir, version: '0.0.2');
      // federated
      final RepositoryPackage plugin2 = createFakePlugin(
          'plugin2', packagesDir.childDirectory('plugin2'),
          version: '0.0.2');

      processRunner.mockProcessesForExecutable['git-diff'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(
            stdout: '${plugin1.pubspecFile.path}\n'
                '${plugin2.pubspecFile.path}\n'))
      ];
      processRunner.mockProcessesForExecutable['git-tag'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(
            stdout: 'plugin1-v0.0.2\n'
                'plugin2-v0.0.2\n'))
      ];

      final List<String> output = await runCapturingPrint(commandRunner,
          <String>['publish', '--all-changed', '--base-sha=HEAD~']);

      expect(
          output,
          containsAllInOrder(<Matcher>[
            contains('plugin1 0.0.2 has already been published'),
            contains('SKIPPING: already published'),
            contains('plugin2 0.0.2 has already been published'),
            contains('SKIPPING: already published'),
          ]));

      expect(
          processRunner.recordedCalls
              .map((ProcessCall call) => call.executable),
          isNot(contains('git-push')));
    });

    test(
        'Existing versions do not trigger release, but fail if the tags do not exist.',
        () async {
      mockHttpResponses['plugin1'] = <String, dynamic>{
        'name': 'plugin1',
        'versions': <String>['0.0.2'],
      };

      mockHttpResponses['plugin2'] = <String, dynamic>{
        'name': 'plugin2',
        'versions': <String>['0.0.2'],
      };

      // Non-federated
      final RepositoryPackage plugin1 =
          createFakePlugin('plugin1', packagesDir, version: '0.0.2');
      // federated
      final RepositoryPackage plugin2 = createFakePlugin(
          'plugin2', packagesDir.childDirectory('plugin2'),
          version: '0.0.2');

      processRunner.mockProcessesForExecutable['git-diff'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(
            stdout: '${plugin1.pubspecFile.path}\n'
                '${plugin2.pubspecFile.path}\n'))
      ];

      Error? commandError;
      final List<String> output = await runCapturingPrint(commandRunner,
          <String>['publish', '--all-changed', '--base-sha=HEAD~'],
          errorHandler: (Error e) {
        commandError = e;
      });

      expect(commandError, isA<ToolExit>());
      expect(
          output,
          containsAllInOrder(<Matcher>[
            contains('plugin1 0.0.2 has already been published, '
                'however the git release tag (plugin1-v0.0.2) was not found.'),
            contains('plugin2 0.0.2 has already been published, '
                'however the git release tag (plugin2-v0.0.2) was not found.'),
          ]));
      expect(
          processRunner.recordedCalls
              .map((ProcessCall call) => call.executable),
          isNot(contains('git-push')));
    });

    test('No version change does not release any plugins', () async {
      // Non-federated
      final RepositoryPackage plugin1 =
          createFakePlugin('plugin1', packagesDir);
      // federated
      final RepositoryPackage plugin2 =
          createFakePlugin('plugin2', packagesDir.childDirectory('plugin2'));

      processRunner.mockProcessesForExecutable['git-diff'] = <FakeProcessInfo>[
        FakeProcessInfo(MockProcess(
            stdout: '${plugin1.libDirectory.childFile('plugin1.dart').path}\n'
                '${plugin2.libDirectory.childFile('plugin2.dart').path}\n'))
      ];

      final List<String> output = await runCapturingPrint(commandRunner,
          <String>['publish', '--all-changed', '--base-sha=HEAD~']);

      expect(output, containsAllInOrder(<String>['Ran for 0 package(s)']));
      expect(
          processRunner.recordedCalls
              .map((ProcessCall call) => call.executable),
          isNot(contains('git-push')));
    });

    test('Do not release flutter_plugin_tools', () async {
      mockHttpResponses['plugin1'] = <String, dynamic>{
        'name': 'flutter_plugin_tools',
        'versions': <String>[],
      };

      final RepositoryPackage flutterPluginTools =
          createFakePlugin('flutter_plugin_tools', packagesDir);
      processRunner.mockProcessesForExecutable['git-diff'] = <FakeProcessInfo>[
        FakeProcessInfo(
            MockProcess(stdout: flutterPluginTools.pubspecFile.path))
      ];

      final List<String> output = await runCapturingPrint(commandRunner,
          <String>['publish', '--all-changed', '--base-sha=HEAD~']);

      expect(
          output,
          containsAllInOrder(<Matcher>[
            contains(
                'SKIPPING: publishing flutter_plugin_tools via the tool is not supported')
          ]));
      expect(
          output.contains(
            'Running `pub publish ` in ${flutterPluginTools.path}...',
          ),
          isFalse);
      expect(
          processRunner.recordedCalls
              .map((ProcessCall call) => call.executable),
          isNot(contains('git-push')));
    });
  });

  group('credential location', () {
    test('Linux with XDG', () async {
      platform = MockPlatform(isLinux: true);
      platform.environment['XDG_CONFIG_HOME'] = '/xdghome/config';
      command = PublishCommand(packagesDir, platform: platform);

      expect(
          command.credentialsPath, '/xdghome/config/dart/pub-credentials.json');
    });

    test('Linux without XDG', () async {
      platform = MockPlatform(isLinux: true);
      platform.environment['HOME'] = '/home';
      command = PublishCommand(packagesDir, platform: platform);

      expect(
          command.credentialsPath, '/home/.config/dart/pub-credentials.json');
    });

    test('macOS', () async {
      platform = MockPlatform(isMacOS: true);
      platform.environment['HOME'] = '/Users/someuser';
      command = PublishCommand(packagesDir, platform: platform);

      expect(command.credentialsPath,
          '/Users/someuser/Library/Application Support/dart/pub-credentials.json');
    });

    test('Windows', () async {
      platform = MockPlatform(isWindows: true);
      platform.environment['APPDATA'] = r'C:\Users\SomeUser\AppData';
      command = PublishCommand(packagesDir, platform: platform);

      expect(command.credentialsPath,
          r'C:\Users\SomeUser\AppData\dart\pub-credentials.json');
    });
  });
}

/// An extension of [RecordingProcessRunner] that stores 'flutter pub publish'
/// calls so that their input streams can be checked in tests.
class TestProcessRunner extends RecordingProcessRunner {
  // Most recent returned publish process.
  late MockProcess mockPublishProcess;

  @override
  Future<io.Process> start(String executable, List<String> args,
      {Directory? workingDirectory}) async {
    final io.Process process =
        await super.start(executable, args, workingDirectory: workingDirectory);
    if (executable == 'flutter' &&
        args.isNotEmpty &&
        args[0] == 'pub' &&
        args[1] == 'publish') {
      mockPublishProcess = process as MockProcess;
    }
    return process;
  }
}

class MockStdin extends Mock implements io.Stdin {
  List<List<int>> mockUserInputs = <List<int>>[];
  final StreamController<List<int>> _controller = StreamController<List<int>>();
  String? readLineOutput;

  @override
  Stream<S> transform<S>(StreamTransformer<List<int>, S> streamTransformer) {
    mockUserInputs.forEach(_addUserInputsToSteam);
    return _controller.stream.transform(streamTransformer);
  }

  @override
  StreamSubscription<List<int>> listen(void Function(List<int> event)? onData,
      {Function? onError, void Function()? 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;

  void _addUserInputsToSteam(List<int> input) => _controller.add(input);
}