From ddfc322de820ca2b3d6c8cfcb1d576481470f90e Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Mon, 29 Jan 2018 23:15:57 -0800 Subject: [PATCH] Creates a package publishing script to publish packages as part of the dev roll process. (#14294) This script will update release metadata in the cloud, and copy the already-built package to the right location and name on cloud storage. The release metadata will be located in gs://flutter_infra/releases/releases.json, and the published packages will end up in gs://flutter_infra/releases///flutter__, where , , , and are determined by the script. At the moment, it only supports dev rolls, but (once we know how those will work) should easily support beta rolls as well. --- dev/tools/lib/archive_publisher.dart | 191 ++++++++++++++++ dev/tools/lib/roll_dev.dart | 23 +- dev/tools/pubspec.yaml | 40 ++++ dev/tools/test/archive_publisher_test.dart | 83 +++++++ dev/tools/test/fake_process_manager.dart | 210 ++++++++++++++++++ dev/tools/test/fake_process_manager_test.dart | 92 ++++++++ 6 files changed, 634 insertions(+), 5 deletions(-) create mode 100644 dev/tools/lib/archive_publisher.dart create mode 100644 dev/tools/test/archive_publisher_test.dart create mode 100644 dev/tools/test/fake_process_manager.dart create mode 100644 dev/tools/test/fake_process_manager_test.dart diff --git a/dev/tools/lib/archive_publisher.dart b/dev/tools/lib/archive_publisher.dart new file mode 100644 index 0000000000..4ea085e2b0 --- /dev/null +++ b/dev/tools/lib/archive_publisher.dart @@ -0,0 +1,191 @@ +// Copyright 2018 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:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:process/process.dart'; + +class ArchivePublisherException implements Exception { + ArchivePublisherException(this.message, [this.result]); + + final String message; + final ProcessResult result; + + @override + String toString() { + String output = 'ArchivePublisherException'; + if (message != null) { + output += ': $message'; + } + final String stderr = result?.stderr ?? ''; + if (stderr.isNotEmpty) { + output += ':\n$result.stderr'; + } + return output; + } +} + +enum Channel { dev, beta } + +/// Publishes the archive created for a particular version and git hash to +/// the releases directory on cloud storage, and updates the metadata for +/// releases. +/// +/// See https://github.com/flutter/flutter/wiki/Release-process for more +/// information on the release process. +class ArchivePublisher { + ArchivePublisher( + this.revision, + this.version, + this.channel, { + this.processManager = const LocalProcessManager(), + this.tempDir, + }) : assert(revision.length == 40, 'Git hash must be 40 characters long (i.e. the entire hash).'); + + /// A git hash describing the revision to publish. It should be the complete + /// hash, not just a prefix. + final String revision; + + /// A version number for the release (e.g. "1.2.3"). + final String version; + + /// The channel to publish to. + // TODO(gspencer): support Channel.beta: it is currently unimplemented. + final Channel channel; + + /// Get the name of the channel as a string. + String get channelName { + switch (channel) { + case Channel.beta: + return 'beta'; + case Channel.dev: + default: + return 'dev'; + } + } + + /// The process manager to use for invoking commands. Typically only + /// used for testing purposes. + final ProcessManager processManager; + + /// The temporary directory used for this publisher. If not set, one will + /// be created, used, and then removed automatically. If set, it will not be + /// deleted when done: that is left to the caller. Typically used by tests. + Directory tempDir; + + static String gsBase = 'gs://flutter_infra'; + static String releaseFolder = '/releases'; + static String baseUrl = 'https://storage.googleapis.com/flutter_infra'; + static String archivePrefix = 'flutter_'; + static String releaseNotesPrefix = 'release_notes_'; + + final String metadataGsPath = '$gsBase$releaseFolder/releases.json'; + + /// Publishes the archive for the given constructor parameters. + bool publishArchive() { + assert(channel == Channel.dev, 'Channel must be dev (beta not yet supported)'); + // Check for access early so that we don't try to publish things if the + // user doesn't have access to the metadata file. + _checkForGSUtilAccess(); + final List platforms = ['linux', 'mac', 'win']; + final Map metadata = {}; + for (String platform in platforms) { + final String src = _builtArchivePath(platform); + final String dest = _destinationArchivePath(platform); + final String srcGsPath = '$gsBase$src'; + final String destGsPath = '$gsBase$releaseFolder$dest'; + _cloudCopy(srcGsPath, destGsPath); + metadata['${platform}_archive'] = '$channelName/$platform$dest'; + } + metadata['release_date'] = new DateTime.now().toUtc().toIso8601String(); + metadata['version'] = version; + _updateMetadata(metadata); + return true; + } + + /// Checks to make sure the user has access to the Google Storage bucket + /// required to publish. Will throw an [ArchivePublisherException] if not. + void _checkForGSUtilAccess() { + // Fetching ACLs requires FULL_CONTROL access. + final ProcessResult result = _runGsUtil(['acl', 'get', metadataGsPath]); + if (result.exitCode != 0) { + throw new ArchivePublisherException( + 'GSUtil cannot get ACLs for metadata file $metadataGsPath', result); + } + } + + void _updateMetadata(Map metadata) { + final ProcessResult result = _runGsUtil(['cat', metadataGsPath]); + if (result.exitCode != 0) { + throw new ArchivePublisherException( + 'Unable to get existing metadata at $metadataGsPath', result); + } + final String currentMetadata = result.stdout; + if (currentMetadata.isEmpty) { + throw new ArchivePublisherException('Empty metadata received from server', result); + } + Map jsonData; + try { + jsonData = json.decode(currentMetadata); + } on FormatException catch (e) { + throw new ArchivePublisherException('Unable to parse JSON metadata received from cloud: $e'); + } + jsonData['current_$channelName'] = revision; + if (!jsonData.containsKey('releases')) { + jsonData['releases'] = {}; + } + if (jsonData['releases'].containsKey(revision)) { + throw new ArchivePublisherException( + 'Revision $revision already exists in metadata! Aborting.'); + } + jsonData['releases'][revision] = metadata; + final Directory localTempDir = tempDir ?? Directory.systemTemp.createTempSync('flutter_'); + final File tempFile = new File(path.join(localTempDir.absolute.path, 'releases.json')); + final JsonEncoder encoder = const JsonEncoder.withIndent(' '); + tempFile.writeAsStringSync(encoder.convert(jsonData)); + _cloudCopy(tempFile.absolute.path, metadataGsPath); + if (tempDir == null) { + localTempDir.delete(recursive: true); + } + } + + String _getArchiveSuffix(String platform) { + switch (platform) { + case 'linux': + case 'mac': + return '.tar.xz'; + case 'win': + return '.zip'; + default: + assert(false, 'platform $platform not recognized.'); + return null; + } + } + + String _builtArchivePath(String platform) { + final String shortRevision = revision.substring(0, revision.length > 10 ? 10 : revision.length); + final String archivePathBase = '/flutter/$revision/$archivePrefix'; + final String suffix = _getArchiveSuffix(platform); + return '$archivePathBase${platform}_$shortRevision$suffix'; + } + + String _destinationArchivePath(String platform) { + final String archivePathBase = '/$channelName/$platform/$archivePrefix'; + final String suffix = _getArchiveSuffix(platform); + return '$archivePathBase${platform}_$version-$channelName$suffix'; + } + + ProcessResult _runGsUtil(List args) { + return processManager.runSync(['gsutil']..addAll(args)); + } + + void _cloudCopy(String src, String dest) { + final ProcessResult result = _runGsUtil(['cp', src, dest]); + if (result.exitCode != 0) { + throw new ArchivePublisherException('GSUtil copy command failed: ${result.stderr}', result); + } + } +} diff --git a/dev/tools/lib/roll_dev.dart b/dev/tools/lib/roll_dev.dart index 3b5c1269d4..2dc6f2c5fa 100644 --- a/dev/tools/lib/roll_dev.dart +++ b/dev/tools/lib/roll_dev.dart @@ -10,7 +10,7 @@ import 'dart:io'; import 'package:args/args.dart'; -import 'package:path/path.dart' as path; +import 'archive_publisher.dart'; const String kIncrement = 'increment'; const String kX = 'x'; @@ -19,9 +19,9 @@ const String kZ = 'z'; const String kHelp = 'help'; void main(List args) { - // If we're run from the `tools` dir, set the cwd to the repo root. - if (path.basename(Directory.current.path) == 'tools') - Directory.current = Directory.current.parent.parent; + // Set the cwd to the repo root, since we know where this script is located. + final Directory scriptLocation = new Directory(Platform.script.toFilePath()); + Directory.current = scriptLocation.parent.parent.parent; final ArgParser argParser = new ArgParser(allowTrailingOptions: false); argParser.addOption( @@ -100,8 +100,10 @@ void main(List args) { runGit('fetch upstream', 'fetch upstream'); runGit('reset upstream/master --hard', 'check out master branch'); runGit('tag $version', 'tag the commit with the version label'); + final String hash = getGitOutput('rev-parse HEAD', 'Get git hash for $version tag'); - print('Your tree is ready to publish Flutter $version to the "dev" channel.'); + print('Your tree is ready to publish Flutter $version (${hash.substring(0, 10)}) ' + 'to the "dev" channel.'); stdout.write('Are you? [yes/no] '); if (stdin.readLineSync() != 'yes') { runGit('tag -d $version', 'remove the tag you did not want to publish'); @@ -109,6 +111,17 @@ void main(List args) { exit(0); } + // Publish the archive before pushing the tag so that if something fails in + // the publish step, we can clean up. + try { + new ArchivePublisher(hash, version, Channel.dev)..publishArchive(); + } on ArchivePublisherException catch (e) { + print('Archive publishing failed.\n$e'); + runGit('tag -d $version', 'remove the tag that was not published'); + print('The dev roll has been aborted.'); + exit(0); + } + runGit('push upstream $version', 'publish the version'); runGit('push upstream HEAD:dev', 'land the new version on the "dev" branch'); print('Flutter version $version has been rolled to the "dev" channel!'); diff --git a/dev/tools/pubspec.yaml b/dev/tools/pubspec.yaml index 9fbb84f165..401c524375 100644 --- a/dev/tools/pubspec.yaml +++ b/dev/tools/pubspec.yaml @@ -8,13 +8,53 @@ dependencies: intl: 0.15.2 meta: 1.1.1 path: 1.5.1 + process: 2.0.7 + +dev_dependencies: + test: 0.12.30+1 + mockito: 2.2.1 async: 2.0.3 # TRANSITIVE DEPENDENCY + barback: 0.15.2+14 # TRANSITIVE DEPENDENCY + boolean_selector: 1.0.2 # TRANSITIVE DEPENDENCY charcode: 1.1.1 # TRANSITIVE DEPENDENCY + cli_util: 0.1.2+1 # TRANSITIVE DEPENDENCY collection: 1.14.5 # TRANSITIVE DEPENDENCY convert: 2.0.1 # TRANSITIVE DEPENDENCY crypto: 2.0.2+1 # TRANSITIVE DEPENDENCY + csslib: 0.14.1 # TRANSITIVE DEPENDENCY + file: 2.3.5 # TRANSITIVE DEPENDENCY + glob: 1.1.5 # TRANSITIVE DEPENDENCY + html: 0.13.2+2 # TRANSITIVE DEPENDENCY + http_multi_server: 2.0.4 # TRANSITIVE DEPENDENCY http_parser: 3.1.1 # TRANSITIVE DEPENDENCY + io: 0.3.1 # TRANSITIVE DEPENDENCY + isolate: 1.1.0 # TRANSITIVE DEPENDENCY + js: 0.6.1 # TRANSITIVE DEPENDENCY + logging: 0.11.3+1 # TRANSITIVE DEPENDENCY + matcher: 0.12.1+4 # TRANSITIVE DEPENDENCY + mime: 0.9.5 # TRANSITIVE DEPENDENCY + multi_server_socket: 1.0.1 # TRANSITIVE DEPENDENCY + node_preamble: 1.4.0 # TRANSITIVE DEPENDENCY + package_config: 1.0.3 # TRANSITIVE DEPENDENCY + package_resolver: 1.0.2 # TRANSITIVE DEPENDENCY + platform: 2.1.1 # TRANSITIVE DEPENDENCY + plugin: 0.2.0+2 # TRANSITIVE DEPENDENCY + pool: 1.3.4 # TRANSITIVE DEPENDENCY + pub_semver: 1.3.2 # TRANSITIVE DEPENDENCY + shelf: 0.7.2 # TRANSITIVE DEPENDENCY + shelf_packages_handler: 1.0.3 # TRANSITIVE DEPENDENCY + shelf_static: 0.2.7 # TRANSITIVE DEPENDENCY + shelf_web_socket: 0.2.2 # TRANSITIVE DEPENDENCY + source_map_stack_trace: 1.1.4 # TRANSITIVE DEPENDENCY + source_maps: 0.10.4 # TRANSITIVE DEPENDENCY source_span: 1.4.0 # TRANSITIVE DEPENDENCY + stack_trace: 1.9.1 # TRANSITIVE DEPENDENCY + stream_channel: 1.6.3 # TRANSITIVE DEPENDENCY string_scanner: 1.0.2 # TRANSITIVE DEPENDENCY + term_glyph: 1.0.0 # TRANSITIVE DEPENDENCY typed_data: 1.1.4 # TRANSITIVE DEPENDENCY + utf: 0.9.0+3 # TRANSITIVE DEPENDENCY + watcher: 0.9.7+6 # TRANSITIVE DEPENDENCY + web_socket_channel: 1.0.6 # TRANSITIVE DEPENDENCY + yaml: 2.1.13 # TRANSITIVE DEPENDENCY diff --git a/dev/tools/test/archive_publisher_test.dart b/dev/tools/test/archive_publisher_test.dart new file mode 100644 index 0000000000..da971dc6bb --- /dev/null +++ b/dev/tools/test/archive_publisher_test.dart @@ -0,0 +1,83 @@ +// Copyright 2018 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:io'; + +import 'package:test/test.dart'; + +import 'package:path/path.dart' as path; + +import '../lib/archive_publisher.dart'; +import 'fake_process_manager.dart'; + +void main() { + group('ArchivePublisher', () { + final List emptyStdout = ['']; + FakeProcessManager processManager; + Directory tempDir; + + setUp(() async { + processManager = new FakeProcessManager(); + tempDir = await Directory.systemTemp.createTemp('flutter_'); + }); + + tearDown(() async { + // On Windows, the directory is locked and not able to be deleted, because it is a + // temporary directory. So we just leave some (very small, because we're not actually + // building archives here) trash around to be deleted at the next reboot. + if (!Platform.isWindows) { + await tempDir.delete(recursive: true); + } + }); + + test('calls the right processes', () { + final Map> calls = >{ + 'gsutil acl get gs://flutter_infra/releases/releases.json': emptyStdout, + 'gsutil cp gs://flutter_infra/flutter/deadbeef/flutter_linux_deadbeef.tar.xz ' + 'gs://flutter_infra/releases/dev/linux/flutter_linux_1.2.3-dev.tar.xz': emptyStdout, + 'gsutil cp gs://flutter_infra/flutter/deadbeef/flutter_mac_deadbeef.tar.xz ' + 'gs://flutter_infra/releases/dev/mac/flutter_mac_1.2.3-dev.tar.xz': emptyStdout, + 'gsutil cp gs://flutter_infra/flutter/deadbeef/flutter_win_deadbeef.zip ' + 'gs://flutter_infra/releases/dev/win/flutter_win_1.2.3-dev.zip': emptyStdout, + 'gsutil cat gs://flutter_infra/releases/releases.json': [ + '''{ + "base_url": "https://storage.googleapis.com/flutter_infra/releases", + "current_beta": "6da8ec6bd0c4801b80d666869e4069698561c043", + "current_dev": "f88c60b38c3a5ef92115d24e3da4175b4890daba", + "releases": { + "6da8ec6bd0c4801b80d666869e4069698561c043": { + "linux_archive": "beta/linux/flutter_linux_0.21.0-beta.tar.xz", + "mac_archive": "beta/mac/flutter_mac_0.21.0-beta.tar.xz", + "windows_archive": "beta/win/flutter_win_0.21.0-beta.tar.xz", + "release_date": "2017-12-19T10:30:00,847287019-08:00", + "release_notes": "beta/release_notes_0.21.0-beta.html", + "version": "0.21.0-beta" + }, + "f88c60b38c3a5ef92115d24e3da4175b4890daba": { + "linux_archive": "dev/linux/flutter_linux_0.22.0-dev.tar.xz", + "mac_archive": "dev/mac/flutter_mac_0.22.0-dev.tar.xz", + "windows_archive": "dev/win/flutter_win_0.22.0-dev.tar.xz", + "release_date": "2018-01-19T13:30:09,728487019-08:00", + "release_notes": "dev/release_notes_0.22.0-dev.html", + "version": "0.22.0-dev" + } + } +} +'''], + 'gsutil cp ${tempDir.path}/releases.json gs://flutter_infra/releases/releases.json': + emptyStdout, + }; + processManager.setResults(calls); + new ArchivePublisher('deadbeef', '1.2.3', Channel.dev, + processManager: processManager, tempDir: tempDir) + ..publishArchive(); + processManager.verifyCalls(calls.keys); + final File outputFile = new File(path.join(tempDir.path, 'releases.json')); + expect(outputFile.existsSync(), isTrue); + final String contents = outputFile.readAsStringSync(); + expect(contents, contains('"current_dev": "deadbeef"')); + expect(contents, contains('"deadbeef": {')); + }); + }); +} diff --git a/dev/tools/test/fake_process_manager.dart b/dev/tools/test/fake_process_manager.dart new file mode 100644 index 0000000000..1a5afab23e --- /dev/null +++ b/dev/tools/test/fake_process_manager.dart @@ -0,0 +1,210 @@ +// Copyright 2018 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'; + +import 'package:process/process.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +/// A mock that can be used to fake a process manager that runs commands +/// and returns results. +/// +/// Call [setResults] to provide a list of results that will return from +/// each command line (with arguments). +/// +/// Call [verifyCalls] to verify that each desired call occurred. +class FakeProcessManager extends Mock implements ProcessManager { + FakeProcessManager({this.stdinResults}) { + _setupMock(); + } + + /// The callback that will be called each time stdin input is supplied to + /// a call. + final StringReceivedCallback stdinResults; + + /// The list of results that will be sent back, organized by the command line + /// that will produce them. Each command line has a list of returned stdout + /// output that will be returned on each successive call. + Map> fakeResults = >{}; + + /// The list of invocations that occurred, in the order they occurred. + List invocations = []; + + /// Verify that the given command lines were called, in the given order. + void verifyCalls(List calls) { + int index = 0; + expect(invocations.length, equals(calls.length)); + for (String call in calls) { + expect(call.split(' '), orderedEquals(invocations[index].positionalArguments[0])); + index++; + } + } + + /// Sets the list of results that will be returned from each successive call. + void setResults(Map> results) { + final Map> resultCodeUnits = >{}; + for (String key in results.keys) { + resultCodeUnits[key] = + results[key].map((String result) => new ProcessResult(0, 0, result, '')).toList(); + } + fakeResults = resultCodeUnits; + } + + ProcessResult _popResult(String key) { + expect(fakeResults, isNotEmpty); + expect(fakeResults, contains(key)); + expect(fakeResults[key], isNotEmpty); + return fakeResults[key].removeAt(0); + } + + FakeProcess _popProcess(String key) => + new FakeProcess(_popResult(key), stdinResults: stdinResults); + + Future _nextProcess(Invocation invocation) async { + invocations.add(invocation); + return new Future.value(_popProcess(invocation.positionalArguments[0].join(' '))); + } + + ProcessResult _nextResultSync(Invocation invocation) { + invocations.add(invocation); + return _popResult(invocation.positionalArguments[0].join(' ')); + } + + Future _nextResult(Invocation invocation) async { + invocations.add(invocation); + return new Future.value(_popResult(invocation.positionalArguments[0].join(' '))); + } + + void _setupMock() { + // Note that not all possible types of invocations are covered here, just the ones + // expected to be called. + // TODO(gspencer): make this more general so that any call will be captured. + when( + start( + typed(captureAny), + environment: typed(captureAny, named: 'environment'), + workingDirectory: typed(captureAny, named: 'workingDirectory'), + ), + ).thenAnswer(_nextProcess); + + when( + start( + typed(captureAny), + ), + ).thenAnswer(_nextProcess); + + when( + run( + typed(captureAny), + environment: typed(captureAny, named: 'environment'), + workingDirectory: typed(captureAny, named: 'workingDirectory'), + ), + ).thenAnswer(_nextResult); + + when( + run( + typed(captureAny), + ), + ).thenAnswer(_nextResult); + + when( + runSync( + typed(captureAny), + environment: typed(captureAny, named: 'environment'), + workingDirectory: typed(captureAny, named: 'workingDirectory'), + ), + ).thenAnswer(_nextResultSync); + + when( + runSync( + typed(captureAny), + ), + ).thenAnswer(_nextResultSync); + + when(killPid(typed(captureAny), typed(captureAny))).thenReturn(true); + + when( + canRun(captureAny, + workingDirectory: typed( + captureAny, + named: 'workingDirectory', + )), + ).thenReturn(true); + } +} + +/// A fake process that can be used to interact with a process "started" by the FakeProcessManager. +class FakeProcess extends Mock implements Process { + FakeProcess(ProcessResult result, {void stdinResults(String input)}) + : stdoutStream = new Stream>.fromIterable(>[result.stdout.codeUnits]), + stderrStream = new Stream>.fromIterable(>[result.stderr.codeUnits]), + desiredExitCode = result.exitCode, + stdinSink = new IOSink(new StringStreamConsumer(stdinResults)) { + _setupMock(); + } + + final IOSink stdinSink; + final Stream> stdoutStream; + final Stream> stderrStream; + final int desiredExitCode; + + void _setupMock() { + when(kill(typed(captureAny))).thenReturn(true); + } + + @override + Future get exitCode => new Future.value(desiredExitCode); + + @override + int get pid => 0; + + @override + IOSink get stdin => stdinSink; + + @override + Stream> get stderr => stderrStream; + + @override + Stream> get stdout => stdoutStream; +} + +/// Callback used to receive stdin input when it occurs. +typedef void StringReceivedCallback(String received); + +/// A stream consumer class that consumes UTF8 strings as lists of ints. +class StringStreamConsumer implements StreamConsumer> { + StringStreamConsumer(this.sendString); + + List>> streams = >>[]; + List>> subscriptions = >>[]; + List> completers = >[]; + + /// The callback called when this consumer receives input. + StringReceivedCallback sendString; + + @override + Future addStream(Stream> value) { + streams.add(value); + completers.add(new Completer()); + subscriptions.add(value.listen((List data) { + sendString(utf8.decode(data)); + })); + subscriptions.last.onDone(() => completers.last.complete(null)); + return new Future.value(null); + } + + @override + Future close() async { + for (Completer completer in completers) { + await completer.future; + } + completers.clear(); + streams.clear(); + subscriptions.clear(); + return new Future.value(null); + } +} diff --git a/dev/tools/test/fake_process_manager_test.dart b/dev/tools/test/fake_process_manager_test.dart new file mode 100644 index 0000000000..ee6a97c82d --- /dev/null +++ b/dev/tools/test/fake_process_manager_test.dart @@ -0,0 +1,92 @@ +// Copyright 2018 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:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; + +import 'fake_process_manager.dart'; + +void main() { + group('ArchivePublisher', () { + FakeProcessManager processManager; + final List stdinCaptured = []; + + void _captureStdin(String item) { + stdinCaptured.add(item); + } + + setUp(() async { + processManager = new FakeProcessManager(stdinResults: _captureStdin); + }); + + tearDown(() async {}); + + test('start works', () async { + final Map> calls = >{ + 'gsutil acl get gs://flutter_infra/releases/releases.json': ['output1'], + 'gsutil cat gs://flutter_infra/releases/releases.json': ['test'], + }; + processManager.setResults(calls); + for (String key in calls.keys) { + final Process process = await processManager.start(key.split(' ')); + String output = ''; + process.stdout.listen((List item) { + output += utf8.decode(item); + }); + await process.exitCode; + expect(output, equals(calls[key][0])); + } + processManager.verifyCalls(calls.keys); + }); + + test('run works', () async { + final Map> calls = >{ + 'gsutil acl get gs://flutter_infra/releases/releases.json': ['output1'], + 'gsutil cat gs://flutter_infra/releases/releases.json': ['test'], + }; + processManager.setResults(calls); + for (String key in calls.keys) { + final ProcessResult result = await processManager.run(key.split(' ')); + expect(result.stdout, equals(calls[key][0])); + } + processManager.verifyCalls(calls.keys); + }); + + test('runSync works', () async { + final Map> calls = >{ + 'gsutil acl get gs://flutter_infra/releases/releases.json': ['output1'], + 'gsutil cat gs://flutter_infra/releases/releases.json': ['test'], + }; + processManager.setResults(calls); + for (String key in calls.keys) { + final ProcessResult result = processManager.runSync(key.split(' ')); + expect(result.stdout, equals(calls[key][0])); + } + processManager.verifyCalls(calls.keys); + }); + + test('captures stdin', () async { + final Map> calls = >{ + 'gsutil acl get gs://flutter_infra/releases/releases.json': ['output1'], + 'gsutil cat gs://flutter_infra/releases/releases.json': ['test'], + }; + processManager.setResults(calls); + for (String key in calls.keys) { + final Process process = await processManager.start(key.split(' ')); + String output = ''; + process.stdout.listen((List item) { + output += utf8.decode(item); + }); + final String testInput = '${calls[key][0]} input'; + process.stdin.add(testInput.codeUnits); + await process.exitCode; + expect(output, equals(calls[key][0])); + expect(stdinCaptured.last, equals(testInput)); + } + processManager.verifyCalls(calls.keys); + }); + }); +}