Skip to content
Draft
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
77 changes: 77 additions & 0 deletions modules/build/src/main/scala/scala/build/Bloop.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import scala.jdk.CollectionConverters.*

object Bloop {

final case class BloopTestOptions(
testOnly: Option[String] = None,
selectedTestClasses: Seq[String] = Nil,
extraArgs: Seq[String] = Nil
)

private object BrokenPipeInCauses {
@tailrec
def unapply(ex: Throwable): Option[IOException] =
Expand Down Expand Up @@ -68,6 +74,77 @@ object Bloop {
)
Left(ex)
}

def test(
projectName: String,
buildServer: BuildServer,
logger: Logger,
buildTargetsTimeout: FiniteDuration,
testOptions: BloopTestOptions = BloopTestOptions()
): Either[Throwable, Int] =
try retry()(logger) {
logger.debug("Listing BSP build targets for test")
val results = buildServer.workspaceBuildTargets()
.get(buildTargetsTimeout.length, buildTargetsTimeout.unit)
val buildTargetOpt = results.getTargets.asScala.find(_.getDisplayName == projectName)

val buildTarget = buildTargetOpt.getOrElse {
throw new Exception(
s"Expected to find project '$projectName' in build targets (only got ${results.getTargets
.asScala.map("'" + _.getDisplayName + "'").mkString(", ")})"
)
}

logger.debug(s"Testing $projectName with Bloop")
val testParams = buildTestParams(buildTarget.getId, testOptions)
val testRes = buildServer.buildTargetTest(testParams).get()

val statusCode = testRes.getStatusCode
val exitCode = statusCode match {
case bsp4j.StatusCode.OK => 0
case bsp4j.StatusCode.ERROR => 1
case bsp4j.StatusCode.CANCELLED => 1
}
logger.debug(if (exitCode == 0) "Tests succeeded" else "Tests failed")
Right(exitCode)
}
catch {
case ex @ BrokenPipeInCauses(_) =>
logger.debug(s"Caught $ex while exchanging with Bloop server, assuming Bloop server exited")
Left(ex)
case ex: ExecutionException =>
logger.debug(
s"Caught $ex while exchanging with Bloop server, you may consider restarting the build server"
)
Left(ex)
}

private def buildTestParams(
buildTargetId: bsp4j.BuildTargetIdentifier,
testOptions: BloopTestOptions
): bsp4j.TestParams = {
val params = new bsp4j.TestParams(List(buildTargetId).asJava)
val classes = testOptions.selectedTestClasses
if classes.nonEmpty then
if testOptions.extraArgs.nonEmpty then {
val selections = classes.map { className =>
new bsp4j.ScalaTestSuiteSelection(className, testOptions.extraArgs.asJava)
}
val suites = new bsp4j.ScalaTestSuites(
selections.asJava,
List.empty[String].asJava,
List.empty[String].asJava
)
params.setDataKind(bsp4j.TestParamsDataKind.SCALA_TEST_SUITES_SELECTION)
params.setData(suites)
}
else {
params.setDataKind(bsp4j.TestParamsDataKind.SCALA_TEST_SUITES)
params.setData(classes.asJava)
}
params
}

