// Copyright 2014 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:convert';
import 'dart:io' show ProcessResult;
import 'dart:typed_data';

import 'package:file/file.dart';
import 'package:file/memory.dart';
import 'package:path/path.dart' as path;
import 'package:platform/platform.dart' show FakePlatform, Platform;

import '../../../packages/flutter_tools/test/src/fake_process_manager.dart';
import '../prepare_package/archive_creator.dart';
import '../prepare_package/archive_publisher.dart';
import '../prepare_package/common.dart';
import '../prepare_package/process_runner.dart';
import '../prepare_package/transactional_update.dart';
import 'common.dart';

void main() {
  const testRef = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef';

  test('PreparePackageException string includes class name and stderr', () {
    final exception = PreparePackageException(
      'failed to prepare package',
      ProcessResult(0, 1, '', 'stderr details'),
    );

    expect(
      exception.toString(),
      'PreparePackageException: failed to prepare package:\nstderr details',
    );
  });

  test('Throws on missing executable', () async {
    // Uses a *real* process manager, since we want to know what happens if
    // it can't find an executable.
    final processRunner = ProcessRunner(subprocessOutput: false);
    expect(
      expectAsync1((List<String> commandLine) async {
        return processRunner.runProcess(commandLine);
      })(<String>['this_executable_better_not_exist_2857632534321']),
      throwsA(isA<PreparePackageException>()),
    );

    await expectLater(
      () => processRunner.runProcess(<String>['this_executable_better_not_exist_2857632534321']),
      throwsA(
        isA<PreparePackageException>().having(
          (PreparePackageException error) => error.message,
          'message',
          contains(
            'ProcessException: Failed to find "this_executable_better_not_exist_2857632534321" in the search path',
          ),
        ),
      ),
    );
  });
  for (final platformName in <String>[Platform.macOS, Platform.linux, Platform.windows]) {
    final platform = FakePlatform(
      operatingSystem: platformName,
      environment: <String, String>{
        'DEPOT_TOOLS': platformName == Platform.windows
            ? path.join('D:', 'depot_tools')
            : '/depot_tools',
      },
    );
    group('ProcessRunner for $platform', () {
      test('Returns stdout', () async {
        final fakeProcessManager = FakeProcessManager.list(<FakeCommand>[
          const FakeCommand(command: <String>['echo', 'test'], stdout: 'output', stderr: 'error'),
        ]);
        final processRunner = ProcessRunner(
          subprocessOutput: false,
          platform: platform,
          processManager: fakeProcessManager,
        );
        final String output = await processRunner.runProcess(<String>['echo', 'test']);
        expect(output, equals('output'));
      });
      test('Throws on process failure', () async {
        final fakeProcessManager = FakeProcessManager.list(<FakeCommand>[
          const FakeCommand(
            command: <String>['echo', 'test'],
            stdout: 'output',
            stderr: 'error',
            exitCode: -1,
          ),
        ]);
        final processRunner = ProcessRunner(
          subprocessOutput: false,
          platform: platform,
          processManager: fakeProcessManager,
        );
        expect(
          expectAsync1((List<String> commandLine) async {
            return processRunner.runProcess(commandLine);
          })(<String>['echo', 'test']),
          throwsA(isA<PreparePackageException>()),
        );
      });
    });

    group('ArchiveCreator for $platformName', () {
      late ArchiveCreator creator;
      late Directory tempDir;
      Directory flutterDir;
      Directory cacheDir;
      late FakeProcessManager processManager;
      late FileSystem fs;
      final args = <List<String>>[];
      final namedArgs = <Map<Symbol, Object?>>[];
      late String flutter;
      late String dart;

      Future<Uint8List> fakeHttpReader(Uri url, {Map<String, String>? headers}) {
        return Future<Uint8List>.value(Uint8List(0));
      }

      setUp(() async {
        processManager = FakeProcessManager.list(<FakeCommand>[]);
        args.clear();
        namedArgs.clear();
        fs = MemoryFileSystem.test();
        tempDir = fs.systemTempDirectory;
        flutterDir = fs.directory(path.join(tempDir.path, 'flutter'));
        flutterDir.createSync(recursive: true);
        cacheDir = fs.directory(path.join(flutterDir.path, 'bin', 'cache'));
        cacheDir.createSync(recursive: true);
        creator = ArchiveCreator(
          tempDir,
          tempDir,
          testRef,
          Branch.beta,
          fs: fs,
          processManager: processManager,
          subprocessOutput: false,
          platform: platform,
          httpReader: fakeHttpReader,
        );
        flutter = path.join(creator.flutterRoot.absolute.path, 'bin', 'flutter');
        dart = path.join(
          creator.flutterRoot.absolute.path,
          'bin',
          'cache',
          'dart-sdk',
          'bin',
          'dart',
        );
      });

      tearDown(() async {
        tryToDelete(tempDir);
      });

      /// The subprocesses expected during archive creation, keyed by command-line.
      ///
      /// [dartArch] is the architecture `dart --version` reports for the Dart
      /// SDK downloaded into the archive. [archiveArch] is the architecture
      /// expected in the archive filename, or null for x64, which is unadorned.
      /// [precacheArgs] are appended to the `flutter precache` invocation.
      Map<String, List<ProcessResult>?> expectedCalls({
        required String dartArch,
        String? archiveArch,
        List<String> precacheArgs = const <String>[],
      }) {
        final String createBase = path.join(tempDir.absolute.path, 'create_');
        final archPrefix = archiveArch == null ? '' : '${archiveArch}_';
        final String archiveName = path.join(
          tempDir.absolute.path,
          'flutter_${platformName}_${archPrefix}v1.2.3-beta'
          '${platform.isLinux ? '.tar.xz' : '.zip'}',
        );
        return <String, List<ProcessResult>?>{
          'git clone -b beta https://flutter.googlesource.com/mirrors/flutter': null,
          'git reset --hard $testRef': null,
          'git remote set-url origin https://github.com/flutter/flutter.git': null,
          'git gc --prune=now --aggressive': null,
          'git describe --tags --exact-match $testRef': <ProcessResult>[
            ProcessResult(0, 0, 'v1.2.3', ''),
          ],
          '$flutter --version --machine': <ProcessResult>[
            ProcessResult(0, 0, '{"dartSdkVersion": "3.2.1"}', ''),
            ProcessResult(0, 0, '{"dartSdkVersion": "3.2.1"}', ''),
          ],
          '$dart --version': <ProcessResult>[
            ProcessResult(
              0,
              0,
              'Dart SDK version: 2.17.0-63.0.beta (beta) (Wed Jan 26 03:48:52 2022 -0800) on "${platformName}_$dartArch"',
              '',
            ),
          ],
          if (platform.isWindows) '7za x ${path.join(tempDir.path, 'mingit.zip')}': null,
          '$flutter doctor': null,
          '$flutter update-packages': null,
          <String>['$flutter precache', ...precacheArgs].join(' '): null,
          '$flutter ide-config': null,
          '$flutter create --template=app ${createBase}app': null,
          '$flutter create --template=package ${createBase}package': null,
          '$flutter create --template=plugin ${createBase}plugin': null,
          '$flutter pub cache list': <ProcessResult>[ProcessResult(0, 0, '{"packages":{}}', '')],
          'git clean -f -x -- **/.packages': null,
          'git clean -f -x -- **/.dart_tool/': null,
          if (platform.isMacOS)
            'codesign -vvvv --check-notarization ${path.join(tempDir.path, 'flutter', 'bin', 'cache', 'dart-sdk', 'bin', 'dart')}':
                null,
          if (platform.isWindows) 'attrib -h .git': null,
          if (platform.isWindows)
            '7za a -tzip -mx=9 $archiveName flutter': null
          else if (platform.isMacOS)
            'zip -r -9 --symlinks $archiveName flutter': null
          else if (platform.isLinux)
            'tar cJf $archiveName --verbose flutter': null,
        };
      }

      /// The environment expected on every [ArchiveCreator] subprocess.
      ///
      /// This is the ambient environment, plus the archive's own pub cache,
      /// plus the host architecture override when one was requested.
      Map<String, String> expectedEnvironment({TargetArch? targetArch}) {
        return <String, String>{
          ...platform.environment,
          'PUB_CACHE': path.join(tempDir.path, '.pub-cache'),
          if (targetArch != null) 'FLUTTER_HOST_ARCH': targetArch.name,
        };
      }

      test('sets PUB_CACHE properly', () async {
        processManager.addCommands(
          convertResults(expectedCalls(dartArch: 'x64'), environment: expectedEnvironment()),
        );
        await creator.initializeRepo();
        await creator.createArchive();
      });

      test('calls the right commands for archive output', () async {
        processManager.addCommands(convertResults(expectedCalls(dartArch: 'x64')));
        await creator.initializeRepo();
        await creator.createArchive();
      });

      test('adds the arch name to the archive for non-x64', () async {
        processManager.addCommands(
          convertResults(expectedCalls(dartArch: 'arm64', archiveArch: 'arm64')),
        );
        await creator.initializeRepo();
        await creator.createArchive();
      });

      test('targetArch drives the precache arch and the tool environment', () async {
        processManager.addCommands(
          convertResults(
            expectedCalls(dartArch: 'x64', precacheArgs: <String>['--host-arch=x64']),
            environment: expectedEnvironment(targetArch: TargetArch.x64),
          ),
        );
        creator = ArchiveCreator(
          tempDir,
          tempDir,
          testRef,
          Branch.beta,
          fs: fs,
          processManager: processManager,
          subprocessOutput: false,
          platform: platform,
          httpReader: fakeHttpReader,
          targetArch: TargetArch.x64,
        );
        await creator.initializeRepo();
        await creator.createArchive();
      });

      test('targetArch names a non-x64 archive after the requested arch', () async {
        processManager.addCommands(
          convertResults(
            expectedCalls(
              dartArch: 'arm64',
              archiveArch: 'arm64',
              precacheArgs: <String>['--host-arch=arm64'],
            ),
            environment: expectedEnvironment(targetArch: TargetArch.arm64),
          ),
        );
        creator = ArchiveCreator(
          tempDir,
          tempDir,
          testRef,
          Branch.beta,
          fs: fs,
          processManager: processManager,
          subprocessOutput: false,
          platform: platform,
          httpReader: fakeHttpReader,
          targetArch: TargetArch.arm64,
        );
        await creator.initializeRepo();
        await creator.createArchive();
      });

      test('throws if the archived Dart SDK does not match targetArch', () async {
        // The requested arch names the archive, but the arch published in the
        // release metadata comes from the Dart SDK that was downloaded, so a
        // disagreement has to fail the build rather than ship a mislabelled
        // archive.
        //
        // The mismatch is caught in initializeRepo, so only the commands up to
        // and including `dart --version` are ever run.
        final Map<String, List<ProcessResult>?> calls = expectedCalls(dartArch: 'arm64');
        final int callsBeforeThrow = calls.keys.toList().indexOf('$dart --version') + 1;
        processManager.addCommands(
          convertResults(
            Map<String, List<ProcessResult>?>.fromEntries(calls.entries.take(callsBeforeThrow)),
          ),
        );
        creator = ArchiveCreator(
          tempDir,
          tempDir,
          testRef,
          Branch.beta,
          fs: fs,
          processManager: processManager,
          subprocessOutput: false,
          platform: platform,
          httpReader: fakeHttpReader,
          targetArch: TargetArch.x64,
        );
        await expectLater(
          creator.initializeRepo,
          throwsA(
            isA<PreparePackageException>().having(
              (PreparePackageException error) => error.message,
              'message',
              contains('Requested an archive for x64, but the Dart SDK in the archive is arm64'),
            ),
          ),
        );
      });

      test('throws when a command errors out', () async {
        final calls = <String, List<ProcessResult>>{
          'git clone -b beta https://flutter.googlesource.com/mirrors/flutter': <ProcessResult>[
            ProcessResult(0, 0, 'output1', ''),
          ],
          'git reset --hard $testRef': <ProcessResult>[ProcessResult(0, -1, 'output2', '')],
        };
        processManager.addCommands(convertResults(calls));
        expect(expectAsync0(creator.initializeRepo), throwsA(isA<PreparePackageException>()));
      });

      test('non-strict mode calls the right commands', () async {
        final String createBase = path.join(tempDir.absolute.path, 'create_');
        final String archiveName = path.join(
          tempDir.absolute.path,
          'flutter_${platformName}_v1.2.3-beta${platform.isLinux ? '.tar.xz' : '.zip'}',
        );
        final calls = <String, List<ProcessResult>?>{
          'git clone -b beta https://flutter.googlesource.com/mirrors/flutter': null,
          'git reset --hard $testRef': null,
          'git remote set-url origin https://github.com/flutter/flutter.git': null,
          'git gc --prune=now --aggressive': null,
          'git describe --tags --abbrev=0 $testRef': <ProcessResult>[
            ProcessResult(0, 0, 'v1.2.3', ''),
          ],
          '$flutter --version --machine': <ProcessResult>[
            ProcessResult(0, 0, '{"dartSdkVersion": "3.2.1"}', ''),
            ProcessResult(0, 0, '{"dartSdkVersion": "3.2.1"}', ''),
          ],
          '$dart --version': <ProcessResult>[
            ProcessResult(
              0,
              0,
              'Dart SDK version: 2.17.0-63.0.beta (beta) (Wed Jan 26 03:48:52 2022 -0800) on "${platformName}_x64"',
              '',
            ),
          ],
          if (platform.isWindows) '7za x ${path.join(tempDir.path, 'mingit.zip')}': null,
          '$flutter doctor': null,
          '$flutter update-packages': null,
          '$flutter precache': null,
          '$flutter ide-config': null,
          '$flutter create --template=app ${createBase}app': null,
          '$flutter create --template=package ${createBase}package': null,
          '$flutter create --template=plugin ${createBase}plugin': null,
          '$flutter pub cache list': <ProcessResult>[ProcessResult(0, 0, '{"packages":{}}', '')],
          'git clean -f -x -- **/.packages': null,
          'git clean -f -x -- **/.dart_tool/': null,
          if (platform.isWindows) 'attrib -h .git': null,
          if (platform.isWindows)
            '7za a -tzip -mx=9 $archiveName flutter': null
          else if (platform.isMacOS)
            'zip -r -9 --symlinks $archiveName flutter': null
          else if (platform.isLinux)
            'tar cJf $archiveName --verbose flutter': null,
        };
        processManager.addCommands(convertResults(calls));
        creator = ArchiveCreator(
          tempDir,
          tempDir,
          testRef,
          Branch.beta,
          fs: fs,
          strict: false,
          processManager: processManager,
          subprocessOutput: false,
          platform: platform,
          httpReader: fakeHttpReader,
        );
        await creator.initializeRepo();
        await creator.createArchive();
      });

      test('fails if binary is not codesigned', () async {
        final String createBase = path.join(tempDir.absolute.path, 'create_');
        final String archiveName = path.join(
          tempDir.absolute.path,
          'flutter_${platformName}_v1.2.3-beta${platform.isLinux ? '.tar.xz' : '.zip'}',
        );
        final codesignFailure = ProcessResult(1, 1, '', 'code object is not signed at all');
        final String binPath = path.join(
          tempDir.path,
          'flutter',
          'bin',
          'cache',
          'dart-sdk',
          'bin',
          'dart',
        );
        final calls = <String, List<ProcessResult>?>{
          'git clone -b beta https://flutter.googlesource.com/mirrors/flutter': null,
          'git reset --hard $testRef': null,
          'git remote set-url origin https://github.com/flutter/flutter.git': null,
          'git gc --prune=now --aggressive': null,
          'git describe --tags --exact-match $testRef': <ProcessResult>[
            ProcessResult(0, 0, 'v1.2.3', ''),
          ],
          '$flutter --version --machine': <ProcessResult>[
            ProcessResult(0, 0, '{"dartSdkVersion": "3.2.1"}', ''),
            ProcessResult(0, 0, '{"dartSdkVersion": "3.2.1"}', ''),
          ],
          '$dart --version': <ProcessResult>[
            ProcessResult(
              0,
              0,
              'Dart SDK version: 2.17.0-63.0.beta (beta) (Wed Jan 26 03:48:52 2022 -0800) on "${platformName}_x64"',
              '',
            ),
          ],
          if (platform.isWindows) '7za x ${path.join(tempDir.path, 'mingit.zip')}': null,
          '$flutter doctor': null,
          '$flutter update-packages': null,
          '$flutter precache': null,
          '$flutter ide-config': null,
          '$flutter create --template=app ${createBase}app': null,
          '$flutter create --template=package ${createBase}package': null,
          '$flutter create --template=plugin ${createBase}plugin': null,
          '$flutter pub cache list': <ProcessResult>[ProcessResult(0, 0, '{"packages":{}}', '')],
          'git clean -f -x -- **/.packages': null,
          'git clean -f -x -- **/.dart_tool/': null,
          if (platform.isMacOS)
            'codesign -vvvv --check-notarization $binPath': <ProcessResult>[codesignFailure],
          if (platform.isWindows) 'attrib -h .git': null,
          if (platform.isWindows)
            '7za a -tzip -mx=9 $archiveName flutter': null
          else if (platform.isMacOS)
            'zip -r -9 --symlinks $archiveName flutter': null
          else if (platform.isLinux)
            'tar cJf $archiveName flutter': null,
        };
        processManager.addCommands(convertResults(calls));
        creator = ArchiveCreator(
          tempDir,
          tempDir,
          testRef,
          Branch.beta,
          fs: fs,
          processManager: processManager,
          subprocessOutput: false,
          platform: platform,
          httpReader: fakeHttpReader,
        );
        await creator.initializeRepo();

        await expectLater(
          () => creator.createArchive(),
          throwsA(
            isA<PreparePackageException>().having(
              (PreparePackageException exception) => exception.message,
              'message',
              contains('The binary $binPath was not codesigned!'),
            ),
          ),
        );
      }, skip: !platform.isMacOS); // [intended] codesign is only available on macOS
    });

    group('ArchivePublisher for $platformName', () {
      late FakeProcessManager processManager;
      late Directory tempDir;
      late FileSystem fs;
      final gsutilCall = platform.isWindows
          ? 'python3 ${path.join("D:", "depot_tools", "gsutil.py")}'
          : 'python3 ${path.join("/", "depot_tools", "gsutil.py")}';
      final releasesName = 'releases_$platformName.json';
      final archiveName = platform.isLinux ? 'archive.tar.xz' : 'archive.zip';
      final archiveMime = platform.isLinux ? 'application/x-gtar' : 'application/zip';
      final gsArchivePath = 'gs://flutter_infra_release/releases/stable/$platformName/$archiveName';

      setUp(() async {
        fs = MemoryFileSystem.test(
          style: platform.isWindows ? FileSystemStyle.windows : FileSystemStyle.posix,
        );
        processManager = FakeProcessManager.list(<FakeCommand>[]);
        tempDir = fs.systemTempDirectory.createTempSync('flutter_prepage_package_test.');
      });

      tearDown(() async {
        tryToDelete(tempDir);
      });

      test('calls the right processes', () async {
        final String archivePath = path.join(tempDir.absolute.path, archiveName);
        final String jsonPath = path.join(tempDir.absolute.path, releasesName);
        final gsJsonPath = 'gs://flutter_infra_release/releases/$releasesName';
        final releasesJson =
            '''
{
  "base_url": "https://storage.googleapis.com/flutter_infra_release/releases",
  "current_release": {
    "beta": "3ea4d06340a97a1e9d7cae97567c64e0569dcaa2",
    "beta": "5a58b36e36b8d7aace89d3950e6deb307956a6a0"
  },
  "releases": [
    {
      "hash": "5a58b36e36b8d7aace89d3950e6deb307956a6a0",
      "channel": "beta",
      "version": "v0.2.3",
      "release_date": "2018-03-20T01:47:02.851729Z",
      "archive": "beta/$platformName/flutter_${platformName}_v0.2.3-beta.zip",
      "sha256": "4fe85a822093e81cb5a66c7fc263f68de39b5797b294191b6d75e7afcc86aff8",
      "dart_sdk_arch": "x64"
    },
    {
      "hash": "b9bd51cc36b706215915711e580851901faebb40",
      "channel": "beta",
      "version": "v0.2.2",
      "release_date": "2018-03-16T18:48:13.375013Z",
      "archive": "beta/$platformName/flutter_${platformName}_v0.2.2-beta.zip",
      "sha256": "6073331168cdb37a4637a5dc073d6a7ef4e466321effa2c529fa27d2253a4d4b",
      "dart_sdk_arch": "x64"
    },
    {
      "hash": "$testRef",
      "channel": "stable",
      "version": "v0.0.0",
      "release_date": "2018-03-20T01:47:02.851729Z",
      "archive": "stable/$platformName/flutter_${platformName}_v0.0.0-beta.zip",
      "sha256": "5dd34873b3a3e214a32fd30c2c319a0f46e608afb72f0d450b2d621a6d02aebd",
      "dart_sdk_arch": "x64"
    }
  ]
}
''';
        fs.file(jsonPath).writeAsStringSync(releasesJson);
        fs.file(fs.path.join(tempDir.path, 'downloaded.json')).createSync(recursive: true);
        fs.file(fs.path.join(tempDir.path, 'downloaded.json')).writeAsStringSync(releasesJson);
        fs.file(archivePath).writeAsStringSync('archive contents');
        final calls = <String, List<ProcessResult>?>{
          '$gsutilCall -- stat $gsArchivePath': <ProcessResult>[ProcessResult(0, 1, '', '')],
          '$gsutilCall -- rm $gsArchivePath': null,
          '$gsutilCall -- -h Content-Type:$archiveMime cp $archivePath $gsArchivePath': null,
          '$gsutilCall -- stat $gsJsonPath': <ProcessResult>[
            ProcessResult(0, 0, 'Generation: 12345', ''),
          ],
          '$gsutilCall -- cp $gsJsonPath#12345 ${fs.path.join(tempDir.path, "downloaded.json")}':
              null,
          '$gsutilCall -- -h x-goog-if-generation-match:12345 cp ${fs.path.join(tempDir.path, "upload.json")} $gsJsonPath':
              null,
        };
        processManager.addCommands(convertResults(calls));
        final File outputFile = fs.file(path.join(tempDir.absolute.path, archiveName));
        outputFile.createSync();
        assert(tempDir.existsSync());
        final publisher = ArchivePublisher(
          tempDir,
          testRef,
          Branch.stable,
          <String, String>{
            'frameworkVersionFromGit': 'v1.2.3',
            'dartSdkVersion': '3.2.1',
            'dartTargetArch': 'x64',
          },
          outputFile,
          false,
          fs: fs,
          processManager: processManager,
          subprocessOutput: false,
          platform: platform,
        );
        assert(tempDir.existsSync());
        await publisher.publishArchive();

        final File releaseFile = fs.file(fs.path.join(tempDir.path, 'upload.json'));
        expect(releaseFile.existsSync(), isTrue);
        final String contents = releaseFile.readAsStringSync();
        // Make sure new data is added.
        expect(contents, contains('"hash": "$testRef"'));
        expect(contents, contains('"channel": "stable"'));
        expect(contents, contains('"archive": "stable/$platformName/$archiveName"'));
        expect(
          contents,
          contains('"sha256": "f69f4865f861193a91d1c5544a894167a7137b788d10bac8edbf5d095f45cb4d"'),
        );
        // Make sure existing entries are preserved.
        expect(contents, contains('"hash": "5a58b36e36b8d7aace89d3950e6deb307956a6a0"'));
        expect(contents, contains('"hash": "b9bd51cc36b706215915711e580851901faebb40"'));
        expect(contents, contains('"channel": "beta"'));
        expect(contents, contains('"channel": "beta"'));
        // Make sure old matching entries are removed.
        expect(contents, isNot(contains('v0.0.0')));
        final jsonData = json.decode(contents) as Map<String, Object?>;
        final releases = jsonData['releases']! as List<Object?>;
        expect(releases.length, equals(3));
        // Make sure the new entry is first (and hopefully it takes less than a
        // minute to go from publishArchive above to this line!).
        expect(
          DateTime.now().difference(
            DateTime.parse((releases[0]! as Map<String, Object?>)['release_date']! as String),
          ),
          lessThan(const Duration(minutes: 1)),
        );
        const encoder = JsonEncoder.withIndent('  ');
        expect(contents, equals(encoder.convert(jsonData)));
      });

      test('contains Dart SDK version info', () async {
        final String archivePath = path.join(tempDir.absolute.path, archiveName);
        final String jsonPath = path.join(tempDir.absolute.path, releasesName);
        final gsJsonPath = 'gs://flutter_infra_release/releases/$releasesName';
        final releasesJson =
            '''
{
  "base_url": "https://storage.googleapis.com/flutter_infra_release/releases",
  "current_release": {
    "beta": "3ea4d06340a97a1e9d7cae97567c64e0569dcaa2",
    "beta": "5a58b36e36b8d7aace89d3950e6deb307956a6a0"
  },
  "releases": [
    {
      "hash": "5a58b36e36b8d7aace89d3950e6deb307956a6a0",
      "channel": "beta",
      "version": "v0.2.3",
      "release_date": "2018-03-20T01:47:02.851729Z",
      "archive": "beta/$platformName/flutter_${platformName}_v0.2.3-beta.zip",
      "sha256": "4fe85a822093e81cb5a66c7fc263f68de39b5797b294191b6d75e7afcc86aff8"
    },
    {
      "hash": "b9bd51cc36b706215915711e580851901faebb40",
      "channel": "beta",
      "version": "v0.2.2",
      "release_date": "2018-03-16T18:48:13.375013Z",
      "archive": "beta/$platformName/flutter_${platformName}_v0.2.2-beta.zip",
      "sha256": "6073331168cdb37a4637a5dc073d6a7ef4e466321effa2c529fa27d2253a4d4b"
    },
    {
      "hash": "$testRef",
      "channel": "stable",
      "version": "v0.0.0",
      "release_date": "2018-03-20T01:47:02.851729Z",
      "archive": "stable/$platformName/flutter_${platformName}_v0.0.0-beta.zip",
      "sha256": "5dd34873b3a3e214a32fd30c2c319a0f46e608afb72f0d450b2d621a6d02aebd"
    }
  ]
}
''';
        fs.file(jsonPath).writeAsStringSync(releasesJson);
        fs.file(fs.path.join(tempDir.path, 'downloaded.json')).createSync(recursive: true);
        fs.file(fs.path.join(tempDir.path, 'downloaded.json')).writeAsStringSync(releasesJson);
        fs.file(archivePath).writeAsStringSync('archive contents');
        final calls = <String, List<ProcessResult>?>{
          '$gsutilCall -- stat $gsArchivePath': <ProcessResult>[ProcessResult(0, 1, '', '')],
          '$gsutilCall -- rm $gsArchivePath': null,
          '$gsutilCall -- -h Content-Type:$archiveMime cp $archivePath $gsArchivePath': null,
          '$gsutilCall -- stat $gsJsonPath': <ProcessResult>[
            ProcessResult(0, 0, 'Generation: 12345', ''),
          ],
          '$gsutilCall -- cp $gsJsonPath#12345 ${fs.path.join(tempDir.path, "downloaded.json")}':
              null,
          '$gsutilCall -- -h x-goog-if-generation-match:12345 cp ${fs.path.join(tempDir.path, "upload.json")} $gsJsonPath':
              null,
        };
        processManager.addCommands(convertResults(calls));
        final File outputFile = fs.file(path.join(tempDir.absolute.path, archiveName));
        outputFile.createSync();
        assert(tempDir.existsSync());
        final publisher = ArchivePublisher(
          tempDir,
          testRef,
          Branch.stable,
          <String, String>{
            'frameworkVersionFromGit': 'v1.2.3',
            'dartSdkVersion': '3.2.1',
            'dartTargetArch': 'x64',
          },
          outputFile,
          false,
          fs: fs,
          processManager: processManager,
          subprocessOutput: false,
          platform: platform,
        );
        assert(tempDir.existsSync());
        await publisher.publishArchive();

        final File releaseFile = fs.file(fs.path.join(tempDir.path, 'upload.json'));
        expect(releaseFile.existsSync(), isTrue);
        final String contents = releaseFile.readAsStringSync();
        expect(contents, contains('"dart_sdk_version": "3.2.1"'));
        expect(contents, contains('"dart_sdk_arch": "x64"'));
      });

      test('Supports multiple architectures', () async {
        final String archivePath = path.join(tempDir.absolute.path, archiveName);
        final String jsonPath = path.join(tempDir.absolute.path, releasesName);
        final gsJsonPath = 'gs://flutter_infra_release/releases/$releasesName';
        final releasesJson =
            '''
{
  "base_url": "https://storage.googleapis.com/flutter_infra_release/releases",
  "current_release": {
    "beta": "3ea4d06340a97a1e9d7cae97567c64e0569dcaa2",
    "beta": "5a58b36e36b8d7aace89d3950e6deb307956a6a0"
  },
  "releases": [
    {
      "hash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
      "channel": "stable",
      "version": "v1.2.3",
      "release_date": "2018-03-20T01:47:02.851729Z",
      "archive": "beta/$platformName/flutter_${platformName}_v0.2.3-beta.zip",
      "sha256": "4fe85a822093e81cb5a66c7fc263f68de39b5797b294191b6d75e7afcc86aff8",
      "dart_sdk_arch": "x64"
    }
  ]
}
''';
        fs.file(jsonPath).writeAsStringSync(releasesJson);
        fs.file(fs.path.join(tempDir.path, 'downloaded.json')).createSync(recursive: true);
        fs.file(fs.path.join(tempDir.path, 'downloaded.json')).writeAsStringSync(releasesJson);
        fs.file(archivePath).writeAsStringSync('archive contents');
        final calls = <String, List<ProcessResult>?>{
          '$gsutilCall -- stat $gsArchivePath': <ProcessResult>[ProcessResult(0, 1, '', '')],
          '$gsutilCall -- rm $gsArchivePath': null,
          '$gsutilCall -- -h Content-Type:$archiveMime cp $archivePath $gsArchivePath': null,
          '$gsutilCall -- stat $gsJsonPath': <ProcessResult>[
            ProcessResult(0, 0, 'Generation: 12345', ''),
          ],
          '$gsutilCall -- cp $gsJsonPath#12345 ${fs.path.join(tempDir.path, "downloaded.json")}':
              null,
          '$gsutilCall -- -h x-goog-if-generation-match:12345 cp ${fs.path.join(tempDir.path, "upload.json")} $gsJsonPath':
              null,
        };
        processManager.addCommands(convertResults(calls));
        final File outputFile = fs.file(path.join(tempDir.absolute.path, archiveName));
        outputFile.createSync();
        assert(tempDir.existsSync());
        final publisher = ArchivePublisher(
          tempDir,
          testRef,
          Branch.stable,
          <String, String>{
            'frameworkVersionFromGit': 'v1.2.3',
            'dartSdkVersion': '3.2.1',
            'dartTargetArch': 'arm64',
          },
          outputFile,
          false,
          fs: fs,
          processManager: processManager,
          subprocessOutput: false,
          platform: platform,
        );
        assert(tempDir.existsSync());

        await publisher.publishArchive();

        final File releaseFile = fs.file(fs.path.join(tempDir.path, 'upload.json'));
        expect(releaseFile.existsSync(), isTrue);
        final String contents = releaseFile.readAsStringSync();
        final releases = jsonDecode(contents) as Map<String, Object?>;
        expect((releases['releases']! as List<Object?>).length, equals(2));
      });

      test('updates base_url from old bucket to new bucket', () async {
        final String archivePath = path.join(tempDir.absolute.path, archiveName);
        final String jsonPath = path.join(tempDir.absolute.path, releasesName);
        final gsJsonPath = 'gs://flutter_infra_release/releases/$releasesName';
        final releasesJson =
            '''
{
  "base_url": "https://storage.googleapis.com/flutter_infra_release/releases",
  "current_release": {
    "beta": "3ea4d06340a97a1e9d7cae97567c64e0569dcaa2",
    "beta": "5a58b36e36b8d7aace89d3950e6deb307956a6a0"
  },
  "releases": [
    {
      "hash": "5a58b36e36b8d7aace89d3950e6deb307956a6a0",
      "channel": "beta",
      "version": "v0.2.3",
      "release_date": "2018-03-20T01:47:02.851729Z",
      "archive": "beta/$platformName/flutter_${platformName}_v0.2.3-beta.zip",
      "sha256": "4fe85a822093e81cb5a66c7fc263f68de39b5797b294191b6d75e7afcc86aff8"
    },
    {
      "hash": "b9bd51cc36b706215915711e580851901faebb40",
      "channel": "beta",
      "version": "v0.2.2",
      "release_date": "2018-03-16T18:48:13.375013Z",
      "archive": "beta/$platformName/flutter_${platformName}_v0.2.2-beta.zip",
      "sha256": "6073331168cdb37a4637a5dc073d6a7ef4e466321effa2c529fa27d2253a4d4b"
    },
    {
      "hash": "$testRef",
      "channel": "stable",
      "version": "v0.0.0",
      "release_date": "2018-03-20T01:47:02.851729Z",
      "archive": "stable/$platformName/flutter_${platformName}_v0.0.0-beta.zip",
      "sha256": "5dd34873b3a3e214a32fd30c2c319a0f46e608afb72f0d450b2d621a6d02aebd"
    }
  ]
}
''';
        fs.file(jsonPath).writeAsStringSync(releasesJson);
        fs.file(fs.path.join(tempDir.path, 'downloaded.json')).createSync(recursive: true);
        fs.file(fs.path.join(tempDir.path, 'downloaded.json')).writeAsStringSync(releasesJson);
        fs.file(archivePath).writeAsStringSync('archive contents');
        final calls = <String, List<ProcessResult>?>{
          '$gsutilCall -- stat $gsArchivePath': <ProcessResult>[ProcessResult(0, 1, '', '')],
          '$gsutilCall -- rm $gsArchivePath': null,
          '$gsutilCall -- -h Content-Type:$archiveMime cp $archivePath $gsArchivePath': null,
          '$gsutilCall -- stat $gsJsonPath': <ProcessResult>[
            ProcessResult(0, 0, 'Generation: 12345', ''),
          ],
          '$gsutilCall -- cp $gsJsonPath#12345 ${fs.path.join(tempDir.path, "downloaded.json")}':
              null,
          '$gsutilCall -- -h x-goog-if-generation-match:12345 cp ${fs.path.join(tempDir.path, "upload.json")} $gsJsonPath':
              null,
        };
        processManager.addCommands(convertResults(calls));
        final File outputFile = fs.file(path.join(tempDir.absolute.path, archiveName));
        outputFile.createSync();
        assert(tempDir.existsSync());
        final publisher = ArchivePublisher(
          tempDir,
          testRef,
          Branch.stable,
          <String, String>{
            'frameworkVersionFromGit': 'v1.2.3',
            'dartSdkVersion': '3.2.1',
            'dartTargetArch': 'x64',
          },
          outputFile,
          false,
          fs: fs,
          processManager: processManager,
          subprocessOutput: false,
          platform: platform,
        );
        assert(tempDir.existsSync());
        await publisher.publishArchive();

        final File releaseFile = fs.file(fs.path.join(tempDir.path, 'upload.json'));
        expect(releaseFile.existsSync(), isTrue);
        final String contents = releaseFile.readAsStringSync();
        final jsonData = json.decode(contents) as Map<String, Object?>;
        expect(
          jsonData['base_url'],
          'https://storage.googleapis.com/flutter_infra_release/releases',
        );
      });

      test('publishArchive throws if forceUpload is false and artifact already exists on cloud storage', () async {
        final archiveName = platform.isLinux ? 'archive.tar.xz' : 'archive.zip';
        final File outputFile = fs.file(path.join(tempDir.absolute.path, archiveName));
        final publisher = ArchivePublisher(
          tempDir,
          testRef,
          Branch.stable,
          <String, String>{
            'frameworkVersionFromGit': 'v1.2.3',
            'dartSdkVersion': '3.2.1',
            'dartTargetArch': 'x64',
          },
          outputFile,
          false,
          fs: fs,
          processManager: processManager,
          subprocessOutput: false,
          platform: platform,
        );
        final calls = <String, List<ProcessResult>>{
          // This process returns 0 because file already exists
          '$gsutilCall -- stat $gsArchivePath': <ProcessResult>[ProcessResult(0, 0, '', '')],
        };
        processManager.addCommands(convertResults(calls));
        expect(() async => publisher.publishArchive(), throwsException);
      });

      test('publishArchive does not throw if forceUpload is true and artifact already exists on cloud storage', () async {
        final archiveName = platform.isLinux ? 'archive.tar.xz' : 'archive.zip';
        final File outputFile = fs.file(path.join(tempDir.absolute.path, archiveName));
        final publisher = ArchivePublisher(
          tempDir,
          testRef,
          Branch.stable,
          <String, String>{
            'frameworkVersionFromGit': 'v1.2.3',
            'dartSdkVersion': '3.2.1',
            'dartTargetArch': 'x64',
          },
          outputFile,
          false,
          fs: fs,
          processManager: processManager,
          subprocessOutput: false,
          platform: platform,
        );
        final String archivePath = path.join(tempDir.absolute.path, archiveName);
        final String jsonPath = path.join(tempDir.absolute.path, releasesName);
        final gsJsonPath = 'gs://flutter_infra_release/releases/$releasesName';
        final releasesJson =
            '''
{
  "base_url": "https://storage.googleapis.com/flutter_infra_release/releases",
  "current_release": {
    "beta": "3ea4d06340a97a1e9d7cae97567c64e0569dcaa2",
    "beta": "5a58b36e36b8d7aace89d3950e6deb307956a6a0"
  },
  "releases": [
    {
      "hash": "5a58b36e36b8d7aace89d3950e6deb307956a6a0",
      "channel": "beta",
      "version": "v0.2.3",
      "release_date": "2018-03-20T01:47:02.851729Z",
      "archive": "beta/$platformName/flutter_${platformName}_v0.2.3-beta.zip",
      "sha256": "4fe85a822093e81cb5a66c7fc263f68de39b5797b294191b6d75e7afcc86aff8"
    },
    {
      "hash": "b9bd51cc36b706215915711e580851901faebb40",
      "channel": "beta",
      "version": "v0.2.2",
      "release_date": "2018-03-16T18:48:13.375013Z",
      "archive": "beta/$platformName/flutter_${platformName}_v0.2.2-beta.zip",
      "sha256": "6073331168cdb37a4637a5dc073d6a7ef4e466321effa2c529fa27d2253a4d4b"
    },
    {
      "hash": "$testRef",
      "channel": "stable",
      "version": "v0.0.0",
      "release_date": "2018-03-20T01:47:02.851729Z",
      "archive": "stable/$platformName/flutter_${platformName}_v0.0.0-beta.zip",
      "sha256": "5dd34873b3a3e214a32fd30c2c319a0f46e608afb72f0d450b2d621a6d02aebd"
    }
  ]
}
''';
        fs.file(jsonPath).writeAsStringSync(releasesJson);
        fs.file(fs.path.join(tempDir.path, 'downloaded.json')).createSync(recursive: true);
        fs.file(fs.path.join(tempDir.path, 'downloaded.json')).writeAsStringSync(releasesJson);
        fs.file(archivePath).writeAsStringSync('archive contents');
        final calls = <String, List<ProcessResult>?>{
          '$gsutilCall -- rm $gsArchivePath': null,
          '$gsutilCall -- -h Content-Type:$archiveMime cp $archivePath $gsArchivePath': null,
          '$gsutilCall -- stat $gsJsonPath': <ProcessResult>[
            ProcessResult(0, 0, 'Generation: 12345', ''),
          ],
          '$gsutilCall -- cp $gsJsonPath#12345 ${fs.path.join(tempDir.path, "downloaded.json")}':
              null,
          '$gsutilCall -- -h x-goog-if-generation-match:12345 cp ${fs.path.join(tempDir.path, "upload.json")} $gsJsonPath':
              null,
        };
        processManager.addCommands(convertResults(calls));
        assert(tempDir.existsSync());
        await publisher.publishArchive(true);
      });
    });

    group('transactionalUpdate', () {
      late FakeProcessManager processManager;
      late FileSystem fs;
      final gsutilCall = platform.isWindows
          ? 'python3 ${path.join("D:", "depot_tools", "gsutil.py")}'
          : 'python3 ${path.join("/", "depot_tools", "gsutil.py")}';
      const gsPath = 'gs://flutter_infra_release/releases/manifest.json';
      late Directory tempDirectory;

      setUp(() async {
        fs = MemoryFileSystem.test(
          style: platform.isWindows ? FileSystemStyle.windows : FileSystemStyle.posix,
        );
        processManager = FakeProcessManager.list(<FakeCommand>[]);
        tempDirectory = fs.systemTempDirectory.createTempSync('test_temp.');
      });

      test('succeeds on first try', () async {
        final calls = <String, List<ProcessResult>?>{
          '$gsutilCall -- stat $gsPath': <ProcessResult>[
            ProcessResult(0, 0, 'Generation: 12345', ''),
          ],
          '$gsutilCall -- cp $gsPath#12345 ${fs.path.join(tempDirectory.path, "downloaded.json")}':
              null,
          '$gsutilCall -- -h x-goog-if-generation-match:12345 cp ${fs.path.join(tempDirectory.path, "upload.json")} $gsPath':
              null,
        };
        processManager.addCommands(convertResults(calls));

        fs.file(fs.path.join(tempDirectory.path, 'downloaded.json')).createSync(recursive: true);
        fs
            .file(fs.path.join(tempDirectory.path, 'downloaded.json'))
            .writeAsStringSync('{"releases": []}');

        await transactionalUpdate(
          gsPath: gsPath,
          fs: fs,
          tempDirectory: tempDirectory,
          runGsUtil: (List<String> args) async {
            final runner = ProcessRunner(
              processManager: processManager,
              platform: platform,
              subprocessOutput: false,
            );
            return runner.runProcess(<String>[
              'python3',
              if (platform.isWindows)
                path.join('D:', 'depot_tools', 'gsutil.py')
              else
                path.join('/', 'depot_tools', 'gsutil.py'),
              '--',
              ...args,
            ]);
          },
          callback: (String contents) async {
            return '{"releases": [{"version": "1.0.0"}]}';
          },
        );
        expect(processManager.hasRemainingExpectations, isFalse);
      });

      test('assumes generation 0 if stat fails', () async {
        final String gsutilScript = platform.isWindows
            ? path.join('D:', 'depot_tools', 'gsutil.py')
            : path.join('/', 'depot_tools', 'gsutil.py');

        final commands = <FakeCommand>[
          FakeCommand(
            command: <String>['python3', gsutilScript, '--', 'stat', gsPath],
            exitCode: 1,
            stderr: 'No such object',
          ),
          FakeCommand(
            command: <String>[
              'python3',
              gsutilScript,
              '--',
              '-h',
              'x-goog-if-generation-match:0',
              'cp',
              fs.path.join(tempDirectory.path, 'upload.json'),
              gsPath,
            ],
          ),
        ];
        processManager.addCommands(commands);

        await transactionalUpdate(
          gsPath: gsPath,
          fs: fs,
          tempDirectory: tempDirectory,
          runGsUtil: (List<String> args) async {
            final runner = ProcessRunner(
              processManager: processManager,
              platform: platform,
              subprocessOutput: false,
            );
            return runner.runProcess(<String>[
              'python3',
              if (platform.isWindows)
                path.join('D:', 'depot_tools', 'gsutil.py')
              else
                path.join('/', 'depot_tools', 'gsutil.py'),
              '--',
              ...args,
            ]);
          },
          callback: (String contents) async {
            expect(contents, equals(''));
            return '{"releases": [{"version": "1.0.0"}]}';
          },
        );
        expect(processManager.hasRemainingExpectations, isFalse);
      });

      test('short-circuits on dryRun', () async {
        var callbackCalled = false;
        await transactionalUpdate(
          gsPath: gsPath,
          fs: fs,
          tempDirectory: tempDirectory,
          dryRun: true,
          runGsUtil: (List<String> args) async {
            fail('runGsUtil should not be called');
          },
          callback: (String contents) async {
            callbackCalled = true;
            expect(contents, equals(''));
            return '{"releases": [{"version": "1.0.0"}]}';
          },
        );
        expect(callbackCalled, isTrue);
      });

      test('retries on upload failure', () async {
        final String gsutilScript = platform.isWindows
            ? path.join('D:', 'depot_tools', 'gsutil.py')
            : path.join('/', 'depot_tools', 'gsutil.py');

        final commands = <FakeCommand>[
          FakeCommand(
            command: <String>['python3', gsutilScript, '--', 'stat', gsPath],
            stdout: 'Generation: 12345',
          ),
          FakeCommand(
            command: <String>[
              'python3',
              gsutilScript,
              '--',
              'cp',
              '$gsPath#12345',
              fs.path.join(tempDirectory.path, 'downloaded.json'),
            ],
          ),
          FakeCommand(
            command: <String>[
              'python3',
              gsutilScript,
              '--',
              '-h',
              'x-goog-if-generation-match:12345',
              'cp',
              fs.path.join(tempDirectory.path, 'upload.json'),
              gsPath,
            ],
            exitCode: 1,
            stderr: '412 Precondition Failed',
          ),
          FakeCommand(
            command: <String>['python3', gsutilScript, '--', 'stat', gsPath],
            stdout: 'Generation: 67890',
          ),
          FakeCommand(
            command: <String>[
              'python3',
              gsutilScript,
              '--',
              'cp',
              '$gsPath#67890',
              fs.path.join(tempDirectory.path, 'downloaded.json'),
            ],
          ),
          FakeCommand(
            command: <String>[
              'python3',
              gsutilScript,
              '--',
              '-h',
              'x-goog-if-generation-match:67890',
              'cp',
              fs.path.join(tempDirectory.path, 'upload.json'),
              gsPath,
            ],
          ),
        ];
        processManager.addCommands(commands);

        fs.file(fs.path.join(tempDirectory.path, 'downloaded.json')).createSync(recursive: true);
        fs
            .file(fs.path.join(tempDirectory.path, 'downloaded.json'))
            .writeAsStringSync('{"releases": []}');

        var callbackCount = 0;
        await transactionalUpdate(
          gsPath: gsPath,
          fs: fs,
          tempDirectory: tempDirectory,
          runGsUtil: (List<String> args) async {
            final runner = ProcessRunner(
              processManager: processManager,
              platform: platform,
              subprocessOutput: false,
            );
            return runner.runProcess(<String>[
              'python3',
              if (platform.isWindows)
                path.join('D:', 'depot_tools', 'gsutil.py')
              else
                path.join('/', 'depot_tools', 'gsutil.py'),
              '--',
              ...args,
            ]);
          },
          callback: (String contents) async {
            callbackCount++;
            return '{"releases": [{"version": "$callbackCount.0.0"}]}';
          },
        );
        expect(processManager.hasRemainingExpectations, isFalse);
        expect(callbackCount, equals(2));
      });
    });
  }
}

/// Converts a map of command lines to their results into [FakeCommand]s.
///
/// If [environment] is given, each command is additionally expected to run with
/// exactly that environment.
List<FakeCommand> convertResults(
  Map<String, List<ProcessResult>?> results, {
  Map<String, String>? environment,
}) {
  final commands = <FakeCommand>[];
  for (final String key in results.keys) {
    final List<ProcessResult>? candidates = results[key];
    final List<String> args = key.split(' ');
    if (candidates == null) {
      commands.add(FakeCommand(command: args, environment: environment));
    } else {
      for (final ProcessResult result in candidates) {
        commands.add(
          FakeCommand(
            command: args,
            environment: environment,
            exitCode: result.exitCode,
            stderr: result.stderr.toString(),
            stdout: result.stdout.toString(),
          ),
        );
      }
    }
  }
  return commands;
}