diff --git a/packages/flutter_tools/lib/executable.dart b/packages/flutter_tools/lib/executable.dart index 3b167e8ef8..af10ac2924 100644 --- a/packages/flutter_tools/lib/executable.dart +++ b/packages/flutter_tools/lib/executable.dart @@ -25,6 +25,7 @@ import 'src/commands/create.dart'; import 'src/commands/daemon.dart'; import 'src/commands/devices.dart'; import 'src/commands/doctor.dart'; +import 'src/commands/downgrade.dart'; import 'src/commands/drive.dart'; import 'src/commands/emulators.dart'; import 'src/commands/format.dart'; @@ -76,6 +77,7 @@ Future main(List args) async { DaemonCommand(hidden: !verboseHelp), DevicesCommand(), DoctorCommand(verbose: verbose), + DowngradeCommand(), DriveCommand(), EmulatorsCommand(), FormatCommand(), diff --git a/packages/flutter_tools/lib/src/commands/downgrade.dart b/packages/flutter_tools/lib/src/commands/downgrade.dart new file mode 100644 index 0000000000..6be814fe08 --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/downgrade.dart @@ -0,0 +1,191 @@ +// 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:process/process.dart'; + +import '../base/common.dart'; +import '../base/file_system.dart'; +import '../base/io.dart'; +import '../base/logger.dart'; +import '../base/process.dart'; +import '../base/terminal.dart'; +import '../base/time.dart'; +import '../cache.dart'; +import '../globals.dart' as globals; +import '../persistent_tool_state.dart'; +import '../runner/flutter_command.dart'; +import '../version.dart'; + +/// The flutter downgrade command returns the SDK to the last recorded version +/// for a particular branch. +/// +/// For example, suppose a user on the beta channel upgrades from 1.2.3 to 1.4.6. +/// The tool will record that sha "abcdefg" was the last active beta channel in the +/// persistent tool state. If the user is still on the beta channel and runs +/// flutter downgrade, this will take the user back to "abcdefg". They will not be +/// able to downgrade again, since the tool only records one prior version. +/// Additionally, if they had switched channels to stable before trying to downgrade, +/// the command would fail since there was no previously recorded stable version. +class DowngradeCommand extends FlutterCommand { + DowngradeCommand({ + PersistentToolState persistentToolState, + Logger logger, + ProcessManager processManager, + FlutterVersion flutterVersion, + AnsiTerminal terminal, + Stdio stdio, + FileSystem fileSystem, + }) : _terminal = terminal, + _flutterVersion = flutterVersion, + _persistentToolState = persistentToolState, + _processManager = processManager, + _stdio = stdio, + _logger = logger, + _fileSystem = fileSystem { + argParser.addOption( + 'working-directory', + hide: true, + help: 'Override the downgrade working directory for integration testing.' + ); + argParser.addFlag( + 'prompt', + defaultsTo: true, + hide: true, + help: 'Disable the downgrade prompt for integration testing.' + ); + } + + AnsiTerminal _terminal; + FlutterVersion _flutterVersion; + PersistentToolState _persistentToolState; + ProcessUtils _processUtils; + ProcessManager _processManager; + Logger _logger; + Stdio _stdio; + FileSystem _fileSystem; + + @override + String get description => 'Downgrade Flutter to the last active version for the current channel.'; + + @override + String get name => 'downgrade'; + + @override + Future runCommand() async { + // Note: commands do not necessarily have access to the correct zone injected + // values when being created. Fields must be lazily instantiated in runCommand, + // at least until the zone injection is refactored. + _terminal ??= globals.terminal; + _logger ??= globals.logger; + _flutterVersion ??= globals.flutterVersion; + _persistentToolState ??= globals.persistentToolState; + _processManager ??= globals.processManager; + _processUtils ??= ProcessUtils(processManager: _processManager, logger: _logger); + _stdio ??= globals.stdio; + _fileSystem ??= globals.fs; + String workingDirectory = Cache.flutterRoot; + if (argResults.wasParsed('working-directory')) { + workingDirectory = stringArg('working-directory'); + _flutterVersion = FlutterVersion(const SystemClock(), workingDirectory); + } + + final String currentChannel = _flutterVersion.channel; + final Channel channel = getChannelForName(currentChannel); + if (channel == null) { + throwToolExit( + 'Flutter is not currently on a known channel. Use "flutter channel " ' + 'to switch to an official channel.', + ); + } + final String lastFlutterVesion = _persistentToolState.lastActiveVersion(channel); + final String currentFlutterVersion = _flutterVersion.frameworkRevision; + if (lastFlutterVesion == null || currentFlutterVersion == lastFlutterVesion) { + final String trailing = await _createErrorMessage(workingDirectory, channel); + throwToolExit( + 'There is no previously recorded version for channel "$currentChannel".\n' + '$trailing' + ); + } + + // Detect unkown versions. + final RunResult parseResult = await _processUtils.run([ + 'git', 'describe', '--tags', lastFlutterVesion, + ], workingDirectory: workingDirectory); + if (parseResult.exitCode != 0) { + throwToolExit('Failed to parse version for downgrade:\n${parseResult.stderr}'); + } + final String humanReadableVersion = parseResult.stdout; + + // If there is a terminal attached, prompt the user to confirm the downgrade. + if (_stdio.hasTerminal && boolArg('prompt')) { + _terminal.usesTerminalUi = true; + final String result = await _terminal.promptForCharInput( + const ['y', 'n'], + prompt: 'Downgrade flutter to version $humanReadableVersion?', + logger: _logger, + ); + if (result == 'n') { + return FlutterCommandResult.success(); + } + } else { + _logger.printStatus('Downgrading Flutter to version $humanReadableVersion'); + } + + // To downgrade the tool, we perform a git checkout --hard, and then + // switch channels. The version recorded must have existed on that branch + // so this operation is safe. + try { + await _processUtils.run( + ['git', 'reset', '--hard', lastFlutterVesion], + throwOnError: true, + workingDirectory: workingDirectory, + ); + } on ProcessException catch (error) { + throwToolExit( + 'Unable to downgrade Flutter: The tool could not update to the version ' + '$humanReadableVersion. This may be due to git not being installed or an ' + 'internal error. Please ensure that git is installed on your computer and ' + 'retry again.\nError: $error.' + ); + } + try { + await _processUtils.run( + ['git', 'checkout', currentChannel, '--'], + throwOnError: true, + workingDirectory: workingDirectory, + ); + } on ProcessException catch (error) { + throwToolExit( + 'Unable to downgrade Flutter: The tool could not switch to the channel ' + '$currentChannel. This may be due to git not being installed or an ' + 'internal error. Please ensure that git is installed on your computer ' + 'and retry again.\nError: $error.' + ); + } + await FlutterVersion.resetFlutterVersionFreshnessCheck(); + _logger.printStatus('Success'); + return FlutterCommandResult.success(); + } + + // Formats an error message that lists the currently stored versions. + Future _createErrorMessage(String workingDirectory, Channel currentChannel) async { + final StringBuffer buffer = StringBuffer(); + for (final Channel channel in Channel.values) { + if (channel == currentChannel) { + continue; + } + final String sha = _persistentToolState.lastActiveVersion(channel); + if (sha == null) { + continue; + } + final RunResult parseResult = await _processUtils.run([ + 'git', 'describe', '--tags', sha, + ], workingDirectory: workingDirectory); + if (parseResult.exitCode == 0) { + buffer.writeln('Channel "${getNameForChannel(channel)}" was previously on: ${parseResult.stdout}.'); + } + } + return buffer.toString(); + } +} diff --git a/packages/flutter_tools/lib/src/commands/upgrade.dart b/packages/flutter_tools/lib/src/commands/upgrade.dart index 705e4e6674..d423513b34 100644 --- a/packages/flutter_tools/lib/src/commands/upgrade.dart +++ b/packages/flutter_tools/lib/src/commands/upgrade.dart @@ -10,6 +10,7 @@ import '../base/common.dart'; import '../base/io.dart'; import '../base/os.dart'; import '../base/process.dart'; +import '../base/time.dart'; import '../cache.dart'; import '../dart/pub.dart'; import '../globals.dart' as globals; @@ -34,6 +35,11 @@ class UpgradeCommand extends FlutterCommand { help: 'For the second half of the upgrade flow requiring the new ' 'version of Flutter. Should not be invoked manually, but ' 're-entrantly by the standard upgrade command.', + ) + ..addOption( + 'working-directory', + hide: true, + help: 'Override the upgrade working directoy for integration testing.' ); } @@ -50,36 +56,50 @@ class UpgradeCommand extends FlutterCommand { @override Future runCommand() { + _commandRunner.workingDirectory = stringArg('working-directory') ?? Cache.flutterRoot; return _commandRunner.runCommand( - boolArg('force'), - boolArg('continue'), - GitTagVersion.determine(), - globals.flutterVersion, + force: boolArg('force'), + continueFlow: boolArg('continue'), + testFlow: stringArg('working-directory') != null, + gitTagVersion: GitTagVersion.determine(processUtils), + flutterVersion: stringArg('working-directory') == null + ? globals.flutterVersion + : FlutterVersion(const SystemClock(), _commandRunner.workingDirectory), ); } } @visibleForTesting class UpgradeCommandRunner { - Future runCommand( - bool force, - bool continueFlow, - GitTagVersion gitTagVersion, - FlutterVersion flutterVersion, - ) async { + + String workingDirectory; + + Future runCommand({ + @required bool force, + @required bool continueFlow, + @required bool testFlow, + @required GitTagVersion gitTagVersion, + @required FlutterVersion flutterVersion, + }) async { if (!continueFlow) { - await runCommandFirstHalf(force, gitTagVersion, flutterVersion); + await runCommandFirstHalf( + force: force, + gitTagVersion: gitTagVersion, + flutterVersion: flutterVersion, + testFlow: testFlow, + ); } else { await runCommandSecondHalf(flutterVersion); } return FlutterCommandResult.success(); } - Future runCommandFirstHalf( - bool force, - GitTagVersion gitTagVersion, - FlutterVersion flutterVersion, - ) async { + Future runCommandFirstHalf({ + @required bool force, + @required GitTagVersion gitTagVersion, + @required FlutterVersion flutterVersion, + @required bool testFlow, + }) async { await verifyUpstreamConfigured(); if (!force && gitTagVersion == const GitTagVersion.unknown()) { // If the commit is a recognized branch and not master, @@ -110,6 +130,7 @@ class UpgradeCommandRunner { 'command with --force.' ); } + recordState(flutterVersion); await resetChanges(gitTagVersion); await upgradeChannel(flutterVersion); final bool alreadyUpToDate = await attemptFastForward(flutterVersion); @@ -117,11 +138,19 @@ class UpgradeCommandRunner { // If the upgrade was a no op, then do not continue with the second half. globals.printStatus('Flutter is already up to date on channel ${flutterVersion.channel}'); globals.printStatus('$flutterVersion'); - } else { + } else if (!testFlow) { await flutterUpgradeContinue(); } } + void recordState(FlutterVersion flutterVersion) { + final Channel channel = getChannelForName(flutterVersion.channel); + if (channel == null) { + return; + } + globals.persistentToolState.updateLastActiveVersion(flutterVersion.frameworkRevision, channel); + } + Future flutterUpgradeContinue() async { final int code = await processUtils.stream( [ @@ -130,7 +159,7 @@ class UpgradeCommandRunner { '--continue', '--no-version-check', ], - workingDirectory: Cache.flutterRoot, + workingDirectory: workingDirectory, allowReentrantFlutter: true, environment: Map.of(globals.platform.environment), ); @@ -156,7 +185,7 @@ class UpgradeCommandRunner { final RunResult result = await processUtils.run( ['git', 'status', '-s'], throwOnError: true, - workingDirectory: Cache.flutterRoot, + workingDirectory: workingDirectory, ); return result.stdout.trim().isNotEmpty; } on ProcessException catch (error) { @@ -179,13 +208,13 @@ class UpgradeCommandRunner { await processUtils.run( [ 'git', 'rev-parse', '@{u}'], throwOnError: true, - workingDirectory: Cache.flutterRoot, + workingDirectory: workingDirectory, ); } catch (e) { throwToolExit( 'Unable to upgrade Flutter: no origin repository configured. ' "Run 'git remote add origin " - "https://github.com/flutter/flutter' in ${Cache.flutterRoot}", + "https://github.com/flutter/flutter' in $workingDirectory", ); } } @@ -206,7 +235,7 @@ class UpgradeCommandRunner { await processUtils.run( ['git', 'reset', '--hard', tag], throwOnError: true, - workingDirectory: Cache.flutterRoot, + workingDirectory: workingDirectory, ); } on ProcessException catch (error) { throwToolExit( @@ -223,7 +252,7 @@ class UpgradeCommandRunner { /// If the user is on a deprecated channel, attempts to migrate them off of /// it. Future upgradeChannel(FlutterVersion flutterVersion) async { - globals.printStatus('Upgrading Flutter from ${Cache.flutterRoot}...'); + globals.printStatus('Upgrading Flutter from $workingDirectory...'); await ChannelCommand.upgradeChannel(); } @@ -237,7 +266,7 @@ class UpgradeCommandRunner { Future attemptFastForward(FlutterVersion oldFlutterVersion) async { final int code = await processUtils.stream( ['git', 'pull', '--ff'], - workingDirectory: Cache.flutterRoot, + workingDirectory: workingDirectory, mapFunction: (String line) => matchesGitLine(line) ? null : line, ); if (code != 0) { @@ -247,7 +276,7 @@ class UpgradeCommandRunner { // Check if the upgrade did anything. bool alreadyUpToDate = false; try { - final FlutterVersion newFlutterVersion = FlutterVersion(); + final FlutterVersion newFlutterVersion = FlutterVersion(const SystemClock(), workingDirectory); alreadyUpToDate = newFlutterVersion.channel == oldFlutterVersion.channel && newFlutterVersion.frameworkRevision == oldFlutterVersion.frameworkRevision; } catch (e) { @@ -268,7 +297,7 @@ class UpgradeCommandRunner { [ globals.fs.path.join('bin', 'flutter'), '--no-color', '--no-version-check', 'precache', ], - workingDirectory: Cache.flutterRoot, + workingDirectory: workingDirectory, allowReentrantFlutter: true, environment: Map.of(globals.platform.environment), ); @@ -296,7 +325,7 @@ class UpgradeCommandRunner { [ globals.fs.path.join('bin', 'flutter'), '--no-version-check', 'doctor', ], - workingDirectory: Cache.flutterRoot, + workingDirectory: workingDirectory, allowReentrantFlutter: true, ); } diff --git a/packages/flutter_tools/lib/src/commands/version.dart b/packages/flutter_tools/lib/src/commands/version.dart index cb8f6cc380..b70bba5ef6 100644 --- a/packages/flutter_tools/lib/src/commands/version.dart +++ b/packages/flutter_tools/lib/src/commands/version.dart @@ -57,8 +57,29 @@ class VersionCommand extends FlutterCommand { final List tags = await getTags(); if (argResults.rest.isEmpty) { tags.forEach(globals.printStatus); - return const FlutterCommandResult(ExitStatus.success); + return FlutterCommandResult.success(); } + + globals.printStatus( + '╔══════════════════════════════════════════════════════════════════════════════╗\n' + '║ Warning: "flutter version" will leave the SDK in a detached HEAD state. ║\n' + '║ If you are using the command to return to a previously installed SDK version ║\n' + '║ consider using the "flutter downgrade" command instead. ║\n' + '╚══════════════════════════════════════════════════════════════════════════════╝\n', + emphasis: true, + ); + if (globals.stdio.stdinHasTerminal) { + globals.terminal.usesTerminalUi = true; + final String result = await globals.terminal.promptForCharInput( + ['y', 'n'], + logger: globals.logger, + prompt: 'Are you sure you want to proceed?' + ); + if (result == 'n') { + return FlutterCommandResult.success(); + } + } + final String version = argResults.rest[0].replaceFirst('v', ''); if (!tags.contains('v$version')) { globals.printError('There is no version: $version'); diff --git a/packages/flutter_tools/lib/src/persistent_tool_state.dart b/packages/flutter_tools/lib/src/persistent_tool_state.dart index 8d6100c58f..49602ae1a0 100644 --- a/packages/flutter_tools/lib/src/persistent_tool_state.dart +++ b/packages/flutter_tools/lib/src/persistent_tool_state.dart @@ -9,6 +9,7 @@ import 'base/config.dart'; import 'base/context.dart'; import 'base/file_system.dart'; import 'base/logger.dart'; +import 'version.dart'; /// A class that represents global (non-project-specific) internal state that /// must persist across tool invocations. @@ -37,6 +38,14 @@ abstract class PersistentToolState { /// /// May give null if the value has not been set. bool redisplayWelcomeMessage; + + /// Returns the last active version for a given [channel]. + /// + /// If there was no active prior version, returns `null` instead. + String lastActiveVersion(Channel channel); + + /// Update the last active version for a given [channel]. + void updateLastActiveVersion(String fullGitHash, Channel channel); } class _DefaultPersistentToolState implements PersistentToolState { @@ -63,6 +72,12 @@ class _DefaultPersistentToolState implements PersistentToolState { static const String _kFileName = '.flutter_tool_state'; static const String _kRedisplayWelcomeMessage = 'redisplay-welcome-message'; + static const Map _lastActiveVersionKeys = { + Channel.master: 'last-active-master-version', + Channel.dev: 'last-active-dev-version', + Channel.beta: 'last-active-beta-version', + Channel.stable: 'last-active-stable-version' + }; final Config _config; @@ -71,8 +86,26 @@ class _DefaultPersistentToolState implements PersistentToolState { return _config.getValue(_kRedisplayWelcomeMessage) as bool; } + @override + String lastActiveVersion(Channel channel) { + final String versionKey = _versionKeyFor(channel); + assert(versionKey != null); + return _config.getValue(versionKey) as String; + } + @override set redisplayWelcomeMessage(bool value) { _config.setValue(_kRedisplayWelcomeMessage, value); } + + @override + void updateLastActiveVersion(String fullGitHash, Channel channel) { + final String versionKey = _versionKeyFor(channel); + assert(versionKey != null); + _config.setValue(versionKey, fullGitHash); + } + + String _versionKeyFor(Channel channel) { + return _lastActiveVersionKeys[channel]; + } } diff --git a/packages/flutter_tools/lib/src/version.dart b/packages/flutter_tools/lib/src/version.dart index e5cf0c1646..8a87986d93 100644 --- a/packages/flutter_tools/lib/src/version.dart +++ b/packages/flutter_tools/lib/src/version.dart @@ -15,14 +15,45 @@ import 'cache.dart'; import 'convert.dart'; import 'globals.dart' as globals; +/// The names of each channel/branch in order of increasing stability. +enum Channel { + master, + dev, + beta, + stable, +} + +/// Retrieve a human-readable name for a given [channel]. +/// +/// Requires [FlutterVersion.officialChannels] to be correctly ordered. +String getNameForChannel(Channel channel) { + return FlutterVersion.officialChannels.elementAt(channel.index); +} + +/// Retrieve the [Channel] representation for a string [name]. +/// +/// Returns `null` if [name] is not in the list of official channels, according +/// to [FlutterVersion.officialChannels]. +Channel getChannelForName(String name) { + if (FlutterVersion.officialChannels.contains(name)) { + return Channel.values[FlutterVersion.officialChannels.toList().indexOf(name)]; + } + return null; +} + class FlutterVersion { - FlutterVersion([this._clock = const SystemClock()]) { - _frameworkRevision = _runGit(gitLog(['-n', '1', '--pretty=format:%H']).join(' ')); - _gitTagVersion = GitTagVersion.determine(); + FlutterVersion([this._clock = const SystemClock(), this._workingDirectory]) { + _frameworkRevision = _runGit( + gitLog(['-n', '1', '--pretty=format:%H']).join(' '), + processUtils, + _workingDirectory, + ); + _gitTagVersion = GitTagVersion.determine(processUtils, _workingDirectory); _frameworkVersion = gitTagVersion.frameworkVersionFor(_frameworkRevision); } final SystemClock _clock; + final String _workingDirectory; String _repositoryUrl; String get repositoryUrl { @@ -60,11 +91,19 @@ class FlutterVersion { /// `master`, `dev`, `beta`, `stable`; or old ones, like `alpha`, `hackathon`, ... String get channel { if (_channel == null) { - final String channel = _runGit('git rev-parse --abbrev-ref --symbolic @{u}'); + final String channel = _runGit( + 'git rev-parse --abbrev-ref --symbolic @{u}', + processUtils, + _workingDirectory, + ); final int slash = channel.indexOf('/'); if (slash != -1) { final String remote = channel.substring(0, slash); - _repositoryUrl = _runGit('git ls-remote --get-url $remote'); + _repositoryUrl = _runGit( + 'git ls-remote --get-url $remote', + processUtils, + _workingDirectory, + ); _channel = channel.substring(slash + 1); } else if (channel.isEmpty) { _channel = 'unknown'; @@ -88,7 +127,11 @@ class FlutterVersion { String _frameworkAge; String get frameworkAge { - return _frameworkAge ??= _runGit(gitLog(['-n', '1', '--pretty=format:%ar']).join(' ')); + return _frameworkAge ??= _runGit( + gitLog(['-n', '1', '--pretty=format:%ar']).join(' '), + processUtils, + _workingDirectory, + ); } String _frameworkVersion; @@ -226,7 +269,7 @@ class FlutterVersion { /// the branch name will be returned as `'[user-branch]'`. String getBranchName({ bool redactUnknownBranches = false }) { _branch ??= () { - final String branch = _runGit('git rev-parse --abbrev-ref HEAD'); + final String branch = _runGit('git rev-parse --abbrev-ref HEAD', processUtils); return branch == 'HEAD' ? channel : branch; }(); if (redactUnknownBranches || _branch.isEmpty) { @@ -599,10 +642,10 @@ String _runSync(List command, { bool lenient = true }) { return ''; } -String _runGit(String command) { +String _runGit(String command, ProcessUtils processUtils, [String workingDirectory]) { return processUtils.runSync( command.split(' '), - workingDirectory: Cache.flutterRoot, + workingDirectory: workingDirectory ?? Cache.flutterRoot, ).stdout.trim(); } @@ -658,8 +701,8 @@ class GitTagVersion { /// The git hash (or an abbreviation thereof) for this commit. final String hash; - static GitTagVersion determine() { - return parse(_runGit('git describe --match v*.*.* --first-parent --long --tags')); + static GitTagVersion determine(ProcessUtils processUtils, [String workingDirectory]) { + return parse(_runGit('git describe --match v*.*.* --first-parent --long --tags', processUtils, workingDirectory)); } static GitTagVersion parse(String version) { diff --git a/packages/flutter_tools/test/commands.shard/hermetic/downgrade_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/downgrade_test.dart new file mode 100644 index 0000000000..db627c2bdb --- /dev/null +++ b/packages/flutter_tools/test/commands.shard/hermetic/downgrade_test.dart @@ -0,0 +1,249 @@ +// 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/memory.dart'; +import 'package:flutter_tools/src/base/io.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/terminal.dart'; +import 'package:flutter_tools/src/cache.dart'; +import 'package:flutter_tools/src/commands/downgrade.dart'; +import 'package:flutter_tools/src/persistent_tool_state.dart'; +import 'package:flutter_tools/src/version.dart'; +import 'package:mockito/mockito.dart'; + +import '../../src/common.dart'; +import '../../src/context.dart'; +import '../../src/mocks.dart'; + +void main() { + FileSystem fileSystem; + BufferLogger bufferLogger; + AnsiTerminal terminal; + ProcessManager processManager; + MockStdio mockStdio; + FlutterVersion flutterVersion; + + setUpAll(() { + Cache.disableLocking(); + }); + + tearDownAll(() { + Cache.enableLocking(); + }); + + setUp(() { + flutterVersion = MockFlutterVersion(); + mockStdio = MockStdio(); + processManager = FakeProcessManager.any(); + terminal = MockTerminal(); + fileSystem = MemoryFileSystem.test(); + bufferLogger = BufferLogger(terminal: terminal, outputPreferences: OutputPreferences.test()); + }); + + testUsingContext('Downgrade exits on unknown channel', () async { + fileSystem.currentDirectory.childFile('.flutter_tool_state') + .writeAsStringSync('{"last-active-master-version":"invalid"}'); + final DowngradeCommand command = DowngradeCommand( + persistentToolState: PersistentToolState.test(directory: fileSystem.currentDirectory, logger: bufferLogger), + processManager: processManager, + terminal: terminal, + stdio: mockStdio, + flutterVersion: flutterVersion, + logger: bufferLogger, + ); + applyMocksToCommand(command); + + expect(createTestCommandRunner(command).run(const ['downgrade']), + throwsToolExit(message: 'Flutter is not currently on a known channel.')); + }); + + testUsingContext('Downgrade exits on no recorded version', () async { + when(flutterVersion.channel).thenReturn('dev'); + fileSystem.currentDirectory.childFile('.flutter_tool_state') + .writeAsStringSync('{"last-active-master-version":"abcd"}'); + final DowngradeCommand command = DowngradeCommand( + persistentToolState: PersistentToolState.test(directory: fileSystem.currentDirectory, logger: bufferLogger), + processManager: FakeProcessManager.list([ + const FakeCommand( + command: [ + 'git', 'describe', '--tags', 'abcd' + ], + exitCode: 0, + stdout: 'v1.2.3' + ) + ]), + terminal: terminal, + stdio: mockStdio, + flutterVersion: flutterVersion, + logger: bufferLogger, + ); + applyMocksToCommand(command); + + expect(createTestCommandRunner(command).run(const ['downgrade']), + throwsToolExit(message: + 'There is no previously recorded version for channel "dev".\n' + 'Channel "master" was previously on: v1.2.3.' + ), + ); + }); + + testUsingContext('Downgrade exits on unknown recorded version', () async { + when(flutterVersion.channel).thenReturn('master'); + fileSystem.currentDirectory.childFile('.flutter_tool_state') + .writeAsStringSync('{"last-active-master-version":"invalid"}'); + final DowngradeCommand command = DowngradeCommand( + persistentToolState: PersistentToolState.test(directory: fileSystem.currentDirectory, logger: bufferLogger), + processManager: FakeProcessManager.list([ + const FakeCommand( + command: [ + 'git', 'describe', '--tags', 'invalid' + ], + exitCode: 1, + ) + ]), + terminal: terminal, + stdio: mockStdio, + flutterVersion: flutterVersion, + logger: bufferLogger, + ); + applyMocksToCommand(command); + + expect(createTestCommandRunner(command).run(const ['downgrade']), + throwsToolExit(message: 'Failed to parse version for downgrade')); + }); + + testUsingContext('Downgrade prompts for user input when terminal is attached - y', () async { + when(flutterVersion.channel).thenReturn('master'); + when(mockStdio.hasTerminal).thenReturn(true); + fileSystem.currentDirectory.childFile('.flutter_tool_state') + .writeAsStringSync('{"last-active-master-version":"g6b00b5e88"}'); + final DowngradeCommand command = DowngradeCommand( + persistentToolState: PersistentToolState.test(directory: fileSystem.currentDirectory, logger: bufferLogger), + processManager: processManager, + terminal: terminal, + stdio: mockStdio, + flutterVersion: flutterVersion, + logger: bufferLogger, + ); + applyMocksToCommand(command); + + when(terminal.promptForCharInput( + const ['y', 'n'], + prompt: anyNamed('prompt'), + logger: anyNamed('logger'), + )).thenAnswer((Invocation invocation) async => 'y'); + + await createTestCommandRunner(command).run(const ['downgrade']); + + verify(terminal.promptForCharInput( + const ['y', 'n'], + prompt: anyNamed('prompt'), + logger: anyNamed('logger'), + )).called(1); + expect(bufferLogger.statusText, contains('Success')); + }); + + testUsingContext('Downgrade prompts for user input when terminal is attached - n', () async { + when(flutterVersion.channel).thenReturn('master'); + when(mockStdio.hasTerminal).thenReturn(true); + fileSystem.currentDirectory.childFile('.flutter_tool_state') + .writeAsStringSync('{"last-active-master-version":"g6b00b5e88"}'); + final DowngradeCommand command = DowngradeCommand( + persistentToolState: PersistentToolState.test(directory: fileSystem.currentDirectory, logger: bufferLogger), + processManager: processManager, + terminal: terminal, + stdio: mockStdio, + flutterVersion: flutterVersion, + logger: bufferLogger, + ); + applyMocksToCommand(command); + + when(terminal.promptForCharInput( + const ['y', 'n'], + prompt: anyNamed('prompt'), + logger: anyNamed('logger'), + )).thenAnswer((Invocation invocation) async => 'n'); + + await createTestCommandRunner(command).run(const ['downgrade']); + + verify(terminal.promptForCharInput( + const ['y', 'n'], + prompt: anyNamed('prompt'), + logger: anyNamed('logger'), + )).called(1); + expect(bufferLogger.statusText, isNot(contains('Success'))); + }); + + testUsingContext('Downgrade does not prompt when there is no terminal', () async { + when(flutterVersion.channel).thenReturn('master'); + when(mockStdio.hasTerminal).thenReturn(false); + fileSystem.currentDirectory.childFile('.flutter_tool_state') + .writeAsStringSync('{"last-active-master-version":"g6b00b5e88"}'); + final DowngradeCommand command = DowngradeCommand( + persistentToolState: PersistentToolState.test( + directory: fileSystem.currentDirectory, + logger: bufferLogger, + ), + processManager: processManager, + terminal: terminal, + stdio: mockStdio, + flutterVersion: flutterVersion, + logger: bufferLogger, + ); + applyMocksToCommand(command); + + await createTestCommandRunner(command).run(const ['downgrade']); + + verifyNever(terminal.promptForCharInput( + const ['y', 'n'], + prompt: anyNamed('prompt'), + logger: anyNamed('logger'), + )); + expect(bufferLogger.statusText, contains('Success')); + }); + + testUsingContext('Downgrade performs correct git commands', () async { + when(flutterVersion.channel).thenReturn('master'); + when(mockStdio.hasTerminal).thenReturn(false); + fileSystem.currentDirectory.childFile('.flutter_tool_state') + .writeAsStringSync('{"last-active-master-version":"g6b00b5e88"}'); + final DowngradeCommand command = DowngradeCommand( + persistentToolState: PersistentToolState.test( + directory: fileSystem.currentDirectory, + logger: bufferLogger, + ), + processManager: FakeProcessManager.list([ + const FakeCommand( + command: [ + 'git', 'describe', '--tags', 'g6b00b5e88' + ], + stdout: 'v1.2.3', + ), + const FakeCommand( + command: [ + 'git', 'reset', '--hard', 'g6b00b5e88' + ], + ), + const FakeCommand( + command: [ + 'git', 'checkout', 'master', '--' + ] + ), + ]), + terminal: terminal, + stdio: mockStdio, + flutterVersion: flutterVersion, + logger: bufferLogger, + ); + applyMocksToCommand(command); + + await createTestCommandRunner(command).run(const ['downgrade']); + + expect(bufferLogger.statusText, contains('Success')); + }); +} + +class MockTerminal extends Mock implements AnsiTerminal {} +class MockStdio extends Mock implements Stdio {} diff --git a/packages/flutter_tools/test/commands.shard/hermetic/version_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/version_test.dart index 98922e0ca0..74596c30c6 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/version_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/version_test.dart @@ -8,11 +8,13 @@ import 'dart:io'; import 'package:flutter_tools/src/base/common.dart'; import 'package:flutter_tools/src/base/io.dart'; +import 'package:flutter_tools/src/base/terminal.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/commands/version.dart'; import 'package:flutter_tools/src/version.dart'; import 'package:mockito/mockito.dart'; import 'package:process/process.dart'; +import 'package:flutter_tools/src/globals.dart' as globals; import '../../src/common.dart'; import '../../src/context.dart'; @@ -20,10 +22,18 @@ import '../../src/mocks.dart' show MockProcess; void main() { group('version', () { + MockStdio mockStdio; + setUpAll(() { Cache.disableLocking(); }); + setUp(() { + mockStdio = MockStdio(); + when(mockStdio.stdinHasTerminal).thenReturn(false); + when(mockStdio.hasTerminal).thenReturn(false); + }); + testUsingContext('version ls', () async { final VersionCommand command = VersionCommand(); await createTestCommandRunner(command).run([ @@ -33,11 +43,18 @@ void main() { expect(testLogger.statusText, equals('v10.0.0\r\nv20.0.0\n')); }, overrides: { ProcessManager: () => MockProcessManager(), + Stdio: () => mockStdio, }); - testUsingContext('version switch', () async { + testUsingContext('version switch prompt is accepted', () async { + when(mockStdio.stdinHasTerminal).thenReturn(true); const String version = '10.0.0'; final VersionCommand command = VersionCommand(); + when(globals.terminal.promptForCharInput(['y', 'n'], + logger: anyNamed('logger'), + prompt: 'Are you sure you want to proceed?') + ).thenAnswer((Invocation invocation) async => 'y'); + await createTestCommandRunner(command).run([ 'version', '--no-pub', @@ -46,6 +63,29 @@ void main() { expect(testLogger.statusText, contains('Switching Flutter to version $version')); }, overrides: { ProcessManager: () => MockProcessManager(), + Stdio: () => mockStdio, + AnsiTerminal: () => MockTerminal(), + }); + + testUsingContext('version switch prompt is declined', () async { + when(mockStdio.stdinHasTerminal).thenReturn(true); + const String version = '10.0.0'; + final VersionCommand command = VersionCommand(); + when(globals.terminal.promptForCharInput(['y', 'n'], + logger: anyNamed('logger'), + prompt: 'Are you sure you want to proceed?') + ).thenAnswer((Invocation invocation) async => 'n'); + + await createTestCommandRunner(command).run([ + 'version', + '--no-pub', + version, + ]); + expect(testLogger.statusText, isNot(contains('Switching Flutter to version $version'))); + }, overrides: { + ProcessManager: () => MockProcessManager(), + Stdio: () => mockStdio, + AnsiTerminal: () => MockTerminal(), }); testUsingContext('version switch, latest commit query fails', () async { @@ -59,6 +99,7 @@ void main() { expect(testLogger.errorText, contains('git failed')); }, overrides: { ProcessManager: () => MockProcessManager(latestCommitFails: true), + Stdio: () => mockStdio, }); testUsingContext('latest commit is parsable when query fails', () { @@ -69,6 +110,7 @@ void main() { ); }, overrides: { ProcessManager: () => MockProcessManager(latestCommitFails: true), + Stdio: () => mockStdio, }); testUsingContext('switch to not supported version without force', () async { @@ -82,6 +124,7 @@ void main() { expect(testLogger.errorText, contains('Version command is not supported in')); }, overrides: { ProcessManager: () => MockProcessManager(), + Stdio: () => mockStdio, }); testUsingContext('switch to not supported version with force', () async { @@ -96,6 +139,7 @@ void main() { expect(testLogger.statusText, contains('Switching Flutter to version $version with force')); }, overrides: { ProcessManager: () => MockProcessManager(), + Stdio: () => mockStdio, }); testUsingContext('tool exit on confusing version', () async { @@ -111,6 +155,7 @@ void main() { ); }, overrides: { ProcessManager: () => MockProcessManager(), + Stdio: () => mockStdio, }); testUsingContext("exit tool if can't get the tags", () async { @@ -123,10 +168,13 @@ void main() { } }, overrides: { ProcessManager: () => MockProcessManager(failGitTag: true), + Stdio: () => mockStdio, }); }); } +class MockTerminal extends Mock implements AnsiTerminal {} +class MockStdio extends Mock implements Stdio {} class MockProcessManager extends Mock implements ProcessManager { MockProcessManager({ this.failGitTag = false, diff --git a/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart b/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart index 1d71094e11..f5ac6fbeb3 100644 --- a/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart +++ b/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart @@ -57,10 +57,11 @@ void main() { testUsingContext('throws on unknown tag, official branch, noforce', () async { final Future result = fakeCommandRunner.runCommand( - false, - false, - const GitTagVersion.unknown(), - flutterVersion, + force: false, + continueFlow: false, + testFlow: false, + gitTagVersion: const GitTagVersion.unknown(), + flutterVersion: flutterVersion, ); expect(result, throwsToolExit()); }, overrides: { @@ -69,10 +70,11 @@ void main() { testUsingContext('does not throw on unknown tag, official branch, force', () async { final Future result = fakeCommandRunner.runCommand( - true, - false, - const GitTagVersion.unknown(), - flutterVersion, + force: true, + continueFlow: false, + testFlow: false, + gitTagVersion: const GitTagVersion.unknown(), + flutterVersion: flutterVersion, ); expect(await result, FlutterCommandResult.success()); }, overrides: { @@ -83,10 +85,11 @@ void main() { testUsingContext('throws tool exit with uncommitted changes', () async { fakeCommandRunner.willHaveUncomittedChanges = true; final Future result = fakeCommandRunner.runCommand( - false, - false, - gitTagVersion, - flutterVersion, + force: false, + continueFlow: false, + testFlow: false, + gitTagVersion: gitTagVersion, + flutterVersion: flutterVersion, ); expect(result, throwsToolExit()); }, overrides: { @@ -97,10 +100,11 @@ void main() { fakeCommandRunner.willHaveUncomittedChanges = true; final Future result = fakeCommandRunner.runCommand( - true, - false, - gitTagVersion, - flutterVersion, + force: true, + continueFlow: false, + testFlow: false, + gitTagVersion: gitTagVersion, + flutterVersion: flutterVersion, ); expect(await result, FlutterCommandResult.success()); }, overrides: { @@ -110,10 +114,11 @@ void main() { testUsingContext("Doesn't throw on known tag, dev branch, no force", () async { final Future result = fakeCommandRunner.runCommand( - false, - false, - gitTagVersion, - flutterVersion, + force: false, + continueFlow: false, + testFlow: false, + gitTagVersion: gitTagVersion, + flutterVersion: flutterVersion, ); expect(await result, FlutterCommandResult.success()); }, overrides: { @@ -124,10 +129,11 @@ void main() { testUsingContext("Doesn't continue on known tag, dev branch, no force, already up-to-date", () async { fakeCommandRunner.alreadyUpToDate = true; final Future result = fakeCommandRunner.runCommand( - false, - false, - gitTagVersion, - flutterVersion, + force: false, + continueFlow: false, + testFlow: false, + gitTagVersion: gitTagVersion, + flutterVersion: flutterVersion, ); expect(await result, FlutterCommandResult.success()); verifyNever(globals.processManager.start( diff --git a/packages/flutter_tools/test/general.shard/persistent_tool_state_test.dart b/packages/flutter_tools/test/general.shard/persistent_tool_state_test.dart index 59dead0089..1f4bfcf4ab 100644 --- a/packages/flutter_tools/test/general.shard/persistent_tool_state_test.dart +++ b/packages/flutter_tools/test/general.shard/persistent_tool_state_test.dart @@ -6,6 +6,7 @@ import 'package:file/memory.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/persistent_tool_state.dart'; +import 'package:flutter_tools/src/version.dart'; import 'package:mockito/mockito.dart'; import '../src/common.dart'; @@ -14,8 +15,8 @@ class MockLogger extends Mock implements Logger {} void main() { testWithoutContext('state can be set and persists', () { - final MemoryFileSystem fs = MemoryFileSystem(); - final Directory directory = fs.directory('state_dir'); + final MemoryFileSystem fileSystem = MemoryFileSystem(); + final Directory directory = fileSystem.directory('state_dir'); directory.createSync(); final File stateFile = directory.childFile('.flutter_tool_state'); final PersistentToolState state1 = PersistentToolState.test( @@ -35,4 +36,28 @@ void main() { ); expect(state2.redisplayWelcomeMessage, false); }); + + testWithoutContext('channel versions can be cached and stored', () { + final MemoryFileSystem fileSystem = MemoryFileSystem(); + final Directory directory = fileSystem.directory('state_dir')..createSync(); + final PersistentToolState state1 = PersistentToolState.test( + directory: directory, + logger: MockLogger(), + ); + + state1.updateLastActiveVersion('abc', Channel.master); + state1.updateLastActiveVersion('def', Channel.dev); + state1.updateLastActiveVersion('ghi', Channel.beta); + state1.updateLastActiveVersion('jkl', Channel.stable); + + final PersistentToolState state2 = PersistentToolState.test( + directory: directory, + logger: MockLogger(), + ); + + expect(state2.lastActiveVersion(Channel.master), 'abc'); + expect(state2.lastActiveVersion(Channel.dev), 'def'); + expect(state2.lastActiveVersion(Channel.beta), 'ghi'); + expect(state2.lastActiveVersion(Channel.stable), 'jkl'); + }); } diff --git a/packages/flutter_tools/test/integration.shard/downgrade_upgrade_integration_test.dart b/packages/flutter_tools/test/integration.shard/downgrade_upgrade_integration_test.dart new file mode 100644 index 0000000000..6c1bc2cbdf --- /dev/null +++ b/packages/flutter_tools/test/integration.shard/downgrade_upgrade_integration_test.dart @@ -0,0 +1,122 @@ +// 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:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/io.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/process.dart'; +import 'package:flutter_tools/src/base/terminal.dart'; +import 'package:platform/platform.dart'; +import 'package:process/process.dart'; + +import '../src/common.dart'; + +const String _kInitialVersion = 'v1.9.1+hotfix.6'; +const String _kBranch = 'stable'; +const FileSystem fileSystem = LocalFileSystem(); +const ProcessManager processManager = LocalProcessManager(); +final ProcessUtils processUtils = ProcessUtils(processManager: processManager, logger: StdoutLogger( + terminal: AnsiTerminal( + platform: const LocalPlatform(), + stdio: const Stdio(), + ), + stdio: const Stdio(), + outputPreferences: OutputPreferences.test(wrapText: true), + timeoutConfiguration: const TimeoutConfiguration(), +)); +final String flutterBin = fileSystem.path.join(getFlutterRoot(), 'bin', 'flutter'); + +/// A test for flutter upgrade & downgrade that checks out a parallel flutter repo. +void main() { + Directory parentDirectory; + + setUp(() { + parentDirectory = fileSystem.systemTempDirectory + .createTempSync('flutter_tools.'); + parentDirectory.createSync(recursive: true); + }); + + tearDown(() { + try { + parentDirectory.deleteSync(recursive: true); + } on FileSystemException { + print('Failed to delete test directory'); + } + }); + + test('Can upgrade and downgrade a Flutter checkout', () async { + final Directory testDirectory = parentDirectory.childDirectory('flutter'); + testDirectory.createSync(recursive: true); + + // Enable longpaths for windows integration test. + await processManager.run([ + 'git', 'config', '--system', 'core.longpaths', 'true', + ]); + + // Step 1. Clone the dev branch of flutter into the test directory. + await processUtils.stream([ + 'git', + 'clone', + 'https://github.com/flutter/flutter.git', + ], workingDirectory: parentDirectory.path, trace: true); + + // Step 2. Switch to the dev branch. + await processUtils.stream([ + 'git', + 'checkout', + '--track', + '-b', + _kBranch, + 'origin/$_kBranch', + ], workingDirectory: testDirectory.path, trace: true); + + // Step 3. Revert to a prior version. + await processUtils.stream([ + 'git', + 'reset', + '--hard', + _kInitialVersion, + ], workingDirectory: testDirectory.path, trace: true); + + // Step 4. Upgrade to the newest dev. This should update the persistent + // tool state with the sha for v1.14.3 + await processUtils.stream([ + flutterBin, + 'upgrade', + '--working-directory=${testDirectory.path}' + ], workingDirectory: testDirectory.path, trace: true); + + // Step 5. Verify that the version is different. + final RunResult versionResult = await processUtils.run([ + 'git', + 'describe', + '--match', + 'v*.*.*', + '--first-parent', + '--long', + '--tags', + ], workingDirectory: testDirectory.path); + expect(versionResult.stdout, isNot(contains(_kInitialVersion))); + + // Step 6. Downgrade back to initial version. + await processUtils.stream([ + flutterBin, + 'downgrade', + '--no-prompt', + '--working-directory=${testDirectory.path}' + ], workingDirectory: testDirectory.path, trace: true); + + // Step 7. Verify downgraded version matches original version. + final RunResult oldVersionResult = await processUtils.run([ + 'git', + 'describe', + '--match', + 'v*.*.*', + '--first-parent', + '--long', + '--tags', + ], workingDirectory: testDirectory.path); + expect(oldVersionResult.stdout, contains(_kInitialVersion)); + }); +}