Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add test reporter for Kotlin/JS #3757

Merged
merged 3 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
4 changes: 2 additions & 2 deletions example/kotlinlib/web/3-hello-kotlinjs/build.mill
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:<"<h1>Hello World Wrong</h1>"> but was:<"<h1>Hello World</h1>...
error: ... expected:<"<h1>Hello World Wrong</h1>"> but was:<"<h1>Hello World</h1>...
...

> cat out/test/linkBinary.dest/binaries/test.js # Generated javascript on disk
Expand Down
2 changes: 1 addition & 1 deletion kotlinlib/src/mill/kotlinlib/KotlinWorkerManagerImpl.scala
Original file line number Diff line number Diff line change
@@ -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

Expand Down
220 changes: 178 additions & 42 deletions kotlinlib/src/mill/kotlinlib/js/KotlinJsModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer the name runKotlinJsBinary, since chances are high the user project may also have some Scala.JS or other JS modules.

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]]).
*/
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand All @@ -448,7 +466,7 @@ trait KotlinJsModule extends KotlinModule { outer =>
Jvm.runSubprocess(
commandArgs = Seq("npm", "install", "[email protected]"),
envArgs = T.env,
workingDir = nodeModulesDir().path
workingDir = workingDir
)
PathRef(workingDir / "node_modules" / "source-map-support" / "register.js")
}
Expand All @@ -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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
override def kotlinJSRunTarget: T[Option[RunTarget]] = outer.kotlinJSRunTarget()
override def kotlinJsRunTarget: T[Option[RunTarget]] = outer.kotlinJsRunTarget()


override def moduleKind: T[ModuleKind] = ModuleKind.PlainModule

override def splitPerModule = false

Expand All @@ -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, "<unknown>", 0)
}
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {

Expand All @@ -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 {
Expand All @@ -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
)
}
}
Expand Down
Loading
Loading