def bloopClassPath(
dep: AnyDependency,
params: ScalaParameters,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package scala.build

import ch.epfl.scala.bsp4j

class BloopTestBuildClient(logger: Logger)
extends ConsoleBloopBuildClient(logger, keepDiagnostics = false) {
private var testsRanCount: Int = 0

def testsRan: Int = testsRanCount

override def onBuildLogMessage(params: bsp4j.LogMessageParams): Unit = {
logger.debug("Received onBuildLogMessage from bloop: " + params)
System.out.println(params.getMessage)
}

override def onBuildTaskStart(params: bsp4j.TaskStartParams): Unit = {
logger.debug("Received onBuildTaskStart from bloop: " + params)
Option(params.getMessage).foreach(System.out.println)
}

override def onBuildTaskFinish(params: bsp4j.TaskFinishParams): Unit = {
logger.debug("Received onBuildTaskFinish from bloop: " + params)
Option(params.getMessage).foreach(System.out.println)
if params.getDataKind == "test-report" then
params.getData match {
case report: bsp4j.TestReport =>
testsRanCount += report.getPassed + report.getFailed + report.getIgnored +
report.getCancelled + report.getSkipped
case _ =>
}
}

override def onBuildShowMessage(params: bsp4j.ShowMessageParams): Unit = {
logger.debug("Received onBuildShowMessage from bloop: " + params)
System.out.println(params.getMessage)
}
}

object BloopTestBuildClient {
def create(logger: Logger): BloopTestBuildClient =
new BloopTestBuildClient(logger)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package scala.build

import java.nio.file.Path
import java.util.regex.Pattern

import scala.build.testrunner.FrameworkUtils.listClasses
import scala.build.testrunner.Logger as TestRunnerLogger

object BloopTestClassDiscovery {

/** Glob pattern matching only `*` wildcards, same semantics as the test-runner. */
def globPattern(expr: String): Pattern = {
val parts = expr.split("\\*", -1)
val b = new StringBuilder()
for (i <- parts.indices) {
if (i != 0) b.append(".*")
if (parts(i).nonEmpty) b.append(Pattern.quote(parts(i).replaceAll("\n", "\\n")))
}
Pattern.compile(b.toString)
}

def matchingTestClasses(
classPath: Seq[Path],
testOnlyGlob: String,
logger: Logger
): Seq[String] = {
val pattern = globPattern(testOnlyGlob)
listClasses(classPath, keepJars = false, TestRunnerLogger(logger.verbosity))
.filter(pattern.matcher(_).matches)
.toVector
.sorted
}
}
93 changes: 93 additions & 0 deletions modules/build/src/main/scala/scala/build/BloopTestRunner.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package scala.build

import bloop.rifle.{BloopRifleConfig, BloopServer}

import scala.build.EitherCps.{either, value}
import scala.build.errors.BuildException
import scala.build.internal.Constants
import scala.concurrent.duration.DurationInt
import scala.util.Try

object BloopTestRunner {
private final class BloopTestFailedError(message: String, cause: Throwable = null)
extends BuildException(message, cause = cause)

def run(
build: Build.Successful,
bloopConfig: BloopRifleConfig,
threads: BuildThreads,
logger: Logger,
requireTests: Boolean,
args: Seq[String]
): Either[BuildException, Int] = either {
val buildClient = BloopTestBuildClient.create(logger)
val workspace = build.inputs.workspace / Constants.workspaceDirName
val classesDir = Build.classesRootDir(build.inputs.workspace, build.inputs.projectName)

val server = value {
Try {
retry()(logger) {
BloopServer.buildServer(
bloopConfig,
"scala-cli",
Constants.version,
workspace.toNIO,
classesDir.toNIO,
buildClient,
threads.bloop,
logger.bloopRifleLogger
)
}
}.toEither.left.map(ex =>
new BloopTestFailedError("Failed to connect to Bloop for running tests", ex)
)
}

try {
val testOptions = value(prepareTestOptions(build, args, logger))
val exitCode = value {
Bloop
.test(
build.project.projectName,
server.server,
logger,
20.seconds,
testOptions
)
.left
.map(ex => new BloopTestFailedError("Bloop test execution failed", ex))
}
if requireTests && buildClient.testsRan == 0 && testOptions.selectedTestClasses.isEmpty
then {
logger.error("Error: no tests were run.")
1
}
else exitCode
}
finally server.shutdown()
}

private def prepareTestOptions(
build: Build.Successful,
args: Seq[String],
logger: Logger
): Either[BuildException, Bloop.BloopTestOptions] = {
val testOnly = build.options.testOptions.testOnly
val selectedTestClasses = testOnly match {
case None => Nil
case Some(glob) =>
BloopTestClassDiscovery.matchingTestClasses(
build.fullClassPath.map(_.toNIO),
glob,
logger
)
}
Right(
Bloop.BloopTestOptions(
testOnly = testOnly,
selectedTestClasses = selectedTestClasses,
extraArgs = args
)
)
}
}
9 changes: 8 additions & 1 deletion modules/build/src/main/scala/scala/build/Build.scala
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ object Build {
sources.resourceDirs ++ artifacts.compileClassPath
def fullClassPath: Seq[os.Path] = Seq(output) ++ dependencyClassPath
def fullCompileClassPath: Seq[os.Path] = fullClassPath ++ dependencyCompileClassPath
def isLegacyScala3: Boolean =
scalaParams.exists { params =>
params.scalaVersion.startsWith("3") &&
params.scalaVersion.coursierVersion < "3.3.0".coursierVersion
}
private lazy val mainClassesFoundInProject: Seq[String] = MainClass.find(output, logger).sorted
private lazy val mainClassesFoundOnExtraClasspath: Seq[String] =
options.classPathOptions.extraClassPath.flatMap(MainClass.find(_, logger)).sorted
Expand Down Expand Up @@ -1090,7 +1095,9 @@ object Build {
resourceDirs = sources.resourceDirs,
scope = scope,
javaHomeOpt = Option(options.javaHomeLocation().value),
javacOptions = javacOptions.toList
javacOptions = javacOptions.toList,
testFrameworkNames =
if scope == Scope.Test then options.testOptions.frameworks.map(_.value) else Nil
)
project
}
Expand Down
33 changes: 27 additions & 6 deletions modules/build/src/main/scala/scala/build/Project.scala
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ final case class Project(
resourceDirs: Seq[os.Path],
javaHomeOpt: Option[os.Path],
scope: Scope,
javacOptions: List[String]
javacOptions: List[String],
testFrameworkNames: Seq[String] = Nil
) {

import Project._
Expand All @@ -55,7 +56,8 @@ final case class Project(
directory.toNIO,
(directory / ".bloop" / projectName).toNIO,
classesDir.toNIO,
scope
scope,
testFrameworkNames
)
.copy(
workspaceDir = Some(workspace.toNIO),
Expand Down Expand Up @@ -169,12 +171,30 @@ object Project {
BloopConfig.Resolution(modules)
}

private def setProjectTestConfig(p: BloopConfig.Project): BloopConfig.Project =
private val jupiterFramework =
BloopConfig.TestFramework(List("com.github.sbt.junit.jupiter.api.JupiterFramework"))
private val zioTestFramework =
BloopConfig.TestFramework(List("zio.test.sbt.ZTestFramework"))
private val weaverFramework =
BloopConfig.TestFramework(List("weaver.framework.CatsEffect"))

private def bloopTestFrameworks(names: Seq[String]): List[BloopConfig.TestFramework] =
names match {
case Nil =>
BloopConfig.TestFramework.DefaultFrameworks ++
List(jupiterFramework, zioTestFramework, weaverFramework)
case ns => ns.map(name => BloopConfig.TestFramework(List(name))).toList
}

private def setProjectTestConfig(
p: BloopConfig.Project,
testFrameworkNames: Seq[String]
): BloopConfig.Project =
p.copy(
dependencies = List(p.name.stripSuffix("-test")),
test = Some(
BloopConfig.Test(
frameworks = BloopConfig.TestFramework.DefaultFrameworks,
frameworks = bloopTestFrameworks(testFrameworkNames),
options = BloopConfig.TestOptions.empty
)
),
Expand All @@ -186,7 +206,8 @@ object Project {
directory: Path,
out: Path,
classesDir: Path,
scope: Scope
scope: Scope,
testFrameworkNames: Seq[String]
): BloopConfig.Project = {
val project = BloopConfig.Project(
name = name,
Expand All @@ -210,7 +231,7 @@ object Project {
sourceGenerators = None
)
if (scope == Scope.Test)
setProjectTestConfig(project)
setProjectTestConfig(project, testFrameworkNames)
else project
}

Expand Down
Loading
Loading