diff --git a/modules/build/src/main/scala/scala/build/internal/NativeReflectTestMain.scala b/modules/build/src/main/scala/scala/build/internal/NativeReflectTestMain.scala new file mode 100644 index 0000000000..7c1d842eab --- /dev/null +++ b/modules/build/src/main/scala/scala/build/internal/NativeReflectTestMain.scala @@ -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 + } +} diff --git a/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala b/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala index c222cf330f..8587e1427d 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala @@ -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 @@ -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 @@ -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 @@ -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 = @@ -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() @@ -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 @@ -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( diff --git a/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala index 4b15e728d8..f6250a009f 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala @@ -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, ".") diff --git a/modules/test-runner/src/main/scala/scala/build/testrunner/AsmTestRunner.scala b/modules/test-runner/src/main/scala/scala/build/testrunner/AsmTestRunner.scala index 0781a9285f..d0f264bb5a 100644 --- a/modules/test-runner/src/main/scala/scala/build/testrunner/AsmTestRunner.scala +++ b/modules/test-runner/src/main/scala/scala/build/testrunner/AsmTestRunner.scala @@ -281,16 +281,20 @@ object AsmTestRunner { } private class TestClassChecker extends asm.ClassVisitor(asm.Opcodes.ASM9) { - private var nameOpt = Option.empty[String] - private var publicConstructorCount0 = 0 - private var isInterfaceOpt = Option.empty[Boolean] - private var isAbstractOpt = Option.empty[Boolean] - private var implements0 = List.empty[String] - def name: String = nameOpt.getOrElse(sys.error("Class not visited")) - def publicConstructorCount: Int = publicConstructorCount0 - def implements: Seq[String] = implements0 - def isAbstract: Boolean = isAbstractOpt.getOrElse(sys.error("Class not visited")) - def isInterface: Boolean = isInterfaceOpt.getOrElse(sys.error("Class not visited")) + private var nameOpt = Option.empty[String] + private var publicConstructorCount0 = 0 + private var isInterfaceOpt = Option.empty[Boolean] + private var isAbstractOpt = Option.empty[Boolean] + private var isPublicOpt = Option.empty[Boolean] + private var hasPublicNoArgConstructor0 = false + private var implements0 = List.empty[String] + def name: String = nameOpt.getOrElse(sys.error("Class not visited")) + def publicConstructorCount: Int = publicConstructorCount0 + def implements: Seq[String] = implements0 + def isAbstract: Boolean = isAbstractOpt.getOrElse(sys.error("Class not visited")) + def isInterface: Boolean = isInterfaceOpt.getOrElse(sys.error("Class not visited")) + def isPublic: Boolean = isPublicOpt.getOrElse(sys.error("Class not visited")) + def hasPublicNoArgConstructor: Boolean = hasPublicNoArgConstructor0 override def visit( version: Int, access: Int, @@ -301,6 +305,7 @@ object AsmTestRunner { ): Unit = { isInterfaceOpt = Some((access & asm.Opcodes.ACC_INTERFACE) != 0) isAbstractOpt = Some((access & asm.Opcodes.ACC_ABSTRACT) != 0) + isPublicOpt = Some((access & asm.Opcodes.ACC_PUBLIC) != 0) nameOpt = Some(name) implements0 = Option(superName).toList ::: implements0 if (interfaces.nonEmpty) @@ -314,12 +319,85 @@ object AsmTestRunner { exceptions: Array[String] ): asm.MethodVisitor = { def isPublic = (access & asm.Opcodes.ACC_PUBLIC) != 0 - if (name == "" && isPublic) + if name == "" && isPublic then { publicConstructorCount0 += 1 + if descriptor == "()V" + then hasPublicNoArgConstructor0 = true + } null } } + /** Names of classes/modules in the given class path that Scala CLI can register for reflective + * instantiation on Scala Native, when a test framework's suite base class lacks the + * `@EnableReflectiveInstantiation` annotation (e.g. munit 1.3.1, see + * [[https://github.com/scalameta/munit/pull/1092]]). + * + * Only public, top-level, concrete definitions are kept, so the generated registration code is + * guaranteed to compile and reference them. + * + * @return + * a tuple of (instantiable class names, loadable module names), both as dotted fully-qualified + * names (module names without the trailing `$`). + */ + private enum Instantiable: + case Cls(name: String) + case Mod(name: String) + + /** Reads the bytecode of a single class into a [[TestClassChecker]], closing the stream when + * done. IO errors are logged at debug level and yield `None`; other errors propagate. + */ + private def readChecker( + openStream: () => InputStream, + rawName: String, + logger: Logger + ): Option[TestClassChecker] = + scala.util.Using(openStream()) { stream => + val checker = new TestClassChecker + new asm.ClassReader(stream).accept(checker, 0) + checker + } match { + case scala.util.Success(checker) => Some(checker) + case scala.util.Failure(e: java.io.IOException) => + logger.debug(s"Could not read bytecode for $rawName: ${e.getMessage}") + None + case scala.util.Failure(e) => throw e + } + + /** Classifies a visited class as an instantiable class or a loadable module, or discards it. */ + private def classify(rawName: String, checker: TestClassChecker): Option[Instantiable] = + val isModuleName = rawName.endsWith("$") + val dottedName = rawName.replace('/', '.').replace('\\', '.') + if !(checker.isPublic && !checker.isAbstract && !checker.isInterface) then None + else if isModuleName then Some(Instantiable.Mod(dottedName.stripSuffix("$"))) + else if checker.hasPublicNoArgConstructor then Some(Instantiable.Cls(dottedName)) + else None + + def instantiableClasses( + classPath: Seq[Path], + logger: Logger + ): (Seq[String], Seq[String]) = + val found = + listClassesByteCode(classPath, keepJars = false, logger) + .flatMap { (rawName, openStream) => + val baseName = rawName.stripSuffix("$") + // skip module-info, inner/anonymous classes and lambdas (they can't be referenced by a + // stable name), as well as package objects and Scala 3 top-level holders (`*$package`). + val isTopLevel = + !rawName.contains("module-info") && + !baseName.contains("$") && + !baseName.endsWith("/package") && + baseName != "package" + if isTopLevel then readChecker(openStream, rawName, logger).flatMap(classify(rawName, _)) + else None + } + .toVector + .distinct + ( + found.collect { case Instantiable.Cls(n) => n }, + found.collect { case Instantiable.Mod(n) => n } + ) + def taskDefs( classPath: Seq[Path], keepJars: Boolean, diff --git a/project/deps/package.mill b/project/deps/package.mill index 7ef86eb917..3b63224593 100644 --- a/project/deps/package.mill +++ b/project/deps/package.mill @@ -202,7 +202,7 @@ object Deps { def metaconfigTypesafe = mvn"org.scalameta::metaconfig-typesafe-config:0.18.2" .exclude(("org.scala-lang", "scala-compiler")) - def munit = mvn"org.scalameta::munit:1.2.2" + def munit = mvn"org.scalameta::munit:1.3.1" def nativeTestRunner = mvn"org.scala-native::test-runner:${Versions.scalaNative}" def nativeTools = mvn"org.scala-native::tools:${Versions.scalaNative}" def osLib = mvn"com.lihaoyi::os-lib:0.11.8"