Skip to content

Commit 152d717

Browse files
authored
Extract bsp testing utils to a helper trait (#3092)
1 parent 49a0b02 commit 152d717

File tree

2 files changed

+302
-286
lines changed

2 files changed

+302
-286
lines changed
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
package scala.cli.integration
2+
3+
import ch.epfl.scala.bsp4j as b
4+
import com.eed3si9n.expecty.Expecty.expect
5+
import com.github.plokhotnyuk.jsoniter_scala.core.*
6+
import com.github.plokhotnyuk.jsoniter_scala.macros.*
7+
import com.google.gson.Gson
8+
import com.google.gson.internal.LinkedTreeMap
9+
import org.eclipse.lsp4j.jsonrpc.messages.ResponseError
10+
11+
import java.net.URI
12+
import java.util.concurrent.{ExecutorService, ScheduledExecutorService}
13+
14+
import scala.annotation.tailrec
15+
import scala.cli.integration.BspSuite.{Details, detailsCodec}
16+
import scala.concurrent.ExecutionContext.Implicits.global
17+
import scala.concurrent.duration.*
18+
import scala.concurrent.{Await, Future, Promise}
19+
import scala.jdk.CollectionConverters.*
20+
import scala.util.control.NonFatal
21+
import scala.util.{Failure, Success, Try}
22+
23+
trait BspSuite { _: ScalaCliSuite =>
24+
protected def extraOptions: Seq[String]
25+
def initParams(root: os.Path): b.InitializeBuildParams =
26+
new b.InitializeBuildParams(
27+
"Scala CLI ITs",
28+
"0",
29+
Constants.bspVersion,
30+
root.toNIO.toUri.toASCIIString,
31+
new b.BuildClientCapabilities(List("java", "scala").asJava)
32+
)
33+
34+
val pool: ExecutorService = TestUtil.threadPool("bsp-tests-jsonrpc", 4)
35+
val scheduler: ScheduledExecutorService = TestUtil.scheduler("bsp-tests-scheduler")
36+
37+
def completeIn(duration: FiniteDuration): Future[Unit] = {
38+
val p = Promise[Unit]()
39+
scheduler.schedule(
40+
new Runnable {
41+
def run(): Unit =
42+
try p.success(())
43+
catch {
44+
case t: Throwable =>
45+
System.err.println(s"Caught $t while trying to complete timer, ignoring it")
46+
}
47+
},
48+
duration.length,
49+
duration.unit
50+
)
51+
p.future
52+
}
53+
54+
override def afterAll(): Unit = {
55+
pool.shutdown()
56+
}
57+
58+
protected def extractMainTargets(targets: Seq[b.BuildTargetIdentifier]): b.BuildTargetIdentifier =
59+
targets.collectFirst {
60+
case t if !t.getUri.contains("-test") => t
61+
}.get
62+
63+
protected def extractTestTargets(targets: Seq[b.BuildTargetIdentifier]): b.BuildTargetIdentifier =
64+
targets.collectFirst {
65+
case t if t.getUri.contains("-test") => t
66+
}.get
67+
68+
def withBsp[T](
69+
inputs: TestInputs,
70+
args: Seq[String],
71+
attempts: Int = if (TestUtil.isCI) 3 else 1,
72+
pauseDuration: FiniteDuration = 5.seconds,
73+
bspOptions: List[String] = List.empty,
74+
bspEnvs: Map[String, String] = Map.empty,
75+
reuseRoot: Option[os.Path] = None,
76+
stdErrOpt: Option[os.RelPath] = None,
77+
extraOptionsOverride: Seq[String] = extraOptions
78+
)(
79+
f: (
80+
os.Path,
81+
TestBspClient,
82+
b.BuildServer & b.ScalaBuildServer & b.JavaBuildServer & b.JvmBuildServer
83+
) => Future[T]
84+
): T = {
85+
86+
def attempt(): Try[T] = Try {
87+
val inputsRoot = inputs.root()
88+
val root = reuseRoot.getOrElse(inputsRoot)
89+
val stdErrPathOpt: Option[os.ProcessOutput] = stdErrOpt.map(path => inputsRoot / path)
90+
val stderr: os.ProcessOutput = stdErrPathOpt.getOrElse(os.Inherit)
91+
92+
val proc = os.proc(TestUtil.cli, "bsp", bspOptions ++ extraOptionsOverride, args)
93+
.spawn(cwd = root, stderr = stderr, env = bspEnvs)
94+
var remoteServer: b.BuildServer & b.ScalaBuildServer & b.JavaBuildServer & b.JvmBuildServer =
95+
null
96+
97+
val bspServerExited = Promise[Unit]()
98+
val t = new Thread("bsp-server-watcher") {
99+
setDaemon(true)
100+
override def run() = {
101+
proc.join()
102+
bspServerExited.success(())
103+
}
104+
}
105+
t.start()
106+
107+
def whileBspServerIsRunning[T](f: Future[T]): Future[T] = {
108+
val ex = new Exception
109+
Future.firstCompletedOf(Seq(f.map(Right(_)), bspServerExited.future.map(Left(_))))
110+
.transform {
111+
case Success(Right(t)) => Success(t)
112+
case Success(Left(())) => Failure(new Exception("BSP server exited too early", ex))
113+
case Failure(ex) => Failure(ex)
114+
}
115+
}
116+
117+
try {
118+
val (localClient, remoteServer0, _) =
119+
TestBspClient.connect(proc.stdout, proc.stdin, pool)
120+
remoteServer = remoteServer0
121+
Await.result(
122+
whileBspServerIsRunning(remoteServer.buildInitialize(initParams(root)).asScala),
123+
Duration.Inf
124+
)
125+
Await.result(whileBspServerIsRunning(f(root, localClient, remoteServer)), Duration.Inf)
126+
}
127+
finally {
128+
if (remoteServer != null)
129+
try
130+
Await.result(whileBspServerIsRunning(remoteServer.buildShutdown().asScala), 20.seconds)
131+
catch {
132+
case NonFatal(e) =>
133+
System.err.println(s"Ignoring $e while shutting down BSP server")
134+
}
135+
proc.join(2.seconds.toMillis)
136+
proc.destroy()
137+
proc.join(2.seconds.toMillis)
138+
proc.destroyForcibly()
139+
}
140+
}
141+
142+
@tailrec
143+
def helper(count: Int): T =
144+
attempt() match {
145+
case Success(t) => t
146+
case Failure(ex) =>
147+
if (count <= 1)
148+
throw new Exception(ex)
149+
else {
150+
System.err.println(s"Caught $ex, trying again in $pauseDuration")
151+
Thread.sleep(pauseDuration.toMillis)
152+
helper(count - 1)
153+
}
154+
}
155+
156+
helper(attempts)
157+
}
158+
159+
def checkTargetUri(root: os.Path, uri: String): Unit = {
160+
val baseUri =
161+
TestUtil.normalizeUri((root / Constants.workspaceDirName).toNIO.toUri.toASCIIString)
162+
.stripSuffix("/")
163+
val expectedPrefixes = Set(
164+
baseUri + "?id=",
165+
baseUri + "/?id="
166+
)
167+
expect(expectedPrefixes.exists(uri.startsWith))
168+
}
169+
170+
protected def readBspConfig(root: os.Path): Details = {
171+
val bspFile = root / ".bsp" / "scala-cli.json"
172+
expect(os.isFile(bspFile))
173+
val content = os.read.bytes(bspFile)
174+
// check that we can decode the connection details
175+
readFromArray(content)(detailsCodec)
176+
}
177+
178+
protected def checkIfBloopProjectIsInitialised(
179+
root: os.Path,
180+
buildTargetsResp: b.WorkspaceBuildTargetsResult
181+
): Unit = {
182+
val targets = buildTargetsResp.getTargets.asScala.map(_.getId).toSeq
183+
expect(targets.length == 2)
184+
185+
val bloopProjectNames = targets.map { target =>
186+
val targetUri = TestUtil.normalizeUri(target.getUri)
187+
checkTargetUri(root, targetUri)
188+
new URI(targetUri).getQuery.stripPrefix("id=")
189+
}
190+
191+
val bloopDir = root / Constants.workspaceDirName / ".bloop"
192+
expect(os.isDir(bloopDir))
193+
194+
bloopProjectNames.foreach { bloopProjectName =>
195+
val bloopProjectJsonPath = bloopDir / s"$bloopProjectName.json"
196+
expect(os.isFile(bloopProjectJsonPath))
197+
}
198+
}
199+
200+
protected def extractDiagnosticsParams(
201+
relevantFilePath: os.Path,
202+
localClient: TestBspClient
203+
): b.PublishDiagnosticsParams = {
204+
val params = localClient.latestDiagnostics().getOrElse {
205+
sys.error("No diagnostics found")
206+
}
207+
expect {
208+
TestUtil.normalizeUri(params.getTextDocument.getUri) == TestUtil.normalizeUri(
209+
relevantFilePath.toNIO.toUri.toASCIIString
210+
)
211+
}
212+
params
213+
}
214+
215+
protected def checkDiagnostic(
216+
diagnostic: b.Diagnostic,
217+
expectedMessage: String,
218+
expectedSeverity: b.DiagnosticSeverity,
219+
expectedStartLine: Int,
220+
expectedStartCharacter: Int,
221+
expectedEndLine: Int,
222+
expectedEndCharacter: Int,
223+
expectedSource: Option[String] = None,
224+
strictlyCheckMessage: Boolean = true
225+
): Unit = {
226+
expect(diagnostic.getSeverity == expectedSeverity)
227+
expect(diagnostic.getRange.getStart.getLine == expectedStartLine)
228+
expect(diagnostic.getRange.getStart.getCharacter == expectedStartCharacter)
229+
expect(diagnostic.getRange.getEnd.getLine == expectedEndLine)
230+
expect(diagnostic.getRange.getEnd.getCharacter == expectedEndCharacter)
231+
val message = TestUtil.removeAnsiColors(diagnostic.getMessage)
232+
if (strictlyCheckMessage)
233+
assertNoDiff(message, expectedMessage)
234+
else
235+
expect(message.contains(expectedMessage))
236+
for (es <- expectedSource)
237+
expect(diagnostic.getSource == es)
238+
}
239+
240+
protected def checkScalaAction(
241+
diagnostic: b.Diagnostic,
242+
expectedActionsSize: Int,
243+
expectedTitle: String,
244+
expectedChanges: Int,
245+
expectedStartLine: Int,
246+
expectedStartCharacter: Int,
247+
expectedEndLine: Int,
248+
expectedEndCharacter: Int,
249+
expectedNewText: String
250+
): Unit = {
251+
expect(diagnostic.getDataKind == "scala")
252+
253+
val gson = new com.google.gson.Gson()
254+
255+
val scalaDiagnostic: b.ScalaDiagnostic = gson.fromJson(
256+
diagnostic.getData.toString,
257+
classOf[b.ScalaDiagnostic]
258+
)
259+
260+
val actions = scalaDiagnostic.getActions.asScala
261+
262+
expect(actions.size == expectedActionsSize)
263+
264+
val action = actions.head
265+
expect(action.getTitle == expectedTitle)
266+
267+
val edit = action.getEdit
268+
expect(edit.getChanges.asScala.size == expectedChanges)
269+
val change = edit.getChanges.asScala.head
270+
271+
val expectedRange = new b.Range(
272+
new b.Position(expectedStartLine, expectedStartCharacter),
273+
new b.Position(expectedEndLine, expectedEndCharacter)
274+
)
275+
expect(change.getRange == expectedRange)
276+
expect(change.getNewText == expectedNewText)
277+
}
278+
279+
protected def extractWorkspaceReloadResponse(workspaceReloadResult: AnyRef)
280+
: Option[ResponseError] =
281+
workspaceReloadResult match {
282+
case gsonMap: LinkedTreeMap[?, ?] if !gsonMap.isEmpty =>
283+
val gson = new Gson()
284+
Some(gson.fromJson(gson.toJson(gsonMap), classOf[ResponseError]))
285+
case _ => None
286+
}
287+
}
288+
289+
object BspSuite {
290+
final protected case class Details(
291+
name: String,
292+
version: String,
293+
bspVersion: String,
294+
argv: List[String],
295+
languages: List[String]
296+
)
297+
protected val detailsCodec: JsonValueCodec[Details] = JsonCodecMaker.make
298+
}

0 commit comments

Comments
 (0)