Add total execution time to the flutter upgrade command (#171475)

Closes https://github.com/flutter/flutter/issues/47090.
This commit is contained in:
Matan Lurey 2025-07-09 14:23:49 -07:00 committed by GitHub
parent 112fd3e6f7
commit e212d3dcd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 282 additions and 46 deletions

View File

@ -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.

View File

@ -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<FlutterCommandResult> 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<FlutterCommandResult> runCommand({
@visibleForTesting
SystemClock clock = const SystemClock();
Future<FlutterCommandResult> 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<void> runCommandFirstHalf({
Future<void> _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<void> flutterUpgradeContinue() async {
@visibleForTesting
Future<void> flutterUpgradeContinue({required DateTime startedAt}) async {
final int code = await globals.processUtils.stream(
<String>[
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<void> runCommandSecondHalf(FlutterVersion flutterVersion) async {
Future<void> _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<bool> 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<FlutterVersion> 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<void> attemptReset(String newRevision) async {
try {
await globals.processUtils.run(
@ -339,6 +396,7 @@ class UpgradeCommandRunner {
}
/// Update the user's packages.
@protected
Future<void> 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<void> runDoctor() async {
globals.printStatus('');
globals.printStatus('Running flutter doctor...');

View File

@ -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<void> reEntryCompleter = Completer<void>();
Future<void> reEnterTool(List<String> command) async {
await runner.run(<String>['upgrade', '--continue', '--no-version-check']);
await runner.run(<String>[
'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 <String>['bin/flutter', 'upgrade', '--continue', '--no-version-check'],
command: const <String>[
'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<void> reEntryCompleter = Completer<void>();
Future<void> reEnterTool(List<String> args) async {
await runner.run(<String>['upgrade', '--continue', '--no-version-check']);
await runner.run(<String>[
'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: <String>['git', 'status', '-s']),
const FakeCommand(command: <String>['git', 'reset', '--hard', upstreamHeadRevision]),
FakeCommand(
command: const <String>['bin/flutter', 'upgrade', '--continue', '--no-version-check'],
command: const <String>[
'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: <Type, Generator>{
@ -196,7 +227,13 @@ void main() {
final Completer<void> reEntryCompleter = Completer<void>();
Future<void> reEnterTool(List<String> command) async {
await runner.run(<String>['upgrade', '--continue', '--no-version-check']);
await runner.run(<String>[
'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 <String>['bin/flutter', 'upgrade', '--continue', '--no-version-check'],
command: const <String>[
'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: <Type, Generator>{

View File

@ -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<FlutterCommandResult> 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<FlutterCommandResult> 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<FlutterCommandResult> 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 <iso-date>',
() 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>[
FakeCommand(
command: <String>[
globals.fs.path.join('bin', 'flutter'),
'upgrade',
'--continue',
'--continue-started-at',
now.toIso8601String(),
'--no-version-check',
],
),
]);
final Future<FlutterCommandResult> 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: <Type, Generator>{
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>[
FakeCommand(
command: <String>[
globals.fs.path.join('bin', 'flutter'),
'--no-color',
'--no-version-check',
'precache',
],
),
]);
final Future<FlutterCommandResult> 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: <Type, Generator>{
ProcessManager: () => processManager,
Platform: () => fakePlatform,
},
);
testUsingContext(
'correctly provides upgrade version on verify only',
() async {
@ -152,8 +267,8 @@ void main() {
fakeCommandRunner.remoteVersion = latestVersion;
final Future<FlutterCommandResult> 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: <String, String>{
@ -343,7 +460,7 @@ void main() {
},
),
);
await realCommandRunner.flutterUpgradeContinue();
await realCommandRunner.flutterUpgradeContinue(startedAt: jan12026);
expect(processManager, hasNoRemainingExpectations);
},
overrides: <Type, Generator>{
@ -375,8 +492,8 @@ void main() {
fakeCommandRunner.workingDirectory = 'workingDirectory/aaa/bbb';
final Future<FlutterCommandResult> 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<FlutterCommandResult> 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<FlutterCommandResult> 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<FlutterCommandResult> 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<FlutterCommandResult> 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<FlutterCommandResult> 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(<String>['upgrade', '--continue']);
await createTestCommandRunner(upgradeCommand).run(<String>[
'upgrade',
'--continue',
'--continue-started-at',
DateTime.now().toIso8601String(),
]);
expect(
json.decode(flutterToolState.readAsStringSync()),