// Copyright 2014 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:async'; import 'package:meta/meta.dart'; import '../artifacts.dart'; import '../base/file_system.dart'; import '../build_info.dart'; import '../bundle.dart'; import '../compile.dart'; import '../flutter_plugins.dart'; import '../globals.dart' as globals; import '../project.dart'; import 'test_time_recorder.dart'; /// A request to the [TestCompiler] for recompilation. final class _CompilationRequest { _CompilationRequest(this.mainUri); /// The entrypoint (containing `main()`) to the Dart program being compiled. final Uri mainUri; /// Invoked when compilation is completed with the compilation output path. Future get result => _result.future; final _result = Completer(); } /// The result of [TestCompiler.compile]. @immutable sealed class TestCompilerResult { const TestCompilerResult({required this.mainUri}); /// The program that was or was attempted to be compiled. final Uri mainUri; } /// A successful run of [TestCompiler.compile]. final class TestCompilerComplete extends TestCompilerResult { const TestCompilerComplete({required this.outputPath, required super.mainUri}); /// Output path of the compiled program. final String outputPath; @override bool operator ==(Object other) { if (other is! TestCompilerComplete) { return false; } return mainUri == other.mainUri && outputPath == other.outputPath; } @override int get hashCode => Object.hash(mainUri, outputPath); @override String toString() { return 'TestCompilerComplete(mainUri: $mainUri, outputPath: $outputPath)'; } } /// A failed run of [TestCompiler.compile]. final class TestCompilerFailure extends TestCompilerResult { const TestCompilerFailure({required this.error, required super.mainUri}); /// Error message that occurred failing compilation. final String error; @override bool operator ==(Object other) { if (other is! TestCompilerFailure) { return false; } return mainUri == other.mainUri && error == other.error; } @override int get hashCode => Object.hash(mainUri, error); @override String toString() { return 'TestCompilerComplete(mainUri: $mainUri, error: $error)'; } } /// A frontend_server wrapper for the flutter test runner. /// /// This class is a wrapper around compiler that allows multiple isolates to /// enqueue compilation requests, but ensures only one compilation at a time. class TestCompiler { /// Creates a new [TestCompiler] which acts as a frontend_server proxy. /// /// [BuildInfo.trackWidgetCreation] configures whether /// the kernel transform is applied to the output. /// This also changes the output file to include a '.track` extension. /// /// [flutterProject] is the project for which we are running tests. /// /// If [precompiledDillPath] is passed, it will be used to initialize the /// compiler. /// /// If [testTimeRecorder] is passed, times will be recorded in it. TestCompiler( this.buildInfo, this.flutterProject, { String? precompiledDillPath, this.testTimeRecorder, }) : testFilePath = precompiledDillPath ?? globals.fs.path.join( flutterProject!.directory.path, getBuildDirectory(), 'test_cache', getDefaultCachedKernelPath( trackWidgetCreation: buildInfo.trackWidgetCreation, dartDefines: buildInfo.dartDefines, extraFrontEndOptions: buildInfo.extraFrontEndOptions, ), ), shouldCopyDillFile = precompiledDillPath == null { // Compiler maintains and updates single incremental dill file. // Incremental compilation requests done for each test copy that file away // for independent execution. final Directory outputDillDirectory = globals.fs.systemTempDirectory.createTempSync( 'flutter_test_compiler.', ); outputDill = outputDillDirectory.childFile('output.dill'); globals.printTrace( 'Compiler will use the following file as its incremental dill file: ${outputDill.path}', ); globals.printTrace('Listening to compiler controller...'); compilerController.stream.listen( _onCompilationRequest, onDone: () { globals.printTrace('Deleting ${outputDillDirectory.path}...'); outputDillDirectory.deleteSync(recursive: true); }, ); } final compilerController = StreamController<_CompilationRequest>(); final compilationQueue = <_CompilationRequest>[]; final FlutterProject? flutterProject; final BuildInfo buildInfo; final String testFilePath; final bool shouldCopyDillFile; final TestTimeRecorder? testTimeRecorder; ResidentCompiler? compiler; late File outputDill; /// Compiles the Dart program (an entrypoint containing `main()`). Future compile(Uri dartEntrypointPath) { if (compilerController.isClosed) { throw StateError('TestCompiler is already disposed.'); } final request = _CompilationRequest(dartEntrypointPath); compilerController.add(request); return request.result; } Future _shutdown() async { // Check for null in case this instance is shut down before the // lazily-created compiler has been created. if (compiler != null) { await compiler!.shutdown(); compiler = null; } } Future dispose() async { await compilerController.close(); await _shutdown(); } /// Create the resident compiler used to compile the test. @visibleForTesting Future createCompiler() async { final residentCompiler = ResidentCompiler( globals.artifacts!.getArtifactPath(Artifact.flutterPatchedSdkPath), artifacts: globals.artifacts!, logger: globals.logger, processManager: globals.processManager, buildMode: buildInfo.mode, trackWidgetCreation: buildInfo.trackWidgetCreation, initializeFromDill: testFilePath, dartDefines: buildInfo.dartDefines, packagesPath: buildInfo.packageConfigPath, frontendServerStarterPath: buildInfo.frontendServerStarterPath, extraFrontEndOptions: buildInfo.extraFrontEndOptions, platform: globals.platform, testCompilation: true, fileSystem: globals.fs, fileSystemRoots: buildInfo.fileSystemRoots, fileSystemScheme: buildInfo.fileSystemScheme, shutdownHooks: globals.shutdownHooks, ); return residentCompiler; } // Handle a compilation request. Future _onCompilationRequest(_CompilationRequest request) async { final bool isEmpty = compilationQueue.isEmpty; compilationQueue.add(request); // Only trigger processing if queue was empty - i.e. no other requests // are currently being processed. This effectively enforces "one // compilation request at a time". if (!isEmpty) { return; } while (compilationQueue.isNotEmpty) { final _CompilationRequest request = compilationQueue.first; globals.printTrace('Compiling ${request.mainUri}'); final compilerTime = Stopwatch()..start(); final Stopwatch? testTimeRecorderStopwatch = testTimeRecorder?.start(TestTimePhases.Compile); var firstCompile = false; if (compiler == null) { compiler = await createCompiler(); firstCompile = true; } final invalidatedRegistrantFiles = []; if (flutterProject != null) { // Update the generated registrant to use the test target's main. final String mainUriString = buildInfo.packageConfig.toPackageUri(request.mainUri)?.toString() ?? request.mainUri.toString(); await generateMainDartWithPluginRegistrant( flutterProject!, buildInfo.packageConfig, mainUriString, globals.fs.file(request.mainUri), ); invalidatedRegistrantFiles.add(flutterProject!.dartPluginRegistrant.absolute.uri); } final CompilerOutput? compilerOutput = await compiler!.recompile( request.mainUri, [request.mainUri, ...invalidatedRegistrantFiles], outputPath: outputDill.path, packageConfig: buildInfo.packageConfig, projectRootPath: flutterProject?.directory.absolute.path, checkDartPluginRegistry: true, fs: globals.fs, ); final String? outputPath = compilerOutput?.outputFilename; // In case compiler didn't produce output or reported compilation // errors, pass [null] upwards to the consumer and shutdown the // compiler to avoid reusing compiler that might have gotten into // a weird state. if (outputPath == null || compilerOutput!.errorCount > 0) { request._result.complete( TestCompilerFailure( error: compilerOutput!.errorMessage ?? 'Unknown Error', mainUri: request.mainUri, ), ); await _shutdown(); } else { if (shouldCopyDillFile) { final String path = request.mainUri.toFilePath(windows: globals.platform.isWindows); final File outputFile = globals.fs.file(outputPath); final File kernelReadyToRun = await outputFile.copy('$path.dill'); final File testCache = globals.fs.file(testFilePath); if (firstCompile || !testCache.existsSync() || (testCache.lengthSync() < outputFile.lengthSync())) { // The idea is to keep the cache file up-to-date and include as // much as possible in an effort to re-use as many packages as // possible. if (!testCache.parent.existsSync()) { testCache.parent.createSync(recursive: true); } await outputFile.copy(testFilePath); } request._result.complete( TestCompilerComplete(outputPath: kernelReadyToRun.path, mainUri: request.mainUri), ); } else { request._result.complete( TestCompilerComplete(outputPath: outputPath, mainUri: request.mainUri), ); } compiler!.accept(); compiler!.reset(); } globals.printTrace('Compiling ${request.mainUri} took ${compilerTime.elapsedMilliseconds}ms'); testTimeRecorder?.stop(TestTimePhases.Compile, testTimeRecorderStopwatch!); // Only remove now when we finished processing the element compilationQueue.removeAt(0); } } }