Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JS nested tests v2 #3913

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -1,5 +1,3 @@
@file:OptIn(ExperimentalStdlibApi::class)

package io.kotest.engine.test

import io.kotest.common.MonotonicTimeSourceCompat
Expand Down Expand Up @@ -79,7 +77,7 @@ internal class TestCaseExecutor(
timeMark,
listOfNotNull(
InvocationTimeoutInterceptor,
if (platform == Platform.JVM && testCase.config.testCoroutineDispatcher) TestDispatcherInterceptor() else null,
if (platform == Platform.JVM && testCase.config.coroutineTestScope) TestDispatcherInterceptor() else null,
if (platform != Platform.JS && testCase.config.coroutineTestScope) TestCoroutineInterceptor() else null,
)
),
Expand Down
@@ -1,6 +1,5 @@
package io.kotest.engine.test.scopes

import io.kotest.common.ExperimentalKotest
import io.kotest.core.concurrency.CoroutineDispatcherFactory
import io.kotest.core.names.DuplicateTestNameMode
import io.kotest.core.test.NestedTest
Expand Down
Expand Up @@ -8,8 +8,9 @@ import io.kotest.engine.test.TestCaseExecutionListener
/**
* A [TestCaseExecutionListener] that completes the Js promise when a test is finished.
*/
internal class PromiseTestCaseExecutionListener(private val done: (errorOrNull: Throwable?) -> Unit) :
AbstractTestCaseExecutionListener() {
internal class PromiseTestCaseExecutionListener(
private val done: JsTestDoneCallback
) : AbstractTestCaseExecutionListener() {

override suspend fun testFinished(testCase: TestCase, result: TestResult) {
done(result.errorOrNull)
Expand Down
@@ -1,16 +1,31 @@
package io.kotest.engine

expect fun jasmineTestFrameworkAvailable(): Boolean
internal expect fun jasmineTestFrameworkAvailable(): Boolean

// Adapters for test framework functions whose signatures differ between JS and Wasm.

expect fun jasmineTestIt(
internal expect fun jasmineTestIt(
description: String,
testFunction: (done: (errorOrNull: Throwable?) -> Unit) -> Any?,
timeout: Int
// some frameworks default to a 2000 timeout,
// here we set to a high number and use the timeout support kotest provides via coroutines
timeout: Int = Int.MAX_VALUE,
testFunction: (done: JsTestDoneCallback) -> Any?,
)

expect fun jasmineTestXit(
internal expect fun jasmineTestXit(
description: String,
testFunction: (done: (errorOrNull: Throwable?) -> Unit) -> Any?
testFunction: (done: JsTestDoneCallback) -> Any?,
)

internal expect fun jasmineTestDescribe(
description: String,
specDefinitions: () -> Unit,
)

internal expect fun jasmineTestXDescribe(
description: String,
specDefinitions: () -> Unit,
)


internal typealias JsTestDoneCallback = (errorOrNull: Throwable?) -> Unit
@@ -1,76 +1,228 @@
package io.kotest.engine.spec

import io.kotest.common.ExperimentalKotest
import io.kotest.common.runPromiseIgnoringErrors
import io.kotest.core.concurrency.CoroutineDispatcherFactory
import io.kotest.core.spec.Spec
import io.kotest.core.test.NestedTest
import io.kotest.core.test.TestCase
import io.kotest.core.test.TestResult
import io.kotest.engine.PromiseTestCaseExecutionListener
import io.kotest.engine.concurrency.NoopCoroutineDispatcherFactory
import io.kotest.core.test.TestScope
import io.kotest.engine.JsTestDoneCallback
import io.kotest.engine.interceptors.EngineContext
import io.kotest.engine.jasmineTestDescribe
import io.kotest.engine.jasmineTestIt
import io.kotest.engine.jasmineTestXit
import io.kotest.engine.listener.TestEngineListener
import io.kotest.engine.spec.interceptor.SpecInterceptorPipeline
import io.kotest.engine.test.TestCaseExecutionListener
import io.kotest.engine.test.TestCaseExecutor
import io.kotest.engine.test.interceptors.testNameEscape
import io.kotest.engine.test.interceptors.escapedJsTestName
import io.kotest.engine.test.listener.TestCaseExecutionListenerToTestEngineListenerAdapter
import io.kotest.engine.test.names.FallbackDisplayNameFormatter
import io.kotest.engine.test.names.getFallbackDisplayNameFormatter
import io.kotest.engine.test.scopes.TerminalTestScope
import io.kotest.engine.test.scopes.DuplicateNameHandlingTestScope
import io.kotest.engine.test.status.isEnabledInternal
import io.kotest.engine.jasmineTestXit
import io.kotest.mpp.Logger
import io.kotest.mpp.bestName
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.coroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch


/**
* A [SpecExecutorDelegate] running tests via a Jasmine-like JavaScript test framework (Jasmine/Mocha/Jest).
*
* Note: we need to use this: https://youtrack.jetbrains.com/issue/KT-22228
*/
@ExperimentalKotest
internal class JasmineTestSpecExecutorDelegate(private val context: EngineContext) : SpecExecutorDelegate {
internal class JasmineTestSpecExecutorDelegate(
private val defaultCoroutineDispatcherFactory: CoroutineDispatcherFactory,
private val context: EngineContext,
) : SpecExecutorDelegate {

private val formatter = getFallbackDisplayNameFormatter(
private val formatter: FallbackDisplayNameFormatter = getFallbackDisplayNameFormatter(
context.configuration.registry,
context.configuration,
)

override suspend fun execute(spec: Spec): Map<TestCase, TestResult> {
val runner = SingleInstanceSpecJsRunner(
specName = spec.describeName,
defaultCoroutineDispatcherFactory = defaultCoroutineDispatcherFactory,
context = context,
formatter = formatter,
coroutineContext = coroutineContext,
)

runner.execute(spec)

return emptyMap()
}

companion object {
private val Spec.describeName: String
get() = this::class.bestName().escapedJsTestName()
}
}


private class SingleInstanceSpecJsRunner(
private val specName: String,
private val defaultCoroutineDispatcherFactory: CoroutineDispatcherFactory,
private val context: EngineContext,
private val formatter: FallbackDisplayNameFormatter,
val coroutineContext: CoroutineContext,
) {

private val logger = Logger(SingleInstanceSpecJsRunner::class)
private val pipeline = SpecInterceptorPipeline(context)
private val materializer = Materializer(context.configuration)
private val testEngineListener: TestEngineListener = context.listener

override suspend fun execute(spec: Spec): Map<TestCase, TestResult> {
val cc = coroutineContext
// we use the spec itself as an outer/parent test.
describe(testNameEscape(spec::class.bestName())) {
materializer.materialize(spec).forEach { root ->

val testDisplayName = testNameEscape(formatter.format(root))

// todo find a way to delegate this to the test case executor
val enabled = root.isEnabledInternal(context.configuration)
if (enabled.isEnabled) {
// we have to always invoke `it` to start the test so that the js test framework doesn't exit
// before we invoke our callback. This also gives us the handle to the done callback.
jasmineTestIt(
description = testDisplayName,
testFunction = { done ->
// ideally we'd just launch the executor and have the listener set up the test,
// but we can't launch a promise inside the describe and have it resolve the "it"
// this means we must duplicate the isEnabled check outside the executor
runPromiseIgnoringErrors {
TestCaseExecutor(
PromiseTestCaseExecutionListener(done),
NoopCoroutineDispatcherFactory,
context
).execute(root, TerminalTestScope(root, cc))
}
// we don't want to return the promise as the js frameworks will use that for test resolution
// instead of the done callback, and we prefer the callback as it allows for custom timeouts
},
// some frameworks default to a 2000 timeout,
// here we set to a high number and use the timeout support kotest provides via coroutines
timeout = Int.MAX_VALUE
private val TestCase.enabled: Boolean get() = isEnabledInternal(conf = context.configuration).isEnabled
private fun TestCase.displayName(): String = formatter.format(this).escapedJsTestName()

suspend fun execute(spec: Spec): Result<Map<TestCase, TestResult>> {
logger.log { Pair(spec::class.bestName(), "executing spec $spec") }
try {
pipeline.execute(spec) { _ ->
val rootTests = materializer.materialize(spec)

logger.log { "discovered ${rootTests.size} rootTests: ${rootTests.joinToString { it.displayName() }}" }

rootTests.forEach { tc ->
runTest(tc, null)
}

Result.success(emptyMap())
}

return Result.success(emptyMap())
} catch (e: Exception) {
e.printStackTrace()
throw e
}
}

/**
* A [TestScope] that runs discovered tests as soon as they are registered in the same spec instance.
*
* This implementation tracks fail fast if configured via TestCase config or globally.
*/
private inner class SingleInstanceTestScope(
override val testCase: TestCase,
val parentScope: SingleInstanceTestScope?,
) : TestScope {
override val coroutineContext: CoroutineContext = this@SingleInstanceSpecJsRunner.coroutineContext

// in the single instance runner we execute each nested test as soon as they are registered
override suspend fun registerTestCase(nested: NestedTest) {
logger.log { Pair(testCase.name.testName, "Registering nested test '${nested}") }

val nestedTestCase = Materializer(context.configuration).materialize(nested, testCase)

runTest(
testCase = nestedTestCase,
parentScope = this@SingleInstanceTestScope,
)
}
}

private suspend fun runTest(
testCase: TestCase,
parentScope: SingleInstanceTestScope?,
) {
val scope = DuplicateNameHandlingTestScope(
mode = context.configuration.duplicateTestNameMode,
delegate = SingleInstanceTestScope(testCase, parentScope)
)

val parents = buildList {
val scopeNames = generateSequence(parentScope) { it.parentScope }
.map { it.testCase.displayName() }
addAll(scopeNames)
add(specName)
}.reversed()

// this approach seems more accurate, but it doesn't work :( It overrides any parent describes?
// fun scopes(names: ArrayDeque<String>, inner: () -> Unit) {
// val name = names.removeFirstOrNull() ?: return inner()
// describe(name) {
// scopes(names, inner)
// }
// }
// val parentsQueue = ArrayDeque(parents)
// parentsQueue.addFirst( specName.escapedJsTestName())
// scopes(parentsQueue) {

val describeName = parents.joinToString("⟶")

jasmineTestDescribe(describeName) {

if (testCase.enabled) {
jasmineTestIt(testCase.displayName()) { done: JsTestDoneCallback ->

val listener = JsTestCaseExecutionListenerAdapter(listener = testEngineListener, done = done)
val executor = TestCaseExecutor(
listener = listener,
defaultCoroutineDispatcherFactory = defaultCoroutineDispatcherFactory,
context = context,
)
} else {
jasmineTestXit(description = testDisplayName, testFunction = {})

globalScopeLaunch {
val result = executor.execute(testCase, scope)
listener.testFinished(testCase, result)
}
}
} else {
jasmineTestXit(testCase.displayName()) { done ->
val listener = JsTestCaseExecutionListenerAdapter(listener = testEngineListener, done = done)

globalScopeLaunch {
listener.testIgnored(testCase, testCase.isEnabledInternal(conf = context.configuration).reason)
}
}
}
}
return emptyMap()
}
}

private external fun describe(description: String, specDefinitions: () -> Unit)

// use GlobalScope, because Kotlin/JS doesn't have runBlocking {}
private fun globalScopeLaunch(
block: suspend CoroutineScope.() -> Unit
) {
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch { block() }
}


/**
* Converts events fired to a [TestCaseExecutionListener] into events fired to a [TestEngineListener]
*/
private class JsTestCaseExecutionListenerAdapter(
private val listener: TestEngineListener,
private val done: JsTestDoneCallback?,
) : TestCaseExecutionListener {

private val logger = Logger(TestCaseExecutionListenerToTestEngineListenerAdapter::class)

override suspend fun testFinished(testCase: TestCase, result: TestResult) {
logger.log { Pair(testCase.name.testName, "Adapting testFinished to engine event $result $testCase") }
listener.testFinished(testCase, result)
done?.invoke(result.errorOrNull)
}

override suspend fun testIgnored(testCase: TestCase, reason: String?) {
logger.log { Pair(testCase.name.testName, "Adapting testIgnored to engine event $reason $testCase") }
listener.testIgnored(testCase, reason)
}

override suspend fun testStarted(testCase: TestCase) {
logger.log { Pair(testCase.name.testName, "Adapting testStarted to engine event $testCase") }
listener.testStarted(testCase)
}
}
Expand Up @@ -8,3 +8,8 @@ package io.kotest.engine.test.interceptors
fun testNameEscape(name: String): String {
return name.replace('.', ' ')
}

/**
* @see testNameEscape
*/
internal fun String.escapedJsTestName(): String = testNameEscape(this)