diff --git a/tool/lib/commands/presubmit.dart b/tool/lib/commands/presubmit.dart new file mode 100644 index 00000000000..c0518035d4f --- /dev/null +++ b/tool/lib/commands/presubmit.dart @@ -0,0 +1,156 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:async'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:cli_util/cli_logging.dart'; +import 'package:io/io.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as path; + +import '../model.dart'; +import '../utils.dart'; + +class PresubmitCommand extends Command { + PresubmitCommand({@visibleForTesting this.processManager}) { + argParser.addFlag( + 'fix', + help: 'Apply dart fixes and formatting.', + defaultsTo: false, + negatable: false, + ); + } + + ProcessManager? processManager; + + @override + String get name => 'presubmit'; + + @override + String get description => + 'Run repo checks, analysis, fix, and format on all packages.'; + + @override + Future run() async { + final log = Logger.standard(); + final repo = DevToolsRepo.getInstance(); + final pm = processManager ?? ProcessManager(); + final fix = argResults!['fix'] as bool; + + log.stdout('Running pub get...'); + final pubGetResult = await runner?.run(['pub-get']); + if (pubGetResult is int && pubGetResult != 0) { + log.stderr('Pub get failed. Exiting early.'); + return 1; + } + + final packages = repo.getPackages(includeSubdirectories: false); + int failureCount = 0; + + if (fix) { + log.stdout('Running Dart Fix and Format...'); + for (final p in packages) { + if (!p.hasAnyDartCode) continue; + + final progress = log.progress(' ${p.relativePath}'); + + final fixProcess = await pm.runProcess( + CliCommand.dart(['fix', '--apply'], throwOnException: false), + workingDirectory: p.packagePath, + ); + + final pathsToFormat = _getPathsToFormat(p); + + final formatProcess = await pm.runProcess( + CliCommand.dart(['format', ...pathsToFormat], throwOnException: false), + workingDirectory: p.packagePath, + ); + + if (fixProcess.exitCode == 0 && formatProcess.exitCode == 0) { + progress.finish(showTiming: true); + } else { + failureCount++; + progress.finish(message: 'failed'); + } + } + + if (failureCount > 0) { + log.stderr('Presubmit failed.'); + log.stderr(' Fix or Format failed on $failureCount packages.'); + return 1; + } + } + + log.stdout('Running Repo Check...'); + final repoCheckResult = await runner?.run(['repo-check']); + if (repoCheckResult is int && repoCheckResult != 0) { + log.stderr('Repo checks failed. Exiting early.'); + return 1; + } + + log.stdout('Running Analyze...'); + final analyzeResult = await runner?.run(['analyze']); + if (analyzeResult is int && analyzeResult != 0) { + log.stderr('Analysis failed. Exiting early.'); + return 1; + } + + if (!fix) { + log.stdout('Running Dart Format Check...'); + for (final p in packages) { + if (!p.hasAnyDartCode) continue; + + final progress = log.progress(' ${p.relativePath}'); + + final pathsToFormat = _getPathsToFormat(p); + + final formatProcess = await pm.runProcess( + CliCommand.dart( + ['format', '--output=none', '--set-exit-if-changed', ...pathsToFormat], + throwOnException: false, + ), + workingDirectory: p.packagePath, + ); + + if (formatProcess.exitCode == 0) { + progress.finish(showTiming: true); + } else { + failureCount++; + progress.finish(message: 'failed'); + } + } + + if (failureCount > 0) { + log.stderr('Presubmit failed.'); + log.stderr(' Formatting issues found in $failureCount packages.'); + return 1; + } + } + + log.stdout('Presubmit passed!'); + return 0; + } + + List _getPathsToFormat(Package p) { + final pathsToFormat = []; + if (p.relativePath == 'tool') { + final children = Directory(p.packagePath).listSync(); + for (final entity in children) { + final name = path.basename(entity.path); + if (name.startsWith('.')) continue; + if (name == 'flutter-sdk') continue; + if (entity is Directory) { + pathsToFormat.add(name); + } else if (entity is File && name.endsWith('.dart')) { + pathsToFormat.add(name); + } + } + } else { + pathsToFormat.add('.'); + } + return pathsToFormat; + } +} diff --git a/tool/lib/devtools_command_runner.dart b/tool/lib/devtools_command_runner.dart index 05f8243524a..7d595447368 100644 --- a/tool/lib/devtools_command_runner.dart +++ b/tool/lib/devtools_command_runner.dart @@ -18,6 +18,7 @@ import 'package:devtools_tool/model.dart'; import 'commands/analyze.dart'; import 'commands/list.dart'; +import 'commands/presubmit.dart'; import 'commands/pub_get.dart'; import 'commands/release_helper.dart'; import 'commands/repo_check.dart'; @@ -37,6 +38,7 @@ class DevToolsCommandRunner extends CommandRunner { addCommand(FixGoldensCommand()); addCommand(GenerateCodeCommand()); addCommand(ListCommand()); + addCommand(PresubmitCommand()); addCommand(PubGetCommand()); addCommand(ReleaseHelperCommand()); addCommand(ReleaseNotesCommand()); diff --git a/tool/pubspec.yaml b/tool/pubspec.yaml index e93e984aa5c..90ccf119490 100644 --- a/tool/pubspec.yaml +++ b/tool/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: cli_util: ^0.4.1 collection: ^1.19.0 io: ^1.0.4 + meta: ^1.18.0 path: ^1.9.0 yaml: ^3.1.2 diff --git a/tool/test/command_test_utils.dart b/tool/test/command_test_utils.dart new file mode 100644 index 00000000000..fd9a362ab9c --- /dev/null +++ b/tool/test/command_test_utils.dart @@ -0,0 +1,128 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:io/io.dart'; + +class MockProcessManager implements ProcessManager { + MockProcessManager({this.onSpawn}); + + final Future Function( + String executable, + Iterable arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment, + bool runInShell, + ProcessStartMode mode, + })? + onSpawn; + + @override + Future spawn( + String executable, + Iterable arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async { + if (onSpawn != null) { + return onSpawn!( + executable, + arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + mode: mode, + ); + } + return MockProcess(); + } + + @override + Future spawnBackground( + String executable, + Iterable arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async { + throw UnimplementedError(); + } + + @override + Future spawnDetached( + String executable, + Iterable arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async { + throw UnimplementedError(); + } +} + +class MockProcess implements Process { + MockProcess({ + this.exitCodeValue = 0, + this.stdoutString = '', + this.stderrString = '', + }); + + final int exitCodeValue; + final String stdoutString; + final String stderrString; + + @override + Future get exitCode => Future.value(exitCodeValue); + + @override + Stream> get stdout => Stream.value(utf8.encode(stdoutString)); + + @override + Stream> get stderr => Stream.value(utf8.encode(stderrString)); + + @override + bool kill([ProcessSignal signal = ProcessSignal.sigterm]) => true; + + @override + int get pid => 0; + + @override + IOSink get stdin => throw UnimplementedError(); +} + +class TestCommandRunner extends CommandRunner { + TestCommandRunner() : super('test', 'test description'); + + void addDummyCommand(String name, [int exitCode = 0]) { + addCommand(DummyCommand(name, exitCode)); + } +} + +class DummyCommand extends Command { + DummyCommand(this.name, this.exitCodeValue); + + @override + final String name; + + @override + String get description => 'Dummy command for testing'; + + final int exitCodeValue; + + @override + Future run() async => exitCodeValue; +} diff --git a/tool/test/presubmit_test.dart b/tool/test/presubmit_test.dart new file mode 100644 index 00000000000..fff08138b20 --- /dev/null +++ b/tool/test/presubmit_test.dart @@ -0,0 +1,176 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:io'; + +import 'package:devtools_tool/commands/presubmit.dart'; +import 'package:devtools_tool/model.dart'; +import 'package:test/test.dart'; + +import 'command_test_utils.dart'; + +void main() { + group('PresubmitCommand', () { + setUp(() { + try { + FlutterSdk.useFromCurrentVm(); + } catch (_) { + FlutterSdk.useFromPathEnvironmentVariable(); + } + }); + + test('succeeds when all steps pass', () async { + final runner = TestCommandRunner(); + runner.addDummyCommand('pub-get'); + runner.addDummyCommand('repo-check'); + runner.addDummyCommand('analyze'); + runner.addCommand(PresubmitCommand(processManager: MockProcessManager())); + + final result = await runner.run(['presubmit']); + expect(result, equals(0)); + }); + + test('runs fix and format when --fix is passed', () async { + final runner = TestCommandRunner(); + runner.addDummyCommand('pub-get'); + runner.addDummyCommand('repo-check'); + runner.addDummyCommand('analyze'); + + final capturedArgs = >[]; + final mockPm = MockProcessManager( + onSpawn: ( + executable, + arguments, { + workingDirectory, + environment, + includeParentEnvironment = true, + runInShell = false, + mode = ProcessStartMode.normal, + }) async { + capturedArgs.add(arguments.toList()); + return MockProcess(); + }, + ); + + runner.addCommand(PresubmitCommand(processManager: mockPm)); + + final result = await runner.run(['presubmit', '--fix']); + expect(result, equals(0)); + + final hasFix = capturedArgs.any((args) => args.contains('fix')); + expect(hasFix, isTrue); + + final formatArgs = capturedArgs.firstWhere( + (args) => args.contains('format'), + orElse: () => [], + ); + expect(formatArgs, isNotEmpty); + expect(formatArgs.contains('--output=none'), isFalse); + expect(formatArgs.contains('--set-exit-if-changed'), isFalse); + }); + + test('fails fast if pub-get fails', () async { + final runner = TestCommandRunner(); + runner.addDummyCommand('pub-get', 1); // fails + runner.addDummyCommand('repo-check'); + runner.addDummyCommand('analyze'); + runner.addCommand(PresubmitCommand(processManager: MockProcessManager())); + + final result = await runner.run(['presubmit']); + expect(result, equals(1)); + }); + + test('fails fast if repo-check fails', () async { + final runner = TestCommandRunner(); + runner.addDummyCommand('pub-get'); + runner.addDummyCommand('repo-check', 1); // fails + runner.addDummyCommand('analyze'); + runner.addCommand(PresubmitCommand(processManager: MockProcessManager())); + + final result = await runner.run(['presubmit']); + expect(result, equals(1)); + }); + + test('fails fast if analyze fails', () async { + final runner = TestCommandRunner(); + runner.addDummyCommand('pub-get'); + runner.addDummyCommand('repo-check'); + runner.addDummyCommand('analyze', 1); // fails + runner.addCommand(PresubmitCommand(processManager: MockProcessManager())); + + final result = await runner.run(['presubmit']); + expect(result, equals(1)); + }); + + test('fails if dart format check fails without --fix', () async { + final runner = TestCommandRunner(); + runner.addDummyCommand('pub-get'); + runner.addDummyCommand('repo-check'); + runner.addDummyCommand('analyze'); + + final mockPm = MockProcessManager( + onSpawn: ( + executable, + arguments, { + workingDirectory, + environment, + includeParentEnvironment = true, + runInShell = false, + mode = ProcessStartMode.normal, + }) async { + if (arguments.contains('format') && + arguments.contains('--set-exit-if-changed')) { + return MockProcess(exitCodeValue: 1); + } + return MockProcess(); + }, + ); + + runner.addCommand(PresubmitCommand(processManager: mockPm)); + + final result = await runner.run(['presubmit']); + expect(result, equals(1)); + }); + + test('filters files for tool package formatting', () async { + final runner = TestCommandRunner(); + runner.addDummyCommand('pub-get'); + runner.addDummyCommand('repo-check'); + runner.addDummyCommand('analyze'); + + final capturedArgs = >[]; + final mockPm = MockProcessManager( + onSpawn: ( + executable, + arguments, { + workingDirectory, + environment, + includeParentEnvironment = true, + runInShell = false, + mode = ProcessStartMode.normal, + }) async { + capturedArgs.add(arguments.toList()); + return MockProcess(); + }, + ); + + runner.addCommand(PresubmitCommand(processManager: mockPm)); + + await runner.run(['presubmit']); + + // Find the format command for the tool package. + // It should contain 'lib' (since 'lib' is one of its children) and NOT + // '.' (which is used for other packages). Or it should contain multiple + // paths. + final toolFormatArgs = capturedArgs.firstWhere( + // 'dart', 'format', and at least two paths + (args) => args.contains('format') && args.length > 3, + orElse: () => [], + ); + + expect(toolFormatArgs, isNotEmpty); + expect(toolFormatArgs.contains('flutter-sdk'), isFalse); + }); + }); +}