Build hooks: Don't require toolchain for unit tests (#178954)

To ensure build hooks emit code assets that are compatible with the main
app, Flutter tools pass a `CCompilerConfig` toolchain configuration
object to hooks. This is generally what we want, hooks on macOS should
use the same `clang` from XCode as the one used to compile the app and
native Flutter plugins for instance.

In some cases however, we need to run hooks without necessarily
compiling a full Flutter app with native sources. A good example for
this is `flutter test`, which runs unit / widget tests in a regular Dart
VM without embedding it in a Flutter application. So since `flutter
test` wouldn't invoke a native compiler, running build hooks shouldn't
fail if the expected toolchain is missing.

Currently however, `flutter test` tries to resolve a compiler toolchain
for the host platform. Doing that on Windows already allows not passing
a `CCompilerConfig` if VSCode wasn't found, but on macOS and Linux, this
crashes. This fixes the issue by allowing those methods to return `null`
instead of throwing. They still throw by default, but for the test
target they are configured to not pass a toolchain to hooks if none
could be resolved. This means that hooks not invoking the provided
toolchain (say because they're only downloading native artifacts
instead) would now work, whereas previously `flutter test` would crash
if no toolchian was found.

This closes https://github.com/flutter/flutter/issues/178715 (but only
the part shared in the original issue description, @dcharkes suggested
fixing a similar issue in the same PR but that is _not_ done here).
This commit is contained in:
Simon Binder 2025-11-26 23:58:00 +01:00 committed by GitHub
parent c70e82554a
commit 43d438f2ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 218 additions and 36 deletions

View File

