Skip to content

Commit

Permalink
Extract bsp testing utils to a helper trait (#3092)
Browse files Browse the repository at this point in the history
  • Loading branch information
Gedochao authored Aug 14, 2024
1 parent 49a0b02 commit 152d717
Show file tree
Hide file tree
Showing 2 changed files with 302 additions and 286 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
package scala.cli.integration

import ch.epfl.scala.bsp4j as b
import com.eed3si9n.expecty.Expecty.expect
import com.github.plokhotnyuk.jsoniter_scala.core.*
import com.github.plokhotnyuk.jsoniter_scala.macros.*
import com.google.gson.Gson
import com.google.gson.internal.LinkedTreeMap
import org.eclipse.lsp4j.jsonrpc.messages.ResponseError

import java.net.URI
import java.util.concurrent.{ExecutorService, ScheduledExecutorService}

import scala.annotation.tailrec
import scala.cli.integration.BspSuite.{Details, detailsCodec}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration.*
import scala.concurrent.{Await, Future, Promise}
import scala.jdk.CollectionConverters.*
import scala.util.control.NonFatal
import scala.util.{Failure, Success, Try}

trait BspSuite { _: ScalaCliSuite =>
protected def extraOptions: Seq[String]
def initParams(root: os.Path): b.InitializeBuildParams =
new b.InitializeBuildParams(
"Scala CLI ITs",
"0",
Constants.bspVersion,
root.toNIO.toUri.toASCIIString,
new b.BuildClientCapabilities(List("java", "scala").asJava)
)

val pool: ExecutorService = TestUtil.threadPool("bsp-tests-jsonrpc", 4)
val scheduler: ScheduledExecutorService = TestUtil.scheduler("bsp-tests-scheduler")

def completeIn(duration: FiniteDuration): Future[Unit] = {
val p = Promise[Unit]()
scheduler.schedule(
new Runnable {
def run(): Unit =
try p.success(())
catch {
case t: Throwable =>
System.err.println(s"Caught $t while trying to complete timer, ignoring it")
}
},
duration.length,
duration.unit
)
p.future
}

override def afterAll(): Unit = {
pool.shutdown()
}

protected def extractMainTargets(targets: Seq[b.BuildTargetIdentifier]): b.BuildTargetIdentifier =
targets.collectFirst {
case t if !t.getUri.contains("-test") => t
}.get

protected def extractTestTargets(targets: Seq[b.BuildTargetIdentifier]): b.BuildTargetIdentifier =
targets.collectFirst {
case t if t.getUri.contains("-test") => t
}.get

def withBsp[T](
inputs: TestInputs,
args: Seq[String],
attempts: Int = if (TestUtil.isCI) 3 else 1,
pauseDuration: FiniteDuration = 5.seconds,
bspOptions: List[String] = List.empty,
bspEnvs: Map[String, String] = Map.empty,
reuseRoot: Option[os.Path] = None,
stdErrOpt: Option[os.RelPath] = None,
extraOptionsOverride: Seq[String] = extraOptions
)(
f: (
os.Path,
TestBspClient,
b.BuildServer & b.ScalaBuildServer & b.JavaBuildServer & b.JvmBuildServer
) => Future[T]
): T = {

def attempt(): Try[T] = Try {
val inputsRoot = inputs.root()
val root = reuseRoot.getOrElse(inputsRoot)
val stdErrPathOpt: Option[os.ProcessOutput] = stdErrOpt.map(path => inputsRoot / path)
val stderr: os.ProcessOutput = stdErrPathOpt.getOrElse(os.Inherit)

val proc = os.proc(TestUtil.cli, "bsp", bspOptions ++ extraOptionsOverride, args)
.spawn(cwd = root, stderr = stderr, env = bspEnvs)
var remoteServer: b.BuildServer & b.ScalaBuildServer & b.JavaBuildServer & b.JvmBuildServer =
null

val bspServerExited = Promise[Unit]()
val t = new Thread("bsp-server-watcher") {
setDaemon(true)
override def run() = {
proc.join()
bspServerExited.success(())
}
}
t.start()

def whileBspServerIsRunning[T](f: Future[T]): Future[T] = {
val ex = new Exception
Future.firstCompletedOf(Seq(f.map(Right(_)), bspServerExited.future.map(Left(_))))
.transform {
case Success(Right(t)) => Success(t)
case Success(Left(())) => Failure(new Exception("BSP server exited too early", ex))
case Failure(ex) => Failure(ex)
}
}

try {
val (localClient, remoteServer0, _) =
TestBspClient.connect(proc.stdout, proc.stdin, pool)
remoteServer = remoteServer0
Await.result(
whileBspServerIsRunning(remoteServer.buildInitialize(initParams(root)).asScala),
Duration.Inf
)
Await.result(whileBspServerIsRunning(f(root, localClient, remoteServer)), Duration.Inf)
}
finally {
if (remoteServer != null)
try
Await.result(whileBspServerIsRunning(remoteServer.buildShutdown().asScala), 20.seconds)
catch {
case NonFatal(e) =>
System.err.println(s"Ignoring $e while shutting down BSP server")
}
proc.join(2.seconds.toMillis)
proc.destroy()
proc.join(2.seconds.toMillis)
proc.destroyForcibly()
}
}

@tailrec
def helper(count: Int): T =
attempt() match {
case Success(t) => t
case Failure(ex) =>
if (count <= 1)
throw new Exception(ex)
else {
System.err.println(s"Caught $ex, trying again in $pauseDuration")
Thread.sleep(pauseDuration.toMillis)
helper(count - 1)
}
}

helper(attempts)
}

def checkTargetUri(root: os.Path, uri: String): Unit = {
val baseUri =
TestUtil.normalizeUri((root / Constants.workspaceDirName).toNIO.toUri.toASCIIString)
.stripSuffix("/")
val expectedPrefixes = Set(
baseUri + "?id=",
baseUri + "/?id="
)
expect(expectedPrefixes.exists(uri.startsWith))
}

protected def readBspConfig(root: os.Path): Details = {
val bspFile = root / ".bsp" / "scala-cli.json"
expect(os.isFile(bspFile))
val content = os.read.bytes(bspFile)
// check that we can decode the connection details
readFromArray(content)(detailsCodec)
}

protected def checkIfBloopProjectIsInitialised(
root: os.Path,
buildTargetsResp: b.WorkspaceBuildTargetsResult
): Unit = {
val targets = buildTargetsResp.getTargets.asScala.map(_.getId).toSeq
expect(targets.length == 2)

val bloopProjectNames = targets.map { target =>
val targetUri = TestUtil.normalizeUri(target.getUri)
checkTargetUri(root, targetUri)
new URI(targetUri).getQuery.stripPrefix("id=")
}

val bloopDir = root / Constants.workspaceDirName / ".bloop"
expect(os.isDir(bloopDir))

bloopProjectNames.foreach { bloopProjectName =>
val bloopProjectJsonPath = bloopDir / s"$bloopProjectName.json"
expect(os.isFile(bloopProjectJsonPath))
}
}

protected def extractDiagnosticsParams(
relevantFilePath: os.Path,
localClient: TestBspClient
): b.PublishDiagnosticsParams = {
val params = localClient.latestDiagnostics().getOrElse {
sys.error("No diagnostics found")
}
expect {
TestUtil.normalizeUri(params.getTextDocument.getUri) == TestUtil.normalizeUri(
relevantFilePath.toNIO.toUri.toASCIIString
)
}
params
}

protected def checkDiagnostic(
diagnostic: b.Diagnostic,
expectedMessage: String,
expectedSeverity: b.DiagnosticSeverity,
expectedStartLine: Int,
expectedStartCharacter: Int,
expectedEndLine: Int,
expectedEndCharacter: Int,
expectedSource: Option[String] = None,
strictlyCheckMessage: Boolean = true
): Unit = {
expect(diagnostic.getSeverity == expectedSeverity)
expect(diagnostic.getRange.getStart.getLine == expectedStartLine)
expect(diagnostic.getRange.getStart.getCharacter == expectedStartCharacter)
expect(diagnostic.getRange.getEnd.getLine == expectedEndLine)
expect(diagnostic.getRange.getEnd.getCharacter == expectedEndCharacter)
val message = TestUtil.removeAnsiColors(diagnostic.getMessage)
if (strictlyCheckMessage)
assertNoDiff(message, expectedMessage)
else
expect(message.contains(expectedMessage))
for (es <- expectedSource)
expect(diagnostic.getSource == es)
}

protected def checkScalaAction(
diagnostic: b.Diagnostic,
expectedActionsSize: Int,
expectedTitle: String,
expectedChanges: Int,
expectedStartLine: Int,
expectedStartCharacter: Int,
expectedEndLine: Int,
expectedEndCharacter: Int,
expectedNewText: String
): Unit = {
expect(diagnostic.getDataKind == "scala")

val gson = new com.google.gson.Gson()

val scalaDiagnostic: b.ScalaDiagnostic = gson.fromJson(
diagnostic.getData.toString,
classOf[b.ScalaDiagnostic]
)

val actions = scalaDiagnostic.getActions.asScala

expect(actions.size == expectedActionsSize)

val action = actions.head
expect(action.getTitle == expectedTitle)

val edit = action.getEdit
expect(edit.getChanges.asScala.size == expectedChanges)
val change = edit.getChanges.asScala.head

val expectedRange = new b.Range(
new b.Position(expectedStartLine, expectedStartCharacter),
new b.Position(expectedEndLine, expectedEndCharacter)
)
expect(change.getRange == expectedRange)
expect(change.getNewText == expectedNewText)
}

protected def extractWorkspaceReloadResponse(workspaceReloadResult: AnyRef)
: Option[ResponseError] =
workspaceReloadResult match {
case gsonMap: LinkedTreeMap[?, ?] if !gsonMap.isEmpty =>
val gson = new Gson()
Some(gson.fromJson(gson.toJson(gsonMap), classOf[ResponseError]))
case _ => None
}
}

object BspSuite {
final protected case class Details(
name: String,
version: String,
bspVersion: String,
argv: List[String],
languages: List[String]
)
protected val detailsCodec: JsonValueCodec[Details] = JsonCodecMaker.make
}
Loading

0 comments on commit 152d717

Please sign in to comment.