| // Copyright (c) 2013, 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 "package:status_file/expectation.dart"; | |
| import 'command.dart'; | |
| import 'command_output.dart'; | |
| import 'configuration.dart'; | |
| import 'path.dart'; | |
| import 'summary_report.dart'; | |
| import 'terminal.dart'; | |
| import 'test_case.dart'; | |
| import 'utils.dart'; | |
| /// Controls how message strings are processed before being displayed. | |
| class Formatter { | |
| /// Messages are left as-is. | |
| static const normal = Formatter._(); | |
| /// Messages are wrapped in ANSI escape codes to color them for display on a | |
| /// terminal. | |
| static const color = _ColorFormatter(); | |
| const Formatter._(); | |
| /// Formats a success message. | |
| String passed(String message) => message; | |
| /// Formats a failure message. | |
| String failed(String message) => message; | |
| /// Formats a section header. | |
| String section(String message) => message; | |
| } | |
| class _ColorFormatter extends Formatter { | |
| static const _gray = "1;30"; | |
| static const _green = "32"; | |
| static const _red = "31"; | |
| static const _escape = '\u001b'; | |
| const _ColorFormatter() : super._(); | |
| @override | |
| String passed(String message) => _color(message, _green); | |
| @override | |
| String failed(String message) => _color(message, _red); | |
| @override | |
| String section(String message) => _color(message, _gray); | |
| static String _color(String message, String color) => | |
| "$_escape[${color}m$message$_escape[0m"; | |
| } | |
| class EventListener { | |
| void testAdded() {} | |
| void done(TestCase test) {} | |
| void allTestsKnown() {} | |
| void allDone() {} | |
| } | |
| class ExitCodeSetter extends EventListener { | |
| @override | |
| void done(TestCase test) { | |
| if (!test.result.canBeOutcomeOf(test.realExpected)) { | |
| exitCode = 1; | |
| } | |
| } | |
| } | |
| class TimedProgressPrinter extends EventListener { | |
| static const interval = Duration(minutes: 5); | |
| int _numTests = 0; | |
| int _numCompleted = 0; | |
| bool _allKnown = false; | |
| late Timer _timer; | |
| TimedProgressPrinter() { | |
| _timer = Timer.periodic(interval, callback); | |
| } | |
| void callback(Timer timer) { | |
| if (_allKnown) { | |
| Terminal.print('$_numCompleted out of $_numTests completed'); | |
| } | |
| Terminal.print( | |
| "Tests running for ${(interval * timer.tick).inMinutes} minutes", | |
| ); | |
| } | |
| @override | |
| void testAdded() => _numTests++; | |
| @override | |
| void done(TestCase test) => _numCompleted++; | |
| @override | |
| void allTestsKnown() => _allKnown = true; | |
| @override | |
| void allDone() => _timer.cancel(); | |
| } | |
| class IgnoredTestMonitor extends EventListener { | |
| static final int maxIgnored = 10; | |
| int countIgnored = 0; | |
| @override | |
| void done(TestCase test) { | |
| if (test.lastCommandOutput.result(test) == Expectation.ignore) { | |
| countIgnored++; | |
| if (countIgnored > maxIgnored) { | |
| Terminal.print( | |
| "\nMore than $maxIgnored tests were ignored due to flakes in", | |
| ); | |
| Terminal.print("the test infrastructure. Notify dart-engprod@."); | |
| Terminal.print("Output of the last ignored test was:"); | |
| Terminal.print(_buildFailureOutput(test)); | |
| exit(1); | |
| } | |
| } | |
| } | |
| @override | |
| void allDone() { | |
| if (countIgnored > 0) { | |
| Terminal.print("Ignored $countIgnored tests due to flaky infrastructure"); | |
| } | |
| } | |
| } | |
| class UnexpectedCrashLogger extends EventListener { | |
| final archivedBinaries = <String, String>{}; | |
| List<String> ldd(String path) { | |
| if (!Platform.isLinux) return <String>[]; | |
| final out = Process.runSync("ldd", [path]).stdout as String; | |
| return out | |
| .split(' ') | |
| .where((String token) => token.startsWith('/')) | |
| .toList(); | |
| } | |
| @override | |
| void done(TestCase test) { | |
| if (!test.result.canBeOutcomeOf(test.realExpected) && | |
| test.result == Expectation.crash && | |
| test.lastCommandExecuted is ProcessCommand && | |
| test.lastCommandOutput.hasCoreDump) { | |
| final mode = test.configuration.mode.name; | |
| final arch = test.configuration.architecture.name; | |
| var pid = "${test.lastCommandOutput.pid}"; | |
| var lastCommand = test.lastCommandExecuted as ProcessCommand; | |
| final binaries = <String>[]; | |
| // We might have a coredump for the process. This coredump will be | |
| // archived by CoreDumpArchiver (see tools/utils.py). | |
| // | |
| // For debugging purposes we need to archive the crashed binary as well. | |
| // | |
| // To simplify the archiving code we simply copy binaries into current | |
| // folder next to core dumps and name them | |
| // `binary.${mode}_${arch}_${binary_name}`. | |
| final binName = lastCommand.executable; | |
| final binFile = File(binName); | |
| final binBaseName = Path(binName).filename; | |
| if (binFile.existsSync()) { | |
| final archived = "binary.${mode}_${arch}_$binBaseName"; | |
| binaries.add(archived); | |
| if (!archivedBinaries.containsKey(binName)) { | |
| TestUtils.copyFile(Path(binName), Path(archived)); | |
| archivedBinaries[binName] = archived; | |
| // On Windows also copy PDB file for the binary. | |
| if (Platform.isWindows) { | |
| final pdbPath = Path("$binName.pdb"); | |
| if (File(pdbPath.toNativePath()).existsSync()) { | |
| TestUtils.copyFile(pdbPath, Path("$archived.pdb")); | |
| } | |
| } | |
| } | |
| } | |
| // Copy libc, etc. | |
| for (final libName in ldd(binName)) { | |
| final libFile = File(libName); | |
| final libBaseName = Path(libName).filename; | |
| if (libFile.existsSync()) { | |
| final archived = "binary.${mode}_${arch}_$libBaseName"; | |
| binaries.add(archived); | |
| if (!archivedBinaries.containsKey(libName)) { | |
| TestUtils.copyFile(Path(libName), Path(archived)); | |
| archivedBinaries[libName] = archived; | |
| } | |
| } | |
| } | |
| final kernelServiceBaseName = 'kernel-service.dart.snapshot'; | |
| final kernelService = File( | |
| '${binFile.parent.path}/$kernelServiceBaseName', | |
| ); | |
| if (kernelService.existsSync()) { | |
| final archived = "binary.${mode}_${arch}_$kernelServiceBaseName"; | |
| binaries.add(archived); | |
| if (!archivedBinaries.containsKey(kernelService.path)) { | |
| TestUtils.copyFile(Path(kernelService.path), Path(archived)); | |
| archivedBinaries[kernelServiceBaseName] = archived; | |
| } | |
| } | |
| if (binaries.isNotEmpty) { | |
| // We have found and copied the binary. | |
| RandomAccessFile? unexpectedCrashesFile; | |
| try { | |
| unexpectedCrashesFile = File( | |
| 'unexpected-crashes', | |
| ).openSync(mode: FileMode.append); | |
| unexpectedCrashesFile.writeStringSync( | |
| "${test.displayName},$pid,${binaries.join(',')}\n", | |
| ); | |
| } catch (e) { | |
| Terminal.print('Failed to add crash to unexpected-crashes list: $e'); | |
| } finally { | |
| try { | |
| if (unexpectedCrashesFile != null) { | |
| unexpectedCrashesFile.closeSync(); | |
| } | |
| } catch (e) { | |
| Terminal.print('Failed to close unexpected-crashes file: $e'); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| class SummaryPrinter extends EventListener { | |
| final bool jsonOnly; | |
| SummaryPrinter({this.jsonOnly = false}); | |
| @override | |
| void allTestsKnown() { | |
| if (jsonOnly) { | |
| Terminal.print("JSON:"); | |
| Terminal.print(jsonEncode(summaryReport.values)); | |
| } else { | |
| summaryReport.printReport(); | |
| } | |
| } | |
| } | |
| class TimingPrinter extends EventListener { | |
| final _commandToTestCases = <Command, List<TestCase>>{}; | |
| final _commandOutputs = <CommandOutput>{}; | |
| final DateTime _startTime; | |
| TimingPrinter(this._startTime); | |
| @override | |
| void done(TestCase test) { | |
| for (var commandOutput in test.commandOutputs.values) { | |
| var command = commandOutput.command; | |
| _commandOutputs.add(commandOutput); | |
| _commandToTestCases.putIfAbsent(command, () => <TestCase>[]).add(test); | |
| } | |
| } | |
| @override | |
| void allDone() { | |
| var d = DateTime.now().difference(_startTime); | |
| Terminal.print('\n--- Total time: ${_timeString(d)} ---'); | |
| var outputs = _commandOutputs.toList(); | |
| outputs.sort((a, b) { | |
| return b.time.inMilliseconds - a.time.inMilliseconds; | |
| }); | |
| for (var commandOutput in outputs.take(20)) { | |
| var command = commandOutput.command; | |
| var testCases = _commandToTestCases[command]!; | |
| var testCasesDescription = testCases | |
| .map((testCase) { | |
| return "${testCase.configurationString}/${testCase.displayName}"; | |
| }) | |
| .join(', '); | |
| Terminal.print( | |
| '${commandOutput.time} - ' | |
| '${command.displayName} - ' | |
| '$testCasesDescription', | |
| ); | |
| } | |
| } | |
| } | |
| class SkippedCompilationsPrinter extends EventListener { | |
| int _skippedCompilations = 0; | |
| @override | |
| void done(TestCase test) { | |
| for (var commandOutput in test.commandOutputs.values) { | |
| if (commandOutput.compilationSkipped) _skippedCompilations++; | |
| } | |
| } | |
| @override | |
| void allDone() { | |
| if (_skippedCompilations > 0) { | |
| Terminal.print( | |
| '\n$_skippedCompilations compilations were skipped because ' | |
| 'the previous output was already up to date.\n', | |
| ); | |
| } | |
| } | |
| } | |
| class TestFailurePrinter extends EventListener { | |
| final Formatter _formatter; | |
| TestFailurePrinter([this._formatter = Formatter.normal]); | |
| @override | |
| void done(TestCase test) { | |
| if (!!test.result.canBeOutcomeOf(test.realExpected)) return; | |
| for (var line in _buildFailureOutput(test, _formatter)) { | |
| Terminal.print(line); | |
| } | |
| } | |
| } | |
| /// Prints a one-line summary of passed and failed tests. | |
| class ResultCountPrinter extends EventListener { | |
| final Formatter _formatter; | |
| int _failedTests = 0; | |
| int _passedTests = 0; | |
| ResultCountPrinter(this._formatter); | |
| @override | |
| void done(TestCase test) { | |
| if (!test.result.canBeOutcomeOf(test.realExpected)) { | |
| _failedTests++; | |
| } else { | |
| _passedTests++; | |
| } | |
| } | |
| @override | |
| void allDone() { | |
| var suffix = _passedTests != 1 ? 's' : ''; | |
| var passed = | |
| '${_formatter.passed(_passedTests.toString())} test$suffix passed'; | |
| String summary; | |
| if (_failedTests == 0) { | |
| summary = 'All $passed'; | |
| } else { | |
| summary = '$passed, ${_formatter.failed(_failedTests.toString())} failed'; | |
| } | |
| var marker = _formatter.section('==='); | |
| Terminal.print('\n$marker $summary $marker'); | |
| } | |
| } | |
| /// Prints a list of the tests that failed. | |
| class FailedTestsPrinter extends EventListener { | |
| final List<TestCase> _failedTests = []; | |
| FailedTestsPrinter(); | |
| @override | |
| void done(TestCase test) { | |
| if (!test.result.canBeOutcomeOf(test.realExpected)) { | |
| _failedTests.add(test); | |
| } | |
| } | |
| @override | |
| void allDone() { | |
| if (_failedTests.isEmpty) return; | |
| Terminal.print(''); | |
| Terminal.print('=== Failed tests ==='); | |
| for (var test in _failedTests) { | |
| var result = test.result.toString(); | |
| if (test.realExpected != Expectation.pass) { | |
| result += ' (expected ${test.realExpected})'; | |
| } | |
| Terminal.print('${test.displayName}: $result'); | |
| } | |
| } | |
| } | |
| class PassingStdoutPrinter extends EventListener { | |
| final Formatter _formatter; | |
| PassingStdoutPrinter([this._formatter = Formatter.normal]); | |
| @override | |
| void done(TestCase test) { | |
| if (!!test.result.canBeOutcomeOf(test.realExpected)) { | |
| var lines = <String>[]; | |
| var output = OutputWriter(_formatter, lines); | |
| for (final command in test.commands) { | |
| var commandOutput = test.commandOutputs[command]; | |
| if (commandOutput == null) continue; | |
| commandOutput.describe(test, test.configuration.progress, output); | |
| } | |
| for (var line in lines) { | |
| Terminal.print(line); | |
| } | |
| } | |
| } | |
| @override | |
| void allDone() {} | |
| } | |
| abstract class ProgressIndicator extends EventListener { | |
| final DateTime _startTime; | |
| int _foundTests = 0; | |
| int _passedTests = 0; | |
| int _failedTests = 0; | |
| bool _allTestsKnown = false; | |
| ProgressIndicator(this._startTime); | |
| static EventListener? fromProgress( | |
| Progress progress, | |
| DateTime startTime, | |
| Formatter formatter, | |
| ) { | |
| switch (progress) { | |
| case Progress.color: | |
| case Progress.compact: | |
| return CompactProgressIndicator(startTime, formatter); | |
| case Progress.line: | |
| case Progress.verbose: | |
| return LineProgressIndicator(startTime); | |
| case Progress.silent: | |
| case Progress.status: | |
| return null; | |
| } | |
| throw "unreachable"; | |
| } | |
| @override | |
| void testAdded() { | |
| _foundTests++; | |
| } | |
| @override | |
| void done(TestCase test) { | |
| if (!test.result.canBeOutcomeOf(test.realExpected)) { | |
| _failedTests++; | |
| } else { | |
| _passedTests++; | |
| } | |
| _printDoneProgress(test); | |
| } | |
| @override | |
| void allTestsKnown() { | |
| _allTestsKnown = true; | |
| } | |
| void _printDoneProgress(TestCase test); | |
| int get _completedTests => _passedTests + _failedTests; | |
| } | |
| abstract class CompactIndicator extends ProgressIndicator { | |
| CompactIndicator(super.startTime); | |
| } | |
| class CompactProgressIndicator extends CompactIndicator { | |
| final Formatter _formatter; | |
| CompactProgressIndicator(super.startTime, this._formatter); | |
| @override | |
| void _printDoneProgress(TestCase test) { | |
| var percent = ((_completedTests / _foundTests) * 100).toInt().toString(); | |
| var progressPadded = (_allTestsKnown ? percent : '--').padLeft(3); | |
| var passedPadded = _passedTests.toString().padLeft(5); | |
| var failedPadded = _failedTests.toString().padLeft(5); | |
| var elapsed = DateTime.now().difference(_startTime); | |
| var progressLine = | |
| '\r[${_timeString(elapsed)} | $progressPadded% | ' | |
| '+${_formatter.passed(passedPadded)} | ' | |
| '-${_formatter.failed(failedPadded)}]'; | |
| Terminal.writeLine(progressLine); | |
| } | |
| @override | |
| void allDone() { | |
| Terminal.finishLine(); | |
| } | |
| } | |
| class LineProgressIndicator extends ProgressIndicator { | |
| LineProgressIndicator(super.startTime); | |
| @override | |
| void _printDoneProgress(TestCase test) { | |
| var status = 'pass'; | |
| if (!test.result.canBeOutcomeOf(test.realExpected)) { | |
| status = 'fail'; | |
| } | |
| Terminal.print( | |
| 'Done ${test.configurationString} ${test.displayName}: $status', | |
| ); | |
| } | |
| } | |
| String _timeString(Duration duration) { | |
| var min = duration.inMinutes; | |
| var sec = duration.inSeconds % 60; | |
| return '${min.toString().padLeft(2, '0')}:${sec.toString().padLeft(2, '0')}'; | |
| } | |
| /// Builds and formats the failure output for a failed test. | |
| class OutputWriter { | |
| final Formatter _formatter; | |
| final List<String?> _lines; | |
| String? _pendingSection; | |
| String? _pendingSubsection; | |
| bool _pendingLine = false; | |
| OutputWriter(this._formatter, this._lines); | |
| void section(String name) { | |
| _pendingSection = name; | |
| _pendingSubsection = null; | |
| _pendingLine = false; | |
| } | |
| void subsection(String name) { | |
| _pendingSubsection = name; | |
| _pendingLine = false; | |
| } | |
| void write(String? line) { | |
| _writePending(); | |
| _lines.add(line); | |
| } | |
| void writeAll(Iterable<String> lines) { | |
| if (lines.isEmpty) return; | |
| _writePending(); | |
| _lines.addAll(lines); | |
| } | |
| /// Writes a blank line that separates lines of output. | |
| /// | |
| /// If no output is written after this before the next section, subsection, | |
| /// or end out output, doesn't write the line. | |
| void separator() { | |
| _pendingLine = true; | |
| } | |
| /// Writes the current section header. | |
| void _writePending() { | |
| if (_pendingSection != null) { | |
| if (_lines.isNotEmpty) _lines.add(""); | |
| _lines.add(_formatter.section("--- $_pendingSection:")); | |
| _pendingSection = null; | |
| } | |
| if (_pendingSubsection != null) { | |
| _lines.add(""); | |
| _lines.add(_formatter.section("$_pendingSubsection:")); | |
| _pendingSubsection = null; | |
| } | |
| if (_pendingLine) { | |
| _lines.add(""); | |
| _pendingLine = false; | |
| } | |
| } | |
| } | |
| List<String> _buildFailureOutput( | |
| TestCase test, [ | |
| Formatter formatter = Formatter.normal, | |
| ]) { | |
| var lines = <String>[]; | |
| var output = OutputWriter(formatter, lines); | |
| _writeFailureStatus(test, formatter, output); | |
| _writeFailureOutput(test, formatter, output); | |
| _writeFailureReproductionCommands(test, formatter, output); | |
| return lines; | |
| } | |
| List<String> _buildFailureLog(TestCase test) { | |
| final formatter = Formatter.normal; | |
| final lines = <String>[]; | |
| final output = OutputWriter(formatter, lines); | |
| _writeFailureOutput(test, formatter, output); | |
| _writeFailureReproductionCommands(test, formatter, output); | |
| return lines; | |
| } | |
| void _writeFailureStatus( | |
| TestCase test, | |
| Formatter formatter, | |
| OutputWriter output, | |
| ) { | |
| output.write(''); | |
| output.write( | |
| formatter.failed('FAILED: ${test.configurationString} ${test.displayName}'), | |
| ); | |
| output.write('Expected: ${test.realExpected}'); | |
| output.write('Actual: ${test.result}'); | |
| final ranAllCommands = test.commandOutputs.length == test.commands.length; | |
| if (!test.lastCommandOutput.hasTimedOut) { | |
| if (!ranAllCommands && !test.hasCompileError) { | |
| output.write('Unexpected compile error.'); | |
| } else { | |
| if (test.hasCompileError) { | |
| output.write('Missing expected compile error.'); | |
| } | |
| if (test.hasRuntimeError) { | |
| output.write('Missing expected runtime error.'); | |
| } | |
| } | |
| } | |
| } | |
| void _writeFailureOutput( | |
| TestCase test, | |
| Formatter formatter, | |
| OutputWriter output, | |
| ) { | |
| for (var i = 0; i < test.commands.length; i++) { | |
| var command = test.commands[i]; | |
| var commandOutput = test.commandOutputs[command]; | |
| if (commandOutput == null) continue; | |
| var time = niceTime(commandOutput.time); | |
| output.section('Command "${command.displayName}" (took $time)'); | |
| output.write(command.toString()); | |
| commandOutput.describe(test, test.configuration.progress, output); | |
| } | |
| } | |
| void _writeFailureReproductionCommands( | |
| TestCase test, | |
| Formatter formatter, | |
| OutputWriter output, | |
| ) { | |
| final ranAllCommands = test.commandOutputs.length == test.commands.length; | |
| if (test.configuration.runtime.isBrowser && ranAllCommands) { | |
| // Additional command for rerunning the steps locally after the fact. | |
| output.section('To debug locally, run'); | |
| output.write(test.configuration.servers.commandLine); | |
| } | |
| output.section('Re-run this test'); | |
| List<String> arguments; | |
| if (Platform.isFuchsia) { | |
| arguments = [Platform.executable, Platform.script.path]; | |
| } else { | |
| arguments = ['python3', 'tools/test.py']; | |
| } | |
| arguments.addAll(test.configuration.reproducingArguments); | |
| arguments.add(test.displayName); | |
| output.write(arguments.map(escapeCommandLineArgument).join(' ')); | |
| } | |
| /// Writes a results.json file with a line for each test. | |
| /// Each line is a json map with the test name and result and expected result. | |
| class ResultWriter extends EventListener { | |
| final List<Map> _results = []; | |
| final List<Map> _logs = []; | |
| final String _outputDirectory; | |
| ResultWriter(this._outputDirectory) { | |
| Directory(_outputDirectory).createSync(recursive: true); | |
| } | |
| @override | |
| void allTestsKnown() { | |
| // Write an empty result log file, that will be overwritten if any tests | |
| // are actually run, when the allDone event handler is invoked. | |
| writeOutputFile([], TestUtils.resultsFileName); | |
| writeOutputFile([], TestUtils.logsFileName); | |
| } | |
| String newlineTerminated(Iterable<String> lines) => | |
| lines.map((l) => '$l\n').join(); | |
| @override | |
| void done(TestCase test) { | |
| var name = test.displayName; | |
| var index = name.indexOf('/'); | |
| var suite = name.substring(0, index); | |
| var testName = name.substring(index + 1); | |
| var time = test.commandOutputs.values.fold<Duration>( | |
| Duration.zero, | |
| (d, o) => d + o.time, | |
| ); | |
| var experiments = test.experiments; | |
| var record = { | |
| "name": name, | |
| "configuration": test.configuration.configuration.name, | |
| "suite": suite, | |
| "test_name": testName, | |
| "time_ms": time.inMilliseconds, | |
| "result": test.result.toString(), | |
| "expected": test.realExpected.toString(), | |
| "matches": test.result.canBeOutcomeOf(test.realExpected), | |
| if (experiments.isNotEmpty) "experiments": experiments, | |
| }; | |
| _results.add(record); | |
| if (test.configuration.writeLogs && record['matches'] != true) { | |
| var log = { | |
| 'name': name, | |
| 'configuration': record['configuration'], | |
| 'result': record['result'], | |
| 'log': newlineTerminated(_buildFailureLog(test)), | |
| }; | |
| _logs.add(log); | |
| } | |
| } | |
| @override | |
| void allDone() { | |
| writeOutputFile(_results, TestUtils.resultsFileName); | |
| writeOutputFile(_logs, TestUtils.logsFileName); | |
| } | |
| void writeOutputFile(List<Map> results, String fileName) { | |
| var path = Uri.directory(_outputDirectory).resolve(fileName); | |
| File.fromUri( | |
| path, | |
| ).writeAsStringSync(newlineTerminated(results.map(jsonEncode))); | |
| } | |
| } |