@ -9,10 +9,16 @@ import '../../../base/file_system.dart';
import '../../../base/io.dart';
import '../../../globals.dart' as globals;
/// Flutter expects `clang++` to be on the path on Linux hosts.
/// Returns a [CCompilerConfig] matching a toolchain that would be used to compile the main app with
/// CMake on Linux.
///
/// Search for the accompanying `clang`, `ar`, and `ld`.
Future<CCompilerConfig> cCompilerConfigLinux() async {
/// Flutter expects `clang++` to be on the path on Linux hosts, which this uses to search for the
/// accompanying `clang`, `ar`, and `ld`.
///
/// If [throwIfNotFound] is false, this is allowed to fail (in which case `null`) is returned. This
/// is used for `flutter test` setups, where no main app is compiled and we thus don't want a
/// `clang` toolchain to be a requirement.
Future<CCompilerConfig?> cCompilerConfigLinux({required bool throwIfNotFound}) async {
const kClangPlusPlusBinary = 'clang++';
// NOTE: these binaries sometimes have different names depending on the installation;
// thus, we check for a few possible options (in order of preference).
@ -25,30 +31,53 @@ Future<CCompilerConfig> cCompilerConfigLinux() async {
kClangPlusPlusBinary,
]);
if (whichResult.exitCode != 0) {
throwToolExit('Failed to find $kClangPlusPlusBinary on PATH.');
if (throwIfNotFound) {
throwToolExit('Failed to find $kClangPlusPlusBinary on PATH.');
} else {
return null;
}
}
File clangPpFile = globals.fs.file((whichResult.stdout as String).trim());
clangPpFile = globals.fs.file(await clangPpFile.resolveSymbolicLinks());
final Directory clangDir = clangPpFile.parent;
return CCompilerConfig(
linker: _findExecutableIfExists(path: clangDir, possibleExecutableNames: kLdBinaryOptions),
compiler: _findExecutableIfExists(path: clangDir, possibleExecutableNames: kClangBinaryOptions),
archiver: _findExecutableIfExists(path: clangDir, possibleExecutableNames: kArBinaryOptions),
Uri? findExecutable({required List<String> possibleExecutableNames, required Directory path}) {
final Uri? found = _findExecutableIfExists(
possibleExecutableNames: possibleExecutableNames,
path: path,
);
if (found == null && throwIfNotFound) {
throwToolExit('Failed to find any of $possibleExecutableNames in $path');
}
return found;
}
final Uri? linker = findExecutable(path: clangDir, possibleExecutableNames: kLdBinaryOptions);
final Uri? compiler = findExecutable(
path: clangDir,
possibleExecutableNames: kClangBinaryOptions,
);
final Uri? archiver = findExecutable(path: clangDir, possibleExecutableNames: kArBinaryOptions);
if (linker == null || compiler == null || archiver == null) {
assert(!throwIfNotFound); // otherwise, findExecutable would have thrown
return null;
}
return CCompilerConfig(linker: linker, compiler: compiler, archiver: archiver);
}
/// Searches for an executable with a name in [possibleExecutableNames]
/// at [path] and returns the first one it finds, if one is found.
/// Otherwise, throws an error.
Uri _findExecutableIfExists({
/// Otherwise, returns `null`.
Uri? _findExecutableIfExists({
required List<String> possibleExecutableNames,
required Directory path,
}) {
return possibleExecutableNames
.map((execName) => path.childFile(execName))
.where((file) => file.existsSync())
.map((file) => file.uri)
.firstOrNull ??
throwToolExit('Failed to find any of $possibleExecutableNames in $path');
.map((execName) => path.childFile(execName))
.where((file) => file.existsSync())
.map((file) => file.uri)
.firstOrNull;
}

View File

@ -168,23 +168,35 @@ Future<void> codesignDylib(
/// Flutter expects `xcrun` to be on the path on macOS hosts.
///
/// Use the `clang`, `ar`, and `ld` that would be used if run with `xcrun`.
Future<CCompilerConfig> cCompilerConfigMacOS() async {
return CCompilerConfig(
compiler: await _findXcrunBinary('clang'),
archiver: await _findXcrunBinary('ar'),
linker: await _findXcrunBinary('ld'),
);
///
/// If no XCode installation was found, [throwIfNotFound] controls whether this
/// throws or returns `null`.
Future<CCompilerConfig?> cCompilerConfigMacOS({required bool throwIfNotFound}) async {
final Uri? compiler = await _findXcrunBinary('clang', throwIfNotFound);
final Uri? archiver = await _findXcrunBinary('ar', throwIfNotFound);
final Uri? linker = await _findXcrunBinary('ld', throwIfNotFound);
if (compiler == null || archiver == null || linker == null) {
assert(!throwIfNotFound);
return null;
}
return CCompilerConfig(compiler: compiler, archiver: archiver, linker: linker);
}
/// Invokes `xcrun --find` to find the full path to [binaryName].
Future<Uri> _findXcrunBinary(String binaryName) async {
Future<Uri?> _findXcrunBinary(String binaryName, bool throwIfNotFound) async {
final ProcessResult xcrunResult = await globals.processManager.run(<String>[
'xcrun',
'--find',
binaryName,
]);
if (xcrunResult.exitCode != 0) {
throwToolExit('Failed to find $binaryName with xcrun:\n${xcrunResult.stderr}');
if (throwIfNotFound) {
throwToolExit('Failed to find $binaryName with xcrun:\n${xcrunResult.stderr}');
} else {
return null;
}
}
return Uri.file((xcrunResult.stdout as String).trim());
}

View File

@ -194,7 +194,19 @@ sealed class CodeAssetTarget extends AssetBuildTarget {
late final CCompilerConfig? cCompilerConfigSync;
Future<void> setCCompilerConfig();
/// On platforms where the Flutter app is compiled with a native toolchain, configures this target
/// to contain a [CCompilerConfig] matching that toolchain.
///
/// While hooks are supposed to be able to find a toolchain on their own, we want them to use the
/// same tools used to build the main app to make static linking easier in the future. So if we're
/// e.g. on Linux and use `clang` to compile the app, hooks should use the same `clang` as a
/// compiler too.
///
/// If [mustMatchAppBuild] is true (the default), this should throw if the expected toolchain
/// could not be found. For `flutter test` setups where no app is compiled, we _prefer_ to use the
/// same toolchain but would allow not passing a [CCompilerConfig] if that fails. This allows
/// hooks that only download code assets instead of compiling them to still function.
Future<void> setCCompilerConfig({bool mustMatchAppBuild = true});
List<CodeAssetExtension> get codeAssetExtensions {
return <CodeAssetExtension>[
@ -223,7 +235,9 @@ class WindowsAssetTarget extends CodeAssetTarget {
];
@override
Future<void> setCCompilerConfig() async => cCompilerConfigSync = await cCompilerConfigWindows();
Future<void> setCCompilerConfig({bool mustMatchAppBuild = true}) async =>
// TODO(simolus3): Respect the mustMatchAppBuild option in cCompilerConfigWindows.
cCompilerConfigSync = await cCompilerConfigWindows();
}
final class LinuxAssetTarget extends CodeAssetTarget {
@ -231,7 +245,8 @@ final class LinuxAssetTarget extends CodeAssetTarget {
: super(os: OS.linux);
@override
Future<void> setCCompilerConfig() async => cCompilerConfigSync = await cCompilerConfigLinux();
Future<void> setCCompilerConfig({bool mustMatchAppBuild = true}) async =>
cCompilerConfigSync = await cCompilerConfigLinux(throwIfNotFound: mustMatchAppBuild);
@override
List<ProtocolExtension> get extensions => <ProtocolExtension>[
@ -252,7 +267,8 @@ final class IOSAssetTarget extends CodeAssetTarget {
final FileSystem fileSystem;
@override
Future<void> setCCompilerConfig() async => cCompilerConfigSync = await cCompilerConfigMacOS();
Future<void> setCCompilerConfig({bool mustMatchAppBuild = true}) async =>
cCompilerConfigSync = await cCompilerConfigMacOS(throwIfNotFound: mustMatchAppBuild);
IOSCodeConfig _getIOSConfig(Map<String, String> environmentDefines, FileSystem fileSystem) {
final String? sdkRoot = environmentDefines[kSdkRoot];
@ -299,7 +315,8 @@ final class MacOSAssetTarget extends CodeAssetTarget {
}
@override
Future<void> setCCompilerConfig() async => cCompilerConfigSync = await cCompilerConfigMacOS();
Future<void> setCCompilerConfig({bool mustMatchAppBuild = true}) async =>
cCompilerConfigSync = await cCompilerConfigMacOS(throwIfNotFound: mustMatchAppBuild);
}
final class AndroidAssetTarget extends CodeAssetTarget {
@ -315,7 +332,8 @@ final class AndroidAssetTarget extends CodeAssetTarget {
final AndroidCodeConfig? _androidCodeConfig;
@override
Future<void> setCCompilerConfig() async => cCompilerConfigSync = await cCompilerConfigAndroid();
Future<void> setCCompilerConfig({bool mustMatchAppBuild = true}) async =>
cCompilerConfigSync = await cCompilerConfigAndroid();
@override
List<ProtocolExtension> get extensions => <ProtocolExtension>[
@ -362,7 +380,8 @@ final class FlutterTesterAssetTarget extends CodeAssetTarget {
CCompilerConfig? get cCompilerConfigSync => subtarget.cCompilerConfigSync;
@override
Future<void> setCCompilerConfig() async => subtarget.setCCompilerConfig();
Future<void> setCCompilerConfig({bool mustMatchAppBuild = true}) =>
subtarget.setCCompilerConfig(mustMatchAppBuild: false);
}
List<AndroidArch> _androidArchs(TargetPlatform targetPlatform, String? androidArchsEnvironment) {

View File

@ -93,7 +93,7 @@ void main() {
await fileSystem.file('/some/path/to/llvm-ar').create();
await fileSystem.file('/some/path/to/ld.lld').create();
final CCompilerConfig result = await cCompilerConfigLinux();
final CCompilerConfig result = (await cCompilerConfigLinux(throwIfNotFound: true))!;
expect(result.compiler, Uri.file('/some/path/to/clang'));
},
);
@ -115,7 +115,7 @@ void main() {
await fileSystem.file('/path/to/$execName').create(recursive: true);
}
final CCompilerConfig result = await cCompilerConfigLinux();
final CCompilerConfig result = (await cCompilerConfigLinux(throwIfNotFound: true))!;
expect(result.linker, Uri.file('/path/to/ld'));
expect(result.compiler, Uri.file('/path/to/clang'));
expect(result.archiver, Uri.file('/path/to/ar'));
@ -123,7 +123,7 @@ void main() {
);
testUsingContext(
'cCompilerConfigLinux with missing binaries',
'cCompilerConfigLinux with missing binaries when required',
overrides: <Type, Generator>{
ProcessManager: () => FakeProcessManager.list(<FakeCommand>[
const FakeCommand(command: <Pattern>['which', 'clang++'], stdout: '/a/path/to/clang++'),
@ -137,7 +137,25 @@ void main() {
await fileSystem.file('/a/path/to/clang++').create(recursive: true);
expect(cCompilerConfigLinux(), throwsA(isA<ToolExit>()));
expect(cCompilerConfigLinux(throwIfNotFound: true), throwsA(isA<ToolExit>()));
},
);
testUsingContext(
'cCompilerConfigLinux with missing binaries when not required',
overrides: <Type, Generator>{
ProcessManager: () => FakeProcessManager.list(<FakeCommand>[
const FakeCommand(command: <Pattern>['which', 'clang++'], stdout: '/a/path/to/clang++'),
]),
FileSystem: () => fileSystem,
},
() async {
if (!const LocalPlatform().isLinux) {
return;
}
await fileSystem.file('/a/path/to/clang++').create(recursive: true);
expect(cCompilerConfigLinux(throwIfNotFound: false), completes);
},
);
}

View File

@ -6,6 +6,7 @@ import 'package:code_assets/code_assets.dart';
import 'package:file/file.dart';
import 'package:file/memory.dart';
import 'package:flutter_tools/src/artifacts.dart';
import 'package:flutter_tools/src/base/common.dart';
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/base/platform.dart';
@ -391,7 +392,7 @@ void main() {
return;
}
final CCompilerConfig result = await cCompilerConfigMacOS();
final CCompilerConfig result = (await cCompilerConfigMacOS(throwIfNotFound: true))!;
expect(
result.compiler,
Uri.file(
@ -429,10 +430,52 @@ void main() {
return;
}
final CCompilerConfig result = await cCompilerConfigMacOS();
final CCompilerConfig result = (await cCompilerConfigMacOS(throwIfNotFound: true))!;
expect(result.compiler, Uri.file('/nix/store/random-path-to-clang-wrapper/bin/clang'));
expect(result.archiver, Uri.file('/nix/store/random-path-to-clang-wrapper/bin/ar'));
expect(result.linker, Uri.file('/nix/store/random-path-to-clang-wrapper/bin/ld'));
},
);
testUsingContext(
'missing xcode when required',
overrides: <Type, Generator>{
ProcessManager: () => FakeProcessManager.list(<FakeCommand>[
for (final binary in <String>['clang', 'ar', 'ld'])
FakeCommand(
command: <Pattern>['xcrun', '--find', binary],
exitCode: 1,
stderr: 'not found',
),
]),
},
() async {
if (!const LocalPlatform().isMacOS) {
return;
}
await expectLater(cCompilerConfigMacOS(throwIfNotFound: true), throwsA(isA<ToolExit>()));
},
);
testUsingContext(
'missing xcode when not required',
overrides: <Type, Generator>{
ProcessManager: () => FakeProcessManager.list(<FakeCommand>[
for (final binary in <String>['clang', 'ar', 'ld'])
FakeCommand(
command: <Pattern>['xcrun', '--find', binary],
exitCode: 1,
stderr: 'not found',
),
]),
},
() async {
if (!const LocalPlatform().isMacOS) {
return;
}
expect(await cCompilerConfigMacOS(throwIfNotFound: false), isNull);
},
);
}

View File

@ -9,12 +9,14 @@ import 'package:file_testing/file_testing.dart';
import 'package:flutter_tools/src/artifacts.dart';
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/base/platform.dart';
import 'package:flutter_tools/src/build_info.dart';
import 'package:flutter_tools/src/build_system/build_system.dart';
import 'package:flutter_tools/src/build_system/targets/native_assets.dart';
import 'package:flutter_tools/src/features.dart';
import 'package:flutter_tools/src/isolated/native_assets/dart_hook_result.dart';
import 'package:flutter_tools/src/isolated/native_assets/native_assets.dart';
import 'package:flutter_tools/src/isolated/native_assets/targets.dart';
import '../../src/common.dart';
import '../../src/context.dart';
@ -234,4 +236,63 @@ void main() {
);
},
);
testUsingContext(
'unit tests does not require compiler toolchain',
overrides: <Type, Generator>{
ProcessManager: () {
const Platform platform = LocalPlatform();
return FakeProcessManager.list([
if (platform.isMacOS)
for (final binary in <String>['clang', 'ar', 'ld'])
FakeCommand(
command: <Pattern>['xcrun', '--find', binary],
exitCode: 1,
stderr: 'not found',
),
if (platform.isLinux)
const FakeCommand(
command: <Pattern>['which', 'clang++'],
exitCode: 1,
stderr: 'not found',
),
]);
},
},
() async {
// This calls setCCompilerConfig() on a test target, which must not throw despite the
// toolchain not being available.
const Platform platform = LocalPlatform();
if (!platform.isLinux && !platform.isMacOS) {
return false;
}
final target = _SetCCompilerConfigTarget(
packagesWithNativeAssetsResult: <String>['bar'],
buildResult: FakeFlutterNativeAssetsBuilderResult.fromAssets(),
);
await runFlutterSpecificHooks(
environmentDefines: {},
targetPlatform: TargetPlatform.tester,
projectUri: projectUri,
fileSystem: fileSystem,
buildRunner: target,
);
expect(target.didSetCCompilerConfig, isTrue);
},
);
}
class _SetCCompilerConfigTarget extends FakeFlutterNativeAssetsBuildRunner {
_SetCCompilerConfigTarget({super.buildResult, super.packagesWithNativeAssetsResult});
var didSetCCompilerConfig = false;
@override
Future<void> setCCompilerConfig(CodeAssetTarget target) async {
await target.setCCompilerConfig();
didSetCCompilerConfig = true;
}
}