diff --git a/packages/flutter_tools/lib/src/base/utils.dart b/packages/flutter_tools/lib/src/base/utils.dart index 9b49cd5827c..f493a711ad6 100644 --- a/packages/flutter_tools/lib/src/base/utils.dart +++ b/packages/flutter_tools/lib/src/base/utils.dart @@ -87,16 +87,24 @@ String toPrettyJson(Object jsonable) { return '$value\n'; } -final NumberFormat kSecondsFormat = NumberFormat('0.0'); -final NumberFormat kMillisecondsFormat = NumberFormat.decimalPattern(); +final NumberFormat _singleDigitPrecision = NumberFormat('0.0'); +final NumberFormat _decimalPattern = NumberFormat.decimalPattern(); + +String getElapsedAsMinutesOrSeconds(Duration duration) { + if (duration.inMinutes < 1) { + return getElapsedAsSeconds(duration); + } + final double minutes = duration.inSeconds / Duration.secondsPerMinute; + return '${_singleDigitPrecision.format(minutes)}m'; +} String getElapsedAsSeconds(Duration duration) { final double seconds = duration.inMilliseconds / Duration.millisecondsPerSecond; - return '${kSecondsFormat.format(seconds)}s'; + return '${_singleDigitPrecision.format(seconds)}s'; } String getElapsedAsMilliseconds(Duration duration) { - return '${kMillisecondsFormat.format(duration.inMilliseconds)}ms'; + return '${_decimalPattern.format(duration.inMilliseconds)}ms'; } /// Return a platform-appropriate [String] representing the size of the given number of bytes. diff --git a/packages/flutter_tools/lib/src/commands/upgrade.dart b/packages/flutter_tools/lib/src/commands/upgrade.dart index 72f5f9afa5c..bfcbe5a30f0 100644 --- a/packages/flutter_tools/lib/src/commands/upgrade.dart +++ b/packages/flutter_tools/lib/src/commands/upgrade.dart @@ -8,6 +8,8 @@ import '../base/common.dart'; import '../base/io.dart'; import '../base/os.dart'; import '../base/process.dart'; +import '../base/time.dart'; +import '../base/utils.dart'; import '../cache.dart'; import '../dart/pub.dart'; import '../globals.dart' as globals; @@ -33,13 +35,19 @@ class UpgradeCommand extends FlutterCommand { ..addFlag( 'continue', hide: !verboseHelp, - negatable: false, help: 'Trigger the second half of the upgrade flow. This should not be invoked ' 'manually. It is used re-entrantly by the standard upgrade command after ' 'the new version of Flutter is available, to hand off the upgrade process ' 'from the old version to the new version.', ) + ..addOption( + 'continue-started-at', + hide: !verboseHelp, + help: + 'If "--continue" is provided, an ISO 8601 timestamp of the time that the ' + 'initial upgrade command was started. This should not be invoked manually.', + ) ..addOption( 'working-directory', hide: !verboseHelp, @@ -69,12 +77,26 @@ class UpgradeCommand extends FlutterCommand { @override bool get shouldUpdateCache => false; + UpgradePhase _parsePhaseFromContinueArg() { + if (!boolArg('continue')) { + return const UpgradePhase.firstHalf(); + } else { + final DateTime? upgradeStartedAt; + if (stringArg('continue-started-at') case final String iso8601String) { + upgradeStartedAt = DateTime.parse(iso8601String); + } else { + upgradeStartedAt = null; + } + return UpgradePhase.secondHalf(upgradeStartedAt: upgradeStartedAt); + } + } + @override Future runCommand() { _commandRunner.workingDirectory = stringArg('working-directory') ?? Cache.flutterRoot!; return _commandRunner.runCommand( + _parsePhaseFromContinueArg(), force: boolArg('force'), - continueFlow: boolArg('continue'), testFlow: stringArg('working-directory') != null, gitTagVersion: GitTagVersion.determine( globals.processUtils, @@ -89,33 +111,62 @@ class UpgradeCommand extends FlutterCommand { } } +@immutable +sealed class UpgradePhase { + const factory UpgradePhase.firstHalf() = _FirstHalf; + const factory UpgradePhase.secondHalf({required DateTime? upgradeStartedAt}) = _SecondHalf; +} + +final class _FirstHalf implements UpgradePhase { + const _FirstHalf(); +} + +final class _SecondHalf implements UpgradePhase { + const _SecondHalf({required this.upgradeStartedAt}); + + /// What time the original `flutter upgrade` command started at. + /// + /// If omitted, the initiating client was too old to know to pass this value. + final DateTime? upgradeStartedAt; +} + @visibleForTesting class UpgradeCommandRunner { String? workingDirectory; // set in runCommand() above - Future runCommand({ + @visibleForTesting + SystemClock clock = const SystemClock(); + + Future runCommand( + UpgradePhase phase, { required bool force, - required bool continueFlow, required bool testFlow, required GitTagVersion gitTagVersion, required FlutterVersion flutterVersion, required bool verifyOnly, }) async { - if (!continueFlow) { - await runCommandFirstHalf( - force: force, - gitTagVersion: gitTagVersion, - flutterVersion: flutterVersion, - testFlow: testFlow, - verifyOnly: verifyOnly, - ); - } else { - await runCommandSecondHalf(flutterVersion); + switch (phase) { + case _FirstHalf(): + await _runCommandFirstHalf( + startedAt: clock.now(), + force: force, + gitTagVersion: gitTagVersion, + flutterVersion: flutterVersion, + testFlow: testFlow, + verifyOnly: verifyOnly, + ); + case _SecondHalf(:final DateTime? upgradeStartedAt): + await _runCommandSecondHalf(flutterVersion); + if (upgradeStartedAt != null) { + final Duration execution = clock.now().difference(upgradeStartedAt); + globals.printStatus('Took ${getElapsedAsMinutesOrSeconds(execution)}'); + } } return FlutterCommandResult.success(); } - Future runCommandFirstHalf({ + Future _runCommandFirstHalf({ + required DateTime startedAt, required bool force, required GitTagVersion gitTagVersion, required FlutterVersion flutterVersion, @@ -185,7 +236,7 @@ class UpgradeCommandRunner { ); await attemptReset(upstreamVersion.frameworkRevision); if (!testFlow) { - await flutterUpgradeContinue(); + await flutterUpgradeContinue(startedAt: startedAt); } } @@ -197,12 +248,15 @@ class UpgradeCommandRunner { globals.persistentToolState!.updateLastActiveVersion(flutterVersion.frameworkRevision, channel); } - Future flutterUpgradeContinue() async { + @visibleForTesting + Future flutterUpgradeContinue({required DateTime startedAt}) async { final int code = await globals.processUtils.stream( [ globals.fs.path.join('bin', 'flutter'), 'upgrade', '--continue', + '--continue-started-at', + startedAt.toIso8601String(), '--no-version-check', ], workingDirectory: workingDirectory, @@ -216,7 +270,7 @@ class UpgradeCommandRunner { // This method should only be called if the upgrade command is invoked // re-entrantly with the `--continue` flag - Future runCommandSecondHalf(FlutterVersion flutterVersion) async { + Future _runCommandSecondHalf(FlutterVersion flutterVersion) async { // Make sure the welcome message re-display is delayed until the end. final PersistentToolState persistentToolState = globals.persistentToolState!; persistentToolState.setShouldRedisplayWelcomeMessage(false); @@ -243,6 +297,7 @@ class UpgradeCommandRunner { } } + @protected Future hasUncommittedChanges() async { try { final RunResult result = await globals.processUtils.run( @@ -265,6 +320,7 @@ class UpgradeCommandRunner { /// Returns the remote HEAD flutter version. /// /// Exits tool if HEAD isn't pointing to a branch, or there is no upstream. + @visibleForTesting Future fetchLatestVersion({required FlutterVersion localVersion}) async { String revision; try { @@ -326,6 +382,7 @@ class UpgradeCommandRunner { /// This is a reset instead of fast forward because if we are on a release /// branch with cherry picks, there may not be a direct fast-forward route /// to the next release. + @visibleForTesting Future attemptReset(String newRevision) async { try { await globals.processUtils.run( @@ -339,6 +396,7 @@ class UpgradeCommandRunner { } /// Update the user's packages. + @protected Future updatePackages(FlutterVersion flutterVersion) async { globals.printStatus(''); globals.printStatus(flutterVersion.toString()); @@ -354,6 +412,7 @@ class UpgradeCommandRunner { } /// Run flutter doctor in case requirements have changed. + @protected Future runDoctor() async { globals.printStatus(''); globals.printStatus('Running flutter doctor...'); diff --git a/packages/flutter_tools/test/commands.shard/hermetic/upgrade_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/upgrade_test.dart index 465a95eafed..88387e4a618 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/upgrade_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/upgrade_test.dart @@ -8,6 +8,7 @@ import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/time.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/commands/upgrade.dart'; import 'package:flutter_tools/src/version.dart'; @@ -34,7 +35,10 @@ void main() { fileSystem = MemoryFileSystem.test(); logger = BufferLogger.test(); processManager = FakeProcessManager.empty(); - command = UpgradeCommand(verboseHelp: false); + command = UpgradeCommand( + verboseHelp: false, + commandRunner: UpgradeCommandRunner()..clock = SystemClock.fixed(DateTime.utc(2026)), + ); runner = createTestCommandRunner(command); }); @@ -47,7 +51,13 @@ void main() { final Completer reEntryCompleter = Completer(); Future reEnterTool(List command) async { - await runner.run(['upgrade', '--continue', '--no-version-check']); + await runner.run([ + 'upgrade', + '--continue', + '--continue-started-at', + '2026-01-01T00:00:00.000Z', + '--no-version-check', + ]); reEntryCompleter.complete(); } @@ -83,7 +93,14 @@ void main() { // re-enter flutter command with the newer version, so that `doctor` // checks will be up to date FakeCommand( - command: const ['bin/flutter', 'upgrade', '--continue', '--no-version-check'], + command: const [ + 'bin/flutter', + 'upgrade', + '--continue', + '--continue-started-at', + '2026-01-01T00:00:00.000Z', + '--no-version-check', + ], onRun: reEnterTool, completer: reEntryCompleter, ), @@ -120,7 +137,13 @@ void main() { final Completer reEntryCompleter = Completer(); Future reEnterTool(List args) async { - await runner.run(['upgrade', '--continue', '--no-version-check']); + await runner.run([ + 'upgrade', + '--continue', + '--continue-started-at', + '2026-01-01T00:00:00.000Z', + '--no-version-check', + ]); reEntryCompleter.complete(); } @@ -141,7 +164,14 @@ void main() { const FakeCommand(command: ['git', 'status', '-s']), const FakeCommand(command: ['git', 'reset', '--hard', upstreamHeadRevision]), FakeCommand( - command: const ['bin/flutter', 'upgrade', '--continue', '--no-version-check'], + command: const [ + 'bin/flutter', + 'upgrade', + '--continue', + '--continue-started-at', + '2026-01-01T00:00:00.000Z', + '--no-version-check', + ], onRun: reEnterTool, completer: reEntryCompleter, ), @@ -178,7 +208,8 @@ void main() { 'For the most up to date stable version of flutter, consider using the "beta" channel ' 'instead. The Flutter "beta" channel enjoys all the same automated testing as the ' '"stable" channel, but is updated roughly once a month instead of once a quarter.\n' - 'To change channel, run the "flutter channel beta" command.\n', + 'To change channel, run the "flutter channel beta" command.\n' + 'Took 0.0s\n', ); }, overrides: { @@ -196,7 +227,13 @@ void main() { final Completer reEntryCompleter = Completer(); Future reEnterTool(List command) async { - await runner.run(['upgrade', '--continue', '--no-version-check']); + await runner.run([ + 'upgrade', + '--continue', + '--continue-started-at', + '2026-01-01T00:00:00.000Z', + '--no-version-check', + ]); reEntryCompleter.complete(); } @@ -226,7 +263,14 @@ void main() { workingDirectory: flutterRoot, ), FakeCommand( - command: const ['bin/flutter', 'upgrade', '--continue', '--no-version-check'], + command: const [ + 'bin/flutter', + 'upgrade', + '--continue', + '--continue-started-at', + '2026-01-01T00:00:00.000Z', + '--no-version-check', + ], onRun: reEnterTool, completer: reEntryCompleter, workingDirectory: flutterRoot, @@ -257,7 +301,8 @@ void main() { '\n' "Instance of 'FakeFlutterVersion'\n" // the real FlutterVersion has a better toString, heh '\n' - 'Running flutter doctor...\n', + 'Running flutter doctor...\n' + 'Took 0.0s\n', ); }, overrides: { 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 9bbac2dee51..49648e92e8f 100644 --- a/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart +++ b/packages/flutter_tools/test/commands.shard/permeable/upgrade_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/io.dart'; import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/base/time.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/commands/upgrade.dart'; import 'package:flutter_tools/src/convert.dart'; @@ -22,6 +23,8 @@ import '../../src/test_flutter_command_runner.dart'; void main() { group('UpgradeCommandRunner', () { + final DateTime jan12026 = DateTime.utc(2026); + late FakeUpgradeCommandRunner fakeCommandRunner; late UpgradeCommandRunner realCommandRunner; late FakeProcessManager processManager; @@ -37,8 +40,10 @@ void main() { ); setUp(() { - fakeCommandRunner = FakeUpgradeCommandRunner(); - realCommandRunner = UpgradeCommandRunner()..workingDirectory = getFlutterRoot(); + fakeCommandRunner = FakeUpgradeCommandRunner()..clock = SystemClock.fixed(jan12026); + realCommandRunner = UpgradeCommandRunner() + ..workingDirectory = getFlutterRoot() + ..clock = SystemClock.fixed(jan12026); processManager = FakeProcessManager.empty(); fakeCommandRunner.willHaveUncommittedChanges = false; fakePlatform = FakePlatform() @@ -59,8 +64,8 @@ void main() { fakeCommandRunner.remoteVersion = latestVersion; final Future result = fakeCommandRunner.runCommand( + const UpgradePhase.firstHalf(), force: false, - continueFlow: false, testFlow: false, gitTagVersion: const GitTagVersion.unknown(), flutterVersion: flutterVersion, @@ -84,8 +89,8 @@ void main() { fakeCommandRunner.willHaveUncommittedChanges = true; final Future result = fakeCommandRunner.runCommand( + const UpgradePhase.firstHalf(), force: false, - continueFlow: false, testFlow: false, gitTagVersion: gitTagVersion, flutterVersion: flutterVersion, @@ -110,8 +115,8 @@ void main() { fakeCommandRunner.remoteVersion = latestVersion; final Future result = fakeCommandRunner.runCommand( + const UpgradePhase.firstHalf(), force: false, - continueFlow: false, testFlow: false, gitTagVersion: gitTagVersion, flutterVersion: flutterVersion, @@ -127,6 +132,116 @@ void main() { }, ); + testUsingContext( + 'starts the upgrade operation and passes now as --continue-started-at ', + () async { + const String revision = 'abc123'; + const String upstreamRevision = 'def456'; + const String version = '1.2.3'; + const String upstreamVersion = '4.5.6'; + + final FakeFlutterVersion flutterVersion = FakeFlutterVersion( + branch: 'beta', + frameworkRevision: revision, + frameworkRevisionShort: revision, + frameworkVersion: version, + ); + + final FakeFlutterVersion latestVersion = FakeFlutterVersion( + frameworkRevision: upstreamRevision, + frameworkRevisionShort: upstreamRevision, + frameworkVersion: upstreamVersion, + ); + + final DateTime now = DateTime.now().subtract(const Duration(minutes: 25)); + fakeCommandRunner.remoteVersion = latestVersion; + fakeCommandRunner.clock = SystemClock.fixed(now); + + processManager.addCommands([ + FakeCommand( + command: [ + globals.fs.path.join('bin', 'flutter'), + 'upgrade', + '--continue', + '--continue-started-at', + now.toIso8601String(), + '--no-version-check', + ], + ), + ]); + + final Future result = fakeCommandRunner.runCommand( + const UpgradePhase.firstHalf(), + force: false, + testFlow: false, + gitTagVersion: gitTagVersion, + flutterVersion: flutterVersion, + verifyOnly: false, + ); + expect(await result, FlutterCommandResult.success()); + expect(testLogger.statusText, isNot(contains('Took '))); + }, + overrides: { + ProcessManager: () => processManager, + Platform: () => fakePlatform, + }, + ); + + testUsingContext( + 'finishes the upgrade operation and prints minutes the operation took', + () async { + const String revision = 'abc123'; + const String upstreamRevision = 'def456'; + const String version = '1.2.3'; + const String upstreamVersion = '4.5.6'; + + final FakeFlutterVersion flutterVersion = FakeFlutterVersion( + branch: 'beta', + frameworkRevision: revision, + frameworkRevisionShort: revision, + frameworkVersion: version, + ); + + final FakeFlutterVersion latestVersion = FakeFlutterVersion( + frameworkRevision: upstreamRevision, + frameworkRevisionShort: upstreamRevision, + frameworkVersion: upstreamVersion, + ); + + fakeCommandRunner.remoteVersion = latestVersion; + + final DateTime now = DateTime.now(); + final DateTime before = now.subtract(const Duration(minutes: 25)); + fakeCommandRunner.clock = SystemClock.fixed(now); + + processManager.addCommands([ + FakeCommand( + command: [ + globals.fs.path.join('bin', 'flutter'), + '--no-color', + '--no-version-check', + 'precache', + ], + ), + ]); + + final Future result = fakeCommandRunner.runCommand( + UpgradePhase.secondHalf(upgradeStartedAt: before), + force: false, + testFlow: false, + gitTagVersion: gitTagVersion, + flutterVersion: flutterVersion, + verifyOnly: false, + ); + expect(await result, FlutterCommandResult.success()); + expect(testLogger.statusText, contains('Took 25.0m')); + }, + overrides: { + ProcessManager: () => processManager, + Platform: () => fakePlatform, + }, + ); + testUsingContext( 'correctly provides upgrade version on verify only', () async { @@ -152,8 +267,8 @@ void main() { fakeCommandRunner.remoteVersion = latestVersion; final Future result = fakeCommandRunner.runCommand( + const UpgradePhase.firstHalf(), force: false, - continueFlow: false, testFlow: false, gitTagVersion: gitTagVersion, flutterVersion: flutterVersion, @@ -335,6 +450,8 @@ void main() { globals.fs.path.join('bin', 'flutter'), 'upgrade', '--continue', + '--continue-started-at', + '2026-01-01T00:00:00.000Z', '--no-version-check', ], environment: { @@ -343,7 +460,7 @@ void main() { }, ), ); - await realCommandRunner.flutterUpgradeContinue(); + await realCommandRunner.flutterUpgradeContinue(startedAt: jan12026); expect(processManager, hasNoRemainingExpectations); }, overrides: { @@ -375,8 +492,8 @@ void main() { fakeCommandRunner.workingDirectory = 'workingDirectory/aaa/bbb'; final Future result = fakeCommandRunner.runCommand( + const UpgradePhase.firstHalf(), force: true, - continueFlow: false, testFlow: true, gitTagVersion: gitTagVersion, flutterVersion: flutterVersion, @@ -417,8 +534,8 @@ void main() { fakeCommandRunner.workingDirectory = 'workingDirectory/aaa/bbb'; final Future result = fakeCommandRunner.runCommand( + const UpgradePhase.firstHalf(), force: true, - continueFlow: false, testFlow: true, gitTagVersion: gitTagVersion, flutterVersion: flutterVersion, @@ -463,8 +580,8 @@ void main() { fakeCommandRunner.workingDirectory = 'workingDirectory/aaa/bbb'; final Future result = fakeCommandRunner.runCommand( + const UpgradePhase.firstHalf(), force: true, - continueFlow: false, testFlow: true, gitTagVersion: currentTag, flutterVersion: flutterVersion, @@ -513,6 +630,8 @@ void main() { globals.fs.path.join('bin', 'flutter'), 'upgrade', '--continue', + '--continue-started-at', + '2026-01-01T00:00:00.000Z', '--no-version-check', ], ), @@ -526,8 +645,8 @@ void main() { final FakeFlutterVersion flutterVersion = FakeFlutterVersion(branch: 'beta'); final Future result = fakeCommandRunner.runCommand( + const UpgradePhase.firstHalf(), force: true, - continueFlow: false, testFlow: false, gitTagVersion: const GitTagVersion.unknown(), flutterVersion: flutterVersion, @@ -550,8 +669,8 @@ void main() { fakeCommandRunner.willHaveUncommittedChanges = true; final Future result = fakeCommandRunner.runCommand( + const UpgradePhase.firstHalf(), force: true, - continueFlow: false, testFlow: false, gitTagVersion: gitTagVersion, flutterVersion: flutterVersion, @@ -573,8 +692,8 @@ void main() { fakeCommandRunner.remoteVersion = FakeFlutterVersion(frameworkRevision: '1234'); final Future result = fakeCommandRunner.runCommand( - force: false, - continueFlow: false, + const UpgradePhase.firstHalf(), + force: true, testFlow: false, gitTagVersion: gitTagVersion, flutterVersion: flutterVersion, @@ -623,7 +742,12 @@ void main() { commandRunner: fakeCommandRunner, ); - await createTestCommandRunner(upgradeCommand).run(['upgrade', '--continue']); + await createTestCommandRunner(upgradeCommand).run([ + 'upgrade', + '--continue', + '--continue-started-at', + DateTime.now().toIso8601String(), + ]); expect( json.decode(flutterToolState.readAsStringSync()),