// 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:io'; import 'dart:convert' show jsonDecode; import 'package:_fe_analyzer_shared/src/testing/annotated_code_helper.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'; import 'package:dart2js_tools/src/dart2js_mapping.dart'; const String INPUT_FILE_NAME = 'input.dart'; class Test { final String code; final Map expectationMap; Test(this.code, this.expectationMap); } class Expectations { final List expectedLines; final List 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 configs) { AnnotatedCode annotatedCode = new AnnotatedCode.fromText(code, commentStart, commentEnd); Map expectationMap = {}; splitByPrefixes(annotatedCode, configs) .forEach((String config, AnnotatedCode annotatedCode) { Map stackTraceMap = {}; List unexpectedLines = []; 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 = new StackTraceLine( methodName, INPUT_FILE_NAME, annotation.lineNo, annotation.columnNo); if (indexText == '') { unexpectedLines.add(stackTraceLine); } else { int stackTraceIndex = int.parse(indexText); assert(!stackTraceMap.containsKey(stackTraceIndex)); stackTraceMap[stackTraceIndex] = stackTraceLine; } } List expectedLines = []; for (int stackTraceIndex in (stackTraceMap.keys.toList()..sort()).reversed) { expectedLines.add(stackTraceMap[stackTraceIndex]); } expectationMap[config] = new Expectations(expectedLines, unexpectedLines); }); return new 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 Future CompileFunc(String input, String output); List emptyPreamble(input, output) => const []; 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 Function(String input, String output) jsPreambles: emptyPreamble, List beforeExceptions: const [], List afterExceptions: const [], bool useJsMethodNamesOnAbsence: false, String Function(String name) jsNameConverter: identityConverter, Directory forcedTmpDir: null, 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}/$INPUT_FILE_NAME'; new 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 = new File('$output.map'); Expect.isTrue( sourceMapFile.existsSync(), "Source map not generated for $input"); String sourceMapText = sourceMapFile.readAsStringSync(); SingleMapping sourceMap = parse(sourceMapText); String jsOutput = new File(output).readAsStringSync(); if (printJs) { print('JavaScript output:'); print(jsOutput); } if (writeJs) { new File('out.js').writeAsStringSync(jsOutput); new File('out.js.map').writeAsStringSync(sourceMapText); } print("Running d8 $output"); List d8Arguments = []; 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 lines = out.split(new RegExp(r'(\r|\n|\r\n)')); List jsStackTrace = []; for (String line in lines) { if (line.startsWith(' at ')) { StackTraceLine stackTraceLine = new StackTraceLine.fromText(line); if (stackTraceLine.lineNo != null && stackTraceLine.columnNo != null) { jsStackTrace.add(stackTraceLine); } } } List dartStackTrace = []; 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; if (targetEntry.sourceUrlId != null) { fileName = sourceMap.urls[targetEntry.sourceUrlId]; } int targetLine = targetEntry.sourceLine + 1; int targetColumn = targetEntry.sourceColumn + 1; if (expandDart2jsInliningData) { SourceFile file = new SourceFile.fromString(jsOutput); int offset = file.getOffset(line.lineNo - 1, line.columnNo - 1); Map> frames = _loadInlinedFrameData(sourceMap, sourceMapText); List 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(new 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(new StackTraceLine( methodName, fileName, targetLine, targetColumn, isMapped: true)); } } Expectations expectations = test.expectationMap[config]; int expectedIndex = 0; List unexpectedLines = []; List unexpectedBeforeLines = []; List unexpectedAfterLines = []; 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 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 (::) /// at (:) /// at () /// at :: /// at : /// at /// 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 new StackTraceLine(methodName, fileName, lineNo, columnNo); } String toString() { StringBuffer sb = new 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(new 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.length == 0) 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 new 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) { if (sources == null) return null; int index = sources.lastIndexOf(': function(', start); if (index < 0) return null; index += 2; var line = file.getLine(index); var lineEntry = _findLineInternal(mapping, line); return _findColumn(line, file.getColumn(index), lineEntry); } Map> _loadInlinedFrameData( SingleMapping mapping, String sourceMapText) { var json = jsonDecode(sourceMapText); return Dart2jsMapping(mapping, json).frames; }