Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package scala.build.internal

/** Generates synthetic Scala Native sources for running tests.
*
* Some test frameworks stopped shipping the `@EnableReflectiveInstantiation` annotation on their
* suite base classes on Scala Native (e.g. munit 1.3.1, which dropped its `test-interface`
* dependency in [[https://github.com/scalameta/munit/pull/1092]]). Without that annotation the
* Scala Native runtime cannot reflectively instantiate the user's test suites, so the test binary
* silently runs zero tests.
*
* To stay robust regardless of whether a framework annotates its suites, Scala CLI generates a
* tiny `main` that registers the discovered test classes with
* [[scala.scalanative.reflect.Reflect]] at startup (the same primitive the compiler plugin emits)
* and then delegates to the regular `scala.scalanative.testinterface.TestMain`. Registration is
* idempotent, so frameworks that do annotate their suites keep working unchanged.
*
* Two sources are generated:
* - a registration shim in `package scala.scalanative.reflect`, because `Reflect`'s `register*`
* methods are `protected[reflect]` (only callable from that package);
* - the entry point itself, kept in the default package so it can reference test suites declared
* in any package, including the default one (a top-level class in the default package cannot
* be referenced from a named package).
*/
object NativeReflectTestMain {

/** Fully-qualified name of the generated entry point, to be used as the native binary main class.
*/
val mainClassName: String = "scalacli_NativeReflectTestMain"

/** File name of the generated registration shim (test-scoped via the `.test.scala` suffix). */
val shimFileName: String = "scalacli-native-reflect-shim.test.scala"

/** File name of the generated entry point (test-scoped via the `.test.scala` suffix). */
val mainFileName: String = "scalacli-native-reflect-test-main.test.scala"

private val shimObject = "scalacli_ScalaCliTestRegistration"

/** Constant shim that exposes [[scala.scalanative.reflect.Reflect]]'s `protected[reflect]`
* registration methods to the generated entry point.
*/
val shimSource: String =
s"""package scala.scalanative.reflect
|
|// Generated by Scala CLI - exposes Reflect's protected[reflect] registration methods.
|object $shimObject {
| def instantiatableClass[T](
| fqcn: String,
| runtimeClass: Class[T],
| constructors: Array[(Array[Class[?]], Array[Any] => Any)]
| ): Unit = Reflect.registerInstantiatableClass(fqcn, runtimeClass, constructors)
|
| def loadableModuleClass[T](
| fqcn: String,
| runtimeClass: Class[T],
| loadModuleFun: () => T
| ): Unit = Reflect.registerLoadableModuleClass(fqcn, runtimeClass, loadModuleFun)
|}
|""".stripMargin

private def registerClass(fqcn: String): String =
s""" _root_.scala.scalanative.reflect.$shimObject.instantiatableClass(
| "$fqcn",
| classOf[$fqcn],
| Array[(Array[Class[?]], Array[Any] => Any)](
| (Array.empty[Class[?]], (_: Array[Any]) => new $fqcn())
| )
| )""".stripMargin

private def registerModule(fqcn: String): String =
s""" _root_.scala.scalanative.reflect.$shimObject.loadableModuleClass(
| "$fqcn$$",
| $fqcn.getClass.asInstanceOf[Class[$fqcn.type]],
| () => $fqcn
| )""".stripMargin

/** @param classes
* dotted fully-qualified names of instantiable test classes (with a public no-arg constructor)
* @param modules
* dotted fully-qualified names of test modules (objects), without the trailing `$`
* @return
* the content of the generated entry point source file
*/
def mainSource(classes: Seq[String], modules: Seq[String]): String = {
val registrations = (classes.map(registerClass) ++ modules.map(registerModule))
.mkString("\n")
s"""// Generated by Scala CLI - registers test suites for reflective instantiation on Scala
|// Native, then delegates to the Scala Native test interface main.
|object $mainClassName {
| def main(args: Array[String]): Unit = {
| register()
| _root_.scala.scalanative.testinterface.TestMain.main(args)
| }
| private def register(): Unit = {
|$registrations
| }
|}
|""".stripMargin
}
}
93 changes: 87 additions & 6 deletions modules/cli/src/main/scala/scala/cli/commands/test/Test.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ package scala.cli.commands.test
import caseapp.*
import caseapp.core.help.HelpFormat

