diff --git a/dev/bots/prepare_package.dart b/dev/bots/prepare_package.dart new file mode 100644 index 00000000000..5a449572cde --- /dev/null +++ b/dev/bots/prepare_package.dart @@ -0,0 +1,265 @@ +// Copyright 2017 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:args/args.dart'; +import 'package:path/path.dart' as path; + +const String CHROMIUM_REPO = + 'https://chromium.googlesource.com/external/github.com/flutter/flutter'; +const String GITHUB_REPO = 'https://github.com/flutter/flutter.git'; + +/// The type of the process runner function. This allows us to +/// inject a fake process runner into the ArchiveCreator for tests. +typedef ProcessResult ProcessRunner( + String executable, + List arguments, { + String workingDirectory, + Map environment, + bool includeParentEnvironment, + bool runInShell, + Encoding stdoutEncoding, + Encoding stderrEncoding, +}); + +/// Error class for when a process fails to run, so we can catch +/// it and provide something more readable than a stack trace. +class ProcessFailedException extends Error { + ProcessFailedException([this.message, this.exitCode]); + + String message = ''; + int exitCode = 0; + + @override + String toString() => message; +} + +/// Creates a pre-populated Flutter archive from a git repo. +class ArchiveCreator { + /// [tempDir] is the directory to use for creating the archive. Will place + /// several GiB of data there, so it should have available space. + /// [outputFile] is the name of the output archive. It should end in either + /// ".tar.bz2" or ".zip". + /// The runner argument is used to inject a mock of [Process.runSync] for + /// testing purposes. + ArchiveCreator(this.tempDir, this.outputFile, {ProcessRunner runner}) + : assert(outputFile.path.toLowerCase().endsWith('.zip') || + outputFile.path.toLowerCase().endsWith('.tar.bz2')), + flutterRoot = new Directory(path.join(tempDir.path, 'flutter')), + _runner = runner ?? Process.runSync { + flutter = path.join( + flutterRoot.absolute.path, + 'bin', + Platform.isWindows ? 'flutter.bat' : 'flutter', + ); + environment = new Map.from(Platform.environment); + environment['PUB_CACHE'] = path.join(flutterRoot.absolute.path, '.pub-cache'); + } + + final Directory flutterRoot; + final Directory tempDir; + final File outputFile; + final ProcessRunner _runner; + String flutter; + final String git = Platform.isWindows ? 'git.bat' : 'git'; + final String zip = Platform.isWindows ? 'zip.exe' : 'zip'; + final String tar = Platform.isWindows ? 'tar.exe' : 'tar'; + Map environment; + + /// Clone the Flutter repo and make sure that the git environment is sane + /// for when the user will unpack it. + void checkoutFlutter(String revision) { + // We want the user to start out the in the 'master' branch instead of a + // detached head. To do that, we need to make sure master points at the + // desired revision. + runGit(['clone', '-b', 'master', CHROMIUM_REPO], workingDirectory: tempDir); + runGit(['reset', '--hard', revision]); + + // Make the origin point to github instead of the chromium mirror. + runGit(['remote', 'remove', 'origin']); + runGit(['remote', 'add', 'origin', GITHUB_REPO]); + } + + /// Prepare the archive repo so that it has all of the caches warmed up and + /// is configured for the user to being working. + void prepareArchive() { + runFlutter(['doctor']); + runFlutter(['update-packages']); + runFlutter(['precache']); + runFlutter(['ide-config']); + + // Create each of the templates, since they will call pub get on + // themselves when created, and this will warm the cache with their + // dependencies too. + for (String template in ['app', 'package', 'plugin']) { + final String createName = path.join(tempDir.path, 'create_$template'); + runFlutter( + ['create', '--template=$template', createName], + ); + } + + // Yes, we could just skip all .packages files when constructing + // the archive, but some are checked in, and we don't want to skip + // those. + runGit(['clean', '-f', '-X', '**/.packages']); + } + + /// Create the archive into the given output file. + void createArchive() { + if (outputFile.path.toLowerCase().endsWith('.zip')) { + createZipArchive(outputFile, flutterRoot); + } else if (outputFile.path.toLowerCase().endsWith('.tar.bz2')) { + createTarArchive(outputFile, flutterRoot); + } + } + + String _runProcess(String executable, List args, {Directory workingDirectory}) { + workingDirectory ??= flutterRoot; + stderr.write('Running "$executable ${args.join(' ')}" in ${workingDirectory.path}.\n'); + ProcessResult result; + try { + result = _runner( + executable, + args, + workingDirectory: workingDirectory.absolute.path, + environment: environment, + includeParentEnvironment: false, + ); + } on ProcessException catch (e) { + final String message = 'Running "$executable ${args.join(' ')}" in ${workingDirectory.path} ' + 'failed with:\n${e.toString()}\n PATH: ${environment['PATH']}'; + throw new ProcessFailedException(message, -1); + } catch (e) { + rethrow; + } + stdout.write(result.stdout); + stderr.write(result.stderr); + if (result.exitCode != 0) { + final String message = 'Running "$executable ${args.join(' ')}" in ${workingDirectory.path} ' + 'failed with ${result.exitCode}.'; + throw new ProcessFailedException(message, result.exitCode); + } + return result.stdout.trim(); + } + + String runFlutter(List args) { + return _runProcess(flutter, args); + } + + String runGit(List args, {Directory workingDirectory}) { + return _runProcess(git, args, workingDirectory: workingDirectory); + } + + String createZipArchive(File output, Directory source) { + final List args = [ + '-r', + '-9', + '-q', + output.absolute.path, + path.basename(source.absolute.path), + ]; + + return _runProcess(zip, args, + workingDirectory: new Directory(path.dirname(source.absolute.path))); + } + + String createTarArchive(File output, Directory source) { + final List args = [ + 'cjf', + output.absolute.path, + path.basename(source.absolute.path), + ]; + return _runProcess(tar, args, + workingDirectory: new Directory(path.dirname(source.absolute.path))); + } +} + +/// Prepares a flutter git repo to be packaged up for distribution. +/// It mainly serves to populate the .pub-cache with any appropriate Dart +/// packages, and the flutter cache in bin/cache with the appropriate +/// dependencies and snapshots. +void main(List argList) { + final ArgParser argParser = new ArgParser(); + argParser.addOption( + 'temp_dir', + defaultsTo: null, + help: 'A location where temporary files may be written. Defaults to a ' + 'directory in the system temp folder. Will write a few GiB of data, ' + 'so it should have sufficient free space.', + ); + argParser.addOption( + 'revision', + defaultsTo: 'master', + help: 'The Flutter revision to build the archive with. Defaults to the ' + "master branch's HEAD revision.", + ); + argParser.addOption( + 'output', + defaultsTo: null, + help: 'The path where the output archive should be written. ' + 'The suffix determines the output format: .tar.bz2 or .zip are the ' + 'only formats supported.', + ); + final ArgResults args = argParser.parse(argList); + + void errorExit(String message, {int exitCode = -1}) { + stderr.write('Error: $message\n\n'); + stderr.write('${argParser.usage}\n'); + exit(exitCode); + } + + if (args['revision'].isEmpty) { + errorExit('Invalid argument: --revision must be specified.'); + } + + Directory tmpDir; + bool removeTempDir = false; + if (args['temp_dir'] == null || args['temp_dir'].isEmpty) { + tmpDir = Directory.systemTemp.createTempSync('flutter_'); + removeTempDir = true; + } else { + tmpDir = new Directory(args['temp_dir']); + if (!tmpDir.existsSync()) { + errorExit("Temporary directory ${args['temp_dir']} doesn't exist."); + } + } + + String outputFileString = args['output']; + if (outputFileString == null || outputFileString.isEmpty) { + final String suffix = Platform.isWindows ? '.zip' : '.tar.bz2'; + outputFileString = path.join(tmpDir.path, 'flutter_${args['revision']}$suffix'); + } else if (!outputFileString.toLowerCase().endsWith('.zip') && + !outputFileString.toLowerCase().endsWith('.tar.bz2')) { + errorExit('Output file has unsupported suffix. It should be either ".zip" or ".tar.bz2".'); + } + + final File outputFile = new File(outputFileString); + if (outputFile.existsSync()) { + errorExit('Output file ${outputFile.absolute.path} already exists.'); + } + + final ArchiveCreator preparer = new ArchiveCreator(tmpDir, outputFile); + int exitCode = 0; + String message; + try { + preparer.checkoutFlutter(args['revision']); + preparer.prepareArchive(); + preparer.createArchive(); + } on ProcessFailedException catch (e) { + exitCode = e.exitCode; + message = e.message; + } catch (e) { + rethrow; + } finally { + if (removeTempDir) { + tmpDir.deleteSync(recursive: true); + } + if (exitCode != 0) { + errorExit(message, exitCode: exitCode); + } + exit(0); + } +} diff --git a/dev/bots/pubspec.yaml b/dev/bots/pubspec.yaml index 54f41c62013..1ec07026fbb 100644 --- a/dev/bots/pubspec.yaml +++ b/dev/bots/pubspec.yaml @@ -1,5 +1,46 @@ name: tests_on_bots -description: Script to run all tests on bots. +description: Scripts which run on bots. dependencies: path: 1.5.1 + args: 0.13.7 + +dev_dependencies: + test: 0.12.26 + mockito: 2.2.1 + + async: 1.13.3 # TRANSITIVE DEPENDENCY + barback: 0.15.2+13 # TRANSITIVE DEPENDENCY + boolean_selector: 1.0.2 # TRANSITIVE DEPENDENCY + charcode: 1.1.1 # TRANSITIVE DEPENDENCY + collection: 1.14.3 # TRANSITIVE DEPENDENCY + convert: 2.0.1 # TRANSITIVE DEPENDENCY + crypto: 2.0.2+1 # TRANSITIVE DEPENDENCY + glob: 1.1.5 # TRANSITIVE DEPENDENCY + http: 0.11.3+14 # TRANSITIVE DEPENDENCY + http_multi_server: 2.0.4 # TRANSITIVE DEPENDENCY + http_parser: 3.1.1 # TRANSITIVE DEPENDENCY + io: 0.3.1 # TRANSITIVE DEPENDENCY + js: 0.6.1 # TRANSITIVE DEPENDENCY + matcher: 0.12.1+4 # TRANSITIVE DEPENDENCY + meta: 1.1.1 # TRANSITIVE DEPENDENCY + mime: 0.9.5 # TRANSITIVE DEPENDENCY + node_preamble: 1.4.0 # TRANSITIVE DEPENDENCY + package_config: 1.0.3 # TRANSITIVE DEPENDENCY + package_resolver: 1.0.2 # TRANSITIVE DEPENDENCY + pool: 1.3.3 # TRANSITIVE DEPENDENCY + pub_semver: 1.3.2 # TRANSITIVE DEPENDENCY + shelf: 0.7.1 # TRANSITIVE DEPENDENCY + shelf_packages_handler: 1.0.3 # TRANSITIVE DEPENDENCY + shelf_static: 0.2.6 # 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.2 # TRANSITIVE DEPENDENCY + string_scanner: 1.0.2 # TRANSITIVE DEPENDENCY + term_glyph: 1.0.0 # TRANSITIVE DEPENDENCY + typed_data: 1.1.4 # TRANSITIVE DEPENDENCY + web_socket_channel: 1.0.6 # TRANSITIVE DEPENDENCY + yaml: 2.1.13 # TRANSITIVE DEPENDENCY diff --git a/dev/bots/test.dart b/dev/bots/test.dart index 3c960572a50..ce5294fbb18 100644 --- a/dev/bots/test.dart +++ b/dev/bots/test.dart @@ -162,6 +162,7 @@ Future _runTests() async { await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter_driver')); await _runFlutterTest(path.join(flutterRoot, 'packages', 'flutter_test')); await _pubRunTest(path.join(flutterRoot, 'packages', 'flutter_tools')); + await _pubRunTest(path.join(flutterRoot, 'dev', 'bots')); await _runAllDartTests(path.join(flutterRoot, 'dev', 'devicelab')); await _runFlutterTest(path.join(flutterRoot, 'dev', 'manual_tests')); diff --git a/dev/bots/test/prepare_package_test.dart b/dev/bots/test/prepare_package_test.dart new file mode 100644 index 00000000000..0d7a3a9b335 --- /dev/null +++ b/dev/bots/test/prepare_package_test.dart @@ -0,0 +1,193 @@ +// Copyright 2017 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:mockito/mockito.dart'; +import 'package:test/test.dart'; +import 'package:path/path.dart' as path; + +import '../prepare_package.dart'; + +void main() { + group('ArchiveCreator', () { + ArchiveCreator preparer; + Directory tmpDir; + Directory flutterDir; + File outputFile; + MockProcessRunner runner; + List results; + final List> args = >[]; + final List> namedArgs = >[]; + final String zipExe = Platform.isWindows ? 'zip.exe' : 'zip'; + final String tarExe = Platform.isWindows ? 'tar.exe' : 'tar'; + final String gitExe = Platform.isWindows ? 'git.bat' : 'git'; + String flutterExe; + + void _verifyCommand(List args, String expected) { + final List expectedList = expected.split(' '); + final String executable = expectedList.removeAt(0); + expect(args[0], executable); + expect(args[1], orderedEquals(expectedList)); + } + + ProcessResult _nextResult(Invocation invocation) { + args.add(invocation.positionalArguments); + namedArgs.add(invocation.namedArguments); + return results.isEmpty ? new MockProcessResult('', '', 0) : results.removeAt(0); + } + + void _answerWithResults() { + when( + runner.call( + typed(captureAny), + typed(captureAny), + environment: typed(captureAny, named: 'environment'), + workingDirectory: typed(captureAny, named: 'workingDirectory'), + includeParentEnvironment: typed(captureAny, named: 'includeParentEnvironment'), + ), + ).thenAnswer(_nextResult); + } + + setUp(() async { + runner = new MockProcessRunner(); + args.clear(); + namedArgs.clear(); + tmpDir = await Directory.systemTemp.createTemp('flutter_'); + flutterDir = new Directory(path.join(tmpDir.path, 'flutter')); + flutterExe = + path.join(flutterDir.path, 'bin', Platform.isWindows ? 'flutter.bat' : 'flutter'); + }); + + tearDown(() async { + await tmpDir.delete(recursive: true); + }); + + test('sets PUB_CACHE properly', () async { + outputFile = new File(path.join(tmpDir.absolute.path, 'flutter_master.tar.bz2')); + preparer = new ArchiveCreator(tmpDir, outputFile, runner: runner); + _answerWithResults(); + results = [new MockProcessResult('deadbeef\n', '', 0)]; + preparer.checkoutFlutter('master'); + preparer.prepareArchive(); + preparer.createArchive(); + expect( + verify(runner.call( + captureAny, + captureAny, + workingDirectory: captureAny, + environment: captureAny, + includeParentEnvironment: typed(captureAny, named: 'includeParentEnvironment'), + )).captured[2]['PUB_CACHE'], + endsWith(path.join('flutter', '.pub-cache')), + ); + }); + + test('calls the right commands for tar output', () async { + outputFile = new File(path.join(tmpDir.absolute.path, 'flutter_master.tar.bz2')); + preparer = new ArchiveCreator(tmpDir, outputFile, runner: runner); + _answerWithResults(); + results = [new MockProcessResult('deadbeef\n', '', 0)]; + preparer.checkoutFlutter('master'); + preparer.prepareArchive(); + preparer.createArchive(); + final List commands = [ + '$gitExe clone -b master https://chromium.googlesource.com/external/github.com/flutter/flutter', + '$gitExe reset --hard master', + '$gitExe remote remove origin', + '$gitExe remote add origin https://github.com/flutter/flutter.git', + '$flutterExe doctor', + '$flutterExe update-packages', + '$flutterExe precache', + '$flutterExe ide-config', + '$flutterExe create --template=app ${path.join(tmpDir.path, 'create_app')}', + '$flutterExe create --template=package ${path.join(tmpDir.path, 'create_package')}', + '$flutterExe create --template=plugin ${path.join(tmpDir.path, 'create_plugin')}', + '$gitExe clean -f -X **/.packages', + '$tarExe cjf ${path.join(tmpDir.path, 'flutter_master.tar.bz2')} flutter', + ]; + int step = 0; + for (String command in commands) { + _verifyCommand(args[step++], command); + } + }); + + test('calls the right commands for zip output', () async { + outputFile = new File(path.join(tmpDir.absolute.path, 'flutter_master.zip')); + preparer = new ArchiveCreator(tmpDir, outputFile, runner: runner); + _answerWithResults(); + results = [new MockProcessResult('deadbeef\n', '', 0)]; + preparer.checkoutFlutter('master'); + preparer.prepareArchive(); + preparer.createArchive(); + final List commands = [ + '$gitExe clone -b master https://chromium.googlesource.com/external/github.com/flutter/flutter', + '$gitExe reset --hard master', + '$gitExe remote remove origin', + '$gitExe remote add origin https://github.com/flutter/flutter.git', + '$flutterExe doctor', + '$flutterExe update-packages', + '$flutterExe precache', + '$flutterExe ide-config', + '$flutterExe create --template=app ${path.join(tmpDir.path, 'create_app')}', + '$flutterExe create --template=package ${path.join(tmpDir.path, 'create_package')}', + '$flutterExe create --template=plugin ${path.join(tmpDir.path, 'create_plugin')}', + '$gitExe clean -f -X **/.packages', + '$zipExe -r -9 -q ${path.join(tmpDir.path, 'flutter_master.zip')} flutter', + ]; + int step = 0; + for (String command in commands) { + _verifyCommand(args[step++], command); + } + }); + + test('throws when a command errors out', () async { + outputFile = new File(path.join(tmpDir.absolute.path, 'flutter.tar.bz2')); + preparer = new ArchiveCreator( + tmpDir, + outputFile, + runner: runner, + ); + + results = [ + new MockProcessResult('', '', 0), + new MockProcessResult('OMG! OMG! an ERROR!\n', '', -1) + ]; + _answerWithResults(); + expect(() => preparer.checkoutFlutter('master'), + throwsA(const isInstanceOf())); + expect(args.length, 2); + _verifyCommand(args[0], + '$gitExe clone -b master https://chromium.googlesource.com/external/github.com/flutter/flutter'); + _verifyCommand(args[1], '$gitExe reset --hard master'); + }); + }); +} + +class MockProcessRunner extends Mock implements Function { + ProcessResult call( + String executable, + List arguments, { + String workingDirectory, + Map environment, + bool includeParentEnvironment, + bool runInShell, + Encoding stdoutEncoding, + Encoding stderrEncoding, + }); +} + +class MockProcessResult extends Mock implements ProcessResult { + MockProcessResult(this.stdout, [this.stderr = '', this.exitCode = 0]); + + @override + dynamic stdout = ''; + + @override + dynamic stderr; + + @override + int exitCode; +}