| // 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: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:collection/collection.dart'; |
| import 'package:dev_compiler/dev_compiler.dart' as ddc_names |
| show libraryUriToJsIdentifier; |
| import 'package:front_end/src/compute_platform_binaries_location.dart' as fe; |
| 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/test_helpers.dart'; |
| |
| // Set an arbitrary cap on generations. |
| final globalMaxGenerations = 100; |
| |
| final testTimeoutSeconds = 10; |
| |
| // The separator between a test file and its inlined diff. |
| // |
| // All contents after this separator are considered are diff comments. |
| final testDiffSeparator = '/** DIFF **/'; |
| |
| final argParser = ArgParser() |
| ..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.', |
| ) |
| ..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.'); |
| |
| late final bool verbose; |
| late final bool debug; |
| |
| Future<void> main(List<String> args) async { |
| final argResults = argParser.parse(args); |
| final runtimePlatform = |
| RuntimePlatforms.values.byName(argResults['runtime'] as String); |
| final testNameFilter = RegExp(argResults['filter'] as String); |
| debug = argResults['debug'] as bool; |
| verbose = argResults['verbose'] as bool; |
| |
| // Used to communicate individual test failures to our test bots. |
| final emitTestResultsJson = argResults['output-directory'] != null; |
| final buildRootUri = fe.computePlatformBinariesLocation(forceBuildDir: true); |
| // We can use the outline instead of the full SDK dill here. |
| final ddcPlatformDillUri = buildRootUri.resolve('ddc_outline.dill'); |
| final vmPlatformDillUri = buildRootUri.resolve('vm_platform_strong.dill'); |
| |
| final sdkRoot = Platform.script.resolve('../../../'); |
| final packageConfigUri = sdkRoot.resolve('.dart_tool/package_config.json'); |
| final allTestsUri = sdkRoot.resolve('tests/hot_reload/'); |
| final soundStableDartSdkJsUri = |
| buildRootUri.resolve('gen/utils/ddc/stable/sdk/ddc/dart_sdk.js'); |
| final ddcModuleLoaderJsUri = |
| sdkRoot.resolve('pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js'); |
| final d8Config = ddc_helpers.D8Configuration(sdkRoot); |
| |
| // Contains generated code for all tests. |
| final generatedCodeDir = Directory.systemTemp.createTempSync(); |
| final generatedCodeUri = generatedCodeDir.uri; |
| _debugPrint('See generated hot reload framework code in $generatedCodeUri'); |
| |
| // The snapshot directory is a staging area the test framework uses to |
| // construct a compile-able test app across reload/restart generations. |
| final snapshotDir = Directory.fromUri(generatedCodeUri.resolve('.snapshot/')); |
| snapshotDir.createSync(); |
| final snapshotUri = snapshotDir.uri; |
| |
| // Contains files emitted from Frontend Server compiles and recompiles. |
| final frontendServerEmittedFilesDirUri = generatedCodeUri.resolve('.fes/'); |
| Directory.fromUri(frontendServerEmittedFilesDirUri).createSync(); |
| final outputDillUri = frontendServerEmittedFilesDirUri.resolve('output.dill'); |
| final outputIncrementalDillUri = |
| frontendServerEmittedFilesDirUri.resolve('output_incremental.dill'); |
| |
| // TODO(markzipan): Support custom entrypoints. |
| final snapshotEntrypointUri = snapshotUri.resolve('main.dart'); |
| final filesystemRootUri = snapshotUri; |
| final filesystemScheme = 'hot-reload-test'; |
| final snapshotEntrypointLibraryName = fe_shared.relativizeUri( |
| filesystemRootUri, snapshotEntrypointUri, fe_shared.isWindows); |
| final snapshotEntrypointWithScheme = |
| '$filesystemScheme:///$snapshotEntrypointLibraryName'; |
| |
| _print('Initializing the Frontend Server.'); |
| HotReloadFrontendServerController controller; |
| final commonArgs = [ |
| '--incremental', |
| '--filesystem-root=${snapshotUri.toFilePath()}', |
| '--filesystem-scheme=$filesystemScheme', |
| '--output-dill=${outputDillUri.toFilePath()}', |
| '--output-incremental-dill=${outputIncrementalDillUri.toFilePath()}', |
| '--packages=${packageConfigUri.toFilePath()}', |
| '--sdk-root=${sdkRoot.toFilePath()}', |
| '--verbosity=${verbose ? 'all' : 'info'}', |
| ]; |
| switch (runtimePlatform) { |
| case RuntimePlatforms.d8: |
| final ddcPlatformDillFromSdkRoot = fe_shared.relativizeUri( |
| sdkRoot, ddcPlatformDillUri, fe_shared.isWindows); |
| final fesArgs = [ |
| ...commonArgs, |
| '--dartdevc-module-format=ddc', |
| '--platform=$ddcPlatformDillFromSdkRoot', |
| '--target=dartdevc', |
| ]; |
| controller = HotReloadFrontendServerController(fesArgs); |
| break; |
| case RuntimePlatforms.chrome: |
| throw Exception('Unsupported platform: $runtimePlatform'); |
| case RuntimePlatforms.vm: |
| final vmPlatformDillFromSdkRoot = fe_shared.relativizeUri( |
| sdkRoot, vmPlatformDillUri, fe_shared.isWindows); |
| final fesArgs = [ |
| ...commonArgs, |
| '--platform=$vmPlatformDillFromSdkRoot', |
| '--target=vm', |
| ]; |
| controller = HotReloadFrontendServerController(fesArgs); |
| break; |
| } |
| controller.start(); |
| |
| Future<void> shutdown() async { |
| // Persist the temp directory for debugging. |
| await controller.stop(); |
| _print('Frontend Server has shut down.'); |
| if (!debug) { |
| generatedCodeDir.deleteSync(recursive: true); |
| } |
| } |
| |
| final testOutcomes = <TestResultOutcome>[]; |
| final validTestSourceName = RegExp(r'.*[a-zA-Z0-9]+.[0-9]+.dart'); |
| 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 (!testNameFilter.hasMatch(testName)) { |
| _print('Skipping test', label: testName); |
| continue; |
| } |
| |
| var outcome = TestResultOutcome( |
| configuration: argResults['named-configuration'] as String, |
| testName: testName, |
| ); |
| var stopwatch = Stopwatch()..start(); |
| |
| // Report results for this test's execution. |
| Future<void> reportTestOutcome(String testOutput, bool testPassed) async { |
| stopwatch.stop(); |
| outcome.elapsedTime = stopwatch.elapsed; |
| outcome.testOutput = testOutput; |
| outcome.matchedExpectations = testPassed; |
| testOutcomes.add(outcome); |
| if (testPassed) { |
| _print('PASSED with:\n $testOutput', label: testName); |
| } else { |
| _print('FAILED with:\n $testOutput', label: testName); |
| } |
| } |
| |
| // Report results for this test's sources' diff validations. |
| void reportDiffOutcome(Uri fileUri, String testOutput, bool testPassed) { |
| final filePath = fileUri.path; |
| var outcome = TestResultOutcome( |
| configuration: argResults['named-configuration'] as String, |
| testName: '$filePath-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); |
| } |
| } |
| |
| final tempUri = generatedCodeUri.resolve('$testName/'); |
| Directory.fromUri(tempUri).createSync(); |
| |
| _print('Generating test assets.', label: testName); |
| _debugPrint('Emitting JS code to ${tempUri.toFilePath()}.', |
| label: testName); |
| |
| var filesystem = HotReloadMemoryFilesystem(tempUri); |
| |
| // Perform checks on this test's files. Checks include: |
| // 1) Count the number of generations and ensure they're capped. |
| // 2) Validate or generate diffs if specified |
| // |
| // Assumes all files are named like '$name.$integer.dart', where 0 is the |
| // first generation. |
| // |
| // TODO(markzipan): Account for subdirectories. |
| var maxGenerations = 0; |
| late ReloadTestConfiguration testConfig; |
| // All files in this test clustered by file name - in generation order. |
| final filesByGeneration = <String, PriorityQueue<(int, Uri)>>{}; |
| |
| for (final file in testDir.listSync()) { |
| if (file is File) { |
| final fileName = file.uri.pathSegments.last; |
| // Process config files. |
| if (fileName == 'config.json') { |
| testConfig = ReloadTestConfiguration.fromJsonFile(file.uri); |
| } else if (fileName.endsWith('.dart')) { |
| if (!validTestSourceName.hasMatch(fileName)) { |
| throw Exception('Invalid test source file name: $fileName\n' |
| 'Valid names look like "file_name.10.dart".'); |
| } |
| final strippedName = |
| fileName.substring(0, fileName.length - '.dart'.length); |
| final generationIndex = strippedName.lastIndexOf('.'); |
| final generationId = |
| int.parse(strippedName.substring(generationIndex + 1)); |
| maxGenerations = max(maxGenerations, generationId); |
| final basename = strippedName.substring(0, generationIndex); |
| filesByGeneration |
| .putIfAbsent( |
| basename, |
| () => PriorityQueue( |
| ((int, Uri) a, (int, Uri) b) => a.$1 - b.$1)) |
| .add((generationId, file.uri)); |
| } |
| } |
| } |
| if (maxGenerations > globalMaxGenerations) { |
| throw Exception('Too many generations specified in test ' |
| '(requested: $maxGenerations, max: $globalMaxGenerations).'); |
| } |
| |
| var diffMode = argResults['diff']!; |
| if (fe_shared.isWindows && diffMode != 'ignore') { |
| _print("Diffing isn't supported on Windows. Defaulting to 'ignore'.", |
| label: testName); |
| diffMode = 'ignore'; |
| } |
| switch (diffMode) { |
| case 'check': |
| _print('Checking source file diffs.', label: testName); |
| filesByGeneration.forEach((basename, filesQueue) { |
| final files = filesQueue.toList(); |
| _debugPrint('Checking source file diffs for $files.', |
| label: testName); |
| files.forEachIndexed((i, (int, Uri) element) { |
| var (_, file) = element; |
| if (i == 0) { |
| // Check that the first file does not have a diff. |
| !File.fromUri(file).readAsStringSync().contains(testDiffSeparator) |
| ? reportDiffOutcome( |
| file, 'First generation does not have a diff', true) |
| : reportDiffOutcome(file, |
| 'First generation should not have any diffs', false); |
| } else { |
| // Check that exactly one diff exists. |
| final currentText = File.fromUri(file).readAsStringSync(); |
| final diffCount = |
| testDiffSeparator.allMatches(currentText).length; |
| if (diffCount == 0) { |
| reportDiffOutcome(file, 'No diff found for $file', false); |
| return; |
| } |
| if (diffCount > 1) { |
| reportDiffOutcome( |
| file, 'Too many diffs found for $file (expected 1)', false); |
| return; |
| } |
| // Check that the diff is properly generated. |
| final (_, previousFile) = files[i - 1]; |
| final (previousCode, _) = _splitTestByDiff(previousFile); |
| final (currentCode, currentDiff) = _splitTestByDiff(file); |
| // 'main' is allowed to have empty diffs since the first |
| // generation must be specified. |
| if (basename != 'main' && previousCode == currentCode) { |
| // TODO(markzipan): Should we make this an error? |
| _print( |
| 'Extraneous file detected. $file is identical to ' |
| '$previousFile and can be removed.', |
| label: testName); |
| } |
| final previousTempUri = generatedCodeUri.resolve('__previous'); |
| final currentTempUri = generatedCodeUri.resolve('__current'); |
| File.fromUri(previousTempUri).writeAsStringSync(previousCode); |
| File.fromUri(currentTempUri).writeAsStringSync(currentCode); |
| final diffOutput = _diffWithFileUris( |
| previousTempUri, currentTempUri, |
| label: testName); |
| File.fromUri(previousTempUri).deleteSync(); |
| File.fromUri(currentTempUri).deleteSync(); |
| if (diffOutput != currentDiff) { |
| reportDiffOutcome( |
| file, |
| 'Unexpected diff found for $file:\n' |
| '-- Expected --\n$diffOutput\n' |
| '-- Actual --\n$currentDiff', |
| false); |
| return; |
| } |
| reportDiffOutcome(file, 'Correct diff found for $file', true); |
| return; |
| } |
| }); |
| }); |
| break; |
| case 'write': |
| _print('Generating source file diffs.', label: testName); |
| filesByGeneration.forEach((basename, filesQueue) { |
| final files = filesQueue.toList(); |
| _debugPrint('Generating source file diffs for $files.', |
| label: testName); |
| files.forEachIndexed((i, (int, Uri) element) { |
| final (_, file) = element; |
| final (currentCode, currentDiff) = _splitTestByDiff(file); |
| // Don't generate a diff for the first file of any generation, |
| // and delete any diffs encountered. |
| if (i == 0) { |
| if (currentDiff.isNotEmpty) { |
| _print('Removing extraneous diff from $file', label: testName); |
| File.fromUri(file).writeAsStringSync(currentCode); |
| } |
| return; |
| } |
| final (_, previousFile) = files[i - 1]; |
| final (previousCode, _) = _splitTestByDiff(previousFile); |
| final previousTempUri = generatedCodeUri.resolve('__previous'); |
| final currentTempUri = generatedCodeUri.resolve('__current'); |
| File.fromUri(previousTempUri).writeAsStringSync(previousCode); |
| File.fromUri(currentTempUri).writeAsStringSync(currentCode); |
| final diffOutput = _diffWithFileUris( |
| previousTempUri, currentTempUri, |
| label: testName); |
| File.fromUri(previousTempUri).deleteSync(); |
| File.fromUri(currentTempUri).deleteSync(); |
| final newCurrentText = |
| '$currentCode${currentCode.endsWith('\n') ? '' : '\n'}$diffOutput\n'; |
| File.fromUri(file).writeAsStringSync(newCurrentText); |
| _print('Writing updated diff to $file', label: testName); |
| _debugPrint('Updated diff:\n$diffOutput', label: testName); |
| reportDiffOutcome(file, 'diff updated for $file', true); |
| }); |
| }); |
| break; |
| case 'ignore': |
| _print('Ignoring source file diffs.', label: testName); |
| filesByGeneration.forEach((basename, filesQueue) { |
| filesQueue.unorderedElements.forEach(((int, Uri) element) { |
| final (_, file) = element; |
| reportDiffOutcome(file, 'Ignoring diff for $file', true); |
| }); |
| }); |
| break; |
| } |
| |
| // Skip this test directory if this platform is excluded. |
| if (testConfig.excludedPlaforms.contains(runtimePlatform)) { |
| _print('Skipping test on platform: ${runtimePlatform.text}', |
| label: testName); |
| continue; |
| } |
| |
| // TODO(markzipan): replace this with a test-configurable main entrypoint. |
| final mainDartFilePath = testDir.uri.resolve('main.dart').toFilePath(); |
| _debugPrint('Test entrypoint: $mainDartFilePath', label: testName); |
| _print('Generating code over ${maxGenerations + 1} generations.', |
| label: testName); |
| |
| var hasCompileError = false; |
| // Generate hot reload/restart generations as subdirectories in a loop. |
| var currentGeneration = 0; |
| while (currentGeneration <= maxGenerations) { |
| _debugPrint('Entering generation $currentGeneration', label: testName); |
| var 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: ' |
| '${snapshotUri.toFilePath()}', |
| label: testName); |
| for (var file in testDir.listSync()) { |
| // Convert a name like `/path/foo.bar.25.dart` to `/path/foo.bar.dart`. |
| if (file is File && file.path.endsWith('.dart')) { |
| final baseName = file.uri.pathSegments.last; |
| final parts = baseName.split('.'); |
| final generationId = int.parse(parts[parts.length - 2]); |
| if (generationId == currentGeneration) { |
| // Reconstruct the name of the file without generation indicators. |
| parts.removeLast(); // Remove `.dart`. |
| parts.removeLast(); // Remove the generation id. |
| parts.add('.dart'); // Re-add `.dart`. |
| final restoredName = parts.join(); |
| final fileSnapshotUri = snapshotUri.resolve(restoredName); |
| final relativeSnapshotPath = fe_shared.relativizeUri( |
| filesystemRootUri, fileSnapshotUri, fe_shared.isWindows); |
| final snapshotPathWithScheme = |
| '$filesystemScheme:///$relativeSnapshotPath'; |
| updatedFilesInCurrentGeneration.add(snapshotPathWithScheme); |
| file.copySync(fileSnapshotUri.toFilePath()); |
| } |
| } |
| } |
| _print( |
| 'Updated files in generation $currentGeneration: ' |
| '[${updatedFilesInCurrentGeneration.join(', ')}]', |
| label: testName); |
| |
| // The first generation calls `compile`, but subsequent ones call |
| // `recompile`. |
| // Likewise, use the incremental output directory for `recompile` calls. |
| String outputDirectoryPath; |
| _print( |
| 'Compiling generation $currentGeneration with the Frontend Server.', |
| label: testName); |
| CompilerOutput compilerOutput; |
| if (currentGeneration == 0) { |
| _debugPrint( |
| 'Compiling snapshot entrypoint: $snapshotEntrypointWithScheme', |
| label: testName); |
| outputDirectoryPath = outputDillUri.toFilePath(); |
| compilerOutput = |
| await controller.sendCompile(snapshotEntrypointWithScheme); |
| } else { |
| _debugPrint( |
| 'Recompiling snapshot entrypoint: $snapshotEntrypointWithScheme', |
| label: testName); |
| outputDirectoryPath = outputIncrementalDillUri.toFilePath(); |
| // TODO(markzipan): Add logic to reject bad compiles. |
| compilerOutput = await controller.sendRecompile( |
| snapshotEntrypointWithScheme, |
| invalidatedFiles: updatedFilesInCurrentGeneration); |
| } |
| // Frontend Server reported compile errors. Fail if they weren't |
| // expected, and do not run tests. |
| if (compilerOutput.errorCount > 0) { |
| hasCompileError = true; |
| await controller.sendReject(); |
| // TODO(markzipan): Determine if 'contains' is good enough to determine |
| // compilation error correctness. |
| if (testConfig.expectedError != null && |
| compilerOutput.outputText.contains(testConfig.expectedError!)) { |
| await reportTestOutcome( |
| 'Expected error found during compilation: ' |
| '${testConfig.expectedError}', |
| true); |
| } else { |
| await reportTestOutcome( |
| 'Test failed with compile error: ${compilerOutput.outputText}', |
| false); |
| } |
| } else { |
| controller.sendAccept(); |
| } |
| |
| // Stop processing further generations if compilation failed. |
| if (hasCompileError) break; |
| |
| _debugPrint( |
| 'Frontend Server successfully compiled outputs to: ' |
| '$outputDirectoryPath', |
| label: testName); |
| |
| if (runtimePlatform.emitsJS) { |
| // Update the memory filesystem with the newly-created JS files |
| _print( |
| 'Loading generation $currentGeneration files ' |
| 'into the memory filesystem.', |
| label: testName); |
| final codeFile = File('$outputDirectoryPath.sources'); |
| final manifestFile = File('$outputDirectoryPath.json'); |
| final sourcemapFile = File('$outputDirectoryPath.map'); |
| filesystem.update( |
| codeFile, |
| manifestFile, |
| sourcemapFile, |
| generation: '$currentGeneration', |
| ); |
| |
| // Write JS files and sourcemaps to their respective generation. |
| _print('Writing generation $currentGeneration assets.', |
| label: testName); |
| _debugPrint('Writing JS assets to ${tempUri.toFilePath()}', |
| label: testName); |
| filesystem.writeToDisk(tempUri, generation: '$currentGeneration'); |
| } else { |
| final dillOutputDir = |
| Directory.fromUri(tempUri.resolve('generation$currentGeneration')); |
| dillOutputDir.createSync(); |
| final dillOutputUri = dillOutputDir.uri.resolve('$testName.dill'); |
| File(outputDirectoryPath).copySync(dillOutputUri.toFilePath()); |
| // Write dills their respective generation. |
| _print('Writing generation $currentGeneration assets.', |
| label: testName); |
| _debugPrint('Writing dill to ${dillOutputUri.toFilePath()}', |
| label: testName); |
| } |
| currentGeneration++; |
| } |
| |
| // Skip to the next test and avoid execution if we encountered a |
| // compilation error. |
| if (hasCompileError) { |
| _print('Did not emit all assets due to compilation error.', |
| label: testName); |
| continue; |
| } |
| |
| _print('Finished emitting assets.', label: testName); |
| |
| final testOutputStreamController = StreamController<List<int>>(); |
| final testOutputBuffer = StringBuffer(); |
| testOutputStreamController.stream |
| .transform(utf8.decoder) |
| .listen(testOutputBuffer.write); |
| var testPassed = false; |
| switch (runtimePlatform) { |
| case RuntimePlatforms.d8: |
| // Run the compiled JS generations with D8. |
| // TODO(markzipan): Add logic for evaluating with Chrome or the VM. |
| _print('Creating D8 hot reload test suite.', label: testName); |
| |
| final d8Suite = D8SuiteRunner( |
| config: d8Config, |
| bootstrapJsUri: tempUri.resolve('generation0/bootstrap.js'), |
| entrypointLibraryExportName: |
| ddc_names.libraryUriToJsIdentifier(snapshotEntrypointUri), |
| dartSdkJsUri: soundStableDartSdkJsUri, |
| ddcModuleLoaderJsUri: ddcModuleLoaderJsUri, |
| outputSink: IOSink(testOutputStreamController.sink), |
| ); |
| await d8Suite.setupTest( |
| testName: testName, |
| scriptDescriptors: filesystem.scriptDescriptorForBootstrap, |
| generationToModifiedFiles: filesystem.generationsToModifiedFilePaths, |
| ); |
| final d8ExitCode = await d8Suite.runTest(testName: testName); |
| testPassed = d8ExitCode == 0; |
| await d8Suite.teardownTest(testName: testName); |
| break; |
| case RuntimePlatforms.chrome: |
| case RuntimePlatforms.vm: |
| final firstGenerationDillUri = |
| tempUri.resolve('generation0/$testName.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(), |
| ]; |
| final vm = await Process.start(Platform.executable, vmArgs); |
| _debugPrint( |
| 'Starting VM with command: ' |
| '${Platform.executable} ${vmArgs.join(" ")}', |
| label: testName); |
| vm.stdout |
| .transform(utf8.decoder) |
| .transform(LineSplitter()) |
| .listen((String line) { |
| _debugPrint('VM stdout: $line', label: testName); |
| testOutputBuffer.writeln(line); |
| }); |
| vm.stderr |
| .transform(utf8.decoder) |
| .transform(LineSplitter()) |
| .listen((String err) { |
| _debugPrint('VM stderr: $err', label: testName); |
| testOutputBuffer.writeln(err); |
| }); |
| _print('Executing VM test.', label: testName); |
| final vmExitCode = await vm.exitCode |
| .timeout(Duration(seconds: testTimeoutSeconds), onTimeout: () { |
| final timeoutText = |
| 'Test timed out after $testTimeoutSeconds seconds.'; |
| _print(timeoutText, label: testName); |
| testOutputBuffer.writeln(timeoutText); |
| vm.kill(); |
| return 1; |
| }); |
| testPassed = vmExitCode == 0; |
| } |
| |
| await reportTestOutcome(testOutputBuffer.toString(), testPassed); |
| } |
| |
| await shutdown(); |
| _print('Testing complete.'); |
| |
| if (emitTestResultsJson) { |
| final testOutcomeResults = testOutcomes.map((o) => o.toRecordJson()); |
| final testOutcomeLogs = testOutcomes.map((o) => o.toLogJson()); |
| final testResultsOutputDir = |
| Uri.directory(argResults['output-directory'] as String); |
| _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()}.'); |
| } |
| |
| // Report failed tests. |
| var failedTests = |
| testOutcomes.where((outcome) => !outcome.matchedExpectations); |
| if (failedTests.isNotEmpty) { |
| print('Some tests failed:'); |
| failedTests.forEach((outcome) { |
| print('${outcome.testName} failed with:\n ${outcome.testOutput}'); |
| }); |
| exit(1); |
| } |
| } |
| |
| /// 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 (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); |
| } |
| |
| /// Prints messages if 'verbose' mode is enabled. |
| void _print(String message, {String? label}) { |
| if (verbose) { |
| final labelText = label == null ? '' : '($label)'; |
| print('hot_reload_test$labelText: $message'); |
| } |
| } |
| |
| /// Prints messages if 'debug' mode is enabled. |
| void _debugPrint(String message, {String? label}) { |
| if (debug) { |
| final labelText = label == null ? '' : '($label)'; |
| print('DEBUG$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 = [ |
| '-du', |
| '--width=120', |
| '--expand-tabs', |
| file1Path, |
| file2Path |
| ]; |
| _debugPrint("Running diff with 'diff ${diffArgs.join(' ')}'.", label: label); |
| final diffProcess = Process.runSync('diff', diffArgs); |
| final errOutput = diffProcess.stderr as String; |
| if (errOutput.isNotEmpty) { |
| throw Exception('diff failed with:\n$errOutput'); |
| } |
| var output = diffProcess.stdout as String; |
| if (trimHeaders) { |
| // Skip the first two lines. |
| // TODO(markzipan): Add support for Windows-style line endings. |
| output = output.split('\n').skip(2).join('\n'); |
| } |
| return commented ? '$testDiffSeparator\n/*\n$output*/' : output; |
| } |
| |
| /// Returns the code and diff portions of [file]. |
| (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); |
| final diffText = text.substring(diffSplitIndex, text.length - 1); |
| // Avoid 'No newline at end of file' messages in the output by appending a |
| // newline if one is not already trailing. |
| return ('$codeText${codeText.endsWith('\n') ? '' : '\n'}', diffText); |
| } |
| |
| abstract class HotReloadSuiteRunner { |
| final String entrypointModuleName; |
| final String entrypointLibraryExportName; |
| final Uri dartSdkJsUri; |
| final Uri ddcModuleLoaderJsUri; |
| final StreamSink<List<int>> outputSink; |
| |
| HotReloadSuiteRunner({ |
| required this.entrypointModuleName, |
| required this.entrypointLibraryExportName, |
| required this.dartSdkJsUri, |
| required this.ddcModuleLoaderJsUri, |
| required this.outputSink, |
| }); |
| |
| /// Logic that needs to be run before every test begins. |
| /// |
| /// [scriptDescriptors] and [generationToModifiedFiles] are only used for |
| /// DDC-based execution environments. |
| Future<void> setupTest( |
| {String? testName, |
| List<Map<String, String?>>? scriptDescriptors, |
| Map<String, List<String>>? generationToModifiedFiles}); |
| |
| /// Executes a test. |
| Future<int> runTest({String? testName}); |
| |
| /// Logic that needs to be run after every test completes. |
| Future<void> teardownTest({String? testName}); |
| } |
| |
| class D8SuiteRunner implements HotReloadSuiteRunner { |
| final ddc_helpers.D8Configuration config; |
| final Uri bootstrapJsUri; |
| @override |
| final String entrypointModuleName; |
| @override |
| final String entrypointLibraryExportName; |
| @override |
| final Uri dartSdkJsUri; |
| @override |
| final Uri ddcModuleLoaderJsUri; |
| @override |
| final StreamSink<List<int>> outputSink; |
| |
| D8SuiteRunner._({ |
| required this.config, |
| required this.bootstrapJsUri, |
| required this.entrypointModuleName, |
| required this.entrypointLibraryExportName, |
| required this.dartSdkJsUri, |
| required this.ddcModuleLoaderJsUri, |
| required this.outputSink, |
| }); |
| |
| factory D8SuiteRunner({ |
| required ddc_helpers.D8Configuration config, |
| required Uri bootstrapJsUri, |
| String entrypointModuleName = 'main.dart', |
| String entrypointLibraryExportName = 'main', |
| required Uri dartSdkJsUri, |
| required Uri ddcModuleLoaderJsUri, |
| StreamSink<List<int>>? outputSink, |
| }) { |
| return D8SuiteRunner._( |
| config: config, |
| entrypointModuleName: entrypointModuleName, |
| entrypointLibraryExportName: entrypointLibraryExportName, |
| bootstrapJsUri: bootstrapJsUri, |
| dartSdkJsUri: dartSdkJsUri, |
| ddcModuleLoaderJsUri: ddcModuleLoaderJsUri, |
| outputSink: outputSink ?? stdout, |
| ); |
| } |
| |
| String _generateBootstrapper({ |
| required List<Map<String, String?>> scriptDescriptors, |
| required Map<String, List<String>> generationToModifiedFiles, |
| }) { |
| return ddc_helpers.generateD8Bootstrapper( |
| ddcModuleLoaderJsPath: escapedString(ddcModuleLoaderJsUri.toFilePath()), |
| dartSdkJsPath: escapedString(dartSdkJsUri.toFilePath()), |
| entrypointModuleName: escapedString(entrypointModuleName), |
| entrypointLibraryExportName: escapedString(entrypointLibraryExportName), |
| scriptDescriptors: scriptDescriptors, |
| modifiedFilesPerGeneration: generationToModifiedFiles, |
| ); |
| } |
| |
| @override |
| Future<void> setupTest({ |
| String? testName, |
| List<Map<String, String?>>? scriptDescriptors, |
| Map<String, List<String>>? generationToModifiedFiles, |
| }) async { |
| _print('Preparing to run D8 test.', label: testName); |
| if (scriptDescriptors == null || generationToModifiedFiles == null) { |
| throw ArgumentError('D8SuiteRunner requires that "scriptDescriptors" ' |
| 'and "generationToModifiedFiles" be provided during setup.'); |
| } |
| final d8BootstrapJS = _generateBootstrapper( |
| scriptDescriptors: scriptDescriptors, |
| generationToModifiedFiles: generationToModifiedFiles); |
| File.fromUri(bootstrapJsUri).writeAsStringSync(d8BootstrapJS); |
| _debugPrint('Writing D8 bootstrapper: $bootstrapJsUri', label: testName); |
| } |
| |
| @override |
| Future<int> runTest({String? testName}) async { |
| final process = await startProcess('D8', config.binary.toFilePath(), [ |
| config.sealNativeObjectScript.toFilePath(), |
| config.preamblesScript.toFilePath(), |
| bootstrapJsUri.toFilePath() |
| ]); |
| unawaited(process.stdout.pipe(outputSink)); |
| return process.exitCode; |
| } |
| |
| @override |
| Future<void> teardownTest({String? testName}) async {} |
| } |