import java.nio.charset.StandardCharsets
import java.nio.file.Path

import scala.build.*
import scala.build.EitherCps.{either, value}
import scala.build.Ops.*
import scala.build.errors.{BuildException, CompositeBuildException}
import scala.build.internal.{Constants, Runner}
import scala.build.input.VirtualScalaFile
import scala.build.internal.{Constants, NativeReflectTestMain, Runner}
import scala.build.internals.ConsoleUtils.ScalaCliConsole
import scala.build.internals.ConsoleUtils.ScalaCliConsole.warnPrefix
import scala.build.options.{BuildOptions, JavaOpt, Platform, Scope}
import scala.build.testrunner.{AsmTestRunner, Logger as TestRunnerLogger}
import scala.cli.CurrentParams
Expand All @@ -23,6 +26,7 @@ import scala.cli.config.Keys
import scala.cli.packaging.Library.fullClassPathMaybeAsJar
import scala.cli.util.ArgHelpers.*
import scala.cli.util.ConfigDbUtils
import scala.util.control.NonFatal

object Test extends ScalaCommand[TestOptions] {
override def group: String = HelpCommandGroup.Main.toString
Expand Down Expand Up @@ -80,6 +84,77 @@ object Test extends ScalaCommand[TestOptions] {
configDb.get(Keys.actions).getOrElse(None)
)

/** Some test frameworks stopped annotating their suite base classes with
* `@EnableReflectiveInstantiation` on Scala Native (e.g. munit 1.3.1, see
* [[https://github.com/scalameta/munit/pull/1092]]). Without that annotation the native
* runtime cannot reflectively instantiate the user's test suites, so the test binary runs zero
* tests.
*
* To stay robust regardless of whether the framework annotates its suites, we discover the
* test suites compiled into the Native test scope, generate a tiny entry point that registers
* them with [[scala.scalanative.reflect.Reflect]] and delegates to the regular native test
* main, then rebuild so the generated source is linked into the binary.
*
* @return
* the builds to test (rebuilt with the generated entry point when applicable) and whether
* the generated entry point should be used as the native test main class.
*/
def withNativeReflectionRegistration(builds: Builds): (Builds, Boolean) = {
val testBuilds = builds.map.collect { case (key, s) if key.scope == Scope.Test => s }
val nativeTestBuilds =
testBuilds.filter(_.options.platform.value == Platform.Native).toVector
// Only safe to inject the native-only registration source when every test build is Native;
// otherwise the generated `scala.scalanative.*` references would break a JVM/JS test scope.
val onlyNativeTestBuilds = nativeTestBuilds.nonEmpty &&
nativeTestBuilds.size == testBuilds.size
(if onlyNativeTestBuilds then nativeTestBuilds.headOption else None) match {
case None => (builds, false)
case Some(build) =>
val output = build.output
val trLogger = TestRunnerLogger(logger.verbosity)
val (classes, modules) =
try AsmTestRunner.instantiableClasses(Seq(output.toNIO), trLogger)
catch {
case NonFatal(e) =>
logger.debug(s"Could not discover Scala Native test suites: ${e.getMessage}")
(Seq.empty[String], Seq.empty[String])
}
if classes.isEmpty && modules.isEmpty
then (builds, false)
else {
val shimFile = VirtualScalaFile(
NativeReflectTestMain.shimSource.getBytes(StandardCharsets.UTF_8),
NativeReflectTestMain.shimFileName
)
val mainFile = VirtualScalaFile(
NativeReflectTestMain.mainSource(classes, modules).getBytes(StandardCharsets.UTF_8),
NativeReflectTestMain.mainFileName
)
Build.build(
inputs = inputs.add(Seq(shimFile, mainFile)),
options = initialBuildOptions,
compilerMaker = compilerMaker,
docCompilerMakerOpt = None,
logger = logger,
crossBuilds = cross,
buildTests = true,
partial = None,
actionableDiagnostics = actionableDiagnostics
) match {
case Right(rebuilt) if !rebuilt.anyFailed => (rebuilt, true)
case other =>
other.left.foreach(e =>
logger.debug(s"Scala Native test reflection rebuild failed: ${e.getMessage}")
)
logger.message(
s"$warnPrefix could not register test suites for Scala Native reflective instantiation; tests may not run."
)
(builds, false)
}
}
}
}

/** Runs the tests via [[testOnce]] if build was successful
* @param builds
* build results, checked for failures
Expand All @@ -93,9 +168,10 @@ object Test extends ScalaCommand[TestOptions] {
sys.exit(1)
}
else {
val optionsKeys = builds.map.keys.toVector.map(_.optionsKey).distinct
val (effectiveBuilds, useNativeReflectionMain) = withNativeReflectionRegistration(builds)
val optionsKeys = effectiveBuilds.map.keys.toVector.map(_.optionsKey).distinct
val builds0 = optionsKeys.flatMap { optionsKey =>
builds.map.get(CrossKey(optionsKey, Scope.Test))
effectiveBuilds.map.get(CrossKey(optionsKey, Scope.Test))
}
val buildsLen = builds0.length
val printBeforeAfterMessages =
Expand All @@ -116,7 +192,8 @@ object Test extends ScalaCommand[TestOptions] {
args.unparsed,
logger,
allowExecve = allowExit && buildsLen <= 1,
asJar = options.shared.asJar
asJar = options.shared.asJar,
nativeReflectionMain = useNativeReflectionMain
)
if (printBeforeAfterMessages && idx < buildsLen - 1)
System.err.println()
Expand Down Expand Up @@ -188,7 +265,8 @@ object Test extends ScalaCommand[TestOptions] {
args: Seq[String],
logger: Logger,
asJar: Boolean,
allowExecve: Boolean
allowExecve: Boolean,
nativeReflectionMain: Boolean
): Either[BuildException, Int] = either {

val predefinedTestFrameworks = build.options.testOptions.frameworks
Expand Down Expand Up @@ -227,10 +305,13 @@ object Test extends ScalaCommand[TestOptions] {
}.flatten
}
case Platform.Native =>
val nativeMainClass =
if nativeReflectionMain then NativeReflectTestMain.mainClassName
else "scala.scalanative.testinterface.TestMain"
value {
Run.withNativeLauncher(
Seq(build),
"scala.scalanative.testinterface.TestMain",
nativeMainClass,
logger
) { launcher =>
Runner.testNative(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -406,18 +406,24 @@ abstract class TestTestDefinitions extends ScalaCliSuite with TestScalaVersionAr
expect(output.contains("Hello from bar"))
}
}
def successfulNativeTest(): Unit =
successfulTestInputs().fromRoot { root =>
val output = os.proc(TestUtil.cli, "test", extraOptions, ".", "--native")
.call(cwd = root)
.out.text()
expect(output.contains("Hello from tests"))
def successfulNativeTest(usedMunitVersion: String = munitVersion): Unit =
successfulTestInputs(s"//> using dep org.scalameta::munit::$usedMunitVersion").fromRoot {
root =>
val output = os.proc(TestUtil.cli, "test", extraOptions, ".", "--native")
.call(cwd = root)
.out.text()
expect(output.contains("Hello from tests"))
}

test("successful test native") {
TestUtil.retryOnCi()(successfulNativeTest())
}

// test for MUnit behaviour pre-munit#1092: https://github.com/scalameta/munit/pull/1092
test("successful test native (munit 1.3.0, pre-#1092)") {
TestUtil.retryOnCi()(successfulNativeTest(usedMunitVersion = "1.3.0"))
}

test("failing test") {
failingTestInputs.fromRoot { root =>
val output = os.proc(TestUtil.cli, "test", extraOptions, ".")
Expand Down
Loading
Loading