[JS IR] Improve stepping test executor

Don't search for box function in the generated code;
instead, stop the execution right before calling it.

This makes stepping tests more accurate.
This commit is contained in:
Sergej Jaskiewicz
2022-10-26 17:18:09 +02:00
committed by Space Team
parent edd0e5e0bf
commit 0e3f48314a
16 changed files with 138 additions and 130 deletions
+8 -1
View File
@@ -34,4 +34,11 @@ fun getD() = true
// EXPECTATIONS JS_IR
// test.kt:3 box
// test.kt:11 box
// test.kt:13 getA
// test.kt:4 box
// test.kt:15 getB
// test.kt:5 box
// test.kt:17 getC
// test.kt:9 box
// test.kt:3 box
// test.kt:11 box
+2 -1
View File
@@ -31,4 +31,5 @@ inline fun foo(n: Int) {}
// test.kt:3 box
// test.kt:3 box
// test.kt:3 box
// test.kt:6 box
// test.kt:3 box
// test.kt:6 box
@@ -56,6 +56,7 @@ fun nop() {}
// test.kt:21 box
// EXPECTATIONS JS_IR
// test.kt:24 box
// test.kt:3 box
// test.kt:4 box
// test.kt:30 nop
@@ -74,4 +75,4 @@ fun nop() {}
// test.kt:15 box
// test.kt:19 box
// test.kt:30 nop
// test.kt:21 box
// test.kt:21 box
+3 -5
View File
@@ -33,10 +33,10 @@ val jesse1VarDecl = "var jesse1 = _.exclamate(jesse);"
// FILE: test.kt
fun noop() {}
fun box() {
noop()
exclamateAndConcat(
"hello",
"world")
@@ -56,8 +56,6 @@ fun box() {
}
// EXPECTATIONS
// test.kt:39 box
// test.kt:36 noop
// test.kt:41 box
// test.kt:42 box
// a.kt:11 box
@@ -79,4 +77,4 @@ fun box() {
// a.kt:6 exclamate
// a.kt:29 box
// a.kt:22 value
// test.kt:56 box
// test.kt:56 box
+2 -1
View File
@@ -19,6 +19,7 @@ inline fun foo() = {
// test.kt:5 box
// EXPECTATIONS JS_IR
// test1.kt:9 box
// test.kt:4 box
// test1.kt:6 box$lambda
// test.kt:5 box
// test.kt:5 box
+2 -1
View File
@@ -25,7 +25,8 @@ inline fun foo() = {
// test.kt:6 box
// EXPECTATIONS JS_IR
// test1.kt:10 box
// test.kt:4 box
// test.kt:5 box
// test1.kt:7 box$lambda
// test.kt:6 box
// test.kt:6 box
+2 -1
View File
@@ -38,6 +38,7 @@ fun baz(v:(() -> Unit)) {
// test.kt:7 box
// EXPECTATIONS JS_IR
// test1.kt:11 box
// test.kt:4 box
// test3.kt:14 baz
// test1.kt:8 box$lambda
@@ -48,4 +49,4 @@ fun baz(v:(() -> Unit)) {
// test3.kt:14 baz
// test1.kt:8 box$lambda
// test3.kt:15 baz
// test.kt:7 box
// test.kt:7 box
+7
View File
@@ -65,5 +65,12 @@ inline fun html(init: () -> Unit) {
// test.kt:21 box
// EXPECTATIONS JS_IR
// test.kt:7 box
// 1.kt:33 box
// 1.kt:36 box
// 1.kt:37 box
// 1.kt:37 box
// 1.kt:41 box
// test.kt:15 box
// test.kt:19 box
// test.kt:21 box
@@ -38,3 +38,6 @@ fun box(): String {
// test.kt:10 box
// EXPECTATIONS JS_IR
// test.kt:3 box
// test.kt:5 box
// test.kt:6 box
+2 -1
View File
@@ -38,6 +38,7 @@ fun fail() : String {
// test.kt:9 box
// EXPECTATIONS JS_IR
// test.kt:16 box
// test.kt:4 box
// test.kt:20 fail
// test.kt:4 box
@@ -47,4 +48,4 @@ fun fail() : String {
// test.kt:16 box
// test.kt:7 box
// test.kt:13 checkEquals
// test.kt:9 box
// test.kt:9 box
@@ -36,6 +36,7 @@ fun fail() : String {
// test.kt:11 box
// EXPECTATIONS JS_IR
// test.kt:14 box
// test.kt:6 box
// test.kt:18 fail
// test.kt:6 box
@@ -45,4 +46,4 @@ fun fail() : String {
// test.kt:14 box
// test.kt:9 box
// test.kt:3 execute
// test.kt:11 box
// test.kt:11 box
@@ -38,6 +38,7 @@ fun fail() : String {
// test.kt:9 box
// EXPECTATIONS JS_IR
// test.kt:16 box
// test.kt:4 box
// test.kt:5 box
// test.kt:20 fail
@@ -47,4 +48,4 @@ fun fail() : String {
// test.kt:16 box
// test.kt:8 box
// test.kt:16 box
// test.kt:9 box
// test.kt:9 box
@@ -37,10 +37,11 @@ fun fail() : String {
// test.kt:9 box
// EXPECTATIONS JS_IR
// test.kt:12 box
// test.kt:4 box
// test.kt:16 fail
// test.kt:7 box
// test.kt:16 fail
// test.kt:12 box
// test.kt:7 box
// test.kt:9 box
// test.kt:9 box
@@ -19,7 +19,13 @@ suspend fun box() {
// test.kt:11 box
// EXPECTATIONS JS_IR
// test.kt:7 box
// test.kt:7 <init>
// test.kt:7 <init>
// test.kt:7 box
// test.kt:7 box
// test.kt:7 box
// test.kt:8 doResume
// test.kt:4 foo
// test.kt:4 foo
// test.kt:9 box$lambda
// test.kt:9 box$lambda
@@ -14,7 +14,13 @@ const code = fs.readFileSync(testFilePath, 'utf-8');
try {
vm.runInContext(code, sandbox, testFilePath);
vm.runInContext('main.box()', sandbox);
// language=JavaScript
vm.runInContext(`
debugger;
main.box();
`,
sandbox
);
} catch (e) {
// Ignore any exceptions
}
@@ -5,16 +5,12 @@
package org.jetbrains.kotlin.js.test.handlers
import kotlinx.coroutines.withTimeout
import org.jetbrains.kotlin.KtPsiSourceFileLinesMapping
import org.jetbrains.kotlin.ir.backend.js.transformers.irToJs.TranslationMode
import org.jetbrains.kotlin.js.parser.sourcemaps.*
import org.jetbrains.kotlin.js.test.debugger.*
import org.jetbrains.kotlin.js.test.utils.getAllFilesForRunner
import org.jetbrains.kotlin.js.test.utils.getBoxFunction
import org.jetbrains.kotlin.psi.psiUtil.startOffset
import org.jetbrains.kotlin.test.TargetBackend
import org.jetbrains.kotlin.test.directives.JsEnvironmentConfigurationDirectives
import org.jetbrains.kotlin.test.model.TestFile
import org.jetbrains.kotlin.test.model.TestModule
import org.jetbrains.kotlin.test.services.TestServices
import org.jetbrains.kotlin.test.services.configuration.JsEnvironmentConfigurator
@@ -29,7 +25,7 @@ import java.net.URISyntaxException
/**
* This class is an analogue of the [DebugRunner][org.jetbrains.kotlin.test.backend.handlers.DebugRunner] from JVM stepping tests.
*
* It runs a generated JavaScript file under a debugger, sets a breakpoint in the beginning of the `box` function
* It runs a generated JavaScript file under a debugger, stops right before entering the `box` function,
* and performs the "step into" action until there is nothing more to step into. On each pause it records the source file name,
* the source line and the function name of the current call frame, and compares this data with the expectations written in the test file.
*
@@ -72,47 +68,54 @@ class JsDebugRunner(testServices: TestServices) : AbstractJsArtifactsCollector(t
sourceMap: SourceMap,
mainModule: TestModule,
) {
val (testFileWithBoxFunction, boxFunctionStartLine) = getBoxFunctionStartLocation(mainModule)
val originalFileWithBoxFunction = testFileWithBoxFunction.originalFile
val boxFunctionLineInGeneratedFile =
sourceMap.breakpointLineInGeneratedFile(originalFileWithBoxFunction, boxFunctionStartLine)
if (boxFunctionLineInGeneratedFile < 0)
error("Could not find the location of the 'box' function in the generated file")
val originalFile = mainModule.files.first { !it.isAdditional }.originalFile
val debuggerFacade = NodeJsDebuggerFacade(jsFilePath)
val jsFileURI = File(jsFilePath).absoluteFile.toURI().withAuthority("")
val jsFile = File(jsFilePath)
val jsFileURI = jsFile.makeURI()
val loggedItems = mutableListOf<SteppingTestLoggedData>()
debuggerFacade.run {
with(debuggerFacade) {
val boxFunctionBreakpoint = debugger.setBreakpointByUrl(boxFunctionLineInGeneratedFile, jsFileURI.toString())
debugger.resume()
waitForResumeEvent()
waitForPauseEvent {
it.reason == Debugger.PauseReason.OTHER && it.hitBreakpoints.contains(boxFunctionBreakpoint.breakpointId)
}
debugger.resume()
waitForResumeEvent()
waitForPauseEvent {
it.reason == Debugger.PauseReason.OTHER // hit the 'debugger' statement
}
suspend fun repeatedlyStepInto(action: suspend (Debugger.CallFrame) -> Boolean) {
while (true) {
val topMostCallFrame = waitForPauseEvent().callFrames[0]
try {
if (URI(scriptUrlByScriptId(topMostCallFrame.location.scriptId)) != jsFileURI) break
} catch (_: URISyntaxException) {
// Probably something like 'evalmachine.<anonymous>' brought us here. Ignore.
}
addCallFrameInfoToLoggedItems(sourceMap, topMostCallFrame, loggedItems)
if (!action(topMostCallFrame)) break
debugger.stepInto()
waitForResumeEvent()
}
debugger.resume()
waitForResumeEvent()
}
fun Debugger.CallFrame.isInFileUnderTest() = try {
URI(scriptUrlByScriptId(location.scriptId)) == jsFileURI
} catch (_: URISyntaxException) {
false
}
repeatedlyStepInto {
!it.isInFileUnderTest()
}
repeatedlyStepInto { callFrame ->
callFrame.isInFileUnderTest().also {
if (it)
addCallFrameInfoToLoggedItems(sourceMap, callFrame, loggedItems)
}
}
debugger.resume()
waitForResumeEvent()
}
checkSteppingTestResult(
mainModule.frontendKind,
mainModule.targetBackend ?: TargetBackend.JS_IR,
originalFileWithBoxFunction,
originalFile,
loggedItems
)
}
@@ -123,82 +126,24 @@ class JsDebugRunner(testServices: TestServices) : AbstractJsArtifactsCollector(t
loggedItems: MutableList<SteppingTestLoggedData>
) {
val originalFunctionName = topMostCallFrame.functionLocation?.let {
sourceMap.getSourceLineForGeneratedLocation(it)?.name
sourceMap.segmentForGeneratedLocation(it.lineNumber, it.columnNumber)?.name
}
sourceMap.getSourceLineForGeneratedLocation(topMostCallFrame.location)?.let { (_, sourceFile, sourceLine, _, _) ->
sourceMap.segmentForGeneratedLocation(
topMostCallFrame.location.lineNumber,
topMostCallFrame.location.columnNumber
)?.let { (_, sourceFile, sourceLine, _, _) ->
if (sourceFile == null || sourceLine < 0) return@let
val testFileName = testFileNameFromMappedLocation(sourceFile, sourceLine) ?: return
val expectation =
formatAsSteppingTestExpectation(testFileName, sourceLine + 1, originalFunctionName ?: topMostCallFrame.functionName, false)
val expectation = formatAsSteppingTestExpectation(
testFileName,
sourceLine + 1,
originalFunctionName ?: topMostCallFrame.functionName,
false,
)
loggedItems.add(SteppingTestLoggedData(sourceLine + 1, false, expectation))
}
}
/**
* Returns the test file and the line number in that file where the body of the `box` function begins.
*/
private fun getBoxFunctionStartLocation(mainModule: TestModule): Pair<TestFile, Int> {
val boxFunction = getBoxFunction(testServices) ?: error("Missing 'box' function")
val file = boxFunction.containingKtFile
val mapping = KtPsiSourceFileLinesMapping(file)
val firstStatementOffset = boxFunction.bodyBlockExpression?.firstStatement?.startOffset
?: boxFunction.bodyExpression?.startOffset
?: boxFunction.startOffset
return mainModule.files.single { it.name == file.name } to mapping.getLineByOffset(firstStatementOffset)
}
/**
* Maps the location in the source file to the location in the generated file.
*
* The Node.js debugger is not sourcemap-aware, so we need to set a breakpoint in the `box` function in the generated JS file.
*
* We don't know where the generated `box` function is located, so we use the source map to figure it out.
*
* This is basically what Intellij IDEA's built-in JavaScript debugger does when you set a breakpoint in a source file: it tries
* to map the location of the breakpoint in the source file to a location in the generated file. Here we use a simplified
* algorithm for that.
*/
private fun SourceMap.breakpointLineInGeneratedFile(sourceFile: File, sourceLine: Int): Int {
val sourceFileAbsolutePath = sourceFile.absoluteFile.normalize()
var candidateSegment: Pair<Int, SourceMapSegment>? = null
for ((generatedLineNumber, group) in groups.withIndex()) {
for (segment in group.segments) {
if (segment.sourceFileName?.let { File(it).absoluteFile.normalize() } != sourceFileAbsolutePath ||
segment.sourceLineNumber != sourceLine)
continue
if (candidateSegment == null)
candidateSegment = generatedLineNumber to segment
// Find the segment that points to the earliest column in the source file
if (segment.sourceColumnNumber < candidateSegment.second.sourceColumnNumber)
candidateSegment = generatedLineNumber to segment
}
}
return candidateSegment?.first ?: -1
}
/**
* Maps [location] in the generated JavaScript file to the corresponding location in a source file.
* @return The source file path (as specified in the source map) and the line number in that source file.
*/
private fun SourceMap.getSourceLineForGeneratedLocation(location: Debugger.Location): SourceMapSegment? {
val group = groups.getOrNull(location.lineNumber)?.takeIf { it.segments.isNotEmpty() } ?: return null
val columnNumber = location.columnNumber ?: return group.segments[0]
return if (columnNumber <= group.segments[0].generatedColumnNumber) {
group.segments[0]
} else {
val candidateIndex = group.segments.indexOfFirst {
columnNumber <= it.generatedColumnNumber
}
if (candidateIndex < 0)
null
else if (candidateIndex == 0 || group.segments[candidateIndex].generatedColumnNumber == columnNumber)
group.segments[candidateIndex]
else
group.segments[candidateIndex - 1]
}
}
/**
* An original test file may represent multiple source files (by using the `// FILE: myFile.kt` comments).
* Sourcemaps contain paths to original test files. However, in test expectations we write names as in the `// FILE:` comments.
@@ -206,9 +151,10 @@ class JsDebugRunner(testServices: TestServices) : AbstractJsArtifactsCollector(t
*/
private fun testFileNameFromMappedLocation(originalFilePath: String, originalFileLineNumber: Int): String? {
val originalFile = File(originalFilePath)
return testServices.moduleStructure.modules.flatMap { it.files }.findLast {
it.originalFile.absolutePath == originalFile.absolutePath && it.startLineNumberInOriginalFile <= originalFileLineNumber
}?.name
return testServices.moduleStructure.modules.asSequence().flatMap { module -> module.files.asSequence().filter { !it.isAdditional } }
.findLast {
it.originalFile.absolutePath == originalFile.absolutePath && it.startLineNumberInOriginalFile <= originalFileLineNumber
}?.name
}
}
@@ -250,26 +196,52 @@ private class NodeJsDebuggerFacade(jsFilePath: String) {
/**
* By the time [body] is called, the execution is paused, no code is executed yet.
*/
fun <T> run(body: suspend NodeJsInspectorClientContext.() -> T) = inspector.run {
fun <T> run(body: suspend Context.() -> T) = inspector.run {
debugger.enable()
debugger.setSkipAllPauses(false)
runtime.runIfWaitingForDebugger()
waitForPauseEvent { it.reason == Debugger.PauseReason.BREAK_ON_START }
withTimeout(30000) {
body()
with(Context(this)) {
waitForPauseEvent { it.reason == Debugger.PauseReason.BREAK_ON_START }
withTimeout(30000) {
body()
}
}
}
fun scriptUrlByScriptId(scriptId: Runtime.ScriptId) = scriptUrls[scriptId] ?: error("unknown scriptId")
inner class Context(private val underlying: NodeJsInspectorClientContext) : NodeJsInspectorClientContext by underlying {
suspend fun NodeJsInspectorClientContext.waitForPauseEvent(suchThat: (Debugger.Event.Paused) -> Boolean = { true }) =
waitForValueToBecomeNonNull {
pausedEvent?.takeIf(suchThat)
}
fun scriptUrlByScriptId(scriptId: Runtime.ScriptId) = scriptUrls[scriptId] ?: error("unknown scriptId $scriptId")
suspend fun NodeJsInspectorClientContext.waitForResumeEvent() = waitForConditionToBecomeTrue { pausedEvent == null }
suspend fun waitForPauseEvent(suchThat: (Debugger.Event.Paused) -> Boolean = { true }) =
waitForValueToBecomeNonNull {
pausedEvent?.takeIf(suchThat)
}
suspend fun waitForResumeEvent() = waitForConditionToBecomeTrue { pausedEvent == null }
}
}
private fun File.makeURI(): URI = absoluteFile.toURI().withAuthority("")
private fun URI.withAuthority(newAuthority: String?) =
URI(scheme, newAuthority, path, query, fragment)
private fun SourceMap.segmentForGeneratedLocation(lineNumber: Int, columnNumber: Int?): SourceMapSegment? {
val group = groups.getOrNull(lineNumber)?.takeIf { it.segments.isNotEmpty() } ?: return null
return if (columnNumber == null || columnNumber <= group.segments[0].generatedColumnNumber) {
group.segments[0]
} else {
val candidateIndex = group.segments.indexOfFirst {
columnNumber <= it.generatedColumnNumber
}
if (candidateIndex < 0)
null
else if (candidateIndex == 0 || group.segments[candidateIndex].generatedColumnNumber == columnNumber)
group.segments[candidateIndex]
else
group.segments[candidateIndex - 1]
}
}