blob: 43c9fa996d43bb72432bf7a77b877231027f7e8d [file] [log] [blame]
// Copyright (c) 2016, 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' show jsonDecode;
import 'dart:io';
import 'package:_fe_analyzer_shared/src/testing/annotated_code_helper.dart';
import 'package:dart2js_tools/src/dart2js_mapping.dart';
import 'package:dart2js_tools/src/util.dart';
import 'package:expect/expect.dart';
import 'package:source_maps/source_maps.dart';
import 'package:source_maps/src/utils.dart';
import 'package:source_span/source_span.dart';
const String inputFileName = 'input.dart';
class Test {
final String code;
final Map<String, Expectations> expectationMap;
Test(this.code, this.expectationMap);
}
class Expectations {
final List<StackTraceLine> expectedLines;
final List<StackTraceLine> unexpectedLines;
Expectations(this.expectedLines, this.unexpectedLines);
}
/// Convert the annotated [code] to a [Test] object using [configs] to split
/// the annotations by prefix.
Test processTestCode(String code, Iterable<String> configs) {
AnnotatedCode annotatedCode =
AnnotatedCode.fromText(code, commentStart, commentEnd);
Map<String, Expectations> expectationMap = <String, Expectations>{};
splitByPrefixes(annotatedCode, configs)
.forEach((String config, AnnotatedCode annotatedCode) {
Map<int, StackTraceLine> stackTraceMap = <int, StackTraceLine>{};
List<StackTraceLine> unexpectedLines = <StackTraceLine>[];
for (Annotation annotation in annotatedCode.annotations) {
String text = annotation.text;
int colonIndex = text.indexOf(':');
String indexText = text.substring(0, colonIndex);
String methodName = text.substring(colonIndex + 1);
StackTraceLine stackTraceLine = StackTraceLine(
methodName, inputFileName, annotation.lineNo, annotation.columnNo);
if (indexText == '') {
unexpectedLines.add(stackTraceLine);
} else {
int stackTraceIndex = int.parse(indexText);
assert(!stackTraceMap.containsKey(stackTraceIndex));
stackTraceMap[stackTraceIndex] = stackTraceLine;
}
}
List<StackTraceLine> expectedLines = [];
for (int stackTraceIndex
in (stackTraceMap.keys.toList()..sort()).reversed) {
expectedLines.add(stackTraceMap[stackTraceIndex]!);
}
expectationMap[config] = Expectations(expectedLines, unexpectedLines);
});
return Test(annotatedCode.sourceCode, expectationMap);
}
/// Compile function used in [testStackTrace]. [input] is the name of the input
/// Dart file and [output] is the name of the generated JavaScript file. The
/// function returns `true` if the compilation succeeded.
typedef CompileFunc = Future<bool> Function(String, String);
List<String> emptyPreamble(input, output) => const <String>[];
String? identityConverter(String? name) => name;
/// Tests the stack trace of [test] using the expectations for [config].
///
/// The [compile] function is called to compile the Dart code in [test] to
/// JavaScript.
///
/// The [jsPreambles] contains the path of additional JavaScript files needed
/// to run the generated JavaScript file.
///
/// The [beforeExceptions] lines are allowed before the intended stack trace.
/// The [afterExceptions] lines are allowed after the intended stack trace.
///
/// If [printJs] is `true`, the generated JavaScript code is print to the
/// console. If [writeJs] is `true` the generated JavaScript code and the
/// generated source map are saved in the current working directory (as 'out.js'
/// and 'out.js.map', respectively).
///
/// If forcedTmpDir is given that directory is used as the out directory and
/// will not be cleaned up. Note that if *not* giving a temporary directory and
/// the test fails the directory will not be cleaned up.
Future testStackTrace(Test test, String config, CompileFunc compile,
{bool printJs = false,
bool writeJs = false,
bool verbose = false,
List<String> Function(String input, String output) jsPreambles =
emptyPreamble,
List<LineException> beforeExceptions = const <LineException>[],
List<LineException> afterExceptions = const <LineException>[],
bool useJsMethodNamesOnAbsence = false,
String? Function(String? name) jsNameConverter = identityConverter,
Directory? forcedTmpDir,
int stackTraceLimit = 10,
expandDart2jsInliningData = false}) async {
Expect.isTrue(test.expectationMap.keys.contains(config),
"No expectations found for '$config' in ${test.expectationMap.keys}");
Directory tmpDir =
forcedTmpDir ?? await Directory.systemTemp.createTemp('stacktrace-test');
String input = '${tmpDir.path}/$inputFileName';
File(input).writeAsStringSync(test.code);
String output = '${tmpDir.path}/out.js';
Expect.isTrue(await compile(input, output),
'Unsuccessful compilation of test:\n${test.code}');
File sourceMapFile = File('$output.map');
Expect.isTrue(
sourceMapFile.existsSync(), 'Source map not generated for $input');
String sourceMapText = sourceMapFile.readAsStringSync();
SingleMapping sourceMap = parseSingleMapping(jsonDecode(sourceMapText));
String jsOutput = File(output).readAsStringSync();
if (printJs) {
print('JavaScript output:');
print(jsOutput);
}
if (writeJs) {
File('out.js').writeAsStringSync(jsOutput);
File('out.js.map').writeAsStringSync(sourceMapText);
}
print('Running d8 $output');
List<String> d8Arguments = <String>[];
d8Arguments.add('--stack-trace-limit');
d8Arguments.add('$stackTraceLimit');
d8Arguments.addAll(jsPreambles(input, output));
d8Arguments.add(output);
ProcessResult runResult = Process.runSync(d8executable, d8Arguments);
String out = '${runResult.stderr}\n${runResult.stdout}';
if (verbose) {
print('d8 output:');
print(out);
}
List<String> lines = out.split(RegExp(r'(\r|\n|\r\n)'));
List<StackTraceLine> jsStackTrace = <StackTraceLine>[];
for (String line in lines) {
if (line.startsWith(' at ')) {
StackTraceLine stackTraceLine = StackTraceLine.fromText(line);
if (stackTraceLine.lineNo != null && stackTraceLine.columnNo != null) {
jsStackTrace.add(stackTraceLine);
}
}
}
List<StackTraceLine> dartStackTrace = <StackTraceLine>[];
for (StackTraceLine line in jsStackTrace) {
TargetEntry? targetEntry = _findColumn(
line.lineNo! - 1, line.columnNo! - 1, _findLine(sourceMap, line));
if (targetEntry == null || targetEntry.sourceUrlId == null) {
dartStackTrace.add(line);
} else {
String fileName = sourceMap.urls[targetEntry.sourceUrlId!];
int targetLine = targetEntry.sourceLine! + 1;
int targetColumn = targetEntry.sourceColumn! + 1;
if (expandDart2jsInliningData) {
SourceFile file = SourceFile.fromString(jsOutput);
int offset = file.getOffset(line.lineNo! - 1, line.columnNo! - 1);
Map<int, List<FrameEntry>> frames =
_loadInlinedFrameData(sourceMap, sourceMapText);
List<int> indices = frames.keys.toList()..sort();
int key = binarySearch(indices, (i) => i > offset) - 1;
int depth = 0;
outer:
while (key >= 0) {
for (var frame in frames[indices[key]]!.reversed) {
if (frame.isEmpty) break outer;
if (frame.isPush) {
if (depth <= 0) {
dartStackTrace.add(StackTraceLine(
'${frame.inlinedMethodName}(inlined)',
fileName,
targetLine,
targetColumn,
isMapped: true));
fileName = frame.callUri!;
targetLine = frame.callLine! + 1;
targetColumn = frame.callColumn! + 1;
} else {
depth--;
}
}
if (frame.isPop) {
depth++;
}
}
key--;
}
targetEntry = findEnclosingFunction(jsOutput, file, offset, sourceMap);
}
String? methodName;
if (targetEntry!.sourceNameId != null) {
methodName = sourceMap.names[targetEntry.sourceNameId!];
} else if (useJsMethodNamesOnAbsence) {
methodName = jsNameConverter(line.methodName);
}
dartStackTrace.add(StackTraceLine(
methodName, fileName, targetLine, targetColumn,
isMapped: true));
}
}
Expectations expectations = test.expectationMap[config]!;
int expectedIndex = 0;
List<StackTraceLine> unexpectedLines = <StackTraceLine>[];
List<StackTraceLine> unexpectedBeforeLines = <StackTraceLine>[];
List<StackTraceLine> unexpectedAfterLines = <StackTraceLine>[];
for (StackTraceLine line in dartStackTrace) {
bool found = false;
if (expectedIndex < expectations.expectedLines.length) {
StackTraceLine expectedLine = expectations.expectedLines[expectedIndex];
if (line.methodName == expectedLine.methodName &&
line.lineNo == expectedLine.lineNo &&
line.columnNo == expectedLine.columnNo) {
found = true;
expectedIndex++;
}
}
for (StackTraceLine unexpectedLine in expectations.unexpectedLines) {
if (line.methodName == unexpectedLine.methodName &&
line.lineNo == unexpectedLine.lineNo &&
line.columnNo == unexpectedLine.columnNo) {
unexpectedLines.add(line);
}
}
if (line.isMapped && !found) {
List<LineException> exceptions =
expectedIndex == 0 ? beforeExceptions : afterExceptions;
for (LineException exception in exceptions) {
String fileName = exception.fileName;
if (line.methodName == exception.methodName &&
line.fileName!.endsWith(fileName)) {
found = true;
}
}
if (!found) {
if (expectedIndex == 0) {
unexpectedBeforeLines.add(line);
} else {
unexpectedAfterLines.add(line);
}
}
}
}
if (verbose) {
print('JavaScript stacktrace:');
print(jsStackTrace.join('\n'));
print('\nDart stacktrace:');
print(dartStackTrace.join('\n'));
}
Expect.equals(
expectedIndex,
expectations.expectedLines.length,
'Missing stack trace lines for test:\n${test.code}\n'
'Actual:\n${dartStackTrace.join('\n')}\n\n'
'Expected:\n${expectations.expectedLines.join('\n')}\n');
Expect.isTrue(
unexpectedLines.isEmpty,
'Unexpected stack trace lines for test:\n${test.code}\n'
'Actual:\n${dartStackTrace.join('\n')}\n\n'
'Unexpected:\n${expectations.unexpectedLines.join('\n')}\n');
Expect.isTrue(
unexpectedBeforeLines.isEmpty && unexpectedAfterLines.isEmpty,
'Unexpected stack trace lines:\n${test.code}\n'
'Actual:\n${dartStackTrace.join('\n')}\n\n'
'Unexpected before:\n${unexpectedBeforeLines.join('\n')}\n\n'
'Unexpected after:\n${unexpectedAfterLines.join('\n')}\n');
if (forcedTmpDir == null) {
print("Deleting '${tmpDir.path}'.");
tmpDir.deleteSync(recursive: true);
}
}
class StackTraceLine {
String? methodName;
String? fileName;
int? lineNo;
int? columnNo;
bool isMapped;
StackTraceLine(this.methodName, this.fileName, this.lineNo, this.columnNo,
{this.isMapped = false});
/// Creates a [StackTraceLine] by parsing a d8 stack trace line [text]. The
/// expected formats are
///
/// at <methodName>(<fileName>:<lineNo>:<columnNo>)
/// at <methodName>(<fileName>:<lineNo>)
/// at <methodName>(<fileName>)
/// at <fileName>:<lineNo>:<columnNo>
/// at <fileName>:<lineNo>
/// at <fileName>
///
factory StackTraceLine.fromText(String text) {
text = text.trim();
assert(text.startsWith('at '));
text = text.substring('at '.length);
String? methodName;
if (text.endsWith(')')) {
int nameEnd = text.indexOf(' (');
methodName = text.substring(0, nameEnd);
text = text.substring(nameEnd + 2, text.length - 1);
}
int? lineNo;
int? columnNo;
String fileName;
int lastColon = text.lastIndexOf(':');
if (lastColon != -1) {
int? lastValue = int.tryParse(text.substring(lastColon + 1));
if (lastValue != null) {
int secondToLastColon = text.lastIndexOf(':', lastColon - 1);
if (secondToLastColon != -1) {
int? secondToLastValue =
int.tryParse(text.substring(secondToLastColon + 1, lastColon));
if (secondToLastValue != null) {
lineNo = secondToLastValue;
columnNo = lastValue;
fileName = text.substring(0, secondToLastColon);
} else {
lineNo = lastValue;
fileName = text.substring(0, lastColon);
}
} else {
lineNo = lastValue;
fileName = text.substring(0, lastColon);
}
} else {
fileName = text;
}
} else {
fileName = text;
}
return StackTraceLine(methodName, fileName, lineNo, columnNo);
}
@override
String toString() {
StringBuffer sb = StringBuffer();
sb.write(' at ');
if (methodName != null) {
sb.write(methodName);
sb.write(' (');
sb.write(fileName ?? '?');
sb.write(':');
sb.write(lineNo);
sb.write(':');
sb.write(columnNo);
sb.write(')');
} else {
sb.write(fileName ?? '?');
sb.write(':');
sb.write(lineNo);
sb.write(':');
sb.write(columnNo);
}
return sb.toString();
}
}
/// Returns [TargetLineEntry] which includes the location in the target [line]
/// number. In particular, the resulting entry is the last entry whose line
/// number is lower or equal to [line].
///
/// Copied from [SingleMapping._findLine].
TargetLineEntry? _findLine(SingleMapping sourceMap, StackTraceLine stLine) {
String filename = stLine.fileName!
.substring(stLine.fileName!.lastIndexOf(RegExp('[\\/]')) + 1);
if (sourceMap.targetUrl != filename) return null;
return _findLineInternal(sourceMap, stLine.lineNo! - 1);
}
TargetLineEntry? _findLineInternal(SingleMapping sourceMap, int line) {
int index = binarySearch(sourceMap.lines, (e) => e.line > line);
return (index <= 0) ? null : sourceMap.lines[index - 1];
}
/// Returns [TargetEntry] which includes the location denoted by
/// [line], [column]. If [lineEntry] corresponds to [line], then this will be
/// the last entry whose column is lower or equal than [column]. If
/// [lineEntry] corresponds to a line prior to [line], then the result will be
/// the very last entry on that line.
///
/// Copied from [SingleMapping._findColumn].
TargetEntry? _findColumn(int line, int column, TargetLineEntry? lineEntry) {
if (lineEntry == null || lineEntry.entries.isEmpty) return null;
if (lineEntry.line != line) return lineEntry.entries.last;
var entries = lineEntry.entries;
int index = binarySearch(entries, (e) => e.column > column);
return (index <= 0) ? null : entries[index - 1];
}
/// Returns the path of the d8 executable.
String get d8executable {
if (Platform.isWindows) {
return 'third_party/d8/windows/d8.exe';
} else if (Platform.isLinux) {
return 'third_party/d8/linux/d8';
} else if (Platform.isMacOS) {
return 'third_party/d8/macos/d8';
}
throw UnsupportedError('Unsupported platform.');
}
/// A line allowed in the mapped stack trace.
class LineException {
final String methodName;
final String fileName;
const LineException(this.methodName, this.fileName);
}
/// Search backwards in [sources] for a function declaration that includes the
/// [start] offset.
TargetEntry? findEnclosingFunction(
String sources, SourceFile file, int start, SingleMapping mapping) {
var index = start;
while (true) {
index = nextDeclarationCandidate(sources, index);
if (index < 0) return null;
var line = file.getLine(index);
var lineEntry = _findLineInternal(mapping, line);
var result = _findColumn(line, file.getColumn(index), lineEntry);
if (result?.column == file.getColumn(index)) return result;
index--;
}
}
/// Returns the index of a candidate location of a enclosing function
/// declaration. We try to find the beginning of a `function` keyword or the
/// `(` of an ES6 method definition, but the search contains some false
/// positives. To rule out false positives, [findEnclosingFunction]
/// validates that the returned location contains a source-map entry, searching
/// for another candidate if not.
int nextDeclarationCandidate(String sources, int start) {
var indexForFunctionKeyword = sources.lastIndexOf('function', start);
// We attempt to identify potential method definitions by looking for any '('
// that precedes a '{'. This method will fail if 1) Dart2JS starts emitting
// functions with initializers or 2) sourcemap boundaries appear at '(' for
// non-method-definition constructs.
var indexForMethodDefinition = sources.lastIndexOf('{', start);
if (indexForFunctionKeyword > indexForMethodDefinition ||
indexForFunctionKeyword < 0) {
return indexForFunctionKeyword;
}
indexForMethodDefinition = sources.lastIndexOf('(', indexForMethodDefinition);
return indexForFunctionKeyword > indexForMethodDefinition
? indexForFunctionKeyword
: indexForMethodDefinition;
}
Map<int, List<FrameEntry>> _loadInlinedFrameData(
SingleMapping mapping, String sourceMapText) {
var json = jsonDecode(sourceMapText);
return Dart2jsMapping(mapping, json).frames;
}