| // Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file |
| // for details. 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 'dart:collection'; |
| import 'dart:convert'; |
| import 'dart:io'; |
| import 'dart:math'; |
| |
| import 'package:_fe_analyzer_shared/src/util/relativize.dart' as fe_shared; |
| import 'package:args/args.dart'; |
| import 'package:bazel_worker/driver.dart'; |
| import 'package:collection/collection.dart'; |
| import 'package:dev_compiler/dev_compiler.dart' |
| as ddc_names |
| show libraryUriToJsIdentifier; |
| import 'package:front_end/src/api_unstable/ddc.dart' as fe; |
| import 'package:path/path.dart' as p; |
| import 'package:reload_test/ddc_helpers.dart' as ddc_helpers; |
| import 'package:reload_test/frontend_server_controller.dart'; |
| import 'package:reload_test/hot_reload_memory_filesystem.dart'; |
| import 'package:reload_test/hot_reload_receipt.dart'; |
| import 'package:reload_test/test_helpers.dart'; |
| |
| final buildRootUri = fe.computePlatformBinariesLocation(forceBuildDir: true); |
| final sdkRoot = Platform.script.resolve('../../../'); |
| |
| /// SDK test directory containing hot reload tests. |
| final allTestsUri = sdkRoot.resolve('tests/hot_reload/'); |
| |
| /// The separator between a test file and its inlined diff. |
| /// |
| /// All contents after this separator are considered are diff comments. |
| final testDiffSeparator = '/** DIFF **/'; |
| |
| Future<void> main(List<String> args) async { |
| final options = Options.parse(args); |
| if (options.help) { |
| print(options.usage); |
| return; |
| } |
| final runner = |
| switch (options.runtime) { |
| RuntimePlatforms.chrome => |
| options.useFeServer |
| ? ChromeSuiteRunner(options) |
| : ChromeStandaloneSuiteRunner(options), |
| RuntimePlatforms.d8 => |
| options.useFeServer |
| ? D8SuiteRunner(options) |
| : D8StandaloneSuiteRunner(options), |
| RuntimePlatforms.vm => VMSuiteRunner(options), |
| } |
| as HotReloadSuiteRunner; |
| await runner.runSuite(); |
| } |
| |
| /// Command line options for the hot reload test suite. |
| class Options { |
| final bool help; |
| final RuntimePlatforms runtime; |
| final bool useFeServer; |
| final String namedConfiguration; |
| final Uri? testResultsOutputDir; |
| final RegExp testNameFilter; |
| final DiffMode diffMode; |
| final bool debug; |
| final bool verbose; |
| |
| Options._({ |
| required this.help, |
| required this.runtime, |
| required this.useFeServer, |
| required this.namedConfiguration, |
| required this.testResultsOutputDir, |
| required this.testNameFilter, |
| required this.diffMode, |
| required this.debug, |
| required this.verbose, |
| }); |
| |
| static final _parser = ArgParser() |
| ..addFlag( |
| 'help', |
| abbr: 'h', |
| help: 'Display this message.', |
| negatable: false, |
| defaultsTo: false, |
| ) |
| ..addOption( |
| 'runtime', |
| abbr: 'r', |
| defaultsTo: 'd8', |
| allowed: RuntimePlatforms.values.map((v) => v.text), |
| help: 'runtime platform used to run tests.', |
| ) |
| ..addOption( |
| 'named-configuration', |
| abbr: 'n', |
| defaultsTo: 'no-configuration', |
| help: 'configuration name to use for emitting test result files.', |
| ) |
| ..addOption( |
| 'output-directory', |
| help: 'directory to emit test results files.', |
| ) |
| ..addOption( |
| 'filter', |
| abbr: 'f', |
| defaultsTo: r'.*', |
| help: 'regexp filter over tests to run.', |
| ) |
| ..addFlag( |
| 'use-fe-server', |
| defaultsTo: true, |
| help: |
| 'Whether to run the suite in using DDC directly instead of the FE ' |
| 'server. Only applicable when targeting the web.', |
| ) |
| ..addOption( |
| 'diff', |
| allowed: ['check', 'write', 'ignore'], |
| allowedHelp: { |
| 'check': 'validate that reload test diffs are generated and correct.', |
| 'write': 'write diffs for reload tests.', |
| 'ignore': 'ignore reload diffs.', |
| }, |
| defaultsTo: 'check', |
| help: |
| 'selects whether test diffs should be checked, written, or ' |
| 'ignored.', |
| ) |
| ..addFlag( |
| 'debug', |
| abbr: 'd', |
| defaultsTo: false, |
| negatable: true, |
| help: 'enables additional debug behavior and logging.', |
| ) |
| ..addFlag( |
| 'verbose', |
| abbr: 'v', |
| defaultsTo: true, |
| negatable: true, |
| help: 'enables verbose logging.', |
| ); |
| |
| /// Usage description for these command line options. |
| String get usage => _parser.usage; |
| |
| factory Options.parse(List<String> args) { |
| final results = _parser.parse(args); |
| final options = Options._( |
| help: results.flag('help'), |
| runtime: RuntimePlatforms.values.byName(results.option('runtime')!), |
| useFeServer: results.flag('use-fe-server'), |
| namedConfiguration: results.option('named-configuration')!, |
| testResultsOutputDir: results.wasParsed('output-directory') |
| ? Uri.directory(results.option('output-directory')!) |
| : null, |
| testNameFilter: RegExp(results.option('filter')!), |
| diffMode: switch (results.option('diff')!) { |
| 'check' => DiffMode.check, |
| 'write' => DiffMode.write, |
| 'ignore' => DiffMode.ignore, |
| _ => throw Exception('Invalid diff mode: ${results.option('diff')}'), |
| }, |
| debug: results.flag('debug'), |
| verbose: results.flag('verbose'), |
| ); |
| |
| if (!options.useFeServer && options.runtime == RuntimePlatforms.vm) { |
| throw ArgumentError( |
| 'Unsupported flag combination: ' |
| '`--runtime=vm` and `--use-fe-server=false`', |
| ); |
| } |
| |
| return options; |
| } |
| } |
| |
| /// Modes for running diff check tests on the hot reload suite. |
| enum DiffMode { check, write, ignore } |
| |
| /// A single test for the hot reload test suite. |
| /// |
| /// A hot reload test is made of a collection of one or more Dart files over a |
| /// number of generational edits that represent interactions with source files |
| /// over the life of a running test program. |
| /// |
| /// Tests in this suite also define a config.json file with further information |
| /// describing how the test runs. |
| class HotReloadTest { |
| /// Root [Directory] containing the source files for this test. |
| final Directory directory; |
| |
| /// Test name used in results. |
| /// |
| /// By convention this matches the name of the [directory]. |
| final String name; |
| |
| /// The files that make up this test. |
| final List<TestFile> files; |
| |
| /// The number of generations in this test (one-based). |
| final int generationCount; |
| |
| /// Platforms that should not run this test. |
| final Set<RuntimePlatforms> excludedPlatforms; |
| |
| /// Edit rejection error messages expected from this test when a hot reload is |
| /// triggered by the generation in which the error is expected. |
| /// |
| /// Specified in the `config.json` file. |
| // TODO(nshahan): Support multiple expected errors for a single generation. |
| final Map<int, String> expectedErrors; |
| |
| /// Map of generation number to whether a hot restart was triggered before the |
| /// generation. |
| final Map<int, bool> isHotRestart; |
| |
| HotReloadTest( |
| this.directory, |
| this.name, |
| this.generationCount, |
| this.files, |
| ReloadTestConfiguration config, |
| this.isHotRestart, |
| ) : excludedPlatforms = config.excludedPlatforms, |
| expectedErrors = config.expectedErrors; |
| |
| /// The files edited in the provided [generation] (zero-based). |
| List<TestFile> filesEditedInGeneration(int generation) => [ |
| for (final file in files) |
| if (file._editsByGeneration.containsKey(generation)) file, |
| ]; |
| } |
| |
| /// An individual test file for a hot reload test across all generations. |
| class TestFile { |
| /// The reconstructed name of the file after removing the generation tag. |
| /// |
| /// For example given the files foo.0.dart, foo.1.dart, and foo.2.dart the |
| /// [baseName] would be 'foo.dart'. |
| final String baseName; |
| |
| /// The individual edits of this file by their generations (zero-based). |
| /// |
| /// By convention, iterating the entries produces them in generational order |
| /// but there could be gaps in the generation numbers. |
| // TODO(nshahan): Move the creation of this data structure to this class to |
| // ensure the iteration order. Alternatively, update the representation to |
| // require there are no gaps in the generations so we don't have to handle |
| // them and can just use a list. |
| final LinkedHashMap<int, TestFileEdit> _editsByGeneration; |
| |
| TestFile(this.baseName, this._editsByGeneration); |
| |
| /// Returns the [TestFileEdit] for the given [generation] (zero-based) for |
| /// this file. |
| TestFileEdit editForGeneration(int generation) { |
| final edit = _editsByGeneration[generation]; |
| if (edit == null) { |
| throw Exception('File: $baseName has no generation: $generation.'); |
| } |
| return edit; |
| } |
| |
| /// All edits for this file in order by generation. |
| List<TestFileEdit> get edits => _editsByGeneration.values.toList(); |
| } |
| |
| /// A single version of a test file at a specific generation. |
| class TestFileEdit { |
| /// The generation this edit belongs to. |
| final int generation; |
| |
| /// The location of this file on disk. |
| final Uri fileUri; |
| |
| TestFileEdit(this.generation, this.fileUri); |
| } |
| |
| abstract class CompiledOutput { |
| final String outputDillPath; |
| int get errorCount; |
| String get outputText; |
| |
| CompiledOutput(this.outputDillPath); |
| } |
| |
| class FrontendServerOutput extends CompiledOutput { |
| final CompilerOutput _output; |
| @override |
| int get errorCount => _output.errorCount; |
| @override |
| String get outputText => _output.outputText; |
| |
| FrontendServerOutput(super.outputDillPath, this._output); |
| } |
| |
| class DdcWorkerOutput extends CompiledOutput { |
| final WorkResponse _response; |
| @override |
| int get errorCount => |
| _response.exitCode == 0 ? 0 : _response.output.split('\n').length - 2; |
| @override |
| String get outputText => _response.output; |
| |
| DdcWorkerOutput(super.outputDillPath, this._response); |
| } |
| |
| /// Backend agnostic class to orchestrate running the hot reload test suite. |
| /// |
| /// [T] is the type of a controller used to perform compilations of Dart code. |
| abstract class HotReloadSuiteRunner<T> { |
| Options options; |
| |
| /// All test results that are reported after running the entire test suite. |
| final List<TestResultOutcome> testOutcomes = []; |
| |
| HotReloadSuiteRunner(this.options); |
| |
| /// The root directory containing generated code for all tests. |
| late final Directory generatedCodeDir = Directory.systemTemp.createTempSync(); |
| |
| /// The directory containing files emitted by compiles and recompiles. |
| late final Directory emittedFilesDir = Directory.fromUri( |
| generatedCodeDir.uri.resolve('.fes/'), |
| )..createSync(); |
| |
| /// The output location for .dill file created by dart. |
| late final Uri outputDillUri = emittedFilesDir.uri.resolve('output.dill'); |
| |
| /// The directory used as a temporary staging area to construct a compile-able |
| /// test app across reload/restart generations. |
| late final Directory snapshotDir = Directory.fromUri( |
| generatedCodeDir.uri.resolve('.snapshot/'), |
| )..createSync(); |
| |
| final filesystemScheme = 'hot-reload-test'; |
| |
| // TODO(markzipan): Support custom entrypoints. |
| late final Uri snapshotEntrypointUri = snapshotDir.uri.resolve('main.dart'); |
| late final String snapshotEntrypointWithScheme = () { |
| final snapshotEntrypointLibraryName = fe_shared.relativizeUri( |
| snapshotDir.uri, |
| snapshotEntrypointUri, |
| fe_shared.isWindows, |
| ); |
| return '$filesystemScheme:///$snapshotEntrypointLibraryName'; |
| }(); |
| |
| late final packageConfigUri = sdkRoot.resolve( |
| '.dart_tool/package_config.json', |
| ); |
| |
| final Uri dartSdkJSUri = buildRootUri.resolve( |
| 'gen/utils/ddc/canary/sdk/ddc/dart_sdk.js', |
| ); |
| final Uri ddcModuleLoaderJSUri = sdkRoot.resolve( |
| 'pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js', |
| ); |
| final String ddcPlatformDillFromSdkRoot = fe_shared.relativizeUri( |
| sdkRoot, |
| buildRootUri.resolve('ddc_outline.dill'), |
| fe_shared.isWindows, |
| ); |
| final String entrypointModuleName = 'hot-reload-test:///main.dart'; |
| late final String entrypointLibraryExportName = ddc_names |
| .libraryUriToJsIdentifier(snapshotEntrypointUri); |
| |
| final stopwatch = Stopwatch(); |
| |
| String? get modeNamePrefix => null; |
| T createController(); |
| Future<void> stopController(T controller); |
| Future<CompiledOutput> sendCompile(T controller, HotReloadTest test); |
| Future<CompiledOutput> sendRecompile( |
| T controller, |
| HotReloadTest test, |
| int generation, |
| List<String> updatedFiles, |
| ); |
| Future<bool> resolveOutput( |
| T controller, |
| HotReloadTest test, |
| CompiledOutput output, |
| int generation, |
| ); |
| void registerOutputDirectory(HotReloadTest test, Uri outputDirectory); |
| |
| /// Runs [test] from compiled and generated assets in [tempDirectory] and |
| /// returns `true` if it passes. |
| /// |
| /// All output (standard and errors) from running the test is written to |
| /// [outputSink]. |
| Future<bool> runTest( |
| HotReloadTest test, |
| Directory tempDirectory, |
| IOSink outputSink, |
| ); |
| |
| Future<void> shutdown(T controller) async { |
| await stopController(controller); |
| |
| // Persist the temp directory for debugging. |
| if (!options.debug) { |
| _print('Deleting temporary directory: ${generatedCodeDir.path}.'); |
| generatedCodeDir.deleteSync(recursive: true); |
| } |
| } |
| |
| Future<void> runSuite() async { |
| // TODO(nshahan): report time for collecting and validating test sources. |
| final testSuite = collectTestSources(options); |
| _debugPrint( |
| 'See generated hot reload framework code in ${generatedCodeDir.uri}', |
| ); |
| final controller = createController(); |
| try { |
| for (final test in testSuite) { |
| stopwatch |
| ..start() |
| ..reset(); |
| diffCheck(test); |
| final tempDirectory = Directory.fromUri( |
| generatedCodeDir.uri.resolve('${test.name}/'), |
| )..createSync(); |
| registerOutputDirectory(test, tempDirectory.uri); |
| var compileSuccess = false; |
| _print('Generating test assets.', label: test.name); |
| // TODO(markzipan): replace this with a test-configurable main |
| // entrypoint. |
| final mainDartFilePath = test.directory.uri |
| .resolve('main.dart') |
| .toFilePath(); |
| _debugPrint('Test entrypoint: $mainDartFilePath', label: test.name); |
| _print( |
| 'Generating code over ${test.generationCount} generations.', |
| label: test.name, |
| ); |
| stopwatch |
| ..start() |
| ..reset(); |
| for ( |
| var generation = 0; |
| generation < test.generationCount; |
| generation++ |
| ) { |
| final updatedFiles = copyGenerationSources(test, generation); |
| compileSuccess = await compileGeneration( |
| test, |
| generation, |
| updatedFiles, |
| controller, |
| ); |
| if (!compileSuccess) break; |
| } |
| if (!compileSuccess) { |
| _print( |
| 'Did not emit all assets due to compilation error.', |
| label: test.name, |
| ); |
| // Skip to the next test and avoid execution if there is an unexpected |
| // compilation error. |
| continue; |
| } |
| _print('Finished emitting assets.', label: test.name); |
| final testOutputStreamController = StreamController<List<int>>(); |
| final testOutputBuffer = StringBuffer(); |
| testOutputStreamController.stream |
| .transform(utf8.decoder) |
| .listen(testOutputBuffer.write); |
| final testPassed = await runTest( |
| test, |
| tempDirectory, |
| IOSink(testOutputStreamController.sink), |
| ); |
| await reportTestOutcome( |
| test.name, |
| testOutputBuffer.toString(), |
| testPassed, |
| ); |
| } |
| await reportAllResults(); |
| } finally { |
| await shutdown(controller); |
| } |
| } |
| |
| /// Compiles all [updatedFiles] in [test] for the given [generation] with the |
| /// [controller], copies all outputs to [outputDirectory], and returns whether |
| /// the compilation was successful. |
| /// |
| /// Reports test failures on compile time errors. |
| Future<bool> compileGeneration( |
| HotReloadTest test, |
| int generation, |
| List<String> updatedFiles, |
| T controller, |
| ) async { |
| // The first generation calls `compile`, but subsequent ones call |
| // `recompile`. |
| // Likewise, use the incremental output file for `recompile` calls. |
| // TODO(nshahan): Sending compile/recompile instructions is likely |
| // the same across backends and should be shared code. |
| _print('Compiling generation $generation.', label: test.name); |
| CompiledOutput compiledOutput; |
| if (generation == 0) { |
| _debugPrint( |
| 'Compiling snapshot entrypoint: $snapshotEntrypointWithScheme', |
| label: test.name, |
| ); |
| compiledOutput = await sendCompile(controller, test); |
| } else { |
| _debugPrint( |
| 'Recompiling snapshot entrypoint: $snapshotEntrypointWithScheme', |
| label: test.name, |
| ); |
| compiledOutput = await sendRecompile( |
| controller, |
| test, |
| generation, |
| updatedFiles, |
| ); |
| } |
| return await resolveOutput(controller, test, compiledOutput, generation); |
| } |
| |
| /// Returns a suite of hot reload tests discovered in the directory |
| /// [allTestsUri]. |
| /// |
| /// Assumes all files that makeup a hot reload test are named like |
| /// '$name.$integer.dart', where 0 is the first generation. |
| /// |
| /// Count the number of generations and ensure they're capped. |
| // TODO(markzipan): Account for subdirectories. |
| List<HotReloadTest> collectTestSources(Options options) { |
| // Set an arbitrary cap on generations. |
| final globalMaxGenerations = 100; |
| final validTestSourceName = RegExp( |
| // Begins with 1 or more word characters. |
| r'^(?<name>[\w,-]+)' |
| // Followed by a dot and 1 or more digits. |
| r'\.(?<generation>\d+)' |
| // Optionally a dot and either the word 'restart' indicating a hot |
| // restart, or the word 'reject', indicating a hot reload rejection. |
| r'((?<restart>\.restart)|(?<reject>\.reject))?' |
| // Ending with a dot and the word 'dart' |
| r'\.dart$', |
| ); |
| final testSuite = <HotReloadTest>[]; |
| for (var testDir in Directory.fromUri(allTestsUri).listSync()) { |
| if (testDir is! Directory) { |
| if (testDir is File) { |
| // Ignore Dart source files, which may be imported as helpers. |
| continue; |
| } |
| throw Exception( |
| 'Non-directory or file entity found in ' |
| '${allTestsUri.toFilePath()}: $testDir', |
| ); |
| } |
| final testDirParts = testDir.uri.pathSegments; |
| final testName = testDirParts[testDirParts.length - 2]; |
| |
| // Skip tests that don't match the name filter. |
| if (!options.testNameFilter.hasMatch(testName)) { |
| _print('Skipping test', label: testName); |
| continue; |
| } |
| var maxGenerations = 0; |
| final configFileUri = testDir.uri.resolve('config.json'); |
| final testConfig = File.fromUri(configFileUri).existsSync() |
| ? ReloadTestConfiguration.fromJsonFile(configFileUri) |
| : ReloadTestConfiguration(); |
| if (testConfig.excludedPlatforms.contains(options.runtime)) { |
| // Skip this test directory if this platform is excluded. |
| _print( |
| 'Skipping test on platform: ${options.runtime.text}', |
| label: testName, |
| ); |
| continue; |
| } |
| final isHotRestart = <int, bool>{}; |
| final expectedErrors = testConfig.expectedErrors; |
| final dartFiles = testDir.listSync().where( |
| (e) => e is File && e.uri.path.endsWith('.dart'), |
| ); |
| // All files in this test clustered by file name - in generation order. |
| final filesByGeneration = <String, PriorityQueue<TestFileEdit>>{}; |
| for (final file in dartFiles) { |
| final fileName = p.basename(file.uri.toFilePath()); |
| final matches = validTestSourceName.allMatches(fileName); |
| if (matches.length != 1) { |
| throw Exception( |
| 'Invalid test source file name: $fileName\n' |
| 'Valid names look like "file_name.10.dart", ' |
| '"file_name.10.restart.dart" or "file_name.10.reject.dart".', |
| ); |
| } |
| final match = matches.single; |
| final name = match.namedGroup('name'); |
| final restoredName = '$name.dart'; |
| final generation = int.parse(match.namedGroup('generation')!); |
| maxGenerations = max(maxGenerations, generation); |
| final restart = match.namedGroup('restart') != null; |
| if (!isHotRestart.containsKey(generation)) { |
| isHotRestart[generation] = restart; |
| } else { |
| if (restart != isHotRestart[generation]) { |
| throw Exception( |
| 'Expected all files for generation $generation to ' |
| "be consistent about having a '.restart' suffix, but $fileName " |
| 'does not match other files in the same generation.', |
| ); |
| } |
| } |
| final rejectExpected = match.namedGroup('reject') != null; |
| assert(!(rejectExpected && restart)); |
| if (rejectExpected && !expectedErrors.containsKey(generation)) { |
| throw Exception( |
| 'Expected error for generation file missing from config.json: ' |
| '$fileName', |
| ); |
| } |
| if (!rejectExpected && expectedErrors.containsKey(generation)) { |
| throw Exception( |
| 'Error for generation $generation found in config.json: ' |
| '"${expectedErrors[generation]}"\n' |
| 'Either remove the error or update the name of this file: ' |
| '$fileName -> ' |
| '$name.$generation.reject.dart', |
| ); |
| } |
| if (generation == 0 && rejectExpected) { |
| throw Exception( |
| 'The first generation may not be rejected: ' |
| '$fileName', |
| ); |
| } |
| filesByGeneration |
| .putIfAbsent( |
| restoredName, |
| () => PriorityQueue( |
| (TestFileEdit a, TestFileEdit b) => a.generation - b.generation, |
| ), |
| ) |
| .add(TestFileEdit(generation, file.uri)); |
| } |
| if (maxGenerations > globalMaxGenerations) { |
| throw Exception( |
| 'Too many generations specified in test ' |
| '(requested: $maxGenerations, max: $globalMaxGenerations).', |
| ); |
| } |
| final testFiles = <TestFile>[]; |
| for (final entry in filesByGeneration.entries) { |
| final fileName = entry.key; |
| final fileEdits = entry.value.toList(); |
| final editsByGeneration = LinkedHashMap<int, TestFileEdit>.from({ |
| for (final edit in fileEdits) edit.generation: edit, |
| }); |
| testFiles.add(TestFile(fileName, editsByGeneration)); |
| } |
| testSuite.add( |
| HotReloadTest( |
| testDir, |
| testName, |
| maxGenerations + 1, |
| testFiles, |
| testConfig, |
| isHotRestart, |
| ), |
| ); |
| } |
| return testSuite; |
| } |
| |
| /// Report results for this test's execution. |
| Future<void> reportTestOutcome( |
| String testName, |
| String testOutput, |
| bool testPassed, |
| ) async { |
| stopwatch.stop(); |
| final fullTestName = '${modeNamePrefix ?? ''}$testName'; |
| final outcome = TestResultOutcome( |
| configuration: options.namedConfiguration, |
| testName: fullTestName, |
| testOutput: testOutput, |
| ); |
| outcome.elapsedTime = stopwatch.elapsed; |
| outcome.matchedExpectations = testPassed; |
| testOutcomes.add(outcome); |
| if (testPassed) { |
| _print('PASSED with:\n $testOutput', label: fullTestName); |
| } else { |
| _print('FAILED with:\n $testOutput', label: fullTestName); |
| } |
| } |
| |
| /// Report results for this test's sources' diff validations. |
| void reportDiffOutcome( |
| String testName, |
| Uri fileUri, |
| String testOutput, |
| bool testPassed, |
| ) { |
| stopwatch.stop(); |
| final filePath = fileUri.path; |
| final relativeFilePath = p.relative(filePath, from: allTestsUri.path); |
| final outcome = TestResultOutcome( |
| configuration: options.namedConfiguration, |
| testName: '$relativeFilePath-diff', |
| testOutput: testOutput, |
| ); |
| outcome.elapsedTime = stopwatch.elapsed; |
| outcome.matchedExpectations = testPassed; |
| testOutcomes.add(outcome); |
| if (testPassed) { |
| _debugPrint( |
| 'PASSED (diff on $filePath) with:\n $testOutput', |
| label: testName, |
| ); |
| } else { |
| _debugPrint( |
| 'FAILED (diff on $filePath) with:\n $testOutput', |
| label: testName, |
| ); |
| } |
| } |
| |
| /// Performs the desired diff checks for [test] and reports the results. |
| void diffCheck(HotReloadTest test) { |
| var diffMode = options.diffMode; |
| if (fe_shared.isWindows && diffMode != DiffMode.ignore) { |
| _print( |
| "Diffing isn't supported on Windows. Defaulting to 'ignore'.", |
| label: test.name, |
| ); |
| diffMode = DiffMode.ignore; |
| } |
| switch (diffMode) { |
| case DiffMode.check: |
| _print('Checking source file diffs.', label: test.name); |
| for (final file in test.files) { |
| _debugPrint( |
| 'Checking source file diffs for $file.', |
| label: test.name, |
| ); |
| var edits = file.edits.iterator; |
| if (!edits.moveNext()) { |
| throw Exception('Test file created with no generation edits.'); |
| } |
| var currentEdit = edits.current; |
| // Check that the first file does not have a diff. |
| var (currentCode, currentDiff) = _splitTestByDiff( |
| currentEdit.fileUri, |
| ); |
| var diffCount = testDiffSeparator.allMatches(currentDiff).length; |
| if (diffCount == 0) { |
| reportDiffOutcome( |
| test.name, |
| currentEdit.fileUri, |
| 'First generation does not have a diff', |
| true, |
| ); |
| } else { |
| reportDiffOutcome( |
| test.name, |
| currentEdit.fileUri, |
| 'First generation should not have any diffs', |
| false, |
| ); |
| } |
| while (edits.moveNext()) { |
| final previousEdit = currentEdit; |
| currentEdit = edits.current; |
| final previousCode = currentCode; |
| (currentCode, currentDiff) = _splitTestByDiff(currentEdit.fileUri); |
| // Check that exactly one diff exists. |
| diffCount = testDiffSeparator.allMatches(currentDiff).length; |
| if (diffCount == 0) { |
| reportDiffOutcome( |
| test.name, |
| currentEdit.fileUri, |
| 'No diff found for ${currentEdit.fileUri}', |
| false, |
| ); |
| continue; |
| } else if (diffCount > 1) { |
| reportDiffOutcome( |
| test.name, |
| currentEdit.fileUri, |
| 'Too many diffs found for ${currentEdit.fileUri} ' |
| '(expected 1)', |
| false, |
| ); |
| continue; |
| } |
| // Check that the diff is properly generated. |
| // 'main' is allowed to have empty diffs since the first |
| // generation must be specified. |
| if (file.baseName != 'main.dart' && previousCode == currentCode) { |
| // TODO(markzipan): Should we make this an error? |
| _print( |
| 'Extraneous file detected. ${currentEdit.fileUri} ' |
| 'is identical to ${previousEdit.fileUri} and can be removed.', |
| label: test.name, |
| ); |
| } |
| final previousTempUri = generatedCodeDir.uri.resolve('__previous'); |
| final currentTempUri = generatedCodeDir.uri.resolve('__current'); |
| // Avoid 'No newline at end of file' messages in the output by |
| // appending a newline to the trimmed source code strings. |
| File.fromUri(previousTempUri).writeAsStringSync('$previousCode\n'); |
| File.fromUri(currentTempUri).writeAsStringSync('$currentCode\n'); |
| final diffOutput = _diffWithFileUris( |
| previousTempUri, |
| currentTempUri, |
| label: test.name, |
| ); |
| File.fromUri(previousTempUri).deleteSync(); |
| File.fromUri(currentTempUri).deleteSync(); |
| if (diffOutput != currentDiff) { |
| reportDiffOutcome( |
| test.name, |
| currentEdit.fileUri, |
| 'Unexpected diff found for ${currentEdit.fileUri}:\n' |
| '-- Expected --\n$diffOutput\n' |
| '-- Actual --\n$currentDiff', |
| false, |
| ); |
| } else { |
| reportDiffOutcome( |
| test.name, |
| currentEdit.fileUri, |
| 'Correct diff found for ${currentEdit.fileUri}', |
| true, |
| ); |
| } |
| } |
| } |
| case DiffMode.write: |
| _print('Generating source file diffs.', label: test.name); |
| for (final file in test.files) { |
| _debugPrint( |
| 'Generating source file diffs for ${file.edits}.', |
| label: test.name, |
| ); |
| var edits = file.edits.iterator; |
| if (!edits.moveNext()) { |
| throw Exception('Test file created with no generation edits.'); |
| } |
| var currentEdit = edits.current; |
| var (currentCode, currentDiff) = _splitTestByDiff( |
| currentEdit.fileUri, |
| ); |
| // Don't generate a diff for the first file of any generation, |
| // and delete any diffs encountered. |
| if (currentDiff.isNotEmpty) { |
| _print( |
| 'Removing extraneous diff from ${currentEdit.fileUri}', |
| label: test.name, |
| ); |
| File.fromUri(currentEdit.fileUri).writeAsStringSync(currentCode); |
| } |
| while (edits.moveNext()) { |
| currentEdit = edits.current; |
| final previousCode = currentCode; |
| (currentCode, currentDiff) = _splitTestByDiff(currentEdit.fileUri); |
| final previousTempUri = generatedCodeDir.uri.resolve('__previous'); |
| final currentTempUri = generatedCodeDir.uri.resolve('__current'); |
| // Avoid 'No newline at end of file' messages in the output by |
| // appending a newline to the trimmed source code strings. |
| File.fromUri(previousTempUri).writeAsStringSync('$previousCode\n'); |
| File.fromUri(currentTempUri).writeAsStringSync('$currentCode\n'); |
| final diffOutput = _diffWithFileUris( |
| previousTempUri, |
| currentTempUri, |
| label: test.name, |
| ); |
| File.fromUri(previousTempUri).deleteSync(); |
| File.fromUri(currentTempUri).deleteSync(); |
| // Write an empty line between the code and the diff comment to |
| // agree with the dart formatter. |
| final newCurrentText = '$currentCode\n\n$diffOutput\n'; |
| File.fromUri(currentEdit.fileUri).writeAsStringSync(newCurrentText); |
| _print( |
| 'Writing updated diff to $currentEdit.fileUri', |
| label: test.name, |
| ); |
| _debugPrint('Updated diff:\n$diffOutput', label: test.name); |
| reportDiffOutcome( |
| test.name, |
| currentEdit.fileUri, |
| 'diff updated for $currentEdit.fileUri', |
| true, |
| ); |
| } |
| } |
| case DiffMode.ignore: |
| _print('Ignoring source file diffs.', label: test.name); |
| for (final file in test.files) { |
| for (final edit in file.edits) { |
| var uri = edit.fileUri; |
| _debugPrint( |
| 'Ignoring source file diffs for $uri.', |
| label: test.name, |
| ); |
| reportDiffOutcome(test.name, uri, 'Ignoring diff for $uri', true); |
| } |
| } |
| } |
| } |
| |
| /// Attempts to extract a reload receipt from [line] and if found passes it as |
| /// a [HotReloadReceipt] to [onReloadReceipt]. |
| /// |
| /// If no reload receipt is found the line is passed to [orElse]. [test] is |
| /// only used to label debug logs. |
| void parseReloadReceipt( |
| HotReloadTest test, |
| String line, |
| Function(HotReloadReceipt) onReloadReceipt, |
| Function(String) orElse, |
| ) { |
| if (line.startsWith(HotReloadReceipt.hotReloadReceiptTag)) { |
| // Reload utils write reload receipts as output lines with a leading tag |
| // so the lines can be extracted here. |
| final reloadReceipt = HotReloadReceipt.fromJson( |
| jsonDecode(line.substring(HotReloadReceipt.hotReloadReceiptTag.length)) |
| as Map<String, dynamic>, |
| ); |
| onReloadReceipt(reloadReceipt); |
| _debugPrint( |
| [ |
| 'Generation ${reloadReceipt.generation} ' |
| 'was ${reloadReceipt.status.name}', |
| if (reloadReceipt.status == Status.rejected) |
| ': "${reloadReceipt.rejectionMessage}"' |
| else |
| '.', |
| ].join(), |
| label: test.name, |
| ); |
| } else { |
| orElse(line); |
| } |
| } |
| |
| /// Validates all reloads/restarts and returns `true` if they were performed |
| /// as expected during the test run. |
| /// |
| /// This serves as a sanity check to ensure that just because no errors were |
| /// reported, the test still ran through all expected generations with the |
| /// expected accept/reject/restart status. |
| bool reloadReceiptCheck( |
| HotReloadTest test, |
| List<HotReloadReceipt> reloadReceipts, |
| ) { |
| // Check number of reloads. |
| // No reload receipt will appear for generation 0. |
| final expectedReloadCount = test.generationCount - 1; |
| if (reloadReceipts.length != expectedReloadCount) { |
| _print( |
| 'Unexpected number of reloads/restarts were performed. ' |
| 'Expected: $expectedReloadCount Actual: ${reloadReceipts.length}\n' |
| '${reloadReceipts.join('\n')}', |
| label: test.name, |
| ); |
| return false; |
| } |
| var expectedGeneration = 0; |
| for (final reloadReceipt in reloadReceipts) { |
| expectedGeneration++; |
| // Validate order of reloads. |
| if (reloadReceipt.generation != expectedGeneration) { |
| _print( |
| 'Generation reload order mismatch. ' |
| 'Expected: $expectedGeneration ' |
| 'Actual: ${reloadReceipt.generation}\n' |
| '${reloadReceipts.join('\n')}', |
| label: test.name, |
| ); |
| return false; |
| } |
| final expectedError = test.expectedErrors[reloadReceipt.generation]; |
| // Check the reloads match the expected accept/reject. |
| if (reloadReceipt.status == Status.accepted && expectedError != null) { |
| _print( |
| 'Generation ${reloadReceipt.generation} was not rejected. ' |
| 'Expected: $expectedError', |
| label: test.name, |
| ); |
| return false; |
| } |
| if (reloadReceipt.status == Status.rejected) { |
| if (expectedError == null) { |
| _print( |
| 'Generation ${reloadReceipt.generation} was unexpectedly ' |
| 'rejected: ${reloadReceipt.rejectionMessage}', |
| label: test.name, |
| ); |
| return false; |
| } |
| final rejectionMessage = reloadReceipt.rejectionMessage; |
| if (rejectionMessage == null || |
| !rejectionMessage.contains(expectedError)) { |
| _print( |
| 'Generation ${reloadReceipt.generation} was rejected but error ' |
| 'was unexpected. Expected: "$expectedError" Actual: ' |
| '${reloadReceipt.rejectionMessage}', |
| label: test.name, |
| ); |
| return false; |
| } |
| } |
| } |
| _debugPrint( |
| 'Generation reloads matched expected outcomes:\n' |
| ' ${reloadReceipts.join('\n ')}', |
| label: test.name, |
| ); |
| return true; |
| } |
| |
| /// Copy all files in [test] for the given [generation] into the snapshot |
| /// directory and returns uris of all the files copied. |
| /// |
| /// The uris describe the copy destination in the form of the hot reload file |
| /// system scheme. |
| List<String> copyGenerationSources(HotReloadTest test, int generation) { |
| _debugPrint('Entering generation $generation', label: test.name); |
| final updatedFilesInCurrentGeneration = <String>[]; |
| // Copy all files in this generation to the snapshot directory with their |
| // names restored (e.g., path/to/main' from 'path/to/main.0.dart). |
| // TODO(markzipan): support subdirectories. |
| _debugPrint( |
| 'Copying Dart files to snapshot directory: ' |
| '${snapshotDir.uri.toFilePath()}', |
| label: test.name, |
| ); |
| for (final file in test.filesEditedInGeneration(generation)) { |
| final fileSnapshotUri = snapshotDir.uri.resolve(file.baseName); |
| final editUri = file.editForGeneration(generation).fileUri; |
| File.fromUri(editUri).copySync(fileSnapshotUri.toFilePath()); |
| final relativeSnapshotPath = fe_shared.relativizeUri( |
| snapshotDir.uri, |
| fileSnapshotUri, |
| fe_shared.isWindows, |
| ); |
| final snapshotPathWithScheme = |
| '$filesystemScheme:///$relativeSnapshotPath'; |
| updatedFilesInCurrentGeneration.add(snapshotPathWithScheme); |
| } |
| _print( |
| 'Updated files in generation $generation: ' |
| '$updatedFilesInCurrentGeneration', |
| label: test.name, |
| ); |
| return updatedFilesInCurrentGeneration; |
| } |
| |
| /// Prints messages if 'debug' mode is enabled. |
| void _debugPrint(String message, {String? label}) { |
| if (options.debug) { |
| final labelText = label == null ? '' : '($label)'; |
| print('DEBUG$labelText: $message'); |
| } |
| } |
| |
| /// Prints messages if 'verbose' mode is enabled. |
| void _print(String message, {String? label}) { |
| if (options.verbose) { |
| final labelText = label == null ? '' : '($label)'; |
| print('hot_reload_test$labelText: $message'); |
| } |
| } |
| |
| /// Returns the diff'd output between two files. |
| /// |
| /// These diffs are appended at the end of updated file generations for better |
| /// test readability. |
| /// |
| /// If [commented] is set, the output will be wrapped in multiline comments |
| /// and the diff separator. |
| /// |
| /// If [trimHeaders] is set, the leading '+++' and '---' file headers will be |
| /// removed. |
| String _diffWithFileUris( |
| Uri file1, |
| Uri file2, { |
| String label = '', |
| bool commented = true, |
| bool trimHeaders = true, |
| }) { |
| final file1Path = file1.toFilePath(); |
| final file2Path = file2.toFilePath(); |
| final diffArgs = ['diff', '-u', file1Path, file2Path]; |
| _debugPrint( |
| "Running diff with 'git diff ${diffArgs.join(' ')}'.", |
| label: label, |
| ); |
| final diffProcess = Process.runSync('git', diffArgs); |
| final errOutput = diffProcess.stderr as String; |
| if (errOutput.isNotEmpty) { |
| throw Exception('git diff failed with:\n$errOutput'); |
| } |
| var output = diffProcess.stdout as String; |
| if (trimHeaders) { |
| // Skip the diff header. 'git diff' has 5 lines in its header. |
| // TODO(markzipan): Add support for Windows-style line endings. |
| output = output.split('\n').skip(5).join('\n'); |
| } |
| return commented ? '$testDiffSeparator\n/*\n$output*/' : output; |
| } |
| |
| /// Returns the code and diff portions of [file] with all leading and trailing |
| /// whitespace trimmed. |
| (String, String) _splitTestByDiff(Uri file) { |
| final text = File.fromUri(file).readAsStringSync(); |
| final diffIndex = text.indexOf(testDiffSeparator); |
| final diffSplitIndex = diffIndex == -1 ? text.length - 1 : diffIndex; |
| final codeText = text.substring(0, diffSplitIndex).trim(); |
| final diffText = text.substring(diffSplitIndex, text.length - 1).trim(); |
| return (codeText, diffText); |
| } |
| |
| /// Runs the [command] with [args] in [environment]. |
| /// |
| /// Will echo the commands to the console before running them when running in |
| /// `verbose` mode. |
| Future<Process> startProcess( |
| String name, |
| String command, |
| List<String> args, { |
| Map<String, String> environment = const {}, |
| ProcessStartMode mode = ProcessStartMode.normal, |
| }) { |
| if (options.verbose) { |
| print('Running $name:\n$command ${args.join(' ')}\n'); |
| if (environment.isNotEmpty) { |
| var environmentVariables = environment.entries |
| .map((e) => '${e.key}: ${e.value}') |
| .join('\n'); |
| print('With environment:\n$environmentVariables\n'); |
| } |
| } |
| return Process.start(command, args, mode: mode, environment: environment); |
| } |
| |
| /// Reports test results to standard out as well as the output .json file if |
| /// requested. |
| Future<void> reportAllResults() async { |
| if (options.testResultsOutputDir != null) { |
| // Used to communicate individual test failures to our test bots. |
| final testOutcomeResults = testOutcomes.map((o) => o.toRecordJson()); |
| final testOutcomeLogs = testOutcomes.map((o) => o.toLogJson()); |
| final testResultsOutputDir = options.testResultsOutputDir!; |
| _print('Saving test results to ${testResultsOutputDir.toFilePath()}.'); |
| |
| // Test outputs must have one JSON blob per line and be |
| // newline-terminated. |
| final testResultsUri = testResultsOutputDir.resolve('results.json'); |
| final testResultsSink = File.fromUri(testResultsUri).openWrite(); |
| testOutcomeResults.forEach(testResultsSink.writeln); |
| await testResultsSink.flush(); |
| await testResultsSink.close(); |
| |
| final testLogsUri = testResultsOutputDir.resolve('logs.json'); |
| if (Platform.isWindows) { |
| // TODO(55297): Logs are disabled on windows until this but is fixed. |
| _print( |
| 'Logs are not written on Windows. ' |
| 'See: https://github.com/dart-lang/sdk/issues/55297', |
| ); |
| } else { |
| final testLogsSink = File.fromUri(testLogsUri).openWrite(); |
| testOutcomeLogs.forEach(testLogsSink.writeln); |
| await testLogsSink.flush(); |
| await testLogsSink.close(); |
| } |
| _print( |
| 'Emitted logs to ${testResultsUri.toFilePath()} ' |
| 'and ${testLogsUri.toFilePath()}.', |
| ); |
| } |
| if (testOutcomes.isEmpty) { |
| print( |
| 'No tests ran: no sub-directories in ${allTestsUri.toFilePath()} ' |
| 'match the provided filter:\n' |
| '${options.testNameFilter}', |
| ); |
| exit(0); |
| } |
| // Report failed tests. |
| var failedTests = testOutcomes.where( |
| (outcome) => !outcome.matchedExpectations, |
| ); |
| if (failedTests.isNotEmpty) { |
| print('Some tests failed:'); |
| failedTests.forEach((outcome) { |
| print(outcome.testName); |
| }); |
| // Exit cleanly after writing test results. |
| exit(0); |
| } |
| } |
| } |
| |
| abstract class DdcStandaloneSuiteRunner |
| extends HotReloadSuiteRunner<BazelWorkerDriver> |
| with DdcResolver { |
| String? acceptedDill; |
| String? pendingDill; |
| late DdcStandaloneFileResolver _fileResolver; |
| FileResolver get fileResolver => _fileResolver; |
| |
| DdcStandaloneSuiteRunner(super.options); |
| |
| @override |
| String get modeNamePrefix => 'standalone_'; |
| |
| @override |
| BazelWorkerDriver createController() { |
| final sdkPath = Uri.parse(p.dirname(Platform.resolvedExecutable)); |
| final aotRuntime = sdkPath.resolve('bin/dartaotruntime'); |
| final ddcSnapshot = sdkPath.resolve( |
| 'bin/snapshots/dartdevc_aot.dart.snapshot', |
| ); |
| _print('Starting DDC worker with: ${aotRuntime.path} ${ddcSnapshot.path}'); |
| |
| return BazelWorkerDriver( |
| () => startProcess('DDC', aotRuntime.path, [ |
| ddcSnapshot.path, |
| '--persistent_worker', |
| ]), |
| maxWorkers: 1, |
| maxRetries: 0, |
| ); |
| } |
| |
| String _createDeltaKernelPath(int generation) { |
| return pendingDill = emittedFilesDir.uri |
| .resolve('delta$generation.dill') |
| .path; |
| } |
| |
| List<String> _getDdcArguments(String deltaKernelPath, bool recompile) { |
| return [ |
| '--modules=ddc', |
| '--canary', |
| '--packages=${packageConfigUri.toFilePath()}', |
| '--dart-sdk-summary=$ddcPlatformDillFromSdkRoot', |
| '--reload-delta-kernel=$deltaKernelPath', |
| '--multi-root=${snapshotDir.uri.toFilePath()}', |
| '--multi-root-scheme=$filesystemScheme', |
| '--experimental-output-compiled-kernel', |
| '--experimental-emit-debug-metadata', |
| if (recompile && acceptedDill != null) |
| '--reload-last-accepted-kernel=$acceptedDill', |
| '-o', |
| emittedFilesDir.uri.resolve('out.js').path, |
| snapshotEntrypointWithScheme, |
| ]; |
| } |
| |
| @override |
| Future<CompiledOutput> sendCompile( |
| BazelWorkerDriver controller, |
| HotReloadTest test, |
| ) async { |
| final deltaKernelPath = _createDeltaKernelPath(0); |
| final response = await controller.doWork( |
| WorkRequest( |
| arguments: _getDdcArguments(deltaKernelPath, false), |
| inputs: [Input(path: deltaKernelPath)], |
| ), |
| ); |
| return DdcWorkerOutput(outputDillUri.path, response); |
| } |
| |
| @override |
| Future<CompiledOutput> sendRecompile( |
| BazelWorkerDriver controller, |
| HotReloadTest test, |
| int generation, |
| List<String> updatedFiles, |
| ) async { |
| final deltaKernelPath = _createDeltaKernelPath(generation); |
| final response = await controller.doWork( |
| WorkRequest( |
| arguments: _getDdcArguments(deltaKernelPath, true), |
| inputs: [Input(path: deltaKernelPath)], |
| ), |
| ); |
| return DdcWorkerOutput(outputDillUri.path, response); |
| } |
| |
| @override |
| void accept(BazelWorkerDriver controller) { |
| if (pendingDill == null) { |
| throw StateError('No pending dill to accept.'); |
| } |
| acceptedDill = pendingDill; |
| pendingDill = null; |
| } |
| |
| @override |
| Future<void> reject(BazelWorkerDriver controller) async { |
| pendingDill = null; |
| } |
| |
| @override |
| Future<void> stopController(BazelWorkerDriver controller) async { |
| await controller.terminateWorkers(); |
| _print('DDC worker has shut down.'); |
| } |
| |
| @override |
| void registerOutputDirectory(HotReloadTest test, Uri outputDirectory) { |
| _fileResolver = DdcStandaloneFileResolver(test, outputDirectory); |
| } |
| |
| @override |
| void emitFiles(HotReloadTest test, CompiledOutput output, int generation) { |
| _fileResolver.saveGenerationMetadata( |
| test, |
| File(output.outputDillPath).parent.uri.resolve('out.js.metadata'), |
| generation, |
| ); |
| } |
| } |
| |
| /// A mixin that provides common logic for resolving DDC compilation outputs. |
| /// |
| /// [T] is the controller type being used to compile the DDC targets. |
| mixin DdcResolver<T> on HotReloadSuiteRunner<T> { |
| void accept(T controller); |
| Future<void> reject(T controller); |
| void emitFiles(HotReloadTest test, CompiledOutput output, int generation); |
| |
| @override |
| Future<bool> resolveOutput( |
| T controller, |
| HotReloadTest test, |
| CompiledOutput output, |
| int generation, |
| ) async { |
| final expectedError = test.expectedErrors[generation]; |
| if (output.errorCount > 0) { |
| // Frontend Server reported compile errors. |
| await reject(controller); |
| if (expectedError != null && output.outputText.contains(expectedError)) { |
| // If the failure was an expected rejection it is OK to continue |
| // compiling generations and run the test. |
| _debugPrint( |
| 'DDC rejected generation $generation: ' |
| '"${output.outputText}"', |
| label: test.name, |
| ); |
| // Remove the expected error from this test to avoid expecting it to |
| // appear as a rejection at runtime. |
| test.expectedErrors[generation] = |
| HotReloadReceipt.compileTimeErrorMessage; |
| return true; |
| } else { |
| // Fail if the error was unexpected. |
| await reportTestOutcome( |
| test.name, |
| 'Test failed with compile error: ${output.outputText}', |
| false, |
| ); |
| return false; |
| } |
| } else { |
| // No errors were reported. |
| accept(controller); |
| } |
| if (expectedError != null) { |
| // A rejection error was expected but not seen. |
| await reportTestOutcome( |
| test.name, |
| 'Missing rejection for generation $generation. ' |
| 'Expected: "$expectedError"', |
| false, |
| ); |
| return false; |
| } |
| _debugPrint( |
| 'Frontend Server successfully compiled outputs to: ' |
| '${output.outputDillPath}', |
| label: test.name, |
| ); |
| |
| emitFiles(test, output, generation); |
| return true; |
| } |
| } |
| |
| class ChromeStandaloneSuiteRunner extends DdcStandaloneSuiteRunner |
| with ChromeTestRunner { |
| ChromeStandaloneSuiteRunner(super.options); |
| } |
| |
| class D8StandaloneSuiteRunner extends DdcStandaloneSuiteRunner |
| with D8TestRunner { |
| D8StandaloneSuiteRunner(super.options); |
| } |
| |
| /// Hot reload test suite runner for backend agnostic behavior compiled by the |
| /// FE server. |
| abstract class HotReloadFeServerSuiteRunner |
| extends HotReloadSuiteRunner<HotReloadFrontendServerController> { |
| /// The output location for the incremental .dill file created by the front |
| /// end server. |
| late final Uri outputIncrementalDillUri = emittedFilesDir.uri.resolve( |
| 'output_incremental.dill', |
| ); |
| |
| HotReloadFeServerSuiteRunner(super.options); |
| |
| /// Custom command line arguments passed to the Front End Server on startup. |
| List<String> get platformFrontEndServerArgs; |
| |
| /// Returns a controller for a freshly started front end server instance to |
| /// handle compile and recompile requests for a hot reload test. |
| @override |
| HotReloadFrontendServerController createController() { |
| _print('Initializing the Frontend Server.'); |
| final fesArgs = [ |
| '--incremental', |
| '--filesystem-root=${snapshotDir.uri.toFilePath()}', |
| '--filesystem-scheme=$filesystemScheme', |
| '--output-dill=${outputDillUri.toFilePath()}', |
| '--output-incremental-dill=${outputIncrementalDillUri.toFilePath()}', |
| '--packages=${packageConfigUri.toFilePath()}', |
| '--sdk-root=${sdkRoot.toFilePath()}', |
| '--verbosity=${options.verbose ? 'all' : 'info'}', |
| ...platformFrontEndServerArgs, |
| ]; |
| return HotReloadFrontendServerController(fesArgs)..start(); |
| } |
| |
| @override |
| Future<void> stopController( |
| HotReloadFrontendServerController controller, |
| ) async { |
| await controller.stop(); |
| _print('Frontend Server has shut down.'); |
| } |
| |
| @override |
| Future<CompiledOutput> sendCompile( |
| HotReloadFrontendServerController controller, |
| HotReloadTest test, |
| ) async { |
| _debugPrint( |
| 'Compiling snapshot entrypoint: $snapshotEntrypointWithScheme', |
| label: test.name, |
| ); |
| final outputDillPath = outputDillUri.toFilePath(); |
| final compilerOutput = await controller.sendCompile( |
| snapshotEntrypointWithScheme, |
| ); |
| return FrontendServerOutput(outputDillPath, compilerOutput); |
| } |
| |
| @override |
| Future<CompiledOutput> sendRecompile( |
| HotReloadFrontendServerController controller, |
| HotReloadTest test, |
| int generation, |
| List<String> updatedFiles, |
| ) async { |
| final outputDillPath = outputIncrementalDillUri.toFilePath(); |
| final compilerOutput = await controller.sendRecompile( |
| snapshotEntrypointWithScheme, |
| invalidatedFiles: updatedFiles, |
| recompileRestart: test.isHotRestart[generation]!, |
| ); |
| return FrontendServerOutput(outputDillPath, compilerOutput); |
| } |
| } |
| |
| /// Hot reload test suite runner for DDC specific behavior compiled by the FE |
| /// server that is agnostic to the environment (d8 vs. chrome) where the |
| /// compiled code is eventually run. |
| abstract class DdcFeServerSuiteRunner extends HotReloadFeServerSuiteRunner |
| with DdcResolver { |
| late HotReloadMemoryFilesystem filesystem; |
| FileResolver get fileResolver => filesystem; |
| |
| DdcFeServerSuiteRunner(super.options); |
| |
| @override |
| void registerOutputDirectory(HotReloadTest test, Uri outputDirectory) { |
| filesystem = HotReloadMemoryFilesystem(outputDirectory); |
| } |
| |
| @override |
| List<String> get platformFrontEndServerArgs => [ |
| '--dartdevc-module-format=ddc', |
| '--dartdevc-canary', |
| '--platform=$ddcPlatformDillFromSdkRoot', |
| '--target=dartdevc', |
| ]; |
| |
| @override |
| void emitFiles(HotReloadTest test, CompiledOutput output, int generation) { |
| final outputDillPath = output.outputDillPath; |
| // Update the memory filesystem with the newly-created JS files. |
| _print( |
| 'Loading generation $generation files into the memory filesystem.', |
| label: test.name, |
| ); |
| final codeFile = File('$outputDillPath.sources'); |
| final manifestFile = File('$outputDillPath.json'); |
| final sourcemapFile = File('$outputDillPath.map'); |
| filesystem.update( |
| codeFile, |
| manifestFile, |
| sourcemapFile, |
| generation: '$generation', |
| ); |
| // Write JS files and sourcemaps to their respective generation. |
| _print('Writing generation $generation assets.', label: test.name); |
| _debugPrint( |
| 'Writing JS assets to ${filesystem.jsRootUri.path}', |
| label: test.name, |
| ); |
| filesystem.writeToDisk(filesystem.jsRootUri, generation: '$generation'); |
| } |
| |
| @override |
| void accept(HotReloadFrontendServerController controller) { |
| controller.sendAccept(); |
| } |
| |
| @override |
| Future<void> reject(HotReloadFrontendServerController controller) => |
| controller.sendReject(); |
| } |
| |
| mixin ChromeTestRunner<T> on HotReloadSuiteRunner<T> { |
| FileResolver get fileResolver; |
| |
| @override |
| Future<void> runSuite() async { |
| // Only allow Chrome when debugging a single test. |
| // TODO(markzipan): Add support for full Chrome testing. |
| if (options.runtime == RuntimePlatforms.chrome) { |
| var matchingTests = Directory.fromUri(allTestsUri).listSync().where(( |
| testDir, |
| ) { |
| if (testDir is! Directory) return false; |
| final testDirParts = testDir.uri.pathSegments; |
| final testName = testDirParts[testDirParts.length - 2]; |
| return options.testNameFilter.hasMatch(testName); |
| }); |
| |
| if (matchingTests.length > 1) { |
| throw Exception( |
| 'Chrome is only supported when debugging a single test.' |
| "Please filter on a single test with '-f'.", |
| ); |
| } |
| } |
| await super.runSuite(); |
| } |
| |
| @override |
| Future<bool> runTest( |
| HotReloadTest test, |
| Directory tempDirectory, |
| IOSink outputSink, |
| ) async { |
| // TODO(markzipan): Chrome tests are currently only configured for |
| // debugging a single test instance. This is due to: |
| // 1) Our tests not capturing test success/failure signals. These must be |
| // determined programmatically since Chrome console errors are unrelated |
| // to the Chrome process's stderr. |
| // 2) Chrome not closing after a test. We need to add logic to detect when |
| // to either shut down Chrome or load the next test (reusing instances). |
| _print('Creating Chrome hot reload test suite.', label: test.name); |
| final mainEntrypointJSUri = tempDirectory.uri.resolve( |
| 'generation0/main_module.bootstrap.js', |
| ); |
| final bootstrapJSUri = tempDirectory.uri.resolve( |
| 'generation0/bootstrap.js', |
| ); |
| final bootstrapHtmlUri = tempDirectory.uri.resolve( |
| 'generation0/index.html', |
| ); |
| _print('Preparing to run Chrome test.', label: test.name); |
| var bootstrapHtml = |
| ''' |
| <html> |
| <head> |
| <base href="/"> |
| </head> |
| <body> |
| <script src="$bootstrapJSUri"></script> |
| </body> |
| </html> |
| '''; |
| final entrypointLibraryExportName = ddc_names.libraryUriToJsIdentifier( |
| snapshotEntrypointUri, |
| ); |
| final ( |
| chromeMainEntrypointJS, |
| chromeBootstrapJS, |
| ) = ddc_helpers.generateChromeBootstrapperFiles( |
| ddcModuleLoaderJsPath: escapedString(ddcModuleLoaderJSUri.toFilePath()), |
| dartSdkJsPath: escapedString(dartSdkJSUri.toFilePath()), |
| entrypointModuleName: escapedString(entrypointModuleName), |
| mainModuleEntrypointJsPath: escapedString( |
| mainEntrypointJSUri.toFilePath(), |
| ), |
| entrypointLibraryExportName: escapedString(entrypointLibraryExportName), |
| scriptDescriptors: fileResolver.scriptDescriptorForBootstrap, |
| modifiedFilesPerGeneration: fileResolver.generationsToModifiedFilePaths, |
| ); |
| _debugPrint( |
| 'Writing Chrome bootstrap files: ' |
| '$mainEntrypointJSUri, $bootstrapJSUri, $bootstrapHtmlUri', |
| label: test.name, |
| ); |
| File.fromUri(mainEntrypointJSUri).writeAsStringSync(chromeMainEntrypointJS); |
| File.fromUri(bootstrapJSUri).writeAsStringSync(chromeBootstrapJS); |
| final bootstrapHtmlFile = File.fromUri(bootstrapHtmlUri) |
| ..writeAsStringSync(bootstrapHtml); |
| _debugPrint('Running test in Chrome.', label: test.name); |
| final reloadReceipts = <HotReloadReceipt>[]; |
| final config = ddc_helpers.ChromeConfiguration(sdkRoot); |
| // Specifying '--user-data-dir' forces Chrome to not reuse an instance. |
| final chromeDataDir = Directory.systemTemp.createTempSync(); |
| final process = |
| await startProcess('Chrome', config.binary.toFilePath(), [ |
| '--no-first-run', |
| '--no-default-browser-check', |
| '--allow-file-access-from-files', |
| '--user-data-dir=${chromeDataDir.path}', |
| '--disable-default-apps', |
| '--disable-translate', |
| // These two flags are used to get the Chrome process to output messages |
| // to stderr so we can read the console.log messages. |
| // TODO(nshahan): Update if there is an easier way to get console.log |
| // messages. |
| '--enable-logging=stderr', |
| '--v=1', |
| bootstrapHtmlFile.path, |
| ]).then((process) { |
| StreamSubscription stdoutSubscription; |
| StreamSubscription stderrSubscription; |
| // The console.log messages in the output are prefixed with a header like: |
| // [42029:259:1126/154323.385793:INFO:CONSOLE(27547)] |
| final chromeConsoleLog = RegExp( |
| // Line starts with digits separated by ":", "." or "\"." |
| r'^\[[\d:\.\/]+' |
| // Followed by a console tag then digits in parenthesis and a space. |
| r':INFO:CONSOLE\(\d+\)\] ' |
| // Followed by the logged message in quotes. |
| r'"(?<consoleLog>.+)"' |
| // Followed by a comma and the source location. |
| r', source:.+$', |
| ); |
| |
| var stdoutDone = Completer<void>(); |
| var stderrDone = Completer<void>(); |
| |
| void closeStdout([_]) { |
| if (!stdoutDone.isCompleted) stdoutDone.complete(); |
| } |
| |
| void closeStderr([_]) { |
| if (!stderrDone.isCompleted) stderrDone.complete(); |
| } |
| |
| stdoutSubscription = process.stdout.listen( |
| (data) => outputSink.addStream, |
| onDone: closeStderr, |
| ); |
| |
| stderrSubscription = process.stderr |
| .transform(utf8.decoder) |
| .transform(LineSplitter()) |
| .listen((rawLine) { |
| final matches = chromeConsoleLog.allMatches(rawLine); |
| // Only considering lines that match the chrome console log format. |
| // All other output is discarded here because the logging is very |
| // chatty. |
| if (matches.isNotEmpty) { |
| final line = matches.single.namedGroup('consoleLog')!; |
| parseReloadReceipt( |
| test, |
| line, |
| reloadReceipts.add, |
| outputSink.writeln, |
| ); |
| } |
| }, onDone: closeStderr); |
| |
| process.exitCode.then((exitCode) { |
| stdoutSubscription.cancel(); |
| stderrSubscription.cancel(); |
| closeStdout(); |
| closeStderr(); |
| }); |
| |
| Future.wait([stdoutDone.future, stderrDone.future]).then((_) { |
| _debugPrint( |
| 'Chrome process successfully shut down.', |
| label: test.name, |
| ); |
| }); |
| |
| return process; |
| }); |
| |
| return await process.exitCode == 0 && |
| reloadReceiptCheck(test, reloadReceipts); |
| } |
| } |
| |
| mixin D8TestRunner<T> on HotReloadSuiteRunner<T> { |
| FileResolver get fileResolver; |
| |
| @override |
| Future<bool> runTest( |
| HotReloadTest test, |
| Directory tempDirectory, |
| IOSink outputSink, |
| ) async { |
| _print('Creating D8 hot reload test suite.', label: test.name); |
| final bootstrapJSUri = tempDirectory.uri.resolve( |
| 'generation0/bootstrap.js', |
| ); |
| _print('Preparing to run D8 test.', label: test.name); |
| final d8BootstrapJS = ddc_helpers.generateD8Bootstrapper( |
| ddcModuleLoaderJsPath: escapedString(ddcModuleLoaderJSUri.toFilePath()), |
| dartSdkJsPath: escapedString(dartSdkJSUri.toFilePath()), |
| entrypointModuleName: escapedString(entrypointModuleName), |
| entrypointLibraryExportName: escapedString(entrypointLibraryExportName), |
| scriptDescriptors: fileResolver.scriptDescriptorForBootstrap, |
| modifiedFilesPerGeneration: fileResolver.generationsToModifiedFilePaths, |
| ); |
| _debugPrint('Writing D8 bootstrapper: $bootstrapJSUri', label: test.name); |
| final bootstrapJSFile = File.fromUri(bootstrapJSUri) |
| ..writeAsStringSync(d8BootstrapJS); |
| _debugPrint('Running test in D8.', label: test.name); |
| final reloadReceipts = <HotReloadReceipt>[]; |
| final config = ddc_helpers.D8Configuration(sdkRoot); |
| final process = await startProcess('D8', config.binary.toFilePath(), [ |
| config.sealNativeObjectScript.toFilePath(), |
| config.preamblesScript.toFilePath(), |
| bootstrapJSFile.path, |
| ]); |
| process.stdout |
| .transform(utf8.decoder) |
| .transform(LineSplitter()) |
| .listen( |
| (line) => parseReloadReceipt( |
| test, |
| line, |
| reloadReceipts.add, |
| outputSink.writeln, |
| ), |
| ); |
| return await process.exitCode == 0 && |
| reloadReceiptCheck(test, reloadReceipts); |
| } |
| } |
| |
| /// Hot reload test suite runner for behavior specific to DDC compiled code |
| /// running in Chrome. |
| class ChromeSuiteRunner extends DdcFeServerSuiteRunner with ChromeTestRunner { |
| ChromeSuiteRunner(super.options); |
| } |
| |
| /// Hot reload test suite runner for behavior specific to DDC compiled code |
| /// running in D8. |
| class D8SuiteRunner extends DdcFeServerSuiteRunner with D8TestRunner { |
| D8SuiteRunner(super.options); |
| } |
| |
| /// Hot reload test suite runner for behavior specific to the VM. |
| class VMSuiteRunner extends HotReloadFeServerSuiteRunner { |
| final String vmPlatformDillFromSdkRoot = fe_shared.relativizeUri( |
| sdkRoot, |
| buildRootUri.resolve('vm_platform.dill'), |
| fe_shared.isWindows, |
| ); |
| late Uri outputDirectoryUri; |
| |
| VMSuiteRunner(super.options); |
| |
| @override |
| List<String> get platformFrontEndServerArgs => [ |
| '--platform=$vmPlatformDillFromSdkRoot', |
| '--target=vm', |
| ]; |
| |
| @override |
| Future<bool> runTest( |
| HotReloadTest test, |
| Directory tempDirectory, |
| IOSink outputSink, |
| ) async { |
| final firstGenerationDillUri = tempDirectory.uri.resolve( |
| 'generation0/${test.name}.dill', |
| ); |
| // Start the VM at generation 0. |
| final vmArgs = [ |
| '--enable-vm-service=0', // 0 avoids port collisions. |
| '--disable-service-auth-codes', |
| '--disable-dart-dev', |
| firstGenerationDillUri.toFilePath(), |
| ]; |
| _debugPrint( |
| 'Starting VM with command: ' |
| '${Platform.executable} ${vmArgs.join(" ")}', |
| label: test.name, |
| ); |
| final reloadReceipts = <HotReloadReceipt>[]; |
| final vm = await Process.start(Platform.executable, vmArgs); |
| vm.stdout |
| .transform(utf8.decoder) |
| .transform(LineSplitter()) |
| .listen( |
| (line) => parseReloadReceipt(test, line, reloadReceipts.add, (line) { |
| _debugPrint('VM stdout: $line', label: test.name); |
| outputSink.writeln(line); |
| }), |
| ); |
| vm.stderr.transform(utf8.decoder).transform(LineSplitter()).listen(( |
| String err, |
| ) { |
| _debugPrint('VM stderr: $err', label: test.name); |
| outputSink.writeln(err); |
| }); |
| _print('Executing VM test.', label: test.name); |
| final testTimeoutSeconds = 10; |
| final vmExitCode = await vm.exitCode.timeout( |
| Duration(seconds: testTimeoutSeconds), |
| onTimeout: () { |
| final timeoutText = 'Test timed out after $testTimeoutSeconds seconds.'; |
| _print(timeoutText, label: test.name); |
| outputSink.writeln(timeoutText); |
| vm.kill(); |
| return 1; |
| }, |
| ); |
| return vmExitCode == 0 && reloadReceiptCheck(test, reloadReceipts); |
| } |
| |
| @override |
| Future<bool> resolveOutput( |
| HotReloadFrontendServerController controller, |
| HotReloadTest test, |
| CompiledOutput output, |
| int generation, |
| ) async { |
| final expectedError = test.expectedErrors[generation]; |
| var hasExpectedCompileError = false; |
| // Frontend Server reported compile errors. Fail if they weren't |
| // expected, and do not run tests. |
| if (output.errorCount > 0) { |
| await controller.sendReject(); |
| if (expectedError != null && output.outputText.contains(expectedError)) { |
| hasExpectedCompileError = true; |
| _debugPrint( |
| 'VM rejected generation $generation: ' |
| '"${output.outputText}"', |
| label: test.name, |
| ); |
| // Remove the expected error from this test to avoid expecting it to |
| // appear as a rejection at runtime. |
| test.expectedErrors[generation] = |
| HotReloadReceipt.compileTimeErrorMessage; |
| } else { |
| await reportTestOutcome( |
| test.name, |
| 'Test failed with compile error: ${output.outputText}', |
| false, |
| ); |
| return false; |
| } |
| } else if (test.expectedErrors.containsKey(generation)) { |
| // Automatically reject generations that are expected to be rejected so |
| // the front end server can update it's internal state correctly. This |
| // ensures the next delta will always be calculated from against the last |
| // accepted generation. The actual rejections will be validated when the |
| // test runs on the VM. |
| await controller.sendReject(); |
| _debugPrint( |
| 'VM compile automatically rejected generation: $generation.', |
| label: test.name, |
| ); |
| } else { |
| controller.sendAccept(); |
| } |
| |
| final outputDillPath = output.outputDillPath; |
| final dillOutputDir = Directory.fromUri( |
| outputDirectoryUri.resolve('generation$generation'), |
| ); |
| dillOutputDir.createSync(); |
| // Write an .error.dill file as a signal to the runtime utils that this |
| // generation contains compile time errors and should not be reloaded. |
| final dillOutputUri = hasExpectedCompileError |
| ? dillOutputDir.uri.resolve('${test.name}.error.dill') |
| : dillOutputDir.uri.resolve('${test.name}.dill'); |
| // Write dills to their respective generation. |
| _print('Writing generation $generation assets.', label: test.name); |
| _debugPrint( |
| 'Writing dill to ${dillOutputUri.toFilePath()}', |
| label: test.name, |
| ); |
| File(outputDillPath).copySync(dillOutputUri.toFilePath()); |
| return true; |
| } |
| |
| @override |
| void registerOutputDirectory(HotReloadTest test, Uri outputDirectory) { |
| outputDirectoryUri = outputDirectory; |
| } |
| } |
| |
| class DdcStandaloneFileResolver implements FileResolver { |
| final HotReloadTest test; |
| final Uri outputDirectory; |
| |
| final Map<int, (String, List<String>)> _perGenerationMetadata = {}; |
| final List<String> _firstGenerationLibraries = []; |
| late final String _firstGenerationFile; |
| |
| DdcStandaloneFileResolver(this.test, this.outputDirectory); |
| |
| void saveGenerationMetadata( |
| HotReloadTest test, |
| Uri metadataFileUri, |
| int generation, |
| ) { |
| final metadataFile = File.fromUri(metadataFileUri); |
| final metadataString = metadataFile.readAsStringSync(); |
| final metadataJson = jsonDecode(metadataString); |
| final librariesJson = metadataJson['libraries'] as List<dynamic>; |
| final libraryUris = [...librariesJson.map((e) => e['importUri'] as String)]; |
| final baseFilename = Uri.parse(metadataJson['moduleUri'] as String); |
| final generationDir = outputDirectory.resolve('generation$generation/'); |
| final renamedFilename = generationDir.resolve('out.js'); |
| _perGenerationMetadata[generation] = (renamedFilename.path, libraryUris); |
| if (generation == 0) { |
| _firstGenerationLibraries.addAll(libraryUris); |
| _firstGenerationFile = renamedFilename.toFilePath( |
| windows: Platform.isWindows, |
| ); |
| } |
| Directory.fromUri(generationDir).createSync(); |
| (File.fromUri(baseFilename)..copySync(renamedFilename.path)); |
| } |
| |
| // Test used to simulate DartPad style hot reload where only a single known |
| // library is edited. There are multiple libraries that get compiled into the |
| // JS bundle but only the main library needs to be updated. This ensures that |
| // the DDC runtime ignores the extra libraries. |
| static const String _mainOnlyTestName = 'main_only'; |
| |
| @override |
| List<Map<String, String?>> get scriptDescriptorForBootstrap { |
| // Only include a single library since the code for library is all included |
| // in the one file. |
| return <Map<String, String?>>[ |
| {'id': _firstGenerationLibraries.first, 'src': _firstGenerationFile}, |
| ]; |
| } |
| |
| @override |
| Map<String, List<List<String>>> get generationsToModifiedFilePaths { |
| if (test.name == _mainOnlyTestName) { |
| return { |
| for (var e in _perGenerationMetadata.entries) |
| '${e.key}': [ |
| [e.value.$2.firstWhere((l) => l.contains('main.dart')), e.value.$1], |
| ], |
| }; |
| } |
| return { |
| for (var e in _perGenerationMetadata.entries) |
| '${e.key}': e.value.$2.map((l) => [l, e.value.$1]).toList(), |
| }; |
| } |
| } |