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 .")
- )
- }
- }
-
-}