From 9bae771b8fcae711aa6ac59a64f6de92262160ca Mon Sep 17 00:00:00 2001 From: Nikita Ogorodnikov <4046447+0xnm@users.noreply.github.com> Date: Wed, 23 Oct 2024 09:29:39 +0200 Subject: [PATCH] Add test reporter for Kotlin/JS (#3757) This PR adds test reporter for Kotlin/JS. It is based on [XUnit](https://mochajs.org/#xunit) reporter provided by Mocha - XML will be generated anyway, irrespective of the `TestModule.testReportXml` option, because structured output is needed in order to be able to parse it. So as a result it will be XML file + test result message, which can be seen in the tests. Adding HTML reporter is not yet possible without using 3rd party packages, because the one provided OOTB by Mocha is [not intended for CLI usage](https://mochajs.org/#html-reporter). I also had to modify the `Jvm.runSubprocess` method, so it doesn't throw if exit code is not 0, but returns result. To comply with the old behavior, all the old call sites will just call `getOrThrow` on the result. This change is needed, because if there is a test failure, `node` process will exit with code 1, but we still need to process and catching generic `Exception` is not an option (because maybe it is not because of the exit code). Upd: Binary compatibility check now complains that `Jvm.runSubprocess` has a different return type, but I'm not sure if it should be a problem, because before it was simply returning `Unit`. But if it is a problem, I can introduce a new method instead. --------- Co-authored-by: 0xnm <0xnm@users.noreply.github.com> Co-authored-by: Li Haoyi --- .../kotlinlib/web/3-hello-kotlinjs/build.mill | 6 +- .../kotlinlib/KotlinWorkerManagerImpl.scala | 2 +- .../mill/kotlinlib/js/KotlinJsModule.scala | 258 +++++++++++++----- .../contrib/ktfmt/KtfmtModuleTests.scala | 2 +- .../js/KotlinJsKotestModuleTests.scala | 38 ++- ...KotlinJsKotlinTestPackageModuleTests.scala | 36 ++- .../mill/kotlinlib/js/KotlinJsLinkTests.scala | 4 +- .../kotlinlib/js/KotlinJsNodeRunTests.scala | 2 +- main/util/src/mill/util/Jvm.scala | 24 +- 9 files changed, 277 insertions(+), 95 deletions(-) diff --git a/example/kotlinlib/web/3-hello-kotlinjs/build.mill b/example/kotlinlib/web/3-hello-kotlinjs/build.mill index f28b7dd95f9..6f18db8c741 100644 --- a/example/kotlinlib/web/3-hello-kotlinjs/build.mill +++ b/example/kotlinlib/web/3-hello-kotlinjs/build.mill @@ -1,5 +1,5 @@ // 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. +// supports Node.js, but lacks support of Browser, Webpack, test runners, etc. // // The example below demonstrates only the minimal compilation, running, and testing of // a single Kotlin/JS module using a single third-party dependency. For more details in @@ -13,7 +13,7 @@ import mill._, kotlinlib._, kotlinlib.js._ object `package` extends RootModule with KotlinJsModule { def moduleKind = ModuleKind.ESModule def kotlinVersion = "1.9.25" - def kotlinJSRunTarget = Some(RunTarget.Node) + def kotlinJsRunTarget = Some(RunTarget.Node) def ivyDeps = Agg( ivy"org.jetbrains.kotlinx:kotlinx-html-js:0.11.0", ) @@ -33,7 +33,7 @@ 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: ... expected:<"

Hello World Wrong

"> but was:<"

Hello World

... ... > cat out/test/linkBinary.dest/binaries/test.js # Generated javascript on disk diff --git a/kotlinlib/src/mill/kotlinlib/KotlinWorkerManagerImpl.scala b/kotlinlib/src/mill/kotlinlib/KotlinWorkerManagerImpl.scala index 5cfc77c10d6..d55e600e3c4 100644 --- a/kotlinlib/src/mill/kotlinlib/KotlinWorkerManagerImpl.scala +++ b/kotlinlib/src/mill/kotlinlib/KotlinWorkerManagerImpl.scala @@ -1,7 +1,7 @@ /* * Original code copied from https://github.com/lefou/mill-kotlin * Original code published under the Apache License Version 2 - * Original Copyright 2020-20 24 Tobias Roeser + * Original Copyright 2020-2024 Tobias Roeser */ package mill.kotlinlib diff --git a/kotlinlib/src/mill/kotlinlib/js/KotlinJsModule.scala b/kotlinlib/src/mill/kotlinlib/js/KotlinJsModule.scala index f8cf6633e1c..5bf273f2994 100644 --- a/kotlinlib/src/mill/kotlinlib/js/KotlinJsModule.scala +++ b/kotlinlib/src/mill/kotlinlib/js/KotlinJsModule.scala @@ -10,10 +10,12 @@ import mill.scalalib.api.CompilationResult import mill.testrunner.TestResult import mill.util.Jvm import mill.{Agg, Args, T} +import sbt.testing.Status import upickle.default.{macroRW, ReadWriter => RW} -import java.io.File +import java.io.{File, FileNotFoundException} import java.util.zip.ZipFile +import scala.xml.XML /** * This module is very experimental. Don't use it, it is still under the development, APIs can change. @@ -29,28 +31,28 @@ trait KotlinJsModule extends KotlinModule { outer => def callMain: T[Boolean] = true /** Binary type (if any) to produce. If [[BinaryKind.Executable]] is selected, then .js file(s) will be produced. */ - def kotlinJSBinaryKind: T[Option[BinaryKind]] = Some(BinaryKind.Executable) + def kotlinJsBinaryKind: T[Option[BinaryKind]] = Some(BinaryKind.Executable) /** Whether to emit a source map. */ - def kotlinJSSourceMap: T[Boolean] = true + def kotlinJsSourceMap: T[Boolean] = true /** Whether to embed sources into source map. */ - def kotlinJSSourceMapEmbedSources: T[SourceMapEmbedSourcesKind] = SourceMapEmbedSourcesKind.Never + def kotlinJsSourceMapEmbedSources: T[SourceMapEmbedSourcesKind] = SourceMapEmbedSourcesKind.Never /** ES target to use. List of the supported ones depends on the Kotlin version. If not provided, default is used. */ - def kotlinJSESTarget: T[Option[String]] = None + def kotlinJsESTarget: T[Option[String]] = None /** * Add variable and function names that you declared in Kotlin code into the source map. See * [[https://kotlinlang.org/docs/compiler-reference.html#source-map-names-policy-simple-names-fully-qualified-names-no Kotlin docs]] for more details */ - def kotlinJSSourceMapNamesPolicy: T[SourceMapNamesPolicy] = SourceMapNamesPolicy.No + def kotlinJsSourceMapNamesPolicy: T[SourceMapNamesPolicy] = SourceMapNamesPolicy.No /** Split generated .js per-module. Effective only if [[BinaryKind.Executable]] is selected. */ def splitPerModule: T[Boolean] = true /** Run target for the executable (if [[BinaryKind.Executable]] is set). */ - def kotlinJSRunTarget: T[Option[RunTarget]] = None + def kotlinJsRunTarget: T[Option[RunTarget]] = None // endregion @@ -89,11 +91,11 @@ trait KotlinJsModule extends KotlinModule { outer => librariesClasspath = compileClasspath(), callMain = callMain(), moduleKind = moduleKind(), - produceSourceMaps = kotlinJSSourceMap(), - sourceMapEmbedSourcesKind = kotlinJSSourceMapEmbedSources(), - sourceMapNamesPolicy = kotlinJSSourceMapNamesPolicy(), + produceSourceMaps = kotlinJsSourceMap(), + sourceMapEmbedSourcesKind = kotlinJsSourceMapEmbedSources(), + sourceMapNamesPolicy = kotlinJsSourceMapNamesPolicy(), splitPerModule = splitPerModule(), - esTarget = kotlinJSESTarget(), + esTarget = kotlinJsESTarget(), kotlinVersion = kotlinVersion(), destinationRoot = T.dest, extraKotlinArgs = kotlincOptions(), @@ -105,54 +107,68 @@ trait KotlinJsModule extends KotlinModule { outer => Task.Command { run(args)() } override def run(args: Task[Args] = Task.Anon(Args())): Command[Unit] = Task.Command { - val binaryKind = kotlinJSBinaryKind() - if (binaryKind.isEmpty || binaryKind.get != BinaryKind.Executable) { - T.log.error("Run action is only allowed for the executable binary") + runJsBinary( + args = args(), + binaryKind = kotlinJsBinaryKind(), + moduleKind = moduleKind(), + binaryDir = linkBinary().classes.path, + runTarget = kotlinJsRunTarget(), + envArgs = T.env, + workingDir = T.dest + ).map(_ => ()).getOrThrow + } + + override def runMainLocal( + @arg(positional = true) mainClass: String, + args: String* + ): Command[Unit] = Task.Command[Unit] { + mill.api.Result.Failure("runMain is not supported in Kotlin/JS.") + } + + override def runMain(@arg(positional = true) mainClass: String, args: String*): Command[Unit] = + Task.Command[Unit] { + mill.api.Result.Failure("runMain is not supported in Kotlin/JS.") } - val moduleKind = this.moduleKind() + protected[js] def runJsBinary( + args: Args = Args(), + binaryKind: Option[BinaryKind], + moduleKind: ModuleKind, + binaryDir: os.Path, + runTarget: Option[RunTarget], + envArgs: Map[String, String] = Map.empty[String, String], + workingDir: os.Path + )(implicit ctx: mill.api.Ctx): Result[Int] = { + if (binaryKind.isEmpty || binaryKind.get != BinaryKind.Executable) { + return Result.Failure("Run action is only allowed for the executable binary") + } - val linkResult = linkBinary().classes if ( moduleKind == ModuleKind.NoModule && - linkResult.path.toIO.listFiles().count(_.getName.endsWith(".js")) > 1 + binaryDir.toIO.listFiles().count(_.getName.endsWith(".js")) > 1 ) { T.log.info("No module type is selected for the executable, but multiple .js files found in the output folder." + " This will probably lead to the dependency resolution failure.") } - kotlinJSRunTarget() match { - case Some(RunTarget.Node) => { - val testBinaryPath = (linkResult.path / s"${moduleName()}.${moduleKind.extension}") + runTarget match { + case Some(RunTarget.Node) => + val binaryPath = (binaryDir / s"${moduleName()}.${moduleKind.extension}") .toIO.getAbsolutePath - Jvm.runSubprocess( + Jvm.runSubprocessWithResult( commandArgs = Seq( "node" - ) ++ args().value ++ Seq(testBinaryPath), - envArgs = T.env, - workingDir = T.dest + ) ++ args.value ++ Seq(binaryPath), + envArgs = envArgs, + workingDir = workingDir ) - } case Some(x) => - T.log.error(s"Run target $x is not supported") + Result.Failure(s"Run target $x is not supported") case None => - throw new IllegalArgumentException("Executable binary should have a run target selected.") + Result.Failure("Executable binary should have a run target selected.") } - - } - - override def runMainLocal( - @arg(positional = true) mainClass: String, - args: String* - ): Command[Unit] = Task.Command[Unit] { - mill.api.Result.Failure("runMain is not supported in Kotlin/JS.") } - override def runMain(@arg(positional = true) mainClass: String, args: String*): Command[Unit] = - Task.Command[Unit] { - mill.api.Result.Failure("runMain is not supported in Kotlin/JS.") - } - /** * The actual Kotlin compile task (used by [[compile]] and [[kotlincHelp]]). */ @@ -166,11 +182,11 @@ trait KotlinJsModule extends KotlinModule { outer => librariesClasspath = compileClasspath(), callMain = callMain(), moduleKind = moduleKind(), - produceSourceMaps = kotlinJSSourceMap(), - sourceMapEmbedSourcesKind = kotlinJSSourceMapEmbedSources(), - sourceMapNamesPolicy = kotlinJSSourceMapNamesPolicy(), + produceSourceMaps = kotlinJsSourceMap(), + sourceMapEmbedSourcesKind = kotlinJsSourceMapEmbedSources(), + sourceMapNamesPolicy = kotlinJsSourceMapNamesPolicy(), splitPerModule = splitPerModule(), - esTarget = kotlinJSESTarget(), + esTarget = kotlinJsESTarget(), kotlinVersion = kotlinVersion(), destinationRoot = T.dest, extraKotlinArgs = kotlincOptions() ++ extraKotlinArgs, @@ -183,17 +199,17 @@ trait KotlinJsModule extends KotlinModule { outer => */ def linkBinary: T[CompilationResult] = Task { kotlinJsCompile( - outputMode = binaryKindToOutputMode(kotlinJSBinaryKind()), + outputMode = binaryKindToOutputMode(kotlinJsBinaryKind()), irClasspath = Some(compile().classes), allKotlinSourceFiles = Seq.empty, librariesClasspath = compileClasspath(), callMain = callMain(), moduleKind = moduleKind(), - produceSourceMaps = kotlinJSSourceMap(), - sourceMapEmbedSourcesKind = kotlinJSSourceMapEmbedSources(), - sourceMapNamesPolicy = kotlinJSSourceMapNamesPolicy(), + produceSourceMaps = kotlinJsSourceMap(), + sourceMapEmbedSourcesKind = kotlinJsSourceMapEmbedSources(), + sourceMapNamesPolicy = kotlinJsSourceMapNamesPolicy(), splitPerModule = splitPerModule(), - esTarget = kotlinJSESTarget(), + esTarget = kotlinJsESTarget(), kotlinVersion = kotlinVersion(), destinationRoot = T.dest, extraKotlinArgs = kotlincOptions(), @@ -386,8 +402,8 @@ trait KotlinJsModule extends KotlinModule { outer => } } - private def moduleName() = fullModuleNameSegments().last - private def fullModuleName() = fullModuleNameSegments().mkString("-") + protected[js] def moduleName(): String = fullModuleNameSegments().last + protected[js] def fullModuleName(): String = fullModuleNameSegments().mkString("-") // **NOTE**: This logic may (and probably is) be incomplete private def isKotlinJsLibrary(path: os.Path)(implicit ctx: mill.api.Ctx): Boolean = { @@ -422,6 +438,8 @@ trait KotlinJsModule extends KotlinModule { outer => */ trait KotlinJsTests extends KotlinTests with KotlinJsModule { + private val defaultXmlReportName = "test-report.xml" + // region private // TODO may be optimized if there is a single folder for all modules @@ -448,7 +466,7 @@ trait KotlinJsModule extends KotlinModule { outer => Jvm.runSubprocess( commandArgs = Seq("npm", "install", "source-map-support@0.5.21"), envArgs = T.env, - workingDir = nodeModulesDir().path + workingDir = workingDir ) PathRef(workingDir / "node_modules" / "source-map-support" / "register.js") } @@ -457,7 +475,9 @@ trait KotlinJsModule extends KotlinModule { outer => override def testFramework = "" - override def kotlinJSBinaryKind: T[Option[BinaryKind]] = Some(BinaryKind.Executable) + override def kotlinJsRunTarget: T[Option[RunTarget]] = outer.kotlinJsRunTarget() + + override def moduleKind: T[ModuleKind] = ModuleKind.PlainModule override def splitPerModule = false @@ -470,20 +490,136 @@ trait KotlinJsModule extends KotlinModule { outer => args: Task[Seq[String]], globSelectors: Task[Seq[String]] ): Task[(String, Seq[TestResult])] = Task.Anon { - // This is a terrible hack, but it works - run(Task.Anon { - Args(args() ++ Seq( + runJsBinary( + // TODO add runner to be able to use test selector + args = 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]) + mochaModule().path.toString(), + "--reporter", + "xunit", + "--reporter-option", + s"output=${testReportXml().getOrElse(defaultXmlReportName)}" + )), + binaryKind = Some(BinaryKind.Executable), + moduleKind = moduleKind(), + binaryDir = linkBinary().classes.path, + runTarget = kotlinJsRunTarget(), + envArgs = T.env, + workingDir = T.dest + ) + + // we don't care about the result returned above (because node will return exit code = 1 when tests fail), what + // matters is if test results file exists + val xmlReportName = testReportXml().getOrElse(defaultXmlReportName) + val xmlReportPath = T.dest / xmlReportName + val testResults = parseTestResults(xmlReportPath) + val totalCount = testResults.length + val passedCount = testResults.count(_.status == Status.Success.name()) + val failedCount = testResults.count(_.status == Status.Failure.name()) + val skippedCount = testResults.count(_.status == Status.Skipped.name()) + val doneMessage = + s""" + |Tests: $totalCount, Passed: $passedCount, Failed: $failedCount, Skipped: $skippedCount + | + |Full report is available at $xmlReportPath + |""".stripMargin + + if (failedCount != 0) { + val failedTests = testResults + .filter(_.status == Status.Failure.name()) + .map(result => + if (result.exceptionName.isEmpty && result.exceptionMsg.isEmpty) { + s"${result.fullyQualifiedName} - ${result.selector}" + } else { + s"${result.fullyQualifiedName} - ${result.selector}: ${result.exceptionName.getOrElse("<>")}:" + + s" ${result.exceptionMsg.getOrElse("<>")}" + } + ) + val failureMessage = + s""" + |Tests failed: + | + |${failedTests.mkString("\n")} + | + |""".stripMargin + Result.Failure(failureMessage, Some((doneMessage, testResults))) + } else { + Result.Success((doneMessage, testResults)) + } } - override def kotlinJSRunTarget: T[Option[RunTarget]] = Some(RunTarget.Node) + private def parseTestResults(path: os.Path): Seq[TestResult] = { + if (!os.exists(path)) { + throw new FileNotFoundException(s"Test results file $path wasn't found") + } + val xml = XML.loadFile(path.toIO) + (xml \ "testcase") + .map { node => + val (testStatus, exceptionName, exceptionMessage, exceptionTrace) = + if (node.child.exists(_.label == "failure")) { + val content = (node \ "failure") + .head + .child + .filter(_.isAtom) + .text + val lines = content.split("\n") + val exceptionMessage = lines.head + val exceptionType = lines(1).splitAt(lines(1).indexOf(":"))._1 + val trace = parseTrace(lines.drop(2)) + (Status.Failure, Some(exceptionType), Some(exceptionMessage), Some(trace)) + } else if (node.child.exists(_.label == "skipped")) { + (Status.Skipped, None, None, None) + } else { + (Status.Success, None, None, None) + } + + TestResult( + fullyQualifiedName = node \@ "classname", + selector = node \@ "name", + // probably in milliseconds? + duration = ((node \@ "time").toDouble * 1000).toLong, + status = testStatus.name(), + exceptionName = exceptionName, + exceptionMsg = exceptionMessage, + exceptionTrace = exceptionTrace + ) + } + } + + private def parseTrace(trace: Seq[String]): Seq[StackTraceElement] = { + trace.map { line => + // there are some lines with methods like this: $interceptCOROUTINE$97.l [as test_1], no idea what is this. + val strippedLine = line.trim.stripPrefix("at ") + val (symbol, location) = strippedLine.splitAt(strippedLine.lastIndexOf("(")) + // symbol can be like that HelloTests$_init_$lambda$slambda_wolooq_1.protoOf.doResume_5yljmg_k$ + // assume that everything past first dot is a method name, and everything before - some synthetic class name + // this may be completely wrong though, but at least location will be right + val (declaringClass, method) = if (symbol.contains(".")) { + symbol.splitAt(symbol.indexOf(".")) + } else { + ("", symbol) + } + // can be what we expect in case if line is pure-JVM: + // src/internal/JSDispatcher.kt:127:25 + // but can also be something like: + // node:internal/process/task_queues:77:11 + // drop closing ), then after split drop position on the line + val locationElements = location.dropRight(1).split(":").dropRight(1) + if (locationElements.length >= 2) { + new StackTraceElement( + declaringClass, + method, + locationElements(locationElements.length - 2), + locationElements.last.toInt + ) + } else { + new StackTraceElement(declaringClass, method, "", 0) + } + } + } } /** diff --git a/kotlinlib/test/src/mill/kotlinlib/contrib/ktfmt/KtfmtModuleTests.scala b/kotlinlib/test/src/mill/kotlinlib/contrib/ktfmt/KtfmtModuleTests.scala index 75fbad0890b..b1a8afe2c62 100644 --- a/kotlinlib/test/src/mill/kotlinlib/contrib/ktfmt/KtfmtModuleTests.scala +++ b/kotlinlib/test/src/mill/kotlinlib/contrib/ktfmt/KtfmtModuleTests.scala @@ -1,6 +1,6 @@ package mill.kotlinlib.ktfmt -import mill.{PathRef, T, Task, api} +import mill.{PathRef, T, api} import mill.kotlinlib.KotlinModule import mill.main.Tasks import mill.testkit.{TestBaseModule, UnitTester} diff --git a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJsKotestModuleTests.scala b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJsKotestModuleTests.scala index 1fca10f08b4..bfc89a068ee 100644 --- a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJsKotestModuleTests.scala +++ b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJsKotestModuleTests.scala @@ -1,9 +1,11 @@ package mill package kotlinlib.js +import mill.api.Result import mill.eval.EvaluatorPaths import mill.testkit.{TestBaseModule, UnitTester} -import utest.{assert, TestSuite, Tests, test} +import sbt.testing.Status +import utest.{TestSuite, Tests, assert, test} object KotlinJsKotestModuleTests extends TestSuite { @@ -19,6 +21,7 @@ object KotlinJsKotestModuleTests extends TestSuite { object foo extends KotlinJsModule { def kotlinVersion = KotlinJsKotestModuleTests.kotlinVersion + override def kotlinJsRunTarget = Some(RunTarget.Node) override def moduleDeps = Seq(module.bar) object test extends KotlinJsModule with KotestTests { @@ -36,19 +39,30 @@ object KotlinJsKotestModuleTests extends TestSuite { val eval = testEval() val command = module.foo.test.test() - val Left(_) = eval.apply(command) + val Left(Result.Failure(failureMessage, Some((doneMessage, testResults)))) = + eval.apply(command) + + val xmlReport = + EvaluatorPaths.resolveDestPaths(eval.outPath, command).dest / "test-report.xml" - // 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:") + os.exists(xmlReport), + os.read(xmlReport).contains("HelloKotestTests.kt:"), + failureMessage == s""" + |Tests failed: + | + |HelloTests - failure: AssertionFailedError: expected:<\"Not hello, world\"> but was:<\"Hello, world\"> + | + |""".stripMargin, + doneMessage == s""" + |Tests: 2, Passed: 1, Failed: 1, Skipped: 0 + | + |Full report is available at $xmlReport + |""".stripMargin, + testResults.length == 2, + testResults.count(result => + result.status == Status.Failure.name() && result.exceptionTrace.getOrElse(Seq.empty).isEmpty + ) == 0 ) } } diff --git a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJsKotlinTestPackageModuleTests.scala b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJsKotlinTestPackageModuleTests.scala index 3540cc9fe57..f28767c83eb 100644 --- a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJsKotlinTestPackageModuleTests.scala +++ b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJsKotlinTestPackageModuleTests.scala @@ -2,9 +2,11 @@ package mill package kotlinlib package js +import mill.api.Result import mill.eval.EvaluatorPaths import mill.testkit.{TestBaseModule, UnitTester} -import utest.{assert, TestSuite, Tests, test} +import sbt.testing.Status +import utest.{TestSuite, Tests, assert, test} object KotlinJsKotlinTestPackageModuleTests extends TestSuite { @@ -20,6 +22,7 @@ object KotlinJsKotlinTestPackageModuleTests extends TestSuite { object foo extends KotlinJsModule { def kotlinVersion = KotlinJsKotlinTestPackageModuleTests.kotlinVersion + override def kotlinJsRunTarget = Some(RunTarget.Node) override def moduleDeps = Seq(module.bar) object test extends KotlinJsModule with KotlinTestPackageTests { @@ -37,17 +40,30 @@ object KotlinJsKotlinTestPackageModuleTests extends TestSuite { val eval = testEval() val command = module.foo.test.test() - val Left(_) = eval.apply(command) + val Left(Result.Failure(failureMessage, Some((doneMessage, testResults)))) = + eval.apply(command) + + val xmlReport = + EvaluatorPaths.resolveDestPaths(eval.outPath, command).dest / "test-report.xml" - // 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:") + os.exists(xmlReport), + os.read(xmlReport).contains("HelloKotlinTestPackageTests.kt:"), + failureMessage == s""" + |Tests failed: + | + |foo HelloTests - failure: AssertionError: Expected , actual . + | + |""".stripMargin, + doneMessage == s""" + |Tests: 2, Passed: 1, Failed: 1, Skipped: 0 + | + |Full report is available at $xmlReport + |""".stripMargin, + testResults.length == 2, + testResults.count(result => + result.status == Status.Failure.name() && result.exceptionTrace.getOrElse(Seq.empty).isEmpty + ) == 0 ) } } diff --git a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJsLinkTests.scala b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJsLinkTests.scala index 6161c91b75c..1eaf4b4356d 100644 --- a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJsLinkTests.scala +++ b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJsLinkTests.scala @@ -4,8 +4,6 @@ import mill.testkit.{TestBaseModule, UnitTester} import mill.{Cross, T} import utest.{TestSuite, Tests, assert, test} -import scala.util.Random - object KotlinJsLinkTests extends TestSuite { private val kotlinVersion = "1.9.25" @@ -15,7 +13,7 @@ object KotlinJsLinkTests extends TestSuite { trait KotlinJsCrossModule extends KotlinJsModule with Cross.Module[Boolean] { override def kotlinVersion = KotlinJsLinkTests.kotlinVersion override def splitPerModule: T[Boolean] = crossValue - override def kotlinJSBinaryKind: T[Option[BinaryKind]] = Some(BinaryKind.Executable) + override def kotlinJsBinaryKind: T[Option[BinaryKind]] = Some(BinaryKind.Executable) override def moduleDeps = Seq(module.bar) } diff --git a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJsNodeRunTests.scala b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJsNodeRunTests.scala index 8075194da38..e80a5c14808 100644 --- a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJsNodeRunTests.scala +++ b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJsNodeRunTests.scala @@ -34,7 +34,7 @@ object KotlinJsNodeRunTests extends TestSuite { override def moduleDeps = Seq(module.bar) override def splitPerModule = crossValue - override def kotlinJSRunTarget = Some(RunTarget.Node) + override def kotlinJsRunTarget = Some(RunTarget.Node) } object bar extends KotlinJsModule { diff --git a/main/util/src/mill/util/Jvm.scala b/main/util/src/mill/util/Jvm.scala index b599c1c1e5e..734ef246ba8 100644 --- a/main/util/src/mill/util/Jvm.scala +++ b/main/util/src/mill/util/Jvm.scala @@ -208,13 +208,28 @@ object Jvm extends CoursierSupport { } /** - * Runs a generic subprocess and waits for it to terminate. + * Runs a generic subprocess and waits for it to terminate. If process exited with non-zero code, exception + * will be thrown. If you want to manually handle exit code, check [[runSubprocessWithResult]] */ def runSubprocess( commandArgs: Seq[String], envArgs: Map[String, String], workingDir: os.Path ): Unit = { + runSubprocessWithResult(commandArgs, envArgs, workingDir).getOrThrow + () + } + + /** + * Runs a generic subprocess and waits for it to terminate. + * + * @return Result with exit code. + */ + def runSubprocessWithResult( + commandArgs: Seq[String], + envArgs: Map[String, String], + workingDir: os.Path + ): Result[Int] = { val process = spawnSubprocessWithBackgroundOutputs( commandArgs, envArgs, @@ -239,8 +254,11 @@ object Jvm extends CoursierSupport { } finally { Runtime.getRuntime().removeShutdownHook(shutdownHook) } - if (process.exitCode() == 0) () - else throw new Exception("Interactive Subprocess Failed (exit code " + process.exitCode() + ")") + if (process.exitCode() == 0) Result.Success(process.exitCode()) + else Result.Failure( + "Interactive Subprocess Failed (exit code " + process.exitCode() + ")", + Some(process.exitCode()) + ) } /**