// Copyright 2013 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/format_command.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; import 'mocks.dart'; import 'util.dart'; void main() { late FileSystem fileSystem; late MockPlatform mockPlatform; late Directory packagesDir; late RecordingProcessRunner processRunner; late FormatCommand analyzeCommand; late CommandRunner runner; late String javaFormatPath; setUp(() { fileSystem = MemoryFileSystem(); mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = RecordingProcessRunner(); analyzeCommand = FormatCommand( packagesDir, processRunner: processRunner, platform: mockPlatform, ); // Create the java formatter file that the command checks for, to avoid // a download. final p.Context path = analyzeCommand.path; javaFormatPath = path.join(path.dirname(path.fromUri(mockPlatform.script)), 'google-java-format-1.3-all-deps.jar'); fileSystem.file(javaFormatPath).createSync(recursive: true); runner = CommandRunner('format_command', 'Test for format_command'); runner.addCommand(analyzeCommand); }); /// Returns a modified version of a list of [relativePaths] that are relative /// to [package] to instead be relative to [packagesDir]. List _getPackagesDirRelativePaths( Directory package, List relativePaths) { final p.Context path = analyzeCommand.path; final String relativeBase = path.relative(package.path, from: packagesDir.path); return relativePaths .map((String relativePath) => path.join(relativeBase, relativePath)) .toList(); } /// Returns a list of [count] relative paths to pass to [createFakePlugin] /// with name [pluginName] such that each path will be 99 characters long /// relative to [packagesDir]. /// /// This is for each of testing batching, since it means each file will /// consume 100 characters of the batch length. List _get99CharacterPathExtraFiles(String pluginName, int count) { final int padding = 99 - pluginName.length - 1 - // the path separator after the plugin name 1 - // the path separator after the padding 10; // the file name const int filenameBase = 10000; final p.Context path = analyzeCommand.path; return [ for (int i = filenameBase; i < filenameBase + count; ++i) path.join('a' * padding, '$i.dart'), ]; } test('formats .dart files', () async { const List files = [ 'lib/a.dart', 'lib/src/b.dart', 'lib/src/c.dart', ]; final Directory pluginDir = createFakePlugin( 'a_plugin', packagesDir, extraFiles: files, ); await runCapturingPrint(runner, ['format']); expect( processRunner.recordedCalls, orderedEquals([ ProcessCall( getFlutterCommand(mockPlatform), [ 'format', ..._getPackagesDirRelativePaths(pluginDir, files) ], packagesDir.path), ])); }); test('fails if flutter format fails', () async { const List files = [ 'lib/a.dart', 'lib/src/b.dart', 'lib/src/c.dart', ]; createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = [MockProcess.failing()]; Error? commandError; final List output = await runCapturingPrint( runner, ['format'], errorHandler: (Error e) { commandError = e; }); expect(commandError, isA()); expect( output, containsAllInOrder([ contains('Failed to format Dart files: exit code 1.'), ])); }); test('formats .java files', () async { const List files = [ 'android/src/main/java/io/flutter/plugins/a_plugin/a.java', 'android/src/main/java/io/flutter/plugins/a_plugin/b.java', ]; final Directory pluginDir = createFakePlugin( 'a_plugin', packagesDir, extraFiles: files, ); await runCapturingPrint(runner, ['format']); expect( processRunner.recordedCalls, orderedEquals([ const ProcessCall('java', ['--version'], null), ProcessCall( 'java', [ '-jar', javaFormatPath, '--replace', ..._getPackagesDirRelativePaths(pluginDir, files) ], packagesDir.path), ])); }); test('fails with a clear message if Java is not in the path', () async { const List files = [ 'android/src/main/java/io/flutter/plugins/a_plugin/a.java', 'android/src/main/java/io/flutter/plugins/a_plugin/b.java', ]; createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable['java'] = [ MockProcess.failing() ]; Error? commandError; final List output = await runCapturingPrint( runner, ['format'], errorHandler: (Error e) { commandError = e; }); expect(commandError, isA()); expect( output, containsAllInOrder([ contains( 'Unable to run \'java\'. Make sure that it is in your path, or ' 'provide a full path with --java.'), ])); }); test('fails if Java formatter fails', () async { const List files = [ 'android/src/main/java/io/flutter/plugins/a_plugin/a.java', 'android/src/main/java/io/flutter/plugins/a_plugin/b.java', ]; createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable['java'] = [ MockProcess.succeeding(), // check for working java MockProcess.failing(), // format ]; Error? commandError; final List output = await runCapturingPrint( runner, ['format'], errorHandler: (Error e) { commandError = e; }); expect(commandError, isA()); expect( output, containsAllInOrder([ contains('Failed to format Java files: exit code 1.'), ])); }); test('honors --java flag', () async { const List files = [ 'android/src/main/java/io/flutter/plugins/a_plugin/a.java', 'android/src/main/java/io/flutter/plugins/a_plugin/b.java', ]; final Directory pluginDir = createFakePlugin( 'a_plugin', packagesDir, extraFiles: files, ); await runCapturingPrint(runner, ['format', '--java=/path/to/java']); expect( processRunner.recordedCalls, orderedEquals([ const ProcessCall('/path/to/java', ['--version'], null), ProcessCall( '/path/to/java', [ '-jar', javaFormatPath, '--replace', ..._getPackagesDirRelativePaths(pluginDir, files) ], packagesDir.path), ])); }); test('formats c-ish files', () async { const List files = [ 'ios/Classes/Foo.h', 'ios/Classes/Foo.m', 'linux/foo_plugin.cc', 'macos/Classes/Foo.h', 'macos/Classes/Foo.mm', 'windows/foo_plugin.cpp', ]; final Directory pluginDir = createFakePlugin( 'a_plugin', packagesDir, extraFiles: files, ); await runCapturingPrint(runner, ['format']); expect( processRunner.recordedCalls, orderedEquals([ const ProcessCall('clang-format', ['--version'], null), ProcessCall( 'clang-format', [ '-i', '--style=Google', ..._getPackagesDirRelativePaths(pluginDir, files) ], packagesDir.path), ])); }); test('fails with a clear message if clang-format is not in the path', () async { const List files = [ 'linux/foo_plugin.cc', 'macos/Classes/Foo.h', ]; createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable['clang-format'] = [ MockProcess.failing() ]; Error? commandError; final List output = await runCapturingPrint( runner, ['format'], errorHandler: (Error e) { commandError = e; }); expect(commandError, isA()); expect( output, containsAllInOrder([ contains( 'Unable to run \'clang-format\'. Make sure that it is in your ' 'path, or provide a full path with --clang-format.'), ])); }); test('honors --clang-format flag', () async { const List files = [ 'windows/foo_plugin.cpp', ]; final Directory pluginDir = createFakePlugin( 'a_plugin', packagesDir, extraFiles: files, ); await runCapturingPrint( runner, ['format', '--clang-format=/path/to/clang-format']); expect( processRunner.recordedCalls, orderedEquals([ const ProcessCall( '/path/to/clang-format', ['--version'], null), ProcessCall( '/path/to/clang-format', [ '-i', '--style=Google', ..._getPackagesDirRelativePaths(pluginDir, files) ], packagesDir.path), ])); }); test('fails if clang-format fails', () async { const List files = [ 'linux/foo_plugin.cc', 'macos/Classes/Foo.h', ]; createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable['clang-format'] = [ MockProcess.succeeding(), // check for working clang-format MockProcess.failing(), // format ]; Error? commandError; final List output = await runCapturingPrint( runner, ['format'], errorHandler: (Error e) { commandError = e; }); expect(commandError, isA()); expect( output, containsAllInOrder([ contains( 'Failed to format C, C++, and Objective-C files: exit code 1.'), ])); }); test('skips known non-repo files', () async { const List skipFiles = [ '/example/build/SomeFramework.framework/Headers/SomeFramework.h', '/example/Pods/APod.framework/Headers/APod.h', '.dart_tool/internals/foo.cc', '.dart_tool/internals/Bar.java', '.dart_tool/internals/baz.dart', ]; const List clangFiles = ['ios/Classes/Foo.h']; const List dartFiles = ['lib/a.dart']; const List javaFiles = [ 'android/src/main/java/io/flutter/plugins/a_plugin/a.java' ]; final Directory pluginDir = createFakePlugin( 'a_plugin', packagesDir, extraFiles: [ ...skipFiles, // Include some files that should be formatted to validate that it's // correctly filtering even when running the commands. ...clangFiles, ...dartFiles, ...javaFiles, ], ); await runCapturingPrint(runner, ['format']); expect( processRunner.recordedCalls, containsAll([ ProcessCall( 'clang-format', [ '-i', '--style=Google', ..._getPackagesDirRelativePaths(pluginDir, clangFiles) ], packagesDir.path), ProcessCall( getFlutterCommand(mockPlatform), [ 'format', ..._getPackagesDirRelativePaths(pluginDir, dartFiles) ], packagesDir.path), ProcessCall( 'java', [ '-jar', javaFormatPath, '--replace', ..._getPackagesDirRelativePaths(pluginDir, javaFiles) ], packagesDir.path), ])); }); test('fails if files are changed with --file-on-change', () async { const List files = [ 'linux/foo_plugin.cc', 'macos/Classes/Foo.h', ]; createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable['git'] = [ MockProcess.succeeding(), ]; const String changedFilePath = 'packages/a_plugin/linux/foo_plugin.cc'; processRunner.resultStdout = changedFilePath; Error? commandError; final List output = await runCapturingPrint(runner, ['format', '--fail-on-change'], errorHandler: (Error e) { commandError = e; }); expect(commandError, isA()); expect( output, containsAllInOrder([ contains('These files are not formatted correctly'), contains(changedFilePath), contains('patch -p1 < files = [ 'linux/foo_plugin.cc', 'macos/Classes/Foo.h', ]; createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable['git'] = [ MockProcess.failing() ]; Error? commandError; final List output = await runCapturingPrint(runner, ['format', '--fail-on-change'], errorHandler: (Error e) { commandError = e; }); expect(commandError, isA()); expect( output, containsAllInOrder([ contains('Unable to determine changed files.'), ])); }); test('reports git diff failures', () async { const List files = [ 'linux/foo_plugin.cc', 'macos/Classes/Foo.h', ]; createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable['git'] = [ MockProcess.succeeding(), // ls-files MockProcess.failing(), // diff ]; const String changedFilePath = 'packages/a_plugin/linux/foo_plugin.cc'; processRunner.resultStdout = changedFilePath; Error? commandError; final List output = await runCapturingPrint(runner, ['format', '--fail-on-change'], errorHandler: (Error e) { commandError = e; }); expect(commandError, isA()); expect( output, containsAllInOrder([ contains('These files are not formatted correctly'), contains(changedFilePath), contains('Unable to determine diff.'), ])); }); test('Batches moderately long file lists on Windows', () async { mockPlatform.isWindows = true; const String pluginName = 'a_plugin'; // -1 since the command itself takes some length. const int batchSize = (windowsCommandLineMax ~/ 100) - 1; // Make the file list one file longer than would fit in the batch. final List batch1 = _get99CharacterPathExtraFiles(pluginName, batchSize + 1); final String extraFile = batch1.removeLast(); createFakePlugin( pluginName, packagesDir, extraFiles: [...batch1, extraFile], ); await runCapturingPrint(runner, ['format']); // Ensure that it was batched... expect(processRunner.recordedCalls.length, 2); // ... and that the spillover into the second batch was only one file. expect( processRunner.recordedCalls, contains( ProcessCall( getFlutterCommand(mockPlatform), [ 'format', '$pluginName\\$extraFile', ], packagesDir.path), )); }); // Validates that the Windows limit--which is much lower than the limit on // other platforms--isn't being used on all platforms, as that would make // formatting slower on Linux and macOS. test('Does not batch moderately long file lists on non-Windows', () async { const String pluginName = 'a_plugin'; // -1 since the command itself takes some length. const int batchSize = (windowsCommandLineMax ~/ 100) - 1; // Make the file list one file longer than would fit in a Windows batch. final List batch = _get99CharacterPathExtraFiles(pluginName, batchSize + 1); createFakePlugin( pluginName, packagesDir, extraFiles: batch, ); await runCapturingPrint(runner, ['format']); expect(processRunner.recordedCalls.length, 1); }); test('Batches extremely long file lists on non-Windows', () async { const String pluginName = 'a_plugin'; // -1 since the command itself takes some length. const int batchSize = (nonWindowsCommandLineMax ~/ 100) - 1; // Make the file list one file longer than would fit in the batch. final List batch1 = _get99CharacterPathExtraFiles(pluginName, batchSize + 1); final String extraFile = batch1.removeLast(); createFakePlugin( pluginName, packagesDir, extraFiles: [...batch1, extraFile], ); await runCapturingPrint(runner, ['format']); // Ensure that it was batched... expect(processRunner.recordedCalls.length, 2); // ... and that the spillover into the second batch was only one file. expect( processRunner.recordedCalls, contains( ProcessCall( getFlutterCommand(mockPlatform), [ 'format', '$pluginName/$extraFile', ], packagesDir.path), )); }); }