Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
ce999dd
feat: gradle init neotest-kotlin
NickHackman Jul 17, 2025
9871597
build(deps): remove guava
NickHackman Jul 17, 2025
7d9998f
rebase onto setup
NickHackman Jul 17, 2025
467f8bd
feat: basic Kotest runner
NickHackman Jul 18, 2025
e70f086
build: show events and stdout
NickHackman Jul 22, 2025
8f0b6c2
build: remove JUnit
NickHackman Jul 22, 2025
d7fad59
build: add kotlin reflect
NickHackman Jul 22, 2025
61815c9
build: add all Kotest dependencies
NickHackman Jul 22, 2025
83fada2
refactor: TestNode represents a Container and Test
NickHackman Jul 22, 2025
d4bf763
feat: Kotest test running
NickHackman Jul 22, 2025
466db73
test: TestNodeSpec
NickHackman Jul 22, 2025
75e30d8
test: KotestTestRunner functional test
NickHackman Jul 22, 2025
d3d289b
test: KotestTestRunner unit tests
NickHackman Jul 22, 2025
45988d0
test: assert duration and stackTrace exist on tests
NickHackman Jul 22, 2025
790ed60
refactor: use AbstractTestEngineListener
NickHackman Jul 23, 2025
424fc5a
test: don't run Example specs
NickHackman Jul 24, 2025
05b298d
refactor: neotest-kotlin -> kotlin-test-launcher
NickHackman Jul 25, 2025
2698c36
refactor: extract out core subproject
NickHackman Jul 25, 2025
9a211c9
refactor: set up basic gradle plugin
NickHackman Jul 25, 2025
64eecb1
refactor(core): runBlocking rather than suspend
NickHackman Jul 25, 2025
11cbd09
feat: add the maven scaffolding
Jul 27, 2025
8ec07df
chore: moved maven -> maven-plugin
Jul 27, 2025
f3e51bc
build: publish to mavenLocal + jackson
NickHackman Aug 1, 2025
b31ef9a
refactor: don't use kotlinx serialization instead use Jackson
NickHackman Aug 1, 2025
1208332
feat: gradle-plugin
NickHackman Aug 1, 2025
05605b4
fix(test-logging.init.gradle.kts): remove test changes for custom plugin
NickHackman Aug 1, 2025
57d48df
feat(gradle-plugin): provide custom outputFile to gradle-plugin
NickHackman Aug 1, 2025
3ed41ac
refactor: local functions
NickHackman Aug 1, 2025
390c394
chore(gitignore): .idea
NickHackman Aug 2, 2025
0317712
feat: log out outputFile
NickHackman Aug 2, 2025
f839015
feat: use kotlin-test-launcher plugin output instead of gradle
NickHackman Aug 3, 2025
871270b
test: TestNode tests
NickHackman Aug 3, 2025
e94e5e1
test: update
NickHackman Aug 3, 2025
def67c4
refactor: remove output_parser entirely
NickHackman Aug 3, 2025
f55320d
test: output/init.lua
NickHackman Aug 3, 2025
5a2ee10
fix: remove output_parser import
NickHackman Aug 3, 2025
d12df8b
refactor: TestNode.status.status -> TestNode.status.type
NickHackman Aug 3, 2025
9e69cb9
fix: use Jackson over kotlinx serialization
NickHackman Aug 3, 2025
784dd37
ci: kotlin-test and lua-test workflows
NickHackman Aug 3, 2025
ef4f0dc
fix: exclude abstract Kotest specs
NickHackman Aug 4, 2025
f726e5c
fix: never allow plugin to be up to date
NickHackman Aug 4, 2025
ac7c854
build: use same version of kotlin
NickHackman Aug 4, 2025
5dfb409
build(deps): add asm
NickHackman Aug 4, 2025
a9c40a8
feat: support executing entire packages
NickHackman Aug 4, 2025
f018821
style: ktlint format
NickHackman Aug 4, 2025
664180a
refactor: use Set rather than List
NickHackman Aug 7, 2025
098d42d
feat: kotlin-test-launcher -> kotlin-test and run from root project
NickHackman Aug 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/kotlin-test.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions .github/workflows/test.yml → .github/workflows/lua-test.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -32,4 +32,4 @@ jobs:
- name: Run tests
run: |
nvim --version
make test
make lua-test
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
.luarc.json
.tests
kotlin-test-launcher/maven-plugin/target/
.idea
9 changes: 7 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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

Expand Down
12 changes: 12 additions & 0 deletions kotlin-test/.gitattributes
Original file line number Diff line number Diff line change
@@ -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

7 changes: 7 additions & 0 deletions kotlin-test/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Ignore Gradle project-specific cache directory
.gradle

# Ignore Gradle build output directory
build

.kotlin
31 changes: 31 additions & 0 deletions kotlin-test/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<Test>().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
}
}
}
34 changes: 34 additions & 0 deletions kotlin-test/core/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<MavenPublication>("kotlin-test-core") {
groupId = project.group.toString()
artifactId = "kotlin-test-core"
version = "1.0.0"
from(components["java"])
}
}
}
Original file line number Diff line number Diff line change
@@ -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<KClass<*>>): 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<KClass<*>>): RunReport =
runBlocking {
flowOf<TestFrameworkRunner>(
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<TestNode.Container>

public sealed interface TestRunResult {
public data class Success(
val report: RunReport,
) : TestRunResult

public object Failure : TestRunResult
}
Original file line number Diff line number Diff line change
@@ -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<TestNode> = mutableListOf()
public val tests: List<TestNode>
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<TestNode.Test> = 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<TestNode> {
val parent = this

return sequence {
val queue = mutableListOf<TestNode.Container>(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<String> = 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
}
}
Loading
Loading