955e351fad
The static error test updater needs to ask analyzer, CFE, and dart2js for the errors for each updated test file. Previously, it would invoke each of those as a separate process, one at a time, for each test file. This was comically slow. I recently updated it to invoke analyzer as a library and analyze all the files at once, which made that part >100x faster. This CL does essentially the same thing for CFE. It's still invoking CFE as a process, but it does so with a batch of files. It's not as fast as analyzer is, but it's much better. It's still calling dart2js once per file but, strangely, that isn't too slow. Also, web static error tests aren't very common, so this isn't as important. Change-Id: I6756901bb761579dc90f8af4d9774014b04bb009 Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/399885 Auto-Submit: Bob Nystrom <rnystrom@google.com> Reviewed-by: Paul Berry <paulberry@google.com> Commit-Queue: Bob Nystrom <rnystrom@google.com>
329 lines
12 KiB
Dart
329 lines
12 KiB
Dart
// Copyright (c) 2019, 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.
|
|
|
|
/// Converts a multi-test to a test using the new static error test framework
|
|
/// (see https://github.com/dart-lang/sdk/tree/main/docs/Testing.md#static-error-tests)
|
|
/// and a copy of the '/none' test.
|
|
library;
|
|
|
|
import 'dart:collection';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:args/args.dart';
|
|
import 'package:path/path.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:test_runner/src/command_output.dart';
|
|
import 'package:test_runner/src/multitest.dart';
|
|
import 'package:test_runner/src/path.dart';
|
|
import 'package:test_runner/src/static_error.dart';
|
|
import 'package:test_runner/src/test_file.dart';
|
|
import 'package:test_runner/src/update_errors.dart';
|
|
|
|
import 'update_static_error_tests.dart' show dartPath;
|
|
|
|
final _analyzerPath = p.join('pkg', 'analyzer_cli', 'bin', 'analyzer.dart');
|
|
|
|
Future<List<StaticError>> getErrors(
|
|
List<String> options, String filePath) async {
|
|
var analyzerErrors = await _runAnalyzer(File(filePath), options);
|
|
var cfeErrors = await _runCfe(File(filePath), options);
|
|
return [...analyzerErrors, ...cfeErrors];
|
|
}
|
|
|
|
bool areSameErrors(List<StaticError> first, List<StaticError> second) {
|
|
if (first.length != second.length) return false;
|
|
for (var i = 0; i < first.length; ++i) {
|
|
if (first[i].compareTo(second[i]) != 0) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// Merges a list of error lists into a single list. The result is sorted with
|
|
/// respect to [StaticError.compareTo].
|
|
List<StaticError> mergeErrors(Iterable<List<StaticError>> errors) {
|
|
// Using a [SplayTreeSet] here results in a sorted list.
|
|
var result = SplayTreeSet<StaticError>();
|
|
for (var list in errors) {
|
|
result.addAll(list);
|
|
}
|
|
return result.toList();
|
|
}
|
|
|
|
const staticOutcomes = [
|
|
"syntax error",
|
|
"compile-time error",
|
|
"static type warning",
|
|
];
|
|
|
|
class UnableToConvertException {
|
|
final String message;
|
|
UnableToConvertException(this.message);
|
|
@override
|
|
String toString() => "unable to convert: $message";
|
|
}
|
|
|
|
class CleanedMultiTest {
|
|
final String text;
|
|
final Map<String, String> subTests;
|
|
CleanedMultiTest(this.text, this.subTests);
|
|
}
|
|
|
|
CleanedMultiTest removeMultiTestMarker(String test) {
|
|
var buffer = StringBuffer();
|
|
var subTests = <String, String>{};
|
|
var lines = LineSplitter.split(test)
|
|
.where((line) => !line.startsWith("// Test created from multitest named"))
|
|
.toList();
|
|
if (lines.length > 1 && lines.last.isEmpty) {
|
|
// If the file ends with a newline, remove the empty line - the loop below
|
|
// will add a newline to the end.
|
|
lines.length--;
|
|
}
|
|
for (var line in lines) {
|
|
var matches = multitestMarker.allMatches(line);
|
|
if (matches.length > 1) {
|
|
throw "internal error: cannot process line '$line'";
|
|
} else if (matches.length == 1) {
|
|
var match = matches.single;
|
|
var annotation = Annotation.tryParse(line)!;
|
|
if (annotation.outcomes.length != 1) {
|
|
throw UnableToConvertException("annotation has multiple outcomes");
|
|
}
|
|
var outcome = annotation.outcomes.single;
|
|
if (outcome == "continued" ||
|
|
outcome == "ok" ||
|
|
staticOutcomes.contains(outcome)) {
|
|
line = line.substring(0, match.start).trimRight();
|
|
if (line.endsWith("//")) {
|
|
line = line.substring(0, line.length - 2).trimRight();
|
|
}
|
|
if (outcome != "continued") {
|
|
subTests[annotation.key] = outcome;
|
|
}
|
|
} else {
|
|
throw UnableToConvertException("test contains dynamic outcome");
|
|
}
|
|
}
|
|
buffer.writeln(line);
|
|
}
|
|
return CleanedMultiTest(buffer.toString(), subTests);
|
|
}
|
|
|
|
Future createRuntimeTest(
|
|
String testFilePath, String multiTestPath, bool writeToFile) async {
|
|
var testName = basename(testFilePath);
|
|
String runtimeTestBase;
|
|
if (testName.endsWith("_test.dart")) {
|
|
runtimeTestBase =
|
|
testName.substring(0, testName.length - "_test.dart".length);
|
|
} else if (testName.endsWith(".dart")) {
|
|
runtimeTestBase = testName.substring(0, testName.length - ".dart".length);
|
|
} else {
|
|
runtimeTestBase = testName;
|
|
}
|
|
var runtimeTestPath = "${dirname(testFilePath)}/$runtimeTestBase"
|
|
"_runtime_test.dart";
|
|
var n = 1;
|
|
while (await File(runtimeTestPath).exists()) {
|
|
runtimeTestPath = "${dirname(testFilePath)}/$runtimeTestBase"
|
|
"_runtime_${n++}_test.dart";
|
|
}
|
|
var testContent = await File(multiTestPath).readAsString();
|
|
var cleanedMultiTest = removeMultiTestMarker(testContent);
|
|
var runtimeTestContent = """
|
|
// TODO(multitest): This was automatically migrated from a multitest and may
|
|
// contain strange or dead code.
|
|
|
|
${cleanedMultiTest.text}""";
|
|
if (writeToFile) {
|
|
var outputFile = File(runtimeTestPath);
|
|
await outputFile.writeAsString(runtimeTestContent, mode: FileMode.append);
|
|
print("Runtime part of the test written to '$runtimeTestPath'.");
|
|
} else {
|
|
print("-- $runtimeTestPath:");
|
|
print(runtimeTestContent);
|
|
}
|
|
}
|
|
|
|
Future<void> convertFile(String testFilePath, bool writeToFile, bool verbose,
|
|
List<String> experiments) async {
|
|
var testFile = File(testFilePath);
|
|
if (!await testFile.exists()) {
|
|
print("File '${testFile.uri.toFilePath()}' not found");
|
|
exitCode = 1;
|
|
return;
|
|
}
|
|
// Read test file and setup output directory.
|
|
var suiteDirectory = Path.raw(Uri.base.path);
|
|
var content = await testFile.readAsString();
|
|
var test = TestFile.read(suiteDirectory, testFilePath);
|
|
if (!content.contains(multitestMarker)) {
|
|
print("Test ${test.path.toNativePath()} is not a multi-test.");
|
|
exitCode = 1;
|
|
return;
|
|
}
|
|
var outputDirectory = await Directory(dirname(testFilePath)).createTemp();
|
|
if (verbose) {
|
|
print("Output directory for generated files: ${outputDirectory.uri.path}");
|
|
}
|
|
try {
|
|
// Generate the sub-tests of the multi-test in [outputDirectory].
|
|
var tests = [
|
|
test,
|
|
...splitMultitest(test, outputDirectory.uri.toFilePath(), suiteDirectory)
|
|
];
|
|
if (!tests[1].name.endsWith("/none")) {
|
|
throw "internal error: expected second test to be the '/none' test";
|
|
}
|
|
// Remove the multi-test marker from the test. We do this here to fail fast
|
|
// for cases we do not support, because generating the front-end errors is
|
|
// quite slow.
|
|
var cleanedTest = removeMultiTestMarker(content);
|
|
var contentWithoutMarkers = cleanedTest.text;
|
|
// Get the reported errors for the multi-test and all generated sub-tests
|
|
// from the analyser and the common front-end.
|
|
var options = [
|
|
...test.sharedOptions,
|
|
if (experiments.isNotEmpty)
|
|
"--enable-experiment=${experiments.join(',')}",
|
|
];
|
|
|
|
var errors = <List<StaticError>>[];
|
|
for (var test in tests) {
|
|
if (verbose) {
|
|
print("Processing ${test.path}");
|
|
}
|
|
errors.add(await getErrors(options, test.path.toNativePath()));
|
|
}
|
|
if (errors[1].isNotEmpty) {
|
|
throw UnableToConvertException("internal error: errors in '/none' test");
|
|
}
|
|
// Check that the multi-test generates the same errors as all sub-tests
|
|
// together - otherwise converting the test would be unsound.
|
|
var sortedOriginalErrors = errors[0].toList()..sort();
|
|
var mergedErrors = mergeErrors(errors.skip(2));
|
|
if (!areSameErrors(sortedOriginalErrors, mergedErrors)) {
|
|
if (verbose) {
|
|
print("Sub-tests have different errors!\n\n"
|
|
"Errors in sub-tests:\n$mergedErrors\n\n"
|
|
"Errors in original test:\n$sortedOriginalErrors\n");
|
|
}
|
|
throw UnableToConvertException(
|
|
"Test produces different errors than its sub-tests.");
|
|
}
|
|
// Insert the error message annotations for the static testing framework
|
|
// and output the result.
|
|
var annotatedContent =
|
|
updateErrorExpectations(testFilePath, contentWithoutMarkers, errors[0]);
|
|
if (writeToFile) {
|
|
await testFile.writeAsString(annotatedContent);
|
|
print("Converted test '${test.path.toNativePath()}'.");
|
|
} else {
|
|
print("-- ${test.path.toNativePath()}:");
|
|
print(annotatedContent);
|
|
}
|
|
// Generate runtime tests for all sub-tests that are generated from the
|
|
// 'none' case and those with 'ok' annotations.
|
|
for (var i = 1; i < tests.length; ++i) {
|
|
var test = tests[i].path.toNativePath();
|
|
var base = basenameWithoutExtension(test);
|
|
var key = base.split("_").last;
|
|
if (key == "none" || cleanedTest.subTests[key] == "ok") {
|
|
await createRuntimeTest(
|
|
testFilePath, tests[i].path.toNativePath(), writeToFile);
|
|
}
|
|
}
|
|
} on UnableToConvertException catch (exception) {
|
|
print(
|
|
"Could not convert ${test.path.toNativePath()}: ${exception.message}");
|
|
exitCode = 1;
|
|
return;
|
|
} finally {
|
|
outputDirectory.delete(recursive: true);
|
|
}
|
|
}
|
|
|
|
Future<void> main(List<String> arguments) async {
|
|
var parser = ArgParser();
|
|
parser.addFlag("verbose", abbr: "v", help: "print additional information");
|
|
parser.addFlag("write", abbr: "w", help: "write output to input file");
|
|
parser.addMultiOption("enable-experiment",
|
|
defaultsTo: <String>[], help: "Enable one or more experimental features");
|
|
|
|
var results = parser.parse(arguments);
|
|
if (results.rest.isEmpty) {
|
|
print("Usage: convert_multi_test.dart [-v] [-w] <input files>");
|
|
print(parser.usage);
|
|
exitCode = 1;
|
|
return;
|
|
}
|
|
var verbose = results["verbose"] as bool;
|
|
var filePaths =
|
|
results.rest.map((path) => Uri.base.resolve(path).toFilePath());
|
|
var writeToFile = results["write"] as bool;
|
|
for (var testFilePath in filePaths) {
|
|
await convertFile(testFilePath, writeToFile, verbose,
|
|
(results["enable-experiment"] as List).cast<String>());
|
|
}
|
|
}
|
|
|
|
/// Invoke analyzer on [file] and gather all static errors it reports.
|
|
Future<List<StaticError>> _runAnalyzer(File file, List<String> options) async {
|
|
var result = await Process.run(dartPath, [
|
|
_analyzerPath,
|
|
...options,
|
|
"--format=json",
|
|
file.absolute.path,
|
|
]);
|
|
|
|
// Analyzer returns 3 when it detects errors, 2 when it detects
|
|
// warnings and --fatal-warnings is enabled, 1 when it detects
|
|
// hints and --fatal-hints or --fatal-infos are enabled.
|
|
if (result.exitCode < 0 || result.exitCode > 3) {
|
|
print("Analyzer run failed: ${result.stdout}\n${result.stderr}");
|
|
print("Error: failed to update ${file.path}");
|
|
return const [];
|
|
}
|
|
|
|
var errors = <StaticError>[];
|
|
var warnings = <StaticError>[];
|
|
AnalysisCommandOutput.parseErrors(result.stdout as String, errors, warnings);
|
|
|
|
return [...errors, ...warnings];
|
|
}
|
|
|
|
/// Invoke CFE on [file] and gather all static errors it reports.
|
|
Future<List<StaticError>> _runCfe(File file, List<String> options) async {
|
|
var absolutePath = file.absolute.path;
|
|
// TODO(rnystrom): Running the CFE command line each time is slow and wastes
|
|
// time generating code, which we don't care about. Import it as a library or
|
|
// at least run it in batch mode.
|
|
var result = await Process.run(dartPath, [
|
|
"pkg/front_end/tool/compile.dart",
|
|
...options,
|
|
"--verify",
|
|
"-o",
|
|
"dev:null", // Output is only created for file URIs.
|
|
absolutePath,
|
|
]);
|
|
|
|
// Running the above command may generate a dill file next to the test, which
|
|
// we don't want, so delete it if present.
|
|
var dill = File("$absolutePath.dill");
|
|
if (await dill.exists()) {
|
|
await dill.delete();
|
|
}
|
|
|
|
if (result.exitCode != 0) {
|
|
print("CFE run failed: ${result.stdout}\n${result.stderr}");
|
|
print("Error: failed to update ${file.path}");
|
|
return const [];
|
|
}
|
|
var errors = <StaticError>[];
|
|
var warnings = <StaticError>[];
|
|
FastaCommandOutput.parseErrors(result.stdout as String, errors, warnings);
|
|
return [...errors, ...warnings];
|
|
}
|