[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:
committed by
Space Team
parent
edd0e5e0bf
commit
0e3f48314a
+8
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user