| // 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:ffi' show Abi; | |
| 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; | |
| String? methodName; | |
| if (colonIndex >= 0) { | |
| indexText = text.substring(0, colonIndex); | |
| methodName = text.substring(colonIndex + 1); | |
| } else { | |
| indexText = text; | |
| } | |
| 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<int>(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<TargetLineEntry>(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<TargetEntry>(entries, (e) => e.column > column); | |
| return (index <= 0) ? null : entries[index - 1]; | |
| } | |
| /// Returns the path of the d8 executable. | |
| String get d8executable { | |
| final arch = Abi.current().toString().split('_')[1]; | |
| if (Platform.isWindows) { | |
| return 'third_party/d8/windows/$arch/d8.exe'; | |
| } else if (Platform.isLinux) { | |
| return 'third_party/d8/linux/$arch/d8'; | |
| } else if (Platform.isMacOS) { | |
| return 'third_party/d8/macos/$arch/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; | |
| } |