Skip to content

Commit

Permalink
Add Kotest support for Kotlin/JS (#3723)
Browse files Browse the repository at this point in the history
This PR brings [Kotest](https://github.com/kotest/kotest) test engine
support for Kotlin/JS target test execution.

Replaces #3710.

---------

Co-authored-by: Li Haoyi <haoyi.sg@gmail.com>
  • Loading branch information
0xnm and lihaoyi authored Oct 14, 2024
1 parent 2831d97 commit 91ff5f8
Show file tree
Hide file tree
Showing 10 changed files with 251 additions and 97 deletions.
35 changes: 18 additions & 17 deletions example/kotlinlib/web/3-hello-kotlinjs/build.mill
Original file line number Diff line number Diff line change
@@ -1,46 +1,47 @@
// KotlinJS support on Mill is still Work In Progress (WIP). As of time of writing it
// Node.js/Webpack test runners and reporting, etc.
// Kotlin/JS support on Mill is still Work In Progress (WIP). As of time of writing it
// supports Node.js, but lacks support of Browser, Webpack, test runners, reporting, etc.
//
// The example below demonstrates only the minimal compilation, running, and testing of
// a single KotlinJS module using a single third-party dependency. For more details in
// fully developing KotlinJS support, see the following ticket:
// a single Kotlin/JS module using a single third-party dependency. For more details in
// fully developing Kotlin/JS support, see the following ticket:
//
// * https://github.com/com-lihaoyi/mill/issues/3611

package build
import mill._, kotlinlib._, kotlinlib.js._

object foo extends KotlinJSModule {
object `package` extends RootModule with KotlinJSModule {
def moduleKind = ModuleKind.ESModule
def kotlinVersion = "1.9.25"
def kotlinJSRunTarget = Some(RunTarget.Node)
def ivyDeps = Agg(
ivy"org.jetbrains.kotlinx:kotlinx-html-js:0.11.0",
)
object test extends KotlinJSModule with KotlinJSKotlinXTests
object test extends KotlinJSModule with KotestTests
}


/** Usage

> mill foo.run
Compiling 1 Kotlin sources to .../out/foo/compile.dest/classes...
> mill run
Compiling 1 Kotlin sources to .../out/compile.dest/classes...
<h1>Hello World</h1>
stringifiedJsObject: ["hello","world","!"]

> mill foo.test # Test is incorrect, `foo.test`` fails
Compiling 1 Kotlin sources to .../out/foo/test/compile.dest/classes...
Linking IR to .../out/foo/test/linkBinary.dest/binaries
produce executable: .../out/foo/test/linkBinary.dest/binaries
> mill test # Test is incorrect, `test` fails
Compiling 1 Kotlin sources to .../out/test/compile.dest/classes...
Linking IR to .../out/test/linkBinary.dest/binaries
produce executable: .../out/test/linkBinary.dest/binaries
...
error: ...AssertionFailedError: expected:<"<h1>Hello World Wrong</h1>"> but was:<"<h1>Hello World</h1>...
...
error: AssertionError: Expected <<h1>Hello World</h1>>, actual <<h1>Hello World Wrong</h1>>.

> cat out/foo/test/linkBinary.dest/binaries/test.js # Generated javascript on disk
...assertEquals_0(..., '<h1>Hello World Wrong<\/h1>');...
> cat out/test/linkBinary.dest/binaries/test.js # Generated javascript on disk
...shouldBe(..., '<h1>Hello World Wrong<\/h1>');...
...

> sed -i.bak 's/Hello World Wrong/Hello World/g' foo/test/src/foo/HelloTests.kt
> sed -i.bak 's/Hello World Wrong/Hello World/g' test/src/foo/HelloTests.kt

> mill foo.test # passes after fixing test
> mill test # passes after fixing test

*/

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ fun main() {
}

fun hello(): String {
return createHTML().h1 { +"Hello World" }.toString()
}
return createHTML(prettyPrint = false).h1 { text("Hello World") }.toString()
}
11 changes: 11 additions & 0 deletions example/kotlinlib/web/3-hello-kotlinjs/test/src/foo/HelloTests.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package foo

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe

class HelloTests: FunSpec({
test("hello") {
val result = hello()
result shouldBe "<h1>Hello World Wrong</h1>"
}
})
109 changes: 93 additions & 16 deletions kotlinlib/src/mill/kotlinlib/js/KotlinJSModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,17 @@ trait KotlinJSModule extends KotlinModule { outer =>
}

kotlinJSRunTarget() match {
case Some(RunTarget.Node) => Jvm.runSubprocess(
case Some(RunTarget.Node) => {
val testBinaryPath = (linkResult.path / s"${moduleName()}.${moduleKind.extension}")
.toIO.getAbsolutePath
Jvm.runSubprocess(
commandArgs = Seq(
"node",
(linkResult.path / s"${moduleName()}.${moduleKind.extension}").toIO.getAbsolutePath
) ++ args().value,
"node"
) ++ args().value ++ Seq(testBinaryPath),
envArgs = T.env,
workingDir = T.dest
)
}
case Some(x) =>
T.log.error(s"Run target $x is not supported")
case None =>
Expand Down Expand Up @@ -379,16 +382,16 @@ trait KotlinJSModule extends KotlinModule { outer =>

// these 2 exist to ignore values added to the display name in case of the cross-modules
// we already have cross-modules in the paths, so we don't need them here
private def moduleName() = millModuleSegments.value
.filter(_.isInstanceOf[Segment.Label])
.map(_.asInstanceOf[Segment.Label])
.last
.value
private def fullModuleNameSegments() = {
millModuleSegments.value
.collect { case label: Segment.Label => label.value } match {
case Nil => Seq("root")
case segments => segments
}
}

private def fullModuleName() = millModuleSegments.value
.filter(_.isInstanceOf[Segment.Label])
.map(_.asInstanceOf[Segment.Label].value)
.mkString("-")
private def moduleName() = fullModuleNameSegments().last
private def fullModuleName() = fullModuleNameSegments().mkString("-")

// **NOTE**: This logic may (and probably is) be incomplete
private def isKotlinJsLibrary(path: os.Path)(implicit ctx: mill.api.Ctx): Boolean = {
Expand Down Expand Up @@ -417,8 +420,45 @@ trait KotlinJSModule extends KotlinModule { outer =>

// region Tests module

/**
* Generic trait to run tests for Kotlin/JS which doesn't specify test
* framework. For the particular implementation see [[KotlinTestPackageTests]] or [[KotestTests]].
*/
trait KotlinJSTests extends KotlinTests with KotlinJSModule {

// region private

// TODO may be optimized if there is a single folder for all modules
// but may be problematic if modules use different NPM packages versions
private def nodeModulesDir = Task(persistent = true) {
PathRef(T.dest)
}

// NB: for the packages below it is important to use specific version
// otherwise with random versions there is a possibility to have conflict
// between the versions of the shared transitive deps
private def mochaModule = Task {
val workingDir = nodeModulesDir().path
Jvm.runSubprocess(
commandArgs = Seq("npm", "install", "mocha@10.2.0"),
envArgs = T.env,
workingDir = workingDir
)
PathRef(workingDir / "node_modules" / "mocha" / "bin" / "mocha.js")
}

private def sourceMapSupportModule = Task {
val workingDir = nodeModulesDir().path
Jvm.runSubprocess(
commandArgs = Seq("npm", "install", "source-map-support@0.5.21"),
envArgs = T.env,
workingDir = nodeModulesDir().path
)
PathRef(workingDir / "node_modules" / "source-map-support" / "register.js")
}

// endregion

override def testFramework = ""

override def kotlinJSBinaryKind: T[Option[BinaryKind]] = Some(BinaryKind.Executable)
Expand All @@ -435,16 +475,53 @@ trait KotlinJSModule extends KotlinModule { outer =>
globSelectors: Task[Seq[String]]
): Task[(String, Seq[TestResult])] = Task.Anon {
// This is a terrible hack, but it works
run()()
run(Task.Anon {
Args(args() ++ Seq(
// TODO this is valid only for the NodeJS target. Once browser support is
// added, need to have different argument handling
"--require",
sourceMapSupportModule().path.toString(),
mochaModule().path.toString()
))
})()
("", Seq.empty[TestResult])
}

override def kotlinJSRunTarget: T[Option[RunTarget]] = Some(RunTarget.Node)
}

trait KotlinJSKotlinXTests extends KotlinJSTests {
/**
* Run tests for Kotlin/JS target using `kotlin.test` package.
*/
trait KotlinTestPackageTests extends KotlinJSTests {
override def ivyDeps = Agg(
ivy"org.jetbrains.kotlin:kotlin-test-js:${kotlinVersion()}"
)
override def kotlinJSRunTarget: T[Option[RunTarget]] = Some(RunTarget.Node)
}

/**
* Run tests for Kotlin/JS target using Kotest framework.
*/
trait KotestTests extends KotlinJSTests {

def kotestVersion: T[String] = "5.9.1"

private def kotestProcessor = Task {
defaultResolver().resolveDeps(
Agg(
ivy"io.kotest:kotest-framework-multiplatform-plugin-embeddable-compiler-jvm:${kotestVersion()}"
)
).head
}

override def kotlincOptions = super.kotlincOptions() ++ Seq(
s"-Xplugin=${kotestProcessor().path}"
)

override def ivyDeps = Agg(
ivy"io.kotest:kotest-framework-engine-js:${kotestVersion()}",
ivy"io.kotest:kotest-assertions-core-js:${kotestVersion()}"
)
}

// endregion
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package foo

import bar.getString
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe

class HelloTests: FunSpec({

test("success") {
getString() shouldBe "Hello, world"
}

test("failure") {
getString() shouldBe "Not hello, world"
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package mill
package kotlinlib.js

import mill.eval.EvaluatorPaths
import mill.testkit.{TestBaseModule, UnitTester}
import utest.{assert, TestSuite, Tests, test}

object KotlinJSKotestModuleTests extends TestSuite {

private val resourcePath = os.Path(sys.env("MILL_TEST_RESOURCE_DIR")) / "kotlin-js"

private val kotlinVersion = "1.9.25"

object module extends TestBaseModule {

object bar extends KotlinJSModule {
def kotlinVersion = KotlinJSKotestModuleTests.kotlinVersion
}

object foo extends KotlinJSModule {
def kotlinVersion = KotlinJSKotestModuleTests.kotlinVersion
override def moduleDeps = Seq(module.bar)

object test extends KotlinJSModule with KotestTests {
override def allSourceFiles = super.allSourceFiles()
.filter(!_.path.toString().endsWith("HelloKotlinTestPackageTests.kt"))
}
}
}

private def testEval() = UnitTester(module, resourcePath)

def tests: Tests = Tests {

test("run tests") {
val eval = testEval()

val command = module.foo.test.test()
val Left(_) = eval.apply(command)

// temporary, because we are running run() task, it won't be test.log, but run.log
val log =
os.read(EvaluatorPaths.resolveDestPaths(eval.outPath, command).log / ".." / "run.log")
assert(
log.contains(
"AssertionFailedError: expected:<\"Not hello, world\"> but was:<\"Hello, world\">"
),
log.contains("1 passing"),
log.contains("1 failing"),
// verify that source map is applied, otherwise all stack entries will point to .js
log.contains("HelloKotestTests.kt:")
)
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package mill
package kotlinlib
package js

import mill.eval.EvaluatorPaths
import mill.testkit.{TestBaseModule, UnitTester}
import utest.{assert, TestSuite, Tests, test}

object KotlinJSKotlinTestPackageModuleTests extends TestSuite {

private val resourcePath = os.Path(sys.env("MILL_TEST_RESOURCE_DIR")) / "kotlin-js"

private val kotlinVersion = "1.9.25"

object module extends TestBaseModule {

object bar extends KotlinJSModule {
def kotlinVersion = KotlinJSKotlinTestPackageModuleTests.kotlinVersion
}

object foo extends KotlinJSModule {
def kotlinVersion = KotlinJSKotlinTestPackageModuleTests.kotlinVersion
override def moduleDeps = Seq(module.bar)

object test extends KotlinJSModule with KotlinTestPackageTests {
override def allSourceFiles = super.allSourceFiles()
.filter(!_.path.toString().endsWith("HelloKotestTests.kt"))
}
}
}

private def testEval() = UnitTester(module, resourcePath)

def tests: Tests = Tests {

test("run tests") {
val eval = testEval()

val command = module.foo.test.test()
val Left(_) = eval.apply(command)

// temporary, because we are running run() task, it won't be test.log, but run.log
val log =
os.read(EvaluatorPaths.resolveDestPaths(eval.outPath, command).log / ".." / "run.log")
assert(
log.contains("AssertionError: Expected <Hello, world>, actual <Not hello, world>."),
log.contains("1 passing"),
log.contains("1 failing"),
// verify that source map is applied, otherwise all stack entries will point to .js
log.contains("HelloKotlinTestPackageTests.kt:")
)
}
}

}
Loading

0 comments on commit 91ff5f8

Please sign in to comment.