| 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/util.dart'; | |
| import 'package:expect/legacy/minitest.dart'; // ignore: deprecated_member_use | |
| import 'package:path/path.dart' as path; | |
| import 'package:source_maps/source_maps.dart'; | |
| /// Runs D8 and steps as the AnnotatedCode dictates. | |
| /// | |
| /// Note that the compiled javascript is expected to be called "js.js" inside | |
| /// the outputPath directory. It is also expected that there is a "js.js.map" | |
| /// file. It is furthermore expected that the js has been compiled from a file | |
| /// in the same folder called test.dart. | |
| ProcessResult runD8AndStep( | |
| String outputPath, | |
| String testFileName, | |
| AnnotatedCode code, | |
| List<String> scriptD8Command, | |
| ) { | |
| var outputFile = path.join(outputPath, 'js.js'); | |
| var sourcemapText = File('$outputFile.map').readAsStringSync(); | |
| SingleMapping sourceMap = parseSingleMapping(jsonDecode(sourcemapText)); | |
| Set<int?> mappedToLines = sourceMap.lines | |
| .map((entry) => entry.entries.map((entry) => entry.sourceLine).toSet()) | |
| .fold(<int?>{}, (prev, e) => prev..addAll(e)); | |
| for (Annotation annotation in code.annotations.where( | |
| (a) => a.text.trim() == 'nm', | |
| )) { | |
| if (mappedToLines.contains(annotation.lineNo - 1)) { | |
| fail( | |
| 'Was not allowed to have a mapping to line ' | |
| '${annotation.lineNo}, but did.\n' | |
| 'Sourcemap looks like this (note 0-indexed):\n' | |
| '${sourceMap.debugString}', | |
| ); | |
| } | |
| } | |
| List<String?> breakpoints = []; | |
| // Annotations are 1-based, js breakpoints are 0-based. | |
| for (Annotation breakAt in code.annotations.where( | |
| (a) => a.text.trim() == 'bl', | |
| )) { | |
| breakpoints.add( | |
| _getJsBreakpointLine(testFileName, sourceMap, breakAt.lineNo - 1), | |
| ); | |
| } | |
| for (Annotation breakAt in code.annotations.where( | |
| (a) => a.text.trim().startsWith('bc:'), | |
| )) { | |
| breakpoints.add( | |
| _getJsBreakpointLineAndColumn( | |
| testFileName, | |
| sourceMap, | |
| breakAt.lineNo - 1, | |
| breakAt.columnNo - 1, | |
| ), | |
| ); | |
| } | |
| File inspectorFile = File.fromUri( | |
| sdkRoot!.uri.resolve('pkg/sourcemap_testing/lib/src/js/inspector.js'), | |
| ); | |
| if (!inspectorFile.existsSync()) throw "Couldn't find 'inspector.js'"; | |
| var outInspectorPath = path.join(outputPath, 'inspector.js'); | |
| inspectorFile.copySync(outInspectorPath); | |
| String debugAction = 'Debugger.stepInto'; | |
| if (code.annotations.any((a) => a.text.trim() == 'Debugger:stepOver')) { | |
| debugAction = 'Debugger.stepOver'; | |
| } | |
| return _runD8(outInspectorPath, scriptD8Command, debugAction, breakpoints); | |
| } | |
| /// Translates the D8 js steps and checks against expectations. | |
| /// | |
| /// Note that the compiled javascript is expected to be called "js.js" inside | |
| /// the outputPath directory. It is also expected that there is a "js.js.map" | |
| /// file. It is furthermore expected that the js has been compiled from a file | |
| /// in the same folder called test.dart. | |
| void checkD8Steps( | |
| String outputPath, | |
| List<String> d8Output, | |
| AnnotatedCode code, { | |
| bool debug = false, | |
| }) { | |
| var outputFilename = 'js.js'; | |
| var outputFile = path.join(outputPath, outputFilename); | |
| var sourcemapText = File('$outputFile.map').readAsStringSync(); | |
| SingleMapping sourceMap = parseSingleMapping(jsonDecode(sourcemapText)); | |
| List<List<_DartStackTraceDataEntry>> result = _extractStackTraces( | |
| d8Output, | |
| sourceMap, | |
| outputFilename, | |
| ); | |
| List<_DartStackTraceDataEntry> trace = result | |
| .map((entry) => entry.first) | |
| .toList(); | |
| if (debug) _debugPrint(trace, outputPath); | |
| List<String> recordStops = trace | |
| .where((entry) => !entry.isError) | |
| .map((entry) => '$entry') | |
| .toList(); | |
| Set<int> recordStopLines = trace | |
| .where((entry) => !entry.isError) | |
| .map((entry) => entry.line) | |
| .toSet(); | |
| Set<String> recordStopLineColumns = trace | |
| .where((entry) => !entry.isError) | |
| .map((entry) => '${entry.line}:${entry.column}') | |
| .toSet(); | |
| List<String?> expectedStops = []; | |
| for (Annotation annotation in code.annotations.where( | |
| (annotation) => | |
| annotation.text.trim().startsWith('s:') || | |
| annotation.text.trim().startsWith('sl:') || | |
| annotation.text.trim().startsWith('bc:'), | |
| )) { | |
| String text = annotation.text.trim(); | |
| int stopNum = int.parse(text.substring(text.indexOf(':') + 1)); | |
| if (expectedStops.length < stopNum) expectedStops.length = stopNum; | |
| if (text.startsWith('sl:')) { | |
| expectedStops[stopNum - 1] = 'test.dart:${annotation.lineNo}:'; | |
| } else { | |
| expectedStops[stopNum - 1] = | |
| 'test.dart:${annotation.lineNo}:${annotation.columnNo}:'; | |
| } | |
| } | |
| List<List<String>?> noBreaksStart = []; | |
| List<List<String>?> noBreaksEnd = []; | |
| for (Annotation annotation in code.annotations.where( | |
| (annotation) => annotation.text.trim().startsWith('nbb:'), | |
| )) { | |
| String text = annotation.text.trim(); | |
| var split = text.split(':'); | |
| int stopNum1 = int.parse(split[1]); | |
| int stopNum2 = int.parse(split[2]); | |
| if (noBreaksStart.length <= stopNum1) noBreaksStart.length = stopNum1 + 1; | |
| (noBreaksStart[stopNum1] ??= []).add('test.dart:${annotation.lineNo}:'); | |
| if (noBreaksEnd.length <= stopNum2) noBreaksEnd.length = stopNum2 + 1; | |
| (noBreaksEnd[stopNum2] ??= []).add('test.dart:${annotation.lineNo}:'); | |
| } | |
| _checkRecordedStops( | |
| recordStops, | |
| expectedStops, | |
| noBreaksStart, | |
| noBreaksEnd, | |
| debug, | |
| ); | |
| for (Annotation annotation in code.annotations.where( | |
| (annotation) => annotation.text.trim() == 'nb', | |
| )) { | |
| // Check that we didn't break where we're not allowed to. | |
| if (recordStopLines.contains(annotation.lineNo)) { | |
| fail( | |
| 'Was not allowed to stop on line ${annotation.lineNo}, but did!' | |
| ' Actual line stops: $recordStopLines${_debugHint(debug)}', | |
| ); | |
| } | |
| } | |
| for (Annotation annotation in code.annotations.where( | |
| (annotation) => annotation.text.trim() == 'nbc', | |
| )) { | |
| // Check that we didn't break where we're not allowed to. | |
| if (recordStopLineColumns.contains( | |
| '${annotation.lineNo}:${annotation.columnNo}', | |
| )) { | |
| fail( | |
| 'Was not allowed to stop on line ${annotation.lineNo} ' | |
| 'column ${annotation.columnNo}, but did!' | |
| ' Actual line stops: $recordStopLineColumns${_debugHint(debug)}', | |
| ); | |
| } | |
| } | |
| if (code.annotations.any((a) => a.text.trim() == 'fail')) { | |
| fail("Test contains 'fail' annotation."); | |
| } | |
| } | |
| void _checkRecordedStops( | |
| List<String> recordStops, | |
| List<String?> expectedStops, | |
| List<List<String>?> noBreaksStart, | |
| List<List<String>?> noBreaksEnd, | |
| bool debug, | |
| ) { | |
| // We want to find all expected lines in recorded lines in order, but allow | |
| // more in between in the recorded lines. | |
| // noBreaksStart and noBreaksStart gives instructions on what's *NOT* allowed | |
| // to be between those points though. | |
| int expectedIndex = 0; | |
| Set<String> aliveNoBreaks = {}; | |
| if (noBreaksStart.isNotEmpty && noBreaksStart[0] != null) { | |
| aliveNoBreaks.addAll(noBreaksStart[0]!); | |
| } | |
| int stopNumber = 0; | |
| for (String recorded in recordStops) { | |
| stopNumber++; | |
| if (expectedIndex == expectedStops.length) break; | |
| var expectedStop = expectedStops[expectedIndex]; | |
| if (expectedStop != null && '$recorded:'.contains(expectedStop)) { | |
| ++expectedIndex; | |
| if (noBreaksStart.length > expectedIndex && | |
| noBreaksStart[expectedIndex] != null) { | |
| aliveNoBreaks.addAll(noBreaksStart[expectedIndex]!); | |
| } | |
| if (noBreaksEnd.length > expectedIndex && | |
| noBreaksEnd[expectedIndex] != null) { | |
| aliveNoBreaks.removeAll(noBreaksEnd[expectedIndex]!); | |
| } | |
| } else { | |
| if (debug) { | |
| // One of the most helpful debugging tools is to see stops that weren't | |
| // matched. The most common failure is we didn't match one particular | |
| // stop location (e.g. because of the column). This gets reported | |
| // as an aliveNoBreaks failure (if the test is using no-breaks like | |
| // `nbb`) or it's reported as "stops don't match" message. | |
| // | |
| // Both failures are difficult to debug without seeing the stops that | |
| // didn't match. No breaks failures are misleading (the problem isn't | |
| // an incorrect break, but we missed a stop, so the aliveNoBreaks is | |
| // wrong), and the normal failure list dumps the entire stop list, | |
| // making it difficult to see where the mismatch was. | |
| // | |
| // Also we add 1 to expectedIndex, because the stop annotations are | |
| // 1-based in the source files (e.g. `/*s:1*/` is expectedIndex 0) | |
| print( | |
| "Skipping stop `$recorded` that didn't match expected stop " | |
| '${expectedIndex + 1} `${expectedStops[expectedIndex]}`', | |
| ); | |
| } | |
| if (aliveNoBreaks.contains( | |
| "${(recorded.split(":")..removeLast()).join(":")}:", | |
| )) { | |
| fail( | |
| "Break '$recorded' was found when it wasn't allowed " | |
| '(js step $stopNumber, after stop ${expectedIndex + 1}). ' | |
| 'This can happen when an expected stop was not matched' | |
| '${_debugHint(debug)}.', | |
| ); | |
| } | |
| } | |
| } | |
| if (expectedIndex != expectedStops.length) { | |
| // Didn't find everything. | |
| fail( | |
| 'Expected to find $expectedStops but found $recordStops' | |
| '${_debugHint(debug)}', | |
| ); | |
| } | |
| } | |
| /// If we're not in debug mode, this returns a message string with information | |
| /// about how to enable debug mode in the test runner. | |
| String _debugHint(bool debug) { | |
| if (debug) return ''; // already in debug mode | |
| return ' (pass -Ddebug=1 to the test runner to see debug information)'; | |
| } | |
| void _debugPrint(List<_DartStackTraceDataEntry> trace, String outputPath) { | |
| StringBuffer sb = StringBuffer(); | |
| var jsFile = File( | |
| path.join(outputPath, 'js.js'), | |
| ).readAsStringSync().split('\n'); | |
| var dartFile = File( | |
| path.join(outputPath, 'test.dart'), | |
| ).readAsStringSync().split('\n'); | |
| List<String> getSnippet(List<String> data, int line, int column) { | |
| List<String> result = List<String>.filled(5, ''); | |
| if (line < 0 || column < 0) return result; | |
| for (int i = 0; i < 5; ++i) { | |
| int j = line - 2 + i; | |
| if (j < 0 || j >= data.length) continue; | |
| result[i] = data[j]; | |
| } | |
| if (result[2].length >= column) { | |
| var before = result[2].substring(0, column); | |
| var after = result[2].substring(column); | |
| result[2] = '$before/*STOP*/$after'; | |
| } | |
| return result; | |
| } | |
| List<String> sideBySide(List<String> a, List<String> b, int columns) { | |
| List<String> result = List<String>.filled(a.length, ''); | |
| for (int i = 0; i < a.length; ++i) { | |
| String left = a[i].padRight(columns).substring(0, columns); | |
| String right = b[i].padRight(columns).substring(0, columns); | |
| result[i] = '$left | $right'; | |
| } | |
| return result; | |
| } | |
| for (int i = 0; i < trace.length; ++i) { | |
| sb.write('\n\nStop #${i + 1}\n\n'); | |
| if (trace[i].isError && trace[i].jsLine < 0) { | |
| sb.write('${trace[i].errorString}\n'); | |
| continue; | |
| } | |
| var jsSnippet = getSnippet(jsFile, trace[i].jsLine, trace[i].jsColumn); | |
| var dartSnippet = getSnippet( | |
| dartFile, | |
| trace[i].line - 1, | |
| trace[i].column - 1, | |
| ); | |
| var view = sideBySide(jsSnippet, dartSnippet, 50); | |
| sb.writeAll(view, '\n'); | |
| } | |
| print(sb.toString()); | |
| } | |
| List<List<_DartStackTraceDataEntry>> _extractStackTraces( | |
| List<String> lines, | |
| SingleMapping sourceMap, | |
| String outputFilename, | |
| ) { | |
| List<List<_DartStackTraceDataEntry>> result = []; | |
| bool inStackTrace = false; | |
| List<String> currentStackTrace = <String>[]; | |
| for (var line in lines) { | |
| if (line.trim() == '--- Debugger stacktrace start ---') { | |
| inStackTrace = true; | |
| } else if (line.trim() == '--- Debugger stacktrace end ---') { | |
| result.add( | |
| _extractStackTrace(currentStackTrace, sourceMap, outputFilename), | |
| ); | |
| currentStackTrace.clear(); | |
| inStackTrace = false; | |
| } else if (inStackTrace) { | |
| currentStackTrace.add(line.trim()); | |
| } | |
| } | |
| return result; | |
| } | |
| List<_DartStackTraceDataEntry> _extractStackTrace( | |
| List<String> js, | |
| SingleMapping sourceMap, | |
| String wantedFile, | |
| ) { | |
| List<_DartStackTraceDataEntry> result = []; | |
| for (String line in js) { | |
| if (!line.contains('$wantedFile:')) { | |
| result.add(_DartStackTraceDataEntry.error("Not correct file @ '$line'")); | |
| continue; | |
| } | |
| Iterable<Match> ms = RegExp(r'(\d+):(\d+)').allMatches(line); | |
| if (ms.isEmpty) { | |
| result.add( | |
| _DartStackTraceDataEntry.error("Line and column not found for '$line'"), | |
| ); | |
| continue; | |
| } | |
| Match m = ms.first; | |
| int l = int.parse(m[1]!); | |
| int c = int.parse(m[2]!); | |
| SourceMapSpan? span = _getColumnOrPredecessor(sourceMap, l, c); | |
| if (span?.start == null) { | |
| result.add( | |
| _DartStackTraceDataEntry.errorWithJsPosition( | |
| "Source map not found for '$line'", | |
| l, | |
| c, | |
| ), | |
| ); | |
| continue; | |
| } | |
| var file = span!.sourceUrl?.pathSegments.last ?? '(unknown file)'; | |
| result.add( | |
| _DartStackTraceDataEntry( | |
| file, | |
| span.start.line + 1, | |
| span.start.column + 1, | |
| l, | |
| c, | |
| ), | |
| ); | |
| } | |
| return result; | |
| } | |
| SourceMapSpan? _getColumnOrPredecessor( | |
| SingleMapping sourceMap, | |
| int line, | |
| int column, | |
| ) { | |
| SourceMapSpan? span = sourceMap.spanFor(line, column); | |
| if (span == null && line > 0) { | |
| span = sourceMap.spanFor(line - 1, 999999); | |
| } | |
| return span; | |
| } | |
| class _DartStackTraceDataEntry { | |
| final String? file; | |
| final int line; | |
| final int column; | |
| final String? errorString; | |
| final int jsLine; | |
| final int jsColumn; | |
| _DartStackTraceDataEntry( | |
| this.file, | |
| this.line, | |
| this.column, | |
| this.jsLine, | |
| this.jsColumn, | |
| ) : errorString = null; | |
| _DartStackTraceDataEntry.error(this.errorString) | |
| : file = null, | |
| line = -1, | |
| column = -1, | |
| jsLine = -1, | |
| jsColumn = -1; | |
| _DartStackTraceDataEntry.errorWithJsPosition( | |
| this.errorString, | |
| this.jsLine, | |
| this.jsColumn, | |
| ) : file = null, | |
| line = -1, | |
| column = -1; | |
| bool get isError => errorString != null; | |
| @override | |
| String toString() => isError ? errorString! : '$file:$line:$column'; | |
| } | |
| class _PointMapping { | |
| final int fromLine; | |
| final int fromColumn; | |
| final int toLine; | |
| final int toColumn; | |
| _PointMapping(this.fromLine, this.fromColumn, this.toLine, this.toColumn); | |
| } | |
| /// Input and output is expected to be 0-based. | |
| /// | |
| /// The "magic 4" below is taken from | |
| /// https://github.com/ChromeDevTools/devtools-frontend/blob/fa18d70a995f06cb73365b2e5b8ae974cf60bd3a/front_end/sources/JavaScriptSourceFrame.js#L1520-L1523 | |
| String? _getJsBreakpointLine( | |
| String testFileName, | |
| SingleMapping sourceMap, | |
| int breakOnLine, | |
| ) { | |
| List<_PointMapping> mappingsOnLines = []; | |
| for (var line in sourceMap.lines) { | |
| for (var entry in line.entries) { | |
| final sourceLine = entry.sourceLine; | |
| if (sourceLine == null) continue; | |
| final sourceColumn = entry.sourceColumn!; | |
| final sourceUrlId = entry.sourceUrlId; | |
| if (sourceLine >= breakOnLine && | |
| sourceLine < breakOnLine + 4 && | |
| sourceUrlId != null && | |
| sourceMap.urls[sourceUrlId] == testFileName) { | |
| mappingsOnLines.add( | |
| _PointMapping(sourceLine, sourceColumn, line.line, entry.column), | |
| ); | |
| } | |
| } | |
| } | |
| if (mappingsOnLines.isEmpty) return null; | |
| mappingsOnLines.sort((a, b) { | |
| if (a.fromLine != b.fromLine) return a.fromLine - b.fromLine; | |
| if (a.fromColumn != b.fromColumn) return a.fromColumn - b.fromColumn; | |
| if (a.toLine != b.toLine) return a.toLine - b.toLine; | |
| return a.toColumn - b.toColumn; | |
| }); | |
| _PointMapping first = mappingsOnLines.first; | |
| mappingsOnLines.retainWhere((p) => p.toLine >= first.toLine); | |
| _PointMapping last = mappingsOnLines.last; | |
| return '${first.toLine}:${first.toColumn}:${last.toLine}:${first.toColumn}'; | |
| } | |
| /// Input and output is expected to be 0-based. | |
| String? _getJsBreakpointLineAndColumn( | |
| String testFileName, | |
| SingleMapping sourceMap, | |
| int breakOnLine, | |
| int breakOnColumn, | |
| ) { | |
| for (var line in sourceMap.lines) { | |
| for (var entry in line.entries) { | |
| if (entry.sourceLine == breakOnLine && | |
| entry.sourceColumn == breakOnColumn && | |
| entry.sourceUrlId != null && | |
| sourceMap.urls[entry.sourceUrlId!] == testFileName) { | |
| return '${line.line}:${entry.column}'; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| ProcessResult _runD8( | |
| String outInspectorPath, | |
| List<String> scriptD8Command, | |
| String debugAction, | |
| List<String?> breakpoints, | |
| ) { | |
| var outInspectorPathRelative = path.relative(outInspectorPath); | |
| ProcessResult runResult = Process.runSync(d8Executable, [ | |
| '--enable-inspector', | |
| outInspectorPathRelative, | |
| ...scriptD8Command, | |
| '--', | |
| debugAction, | |
| ...breakpoints.nonNulls, | |
| ]); | |
| if (runResult.exitCode != 0) { | |
| print(runResult.stderr); | |
| print(runResult.stdout); | |
| throw 'Exit code: ${runResult.exitCode} from d8'; | |
| } | |
| return runResult; | |
| } | |
| File? _cachedD8File; | |
| Directory? _cachedSdkRoot; | |
| File getD8File() { | |
| File attemptFileFromDir(Directory dir) { | |
| final arch = Abi.current().toString().split('_')[1]; | |
| if (Platform.isWindows) { | |
| return File( | |
| '${dir.path}${Platform.pathSeparator}d8/windows/$arch/d8.exe', | |
| ); | |
| } else if (Platform.isLinux) { | |
| return File('${dir.path}${Platform.pathSeparator}d8/linux/$arch/d8'); | |
| } else if (Platform.isMacOS) { | |
| return File('${dir.path}${Platform.pathSeparator}d8/macos/$arch/d8'); | |
| } | |
| throw UnsupportedError('Unsupported platform.'); | |
| } | |
| File search() { | |
| Directory dir = File.fromUri(Platform.script).parent; | |
| while (dir.path.length > 1) { | |
| for (var entry in dir.listSync()) { | |
| if (entry is! Directory) continue; | |
| if (entry.path.endsWith('third_party')) { | |
| List<String> segments = entry.uri.pathSegments; | |
| if (segments[segments.length - 2] == 'third_party') { | |
| File possibleD8 = attemptFileFromDir(entry); | |
| if (possibleD8.existsSync()) { | |
| _cachedSdkRoot = dir; | |
| return possibleD8; | |
| } | |
| } | |
| } | |
| } | |
| dir = dir.parent; | |
| } | |
| throw 'Cannot find D8 directory.'; | |
| } | |
| return _cachedD8File ??= search(); | |
| } | |
| Directory? get sdkRoot { | |
| getD8File(); | |
| return _cachedSdkRoot; | |
| } | |
| String get d8Executable { | |
| return getD8File().path; | |
| } |