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/<channel>/<platform>/flutter_<platform>_<version><archive suffix>, where <channel>, <platform>, <version>, and <archive suffix> 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.
This commit is contained in:
parent
b7f6be6ff2
commit
ddfc322de8
191
dev/tools/lib/archive_publisher.dart
Normal file
191
dev/tools/lib/archive_publisher.dart
Normal file
@ -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<String> platforms = <String>['linux', 'mac', 'win'];
|
||||||
|
final Map<String, String> metadata = <String, String>{};
|
||||||
|
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(<String>['acl', 'get', metadataGsPath]);
|
||||||
|
if (result.exitCode != 0) {
|
||||||
|
throw new ArchivePublisherException(
|
||||||
|
'GSUtil cannot get ACLs for metadata file $metadataGsPath', result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateMetadata(Map<String, String> metadata) {
|
||||||
|
final ProcessResult result = _runGsUtil(<String>['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<String, dynamic> 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'] = <String, dynamic>{};
|
||||||
|
}
|
||||||
|
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<String> args) {
|
||||||
|
return processManager.runSync(<String>['gsutil']..addAll(args));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cloudCopy(String src, String dest) {
|
||||||
|
final ProcessResult result = _runGsUtil(<String>['cp', src, dest]);
|
||||||
|
if (result.exitCode != 0) {
|
||||||
|
throw new ArchivePublisherException('GSUtil copy command failed: ${result.stderr}', result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -10,7 +10,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:args/args.dart';
|
import 'package:args/args.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'archive_publisher.dart';
|
||||||
|
|
||||||
const String kIncrement = 'increment';
|
const String kIncrement = 'increment';
|
||||||
const String kX = 'x';
|
const String kX = 'x';
|
||||||
@ -19,9 +19,9 @@ const String kZ = 'z';
|
|||||||
const String kHelp = 'help';
|
const String kHelp = 'help';
|
||||||
|
|
||||||
void main(List<String> args) {
|
void main(List<String> args) {
|
||||||
// If we're run from the `tools` dir, set the cwd to the repo root.
|
// Set the cwd to the repo root, since we know where this script is located.
|
||||||
if (path.basename(Directory.current.path) == 'tools')
|
final Directory scriptLocation = new Directory(Platform.script.toFilePath());
|
||||||
Directory.current = Directory.current.parent.parent;
|
Directory.current = scriptLocation.parent.parent.parent;
|
||||||
|
|
||||||
final ArgParser argParser = new ArgParser(allowTrailingOptions: false);
|
final ArgParser argParser = new ArgParser(allowTrailingOptions: false);
|
||||||
argParser.addOption(
|
argParser.addOption(
|
||||||
@ -100,8 +100,10 @@ void main(List<String> args) {
|
|||||||
runGit('fetch upstream', 'fetch upstream');
|
runGit('fetch upstream', 'fetch upstream');
|
||||||
runGit('reset upstream/master --hard', 'check out master branch');
|
runGit('reset upstream/master --hard', 'check out master branch');
|
||||||
runGit('tag $version', 'tag the commit with the version label');
|
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] ');
|
stdout.write('Are you? [yes/no] ');
|
||||||
if (stdin.readLineSync() != 'yes') {
|
if (stdin.readLineSync() != 'yes') {
|
||||||
runGit('tag -d $version', 'remove the tag you did not want to publish');
|
runGit('tag -d $version', 'remove the tag you did not want to publish');
|
||||||
@ -109,6 +111,17 @@ void main(List<String> args) {
|
|||||||
exit(0);
|
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 $version', 'publish the version');
|
||||||
runGit('push upstream HEAD:dev', 'land the new version on the "dev" branch');
|
runGit('push upstream HEAD:dev', 'land the new version on the "dev" branch');
|
||||||
print('Flutter version $version has been rolled to the "dev" channel!');
|
print('Flutter version $version has been rolled to the "dev" channel!');
|
||||||
|
@ -8,13 +8,53 @@ dependencies:
|
|||||||
intl: 0.15.2
|
intl: 0.15.2
|
||||||
meta: 1.1.1
|
meta: 1.1.1
|
||||||
path: 1.5.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
|
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
|
charcode: 1.1.1 # TRANSITIVE DEPENDENCY
|
||||||
|
cli_util: 0.1.2+1 # TRANSITIVE DEPENDENCY
|
||||||
collection: 1.14.5 # TRANSITIVE DEPENDENCY
|
collection: 1.14.5 # TRANSITIVE DEPENDENCY
|
||||||
convert: 2.0.1 # TRANSITIVE DEPENDENCY
|
convert: 2.0.1 # TRANSITIVE DEPENDENCY
|
||||||
crypto: 2.0.2+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
|
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
|
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
|
string_scanner: 1.0.2 # TRANSITIVE DEPENDENCY
|
||||||
|
term_glyph: 1.0.0 # TRANSITIVE DEPENDENCY
|
||||||
typed_data: 1.1.4 # 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
|
||||||
|
83
dev/tools/test/archive_publisher_test.dart
Normal file
83
dev/tools/test/archive_publisher_test.dart
Normal file
@ -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<String> emptyStdout = <String>[''];
|
||||||
|
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<String, List<String>> calls = <String, List<String>>{
|
||||||
|
'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': <String>[
|
||||||
|
'''{
|
||||||
|
"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": {'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
210
dev/tools/test/fake_process_manager.dart
Normal file
210
dev/tools/test/fake_process_manager.dart
Normal file
@ -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<String, List<ProcessResult>> fakeResults = <String, List<ProcessResult>>{};
|
||||||
|
|
||||||
|
/// The list of invocations that occurred, in the order they occurred.
|
||||||
|
List<Invocation> invocations = <Invocation>[];
|
||||||
|
|
||||||
|
/// Verify that the given command lines were called, in the given order.
|
||||||
|
void verifyCalls(List<String> 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<String, List<String>> results) {
|
||||||
|
final Map<String, List<ProcessResult>> resultCodeUnits = <String, List<ProcessResult>>{};
|
||||||
|
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<Process> _nextProcess(Invocation invocation) async {
|
||||||
|
invocations.add(invocation);
|
||||||
|
return new Future<Process>.value(_popProcess(invocation.positionalArguments[0].join(' ')));
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessResult _nextResultSync(Invocation invocation) {
|
||||||
|
invocations.add(invocation);
|
||||||
|
return _popResult(invocation.positionalArguments[0].join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ProcessResult> _nextResult(Invocation invocation) async {
|
||||||
|
invocations.add(invocation);
|
||||||
|
return new Future<ProcessResult>.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<List<int>>.fromIterable(<List<int>>[result.stdout.codeUnits]),
|
||||||
|
stderrStream = new Stream<List<int>>.fromIterable(<List<int>>[result.stderr.codeUnits]),
|
||||||
|
desiredExitCode = result.exitCode,
|
||||||
|
stdinSink = new IOSink(new StringStreamConsumer(stdinResults)) {
|
||||||
|
_setupMock();
|
||||||
|
}
|
||||||
|
|
||||||
|
final IOSink stdinSink;
|
||||||
|
final Stream<List<int>> stdoutStream;
|
||||||
|
final Stream<List<int>> stderrStream;
|
||||||
|
final int desiredExitCode;
|
||||||
|
|
||||||
|
void _setupMock() {
|
||||||
|
when(kill(typed(captureAny))).thenReturn(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> get exitCode => new Future<int>.value(desiredExitCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get pid => 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
IOSink get stdin => stdinSink;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<int>> get stderr => stderrStream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<int>> 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<List<int>> {
|
||||||
|
StringStreamConsumer(this.sendString);
|
||||||
|
|
||||||
|
List<Stream<List<int>>> streams = <Stream<List<int>>>[];
|
||||||
|
List<StreamSubscription<List<int>>> subscriptions = <StreamSubscription<List<int>>>[];
|
||||||
|
List<Completer<dynamic>> completers = <Completer<dynamic>>[];
|
||||||
|
|
||||||
|
/// The callback called when this consumer receives input.
|
||||||
|
StringReceivedCallback sendString;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<dynamic> addStream(Stream<List<int>> value) {
|
||||||
|
streams.add(value);
|
||||||
|
completers.add(new Completer<dynamic>());
|
||||||
|
subscriptions.add(value.listen((List<int> data) {
|
||||||
|
sendString(utf8.decode(data));
|
||||||
|
}));
|
||||||
|
subscriptions.last.onDone(() => completers.last.complete(null));
|
||||||
|
return new Future<dynamic>.value(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<dynamic> close() async {
|
||||||
|
for (Completer<dynamic> completer in completers) {
|
||||||
|
await completer.future;
|
||||||
|
}
|
||||||
|
completers.clear();
|
||||||
|
streams.clear();
|
||||||
|
subscriptions.clear();
|
||||||
|
return new Future<dynamic>.value(null);
|
||||||
|
}
|
||||||
|
}
|
92
dev/tools/test/fake_process_manager_test.dart
Normal file
92
dev/tools/test/fake_process_manager_test.dart
Normal file
@ -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<String> stdinCaptured = <String>[];
|
||||||
|
|
||||||
|
void _captureStdin(String item) {
|
||||||
|
stdinCaptured.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
processManager = new FakeProcessManager(stdinResults: _captureStdin);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {});
|
||||||
|
|
||||||
|
test('start works', () async {
|
||||||
|
final Map<String, List<String>> calls = <String, List<String>>{
|
||||||
|
'gsutil acl get gs://flutter_infra/releases/releases.json': <String>['output1'],
|
||||||
|
'gsutil cat gs://flutter_infra/releases/releases.json': <String>['test'],
|
||||||
|
};
|
||||||
|
processManager.setResults(calls);
|
||||||
|
for (String key in calls.keys) {
|
||||||
|
final Process process = await processManager.start(key.split(' '));
|
||||||
|
String output = '';
|
||||||
|
process.stdout.listen((List<int> item) {
|
||||||
|
output += utf8.decode(item);
|
||||||
|
});
|
||||||
|
await process.exitCode;
|
||||||
|
expect(output, equals(calls[key][0]));
|
||||||
|
}
|
||||||
|
processManager.verifyCalls(calls.keys);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('run works', () async {
|
||||||
|
final Map<String, List<String>> calls = <String, List<String>>{
|
||||||
|
'gsutil acl get gs://flutter_infra/releases/releases.json': <String>['output1'],
|
||||||
|
'gsutil cat gs://flutter_infra/releases/releases.json': <String>['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<String, List<String>> calls = <String, List<String>>{
|
||||||
|
'gsutil acl get gs://flutter_infra/releases/releases.json': <String>['output1'],
|
||||||
|
'gsutil cat gs://flutter_infra/releases/releases.json': <String>['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<String, List<String>> calls = <String, List<String>>{
|
||||||
|
'gsutil acl get gs://flutter_infra/releases/releases.json': <String>['output1'],
|
||||||
|
'gsutil cat gs://flutter_infra/releases/releases.json': <String>['test'],
|
||||||
|
};
|
||||||
|
processManager.setResults(calls);
|
||||||
|
for (String key in calls.keys) {
|
||||||
|
final Process process = await processManager.start(key.split(' '));
|
||||||
|
String output = '';
|
||||||
|
process.stdout.listen((List<int> 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user