From f4546c0791413348e539f62330c99dab16eee2e7 Mon Sep 17 00:00:00 2001 From: stuartmorgan Date: Sun, 14 Nov 2021 21:26:05 -0500 Subject: [PATCH] [flutter_plugin_tools] Build gtest unit tests (#4492) --- script/tool/CHANGELOG.md | 2 + script/tool/lib/src/common/cmake.dart | 118 ++++++++++++ script/tool/lib/src/common/gradle.dart | 3 - script/tool/lib/src/native_test_command.dart | 69 +++++-- .../tool/test/native_test_command_test.dart | 172 +++++++++++++++--- 5 files changed, 324 insertions(+), 40 deletions(-) create mode 100644 script/tool/lib/src/common/cmake.dart diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 5a037c89c6..31efc28aa3 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,5 +1,7 @@ ## NEXT +- `native-test` now builds unit tests before running them on Windows and Linux, + matching the behavior of other platforms. - Added `--log-timing` to add timing information to package headers in looping commands. diff --git a/script/tool/lib/src/common/cmake.dart b/script/tool/lib/src/common/cmake.dart new file mode 100644 index 0000000000..04ad880292 --- /dev/null +++ b/script/tool/lib/src/common/cmake.dart @@ -0,0 +1,118 @@ +// 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 'package:file/file.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:platform/platform.dart'; + +import 'process_runner.dart'; + +const String _cacheCommandKey = 'CMAKE_COMMAND:INTERNAL'; + +/// A utility class for interacting with CMake projects. +class CMakeProject { + /// Creates an instance that runs commands for [project] with the given + /// [processRunner]. + CMakeProject( + this.flutterProject, { + required this.buildMode, + this.processRunner = const ProcessRunner(), + this.platform = const LocalPlatform(), + }); + + /// The directory of a Flutter project to run Gradle commands in. + final Directory flutterProject; + + /// The [ProcessRunner] used to run commands. Overridable for testing. + final ProcessRunner processRunner; + + /// The platform that commands are being run on. + final Platform platform; + + /// The build mode (e.g., Debug, Release). + /// + /// This is a constructor paramater because on Linux many properties depend + /// on the build mode since it uses a single-configuration generator. + final String buildMode; + + late final String _cmakeCommand = _determineCmakeCommand(); + + /// The project's platform directory name. + String get _platformDirName => platform.isWindows ? 'windows' : 'linux'; + + /// The project's 'example' build directory for this instance's platform. + Directory get buildDirectory { + Directory buildDir = + flutterProject.childDirectory('build').childDirectory(_platformDirName); + if (platform.isLinux) { + buildDir = buildDir + // TODO(stuartmorgan): Support arm64 if that ever becomes a supported + // CI configuration for the repository. + .childDirectory('x64') + // Linux uses a single-config generator, so the base build directory + // includes the configuration. + .childDirectory(buildMode.toLowerCase()); + } + return buildDir; + } + + File get _cacheFile => buildDirectory.childFile('CMakeCache.txt'); + + /// Returns the CMake command to run build commands for this project. + /// + /// Assumes the project has been built at least once, such that the CMake + /// generation step has run. + String getCmakeCommand() { + return _cmakeCommand; + } + + /// Returns the CMake command to run build commands for this project. This is + /// used to initialize _cmakeCommand, and should not be called directly. + /// + /// Assumes the project has been built at least once, such that the CMake + /// generation step has run. + String _determineCmakeCommand() { + // On Linux 'cmake' is expected to be in the path, so doesn't need to + // be lookup up and cached. + if (platform.isLinux) { + return 'cmake'; + } + final File cacheFile = _cacheFile; + String? command; + for (String line in cacheFile.readAsLinesSync()) { + line = line.trim(); + if (line.startsWith(_cacheCommandKey)) { + command = line.substring(line.indexOf('=') + 1).trim(); + break; + } + } + if (command == null) { + printError('Unable to find CMake command in ${cacheFile.path}'); + throw ToolExit(100); + } + return command; + } + + /// Whether or not the project is ready to have CMake commands run on it + /// (i.e., whether the `flutter` tool has generated the necessary files). + bool isConfigured() => _cacheFile.existsSync(); + + /// Runs a `cmake` command with the given parameters. + Future runBuild( + String target, { + List arguments = const [], + }) { + return processRunner.runAndStream( + getCmakeCommand(), + [ + '--build', + buildDirectory.path, + '--target', + target, + if (platform.isWindows) ...['--config', buildMode], + ...arguments, + ], + ); + } +} diff --git a/script/tool/lib/src/common/gradle.dart b/script/tool/lib/src/common/gradle.dart index e7214bf297..9da4e89811 100644 --- a/script/tool/lib/src/common/gradle.dart +++ b/script/tool/lib/src/common/gradle.dart @@ -14,9 +14,6 @@ const String _gradleWrapperNonWindows = 'gradlew'; class GradleProject { /// Creates an instance that runs commands for [project] with the given /// [processRunner]. - /// - /// If [log] is true, commands run by this instance will long various status - /// messages. GradleProject( this.flutterProject, { this.processRunner = const ProcessRunner(), diff --git a/script/tool/lib/src/native_test_command.dart b/script/tool/lib/src/native_test_command.dart index 4911b4aeb1..0b0dd26ba2 100644 --- a/script/tool/lib/src/native_test_command.dart +++ b/script/tool/lib/src/native_test_command.dart @@ -5,6 +5,7 @@ import 'package:file/file.dart'; import 'package:platform/platform.dart'; +import 'common/cmake.dart'; import 'common/core.dart'; import 'common/gradle.dart'; import 'common/package_looping_command.dart'; @@ -456,8 +457,8 @@ this command. file.basename.endsWith('_tests.exe'); } - return _runGoogleTestTests(plugin, - buildDirectoryName: 'windows', isTestBinary: isTestBinary); + return _runGoogleTestTests(plugin, 'Windows', 'Debug', + isTestBinary: isTestBinary); } Future<_PlatformResult> _testLinux( @@ -471,8 +472,16 @@ this command. file.basename.endsWith('_tests'); } - return _runGoogleTestTests(plugin, - buildDirectoryName: 'linux', isTestBinary: isTestBinary); + // Since Linux uses a single-config generator, building-examples only + // generates the build files for release, so the tests have to be run in + // release mode as well. + // + // TODO(stuartmorgan): Consider adding a command to `flutter` that would + // generate build files without doing a build, and using that instead of + // relying on running build-examples. See + // https://github.com/flutter/flutter/issues/93407. + return _runGoogleTestTests(plugin, 'Linux', 'Release', + isTestBinary: isTestBinary); } /// Finds every file in the [buildDirectoryName] subdirectory of [plugin]'s @@ -482,38 +491,66 @@ this command. /// The binaries are assumed to be Google Test test binaries, thus returning /// zero for success and non-zero for failure. Future<_PlatformResult> _runGoogleTestTests( - RepositoryPackage plugin, { - required String buildDirectoryName, + RepositoryPackage plugin, + String platformName, + String buildMode, { required bool Function(File) isTestBinary, }) async { final List testBinaries = []; + bool hasMissingBuild = false; + bool buildFailed = false; for (final RepositoryPackage example in plugin.getExamples()) { - final Directory buildDir = example.directory - .childDirectory('build') - .childDirectory(buildDirectoryName); - if (!buildDir.existsSync()) { + final CMakeProject project = CMakeProject(example.directory, + buildMode: buildMode, + processRunner: processRunner, + platform: platform); + if (!project.isConfigured()) { + printError('ERROR: Run "flutter build" on ${example.displayName}, ' + 'or run this tool\'s "build-examples" command, for the target ' + 'platform before executing tests.'); + hasMissingBuild = true; continue; } - testBinaries.addAll(buildDir + + // By repository convention, example projects create an aggregate target + // called 'unit_tests' that builds all unit tests (usually just an alias + // for a specific test target). + final int exitCode = await project.runBuild('unit_tests'); + if (exitCode != 0) { + printError('${example.displayName} unit tests failed to build.'); + buildFailed = true; + } + + testBinaries.addAll(project.buildDirectory .listSync(recursive: true) .whereType() .where(isTestBinary) .where((File file) { - // Only run the release build of the unit tests, to avoid running the - // same tests multiple times. Release is used rather than debug since - // `build-examples` builds release versions. + // Only run the `buildMode` build of the unit tests, to avoid running + // the same tests multiple times. final List components = path.split(file.path); - return components.contains('release') || components.contains('Release'); + return components.contains(buildMode) || + components.contains(buildMode.toLowerCase()); })); } + if (hasMissingBuild) { + return _PlatformResult(RunState.failed, + error: 'Examples must be built before testing.'); + } + + if (buildFailed) { + return _PlatformResult(RunState.failed, + error: 'Failed to build $platformName unit tests.'); + } + if (testBinaries.isEmpty) { final String binaryExtension = platform.isWindows ? '.exe' : ''; printError( 'No test binaries found. At least one *_test(s)$binaryExtension ' 'binary should be built by the example(s)'); return _PlatformResult(RunState.failed, - error: 'No $buildDirectoryName unit tests found'); + error: 'No $platformName unit tests found'); } bool passing = true; diff --git a/script/tool/test/native_test_command_test.dart b/script/tool/test/native_test_command_test.dart index ba93efcb3a..697cbd4b84 100644 --- a/script/tool/test/native_test_command_test.dart +++ b/script/tool/test/native_test_command_test.dart @@ -8,10 +8,12 @@ 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/cmake.dart'; import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/common/file_utils.dart'; import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; import 'package:flutter_plugin_tools/src/native_test_command.dart'; +import 'package:platform/platform.dart'; import 'package:test/test.dart'; import 'mocks.dart'; @@ -53,6 +55,16 @@ final Map _kDeviceListMap = { } }; +const String _fakeCmakeCommand = 'path/to/cmake'; + +void _createFakeCMakeCache(Directory pluginDir, Platform platform) { + final CMakeProject project = CMakeProject(pluginDir.childDirectory('example'), + platform: platform, buildMode: 'Release'); + final File cache = project.buildDirectory.childFile('CMakeCache.txt'); + cache.createSync(recursive: true); + cache.writeAsStringSync('CMAKE_COMMAND:INTERNAL=$_fakeCmakeCommand'); +} + // TODO(stuartmorgan): Rework these tests to use a mock Xcode instead of // doing all the process mocking and validation. void main() { @@ -67,7 +79,10 @@ void main() { setUp(() { fileSystem = MemoryFileSystem(); - mockPlatform = MockPlatform(isMacOS: true); + // iOS and macOS tests expect macOS, Linux tests expect Linux; nothing + // needs to distinguish between Linux and macOS, so set both to true to + // allow them to share a setup group. + mockPlatform = MockPlatform(isMacOS: true, isLinux: true); packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = RecordingProcessRunner(); final NativeTestCommand command = NativeTestCommand(packagesDir, @@ -133,6 +148,26 @@ void main() { package.path); } + // Returns the ProcessCall to expect for build the Linux unit tests for the + // given plugin. + ProcessCall _getLinuxBuildCall(Directory pluginDir) { + return ProcessCall( + 'cmake', + [ + '--build', + pluginDir + .childDirectory('example') + .childDirectory('build') + .childDirectory('linux') + .childDirectory('x64') + .childDirectory('release') + .path, + '--target', + 'unit_tests' + ], + null); + } + test('fails if no platforms are provided', () async { Error? commandError; final List output = await runCapturingPrint( @@ -844,15 +879,16 @@ void main() { }); group('Linux', () { - test('runs unit tests', () async { + test('builds and runs unit tests', () async { const String testBinaryRelativePath = - 'build/linux/foo/release/bar/plugin_test'; + 'build/linux/x64/release/bar/plugin_test'; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/$testBinaryRelativePath' ], platformSupport: { kPlatformLinux: const PlatformDetails(PlatformSupport.inline), }); + _createFakeCMakeCache(pluginDirectory, mockPlatform); final File testBinary = childFileWithSubcomponents(pluginDirectory, ['example', ...testBinaryRelativePath.split('/')]); @@ -874,15 +910,16 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ + _getLinuxBuildCall(pluginDirectory), ProcessCall(testBinary.path, const [], null), ])); }); test('only runs release unit tests', () async { const String debugTestBinaryRelativePath = - 'build/linux/foo/debug/bar/plugin_test'; + 'build/linux/x64/debug/bar/plugin_test'; const String releaseTestBinaryRelativePath = - 'build/linux/foo/release/bar/plugin_test'; + 'build/linux/x64/release/bar/plugin_test'; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/$debugTestBinaryRelativePath', @@ -890,6 +927,7 @@ void main() { ], platformSupport: { kPlatformLinux: const PlatformDetails(PlatformSupport.inline), }); + _createFakeCMakeCache(pluginDirectory, mockPlatform); final File releaseTestBinary = childFileWithSubcomponents( pluginDirectory, @@ -909,15 +947,15 @@ void main() { ]), ); - // Only the release version should be run. expect( processRunner.recordedCalls, orderedEquals([ + _getLinuxBuildCall(pluginDirectory), ProcessCall(releaseTestBinary.path, const [], null), ])); }); - test('fails if there are no unit tests', () async { + test('fails if CMake has not been configured', () async { createFakePlugin('plugin', packagesDir, platformSupport: { kPlatformLinux: const PlatformDetails(PlatformSupport.inline), @@ -936,22 +974,56 @@ void main() { expect( output, containsAllInOrder([ - contains('No test binaries found.'), + contains('plugin:\n' + ' Examples must be built before testing.') ]), ); expect(processRunner.recordedCalls, orderedEquals([])); }); + test('fails if there are no unit tests', () async { + final Directory pluginDirectory = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformLinux: const PlatformDetails(PlatformSupport.inline), + }); + _createFakeCMakeCache(pluginDirectory, mockPlatform); + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--linux', + '--no-integration', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('No test binaries found.'), + ]), + ); + + expect( + processRunner.recordedCalls, + orderedEquals([ + _getLinuxBuildCall(pluginDirectory), + ])); + }); + test('fails if a unit test fails', () async { const String testBinaryRelativePath = - 'build/linux/foo/release/bar/plugin_test'; + 'build/linux/x64/release/bar/plugin_test'; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/$testBinaryRelativePath' ], platformSupport: { kPlatformLinux: const PlatformDetails(PlatformSupport.inline), }); + _createFakeCMakeCache(pluginDirectory, mockPlatform); final File testBinary = childFileWithSubcomponents(pluginDirectory, ['example', ...testBinaryRelativePath.split('/')]); @@ -979,6 +1051,7 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ + _getLinuxBuildCall(pluginDirectory), ProcessCall(testBinary.path, const [], null), ])); }); @@ -1524,16 +1597,37 @@ void main() { runner.addCommand(command); }); + // Returns the ProcessCall to expect for build the Windows unit tests for + // the given plugin. + ProcessCall _getWindowsBuildCall(Directory pluginDir) { + return ProcessCall( + _fakeCmakeCommand, + [ + '--build', + pluginDir + .childDirectory('example') + .childDirectory('build') + .childDirectory('windows') + .path, + '--target', + 'unit_tests', + '--config', + 'Debug' + ], + null); + } + group('Windows', () { test('runs unit tests', () async { const String testBinaryRelativePath = - 'build/windows/foo/Release/bar/plugin_test.exe'; + 'build/windows/Debug/bar/plugin_test.exe'; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/$testBinaryRelativePath' ], platformSupport: { kPlatformWindows: const PlatformDetails(PlatformSupport.inline), }); + _createFakeCMakeCache(pluginDirectory, mockPlatform); final File testBinary = childFileWithSubcomponents(pluginDirectory, ['example', ...testBinaryRelativePath.split('/')]); @@ -1555,15 +1649,16 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ + _getWindowsBuildCall(pluginDirectory), ProcessCall(testBinary.path, const [], null), ])); }); - test('only runs release unit tests', () async { + test('only runs debug unit tests', () async { const String debugTestBinaryRelativePath = - 'build/windows/foo/Debug/bar/plugin_test.exe'; + 'build/windows/Debug/bar/plugin_test.exe'; const String releaseTestBinaryRelativePath = - 'build/windows/foo/Release/bar/plugin_test.exe'; + 'build/windows/Release/bar/plugin_test.exe'; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/$debugTestBinaryRelativePath', @@ -1571,10 +1666,10 @@ void main() { ], platformSupport: { kPlatformWindows: const PlatformDetails(PlatformSupport.inline), }); + _createFakeCMakeCache(pluginDirectory, mockPlatform); - final File releaseTestBinary = childFileWithSubcomponents( - pluginDirectory, - ['example', ...releaseTestBinaryRelativePath.split('/')]); + final File debugTestBinary = childFileWithSubcomponents(pluginDirectory, + ['example', ...debugTestBinaryRelativePath.split('/')]); final List output = await runCapturingPrint(runner, [ 'native-test', @@ -1590,15 +1685,15 @@ void main() { ]), ); - // Only the release version should be run. expect( processRunner.recordedCalls, orderedEquals([ - ProcessCall(releaseTestBinary.path, const [], null), + _getWindowsBuildCall(pluginDirectory), + ProcessCall(debugTestBinary.path, const [], null), ])); }); - test('fails if there are no unit tests', () async { + test('fails if CMake has not been configured', () async { createFakePlugin('plugin', packagesDir, platformSupport: { kPlatformWindows: const PlatformDetails(PlatformSupport.inline), @@ -1617,22 +1712,56 @@ void main() { expect( output, containsAllInOrder([ - contains('No test binaries found.'), + contains('plugin:\n' + ' Examples must be built before testing.') ]), ); expect(processRunner.recordedCalls, orderedEquals([])); }); + test('fails if there are no unit tests', () async { + final Directory pluginDirectory = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformWindows: const PlatformDetails(PlatformSupport.inline), + }); + _createFakeCMakeCache(pluginDirectory, mockPlatform); + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--windows', + '--no-integration', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('No test binaries found.'), + ]), + ); + + expect( + processRunner.recordedCalls, + orderedEquals([ + _getWindowsBuildCall(pluginDirectory), + ])); + }); + test('fails if a unit test fails', () async { const String testBinaryRelativePath = - 'build/windows/foo/Release/bar/plugin_test.exe'; + 'build/windows/Debug/bar/plugin_test.exe'; final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, extraFiles: [ 'example/$testBinaryRelativePath' ], platformSupport: { kPlatformWindows: const PlatformDetails(PlatformSupport.inline), }); + _createFakeCMakeCache(pluginDirectory, mockPlatform); final File testBinary = childFileWithSubcomponents(pluginDirectory, ['example', ...testBinaryRelativePath.split('/')]); @@ -1660,6 +1789,7 @@ void main() { expect( processRunner.recordedCalls, orderedEquals([ + _getWindowsBuildCall(pluginDirectory), ProcessCall(testBinary.path, const [], null), ])); });