-
-
Notifications
You must be signed in to change notification settings - Fork 369
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
lihaoyi
merged 3 commits into
com-lihaoyi:main
from
0xnm:add-test-reporter-for-kotlin-js
Oct 23, 2024
Merged
Changes from 2 commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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. | ||||||
|
@@ -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]]). | ||||||
*/ | ||||||
|
@@ -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", "[email protected]"), | ||||||
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() | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
||||||
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, "<unknown>", 0) | ||||||
} | ||||||
} | ||||||
} | ||||||
} | ||||||
|
||||||
/** | ||||||
|
2 changes: 1 addition & 1 deletion
2
kotlinlib/test/src/mill/kotlinlib/contrib/ktfmt/KtfmtModuleTests.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.