From 772437627b958b0ea30fd560510e93b3eaed5dc7 Mon Sep 17 00:00:00 2001 From: Christopher Fujino Date: Fri, 6 Nov 2020 11:14:04 -0800 Subject: [PATCH] [flutter_conductor] update dev/tools with release tool (#69791) --- dev/tools/.gitignore | 1 + dev/tools/bin/conductor | 39 + dev/tools/bin/conductor.dart | 77 ++ dev/tools/lib/git.dart | 72 ++ dev/tools/lib/globals.dart | 26 + dev/tools/lib/repository.dart | 365 +++++++ dev/tools/lib/roll_dev.dart | 393 +++---- dev/tools/lib/stdio.dart | 62 ++ dev/tools/lib/version.dart | 211 ++++ dev/tools/pubspec.yaml | 2 + dev/tools/test/common.dart | 112 ++ dev/tools/test/roll_dev_integration_test.dart | 132 +++ dev/tools/test/roll_dev_test.dart | 963 +++++++++++------- dev/tools/test/version_test.dart | 87 ++ 14 files changed, 1885 insertions(+), 657 deletions(-) create mode 100644 dev/tools/.gitignore create mode 100755 dev/tools/bin/conductor create mode 100644 dev/tools/bin/conductor.dart create mode 100644 dev/tools/lib/git.dart create mode 100644 dev/tools/lib/globals.dart create mode 100644 dev/tools/lib/repository.dart create mode 100644 dev/tools/lib/stdio.dart create mode 100644 dev/tools/lib/version.dart create mode 100644 dev/tools/test/roll_dev_integration_test.dart create mode 100644 dev/tools/test/version_test.dart diff --git a/dev/tools/.gitignore b/dev/tools/.gitignore new file mode 100644 index 0000000000..b3dd1520c4 --- /dev/null +++ b/dev/tools/.gitignore @@ -0,0 +1 @@ +checkouts/ diff --git a/dev/tools/bin/conductor b/dev/tools/bin/conductor new file mode 100755 index 0000000000..0024a31a1b --- /dev/null +++ b/dev/tools/bin/conductor @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Needed because if it is set, cd may print the path it changed to. +unset CDPATH + +# On Mac OS, readlink -f doesn't work, so follow_links traverses the path one +# link at a time, and then cds into the link destination and find out where it +# ends up. +# +# The returned filesystem path must be a format usable by Dart's URI parser, +# since the Dart command line tool treats its argument as a file URI, not a +# filename. For instance, multiple consecutive slashes should be reduced to a +# single slash, since double-slashes indicate a URI "authority", and these are +# supposed to be filenames. There is an edge case where this will return +# multiple slashes: when the input resolves to the root directory. However, if +# that were the case, we wouldn't be running this shell, so we don't do anything +# about it. +# +# The function is enclosed in a subshell to avoid changing the working directory +# of the caller. +function follow_links() ( + cd -P "$(dirname -- "$1")" + file="$PWD/$(basename -- "$1")" + while [[ -h "$file" ]]; do + cd -P "$(dirname -- "$file")" + file="$(readlink -- "$file")" + cd -P "$(dirname -- "$file")" + file="$PWD/$(basename -- "$file")" + done + echo "$file" +) + +PROG_NAME="$(follow_links "${BASH_SOURCE[0]}")" +BIN_DIR="$(cd "${PROG_NAME%/*}" ; pwd -P)" +DART_BIN="$BIN_DIR/../../../bin/dart" + +"$DART_BIN" --enable-asserts "$BIN_DIR/conductor.dart" "$@" diff --git a/dev/tools/bin/conductor.dart b/dev/tools/bin/conductor.dart new file mode 100644 index 0000000000..b5a30714e5 --- /dev/null +++ b/dev/tools/bin/conductor.dart @@ -0,0 +1,77 @@ +// 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. + +// Rolls the dev channel. +// Only tested on Linux. +// +// See: https://github.com/flutter/flutter/wiki/Release-process + +import 'dart:io' as io; + +import 'package:args/command_runner.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:platform/platform.dart'; +import 'package:process/process.dart'; +import 'package:dev_tools/repository.dart'; +import 'package:dev_tools/roll_dev.dart'; +import 'package:dev_tools/stdio.dart'; + +void main(List args) { + const FileSystem fileSystem = LocalFileSystem(); + const ProcessManager processManager = LocalProcessManager(); + const Platform platform = LocalPlatform(); + final Stdio stdio = VerboseStdio( + stdout: io.stdout, + stderr: io.stderr, + stdin: io.stdin, + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + platform: platform, + processManager: processManager, + ); + final CommandRunner runner = CommandRunner( + 'conductor', + 'A tool for coordinating Flutter releases.', + usageLineLength: 80, + ); + + >[ + RollDev( + fileSystem: fileSystem, + platform: platform, + repository: checkouts.addRepo( + fileSystem: fileSystem, + platform: platform, + repoType: RepositoryType.framework, + stdio: stdio, + ), + stdio: stdio, + ), + ].forEach(runner.addCommand); + + if (!assertsEnabled()) { + stdio.printError('The conductor tool must be run with --enable-asserts.'); + io.exit(1); + } + + try { + runner.run(args); + } on Exception catch (e) { + stdio.printError(e.toString()); + io.exit(1); + } +} + +bool assertsEnabled() { + // Verify asserts enabled + bool assertsEnabled = false; + + assert(() { + assertsEnabled = true; + return true; + }()); + return assertsEnabled; +} diff --git a/dev/tools/lib/git.dart b/dev/tools/lib/git.dart new file mode 100644 index 0000000000..468c91d4a7 --- /dev/null +++ b/dev/tools/lib/git.dart @@ -0,0 +1,72 @@ +// 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:io'; + +import 'package:meta/meta.dart'; +import 'package:process/process.dart'; + +import './globals.dart'; + +/// A wrapper around git process calls that can be mocked for unit testing. +class Git { + Git(this.processManager) : assert(processManager != null); + + final ProcessManager processManager; + + String getOutput( + List args, + String explanation, { + @required String workingDirectory, + }) { + final ProcessResult result = _run(args, workingDirectory); + if (result.exitCode == 0) { + return stdoutToString(result.stdout); + } + _reportFailureAndExit(args, workingDirectory, result, explanation); + return null; // for the analyzer's sake + } + + int run( + List args, + String explanation, { + bool allowNonZeroExitCode = false, + @required String workingDirectory, + }) { + final ProcessResult result = _run(args, workingDirectory); + if (result.exitCode != 0 && !allowNonZeroExitCode) { + _reportFailureAndExit(args, workingDirectory, result, explanation); + } + return result.exitCode; + } + + ProcessResult _run(List args, String workingDirectory) { + return processManager.runSync( + ['git', ...args], + workingDirectory: workingDirectory, + ); + } + + void _reportFailureAndExit( + List args, + String workingDirectory, + ProcessResult result, + String explanation, + ) { + final StringBuffer message = StringBuffer(); + if (result.exitCode != 0) { + message.writeln( + 'Command "git ${args.join(' ')}" failed in directory "$workingDirectory" to ' + '$explanation. Git exited with error code ${result.exitCode}.', + ); + } else { + message.writeln('Command "git ${args.join(' ')}" failed to $explanation.'); + } + if ((result.stdout as String).isNotEmpty) + message.writeln('stdout from git:\n${result.stdout}\n'); + if ((result.stderr as String).isNotEmpty) + message.writeln('stderr from git:\n${result.stderr}\n'); + throw Exception(message); + } +} diff --git a/dev/tools/lib/globals.dart b/dev/tools/lib/globals.dart new file mode 100644 index 0000000000..3d9c9207ef --- /dev/null +++ b/dev/tools/lib/globals.dart @@ -0,0 +1,26 @@ +// 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. + +const String kIncrement = 'increment'; +const String kCommit = 'commit'; +const String kRemoteName = 'remote'; +const String kJustPrint = 'just-print'; +const String kYes = 'yes'; +const String kForce = 'force'; +const String kSkipTagging = 'skip-tagging'; + +const String kUpstreamRemote = 'https://github.com/flutter/flutter.git'; + +const List kReleaseChannels = [ + 'stable', + 'beta', + 'dev', + 'master', +]; + +/// Cast a dynamic to String and trim. +String stdoutToString(dynamic input) { + final String str = input as String; + return str.trim(); +} diff --git a/dev/tools/lib/repository.dart b/dev/tools/lib/repository.dart new file mode 100644 index 0000000000..f54add1fa1 --- /dev/null +++ b/dev/tools/lib/repository.dart @@ -0,0 +1,365 @@ +// 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' show jsonDecode; +import 'dart:io' as io; + +import 'package:file/file.dart'; +import 'package:meta/meta.dart'; +import 'package:process/process.dart'; +import 'package:platform/platform.dart'; + +import './git.dart'; +import './globals.dart' as globals; +import './stdio.dart'; +import './version.dart'; + +/// A source code repository. +class Repository { + Repository({ + @required this.name, + @required this.upstream, + @required this.processManager, + @required this.stdio, + @required this.platform, + @required this.fileSystem, + @required this.parentDirectory, + this.localUpstream = false, + this.useExistingCheckout = false, + }) : git = Git(processManager), + assert(localUpstream != null), + assert(useExistingCheckout != null); + + final String name; + final String upstream; + final Git git; + final ProcessManager processManager; + final Stdio stdio; + final Platform platform; + final FileSystem fileSystem; + final Directory parentDirectory; + final bool useExistingCheckout; + + /// If the repository will be used as an upstream for a test repo. + final bool localUpstream; + + Directory _checkoutDirectory; + + /// Lazily-loaded directory for the repository checkout. + /// + /// Cloning a repository is time-consuming, thus the repository is not cloned + /// until this getter is called. + Directory get checkoutDirectory { + if (_checkoutDirectory != null) { + return _checkoutDirectory; + } + _checkoutDirectory = parentDirectory.childDirectory(name); + if (checkoutDirectory.existsSync() && !useExistingCheckout) { + deleteDirectory(); + } + if (!checkoutDirectory.existsSync()) { + stdio.printTrace('Cloning $name to ${checkoutDirectory.path}...'); + git.run( + ['clone', '--', upstream, checkoutDirectory.path], + 'Cloning $name repo', + workingDirectory: parentDirectory.path, + ); + if (localUpstream) { + // These branches must exist locally for the repo that depends on it + // to fetch and push to. + for (final String channel in globals.kReleaseChannels) { + git.run( + ['checkout', channel, '--'], + 'check out branch $channel locally', + workingDirectory: checkoutDirectory.path, + ); + } + } + } else { + stdio.printTrace( + 'Using existing $name repo at ${checkoutDirectory.path}...', + ); + } + return _checkoutDirectory; + } + + void deleteDirectory() { + if (!checkoutDirectory.existsSync()) { + stdio.printTrace( + 'Tried to delete ${checkoutDirectory.path} but it does not exist.', + ); + return; + } + stdio.printTrace('Deleting $name from ${checkoutDirectory.path}...'); + checkoutDirectory.deleteSync(recursive: true); + } + + /// The URL of the remote named [remoteName]. + String remoteUrl(String remoteName) { + assert(remoteName != null); + return git.getOutput( + ['remote', 'get-url', remoteName], + 'verify the URL of the $remoteName remote', + workingDirectory: checkoutDirectory.path, + ); + } + + /// Verify the repository's git checkout is clean. + bool gitCheckoutClean() { + final String output = git.getOutput( + ['status', '--porcelain'], + 'check that the git checkout is clean', + workingDirectory: checkoutDirectory.path, + ); + return output == ''; + } + + /// Fetch all branches and associated commits and tags from [remoteName]. + void fetch(String remoteName) { + git.run( + ['fetch', remoteName, '--tags'], + 'fetch $remoteName --tags', + workingDirectory: checkoutDirectory.path, + ); + } + + /// Obtain the version tag of the previous dev release. + String getFullTag(String remoteName) { + const String glob = '*.*.*-*.*.pre'; + // describe the latest dev release + final String ref = 'refs/remotes/$remoteName/dev'; + return git.getOutput( + ['describe', '--match', glob, '--exact-match', '--tags', ref], + 'obtain last released version number', + workingDirectory: checkoutDirectory.path, + ); + } + + /// Look up the commit for [ref]. + String reverseParse(String ref) { + final String revisionHash = git.getOutput( + ['rev-parse', ref], + 'look up the commit for the ref $ref', + workingDirectory: checkoutDirectory.path, + ); + assert(revisionHash.isNotEmpty); + return revisionHash; + } + + /// Determines if one ref is an ancestor for another. + bool isAncestor(String possibleAncestor, String possibleDescendant) { + final int exitcode = git.run( + [ + 'merge-base', + '--is-ancestor', + possibleDescendant, + possibleAncestor + ], + 'verify $possibleAncestor is a direct ancestor of $possibleDescendant.', + allowNonZeroExitCode: true, + workingDirectory: checkoutDirectory.path, + ); + return exitcode == 0; + } + + /// Determines if a given commit has a tag. + bool isCommitTagged(String commit) { + final int exitcode = git.run( + ['describe', '--exact-match', '--tags', commit], + 'verify $commit is already tagged', + allowNonZeroExitCode: true, + workingDirectory: checkoutDirectory.path, + ); + return exitcode == 0; + } + + /// Resets repository HEAD to [commit]. + void reset(String commit) { + git.run( + ['reset', commit, '--hard'], + 'reset to the release commit', + workingDirectory: checkoutDirectory.path, + ); + } + + /// Tag [commit] and push the tag to the remote. + void tag(String commit, String tagName, String remote) { + git.run( + ['tag', tagName, commit], + 'tag the commit with the version label', + workingDirectory: checkoutDirectory.path, + ); + git.run( + ['push', remote, tagName], + 'publish the tag to the repo', + workingDirectory: checkoutDirectory.path, + ); + } + + /// Push [commit] to the release channel [branch]. + void updateChannel( + String commit, + String remote, + String branch, { + bool force = false, + }) { + git.run( + [ + 'push', + if (force) '--force', + remote, + '$commit:$branch', + ], + 'update the release branch with the commit', + workingDirectory: checkoutDirectory.path, + ); + } + + Version flutterVersion() { + // Build tool + processManager.runSync([ + fileSystem.path.join(checkoutDirectory.path, 'bin', 'flutter'), + 'help', + ]); + // Check version + final io.ProcessResult result = processManager.runSync([ + fileSystem.path.join(checkoutDirectory.path, 'bin', 'flutter'), + '--version', + '--machine', + ]); + final Map versionJson = jsonDecode( + globals.stdoutToString(result.stdout), + ) as Map; + return Version.fromString(versionJson['frameworkVersion'] as String); + } + + /// Create an empty commit and return the revision. + @visibleForTesting + String authorEmptyCommit([String message = 'An empty commit']) { + git.run( + [ + '-c', + 'user.name=Conductor', + '-c', + 'user.email=conductor@flutter.dev', + 'commit', + '--allow-empty', + '-m', + '\'$message\'', + ], + 'create an empty commit', + workingDirectory: checkoutDirectory.path, + ); + return reverseParse('HEAD'); + } + + /// Create a new clone of the current repository. + /// + /// The returned repository will inherit all properties from this one, except + /// for the upstream, which will be the path to this repository on disk. + /// + /// This method is for testing purposes. + @visibleForTesting + Repository cloneRepository(String cloneName) { + assert(localUpstream); + cloneName ??= 'clone-of-$name'; + return Repository( + fileSystem: fileSystem, + name: cloneName, + parentDirectory: parentDirectory, + platform: platform, + processManager: processManager, + stdio: stdio, + upstream: 'file://${checkoutDirectory.path}/', + useExistingCheckout: useExistingCheckout, + ); + } +} + +/// An enum of all the repositories that the Conductor supports. +enum RepositoryType { + framework, + engine, +} + +class Checkouts { + Checkouts({ + @required Platform platform, + @required this.fileSystem, + @required this.processManager, + Directory parentDirectory, + String directoryName = 'checkouts', + }) { + if (parentDirectory != null) { + directory = parentDirectory.childDirectory(directoryName); + } else { + String filePath; + // If a test + if (platform.script.scheme == 'data') { + final RegExp pattern = RegExp( + r'(file:\/\/[^"]*[/\\]dev\/tools[/\\][^"]+\.dart)', + multiLine: true, + ); + final Match match = + pattern.firstMatch(Uri.decodeFull(platform.script.path)); + if (match == null) { + throw Exception( + 'Cannot determine path of script!\n${platform.script.path}', + ); + } + filePath = Uri.parse(match.group(1)).path.replaceAll(r'%20', ' '); + } else { + filePath = platform.script.toFilePath(); + } + final String checkoutsDirname = fileSystem.path.normalize( + fileSystem.path.join( + fileSystem.path.dirname(filePath), + '..', + 'checkouts', + ), + ); + directory = fileSystem.directory(checkoutsDirname); + } + if (!directory.existsSync()) { + directory.createSync(recursive: true); + } + } + + Directory directory; + final FileSystem fileSystem; + final ProcessManager processManager; + + Repository addRepo({ + @required RepositoryType repoType, + @required Stdio stdio, + @required Platform platform, + FileSystem fileSystem, + String upstream, + String name, + bool localUpstream = false, + bool useExistingCheckout = false, + }) { + switch (repoType) { + case RepositoryType.framework: + name ??= 'framework'; + upstream ??= 'https://github.com/flutter/flutter.git'; + break; + case RepositoryType.engine: + name ??= 'engine'; + upstream ??= 'https://github.com/flutter/engine.git'; + break; + } + return Repository( + name: name, + upstream: upstream, + stdio: stdio, + platform: platform, + fileSystem: fileSystem, + parentDirectory: directory, + processManager: processManager, + localUpstream: localUpstream, + useExistingCheckout: useExistingCheckout, + ); + } +} diff --git a/dev/tools/lib/roll_dev.dart b/dev/tools/lib/roll_dev.dart index 31a9cab423..46a16555b2 100644 --- a/dev/tools/lib/roll_dev.dart +++ b/dev/tools/lib/roll_dev.dart @@ -2,319 +2,190 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// Rolls the dev channel. -// Only tested on Linux. -// -// See: https://github.com/flutter/flutter/wiki/Release-process - -import 'dart:io'; - import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:file/file.dart'; import 'package:meta/meta.dart'; +import 'package:platform/platform.dart'; -const String kIncrement = 'increment'; -const String kX = 'x'; -const String kY = 'y'; -const String kZ = 'z'; -const String kCommit = 'commit'; -const String kOrigin = 'origin'; -const String kJustPrint = 'just-print'; -const String kYes = 'yes'; -const String kHelp = 'help'; -const String kForce = 'force'; -const String kSkipTagging = 'skip-tagging'; +import './globals.dart'; +import './repository.dart'; +import './stdio.dart'; +import './version.dart'; -const String kUpstreamRemote = 'git@github.com:flutter/flutter.git'; - -void main(List args) { - final ArgParser argParser = ArgParser(allowTrailingOptions: false); - - ArgResults argResults; - try { - argResults = parseArguments(argParser, args); - } on ArgParserException catch (error) { - print(error.message); - print(argParser.usage); - exit(1); +/// Create a new dev release without cherry picks. +class RollDev extends Command { + RollDev({ + this.fileSystem, + this.platform, + this.repository, + this.stdio, + }) { + argParser.addOption( + kIncrement, + help: 'Specifies which part of the x.y.z version number to increment. Required.', + valueHelp: 'level', + allowed: ['y', 'z', 'm'], + allowedHelp: { + 'y': 'Indicates the first dev release after a beta release.', + 'z': 'Indicates a hotfix to a stable release.', + 'm': 'Indicates a standard dev release.', + }, + ); + argParser.addOption( + kCommit, + help: 'Specifies which git commit to roll to the dev branch. Required.', + valueHelp: 'hash', + defaultsTo: null, // This option is required + ); + argParser.addFlag( + kForce, + abbr: 'f', + help: 'Force push. Necessary when the previous release had cherry-picks.', + negatable: false, + ); + argParser.addFlag( + kJustPrint, + negatable: false, + help: + "Don't actually roll the dev channel; " + 'just print the would-be version and quit.', + ); + argParser.addFlag( + kSkipTagging, + negatable: false, + help: 'Do not create tag and push to remote, only update release branch. ' + 'For recovering when the script fails trying to git push to the release branch.' + ); + argParser.addFlag(kYes, negatable: false, abbr: 'y', help: 'Skip the confirmation prompt.'); } - try { - run( - usage: argParser.usage, + final FileSystem fileSystem; + final Platform platform; + final Stdio stdio; + final Repository repository; + + @override + String get name => 'roll-dev'; + + @override + String get description => + 'For publishing a dev release without cherry picks.'; + + @override + void run() { + rollDev( argResults: argResults, - git: const Git(), + fileSystem: fileSystem, + platform: platform, + repository: repository, + stdio: stdio, + usage: argParser.usage, ); - } on Exception catch (e) { - print(e.toString()); - exit(1); } } /// Main script execution. /// /// Returns true if publishing was successful, else false. -bool run({ +@visibleForTesting +bool rollDev({ @required String usage, @required ArgResults argResults, - @required Git git, + @required Stdio stdio, + @required Platform platform, + @required FileSystem fileSystem, + @required Repository repository, + String remoteName = 'origin', }) { final String level = argResults[kIncrement] as String; final String commit = argResults[kCommit] as String; - final String origin = argResults[kOrigin] as String; final bool justPrint = argResults[kJustPrint] as bool; final bool autoApprove = argResults[kYes] as bool; - final bool help = argResults[kHelp] as bool; final bool force = argResults[kForce] as bool; final bool skipTagging = argResults[kSkipTagging] as bool; - if (help || level == null || commit == null) { - print( - 'roll_dev.dart --increment=level --commit=hash • update the version tags ' - 'and roll a new dev build.\n$usage' - ); + if (level == null || commit == null) { + stdio.printStatus( + 'roll_dev.dart --increment=level --commit=hash • update the version tags ' + 'and roll a new dev build.\n$usage'); return false; } - final String remote = git.getOutput( - 'remote get-url $origin', - 'check whether this is a flutter checkout', - ); - if (remote != kUpstreamRemote) { + final String remoteUrl = repository.remoteUrl(remoteName); + + if (!repository.gitCheckoutClean()) { throw Exception( - 'The remote named $origin is set to $remote, when $kUpstreamRemote was ' - 'expected.\nFor more details see: ' - 'https://github.com/flutter/flutter/wiki/Release-process' - ); + 'Your git repository is not clean. Try running "git clean -fd". Warning, ' + 'this will delete files! Run with -n to find out which ones.'); } - if (git.getOutput('status --porcelain', 'check status of your local checkout') != '') { + repository.fetch(remoteName); + + // Verify [commit] is valid + repository.reverseParse(commit); + + stdio.printStatus('remoteName is $remoteName'); + final Version lastVersion = + Version.fromString(repository.getFullTag(remoteName)); + + final Version version = + skipTagging ? lastVersion : Version.increment(lastVersion, level); + final String tagName = version.toString(); + + if (repository.reverseParse(lastVersion.toString()).contains(commit.trim())) { throw Exception( - 'Your git repository is not clean. Try running "git clean -fd". Warning, ' - 'this will delete files! Run with -n to find out which ones.' - ); - } - - git.run('fetch $origin', 'fetch $origin'); - - final String lastVersion = getFullTag(git, origin); - - final String version = skipTagging - ? lastVersion - : incrementLevel(lastVersion, level); - - if (git.getOutput( - 'rev-parse $lastVersion', - 'check if commit is already on dev', - ).contains(commit.trim())) { - throw Exception('Commit $commit is already on the dev branch as $lastVersion.'); + 'Commit $commit is already on the dev branch as $lastVersion.'); } if (justPrint) { - print(version); + stdio.printStatus(tagName); return false; } - if (skipTagging) { - git.run( - 'describe --exact-match --tags $commit', - 'verify $commit is already tagged. You can only use the flag ' - '`$kSkipTagging` if the commit has already been tagged.' - ); + if (skipTagging && !repository.isCommitTagged(commit)) { + throw Exception( + 'The $kSkipTagging flag is only supported for tagged commits.'); } - if (!force) { - git.run( - 'merge-base --is-ancestor $lastVersion $commit', - 'verify $lastVersion is a direct ancestor of $commit. The flag `$kForce`' - 'is required to force push a new release past a cherry-pick', - ); + if (!force && !repository.isAncestor(commit, lastVersion.toString())) { + throw Exception( + 'The previous dev tag $lastVersion is not a direct ancestor of $commit.\n' + 'The flag "$kForce" is required to force push a new release past a cherry-pick.'); } - git.run('reset $commit --hard', 'reset to the release commit'); + final String hash = repository.reverseParse(commit); - final String hash = git.getOutput('rev-parse HEAD', 'Get git hash for $commit'); + // [commit] can be a prefix for [hash]. + assert(hash.startsWith(commit)); // PROMPT - if (autoApprove) { - print('Publishing Flutter $version (${hash.substring(0, 10)}) to the "dev" channel.'); + stdio.printStatus( + 'Publishing Flutter $version ($hash) to the "dev" channel.'); } else { - 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') { - print('The dev roll has been aborted.'); + stdio.printStatus('Your tree is ready to publish Flutter $version ' + '($hash) to the "dev" channel.'); + stdio.write('Are you? [yes/no] '); + if (stdio.readLineSync() != 'yes') { + stdio.printError('The dev roll has been aborted.'); return false; } } if (!skipTagging) { - git.run('tag $version', 'tag the commit with the version label'); - git.run('push $origin $version', 'publish the version'); + repository.tag(commit, version.toString(), remoteName); } - git.run( - 'push ${force ? "--force " : ""}$origin HEAD:dev', - 'land the new version on the "dev" branch', + + repository.updateChannel( + commit, + remoteName, + 'dev', + force: force, + ); + + stdio.printStatus( + 'Flutter version $version has been rolled to the "dev" channel at $remoteUrl.', ); - print('Flutter version $version has been rolled to the "dev" channel!'); return true; } - -ArgResults parseArguments(ArgParser argParser, List args) { - argParser.addOption( - kIncrement, - help: 'Specifies which part of the x.y.z version number to increment. Required.', - valueHelp: 'level', - allowed: [kX, kY, kZ], - allowedHelp: { - kX: 'Indicates a major development, e.g. typically changed after a big press event.', - kY: 'Indicates a minor development, e.g. typically changed after a beta release.', - kZ: 'Indicates the least notable level of change. You normally want this.', - }, - ); - argParser.addOption( - kCommit, - help: 'Specifies which git commit to roll to the dev branch. Required.', - valueHelp: 'hash', - defaultsTo: null, // This option is required - ); - argParser.addOption( - kOrigin, - help: 'Specifies the name of the upstream repository', - valueHelp: 'repository', - defaultsTo: 'upstream', - ); - argParser.addFlag( - kForce, - abbr: 'f', - help: 'Force push. Necessary when the previous release had cherry-picks.', - negatable: false, - ); - argParser.addFlag( - kJustPrint, - negatable: false, - help: - "Don't actually roll the dev channel; " - 'just print the would-be version and quit.', - ); - argParser.addFlag( - kSkipTagging, - negatable: false, - help: 'Do not create tag and push to remote, only update release branch. ' - 'For recovering when the script fails trying to git push to the release branch.' - ); - argParser.addFlag(kYes, negatable: false, abbr: 'y', help: 'Skip the confirmation prompt.'); - argParser.addFlag(kHelp, negatable: false, help: 'Show this help message.', hide: true); - - return argParser.parse(args); -} - -/// Obtain the version tag of the previous dev release. -String getFullTag(Git git, String remote) { - const String glob = '*.*.*-*.*.pre'; - // describe the latest dev release - final String ref = 'refs/remotes/$remote/dev'; - return git.getOutput( - 'describe --match $glob --exact-match --tags $ref', - 'obtain last released version number', - ); -} - -Match parseFullTag(String version) { - // of the form: x.y.z-m.n.pre - final RegExp versionPattern = RegExp( - r'^(\d+)\.(\d+)\.(\d+)-(\d+)\.(\d+)\.pre$'); - return versionPattern.matchAsPrefix(version); -} - -String getVersionFromParts(List parts) { - // where parts correspond to [x, y, z, m, n] from tag - assert(parts.length == 5); - final StringBuffer buf = StringBuffer() - // take x, y, and z - ..write(parts.take(3).join('.')) - ..write('-') - // skip x, y, and z, take m and n - ..write(parts.skip(3).take(2).join('.')) - ..write('.pre'); - // return a string that looks like: '1.2.3-4.5.pre' - return buf.toString(); -} - -/// A wrapper around git process calls that can be mocked for unit testing. -class Git { - const Git(); - - String getOutput(String command, String explanation) { - final ProcessResult result = _run(command); - if ((result.stderr as String).isEmpty && result.exitCode == 0) - return (result.stdout as String).trim(); - _reportFailureAndExit(result, explanation); - return null; // for the analyzer's sake - } - - void run(String command, String explanation) { - final ProcessResult result = _run(command); - if (result.exitCode != 0) - _reportFailureAndExit(result, explanation); - } - - ProcessResult _run(String command) { - return Process.runSync('git', command.split(' ')); - } - - void _reportFailureAndExit(ProcessResult result, String explanation) { - final StringBuffer message = StringBuffer(); - if (result.exitCode != 0) { - message.writeln('Failed to $explanation. Git exited with error code ${result.exitCode}.'); - } else { - message.writeln('Failed to $explanation.'); - } - if ((result.stdout as String).isNotEmpty) - message.writeln('stdout from git:\n${result.stdout}\n'); - if ((result.stderr as String).isNotEmpty) - message.writeln('stderr from git:\n${result.stderr}\n'); - throw Exception(message); - } -} - -/// Return a copy of the [version] with [level] incremented by one. -String incrementLevel(String version, String level) { - final Match match = parseFullTag(version); - if (match == null) { - String errorMessage; - if (version.isEmpty) { - errorMessage = 'Could not determine the version for this build.'; - } else { - errorMessage = 'Git reported the latest version as "$version", which ' - 'does not fit the expected pattern.'; - } - throw Exception(errorMessage); - } - - final List parts = match.groups([1, 2, 3, 4, 5]).map(int.parse).toList(); - - switch (level) { - case kX: - parts[0] += 1; - parts[1] = 0; - parts[2] = 0; - parts[3] = 0; - parts[4] = 0; - break; - case kY: - parts[1] += 1; - parts[2] = 0; - parts[3] = 0; - parts[4] = 0; - break; - case kZ: - parts[2] = 0; - parts[3] += 1; - parts[4] = 0; - break; - default: - throw Exception('Unknown increment level. The valid values are "$kX", "$kY", and "$kZ".'); - } - return getVersionFromParts(parts); -} diff --git a/dev/tools/lib/stdio.dart b/dev/tools/lib/stdio.dart new file mode 100644 index 0000000000..53231de3bd --- /dev/null +++ b/dev/tools/lib/stdio.dart @@ -0,0 +1,62 @@ +// 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:io'; + +import 'package:meta/meta.dart'; + +abstract class Stdio { + /// Error/warning messages printed to STDERR. + void printError(String message); + + /// Ordinary STDOUT messages. + void printStatus(String message); + + /// Debug messages that are only printed in verbose mode. + void printTrace(String message); + + /// Write string to STDOUT without trailing newline. + void write(String message); + + /// Read a line of text from STDIN. + String readLineSync(); +} + +/// A logger that will print out trace messages. +class VerboseStdio extends Stdio { + VerboseStdio({ + @required this.stdout, + @required this.stderr, + @required this.stdin, + }) : assert(stdout != null), assert(stderr != null), assert(stdin != null); + + final Stdout stdout; + final Stdout stderr; + final Stdin stdin; + + @override + void printError(String message) { + stderr.writeln(message); + } + + @override + void printStatus(String message) { + stdout.writeln(message); + } + + @override + void printTrace(String message) { + stdout.writeln(message); + } + + @override + void write(String message) { + stdout.write(message); + } + + @override + String readLineSync() { + return stdin.readLineSync(); + } +} diff --git a/dev/tools/lib/version.dart b/dev/tools/lib/version.dart new file mode 100644 index 0000000000..cd4452480d --- /dev/null +++ b/dev/tools/lib/version.dart @@ -0,0 +1,211 @@ +// 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 'package:meta/meta.dart'; + +/// Possible string formats that `flutter --version` can return. +enum VersionType { + /// A stable flutter release. + /// + /// Example: '1.2.3' + stable, + /// A pre-stable flutter release. + /// + /// Example: '1.2.3-4.5.pre' + development, + /// A master channel flutter version. + /// + /// Example: '1.2.3-4.0.pre.10' + /// + /// The last number is the number of commits past the last tagged version. + latest, +} + +final Map versionPatterns = { + VersionType.stable: RegExp(r'^(\d+)\.(\d+)\.(\d+)$'), + VersionType.development: RegExp(r'^(\d+)\.(\d+)\.(\d+)-(\d+)\.(\d+)\.pre$'), + VersionType.latest: RegExp(r'^(\d+)\.(\d+)\.(\d+)-(\d+)\.(\d+)\.pre\.(\d+)$'), +}; + +class Version { + Version({ + @required this.x, + @required this.y, + @required this.z, + this.m, + this.n, + this.commits, + @required this.type, + }) { + switch (type) { + case VersionType.stable: + assert(m == null); + assert(n == null); + assert(commits == null); + break; + case VersionType.development: + assert(m != null); + assert(n != null); + assert(commits == null); + break; + case VersionType.latest: + assert(m != null); + assert(n != null); + assert(commits != null); + break; + } + } + + /// Create a new [Version] from a version string. + /// + /// It is expected that [versionString] will be generated by + /// `flutter --version` and match one of `stablePattern`, `developmentPattern` + /// and `latestPattern`. + factory Version.fromString(String versionString) { + assert(versionString != null); + + versionString = versionString.trim(); + // stable tag + Match match = versionPatterns[VersionType.stable].firstMatch(versionString); + if (match != null) { + // parse stable + final List parts = + match.groups([1, 2, 3]).map(int.parse).toList(); + return Version( + x: parts[0], + y: parts[1], + z: parts[2], + type: VersionType.stable, + ); + } + // development tag + match = versionPatterns[VersionType.development].firstMatch(versionString); + if (match != null) { + // parse development + final List parts = + match.groups([1, 2, 3, 4, 5]).map(int.parse).toList(); + return Version( + x: parts[0], + y: parts[1], + z: parts[2], + m: parts[3], + n: parts[4], + type: VersionType.development, + ); + } + // latest tag + match = versionPatterns[VersionType.latest].firstMatch(versionString); + if (match != null) { + // parse latest + final List parts = + match.groups([1, 2, 3, 4, 5, 6]).map(int.parse).toList(); + return Version( + x: parts[0], + y: parts[1], + z: parts[2], + m: parts[3], + n: parts[4], + commits: parts[5], + type: VersionType.latest, + ); + } + throw Exception('${versionString.trim()} cannot be parsed'); + } + + // Returns a new version with the given [increment] part incremented. + // NOTE new version must be of same type as previousVersion. + factory Version.increment( + Version previousVersion, + String increment, { + VersionType nextVersionType, + }) { + final int nextX = previousVersion.x; + int nextY = previousVersion.y; + int nextZ = previousVersion.z; + int nextM = previousVersion.m; + int nextN = previousVersion.n; + if (nextVersionType == null) { + if (previousVersion.type == VersionType.latest) { + nextVersionType = VersionType.development; + } else { + nextVersionType = previousVersion.type; + } + } + + switch (increment) { + case 'x': + // This was probably a mistake. + throw Exception('Incrementing x is not supported by this tool.'); + break; + case 'y': + // Dev release following a beta release. + nextY += 1; + nextZ = 0; + if (previousVersion.type != VersionType.stable) { + nextM = 0; + nextN = 0; + } + break; + case 'z': + // Hotfix to stable release. + assert(previousVersion.type == VersionType.stable); + nextZ += 1; + break; + case 'm': + // Regular dev release. + assert(previousVersion.type == VersionType.development); + assert(nextM != null); + nextM += 1; + nextN = 0; + break; + case 'n': + // Hotfix to internal roll. + nextN += 1; + break; + default: + throw Exception('Unknown increment level $increment.'); + } + return Version( + x: nextX, + y: nextY, + z: nextZ, + m: nextM, + n: nextN, + type: nextVersionType, + ); + } + + /// Major version. + final int x; + + /// Zero-indexed count of beta releases after a major release. + final int y; + + /// Number of hotfix releases after a stable release. + final int z; + + /// Zero-indexed count of dev releases after a beta release. + final int m; + + /// Number of hotfixes required to make a dev release. + final int n; + + /// Number of commits past last tagged dev release. + final int commits; + + final VersionType type; + + @override + String toString() { + switch (type) { + case VersionType.stable: + return '$x.$y.$z'; + case VersionType.development: + return '$x.$y.$z-$m.$n.pre'; + case VersionType.latest: + return '$x.$y.$z-$m.$n.pre.$commits'; + } + return null; // For analyzer + } +} diff --git a/dev/tools/pubspec.yaml b/dev/tools/pubspec.yaml index 012be369e5..d1df30ab6e 100644 --- a/dev/tools/pubspec.yaml +++ b/dev/tools/pubspec.yaml @@ -8,6 +8,8 @@ environment: dependencies: archive: 2.0.13 args: 1.6.0 + flutter_tools: + path: '../../packages/flutter_tools' http: 0.12.2 intl: 0.16.1 meta: 1.3.0-nullsafety.6 diff --git a/dev/tools/test/common.dart b/dev/tools/test/common.dart index 6ca543fdc0..d2e43121d9 100644 --- a/dev/tools/test/common.dart +++ b/dev/tools/test/common.dart @@ -7,6 +7,10 @@ import 'dart:io'; import 'package:test/test.dart' hide TypeMatcher, isInstanceOf; import 'package:test/test.dart' as test_package show TypeMatcher; +import 'package:dev_tools/stdio.dart'; + +import 'package:args/args.dart'; + export 'package:test/test.dart' hide TypeMatcher, isInstanceOf; // Defines a 'package:test' shim. @@ -25,3 +29,111 @@ void tryToDelete(Directory directory) { print('Failed to delete ${directory.path}: $error'); } } + +Matcher throwsExceptionWith(String messageSubString) { + return throwsA( + isA().having( + (Exception e) => e.toString(), + 'description', + contains(messageSubString), + ), + ); +} + +class TestStdio implements Stdio { + TestStdio({ + this.verbose = false, + List stdin, + }) { + _stdin = stdin ?? []; + } + + final StringBuffer _error = StringBuffer(); + String get error => _error.toString(); + + final StringBuffer _stdout = StringBuffer(); + String get stdout => _stdout.toString(); + final bool verbose; + List _stdin; + + @override + void printError(String message) { + _error.writeln(message); + } + + @override + void printStatus(String message) { + _stdout.writeln(message); + } + + @override + void printTrace(String message) { + if (verbose) { + _stdout.writeln(message); + } + } + + @override + void write(String message) { + _stdout.write(message); + } + + @override + String readLineSync() { + if (_stdin.isEmpty) { + throw Exception('Unexpected call to readLineSync!'); + } + return _stdin.removeAt(0); + } +} + +class FakeArgResults implements ArgResults { + FakeArgResults({ + String level, + String commit, + String remote, + bool justPrint = false, + bool autoApprove = true, // so we don't have to mock stdin + bool help = false, + bool force = false, + bool skipTagging = false, + }) : _parsedArgs = { + 'increment': level, + 'commit': commit, + 'remote': remote, + 'just-print': justPrint, + 'yes': autoApprove, + 'help': help, + 'force': force, + 'skip-tagging': skipTagging, + }; + + @override + String name; + + @override + ArgResults command; + + @override + final List rest = []; + + @override + List arguments; + + final Map _parsedArgs; + + @override + Iterable get options { + return null; + } + + @override + dynamic operator [](String name) { + return _parsedArgs[name]; + } + + @override + bool wasParsed(String name) { + return null; + } +} diff --git a/dev/tools/test/roll_dev_integration_test.dart b/dev/tools/test/roll_dev_integration_test.dart new file mode 100644 index 0000000000..d120a49d52 --- /dev/null +++ b/dev/tools/test/roll_dev_integration_test.dart @@ -0,0 +1,132 @@ +// 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 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:platform/platform.dart'; +import 'package:process/process.dart'; + +import 'package:dev_tools/roll_dev.dart' show rollDev; +import 'package:dev_tools/repository.dart'; +import 'package:dev_tools/version.dart'; + +import './common.dart'; + +void main() { + group('roll-dev', () { + TestStdio stdio; + Platform platform; + ProcessManager processManager; + FileSystem fileSystem; + const String usageString = 'Usage: flutter conductor.'; + + Checkouts checkouts; + Repository frameworkUpstream; + Repository framework; + + setUp(() { + platform = const LocalPlatform(); + fileSystem = const LocalFileSystem(); + processManager = const LocalProcessManager(); + stdio = TestStdio(verbose: true); + checkouts = Checkouts( + fileSystem: fileSystem, + platform: platform, + processManager: processManager, + ); + + frameworkUpstream = checkouts.addRepo( + repoType: RepositoryType.framework, + name: 'framework-upstream', + stdio: stdio, + platform: platform, + localUpstream: true, + fileSystem: fileSystem, + useExistingCheckout: false, + ); + + // This repository has [frameworkUpstream] set as its push/pull remote. + framework = frameworkUpstream.cloneRepository('test-framework'); + }); + + test('increment m', () { + final Version initialVersion = framework.flutterVersion(); + + final String latestCommit = framework.authorEmptyCommit(); + + final FakeArgResults fakeArgResults = FakeArgResults( + level: 'm', + commit: latestCommit, + remote: 'origin', + ); + + expect( + rollDev( + usage: usageString, + argResults: fakeArgResults, + stdio: stdio, + fileSystem: fileSystem, + platform: platform, + repository: framework, + ), + true, + ); + expect( + stdio.stdout, + contains(RegExp(r'Publishing Flutter \d+\.\d+\.\d+-\d+\.\d+\.pre \(')), + ); + + final Version finalVersion = framework.flutterVersion(); + expect( + initialVersion.toString() != finalVersion.toString(), + true, + reason: 'initialVersion = $initialVersion; finalVersion = $finalVersion', + ); + expect(finalVersion.n, 0); + expect(finalVersion.commits, null); + }); + + test('increment y', () { + final Version initialVersion = framework.flutterVersion(); + + final String latestCommit = framework.authorEmptyCommit(); + + final FakeArgResults fakeArgResults = FakeArgResults( + level: 'y', + commit: latestCommit, + remote: 'origin', + ); + + expect( + rollDev( + usage: usageString, + argResults: fakeArgResults, + stdio: stdio, + fileSystem: fileSystem, + platform: platform, + repository: framework, + ), + true, + ); + expect( + stdio.stdout, + contains(RegExp(r'Publishing Flutter \d+\.\d+\.\d+-\d+\.\d+\.pre \(')), + ); + + final Version finalVersion = framework.flutterVersion(); + expect( + initialVersion.toString() != finalVersion.toString(), + true, + reason: 'initialVersion = $initialVersion; finalVersion = $finalVersion', + ); + expect(finalVersion.y, initialVersion.y + 1); + expect(finalVersion.z, 0); + expect(finalVersion.m, 0); + expect(finalVersion.n, 0); + expect(finalVersion.commits, null); + }); + }, onPlatform: { + 'windows': const Skip('Flutter Conductor only supported on macos/linux'), + }); +} diff --git a/dev/tools/test/roll_dev_test.dart b/dev/tools/test/roll_dev_test.dart index 7d13648b89..468ded6874 100644 --- a/dev/tools/test/roll_dev_test.dart +++ b/dev/tools/test/roll_dev_test.dart @@ -2,41 +2,48 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:args/args.dart'; -import 'package:dev_tools/roll_dev.dart'; -import 'package:mockito/mockito.dart'; +import 'package:file/memory.dart'; +import 'package:platform/platform.dart'; +import 'package:dev_tools/roll_dev.dart'; +import 'package:dev_tools/globals.dart'; +import 'package:dev_tools/repository.dart'; + +import '../../../packages/flutter_tools/test/src/fake_process_manager.dart'; import './common.dart'; void main() { - group('run()', () { + group('rollDev()', () { const String usage = 'usage info...'; - const String level = 'z'; + const String level = 'm'; const String commit = 'abcde012345'; - const String origin = 'upstream'; + const String remote = 'origin'; const String lastVersion = '1.2.0-0.0.pre'; const String nextVersion = '1.2.0-1.0.pre'; + const String checkoutsParentDirectory = '/path/to/directory/'; FakeArgResults fakeArgResults; - MockGit mockGit; + MemoryFileSystem fileSystem; + TestStdio stdio; + Repository repo; + Checkouts checkouts; + FakePlatform platform; + FakeProcessManager processManager; setUp(() { - mockGit = MockGit(); - }); - - test('returns false if help requested', () { - fakeArgResults = FakeArgResults( - level: level, - commit: commit, - origin: origin, - help: true, + stdio = TestStdio(); + fileSystem = MemoryFileSystem.test(); + platform = FakePlatform(); + processManager = FakeProcessManager.list([]); + checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory), + platform: platform, + processManager: processManager, ); - expect( - run( - usage: usage, - argResults: fakeArgResults, - git: mockGit, - ), - false, + repo = checkouts.addRepo( + platform: platform, + repoType: RepositoryType.framework, + stdio: stdio, ); }); @@ -44,13 +51,16 @@ void main() { fakeArgResults = FakeArgResults( level: null, commit: commit, - origin: origin, + remote: remote, ); expect( - run( - usage: usage, + rollDev( argResults: fakeArgResults, - git: mockGit, + fileSystem: fileSystem, + platform: platform, + repository: repo, + stdio: stdio, + usage: usage, ), false, ); @@ -60,470 +70,631 @@ void main() { fakeArgResults = FakeArgResults( level: level, commit: null, - origin: origin, + remote: remote, ); expect( - run( - usage: usage, + rollDev( argResults: fakeArgResults, - git: mockGit, + fileSystem: fileSystem, + platform: platform, + repository: repo, + stdio: stdio, + usage: usage, ), false, ); }); - test('throws exception if upstream remote wrong', () { - const String remote = 'wrong-remote'; - when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(remote); - fakeArgResults = FakeArgResults( - level: level, - commit: commit, - origin: origin, - ); - const String errorMessage = 'The remote named $origin is set to $remote, when $kUpstreamRemote was expected.'; - expect( - () => run( - usage: usage, - argResults: fakeArgResults, - git: mockGit, - ), - throwsExceptionWith(errorMessage), - ); - }); - test('throws exception if git checkout not clean', () { - when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(kUpstreamRemote); - when(mockGit.getOutput('status --porcelain', any)).thenReturn( - ' M dev/tools/test/roll_dev_test.dart', - ); + processManager.addCommands([ + const FakeCommand(command: [ + 'git', + 'clone', + '--', + kUpstreamRemote, + '${checkoutsParentDirectory}checkouts/framework', + ]), + const FakeCommand(command: [ + 'git', + 'remote', + 'get-url', + remote, + ], stdout: kUpstreamRemote), + const FakeCommand(command: [ + 'git', + 'status', + '--porcelain', + ], stdout: ' M dev/conductor/bin/conductor.dart'), + ]); fakeArgResults = FakeArgResults( level: level, commit: commit, - origin: origin, + remote: remote, ); Exception exception; try { - run( - usage: usage, + rollDev( argResults: fakeArgResults, - git: mockGit, + fileSystem: fileSystem, + platform: platform, + repository: repo, + stdio: stdio, + usage: usage, ); } on Exception catch (e) { exception = e; } const String pattern = r'Your git repository is not clean. Try running ' - '"git clean -fd". Warning, this will delete files! Run with -n to find ' - 'out which ones.'; + '"git clean -fd". Warning, this will delete files! Run with -n to find ' + 'out which ones.'; expect(exception?.toString(), contains(pattern)); }); test('does not reset or tag if --just-print is specified', () { - when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(kUpstreamRemote); - when(mockGit.getOutput('status --porcelain', any)).thenReturn(''); - when(mockGit.getOutput( - 'describe --match *.*.*-*.*.pre --exact-match --tags refs/remotes/$origin/dev', - any, - )).thenReturn(lastVersion); - when(mockGit.getOutput( - 'rev-parse $lastVersion', - any, - )).thenReturn('zxy321'); + processManager.addCommands([ + const FakeCommand(command: [ + 'git', + 'clone', + '--', + kUpstreamRemote, + '${checkoutsParentDirectory}checkouts/framework', + ]), + const FakeCommand(command: [ + 'git', + 'remote', + 'get-url', + remote, + ], stdout: kUpstreamRemote), + const FakeCommand(command: [ + 'git', + 'status', + '--porcelain', + ]), + const FakeCommand(command: [ + 'git', + 'fetch', + remote, + '--tags', + ]), + const FakeCommand(command: [ + 'git', + 'rev-parse', + commit, + ], stdout: commit), + const FakeCommand(command: [ + 'git', + 'describe', + '--match', + '*.*.*-*.*.pre', + '--exact-match', + '--tags', + 'refs/remotes/$remote/dev', + ], stdout: lastVersion), + const FakeCommand(command: [ + 'git', + 'rev-parse', + lastVersion, + ], stdout: 'zxy321'), + ]); + fakeArgResults = FakeArgResults( level: level, commit: commit, - origin: origin, + remote: remote, justPrint: true, ); - expect(run( - usage: usage, - argResults: fakeArgResults, - git: mockGit, - ), false); - verify(mockGit.run('fetch $origin', any)); - verifyNever(mockGit.run('reset $commit --hard', any)); - verifyNever(mockGit.getOutput('rev-parse HEAD', any)); + expect( + rollDev( + usage: usage, + argResults: fakeArgResults, + fileSystem: fileSystem, + platform: platform, + repository: repo, + stdio: stdio, + ), + false, + ); + expect(stdio.stdout.contains(nextVersion), true); }); - test('exits with exception if --skip-tagging is provided but commit isn\'t ' - 'already tagged', () { - when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(kUpstreamRemote); - when(mockGit.getOutput('status --porcelain', any)).thenReturn(''); - when(mockGit.getOutput( - 'describe --match *.*.*-*.*.pre --exact-match --tags refs/remotes/$origin/dev', - any, - )).thenReturn(lastVersion); - when(mockGit.getOutput( - 'rev-parse $lastVersion', - any, - )).thenReturn('zxy321'); - const String exceptionMessage = 'Failed to verify $commit is already ' - 'tagged. You can only use the flag `$kSkipTagging` if the commit has ' - 'already been tagged.'; - when(mockGit.run( - 'describe --exact-match --tags $commit', - any, - )).thenThrow(Exception(exceptionMessage)); + test( + 'exits with exception if --skip-tagging is provided but commit isn\'t ' + 'already tagged', () { + processManager.addCommands([ + const FakeCommand(command: [ + 'git', + 'clone', + '--', + kUpstreamRemote, + '${checkoutsParentDirectory}checkouts/framework', + ]), + const FakeCommand(command: [ + 'git', + 'remote', + 'get-url', + remote, + ], stdout: kUpstreamRemote), + const FakeCommand(command: [ + 'git', + 'status', + '--porcelain', + ]), + const FakeCommand(command: [ + 'git', + 'fetch', + remote, + '--tags', + ]), + const FakeCommand(command: [ + 'git', + 'rev-parse', + commit, + ], stdout: commit), + const FakeCommand(command: [ + 'git', + 'describe', + '--match', + '*.*.*-*.*.pre', + '--exact-match', + '--tags', + 'refs/remotes/$remote/dev', + ], stdout: lastVersion), + const FakeCommand(command: [ + 'git', + 'rev-parse', + lastVersion, + ], stdout: 'zxy321'), + const FakeCommand(command: [ + 'git', + 'describe', + '--exact-match', + '--tags', + commit, + ], exitCode: 1), + ]); + + const String exceptionMessage = + 'The $kSkipTagging flag is only supported ' + 'for tagged commits.'; fakeArgResults = FakeArgResults( level: level, commit: commit, - origin: origin, + remote: remote, skipTagging: true, ); expect( - () => run( + () => rollDev( usage: usage, argResults: fakeArgResults, - git: mockGit, + fileSystem: fileSystem, + platform: platform, + repository: repo, + stdio: stdio, ), throwsExceptionWith(exceptionMessage), ); - verify(mockGit.run('fetch $origin', any)); - verifyNever(mockGit.run('reset $commit --hard', any)); - verifyNever(mockGit.getOutput('rev-parse HEAD', any)); }); test('throws exception if desired commit is already tip of dev branch', () { - when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(kUpstreamRemote); - when(mockGit.getOutput('status --porcelain', any)).thenReturn(''); - when(mockGit.getOutput( - 'describe --match *.*.*-*.*.pre --exact-match --tags refs/remotes/$origin/dev', - any, - )).thenReturn(lastVersion); - when(mockGit.getOutput( - 'rev-parse $lastVersion', - any, - )).thenReturn(commit); + processManager.addCommands([ + const FakeCommand(command: [ + 'git', + 'clone', + '--', + kUpstreamRemote, + '${checkoutsParentDirectory}checkouts/framework', + ]), + const FakeCommand(command: [ + 'git', + 'remote', + 'get-url', + remote, + ], stdout: kUpstreamRemote), + const FakeCommand(command: [ + 'git', + 'status', + '--porcelain', + ]), + const FakeCommand(command: [ + 'git', + 'fetch', + remote, + '--tags', + ]), + const FakeCommand(command: [ + 'git', + 'rev-parse', + commit, + ], stdout: commit), + const FakeCommand(command: [ + 'git', + 'describe', + '--match', + '*.*.*-*.*.pre', + '--exact-match', + '--tags', + 'refs/remotes/$remote/dev', + ], stdout: lastVersion), + // [commit] is already [lastVersion] + const FakeCommand(command: [ + 'git', + 'rev-parse', + lastVersion, + ], stdout: commit), + ]); fakeArgResults = FakeArgResults( level: level, commit: commit, - origin: origin, + remote: remote, justPrint: true, ); expect( - () => run( + () => rollDev( usage: usage, argResults: fakeArgResults, - git: mockGit, + fileSystem: fileSystem, + platform: platform, + repository: repo, + stdio: stdio, + ), + throwsExceptionWith( + 'Commit $commit is already on the dev branch as $lastVersion', ), - throwsExceptionWith('is already on the dev branch as'), ); - verify(mockGit.run('fetch $origin', any)); - verifyNever(mockGit.run('reset $commit --hard', any)); - verifyNever(mockGit.getOutput('rev-parse HEAD', any)); }); - test('does not tag if last release is not direct ancestor of desired ' + test( + 'does not tag if last release is not direct ancestor of desired ' 'commit and --force not supplied', () { - when(mockGit.getOutput('remote get-url $origin', any)) - .thenReturn(kUpstreamRemote); - when(mockGit.getOutput('status --porcelain', any)) - .thenReturn(''); - when(mockGit.getOutput( - 'describe --match *.*.*-*.*.pre --exact-match --tags refs/remotes/$origin/dev', - any, - )).thenReturn(lastVersion); - when(mockGit.getOutput( - 'rev-parse $lastVersion', - any, - )).thenReturn('zxy321'); - when(mockGit.run('merge-base --is-ancestor $lastVersion $commit', any)) - .thenThrow(Exception( - 'Failed to verify $lastVersion is a direct ancestor of $commit. The ' - 'flag `--force` is required to force push a new release past a ' - 'cherry-pick', - )); + processManager.addCommands([ + const FakeCommand(command: [ + 'git', + 'clone', + '--', + kUpstreamRemote, + '${checkoutsParentDirectory}checkouts/framework', + ]), + const FakeCommand(command: [ + 'git', + 'remote', + 'get-url', + remote, + ], stdout: kUpstreamRemote), + const FakeCommand(command: [ + 'git', + 'status', + '--porcelain', + ]), + const FakeCommand(command: [ + 'git', + 'fetch', + remote, + '--tags', + ]), + const FakeCommand(command: [ + 'git', + 'rev-parse', + commit, + ], stdout: commit), + const FakeCommand(command: [ + 'git', + 'describe', + '--match', + '*.*.*-*.*.pre', + '--exact-match', + '--tags', + 'refs/remotes/$remote/dev', + ], stdout: lastVersion), + const FakeCommand(command: [ + 'git', + 'rev-parse', + lastVersion, + ], stdout: 'zxy321'), + const FakeCommand(command: [ + 'git', + 'merge-base', + '--is-ancestor', + lastVersion, + commit, + ], exitCode: 1), + ]); + fakeArgResults = FakeArgResults( level: level, commit: commit, - origin: origin, + remote: remote, ); - const String errorMessage = 'Failed to verify $lastVersion is a direct ' - 'ancestor of $commit. The flag `--force` is required to force push a ' - 'new release past a cherry-pick'; + const String errorMessage = 'The previous dev tag $lastVersion is not a ' + 'direct ancestor of $commit.'; expect( - () => run( + () => rollDev( argResults: fakeArgResults, - git: mockGit, + fileSystem: fileSystem, + platform: platform, + repository: repo, + stdio: stdio, usage: usage, ), throwsExceptionWith(errorMessage), ); - - verify(mockGit.run('fetch $origin', any)); - verifyNever(mockGit.run('reset $commit --hard', any)); - verifyNever(mockGit.run('push $origin HEAD:dev', any)); - verifyNever(mockGit.run('tag $nextVersion', any)); }); test('does not tag but updates branch if --skip-tagging provided', () { - when(mockGit.getOutput('remote get-url $origin', any)) - .thenReturn(kUpstreamRemote); - when(mockGit.getOutput('status --porcelain', any)) - .thenReturn(''); - when(mockGit.getOutput( - 'describe --match *.*.*-*.*.pre --exact-match --tags refs/remotes/$origin/dev', - any, - )).thenReturn(lastVersion); - when(mockGit.getOutput( - 'rev-parse $lastVersion', - any, - )).thenReturn('zxy321'); - when(mockGit.getOutput('rev-parse HEAD', any)).thenReturn(commit); + processManager.addCommands([ + const FakeCommand(command: [ + 'git', + 'clone', + '--', + kUpstreamRemote, + '${checkoutsParentDirectory}checkouts/framework', + ]), + const FakeCommand(command: [ + 'git', + 'remote', + 'get-url', + remote, + ], stdout: kUpstreamRemote), + const FakeCommand(command: [ + 'git', + 'status', + '--porcelain', + ]), + const FakeCommand(command: [ + 'git', + 'fetch', + remote, + '--tags', + ]), + const FakeCommand(command: [ + 'git', + 'rev-parse', + commit, + ], stdout: commit), + const FakeCommand(command: [ + 'git', + 'describe', + '--match', + '*.*.*-*.*.pre', + '--exact-match', + '--tags', + 'refs/remotes/$remote/dev', + ], stdout: lastVersion), + const FakeCommand(command: [ + 'git', + 'rev-parse', + lastVersion, + ], stdout: 'zxy321'), + const FakeCommand(command: [ + 'git', + 'describe', + '--exact-match', + '--tags', + commit, + ]), + const FakeCommand(command: [ + 'git', + 'merge-base', + '--is-ancestor', + lastVersion, + commit, + ]), + const FakeCommand(command: [ + 'git', + 'rev-parse', + commit, + ], stdout: commit), + const FakeCommand(command: [ + 'git', + 'push', + remote, + '$commit:dev', + ]), + ]); fakeArgResults = FakeArgResults( level: level, commit: commit, - origin: origin, + remote: remote, skipTagging: true, ); - expect(run( - usage: usage, - argResults: fakeArgResults, - git: mockGit, - ), true); - verify(mockGit.run('fetch $origin', any)); - verify(mockGit.run('reset $commit --hard', any)); - verifyNever(mockGit.run('tag $nextVersion', any)); - verifyNever(mockGit.run('push $origin $nextVersion', any)); - verify(mockGit.run('push $origin HEAD:dev', any)); + expect( + rollDev( + usage: usage, + argResults: fakeArgResults, + fileSystem: fileSystem, + platform: platform, + repository: repo, + stdio: stdio, + ), + true, + ); }); test('successfully tags and publishes release', () { - when(mockGit.getOutput('remote get-url $origin', any)) - .thenReturn(kUpstreamRemote); - when(mockGit.getOutput('status --porcelain', any)) - .thenReturn(''); - when(mockGit.getOutput( - 'describe --match *.*.*-*.*.pre --exact-match --tags refs/remotes/$origin/dev', - any, - )).thenReturn('1.2.0-0.0.pre'); - when(mockGit.getOutput( - 'rev-parse $lastVersion', - any, - )).thenReturn('zxy321'); - when(mockGit.getOutput('rev-parse HEAD', any)).thenReturn(commit); + processManager.addCommands([ + const FakeCommand(command: [ + 'git', + 'clone', + '--', + kUpstreamRemote, + '${checkoutsParentDirectory}checkouts/framework', + ]), + const FakeCommand(command: [ + 'git', + 'remote', + 'get-url', + remote, + ], stdout: kUpstreamRemote), + const FakeCommand(command: [ + 'git', + 'status', + '--porcelain', + ]), + const FakeCommand(command: [ + 'git', + 'fetch', + remote, + '--tags', + ]), + const FakeCommand(command: [ + 'git', + 'rev-parse', + commit, + ], stdout: commit), + const FakeCommand(command: [ + 'git', + 'describe', + '--match', + '*.*.*-*.*.pre', + '--exact-match', + '--tags', + 'refs/remotes/$remote/dev', + ], stdout: lastVersion), + const FakeCommand(command: [ + 'git', + 'rev-parse', + lastVersion, + ], stdout: 'zxy321'), + const FakeCommand(command: [ + 'git', + 'merge-base', + '--is-ancestor', + lastVersion, + commit, + ]), + const FakeCommand(command: [ + 'git', + 'rev-parse', + commit, + ], stdout: commit), + const FakeCommand(command: [ + 'git', + 'tag', + nextVersion, + commit, + ]), + const FakeCommand(command: [ + 'git', + 'push', + remote, + nextVersion, + ]), + const FakeCommand(command: [ + 'git', + 'push', + remote, + '$commit:dev', + ]), + ]); fakeArgResults = FakeArgResults( level: level, commit: commit, - origin: origin, + remote: remote, + ); + expect( + rollDev( + usage: usage, + argResults: fakeArgResults, + fileSystem: fileSystem, + platform: platform, + repository: repo, + stdio: stdio, + ), + true, ); - expect(run( - usage: usage, - argResults: fakeArgResults, - git: mockGit, - ), true); - verify(mockGit.run('fetch $origin', any)); - verify(mockGit.run('reset $commit --hard', any)); - verify(mockGit.run('tag $nextVersion', any)); - verify(mockGit.run('push $origin $nextVersion', any)); - verify(mockGit.run('push $origin HEAD:dev', any)); }); test('successfully publishes release with --force', () { - when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(kUpstreamRemote); - when(mockGit.getOutput('status --porcelain', any)).thenReturn(''); - when(mockGit.getOutput( - 'describe --match *.*.*-*.*.pre --exact-match --tags refs/remotes/$origin/dev', - any, - )).thenReturn(lastVersion); - when(mockGit.getOutput( - 'rev-parse $lastVersion', - any, - )).thenReturn('zxy321'); - when(mockGit.getOutput('rev-parse HEAD', any)).thenReturn(commit); + processManager.addCommands([ + const FakeCommand(command: [ + 'git', + 'clone', + '--', + kUpstreamRemote, + '${checkoutsParentDirectory}checkouts/framework', + ]), + const FakeCommand(command: [ + 'git', + 'remote', + 'get-url', + remote, + ], stdout: kUpstreamRemote), + const FakeCommand(command: [ + 'git', + 'status', + '--porcelain', + ]), + const FakeCommand(command: [ + 'git', + 'fetch', + remote, + '--tags', + ]), + const FakeCommand(command: [ + 'git', + 'rev-parse', + commit, + ], stdout: commit), + const FakeCommand(command: [ + 'git', + 'describe', + '--match', + '*.*.*-*.*.pre', + '--exact-match', + '--tags', + 'refs/remotes/$remote/dev', + ], stdout: lastVersion), + const FakeCommand(command: [ + 'git', + 'rev-parse', + lastVersion, + ], stdout: 'zxy321'), + const FakeCommand(command: [ + 'git', + 'rev-parse', + commit, + ], stdout: commit), + const FakeCommand(command: [ + 'git', + 'tag', + nextVersion, + commit, + ]), + const FakeCommand(command: [ + 'git', + 'push', + remote, + nextVersion, + ]), + const FakeCommand(command: [ + 'git', + 'push', + '--force', + remote, + '$commit:dev', + ]), + ]); + fakeArgResults = FakeArgResults( level: level, commit: commit, - origin: origin, + remote: remote, force: true, ); - expect(run( - usage: usage, - argResults: fakeArgResults, - git: mockGit, - ), true); - verify(mockGit.run('fetch $origin', any)); - verify(mockGit.run('reset $commit --hard', any)); - verify(mockGit.run('tag $nextVersion', any)); - verify(mockGit.run('push --force $origin HEAD:dev', any)); - }); - }); - - group('parseFullTag', () { - test('returns match on valid version input', () { - final List validTags = [ - '1.2.3-1.2.pre', - '10.2.30-12.22.pre', - '1.18.0-0.0.pre', - '2.0.0-1.99.pre', - '12.34.56-78.90.pre', - '0.0.1-0.0.pre', - '958.80.144-6.224.pre', - ]; - for (final String validTag in validTags) { - final Match match = parseFullTag(validTag); - expect(match, isNotNull, reason: 'Expected $validTag to be parsed'); - } - }); - - test('returns null on invalid version input', () { - final List invalidTags = [ - '1.2.3-1.2.pre-3-gabc123', - '1.2.3-1.2.3.pre', - '1.2.3.1.2.pre', - '1.2.3-dev.1.2', - '1.2.3-1.2-3', - 'v1.2.3', - '2.0.0', - 'v1.2.3-1.2.pre', - '1.2.3-1.2.pre_', - ]; - for (final String invalidTag in invalidTags) { - final Match match = parseFullTag(invalidTag); - expect(match, null, reason: 'Expected $invalidTag to not be parsed'); - } - }); - }); - - group('getVersionFromParts', () { - test('returns correct string from valid parts', () { - List parts = [1, 2, 3, 4, 5]; - expect(getVersionFromParts(parts), '1.2.3-4.5.pre'); - - parts = [11, 2, 33, 1, 0]; - expect(getVersionFromParts(parts), '11.2.33-1.0.pre'); - }); - }); - - group('incrementLevel()', () { - const String hash = 'abc123'; - - test('throws exception if hash is not valid release candidate', () { - String level = 'z'; - - String version = '1.0.0-0.0.pre-1-g$hash'; expect( - () => incrementLevel(version, level), - throwsExceptionWith('Git reported the latest version as "$version"'), - reason: 'should throw because $version should be an exact tag', - ); - - version = '1.2.3'; - expect( - () => incrementLevel(version, level), - throwsExceptionWith('Git reported the latest version as "$version"'), - reason: 'should throw because $version should be a dev tag, not stable.' - ); - - version = '1.0.0-0.0.pre-1-g$hash'; - level = 'q'; - expect( - () => incrementLevel(version, level), - throwsExceptionWith('Git reported the latest version as "$version"'), - reason: 'should throw because $level is unsupported', + rollDev( + argResults: fakeArgResults, + fileSystem: fileSystem, + platform: platform, + repository: repo, + stdio: stdio, + usage: usage, + ), + true, ); + expect(processManager.hasRemainingExpectations, false); }); - - test('successfully increments x', () { - const String level = 'x'; - - String version = '1.0.0-0.0.pre'; - expect(incrementLevel(version, level), '2.0.0-0.0.pre'); - - version = '10.20.0-40.50.pre'; - expect(incrementLevel(version, level), '11.0.0-0.0.pre'); - - version = '1.18.0-3.0.pre'; - expect(incrementLevel(version, level), '2.0.0-0.0.pre'); - }); - - test('successfully increments y', () { - const String level = 'y'; - - String version = '1.0.0-0.0.pre'; - expect(incrementLevel(version, level), '1.1.0-0.0.pre'); - - version = '10.20.0-40.50.pre'; - expect(incrementLevel(version, level), '10.21.0-0.0.pre'); - - version = '1.18.0-3.0.pre'; - expect(incrementLevel(version, level), '1.19.0-0.0.pre'); - }); - - test('successfully increments z', () { - const String level = 'z'; - - String version = '1.0.0-0.0.pre'; - expect(incrementLevel(version, level), '1.0.0-1.0.pre'); - - version = '10.20.0-40.50.pre'; - expect(incrementLevel(version, level), '10.20.0-41.0.pre'); - - version = '1.18.0-3.0.pre'; - expect(incrementLevel(version, level), '1.18.0-4.0.pre'); - }); + }, onPlatform: { + 'windows': const Skip('Flutter Conductor only supported on macos/linux'), }); } - -Matcher throwsExceptionWith(String messageSubString) { - return throwsA( - isA().having( - (Exception e) => e.toString(), - 'description', - contains(messageSubString), - ), - ); -} - -class FakeArgResults implements ArgResults { - FakeArgResults({ - String level, - String commit, - String origin, - bool justPrint = false, - bool autoApprove = true, // so we don't have to mock stdin - bool help = false, - bool force = false, - bool skipTagging = false, - }) : _parsedArgs = { - 'increment': level, - 'commit': commit, - 'origin': origin, - 'just-print': justPrint, - 'yes': autoApprove, - 'help': help, - 'force': force, - 'skip-tagging': skipTagging, - }; - - @override - String name; - - @override - ArgResults command; - - @override - final List rest = []; - - @override - List arguments; - - final Map _parsedArgs; - - @override - Iterable get options { - return null; - } - - @override - dynamic operator [](String name) { - return _parsedArgs[name]; - } - - @override - bool wasParsed(String name) { - return null; - } -} - -class MockGit extends Mock implements Git {} diff --git a/dev/tools/test/version_test.dart b/dev/tools/test/version_test.dart new file mode 100644 index 0000000000..e51e182ae4 --- /dev/null +++ b/dev/tools/test/version_test.dart @@ -0,0 +1,87 @@ +// 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 'package:dev_tools/version.dart'; + +import './common.dart'; + +void main() { + group('Version.increment()', () { + test('throws exception on nonsensical `level`', () { + final List levels = ['f', '0', 'xyz']; + for (final String level in levels) { + final Version version = Version.fromString('1.0.0-0.0.pre'); + expect( + () => Version.increment(version, level).toString(), + throwsExceptionWith('Unknown increment level $level.'), + ); + } + }); + + test('does not support incrementing x', () { + const String level = 'x'; + + final Version version = Version.fromString('1.0.0-0.0.pre'); + expect( + () => Version.increment(version, level).toString(), + throwsExceptionWith( + 'Incrementing $level is not supported by this tool'), + ); + }); + + test('successfully increments y', () { + const String level = 'y'; + + Version version = Version.fromString('1.0.0-0.0.pre'); + expect(Version.increment(version, level).toString(), '1.1.0-0.0.pre'); + + version = Version.fromString('10.20.0-40.50.pre'); + expect(Version.increment(version, level).toString(), '10.21.0-0.0.pre'); + + version = Version.fromString('1.18.0-3.0.pre'); + expect(Version.increment(version, level).toString(), '1.19.0-0.0.pre'); + }); + + test('successfully increments z', () { + const String level = 'm'; + + Version version = Version.fromString('1.0.0-0.0.pre'); + expect(Version.increment(version, level).toString(), '1.0.0-1.0.pre'); + + version = Version.fromString('10.20.0-40.50.pre'); + expect(Version.increment(version, level).toString(), '10.20.0-41.0.pre'); + + version = Version.fromString('1.18.0-3.0.pre'); + expect(Version.increment(version, level).toString(), '1.18.0-4.0.pre'); + }); + + test('successfully increments m', () { + const String level = 'm'; + + Version version = Version.fromString('1.0.0-0.0.pre'); + expect(Version.increment(version, level).toString(), '1.0.0-1.0.pre'); + + version = Version.fromString('10.20.0-40.50.pre'); + expect(Version.increment(version, level).toString(), '10.20.0-41.0.pre'); + + version = Version.fromString('1.18.0-3.0.pre'); + expect(Version.increment(version, level).toString(), '1.18.0-4.0.pre'); + }); + + test('successfully increments n', () { + const String level = 'n'; + + Version version = Version.fromString('1.0.0-0.0.pre'); + expect(Version.increment(version, level).toString(), '1.0.0-0.1.pre'); + + version = Version.fromString('10.20.0-40.50.pre'); + expect(Version.increment(version, level).toString(), '10.20.0-40.51.pre'); + + version = Version.fromString('1.18.0-3.0.pre'); + expect(Version.increment(version, level).toString(), '1.18.0-3.1.pre'); + }); + }, onPlatform: { + 'windows': const Skip('Flutter Conductor only supported on macos/linux'), + }); +}