diff --git a/.github/workflows/kotlin-test.yml b/.github/workflows/kotlin-test.yml new file mode 100644 index 0000000..374ae7d --- /dev/null +++ b/.github/workflows/kotlin-test.yml @@ -0,0 +1,29 @@ +name: Kotlin Tests + +on: [push, pull_request] + +jobs: + tests: + name: Kotlin Tests + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04] + rev: [nightly] + + steps: + - uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: 21 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run tests + run: | + make kotlin-test diff --git a/.github/workflows/test.yml b/.github/workflows/lua-test.yml similarity index 91% rename from .github/workflows/test.yml rename to .github/workflows/lua-test.yml index be18cd1..af17b61 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/lua-test.yml @@ -1,10 +1,10 @@ -name: Tests +name: Lua Tests on: [push, pull_request] jobs: tests: - name: tests + name: Lua Tests runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -32,4 +32,4 @@ jobs: - name: Run tests run: | nvim --version - make test + make lua-test diff --git a/.gitignore b/.gitignore index 9bd7719..d71b27f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .luarc.json .tests +kotlin-test-launcher/maven-plugin/target/ +.idea diff --git a/Makefile b/Makefile index d2fc0e6..2fa5047 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,16 @@ -.PHONY: test clean format check +.PHONY: lua-test kotlin-test test clean format check SOURCES := $(shell find lua tests -name *.lua) # timeout set to 3 mins (in milliseconds) to allow for gradle setup time -test: +lua-test: nvim --headless --noplugin -u tests/bootstrap_init.lua -c "PlenaryBustedDirectory tests/ { minimal_init = './tests/minimal_init.lua', timeout = 180000 }" +kotlin-test: + ./kotlin-test-launcher/gradlew -p kotlin-test-launcher test + +test: lua-test kotlin-test + clean: rm -rf .tests diff --git a/kotlin-test/.gitattributes b/kotlin-test/.gitattributes new file mode 100644 index 0000000..f91f646 --- /dev/null +++ b/kotlin-test/.gitattributes @@ -0,0 +1,12 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + +# Binary files should be left untouched +*.jar binary + diff --git a/kotlin-test/.gitignore b/kotlin-test/.gitignore new file mode 100644 index 0000000..5917190 --- /dev/null +++ b/kotlin-test/.gitignore @@ -0,0 +1,7 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build + +.kotlin diff --git a/kotlin-test/build.gradle.kts b/kotlin-test/build.gradle.kts new file mode 100644 index 0000000..5281cfe --- /dev/null +++ b/kotlin-test/build.gradle.kts @@ -0,0 +1,31 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.api.tasks.testing.logging.TestLogEvent + +plugins { + alias(libs.plugins.kotlin.jvm) +} + +allprojects { + group = "io.github.codymikol" + + repositories { + mavenCentral() + } +} + +subprojects { + apply(plugin = "org.jetbrains.kotlin.jvm") + + tasks.withType().configureEach { + useJUnitPlatform() + + // Only run tests that end with Spec + include("**/*Spec.class") + + testLogging { + showStandardStreams = true + events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED) + exceptionFormat = TestExceptionFormat.FULL + } + } +} diff --git a/kotlin-test/core/build.gradle.kts b/kotlin-test/core/build.gradle.kts new file mode 100644 index 0000000..43bd59b --- /dev/null +++ b/kotlin-test/core/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + `java-library` + `maven-publish` +} + +dependencies { + implementation(libs.kotest.framework.engine) + implementation(libs.coroutines) + implementation(libs.reflect) + + testImplementation(libs.bundles.kotest) + testImplementation(libs.bundles.jackson) +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +kotlin { + explicitApi() +} + +publishing { + publications { + create("kotlin-test-core") { + groupId = project.group.toString() + artifactId = "kotlin-test-core" + version = "1.0.0" + from(components["java"]) + } + } +} diff --git a/kotlin-test/core/src/main/kotlin/io/github/codymikol/kotlintestlauncher/TestFrameworkRunner.kt b/kotlin-test/core/src/main/kotlin/io/github/codymikol/kotlintestlauncher/TestFrameworkRunner.kt new file mode 100644 index 0000000..9cd5aa3 --- /dev/null +++ b/kotlin-test/core/src/main/kotlin/io/github/codymikol/kotlintestlauncher/TestFrameworkRunner.kt @@ -0,0 +1,54 @@ +package io.github.codymikol.kotlintestlauncher + +import io.github.codymikol.kotlintestlauncher.kotest.KotestTestRunner +import io.kotest.common.runBlocking +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.fold +import kotlinx.coroutines.flow.map +import kotlin.reflect.KClass + +/** + * Base interface for all test framework runners. + */ +public interface TestFrameworkRunner { + /** + * Runs the provided test [classes] using the [TestFrameworkRunner]. + */ + public suspend fun run(classes: Collection>): TestRunResult + + /** + * Whether this class is runnable by this [TestFrameworkRunner]. + */ + public fun isRunnable(kclass: KClass<*>): Boolean + + public companion object { + /** + * Executes all tests using all supported [TestFrameworkRunner]s + * generating a [RunReport] that contains all classes and their corresponding test + * statuses. + */ + public fun runAll(classes: Set>): RunReport = + runBlocking { + flowOf( + KotestTestRunner, + ).map { runner -> + val runnableClasses = classes.filter { runner.isRunnable(it) } + + when (val result = runner.run(runnableClasses)) { + is TestRunResult.Success -> result.report + is TestRunResult.Failure -> TODO() + } + }.fold(emptySet()) { acc, it -> acc + it } + } + } +} + +public typealias RunReport = Set + +public sealed interface TestRunResult { + public data class Success( + val report: RunReport, + ) : TestRunResult + + public object Failure : TestRunResult +} diff --git a/kotlin-test/core/src/main/kotlin/io/github/codymikol/kotlintestlauncher/TestNode.kt b/kotlin-test/core/src/main/kotlin/io/github/codymikol/kotlintestlauncher/TestNode.kt new file mode 100644 index 0000000..5e7b07a --- /dev/null +++ b/kotlin-test/core/src/main/kotlin/io/github/codymikol/kotlintestlauncher/TestNode.kt @@ -0,0 +1,180 @@ +package io.github.codymikol.kotlintestlauncher + +import io.kotest.core.test.TestResult +import kotlin.time.Duration + +/** + * A test node, the parent union type of [TestNode.Test] and a [TestNode.Container] + * where a [TestNode.Test] is a terminal and a [TestNode.Container] can contain multiple + * other containers or tests. + */ +public sealed interface TestNode { + public val name: String + + /** + * The total execution time of the [TestNode.Test] or all [TestNode.Container.nodes]. + */ + public val duration: Duration + public val type: TestNodeType + + public enum class TestNodeType { + CONTAINER, + TEST, + } + + /** + * Terminal node. + */ + public data class Test( + override val name: String, + override val duration: Duration, + public val status: TestStatus, + ) : TestNode { + override val type: TestNodeType = TestNodeType.TEST + } + + /** + * Can contain multiple other [Container]s or [Test]s inside of [nodes]. + * This can be the root class node, top level namespace, or a nested namespace. + */ + public data class Container( + override val name: String, + ) : TestNode { + private val nodes: MutableList = mutableListOf() + public val tests: List + get() = nodes.toList() + + override val type: TestNodeType = TestNodeType.CONTAINER + + override val duration: Duration + get() = this.nodes.fold(Duration.ZERO) { acc, it -> acc + it.duration } + + public val status: Status + get() { + val tests: List = this.visitAllNodes().filterIsInstance(TestNode.Test::class.java).toList() + return when { + tests.all { it.status == TestStatus.Success } -> Status.SUCCESS + tests.any { it.status is TestStatus.Failure } -> Status.FAILURE + else -> Status.IGNORED + } + } + + internal fun visitAllNodes(): Sequence { + val parent = this + + return sequence { + val queue = mutableListOf(parent) + while (queue.isNotEmpty()) { + val container = queue.removeFirst() + + container.nodes.forEach { child -> + yield(child) + + if (child is TestNode.Container) { + queue.add(child) + } + } + } + } + } + + /** + * Adds the [node] to the [Container] appending onto potentially nested [TestNode.Container]s + * specified by [parentNames]. An empty list for [parentNames] means that it's top level. + * + * @throws IllegalArgumentException [parentNames] references a non-existent node or a node that + * isn't a [TestNode.Container]. + */ + @Throws(IllegalArgumentException::class) + internal fun add( + node: TestNode, + parentNames: List = emptyList(), + ): Boolean { + val child = + parentNames.fold(this) { currentNode, parentName -> + val child = + requireNotNull(currentNode.nodes.find { it.name == parentName }) { + "${this.name} > ${parentNames.joinToString(separator = " > ")} does not exist" + } + + require(child is TestNode.Container) { + "${this.name} > ${parentNames.joinToString(separator = " > ")} is not a TestNode.Container" + } + + child + } + + require( + !child.nodes.any { it.name == node.name }, + ) { "${this.name} > ${parentNames.joinToString(separator = " > ")} > ${node.name} already exists" } + + return child.nodes.add(node) + } + } +} + +public enum class Status { + SUCCESS, + FAILURE, + IGNORED, +} + +public sealed interface TestStatus { + public val type: Status + + public object Success : TestStatus { + override val type: Status = Status.SUCCESS + } + + public companion object { + internal fun from(kotestResult: TestResult): TestStatus = + when (kotestResult) { + is TestResult.Success -> Success + is TestResult.Failure -> { + val error = kotestResult.errorOrNull + + Failure( + stackTrace = error?.stackTraceToString(), + error = + error?.run { + val traceOrigin = stackTrace?.firstOrNull() + + Failure.Error( + message = message, + lineNumber = traceOrigin?.lineNumber, + filename = traceOrigin?.fileName, + ) + }, + ) + } + is TestResult.Ignored -> Ignored(reason = kotestResult.reason) + is TestResult.Error -> TODO() + } + } + + public data class Failure( + /** + * The entire stacktrace as a String, this is useful for displaying what + * a thrown exception would yield in the console. + */ + public val stackTrace: String?, + /** + * Programmatic view of the original error. + */ + public val error: Error?, + ) : TestStatus { + override val type: Status = Status.FAILURE + + public data class Error( + public val message: String?, + public val lineNumber: Int?, + public val filename: String?, + ) + } + + public data class Ignored( + public val reason: String? = null, + ) : TestStatus { + override val type: Status = Status.IGNORED + } +} diff --git a/kotlin-test/core/src/main/kotlin/io/github/codymikol/kotlintestlauncher/kotest/KotestTestReporter.kt b/kotlin-test/core/src/main/kotlin/io/github/codymikol/kotlintestlauncher/kotest/KotestTestReporter.kt new file mode 100644 index 0000000..205e047 --- /dev/null +++ b/kotlin-test/core/src/main/kotlin/io/github/codymikol/kotlintestlauncher/kotest/KotestTestReporter.kt @@ -0,0 +1,147 @@ +package io.github.codymikol.kotlintestlauncher.kotest + +import io.github.codymikol.kotlintestlauncher.RunReport +import io.github.codymikol.kotlintestlauncher.TestNode +import io.github.codymikol.kotlintestlauncher.TestStatus +import io.kotest.common.KotestInternal +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.kotest.core.test.TestType +import io.kotest.engine.listener.AbstractTestEngineListener +import io.kotest.engine.listener.TestEngineListener +import kotlin.reflect.KClass +import kotlin.time.Duration + +/** + * Implements Kotest's [TestEngineListener] for the sole purpose of observing spec/test completion + * to create a [report]. + * + * This reporter is **not** thread safe. + */ +@OptIn(KotestInternal::class) +internal class KotestTestReporter : AbstractTestEngineListener() { + private val results: MutableSet = mutableSetOf() + + internal fun report(): RunReport = this.results.toSet() + + /** + * Invoked once per [Spec] to indicate that this spec will be instantiated + * and any active tests invoked. + */ + override suspend fun specStarted(kclass: KClass<*>) { + val name = checkNotNull(kclass.qualifiedName) + this.results.add(TestNode.Container(name = name)) + } + + /** + * Invoked when a spec is ignored. An optional [reason] for being ignored can be provided. + */ + override suspend fun specIgnored( + kclass: KClass<*>, + reason: String?, + ) { + val name = checkNotNull(kclass.qualifiedName) + this.results.add(TestNode.Container(name = name)) + } + + /** + * Is invoked once per [Spec] class to indicate this spec has completed. + */ + override suspend fun specFinished( + kclass: KClass<*>, + result: TestResult, + ) { + val name = checkNotNull(kclass.qualifiedName) + checkNotNull(this.results.firstOrNull { it.name == name }) { "specFinished event for class '$name' that hasn't been started." } + } + + /** + * Invoked if a [TestCase] is about to be executed. + * Will not be invoked if the test is ignored. + */ + override suspend fun testStarted(testCase: TestCase) { + val name = checkNotNull(testCase.spec.javaClass.kotlin.qualifiedName) + if (testCase.type != TestType.Container) { + return + } + + val current = + checkNotNull(this.results.find { it.name == name }) { + "testStarted event for class '$name' and test '${testCase.name.testName}' that hasn't been started." + } + + current.add( + node = TestNode.Container(name = testCase.name.testName), + parentNames = testCase.parentsToList(), + ) + } + + /** + * Invoked if a [TestCase] will be skipped. + */ + override suspend fun testIgnored( + testCase: TestCase, + reason: String?, + ) { + val name = checkNotNull(testCase.spec.javaClass.kotlin.qualifiedName) + val current = + checkNotNull(this.results.find { it.name == name }) { + "testIgnored event for class '$name' and test '${testCase.name.testName}' that hasn't been started." + } + + current.add( + node = + if (testCase.type == TestType.Container) { + TestNode.Container(name = testCase.name.testName) + } else { + TestNode.Test( + name = testCase.name.testName, + status = TestStatus.Ignored(reason = reason), + duration = Duration.ZERO, + ) + }, + parentNames = testCase.parentsToList(), + ) + } + + /** + * Invoked when all the invocations of a [TestCase] have completed. + * This function will only be invoked if a test case was enabled. + */ + override suspend fun testFinished( + testCase: TestCase, + result: TestResult, + ) { + val name = checkNotNull(testCase.spec.javaClass.kotlin.qualifiedName) + if (testCase.type == TestType.Container) { + return + } + + val current = + checkNotNull(this.results.find { it.name == name }) { + "testFinished event for class '$name' and test '${testCase.name.testName}' that hasn't been started." + } + + current.add( + node = + TestNode.Test( + name = testCase.name.testName, + status = TestStatus.from(result), + duration = result.duration, + ), + parentNames = testCase.parentsToList(), + ) + } +} + +internal fun TestCase.parentsToList(): List { + val testCase = this + + return buildList { + var parent = testCase.parent + while (parent != null) { + this.add(parent.name.testName) + parent = parent.parent + } + }.reversed() +} diff --git a/kotlin-test/core/src/main/kotlin/io/github/codymikol/kotlintestlauncher/kotest/KotestTestRunner.kt b/kotlin-test/core/src/main/kotlin/io/github/codymikol/kotlintestlauncher/kotest/KotestTestRunner.kt new file mode 100644 index 0000000..3081702 --- /dev/null +++ b/kotlin-test/core/src/main/kotlin/io/github/codymikol/kotlintestlauncher/kotest/KotestTestRunner.kt @@ -0,0 +1,37 @@ +package io.github.codymikol.kotlintestlauncher.kotest + +import io.github.codymikol.kotlintestlauncher.TestFrameworkRunner +import io.github.codymikol.kotlintestlauncher.TestRunResult +import io.kotest.common.KotestInternal +import io.kotest.core.spec.Spec +import io.kotest.engine.TestEngineLauncher +import io.kotest.engine.listener.CompositeTestEngineListener +import io.kotest.engine.listener.PinnedSpecTestEngineListener +import io.kotest.engine.listener.ThreadSafeTestEngineListener +import kotlin.reflect.KClass +import kotlin.reflect.full.isSubclassOf + +internal object KotestTestRunner : TestFrameworkRunner { + override fun isRunnable(kclass: KClass<*>): Boolean = kclass.isSubclassOf(Spec::class) && !kclass.isAbstract + + /** + * Heavily influenced by [Kotest launcher main.kt](https://github.com/kotest/kotest/blob/b98f125bd9f2efe592e9e69faa082f4ba11a8c22/kotest-framework/kotest-framework-engine/src/jvmMain/kotlin/io/kotest/engine/launcher/main.kt) + */ + @OptIn(KotestInternal::class) + override suspend fun run(classes: Collection>): TestRunResult { + val reporter = KotestTestReporter() + + @Suppress("UNCHECKED_CAST") // safe because [isRunnable] ensures that this is a KClass + val result = + TestEngineLauncher( + CompositeTestEngineListener( + listOf( + ThreadSafeTestEngineListener(PinnedSpecTestEngineListener(reporter)), + ), + ), + ).withClasses(classes.toList() as List>) + .async() + + return if (result.errors.isNotEmpty()) TestRunResult.Failure else TestRunResult.Success(report = reporter.report()) + } +} diff --git a/kotlin-test/core/src/test/kotlin/io/github/codymikol/kotlintestlauncher/TestNodeSpec.kt b/kotlin-test/core/src/test/kotlin/io/github/codymikol/kotlintestlauncher/TestNodeSpec.kt new file mode 100644 index 0000000..f75f4bb --- /dev/null +++ b/kotlin-test/core/src/test/kotlin/io/github/codymikol/kotlintestlauncher/TestNodeSpec.kt @@ -0,0 +1,202 @@ +package io.github.codymikol.kotlintestlauncher + +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrowExactly +import io.kotest.core.spec.style.FunSpec +import io.kotest.core.test.TestResult +import io.kotest.matchers.maps.shouldContainKey +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class TestNodeSpec : + FunSpec({ + context("TestStatus") { + context("from Kotest") { + test("Success") { + val actual = TestStatus.from(TestResult.Success(Duration.ZERO)) + actual shouldBe TestStatus.Success + } + + test("Failure") { + val assertionError = AssertionError() + val actual = TestStatus.from(TestResult.Failure(duration = Duration.ZERO, cause = assertionError)) + actual.shouldBeInstanceOf() + } + + test("Ignored") { + val actual = TestStatus.from(TestResult.Ignored(reason = "reason it's ignored")) + actual shouldBe TestStatus.Ignored("reason it's ignored") + } + } + } + + context("TestNode") { + context("TestNode.Container") { + context("status") { + test("all passed") { + val container = TestNode.Container(name = "example") + container.add(TestNode.Test(name = "pass", duration = 5.seconds, status = TestStatus.Success)) + container.add(TestNode.Test(name = "pass1", duration = 5.seconds, status = TestStatus.Success)) + container.add(TestNode.Test(name = "pass2", duration = 5.seconds, status = TestStatus.Success)) + container.add(TestNode.Test(name = "pass3", duration = 5.seconds, status = TestStatus.Success)) + + container.status shouldBe Status.SUCCESS + } + + test("single failure") { + val container = TestNode.Container(name = "example") + container.add(TestNode.Test(name = "pass", duration = 5.seconds, status = TestStatus.Success)) + container.add( + TestNode.Test( + name = "pass1", + duration = 5.seconds, + status = + TestStatus.Failure( + stackTrace = "", + error = TestStatus.Failure.Error(message = null, lineNumber = null, filename = null), + ), + ), + ) + container.add(TestNode.Test(name = "pass2", duration = 5.seconds, status = TestStatus.Success)) + container.add(TestNode.Test(name = "pass3", duration = 5.seconds, status = TestStatus.Success)) + + container.status shouldBe Status.FAILURE + } + + test("mixture") { + val container = TestNode.Container(name = "example") + container.add(TestNode.Test(name = "pass", duration = 5.seconds, status = TestStatus.Success)) + container.add( + TestNode.Test( + name = "failure", + duration = 5.seconds, + status = + + TestStatus.Failure( + stackTrace = "", + error = TestStatus.Failure.Error(message = null, lineNumber = null, filename = null), + ), + ), + ) + container.add( + TestNode.Test( + name = "failure1", + duration = 5.seconds, + status = + TestStatus.Failure( + stackTrace = "", + error = TestStatus.Failure.Error(message = null, lineNumber = null, filename = null), + ), + ), + ) + container.add(TestNode.Test(name = "pass1", duration = 5.seconds, status = TestStatus.Ignored())) + + container.status shouldBe Status.FAILURE + } + + test("all ignored") { + val container = TestNode.Container(name = "example") + container.add(TestNode.Test(name = "ignored", duration = 5.seconds, status = TestStatus.Ignored())) + container.add(TestNode.Test(name = "ignored1", duration = 5.seconds, status = TestStatus.Ignored())) + container.add(TestNode.Test(name = "ignored2", duration = 5.seconds, status = TestStatus.Ignored())) + container.add(TestNode.Test(name = "ignored3", duration = 5.seconds, status = TestStatus.Ignored())) + + container.status shouldBe Status.IGNORED + } + } + + context("add") { + test("success") { + val container = TestNode.Container(name = "example") + + shouldNotThrowAny { + container.add(TestNode.Container(name = "inner")) shouldBe true + container.add(TestNode.Container(name = "nested-inner"), listOf("inner")) shouldBe true + container.add(TestNode.Container(name = "pass"), listOf("inner", "nested-inner")) shouldBe true + } + + val nodes = container.visitAllNodes().associateBy { it.name } + nodes shouldContainKey "inner" + nodes shouldContainKey "nested-inner" + nodes shouldContainKey "pass" + } + + test("add to test that already exists") { + val container = TestNode.Container(name = "example") + container.add(TestNode.Container(name = "inner")) shouldBe true + container.add(TestNode.Container(name = "nested-inner"), listOf("inner")) shouldBe true + container.add(TestNode.Container(name = "pass"), listOf("inner", "nested-inner")) shouldBe true + + val exception = + shouldThrowExactly { + container.add(TestNode.Container(name = "pass"), listOf("inner", "nested-inner")) shouldBe true + } + + exception.message shouldBe "example > inner > nested-inner > pass already exists" + } + + test("add to unknown sub container") { + val container = TestNode.Container(name = "example") + + val exception = + shouldThrowExactly { + container.add(node = TestNode.Container(name = "inner"), listOf("unknown")) + } + + exception.message shouldBe "example > unknown does not exist" + } + + test("add to non-container") { + val container = TestNode.Container(name = "example") + container.add(TestNode.Test(name = "inner", duration = 5.seconds, status = TestStatus.Success)) + + val exception = + shouldThrowExactly { + container.add(node = TestNode.Container(name = "nested-inner"), listOf("inner")) + } + + exception.message shouldBe "example > inner is not a TestNode.Container" + } + } + } + + context("type") { + test("container") { + val container = TestNode.Container(name = "example") + } + + test("test") { + val container = TestNode.Test(name = "example", duration = 5.seconds, status = TestStatus.Success) + } + } + + context("duration") { + test("single test") { + val test = TestNode.Test(name = "pass", duration = 5.seconds, status = TestStatus.Success) + test.duration shouldBe 5.seconds + } + + test("empty container") { + val container = TestNode.Container(name = "example") + container.duration shouldBe Duration.ZERO + } + + test("container with single test") { + val container = TestNode.Container(name = "example") + container.add(TestNode.Test(name = "pass", duration = 5.seconds, status = TestStatus.Success)) + container.duration shouldBe 5.seconds + } + + test("container with nested containers with tests") { + val container = TestNode.Container(name = "example") + container.add(TestNode.Test(name = "pass", duration = 5.seconds, status = TestStatus.Success)) + container.add(TestNode.Container("inner")) + container.add(TestNode.Test(name = "pass", duration = 10.seconds, status = TestStatus.Success), listOf("inner")) + + container.duration shouldBe 15.seconds + } + } + } + }) diff --git a/kotlin-test/core/src/test/kotlin/io/github/codymikol/kotlintestlauncher/kotest/KotestExample.kt b/kotlin-test/core/src/test/kotlin/io/github/codymikol/kotlintestlauncher/kotest/KotestExample.kt new file mode 100644 index 0000000..0dbd853 --- /dev/null +++ b/kotlin-test/core/src/test/kotlin/io/github/codymikol/kotlintestlauncher/kotest/KotestExample.kt @@ -0,0 +1,69 @@ +package io.github.codymikol.kotlintestlauncher.kotest + +import io.kotest.assertions.assertSoftly +import io.kotest.core.spec.style.FunSpec +import io.kotest.data.row +import io.kotest.datatest.withData +import io.kotest.matchers.ints.shouldBeEven +import io.kotest.matchers.ints.shouldBeOdd +import io.kotest.matchers.shouldBe + +class KotestExample : + FunSpec({ + test("pass") { + 1.shouldBeOdd() + } + + test("fail") { + 1.shouldBeEven() + } + + context("top level") { + test("pass") { + 1.shouldBeOdd() + } + + test("fail") { + 1.shouldBeEven() + } + + withData( + mapOf( + "1 == 1" to row(1, 1), + "1 == 2" to row(1, 2), + "1 == 3" to row(1, 3), + "1 == 4" to row(1, 4), + ), + ) { (input, expected) -> + input shouldBe expected + } + + test("assert softly") { + assertSoftly { + 1.shouldBeEven() + 1 shouldBe 2 + 1 shouldBe 3 + } + } + + context("nested") { + test("pass") { + 1.shouldBeOdd() + } + + test("fail") { + 1.shouldBeEven() + } + } + + xtest("ignored test") { + 1.shouldBeOdd() + } + + xcontext("ignored context") { + xtest("ignored") { + 1.shouldBeOdd() + } + } + } + }) diff --git a/kotlin-test/core/src/test/kotlin/io/github/codymikol/kotlintestlauncher/kotest/KotestTestRunnerFunctionalSpec.kt b/kotlin-test/core/src/test/kotlin/io/github/codymikol/kotlintestlauncher/kotest/KotestTestRunnerFunctionalSpec.kt new file mode 100644 index 0000000..7b90693 --- /dev/null +++ b/kotlin-test/core/src/test/kotlin/io/github/codymikol/kotlintestlauncher/kotest/KotestTestRunnerFunctionalSpec.kt @@ -0,0 +1,170 @@ +package io.github.codymikol.kotlintestlauncher.kotest + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import io.github.codymikol.kotlintestlauncher.TestRunResult +import io.kotest.assertions.json.shouldContainJsonKey +import io.kotest.assertions.json.shouldEqualSpecifiedJson +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.types.shouldBeInstanceOf + +class KotestTestRunnerFunctionalSpec : + FunSpec({ + context("functional") { + test("run") { + val result = KotestTestRunner.run(listOf(KotestExample::class)) + val actual = result.shouldBeInstanceOf() + val actualJson = ObjectMapper().registerKotlinModule().writeValueAsString(actual.report) + + actualJson.shouldContainJsonKey("$[0].tests[0].duration") + actualJson.shouldContainJsonKey("$[0].tests[1].status.stackTrace") + + actualJson shouldEqualSpecifiedJson + """ + [ + { + "name": "io.github.codymikol.kotlintestlauncher.kotest.KotestExample", + "tests": [ + { + "type": "TEST", + "name": "pass", + "status": { + "type": "SUCCESS" + } + }, + { + "type": "TEST", + "name": "fail", + "status": { + "type": "FAILURE", + "error": { + "message": "1 should be even", + "lineNumber": 18, + "filename": "KotestExample.kt" + } + } + }, + { + "type": "CONTAINER", + "name": "top level", + "tests": [ + { + "type": "TEST", + "name": "pass", + "status": { + "type": "SUCCESS" + } + }, + { + "type": "TEST", + "name": "fail", + "status": { + "type": "FAILURE", + "error": { + "message": "1 should be even", + "lineNumber": 27, + "filename": "KotestExample.kt" + } + } + }, + { + "type": "TEST", + "name": "1 == 1", + "status": { + "type": "SUCCESS" + } + }, + { + "type": "TEST", + "name": "1 == 2", + "status": { + "type": "FAILURE", + "error": { + "message": "expected:<2> but was:<1>", + "lineNumber": 38, + "filename": "KotestExample.kt" + } + } + }, + { + "type": "TEST", + "name": "1 == 3", + "status": { + "type": "FAILURE", + "error": { + "message": "expected:<3> but was:<1>", + "lineNumber": 38, + "filename": "KotestExample.kt" + } + } + }, + { + "type": "TEST", + "name": "1 == 4", + "status": { + "type": "FAILURE", + "error": { + "message": "expected:<4> but was:<1>", + "lineNumber": 38, + "filename": "KotestExample.kt" + } + } + }, + { + "type": "TEST", + "name": "assert softly", + "status": { + "type": "FAILURE", + "error": { + "message": "The following 3 assertions failed:\n1) 1 should be even\n at io.github.codymikol.kotlintestlauncher.kotest.KotestExample$1$3$4.invokeSuspend(KotestExample.kt:43)\n2) expected:<2> but was:<1>\n at io.github.codymikol.kotlintestlauncher.kotest.KotestExample$1$3$4.invokeSuspend(KotestExample.kt:44)\n3) expected:<3> but was:<1>\n at io.github.codymikol.kotlintestlauncher.kotest.KotestExample$1$3$4.invokeSuspend(KotestExample.kt:45)\n", + "lineNumber": 95, + "filename": "KotestExample.kt" + } + } + }, + { + "type": "CONTAINER", + "name": "nested", + "tests": [ + { + "type": "TEST", + "name": "pass", + "status": { + "type": "SUCCESS" + } + }, + { + "type": "TEST", + "name": "fail", + "status": { + "type": "FAILURE", + "error": { + "message": "1 should be even", + "lineNumber": 55, + "filename": "KotestExample.kt" + } + } + } + ] + }, + { + "type": "TEST", + "name": "ignored test", + "status": { + "type": "IGNORED", + "reason": "Disabled by xmethod" + } + }, + { + "type": "CONTAINER", + "name": "ignored context" + } + ] + } + ] + } + ] + """.trimIndent() + } + } + }) diff --git a/kotlin-test/core/src/test/kotlin/io/github/codymikol/kotlintestlauncher/kotest/KotestTestRunnerSpec.kt b/kotlin-test/core/src/test/kotlin/io/github/codymikol/kotlintestlauncher/kotest/KotestTestRunnerSpec.kt new file mode 100644 index 0000000..130b9b6 --- /dev/null +++ b/kotlin-test/core/src/test/kotlin/io/github/codymikol/kotlintestlauncher/kotest/KotestTestRunnerSpec.kt @@ -0,0 +1,36 @@ +package io.github.codymikol.kotlintestlauncher.kotest + +import io.kotest.core.extensions.Extension +import io.kotest.core.spec.RootTest +import io.kotest.core.spec.Spec +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +open class Subclass : Spec() { + override fun globalExtensions(): List = emptyList() + + override fun rootTests(): List = emptyList() +} + +class SubclassSubclass : Subclass() + +class KotestTestRunnerSpec : + FunSpec({ + context("isRunnable") { + test("FunSpec subclass") { + KotestTestRunner.isRunnable(KotestTestRunnerSpec::class) shouldBe true + } + + test("Spec subclass") { + KotestTestRunner.isRunnable(Subclass::class) shouldBe true + } + + test("subclass of Spec subclass") { + KotestTestRunner.isRunnable(SubclassSubclass::class) shouldBe true + } + + test("fail") { + KotestTestRunner.isRunnable(String::class) shouldBe false + } + } + }) diff --git a/kotlin-test/gradle-plugin/build.gradle.kts b/kotlin-test/gradle-plugin/build.gradle.kts new file mode 100644 index 0000000..2f624de --- /dev/null +++ b/kotlin-test/gradle-plugin/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + `kotlin-dsl` + `java-gradle-plugin` + `maven-publish` +} + +dependencies { + implementation(project(":core")) + implementation(libs.bundles.jackson) + implementation(libs.asm) + compileOnly(libs.kotlin.gradle.plugin) + + testImplementation(libs.bundles.kotest) +} + +gradlePlugin { + plugins { + create("kotlinTest") { + id = "io.github.codymikol.kotlintest" + implementationClass = "io.github.codymikol.kotlintest.plugin.KotlinTestPlugin" + } + } +} + +publishing { + publications { + create("kotlin-test") { + from(components["java"]) + + groupId = project.group.toString() + artifactId = "kotlin-test" + version = "1.0.0" + } + } + + repositories { + mavenLocal() + } +} diff --git a/kotlin-test/gradle-plugin/src/main/kotlin/io/github/codymikol/kotlintest/plugin/KotlinTestPlugin.kt b/kotlin-test/gradle-plugin/src/main/kotlin/io/github/codymikol/kotlintest/plugin/KotlinTestPlugin.kt new file mode 100644 index 0000000..9d4b84f --- /dev/null +++ b/kotlin-test/gradle-plugin/src/main/kotlin/io/github/codymikol/kotlintest/plugin/KotlinTestPlugin.kt @@ -0,0 +1,33 @@ +package io.github.codymikol.kotlintest.plugin + +import io.github.codymikol.kotlintest.plugin.task.KotlinTestTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.register +import java.io.File +import java.util.UUID + +@Suppress("unused") // entry point for Gradle plugin, always used. +class KotlinTestPlugin : Plugin { + override fun apply(project: Project) { + // We require compileTestKotlin to load tests and run + if (project.tasks.findByName("compileTestKotlin") == null) { + return + } + + project.tasks.register("kotlinTest") { + group = "verification" + description = "Run tests across Kotlin frameworks" + + // Never up to date + outputs.upToDateWhen { false } + + this.dependsOn("compileTestKotlin") + + // Dependencies + classes.set(project.properties["classes"]?.toString()) + outputFile.convention(project.layout.buildDirectory.file("$name/output-${UUID.randomUUID()}.json")) + outputFile.set(project.properties["outputFile"]?.toString()?.let { File(it) }) + } + } +} diff --git a/kotlin-test/gradle-plugin/src/main/kotlin/io/github/codymikol/kotlintest/plugin/task/KotlinTestTask.kt b/kotlin-test/gradle-plugin/src/main/kotlin/io/github/codymikol/kotlintest/plugin/task/KotlinTestTask.kt new file mode 100644 index 0000000..7b2b594 --- /dev/null +++ b/kotlin-test/gradle-plugin/src/main/kotlin/io/github/codymikol/kotlintest/plugin/task/KotlinTestTask.kt @@ -0,0 +1,82 @@ +package io.github.codymikol.kotlintest.plugin.task + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import io.github.codymikol.kotlintestlauncher.TestFrameworkRunner +import org.gradle.api.DefaultTask +import org.gradle.api.file.FileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import org.objectweb.asm.ClassReader +import java.net.URLClassLoader +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.name +import kotlin.io.path.readBytes +import kotlin.reflect.KClass +import kotlin.streams.asSequence + +abstract class KotlinTestTask : DefaultTask() { + @get:Input + abstract val classes: Property + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + /** + * Whether the [Path] is a Java Class and not a nested class. + */ + internal fun Path.isClass(): Boolean = this.name.endsWith(".class") && "$" !in this.name + + /** + * Parses the Java Class located at [Path] getting its fully qualified class name. + */ + internal fun Path.toQualifiedClassName(): String = + ClassReader(this.readBytes()) + .className + .replace("/", ".") + + /** + * Loads all classes in the [FileCollection] that match the [requestedClasses]. + */ + internal fun FileCollection.loadClasses( + classLoader: URLClassLoader, + requestedClasses: List, + ): Set> = + this + .filter { it.exists() } + .flatMap { file -> + Files.walk(file.toPath()).asSequence().filter { path -> path.isClass() } + }.map { classPath -> classPath.toQualifiedClassName() } + .filter { fqcn -> + requestedClasses.any { className -> fqcn == className || fqcn.startsWith(className) } + }.mapNotNull { fqcn -> + try { + classLoader.loadClass(fqcn).kotlin + } catch (_: ClassNotFoundException) { + null + } + }.toSet() + + @TaskAction + fun run() { + val java = project.extensions.getByType(JavaPluginExtension::class.java) + val outputFile = this@KotlinTestTask.outputFile.asFile.get() + val testSourceSet = java.sourceSets.getByName("test").runtimeClasspath + val classLoader = URLClassLoader(testSourceSet.map { it.toURI().toURL() }.toTypedArray(), this.javaClass.classLoader) + + val classes = + testSourceSet + .loadClasses(classLoader = classLoader, requestedClasses = classes.get().split(",")) + + val report = TestFrameworkRunner.runAll(classes = classes) + val mapper = ObjectMapper().registerKotlinModule() + + mapper.writeValue(outputFile, report) + } +} \ No newline at end of file diff --git a/kotlin-test/gradle.properties b/kotlin-test/gradle.properties new file mode 100644 index 0000000..1385fa5 --- /dev/null +++ b/kotlin-test/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.configuration-cache=true + diff --git a/kotlin-test/gradle/libs.versions.toml b/kotlin-test/gradle/libs.versions.toml new file mode 100644 index 0000000..0e25eb0 --- /dev/null +++ b/kotlin-test/gradle/libs.versions.toml @@ -0,0 +1,30 @@ +[versions] +kotest = "5.9.1" +kotlinx-coroutines = "1.10.2" +jackson = "2.19.2" +asm = "9.8" +kotlin = "2.1.20" + +[libraries] +# kotlinx +coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } +jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } +jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } + +# kotest +kotest-framework-engine = { module = "io.kotest:kotest-framework-engine-jvm", version.ref = "kotest" } +kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } +kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } +kotest-json = { module = "io.kotest:kotest-assertions-json", version.ref = "kotest" } +kotest-datatest = { module = "io.kotest:kotest-framework-datatest", version.ref = "kotest" } + +asm = { module = "org.ow2.asm:asm", version.ref = "asm" } +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } + +[bundles] +kotest = ["kotest-runner-junit5", "kotest-assertions", "kotest-json", "kotest-datatest"] +jackson = [ "jackson-databind", "jackson-kotlin" ] diff --git a/kotlin-test/gradle/wrapper/gradle-wrapper.jar b/kotlin-test/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/kotlin-test/gradle/wrapper/gradle-wrapper.jar differ diff --git a/kotlin-test/gradle/wrapper/gradle-wrapper.properties b/kotlin-test/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/kotlin-test/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/kotlin-test/gradlew b/kotlin-test/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/kotlin-test/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/kotlin-test/gradlew.bat b/kotlin-test/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/kotlin-test/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/kotlin-test/maven-plugin/pom.xml b/kotlin-test/maven-plugin/pom.xml new file mode 100644 index 0000000..c026571 --- /dev/null +++ b/kotlin-test/maven-plugin/pom.xml @@ -0,0 +1,85 @@ + + 4.0.0 + io.github.codymikol.kotlintestlauncher + neotest-kotest-maven-plugin + jar + 1.0-SNAPSHOT + neotest-kotest-maven-plugin + http://maven.apache.org + + + + + org.jetbrains.kotlin + kotlin-stdlib + 1.9.10 + + + + io.kotest + kotest-runner-junit5 + 5.6.2 + test + + + + org.apache.maven + maven-plugin-api + 3.9.4 + + + + org.apache.maven + maven-core + 3.9.4 + + + + org.jetbrains.kotlin + kotlin-stdlib + 1.9.10 + + + + org.apache.maven.plugin-tools + maven-plugin-annotations + 3.11.0 + provided + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + 1.9.10 + + + compile + compile + + compile + + + + test-compile + test-compile + + test-compile + + + + + + + src/main/kotlin + + + + + + + + diff --git a/kotlin-test/maven-plugin/src/main/kotlin/io/github/codymikol/kotlintestlauncher/KotlinTestLauncher.kt b/kotlin-test/maven-plugin/src/main/kotlin/io/github/codymikol/kotlintestlauncher/KotlinTestLauncher.kt new file mode 100644 index 0000000..edf32df --- /dev/null +++ b/kotlin-test/maven-plugin/src/main/kotlin/io/github/codymikol/kotlintestlauncher/KotlinTestLauncher.kt @@ -0,0 +1,15 @@ +import org.apache.maven.plugin.AbstractMojo +import org.apache.maven.plugin.MojoExecutionException +import org.apache.maven.plugins.annotations.Mojo +import org.apache.maven.plugins.annotations.LifecyclePhase + +@Mojo(name = "test", defaultPhase = LifecyclePhase.VERIFY) +class KotlinTestLauncherPlugin : AbstractMojo() { + + @Throws(MojoExecutionException::class) + override fun execute() { + log.info("Dicover and run tests here...") + } + +} + diff --git a/kotlin-test/settings.gradle.kts b/kotlin-test/settings.gradle.kts new file mode 100644 index 0000000..6da73a4 --- /dev/null +++ b/kotlin-test/settings.gradle.kts @@ -0,0 +1,6 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" +} + +rootProject.name = "kotlin-test" +include("core", "gradle-plugin") diff --git a/lua/neotest-kotlin/command.lua b/lua/neotest-kotlin/command.lua index 16c5337..7590cea 100644 --- a/lua/neotest-kotlin/command.lua +++ b/lua/neotest-kotlin/command.lua @@ -1,11 +1,10 @@ local M = {} ---Constructs the gradle command to execute ----@param tests string the name of the test block ---@param specs string the package name of the file you are interpreting ---@param outfile string where the test output will be written to. ---@return string command the gradle command to execute -function M.build(tests, specs, outfile) +function M.build(specs, outfile) local INIT_SCRIPT_NAME = "test-logging.init.gradle.kts" local init_script_path = @@ -17,10 +16,9 @@ function M.build(tests, specs, outfile) end return string.format( - "kotest_filter_specs='%s' kotest_filter_tests='%s' ./gradlew -I %s test --console=plain | tee -a %s", - specs, - tests, + "./gradlew -I %s kotlinTest -Pclasses=%s -PoutputFile=%s", init_script_path, + specs, outfile ) end diff --git a/lua/neotest-kotlin/init.lua b/lua/neotest-kotlin/init.lua index 363dc9d..c7d1a57 100644 --- a/lua/neotest-kotlin/init.lua +++ b/lua/neotest-kotlin/init.lua @@ -2,7 +2,7 @@ local async = require("neotest.async") local command = require("neotest-kotlin.command") local filter = require("neotest-kotlin.filter") local lib = require("neotest.lib") -local output_parser = require("neotest-kotlin.output_parser") +local output = require("neotest-kotlin.output") local treesitter = require("neotest-kotlin.treesitter") local M = {} @@ -87,9 +87,8 @@ function M.Adapter.build_spec(args) end ---@type string - local results_path = async.fn.tempname() .. ".txt" + local results_path = async.fn.tempname() .. ".json" local pos = tree:data() - local tests = "*" ---@type neotest.RunSpec local run_spec = { @@ -101,8 +100,8 @@ function M.Adapter.build_spec(args) } if pos.type == "dir" then - local package = dir_determine_package(pos.path) .. ".*" - run_spec.command = command.build(tests, package, results_path) + local package = dir_determine_package(pos.path) or "" + run_spec.command = command.build(package, results_path) elseif pos.type == "file" or pos.type == "namespace" @@ -114,7 +113,7 @@ function M.Adapter.build_spec(args) treesitter.list_all_classes(pos.path)[1] ) - run_spec.command = command.build(tests, package, results_path) + run_spec.command = command.build(package, results_path) end print(run_spec.command) @@ -141,9 +140,9 @@ function M.Adapter.results(spec, result, tree) local result_path = spec.context.results_path local path = spec.context.path - ---@type string[] - local lines = lib.files.read_lines(result_path) - return output_parser.parse_lines(lines, path) + ---@type string + local json_content = lib.files.read(result_path) + return output.json_to_results(path, json_content) end return M.Adapter diff --git a/lua/neotest-kotlin/output/init.lua b/lua/neotest-kotlin/output/init.lua new file mode 100644 index 0000000..b4d2fe3 --- /dev/null +++ b/lua/neotest-kotlin/output/init.lua @@ -0,0 +1,74 @@ +local TestNode = require("neotest-kotlin.output.test_node") +local neotest = require("neotest.lib") +local treesitter = require("neotest-kotlin.treesitter") + +local M = {} + +---Determines all fully qualified classes in the provided file +---@param file string +---@return table +local function determine_all_classes_file(file) + if neotest.files.is_dir(file) then + error( + string.format( + "determine_all_classes_file only operates on files, not directories '%s'", + file + ) + ) + end + + ---@type table + local results = {} + local package = treesitter.java_package(file) + local classes = treesitter.list_all_classes(file) + + for _, class in ipairs(classes) do + results[package .. "." .. class] = file + end + + return results +end + +---Determines all fully qualified classes in the provided path +---@param path string +---@return table +function M.determine_all_classes(path) + local results = {} + + if neotest.files.is_dir(path) then + local files = neotest.files.find(path) + + for _, file in ipairs(files) do + results = + vim.tbl_extend("keep", results, determine_all_classes_file(file)) + end + else + results = determine_all_classes_file(path) + end + + return results +end + +---Converts JSON of TestNodes to neotest.Results +---@param path string +---@param json_content string +---@return table +function M.json_to_results(path, json_content) + ---@type any[] + local test_nodes = vim.json.decode(json_content) + + local results = {} + local class_to_path = M.determine_all_classes(path) + + for _, node in ipairs(test_nodes) do + local class_node = TestNode.from(node) + local class_path = class_to_path[class_node.name] + + results = + vim.tbl_extend("force", results, class_node:to_results(class_path)) + end + + return results +end + +return M diff --git a/lua/neotest-kotlin/output/test_node.lua b/lua/neotest-kotlin/output/test_node.lua new file mode 100644 index 0000000..5e1f807 --- /dev/null +++ b/lua/neotest-kotlin/output/test_node.lua @@ -0,0 +1,128 @@ +local async = require("neotest.async") + +---@class TestNodeStatusError +---@field message string +---@field lineNumber integer +---@field filename string + +---@class TestNodeStatus +---@field type string +---@field stackTrace? string only applies to status FAILURE +---@field error? TestNodeStatusError only applies to status FAILURE +---@field reason? string only applies to status IGNORED + +---@class TestNode +---@field name string +---@field type string +---@field status TestNodeStatus +---@field tests? TestNode[] only applied to type CONTAINER + +local TestNode = {} +TestNode.__index = TestNode + +---Creates a new Container TestNode +---@param name string +---@param status TestNodeStatus +---@param tests TestNode[] +---@return TestNode +function TestNode.newContainer(name, status, tests) + ---@type TestNode + local self = setmetatable({}, TestNode) + self.name = name + self.status = status + self.tests = tests + self.type = "CONTAINER" + + return self +end + +---Creates a new Container TestNode +---@param name string +---@param status TestNodeStatus +---@return TestNode +function TestNode.newTest(name, status) + ---@type TestNode + local self = setmetatable({}, TestNode) + self.name = name + self.status = status + self.type = "TEST" + + return self +end + +---Creates a TestNode from a table +---@param tbl any +---@return TestNode +function TestNode.from(tbl) + return setmetatable(tbl, TestNode) +end + +---Converts a TestNode.status to a neotest.ResultStatus +---@return neotest.ResultStatus +function TestNode:to_status() + if self.status.type == "SUCCESS" then + return "passed" + elseif self.status.type == "FAILURE" then + return "failed" + else + return "skipped" + end +end + +---Converts a TestNode to a neotest.Result +---@param id string +---@return string, neotest.Result +function TestNode:to_result(id) + ---@type neotest.Result + local result = { + status = self:to_status(), + } + + if self.status.type == "FAILURE" then + local error = self.status.error + assert(error ~= nil, "TestNodeStatus is FAILURE, but has no errors") + + result.short = error.message + result.errors = { + { message = error.message, line = error.lineNumber - 1 }, + } + + local output_path = async.fn.tempname() + + async.fn.writefile( + vim.fn.split(self.status.stackTrace, "\n", false), + output_path + ) + result.output = output_path + end + + return id .. "::" .. self.name, result +end + +---Converts a TestNode and all TestNodes contained into neotest.Results +---using Depth First Search. +---@param id string +---@return table +function TestNode:to_results(id) + ---@type table + local results = {} + + for _, test in ipairs(self.tests or {}) do + local testNode = TestNode.from(test) + + if testNode.type == "CONTAINER" then + results = vim.tbl_extend( + "force", + results, + testNode:to_results(id .. "::" .. testNode.name) + ) + else + local test_id, result = testNode:to_result(id) + results[test_id] = result + end + end + + return results +end + +return TestNode diff --git a/lua/neotest-kotlin/output_parser.lua b/lua/neotest-kotlin/output_parser.lua deleted file mode 100644 index e34af5f..0000000 --- a/lua/neotest-kotlin/output_parser.lua +++ /dev/null @@ -1,287 +0,0 @@ -local async = require("neotest.async") -local neotest = require("neotest.lib") -local treesitter = require("neotest-kotlin.treesitter") - -local M = {} - ----@enum neotest.ResultStatus -local ResultStatus = { - passed = "passed", - failed = "failed", - skipped = "skipped", - -- None is not part of neotest - none = "none", -} - ----Gets the result of a Gradle test output line ----@param line string ----@return neotest.ResultStatus status passed, skipped, failed, none -function M.parse_status(line) - ---@type neotest.ResultStatus - local result = "none" - - if vim.endswith(line, "PASSED") then - result = "passed" - elseif vim.endswith(line, "SKIPPED") then - result = "skipped" - elseif vim.endswith(line, "FAILED") then - result = "failed" - end - - return result -end - --- org.example.KotestDescribeSpec > a namespace > should handle failed assertions FAILED --- '/home/nick/GitHub/neotest-kotlin/lua/tests/example_project/app/src/test/kotlin/org/example/KotestDescribeExample.kt::"a namespace"::"a nested namespace"::"should handle failed assertions"' ----Parses the Neotest id from a Gradle test line output ----@param line string ----@param class_to_path table fully qualified class name to path ----@return string? neotest_id -function M.parse_test_id(line, class_to_path) - local split = vim.split(line, ">", { trimempty = true }) - -- Must have at least "fully qualified test name > test" - if #split < 2 then - return nil - end - - local fully_qualified_class = vim.trim(split[1]) - if class_to_path[fully_qualified_class] == nil then - return nil - end - - local names = { unpack(split, 2) } - local result = class_to_path[fully_qualified_class] - - for i, segment in ipairs(names) do - segment = vim.trim(segment) - if (i + 1) == #split then - segment = segment:match("(.+) [PASSED|FAILED|SKIPPED]") - end - - -- Deeply nested tests potentially have segments prefixed by the - -- fully qualified class name. - -- - -- example: org.example.KotestDescribeExample.this is the test name - if vim.startswith(segment, fully_qualified_class .. ".") then - segment = segment:sub(#fully_qualified_class + 2) - end - - result = result .. "::" .. segment - end - - return result -end - ----@param line string ----@param class_to_path table fully qualified class name to path ----@return string? -function find_class_by_line(line, class_to_path) - return vim.tbl_filter(function(value) - return vim.startswith(line, value) - end, vim.tbl_keys(class_to_path))[1] -end - ----Whether the line is a valid gradle test line ----@param line string ----@param class_to_path table fully qualified class name to path ----@return boolean -function M.is_valid_gradle_test_line(line, class_to_path) - if M.parse_status(line) == "none" then - return false - end - - return find_class_by_line(line, class_to_path) ~= nil -end - ----@class TestResult ----@field id string neotest id ----@field status string passed, skipped, failed, none - ----Determines all fully qualified classes in the provided file ----@param file string ----@return table -local function determine_all_classes_file(file) - if neotest.files.is_dir(file) then - error( - string.format( - "determine_all_classes_file only operates on files, not directories '%s'", - file - ) - ) - end - - ---@type table - local results = {} - local package = treesitter.java_package(file) - local classes = treesitter.list_all_classes(file) - - for _, class in ipairs(classes) do - results[package .. "." .. class] = file - end - - return results -end - ----Determines all fully qualified classes in the provided path ----@param path string ----@return table -function M.determine_all_classes(path) - local results = {} - - if neotest.files.is_dir(path) then - local files = neotest.files.find(path) - - for _, file in ipairs(files) do - results = - vim.tbl_extend("keep", results, determine_all_classes_file(file)) - end - else - results = determine_all_classes_file(path) - end - - return results -end - ----Parses test output for Kotest ----@param output string[] all lines of output associated with this test failure ----@param class_to_path table fully qualified class name to path ----@return string?, neotest.Error[] -function parse_kotest_assertion_error(output, class_to_path) - local errors = {} - local short = nil - - -- Output isn't long enough to have errors - if #output < 3 then - return short, errors - end - - local fully_qualified_class = find_class_by_line(output[1], class_to_path) - if fully_qualified_class == nil then - return short, errors - end - - local file_name = vim.fs.basename(class_to_path[fully_qualified_class]) - - -- Match a Kotest soft assertion - -- - -- Example: - -- ```text - -- org.example.KotestFunSpec > namespace > fail FAILED - -- io.kotest.assertions.MultiAssertionError: The following 3 assertions failed: - -- 1) expected:<"b"> but was:<"a"> - -- at org.example.KotestFunSpec$1$1$2.invokeSuspend(KotestFunSpec.kt:15) - -- 2) expected:<"c"> but was:<"b"> - -- at org.example.KotestFunSpec$1$1$2.invokeSuspend(KotestFunSpec.kt:16) - -- 3) expected:<"d"> but was:<"c"> - -- at org.example.KotestFunSpec$1$1$2.invokeSuspend(KotestFunSpec.kt:17) - -- ``` - -- Output: { - -- { message = 'expected:<"b"> but was:<"a">', line = 15 } - -- { message = 'expected:<"c"> but was:<"b">', line = 16 } - -- { message = 'expected:<"d"> but was:<"c">', line = 17 } - -- } - if output[2]:find("MultiAssertionError") then - local assertion_count = - output[2]:match("MultiAssertionError: The following (%d+)") - local count = tonumber(assertion_count) - if count == nil then - return short, errors - end - - local total_test_assertion_lines = (count * 2) + 2 - if #output < total_test_assertion_lines then - return short, errors - end - - for i = 3, total_test_assertion_lines, 2 do - local message = output[i]:match("%) (.*)") - local line_number = output[i + 1]:match(file_name .. ":(%d+)") - table.insert( - errors, - { message = vim.trim(message), line = tonumber(line_number) - 1 } - ) - end - else - -- Match a Kotest standard assertion - -- - -- Example: - -- ```text - --org.example.KotestDescribeSpec > a namespace > should handle failed assertions FAILED - -- io.kotest.assertions.AssertionFailedError: expected:<"b"> but was:<"a"> - -- at app//org.example.KotestDescribeSpec$1$1$4$1.invokeSuspend(KotestDescribeSpec.kt:22) - --``` - -- Output: { message = "expected:<"b"> but was:<"a">", line = 22 } - local message = output[2]:match(": (.*)") - local line_number = output[3]:match(file_name .. ":(%d+)") - - if message == nil or line_number == nil then - return short, errors - end - - table.insert( - errors, - { message = vim.trim(message), line = tonumber(line_number) - 1 } - ) - end - - short = table.concat( - vim.tbl_map(function(value) - return value.message - end, errors), - "\n" - ) - - return short, errors -end - ----Converts lines of gradle output to test results ----@param lines string[] ----@param path string ----@return table -function M.parse_lines(lines, path) - ---@type neotest.Result[] - local results = {} - local classes = M.determine_all_classes(path) - - for i, line in ipairs(lines) do - if not M.is_valid_gradle_test_line(line, classes) then - goto continue - end - - local id = M.parse_test_id(line, classes) - if not id then - goto continue - end - - ---@type string[] - local output = { line } - - for j = i + 1, #lines do - if - vim.trim(lines[j]) == "" - or M.is_valid_gradle_test_line(lines[j], classes) - then - break - end - - table.insert(output, lines[j]) - end - - local output_path = async.fn.tempname() - async.fn.writefile(output, output_path) - - local short, errors = parse_kotest_assertion_error(output, classes) - results[id] = { - short = short or line, - status = M.parse_status(line), - output = output_path, - errors = errors, - } - - ::continue:: - end - - return results -end - -return M diff --git a/test-logging.init.gradle.kts b/test-logging.init.gradle.kts index aa2e842..f954914 100644 --- a/test-logging.init.gradle.kts +++ b/test-logging.init.gradle.kts @@ -5,22 +5,21 @@ * so that the plugin can effectively parse the output and it's usable * for users. */ -import org.gradle.api.tasks.testing.logging.TestExceptionFormat -import org.gradle.api.tasks.testing.logging.TestLogEvent +import io.github.codymikol.kotlintest.plugin.KotlinTestPlugin + +initscript { + repositories { + mavenLocal() + mavenCentral() + } + + dependencies { + classpath("io.github.codymikol:kotlin-test:1.0.0") + } +} allprojects { afterEvaluate { - tasks.withType().configureEach { - /** - * Force re-run the tests so we have output to parse - * [docs](https://blog.gradle.org/stop-rerunning-tests) - */ - outputs.upToDateWhen { false } - - testLogging { - events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED) - exceptionFormat = TestExceptionFormat.FULL - } - } + apply() } } diff --git a/tests/command_spec.lua b/tests/command_spec.lua index 8e6f0e9..e35831e 100644 --- a/tests/command_spec.lua +++ b/tests/command_spec.lua @@ -2,18 +2,15 @@ local command = require("neotest-kotlin.command") describe("command", function() it("valid", function() - local actual = command.build( - "An example namespace", - "com.codymikol.gummibear.pizza.FooClass", - "/tmp/results_example.txt" - ) + local actual = + command.build("An example namespace", "/tmp/results_example.json") local init_script_path = vim.api.nvim_get_runtime_file("test-logging.init.gradle.kts", false)[1] assert.equals( string.format( - "kotest_filter_specs='com.codymikol.gummibear.pizza.FooClass' kotest_filter_tests='An example namespace' ./gradlew -I %s test --console=plain | tee -a /tmp/results_example.txt", + "./gradlew -I %s :kotlinTest -Pclasses=An example namespace -PoutputFile=/tmp/results_example.json", init_script_path ), actual diff --git a/tests/output/output_spec.lua b/tests/output/output_spec.lua new file mode 100644 index 0000000..2276d05 --- /dev/null +++ b/tests/output/output_spec.lua @@ -0,0 +1,41 @@ +local nio = require("nio") +local output = require("neotest-kotlin.output") + +describe("output", function() + local example_project_path = vim.fs.joinpath( + debug.getinfo(1).source:match("@?(.*/)"), + "..", + "example_project", + "app", + "src", + "test", + "kotlin", + "org", + "example" + ) + + describe("determine_all_classes", function() + nio.tests.it("directory", function() + local actual = output.determine_all_classes(example_project_path) + + assert.not_nil(actual["org.example.KotestDescribeSpec"]) + assert.is_true( + vim.startswith( + actual["org.example.KotestDescribeSpec"], + example_project_path + ) + ) + end) + + nio.tests.it("file", function() + local test_path = + vim.fs.joinpath(example_project_path, "KotestDescribeSpec.kt") + local actual = output.determine_all_classes(test_path) + + assert.not_nil(actual["org.example.KotestDescribeSpec"]) + assert.is_true( + vim.startswith(actual["org.example.KotestDescribeSpec"], test_path) + ) + end) + end) +end) diff --git a/tests/output/test_node_spec.lua b/tests/output/test_node_spec.lua new file mode 100644 index 0000000..da7c647 --- /dev/null +++ b/tests/output/test_node_spec.lua @@ -0,0 +1,351 @@ +local TestNode = require("neotest-kotlin.output.test_node") + +---Equivalent to assert.are.same, but only matches fields specified in expected. +---@param expected table +---@param actual table +---@param path? string +function assert.equals_specified(expected, actual, path) + path = path or "" + for k, v in pairs(expected) do + local key_path = path .. "." .. tostring(k) + if type(v) == "table" then + assert.is_table(actual[k], "Expected table at: " .. key_path) + assert.equals_specified(v, actual[k], key_path) + else + assert.are.equal(v, actual[k], "Mismatch at: " .. key_path) + end + end +end + +describe("TestNode", function() + describe("decode", function() + it("container", function() + local input = [[ + { + "name": "namespace", + "type": "CONTAINER", + "status": { + "type": "FAILURE" + }, + "tests": [ + { + "name": "passed", + "type": "TEST", + "status": { + "type": "SUCCESS" + } + }, + { + "name": "skipped", + "type": "TEST", + "status": { + "type": "IGNORED", + "reason": "reason for being ignored" + } + }, + { + "name": "failure", + "type": "TEST", + "status": { + "type": "FAILURE", + "stackTrace": "example", + "error": { + "filename": "/example/path/to/file.kt", + "lineNumber": 5, + "message": "example" + } + } + } + ] + } + ]] + + local node = vim.json.decode(input) + local test_node = TestNode.from(node) + + assert.equals(3, #test_node.tests) + assert.equals_specified({ + name = "namespace", + type = "CONTAINER", + status = { type = "FAILURE" }, + }, test_node) + end) + + it("passed", function() + local input = [[ + { + "name": "passed", + "type": "TEST", + "status": { + "type": "SUCCESS" + } + } + ]] + + local node = vim.json.decode(input) + local test_node = TestNode.from(node) + + assert.is_nil(test_node.tests) + assert.equals_specified({ + name = "passed", + type = "TEST", + tests = nil, + status = { type = "SUCCESS" }, + }, test_node) + end) + + it("skipped", function() + local input = [[ + { + "name": "skipped", + "type": "TEST", + "status": { + "type": "IGNORED", + "reason": "reason for being ignored" + } + } + ]] + + local node = vim.json.decode(input) + local test_node = TestNode.from(node) + + assert.is_nil(test_node.tests) + assert.equals_specified({ + name = "skipped", + type = "TEST", + tests = nil, + status = { type = "IGNORED", reason = "reason for being ignored" }, + }, test_node) + end) + + it("failure", function() + local input = [[ + { + "name": "failure", + "type": "TEST", + "status": { + "type": "FAILURE", + "stackTrace": "example", + "error": { + "filename": "/example/path/to/file.kt", + "lineNumber": 5, + "message": "example" + } + } + } + ]] + + local node = vim.json.decode(input) + local test_node = TestNode.from(node) + + assert.equals_specified({ + name = "failure", + type = "TEST", + tests = nil, + status = { + type = "FAILURE", + stackTrace = "example", + error = { + filename = "/example/path/to/file.kt", + lineNumber = 5, + message = "example", + }, + }, + }, test_node) + end) + end) + + describe("to_status", function() + it("passed", function() + local node = TestNode.newTest("name", { type = "SUCCESS" }) + assert.equals("passed", node:to_status()) + end) + + it("failed", function() + local node = TestNode.newTest("name", { type = "FAILURE" }) + assert.equals("failed", node:to_status()) + end) + + it("skipped", function() + local node = TestNode.newTest("name", { type = "IGNORED" }) + assert.equals("skipped", node:to_status()) + end) + end) + + describe("to_results", function() + local path = "/example/path/to/file.kt" + + it("single top-level test", function() + local node = TestNode.newContainer( + "org.example.File", + { type = "SUCCESS" }, + { TestNode.newTest("passed", { type = "SUCCESS" }) } + ) + + local results = node:to_results(path) + assert.equals_specified({ + ["/example/path/to/file.kt::passed"] = { status = "passed" }, + }, results) + end) + + it("multiple top-level test", function() + local node = TestNode.newContainer( + "org.example.File", + { type = "FAILURE" }, + { + TestNode.newTest("passed", { type = "SUCCESS" }), + TestNode.newTest("skipped", { type = "IGNORED" }), + TestNode.newTest("failed", { + type = "FAILURE", + stackTrace = "example", + error = { lineNumber = 5, filename = path, message = "example" }, + }), + } + ) + + local results = node:to_results(path) + assert.equals_specified({ + ["/example/path/to/file.kt::passed"] = { status = "passed" }, + ["/example/path/to/file.kt::skipped"] = { status = "skipped" }, + ["/example/path/to/file.kt::failed"] = { + status = "failed", + short = "example", + errors = { { line = 4, message = "example" } }, + }, + }, results) + end) + + it("multiple nested tests", function() + local node = TestNode.newContainer( + "org.example.File", + { type = "FAILURE" }, + { + TestNode.newContainer("namespace", { type = "FAILURE" }, { + TestNode.newTest("passed", { type = "SUCCESS" }), + TestNode.newTest("skipped", { type = "IGNORED" }), + TestNode.newTest("failed", { + type = "FAILURE", + stackTrace = "example", + error = { lineNumber = 5, filename = path, message = "example" }, + }), + }), + } + ) + + local results = node:to_results(path) + assert.equals_specified({ + ["/example/path/to/file.kt::namespace::passed"] = { status = "passed" }, + ["/example/path/to/file.kt::namespace::skipped"] = { + status = "skipped", + }, + ["/example/path/to/file.kt::namespace::failed"] = { + status = "failed", + short = "example", + errors = { { line = 4, message = "example" } }, + }, + }, results) + end) + + it("deeply nested test", function() + local node = TestNode.newContainer( + "org.example.File", + { type = "SUCCESS" }, + { + TestNode.newContainer("namespace", { type = "SUCCESS" }, { + TestNode.newContainer("nested namespace", { type = "SUCCESS" }, { + TestNode.newContainer( + "nested nested namespace", + { type = "SUCCESS" }, + { + TestNode.newTest("passed", { type = "SUCCESS" }), + } + ), + }), + }), + } + ) + + local results = node:to_results(path) + assert.are.same({ + ["/example/path/to/file.kt::namespace::nested namespace::nested nested namespace::passed"] = { + status = "passed", + }, + }, results) + end) + + it("single container", function() + local node = TestNode.newContainer( + "org.example.File", + { type = "SUCCESS" }, + {} + ) + + local results = node:to_results(path) + assert.are.same({}, results) + end) + + it("nested containers", function() + local node = TestNode.newContainer( + "org.example.File", + { type = "SUCCESS" }, + { + TestNode.newContainer("namespace", { type = "SUCCESS" }, {}), + } + ) + + local results = node:to_results(path) + assert.are.same({}, results) + end) + end) + + describe("to_result", function() + it("passed", function() + local node = TestNode.newTest("pass", { type = "SUCCESS" }) + local id, result = node:to_result("/example/path/to/file.kt") + + assert.equals("/example/path/to/file.kt::pass", id) + assert.are.same({ status = "passed" }, result) + end) + + it("passed - nested", function() + local node = TestNode.newTest("pass", { type = "SUCCESS" }) + local id, result = + node:to_result("/example/path/to/file.kt::namespace::nested namespace") + + assert.equals( + "/example/path/to/file.kt::namespace::nested namespace::pass", + id + ) + assert.are.same({ status = "passed" }, result) + end) + + it("skipped", function() + local node = TestNode.newTest("skipped", { type = "IGNORED" }) + local id, result = node:to_result("/example/path/to/file.kt") + + assert.equals("/example/path/to/file.kt::skipped", id) + assert.are.same({ status = "skipped" }, result) + end) + + it("failed", function() + local node = TestNode.newTest("failed", { + type = "FAILURE", + stackTrace = "example\nstacktrace\nhere", + error = { + filename = "/example/path/to/file.kt}", + lineNumber = 5, + message = "example", + }, + }) + + local id, result = node:to_result("/example/path/to/file.kt") + + assert.equals("/example/path/to/file.kt::failed", id) + + assert.not_nil(result.output) + assert.equals("failed", result.status) + assert.equals("example", result.short) + assert.are.same({ { line = 4, message = "example" } }, result.errors) + end) + end) +end) diff --git a/tests/output_parser_functional_spec.lua b/tests/output_parser_functional_spec.lua deleted file mode 100644 index 58e181b..0000000 --- a/tests/output_parser_functional_spec.lua +++ /dev/null @@ -1,243 +0,0 @@ -local async = require("neotest.async") -local neotest_kotlin = require("neotest-kotlin") -local nio = require("nio") - -describe("output_parser functional", function() - local example_project_path = - vim.fs.joinpath(debug.getinfo(1).source:match("@?(.*/)"), "example_project") - local init_script_path = - vim.api.nvim_get_runtime_file("test-logging.init.gradle.kts", false)[1] - assert.is_not_nil(init_script_path) - init_script_path = vim.fs.abspath(init_script_path) - - local tests_path = vim.fs.joinpath( - example_project_path, - "app", - "src", - "test", - "kotlin", - "org", - "example" - ) - - local funspec_file = vim.fs.joinpath(tests_path, "KotestFunSpec.kt") - local shouldspec_file = vim.fs.joinpath(tests_path, "KotestShouldSpec.kt") - local describespec_file = vim.fs.joinpath(tests_path, "KotestDescribeSpec.kt") - local stringspec_file = vim.fs.joinpath(tests_path, "KotestStringSpec.kt") - local expectspec_file = vim.fs.joinpath(tests_path, "KotestExpectSpec.kt") - local freespec_file = vim.fs.joinpath(tests_path, "KotestFreeSpec.kt") - local featurespec_file = vim.fs.joinpath(tests_path, "KotestFeatureSpec.kt") - local annotationspec_file = - vim.fs.joinpath(tests_path, "KotestAnnotationSpec.kt") - local wordspec_file = vim.fs.joinpath(tests_path, "KotestWordSpec.kt") - - nio.tests.it("functional test", function() - ---@type string - local results_path = async.fn.tempname() .. ".txt" - - local cmd = string.format( - "kotest_filter_specs='%s' kotest_filter_tests='%s' %s/gradlew -p %s -I %s test --console=plain | tee -a %s", - "org.example.*", - "*", - example_project_path, - example_project_path, - init_script_path, - results_path - ) - - local success, _, _ = os.execute(cmd) - assert.is_true(success == 0) - - local spec = { - context = { - results_path = results_path, - path = tests_path, - }, - } - - local results = neotest_kotlin.results(spec, {}, {}) - local ids = vim.tbl_keys(results) - assert.equals(37, #ids) - - -- KotestWordSpec.kt - assert.equals(6, #vim.tbl_filter(function(value) - return vim.startswith(value, wordspec_file) - end, ids)) - - assert.equals( - "passed", - results[wordspec_file .. "::When namespace when::nested When namespace should::pass"].status - ) - assert.equals( - "failed", - results[wordspec_file .. "::When namespace when::nested When namespace should::fail"].status - ) - assert.equals( - "passed", - results[wordspec_file .. "::`when` namespace when::nested `when` namespace should::pass"].status - ) - assert.equals( - "failed", - results[wordspec_file .. "::`when` namespace when::nested `when` namespace should::fail"].status - ) - assert.equals( - "passed", - results[wordspec_file .. "::namespace should::pass"].status - ) - assert.equals( - "failed", - results[wordspec_file .. "::namespace should::fail"].status - ) - - -- KotestDescribeSpec.kt - assert.equals(6, #vim.tbl_filter(function(value) - return vim.startswith(value, describespec_file) - end, ids)) - - assert.equals( - "passed", - results[describespec_file .. "::a namespace::should handle passed assertions"].status - ) - assert.equals( - "failed", - results[describespec_file .. "::a namespace::should handle failed assertions"].status - ) - assert.equals( - "skipped", - results[describespec_file .. "::a namespace::should handle skipped assertions"].status - ) - assert.equals( - "passed", - results[describespec_file .. "::a namespace::a nested namespace::should handle passed assertions"].status - ) - assert.equals( - "failed", - results[describespec_file .. "::a namespace::a nested namespace::should handle failed assertions"].status - ) - assert.equals( - "skipped", - results[describespec_file .. "::a namespace::a nested namespace::should handle skipped assertions"].status - ) - - -- KotestFunSpec.kt - assert.equals(4, #vim.tbl_filter(function(value) - return vim.startswith(value, funspec_file) - end, ids)) - - assert.equals("passed", results[funspec_file .. "::namespace::pass"].status) - assert.equals("failed", results[funspec_file .. "::namespace::fail"].status) - assert.equals( - "passed", - results[funspec_file .. "::namespace::nested namespace::pass"].status - ) - assert.equals( - "failed", - results[funspec_file .. "::namespace::nested namespace::fail"].status - ) - - -- KotestFreeSpec.kt - assert.equals(4, #vim.tbl_filter(function(value) - return vim.startswith(value, freespec_file) - end, ids)) - - assert.equals( - "passed", - results[freespec_file .. "::namespace::pass"].status - ) - assert.equals( - "failed", - results[freespec_file .. "::namespace::fail"].status - ) - assert.equals( - "passed", - results[freespec_file .. "::namespace::nested namespace::pass"].status - ) - assert.equals( - "failed", - results[freespec_file .. "::namespace::nested namespace::fail"].status - ) - - -- KotestFeatureSpec.kt - assert.equals(4, #vim.tbl_filter(function(value) - return vim.startswith(value, featurespec_file) - end, ids)) - - assert.equals( - "passed", - results[featurespec_file .. "::namespace::pass"].status - ) - assert.equals( - "failed", - results[featurespec_file .. "::namespace::fail"].status - ) - assert.equals( - "passed", - results[featurespec_file .. "::namespace::nested namespace::pass"].status - ) - assert.equals( - "failed", - results[featurespec_file .. "::namespace::nested namespace::fail"].status - ) - - -- KotestExpectSpec.kt - assert.equals(4, #vim.tbl_filter(function(value) - return vim.startswith(value, expectspec_file) - end, ids)) - - assert.equals( - "passed", - results[expectspec_file .. "::namespace::pass"].status - ) - assert.equals( - "failed", - results[expectspec_file .. "::namespace::fail"].status - ) - assert.equals( - "passed", - results[expectspec_file .. "::namespace::nested namespace::pass"].status - ) - assert.equals( - "failed", - results[expectspec_file .. "::namespace::nested namespace::fail"].status - ) - - -- KotestAnnotationSpec.kt - assert.equals(3, #vim.tbl_filter(function(value) - return vim.startswith(value, annotationspec_file) - end, ids)) - - assert.equals("passed", results[annotationspec_file .. "::pass"].status) - assert.equals("failed", results[annotationspec_file .. "::fail"].status) - assert.equals("skipped", results[annotationspec_file .. "::ignore"].status) - - -- KotestStringSpec.kt - assert.equals(2, #vim.tbl_filter(function(value) - return vim.startswith(value, stringspec_file) - end, ids)) - - assert.equals("passed", results[stringspec_file .. "::pass"].status) - assert.equals("failed", results[stringspec_file .. "::fail"].status) - - -- KotestShouldSpec.kt - assert.equals(4, #vim.tbl_filter(function(value) - return vim.startswith(value, shouldspec_file) - end, ids)) - - assert.equals( - "passed", - results[shouldspec_file .. "::namespace::pass"].status - ) - assert.equals( - "failed", - results[shouldspec_file .. "::namespace::fail"].status - ) - assert.equals( - "passed", - results[shouldspec_file .. "::namespace::nested namespace::pass"].status - ) - assert.equals( - "failed", - results[shouldspec_file .. "::namespace::nested namespace::fail"].status - ) - end) -end) diff --git a/tests/output_parser_spec.lua b/tests/output_parser_spec.lua deleted file mode 100644 index 5d134c6..0000000 --- a/tests/output_parser_spec.lua +++ /dev/null @@ -1,290 +0,0 @@ -local nio = require("nio") -local output_parser = require("neotest-kotlin.output_parser") - -describe("output_parser", function() - local example_project_path = vim.fs.joinpath( - debug.getinfo(1).source:match("@?(.*/)"), - "example_project", - "app", - "src", - "test", - "kotlin", - "org", - "example" - ) - - local classes = { - ["org.example.KotestDescribeSpec"] = "/home/user/project/org/example/KotestDescribeSpec.kt", - ["org.example.inner.inner2.inner3.KotestDescribeSpec"] = "/home/user/project/org/example/inner/inner2/inner3/KotestDescribeSpec.kt", - ["org.example.inner.KotestDescribeSpec"] = "/home/user/project/org/example/inner/KotestDescribeSpec.kt", - } - - describe("determine_all_classes", function() - nio.tests.it("directory", function() - local actual = output_parser.determine_all_classes(example_project_path) - - assert.not_nil(actual["org.example.KotestDescribeSpec"]) - assert.is_true( - vim.startswith( - actual["org.example.KotestDescribeSpec"], - example_project_path - ) - ) - end) - - nio.tests.it("file", function() - local test_path = - vim.fs.joinpath(example_project_path, "KotestDescribeSpec.kt") - local actual = output_parser.determine_all_classes(test_path) - - assert.not_nil(actual["org.example.KotestDescribeSpec"]) - assert.is_true( - vim.startswith(actual["org.example.KotestDescribeSpec"], test_path) - ) - end) - end) - - describe("parse_lines", function() - nio.tests.it("complete example", function() - local actual = output_parser.parse_lines({ - "> Task :app:cleanTest", - "> Task :app:checkKotlinGradlePluginConfigurationErrors", - "> Task :app:compileKotlin UP-TO-DATE", - "> Task :app:compileJava NO-SOURCE", - "> Task :app:processResources NO-SOURCE", - "> Task :app:classes UP-TO-DATE", - "> Task :app:compileTestKotlin UP-TO-DATE", - "> Task :app:compileTestJava NO-SOURCE", - "> Task :app:processTestResources NO-SOURCE", - "> Task :app:testClasses UP-TO-DATE", - "> Task :app:test", - "org.example.KotestDescribeSpec > namespace > fail FAILED", - "io.kotest.assertions.MultiAssertionError: The following 3 assertions failed:", - '1) expected:<"b"> but was:<"a">', - "at org.example.KotestDescribeSpec$1$1$2.invokeSuspend(KotestDescribeSpec.kt:15)", - '2) expected:<"c"> but was:<"b">', - "at org.example.KotestDescribeSpec$1$1$2.invokeSuspend(KotestDescribeSpec.kt:16)", - '3) expected:<"d"> but was:<"c">', - "at org.example.KotestFunSpec$1$1$2.invokeSuspend(KotestDescribeSpec.kt:17)", - "org.example.KotestDescribeSpec > a namespace > should handle failed assertions FAILED", - ' io.kotest.assertions.AssertionFailedError: expected:<"b"> but was:<"a">', - " at app//org.example.KotestDescribeSpec$1$1$1.invokeSuspend(KotestDescribeSpec.kt:9)", - " at app//org.example.KotestDescribeSpec$1$1$1.invoke(KotestDescribeSpec.kt)", - "org.example.KotestDescribeSpec > a namespace > should handle passed assertions PASSED", - "", - "org.example.KotestDescribeSpec > a namespace > should handle skipped assertions SKIPPED", - "", - "org.example.KotestDescribeSpec > a namespace > a nested namespace > org.example.KotestDescribeSpec.should handle failed assertions FAILED", - ' io.kotest.assertions.AssertionFailedError: expected:<"b"> but was:<"a">', - " at app//org.example.KotestDescribeSpec$1$1$4$1.invokeSuspend(KotestDescribeSpec.kt:22)", - " at app//org.example.KotestDescribeSpec$1$1$4$1.invoke(KotestDescribeSpec.kt)", - " at app//org.example.KotestDescribeSpec$1$1$4$1.invoke(KotestDescribeSpec.kt)", - " at app//io.kotest.core.spec.style.scopes.DescribeSpecContainerScope$it$3.invokeSuspend(DescribeSpecContainerScope.kt:112)", - "org.example.KotestDescribeSpec > a namespace > a nested namespace > org.example.KotestDescribeSpec.should handle passed assertions PASSED", - "org.example.KotestDescribeSpec > a namespace > a nested namespace > org.example.KotestDescribeSpec.should handle skipped assertions SKIPPED", - "6 tests completed, 2 failed, 2 skipped", - "> Task :app:test FAILED", - "FAILURE: Build failed with an exception.", - "* What went wrong:", - "Execution failed for task ':app:test'.", - "> There were failing tests. See the report at: file:///home/nick/GitHub/neotest-kotlin/lua/tests/example_project/app/build/reports/tests/test/index.html", - "* Try:", - "> Run with --scan to get full insights.", - "BUILD FAILED in 4s", - "5 actionable tasks: 3 executed, 2 up-to-date", - }, example_project_path) - - assert.equals(7, #vim.tbl_keys(actual)) - - local test_path = - vim.fs.joinpath(example_project_path, "KotestDescribeSpec.kt") - - local soft_assert_test = actual[test_path .. "::namespace::fail"] - assert.is_not_nil(soft_assert_test) - assert.equals("failed", soft_assert_test.status) - - assert.equals( - 'expected:<"b"> but was:<"a">\nexpected:<"c"> but was:<"b">\nexpected:<"d"> but was:<"c">', - soft_assert_test.short - ) - local errors = soft_assert_test.errors - assert.is_not_nil(errors) - assert.equals(14, errors[1].line) - assert.equals('expected:<"b"> but was:<"a">', errors[1].message) - assert.equals(15, errors[2].line) - assert.equals('expected:<"c"> but was:<"b">', errors[2].message) - assert.equals(16, errors[3].line) - assert.equals('expected:<"d"> but was:<"c">', errors[3].message) - - local test1 = - actual[test_path .. "::a namespace::should handle failed assertions"] - assert.is_not_nil(test1) - assert.equals("failed", test1.status) - errors = test1.errors - assert.is_not_nil(errors) - assert.equals(8, errors[1].line) - assert.equals('expected:<"b"> but was:<"a">', errors[1].message) - assert.equals('expected:<"b"> but was:<"a">', test1.short) - - local test2 = - actual[test_path .. "::a namespace::should handle passed assertions"] - assert.is_not_nil(test2) - assert.equals("passed", test2.status) - assert.is_true(vim.tbl_isempty(test2.errors)) - assert.equals( - "org.example.KotestDescribeSpec > a namespace > should handle passed assertions PASSED", - test2.short - ) - - assert.equals(2, #vim.tbl_keys(vim.tbl_filter(function(value) - return value.status == "passed" - end, actual))) - - assert.equals(3, #vim.tbl_keys(vim.tbl_filter(function(value) - return value.status == "failed" - end, actual))) - - assert.equals(2, #vim.tbl_keys(vim.tbl_filter(function(value) - return value.status == "skipped" - end, actual))) - end) - end) - - describe("is_valid_gradle_test_line", function() - it("valid", function() - local actual = output_parser.is_valid_gradle_test_line( - "org.example.KotestDescribeSpec > should handle failed assertions FAILED", - classes - ) - - assert.is_true(actual) - end) - - it("unknown package prefix", function() - local actual = output_parser.is_valid_gradle_test_line( - "org.example.Unknown > should handle failed assertions FAILED", - classes - ) - - assert.is_false(actual) - end) - - it("no status", function() - local actual = output_parser.is_valid_gradle_test_line( - "org.example.KotestDescribeSpec > should handle failed assertions unknown", - classes - ) - - assert.is_false(actual) - end) - end) - - describe("parse_status", function() - it("passed", function() - local actual = output_parser.parse_status("PASSED") - assert.equal("passed", actual) - end) - - it("failed", function() - local actual = output_parser.parse_status("FAILED") - assert.equal("failed", actual) - end) - - it("skipped", function() - local actual = output_parser.parse_status("SKIPPED") - assert.equal("skipped", actual) - end) - - it("none", function() - local actual = output_parser.parse_status("random input") - assert.equal("none", actual) - end) - end) - - describe("parse_test_id", function() - it("invalid test line - gradle task", function() - local actual = output_parser.parse_test_id("> Task :app:test", classes) - - assert.is_nil(actual) - end) - - it("invalid test line - assertion error", function() - local actual = output_parser.parse_test_id( - [[io.kotest.assertions.AssertionFailedError: expected:<"b"> but was:<"a">]], - classes - ) - - assert.is_nil(actual) - end) - - it("invalid test line - stacktrace", function() - local actual = output_parser.parse_test_id( - [[at app//io.kotest.engine.test.TestInvocationInterceptor$runBeforeTestAfter$executeWithBeforeAfter$1.invokeSuspend(TestInvocatio]], - classes - ) - - assert.is_nil(actual) - end) - - it("invalid test line - failure", function() - local actual = output_parser.parse_test_id( - [[FAILURE: Build failed with an exception.]], - classes - ) - - assert.is_nil(actual) - end) - - it("invalid test line - build actions", function() - local actual = output_parser.parse_test_id( - [[5 actionable tasks: 3 executed, 2 up-to-date]], - classes - ) - - assert.is_nil(actual) - end) - - it("invalid test line - no test only fqn", function() - local actual = - output_parser.parse_test_id("org.example.KotestDescribeSpec", classes) - - assert.is_nil(actual) - end) - - it("valid top-level test", function() - local actual = output_parser.parse_test_id( - "org.example.KotestDescribeSpec > should handle failed assertions FAILED", - classes - ) - - assert.equal( - "/home/user/project/org/example/KotestDescribeSpec.kt::should handle failed assertions", - actual - ) - end) - - it("valid single namespace", function() - local actual = output_parser.parse_test_id( - "org.example.KotestDescribeSpec > a namespace > should handle failed assertions FAILED", - classes - ) - - assert.equal( - "/home/user/project/org/example/KotestDescribeSpec.kt::a namespace::should handle failed assertions", - actual - ) - end) - - it("valid multiple nested namespace", function() - local actual = output_parser.parse_test_id( - "org.example.KotestDescribeSpec > a namespace > a nested namespace > org.example.KotestDescribeSpec.should handle failed assertions FAILED", - classes - ) - - assert.equal( - "/home/user/project/org/example/KotestDescribeSpec.kt::a namespace::a nested namespace::should handle failed assertions", - actual - ) - end) - end) -end)