From 03090df3fa4bfbe56f01a792cd8b2273bb95a64c Mon Sep 17 00:00:00 2001 From: Jens Johansen Date: Thu, 30 Nov 2017 11:10:51 +0000 Subject: [PATCH] [DDC/dart2js] Move sourcemap testing to shared place This moves the logic previously used in DDC for testing stepping via sourcemaps to a shared location so both DDC and dart2js can make use of the same thing. Bug: Change-Id: I83a2b1fc4fe5cefe3c8537e50d03c3e41e0490a8 Reviewed-on: https://dart-review.googlesource.com/24110 Commit-Queue: Jens Johansen Reviewed-by: Johnni Winther --- pkg/dev_compiler/test/sourcemap/README.md | 66 +-- pkg/dev_compiler/test/sourcemap/common.dart | 442 +---------------- pkg/sourcemap_testing/README.md | 124 +++++ .../lib/src/js}/inspector.js | 0 .../lib/src/stepping_helper.dart | 448 ++++++++++++++++++ 5 files changed, 586 insertions(+), 494 deletions(-) create mode 100644 pkg/sourcemap_testing/README.md rename pkg/{dev_compiler/test/sourcemap/jsHelpers => sourcemap_testing/lib/src/js}/inspector.js (100%) create mode 100644 pkg/sourcemap_testing/lib/src/stepping_helper.dart diff --git a/pkg/dev_compiler/test/sourcemap/README.md b/pkg/dev_compiler/test/sourcemap/README.md index fca2366f475..8562f98cc9d 100644 --- a/pkg/dev_compiler/test/sourcemap/README.md +++ b/pkg/dev_compiler/test/sourcemap/README.md @@ -1,9 +1,9 @@ # Testing of source maps -This folder contains a testing framework for validating the debugging behavior of the generated js. -It currently has 2 suits: One for (legacy) DDC and one for DDC with kernel (DDK). -In addition it uses a framework for testing stacktraces. This also exists in the same two -configurations. +This folder contains two types of tests for validating sourcemaps: +the debugging behavior and the stacktrace behavior. + +For both there are 2 suits: One for (legacy) DDC and one for DDC with kernel (DDK). Running the tests likely requires the compilation of the correct targets. DDK currently also requires `ddc_sdk.dill` inside @@ -16,49 +16,9 @@ All tests are plain Dart files and goes in "testfiles" (debugging tests) or "sta ## Debugging tests (step tests) -These tests works in a few steps: -1) Create the JS -2) Run the JS with D8, setting instructed brakepoints etc. -3) Translating JS positions to dart positions -4) Validating the stopped at positions. +See `README.md` in `pkg/sourcemap_testing`. -The test files themselves contain information about where to stop, which brakepoints to expect etc. -The contain this information in comments inlined in the code as in `/*key*/` where `key` can be on -of the following: - -* **fail**: Will fail the test. Useful for debugging in conjunction with -`-Ddebug=true -- suite//singletest` (see below). -* **Debugger:stepOver**: Will step over breakpoints. Default (i.e. without this) is to step into. -* **bl** (break line): insert a breakpoint on this line. This does not add any new expected breaks. -* **s:{i}** (stop): adds an expected stop as the `i`th stop (1-indexed). -* **sl:{i}** (stop at line): adds an expected stop as the `i`th stop (1-indexed). Only check the -line number. -* **nb** (no break): The debugger should never break on this line. -* **nbc** (no break column): The debugger should never break on this line and column. -* **nbb:{i}:{j}** (no break between): The debugger should not break on this line between expectation -`i` and `j` (1-indexed). Note that `from` can also be the special value `0` meaning from the very -first stop. For example `nbb:0:1` means not before first expected stop. -* **nm** (no mapping): There's not allowed to be any mapping to this line. -* **bc:{i}** (break column): inserts a breakpoint at this line and column and adds an expected stop -as the `i`th stop (1-indexed). - -Note that in an ideal world `bc:{i}` would not be unnecessary: Stopping at a line and stepping -should generally be enough. Because of the current behavior of d8 though, for instance -``` -baz(foo(), bar()) -``` -will stop at `baz`, go into `foo`, stop at `bar`, go into `bar` and stop at `baz`. -From a Dart perspective we would instead expect it to stop at `foo`, go into `foo`, stop at `bar`, -go into `bar` and stop a `baz`. -Having **bc:{i}** allows us to force this behavior as d8 can actually stop at `foo` too. - -All of these annotations are removed before compiling to js and the expected output thus refers to -the unannotated code. - -When the test confirms that the debugger broke at the expected locations it allows for additional -breakpoints before, between and after the expected breakpoints. - -## Debugging a test +### Debugging a test One can filter which tests are run by running (from the sourcemap folder): ``` @@ -104,17 +64,3 @@ Stop #3 [...] ``` - -## Technical details - -Some of the logic comes from https://github.com/ChromeDevTools/devtools-frontend/, for instance see -https://github.com/ChromeDevTools/devtools-frontend/blob/fa18d70a995f06cb73365b2e5b8ae974cf60bd3a/ -front_end/sources/JavaScriptSourceFrame.js#L1520-L1523 -for how a line breakpoint is resolved: -Basically the line asked to break on in user code (e.g. in dart code) is asked for first and last -javascript positions; these are then used to get possible breakpoints in that part. If there are -none it tries the next line (etc for a number of lines). Once it finds something (in javascript -positions) it converts that to user code position (e.g. in dart code), normalizes it by converting -to javascript position and back to user code position again, then converts to javascript position -and sets the breakpoint. -This is to some extend mimicked here when setting a line break (though not a "column break"). diff --git a/pkg/dev_compiler/test/sourcemap/common.dart b/pkg/dev_compiler/test/sourcemap/common.dart index 664aeb88de0..967c7ad3bb9 100644 --- a/pkg/dev_compiler/test/sourcemap/common.dart +++ b/pkg/dev_compiler/test/sourcemap/common.dart @@ -4,10 +4,9 @@ import 'dart:io'; -import 'package:expect/minitest.dart'; import 'package:path/path.dart' as path; -import 'package:source_maps/source_maps.dart'; import 'package:sourcemap_testing/src/annotated_code_helper.dart'; +import 'package:sourcemap_testing/src/stepping_helper.dart'; import 'package:testing/testing.dart'; class Data { @@ -17,34 +16,6 @@ class Data { List d8Output; } -class DartStackTraceDataEntry { - final String file; - final int line; - final int column; - final 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; - - get isError => errorString != null; - - String toString() => isError ? errorString : "$file:$line:$column"; -} - abstract class ChainContextWithCleanupHelper extends ChainContext { Map cleanupHelper = {}; @@ -82,8 +53,7 @@ class SetCwdToSdkRoot extends Step { Future> run(Data input, ChainContext context) async { // stacktrace_helper assumes CWD is the sdk root dir. - var outerDir = getD8File().parent.parent.parent.parent; - Directory.current = outerDir; + Directory.current = sdkRoot; return pass(input); } } @@ -94,55 +64,10 @@ class StepWithD8 extends Step { String get name => "step"; Future> run(Data data, ChainContext context) async { - var outputPath = data.outDir.path; - var outputFilename = "js.js"; - var outputFile = path.join(outputPath, outputFilename); - var outWrapperPath = path.join(outputPath, "wrapper.js"); - var outInspectorPath = path.join(outputPath, "inspector.js"); - - SingleMapping sourceMap = - parse(new File("${outputFile}.map").readAsStringSync()); - - Set mappedToLines = sourceMap.lines - .map((entry) => entry.entries.map((entry) => entry.sourceLine).toSet()) - .fold(new Set(), (prev, e) => prev..addAll(e)); - - for (Annotation annotation - in data.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 breakpoints = []; - // Annotations are 1-based, js breakpoints are 0-based. - for (Annotation breakAt - in data.code.annotations.where((a) => a.text.trim() == "bl")) { - breakpoints.add(getJsBreakpointLine(sourceMap, breakAt.lineNo - 1)); - } - for (Annotation breakAt in data.code.annotations - .where((a) => a.text.trim().startsWith("bc:"))) { - breakpoints.add(getJsBreakpointLineAndColumn( - sourceMap, breakAt.lineNo - 1, breakAt.columnNo - 1)); - } - - String inspectorPath = new File.fromUri(Platform.script).parent.path + - Platform.pathSeparator + - "jsHelpers" + - Platform.pathSeparator + - "inspector.js"; - new File(inspectorPath).copySync(outInspectorPath); - String debugAction = "Debugger.stepInto"; - if (data.code.annotations - .any((a) => a.text.trim() == "Debugger:stepOver")) { - debugAction = "Debugger.stepOver"; - } - - ProcessResult runResult = - runD8(outInspectorPath, outWrapperPath, debugAction, breakpoints); + var outWrapperPathRelative = + path.relative(path.join(data.outDir.path, "wrapper.js")); + ProcessResult runResult = runD8AndStep( + data.outDir.path, data.code, ['--module', outWrapperPathRelative]); data.d8Output = runResult.stdout.split("\n"); return pass(data); } @@ -156,360 +81,13 @@ class CheckSteps extends Step { String get name => "check"; Future> run(Data data, ChainContext context) async { - var outputPath = data.outDir.path; - var outputFilename = "js.js"; - var outputFile = path.join(outputPath, outputFilename); - - SingleMapping sourceMap = - parse(new File("${outputFile}.map").readAsStringSync()); - - List> result = - extractStackTraces(data.d8Output, sourceMap, outputFilename); - - List trace = - result.map((entry) => entry.first).toList(); - if (debug) debugPrint(trace, outputPath); - - List recordStops = trace - .where((entry) => !entry.isError) - .map((entry) => "$entry") - .toList(); - - Set recordStopLines = trace - .where((entry) => !entry.isError) - .map((entry) => entry.line) - .toSet(); - Set recordStopLineColumns = trace - .where((entry) => !entry.isError) - .map((entry) => "${entry.line}:${entry.column}") - .toSet(); - - List expectedStops = []; - for (Annotation annotation in data.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> noBreaksStart = []; - List> noBreaksEnd = []; - for (Annotation annotation in data.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] ??= []; - if (noBreaksEnd.length <= stopNum2) noBreaksEnd.length = stopNum2 + 1; - noBreaksEnd[stopNum2] ??= []; - - noBreaksStart[stopNum1].add("test.dart:${annotation.lineNo}:"); - noBreaksEnd[stopNum2].add("test.dart:${annotation.lineNo}:"); - } - - checkRecordedStops(recordStops, expectedStops, noBreaksStart, noBreaksEnd); - - for (Annotation annotation in data.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!"); - } - } - for (Annotation annotation in data.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!"); - } - } - - if (data.code.annotations.any((a) => a.text.trim() == "fail")) { - fail("Test contains 'fail' annotation."); - } - + checkD8Steps(data.outDir.path, data.d8Output, data.code, debug: debug); return pass(data); } - - void debugPrint(List trace, String outputPath) { - StringBuffer sb = new StringBuffer(); - var jsFile = - new File(path.join(outputPath, "js.js")).readAsStringSync().split("\n"); - var dartFile = new File(path.join(outputPath, "test.dart")) - .readAsStringSync() - .split("\n"); - - List getSnippet(List data, int line, int column) { - List result = new List.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) { - result[2] = result[2].substring(0, column) + - "/*STOP*/" + - result[2].substring(column); - } - return result; - } - - List sideBySide(List a, List b, int columns) { - List result = new List(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()); - } -} - -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(SingleMapping sourceMap, int breakOnLine) { - List mappingsOnLines = []; - for (var line in sourceMap.lines) { - for (var entry in line.entries) { - if (entry.sourceLine >= breakOnLine && - entry.sourceLine < breakOnLine + 4) { - mappingsOnLines.add(new PointMapping( - entry.sourceLine, entry.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( - 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) - return "${line.line}:${entry.column}"; - } - } - return null; -} - -ProcessResult runD8(String outInspectorPath, String outWrapperPath, - String debugAction, List breakpoints) { - var outInspectorPathRelative = path.relative(outInspectorPath); - var outWrapperPathRelative = path.relative(outWrapperPath); - ProcessResult runResult = Process.runSync( - d8Executable, - [ - '--enable-inspector', - outInspectorPathRelative, - '--module', - outWrapperPathRelative, - "--", - debugAction - ]..addAll(breakpoints.where((s) => s != null))); - if (runResult.exitCode != 0) { - print(runResult.stderr); - print(runResult.stdout); - throw "Exit code: ${runResult.exitCode} from d8"; - } - return runResult; -} - -List> extractStackTraces( - lines, SingleMapping sourceMap, String outputFilename) { - List> result = []; - bool inStackTrace = false; - List currentStackTrace = []; - 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 extractStackTrace( - List js, SingleMapping sourceMap, String wantedFile) { - List result = []; - for (String line in js) { - if (!line.contains("$wantedFile:")) { - result - .add(new DartStackTraceDataEntry.error("Not correct file @ '$line'")); - continue; - } - Iterable ms = new RegExp(r"(\d+):(\d+)").allMatches(line); - if (ms.isEmpty) { - result.add(new DartStackTraceDataEntry.error( - "Line and column not found for '$line'")); - continue; - } - Match m = ms.first; - int l = int.parse(m.group(1)); - int c = int.parse(m.group(2)); - SourceMapSpan span = getColumnOrPredecessor(sourceMap, l, c); - if (span?.start == null) { - result.add(new DartStackTraceDataEntry.errorWithJsPosition( - "Source map not found for '$line'", l, c)); - continue; - } - var file = span.sourceUrl?.pathSegments?.last ?? "(unknown file)"; - result.add(new 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; -} - -void checkRecordedStops(List recordStops, List expectedStops, - List> noBreaksStart, List> noBreaksEnd) { - // 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 aliveNoBreaks = new Set(); - if (noBreaksStart.length > 0 && noBreaksStart[0] != null) { - aliveNoBreaks.addAll(noBreaksStart[0]); - } - int stopNumber = 0; - for (String recorded in recordStops) { - stopNumber++; - if (expectedIndex == expectedStops.length) break; - if ("$recorded:".contains(expectedStops[expectedIndex])) { - ++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 (aliveNoBreaks - .contains("${(recorded.split(":")..removeLast()).join(":")}:")) { - fail("Break '$recorded' was found when it wasn't allowed " - "(js step $stopNumber, after stop $expectedIndex)"); - } - } - if (expectedIndex != expectedStops.length) { - // Didn't find everything. - fail("Expected to find $expectedStops but found $recordStops"); - } -} - -File _cachedD8File; -File getD8File() { - File attemptFileFromDir(Directory dir) { - if (Platform.isWindows) { - return new File(dir.path + Platform.pathSeparator + "d8/windows/d8.exe"); - } else if (Platform.isLinux) { - return new File(dir.path + Platform.pathSeparator + "d8/linux/d8"); - } else if (Platform.isMacOS) { - return new File(dir.path + Platform.pathSeparator + "d8/macos/d8"); - } - throw new UnsupportedError('Unsupported platform.'); - } - - File search() { - Directory dir = new 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 segments = entry.uri.pathSegments; - if (segments[segments.length - 2] == "third_party") { - File possibleD8 = attemptFileFromDir(entry); - if (possibleD8.existsSync()) return possibleD8; - } - } - } - dir = dir.parent; - } - - throw "Cannot find D8 directory."; - } - - return _cachedD8File ??= search(); } File findInOutDir(String relative) { - var outerDir = getD8File().parent.parent.parent.parent.path; + var outerDir = sdkRoot.path; for (var outDir in const ["out/ReleaseX64", "xcodebuild/ReleaseX64"]) { var tryPath = path.join(outerDir, outDir, relative); File file = new File(tryPath); @@ -518,10 +96,6 @@ File findInOutDir(String relative) { throw "Couldn't find $relative. Try building more targets."; } -String get d8Executable { - return getD8File().path; -} - String get dartExecutable { return Platform.resolvedExecutable; } diff --git a/pkg/sourcemap_testing/README.md b/pkg/sourcemap_testing/README.md new file mode 100644 index 00000000000..fa8ea7acb7e --- /dev/null +++ b/pkg/sourcemap_testing/README.md @@ -0,0 +1,124 @@ +# Testing of source maps + +Currently this package consists of two "frameworks": +1) stacktrace_helper.dart +2) stepping_helper.dart + +It is intended to be a shared resource between DDC and dart2js. + +## Stacktraces ("stacktrace_helper.dart") + +TODO + +## Debugging tests (step tests, "stepping_helper.dart") + +This is supposed to work in a few steps: +1) Create the JS +2) Run the JS with D8, setting instructed breakpoints etc. +3) Translating JS positions to Dart positions +4) Validating the stepped positions. + +In the above, the helper assumes +1) The dart file is called "test.dart" and is placed in the output directory before compiling to +"js.js" in the output dir. + +And then performs via +2) `runD8AndStep` +3) `checkD8Steps` +4) (done in above step) + +The test files themselves contain information about where to stop, which breakpoints to expect etc. +They contain this information in comments inlined in the code as in `/*key*/` where `key` can be one +of the below. + +_Not context sensitive_ + +These comments can be anywhere in the file and their position does not matter. + +* **fail**: Will fail the test. Useful for debugging in conjunction with debug being set to true +(see below). +* **Debugger:stepOver**: Will step over breakpoints. Default (i.e. without this) is to step into. + +_Context sensitive_ + +These comments should be placed at the wanted position: The line and possibly column position of the +comment matters. They refer to the next non-whitespace position in the source. + +* **bl** (break line): insert a breakpoint on this line. This does not add any new expected breaks. +* **s:{i}** (stop): adds an expected stop as the `i`th stop (1-indexed). +* **sl:{i}** (stop at line): adds an expected stop as the `i`th stop (1-indexed). Only check the +line number. +* **nb** (no break): The debugger should never break on this line. +* **nbc** (no break column): The debugger should never break on this line and column. +* **nbb:{i}:{j}** (no break between): The debugger should not break on this line between expectation +`i` and `j` (1-indexed). Note that `from` can also be the special value `0` meaning from the very +first stop. For example `nbb:0:1` means not before first expected stop. +* **nm** (no mapping): There's not allowed to be any mapping to this line. +* **bc:{i}** (break column): inserts a breakpoint at this line and column and adds an expected stop +as the `i`th stop (1-indexed). + +Note that in an ideal world `bc:{i}` would not be unnecessary: Stopping at a line and stepping +should generally be enough. Because of the current behavior of d8 though, for instance +``` +baz(foo(), bar()) +``` +will stop at `baz`, go into `foo`, stop at `bar`, go into `bar` and stop at `baz`. +From a Dart perspective we would instead expect it to stop at `foo`, go into `foo`, stop at `bar`, +go into `bar` and stop a `baz`. +Having **bc:{i}** allows us to force this behavior as d8 can actually stop at `foo` too. + +All of these annotations are removed before compiling to js and the expected output thus refers to +the unannotated code. + +When the test confirms that the debugger stopped at the expected locations it allows for additional +breakpoints before, between and after the expected breakpoints. + +### Debugging a test + +By calling `checkD8Steps` with the `debug` parameter set to `true` one can get information like this +dumped to standard out: + +``` +Stop #1 + +test.main = function() { | main() { + try { | try { + let value = /*STOP*/"world"; | var value = /*STOP*/"world"; + dart.throw(dart.str`Hello, ${value}`); | // Comment + } catch (e) { | throw "Hello, $value"; + +Stop #2 + + try { | var value = "world"; + let value = "world"; | // Comment + /*STOP*/dart.throw(dart.str`Hello, ${value}`); | /*STOP*/throw "Hello, $value"; + } catch (e) { | } + let st = dart.stackTrace(e); | // Comment + +Stop #3 + + dart.throw(dart.str`Hello, ${value}`); | } + } catch (e) { | // Comment + let st = /*STOP*/dart.stackTrace(e); | catch (e, /*STOP*/st) { + { | print(e); + core.print(e); | print(st); + +[...] +``` + +This can for instance be useful in combination with `/*fail*/` when adding new tests to see all the +places where the debugger stopped. + +### Technical details + +Some of the logic comes from https://github.com/ChromeDevTools/devtools-frontend/, for instance see +https://github.com/ChromeDevTools/devtools-frontend/blob/fa18d70a995f06cb73365b2e5b8ae974cf60bd3a/ +front_end/sources/JavaScriptSourceFrame.js#L1520-L1523 +for how a line breakpoint is resolved: +Basically the line asked to break on in user code (e.g. in dart code) is asked for first and last +javascript positions; these are then used to get possible breakpoints in that part. If there are +none it tries the next line (etc for a number of lines). Once it finds something (in javascript +positions) it converts that to user code position (e.g. in dart code), normalizes it by converting +to javascript position and back to user code position again, then converts to javascript position +and sets the breakpoint. +This is to some extend mimicked here when setting a line break (though not a "column break"). diff --git a/pkg/dev_compiler/test/sourcemap/jsHelpers/inspector.js b/pkg/sourcemap_testing/lib/src/js/inspector.js similarity index 100% rename from pkg/dev_compiler/test/sourcemap/jsHelpers/inspector.js rename to pkg/sourcemap_testing/lib/src/js/inspector.js diff --git a/pkg/sourcemap_testing/lib/src/stepping_helper.dart b/pkg/sourcemap_testing/lib/src/stepping_helper.dart new file mode 100644 index 00000000000..a906bd2b935 --- /dev/null +++ b/pkg/sourcemap_testing/lib/src/stepping_helper.dart @@ -0,0 +1,448 @@ +import 'dart:io'; + +import 'package:expect/minitest.dart'; +import 'package:path/path.dart' as path; +import 'package:source_maps/source_maps.dart'; + +import 'annotated_code_helper.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, AnnotatedCode code, List scriptD8Command) { + var outputFile = path.join(outputPath, "js.js"); + SingleMapping sourceMap = + parse(new File("${outputFile}.map").readAsStringSync()); + + Set mappedToLines = sourceMap.lines + .map((entry) => entry.entries.map((entry) => entry.sourceLine).toSet()) + .fold(new Set(), (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 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(sourceMap, breakAt.lineNo - 1)); + } + for (Annotation breakAt + in code.annotations.where((a) => a.text.trim().startsWith("bc:"))) { + breakpoints.add(_getJsBreakpointLineAndColumn( + sourceMap, breakAt.lineNo - 1, breakAt.columnNo - 1)); + } + + File inspectorFile = new 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 d8Output, AnnotatedCode code, + {bool debug: false}) { + var outputFilename = "js.js"; + var outputFile = path.join(outputPath, outputFilename); + SingleMapping sourceMap = + parse(new File("${outputFile}.map").readAsStringSync()); + + List> result = + _extractStackTraces(d8Output, sourceMap, outputFilename); + + List<_DartStackTraceDataEntry> trace = + result.map((entry) => entry.first).toList(); + if (debug) _debugPrint(trace, outputPath); + + List recordStops = + trace.where((entry) => !entry.isError).map((entry) => "$entry").toList(); + + Set recordStopLines = + trace.where((entry) => !entry.isError).map((entry) => entry.line).toSet(); + Set recordStopLineColumns = trace + .where((entry) => !entry.isError) + .map((entry) => "${entry.line}:${entry.column}") + .toSet(); + + List 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> noBreaksStart = []; + List> 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] ??= []; + if (noBreaksEnd.length <= stopNum2) noBreaksEnd.length = stopNum2 + 1; + noBreaksEnd[stopNum2] ??= []; + + noBreaksStart[stopNum1].add("test.dart:${annotation.lineNo}:"); + noBreaksEnd[stopNum2].add("test.dart:${annotation.lineNo}:"); + } + + _checkRecordedStops(recordStops, expectedStops, noBreaksStart, noBreaksEnd); + + 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!"); + } + } + 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!"); + } + } + + if (code.annotations.any((a) => a.text.trim() == "fail")) { + fail("Test contains 'fail' annotation."); + } +} + +void _checkRecordedStops(List recordStops, List expectedStops, + List> noBreaksStart, List> noBreaksEnd) { + // 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 aliveNoBreaks = new Set(); + if (noBreaksStart.length > 0 && noBreaksStart[0] != null) { + aliveNoBreaks.addAll(noBreaksStart[0]); + } + int stopNumber = 0; + for (String recorded in recordStops) { + stopNumber++; + if (expectedIndex == expectedStops.length) break; + if ("$recorded:".contains(expectedStops[expectedIndex])) { + ++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 (aliveNoBreaks + .contains("${(recorded.split(":")..removeLast()).join(":")}:")) { + fail("Break '$recorded' was found when it wasn't allowed " + "(js step $stopNumber, after stop $expectedIndex)"); + } + } + if (expectedIndex != expectedStops.length) { + // Didn't find everything. + fail("Expected to find $expectedStops but found $recordStops"); + } +} + +void _debugPrint(List<_DartStackTraceDataEntry> trace, String outputPath) { + StringBuffer sb = new StringBuffer(); + var jsFile = + new File(path.join(outputPath, "js.js")).readAsStringSync().split("\n"); + var dartFile = new File(path.join(outputPath, "test.dart")) + .readAsStringSync() + .split("\n"); + + List getSnippet(List data, int line, int column) { + List result = new List.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) { + result[2] = result[2].substring(0, column) + + "/*STOP*/" + + result[2].substring(column); + } + return result; + } + + List sideBySide(List a, List b, int columns) { + List result = new List(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> _extractStackTraces( + lines, SingleMapping sourceMap, String outputFilename) { + List> result = []; + bool inStackTrace = false; + List currentStackTrace = []; + 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 js, SingleMapping sourceMap, String wantedFile) { + List<_DartStackTraceDataEntry> result = []; + for (String line in js) { + if (!line.contains("$wantedFile:")) { + result.add( + new _DartStackTraceDataEntry.error("Not correct file @ '$line'")); + continue; + } + Iterable ms = new RegExp(r"(\d+):(\d+)").allMatches(line); + if (ms.isEmpty) { + result.add(new _DartStackTraceDataEntry.error( + "Line and column not found for '$line'")); + continue; + } + Match m = ms.first; + int l = int.parse(m.group(1)); + int c = int.parse(m.group(2)); + SourceMapSpan span = _getColumnOrPredecessor(sourceMap, l, c); + if (span?.start == null) { + result.add(new _DartStackTraceDataEntry.errorWithJsPosition( + "Source map not found for '$line'", l, c)); + continue; + } + var file = span.sourceUrl?.pathSegments?.last ?? "(unknown file)"; + result.add(new _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 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; + + get isError => errorString != null; + + 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(SingleMapping sourceMap, int breakOnLine) { + List<_PointMapping> mappingsOnLines = []; + for (var line in sourceMap.lines) { + for (var entry in line.entries) { + if (entry.sourceLine >= breakOnLine && + entry.sourceLine < breakOnLine + 4) { + mappingsOnLines.add(new _PointMapping( + entry.sourceLine, entry.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( + 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) + return "${line.line}:${entry.column}"; + } + } + return null; +} + +ProcessResult _runD8(String outInspectorPath, List scriptD8Command, + String debugAction, List breakpoints) { + var outInspectorPathRelative = path.relative(outInspectorPath); + ProcessResult runResult = Process.runSync( + d8Executable, + ['--enable-inspector', outInspectorPathRelative] + ..addAll(scriptD8Command) + ..addAll(["--", debugAction]) + ..addAll(breakpoints.where((s) => s != null))); + 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) { + if (Platform.isWindows) { + return new File(dir.path + Platform.pathSeparator + "d8/windows/d8.exe"); + } else if (Platform.isLinux) { + return new File(dir.path + Platform.pathSeparator + "d8/linux/d8"); + } else if (Platform.isMacOS) { + return new File(dir.path + Platform.pathSeparator + "d8/macos/d8"); + } + throw new UnsupportedError('Unsupported platform.'); + } + + File search() { + Directory dir = new 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 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; +}