From 91ff5f8e10fddf33d88ec2233f100f30a76e6844 Mon Sep 17 00:00:00 2001 From: Nikita Ogorodnikov <4046447+0xnm@users.noreply.github.com> Date: Mon, 14 Oct 2024 06:24:22 +0200 Subject: [PATCH] Add Kotest support for Kotlin/JS (#3723) 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 --- .../kotlinlib/web/3-hello-kotlinjs/build.mill | 35 +++--- .../foo/test/src/foo/HelloTests.kt | 15 --- .../{foo => }/src/foo/Hello.kt | 4 +- .../test/src/foo/HelloTests.kt | 11 ++ .../mill/kotlinlib/js/KotlinJSModule.scala | 109 +++++++++++++++--- .../foo/test/src/foo/HelloKotestTests.kt | 16 +++ ...ests.kt => HelloKotlinTestPackageTests.kt} | 0 .../js/KotlinJSKotestModuleTests.scala | 56 +++++++++ ...KotlinJSKotlinTestPackageModuleTests.scala | 55 +++++++++ .../js/KotlinJSTestModuleTests.scala | 47 -------- 10 files changed, 251 insertions(+), 97 deletions(-) delete mode 100644 example/kotlinlib/web/3-hello-kotlinjs/foo/test/src/foo/HelloTests.kt rename example/kotlinlib/web/3-hello-kotlinjs/{foo => }/src/foo/Hello.kt (81%) create mode 100644 example/kotlinlib/web/3-hello-kotlinjs/test/src/foo/HelloTests.kt create mode 100644 kotlinlib/test/resources/kotlin-js/foo/test/src/foo/HelloKotestTests.kt rename kotlinlib/test/resources/kotlin-js/foo/test/src/foo/{HelloTests.kt => HelloKotlinTestPackageTests.kt} (100%) create mode 100644 kotlinlib/test/src/mill/kotlinlib/js/KotlinJSKotestModuleTests.scala create mode 100644 kotlinlib/test/src/mill/kotlinlib/js/KotlinJSKotlinTestPackageModuleTests.scala delete mode 100644 kotlinlib/test/src/mill/kotlinlib/js/KotlinJSTestModuleTests.scala diff --git a/example/kotlinlib/web/3-hello-kotlinjs/build.mill b/example/kotlinlib/web/3-hello-kotlinjs/build.mill index cd69d50d31c..800fb8db491 100644 --- a/example/kotlinlib/web/3-hello-kotlinjs/build.mill +++ b/example/kotlinlib/web/3-hello-kotlinjs/build.mill @@ -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...

Hello World

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:<"

Hello World Wrong

"> but was:<"

Hello World

... ... -error: AssertionError: Expected <

Hello World

>, actual <

Hello World Wrong

>. -> cat out/foo/test/linkBinary.dest/binaries/test.js # Generated javascript on disk -...assertEquals_0(..., '

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

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 */ diff --git a/example/kotlinlib/web/3-hello-kotlinjs/foo/test/src/foo/HelloTests.kt b/example/kotlinlib/web/3-hello-kotlinjs/foo/test/src/foo/HelloTests.kt deleted file mode 100644 index fc33731c87a..00000000000 --- a/example/kotlinlib/web/3-hello-kotlinjs/foo/test/src/foo/HelloTests.kt +++ /dev/null @@ -1,15 +0,0 @@ -package foo - -import kotlin.test.Test -import kotlin.test.assertEquals - -class HelloTests { - - @Test - fun testHello() { - val result = hello() - assertEquals(result.trim(), "

Hello World Wrong

") - result - } -} - diff --git a/example/kotlinlib/web/3-hello-kotlinjs/foo/src/foo/Hello.kt b/example/kotlinlib/web/3-hello-kotlinjs/src/foo/Hello.kt similarity index 81% rename from example/kotlinlib/web/3-hello-kotlinjs/foo/src/foo/Hello.kt rename to example/kotlinlib/web/3-hello-kotlinjs/src/foo/Hello.kt index b3348c98139..47d4a851c36 100644 --- a/example/kotlinlib/web/3-hello-kotlinjs/foo/src/foo/Hello.kt +++ b/example/kotlinlib/web/3-hello-kotlinjs/src/foo/Hello.kt @@ -12,5 +12,5 @@ fun main() { } fun hello(): String { - return createHTML().h1 { +"Hello World" }.toString() -} \ No newline at end of file + return createHTML(prettyPrint = false).h1 { text("Hello World") }.toString() +} diff --git a/example/kotlinlib/web/3-hello-kotlinjs/test/src/foo/HelloTests.kt b/example/kotlinlib/web/3-hello-kotlinjs/test/src/foo/HelloTests.kt new file mode 100644 index 00000000000..bb7cc3c4535 --- /dev/null +++ b/example/kotlinlib/web/3-hello-kotlinjs/test/src/foo/HelloTests.kt @@ -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 "

Hello World Wrong

" + } +}) diff --git a/kotlinlib/src/mill/kotlinlib/js/KotlinJSModule.scala b/kotlinlib/src/mill/kotlinlib/js/KotlinJSModule.scala index 07f284a4a37..2acfda58be5 100644 --- a/kotlinlib/src/mill/kotlinlib/js/KotlinJSModule.scala +++ b/kotlinlib/src/mill/kotlinlib/js/KotlinJSModule.scala @@ -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 => @@ -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 = { @@ -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) @@ -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 diff --git a/kotlinlib/test/resources/kotlin-js/foo/test/src/foo/HelloKotestTests.kt b/kotlinlib/test/resources/kotlin-js/foo/test/src/foo/HelloKotestTests.kt new file mode 100644 index 00000000000..56b3def1857 --- /dev/null +++ b/kotlinlib/test/resources/kotlin-js/foo/test/src/foo/HelloKotestTests.kt @@ -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" + } +}) diff --git a/kotlinlib/test/resources/kotlin-js/foo/test/src/foo/HelloTests.kt b/kotlinlib/test/resources/kotlin-js/foo/test/src/foo/HelloKotlinTestPackageTests.kt similarity index 100% rename from kotlinlib/test/resources/kotlin-js/foo/test/src/foo/HelloTests.kt rename to kotlinlib/test/resources/kotlin-js/foo/test/src/foo/HelloKotlinTestPackageTests.kt diff --git a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSKotestModuleTests.scala b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSKotestModuleTests.scala new file mode 100644 index 00000000000..b9327e55576 --- /dev/null +++ b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSKotestModuleTests.scala @@ -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:") + ) + } + } + +} diff --git a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSKotlinTestPackageModuleTests.scala b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSKotlinTestPackageModuleTests.scala new file mode 100644 index 00000000000..fdd9b2039a0 --- /dev/null +++ b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSKotlinTestPackageModuleTests.scala @@ -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 , actual ."), + 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:") + ) + } + } + +} diff --git a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSTestModuleTests.scala b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSTestModuleTests.scala deleted file mode 100644 index 37e2dd138ad..00000000000 --- a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSTestModuleTests.scala +++ /dev/null @@ -1,47 +0,0 @@ -package mill -package kotlinlib -package js - -import mill.eval.EvaluatorPaths -import mill.testkit.{TestBaseModule, UnitTester} -import utest.{TestSuite, Tests, test} - -object KotlinJSTestModuleTests 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 = KotlinJSTestModuleTests.kotlinVersion - } - - object foo extends KotlinJSModule { - def kotlinVersion = KotlinJSTestModuleTests.kotlinVersion - override def moduleDeps = Seq(module.bar) - - object test extends KotlinJSModule with KotlinJSKotlinXTests - } - } - - 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 = EvaluatorPaths.resolveDestPaths(eval.outPath, command).log / ".." / "run.log" - assert( - os.read(log).contains("AssertionError: Expected , actual .") - ) - } - } - -}