Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add testing suite for LSP server #351

Open
wants to merge 36 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
0c9266b
Initial bootstrap of lsp server testing suite
marvinborner Dec 13, 2023
69696dd
Restructuring and initial bindings
marvinborner Dec 14, 2023
e8e3176
Initial LSP server connection bindings
marvinborner Dec 15, 2023
7bde25a
Add basic initialization request
marvinborner Dec 18, 2023
31ea164
Give ScalablyTyped another shot
marvinborner Dec 27, 2023
a3fece0
Basic reimplementation using ScalablyTyped
marvinborner Jan 3, 2024
5fd0669
Add child spawning with repeated connection attempts
marvinborner Jan 4, 2024
b9d4b5f
Start testing flow
marvinborner Jan 5, 2024
0d31058
Add symbol iteration
marvinborner Jan 5, 2024
101f355
Add some more LSP requests
marvinborner Jan 8, 2024
69652ca
Start MUnit integration
marvinborner Jan 10, 2024
ee8c540
Bootstrap utest
marvinborner Jan 11, 2024
c23a833
Fix ClassCastException
marvinborner Jan 11, 2024
97f25fe
Implement actual test checking and basic tests
marvinborner Jan 12, 2024
98df568
Bump Scala to commonSettings 3.3.1
marvinborner Jan 12, 2024
6587bd1
Fix warnings induced by previous commit
marvinborner Jan 12, 2024
78fa4e9
Slightly better test sequencing
marvinborner Jan 15, 2024
b39f330
Start dependency tree for sequentially executing tests
marvinborner Jan 16, 2024
aab6f11
Better solution for sequential execution of future tests
marvinborner Jan 18, 2024
f398aa4
Add symbol hovering tests and reference comparison
marvinborner Jan 19, 2024
4219bea
Add remaining symbol tests
marvinborner Jan 19, 2024
5e6ff3b
Add .check generating/overwriting mode
marvinborner Jan 20, 2024
4487292
Add diagnostic notification handling
marvinborner Jan 22, 2024
be93afa
Add test for unhandled notifications
marvinborner Jan 22, 2024
67cdfa3
Don't carry errors through all tests
marvinborner Jan 22, 2024
305af23
Add lspTest/test to workflow
marvinborner Jan 22, 2024
eb748df
Fix several bugs when processing /examples
marvinborner Jan 23, 2024
4523397
Potential CI fix
marvinborner Jan 25, 2024
ac666db
Support all did...TextDocument notifications
marvinborner Feb 4, 2024
78acdd0
Add formatting and command execution requests
marvinborner Feb 5, 2024
0e467ed
Switch to benchmarks as testing directory
marvinborner Feb 6, 2024
466930e
Potential CI fix
marvinborner Feb 6, 2024
ebbd5e5
Work on TODOs
marvinborner Feb 6, 2024
d6d4435
Bump node to v14 for vscode-jsonrpc to compile
marvinborner Feb 6, 2024
457743d
Run LSP tests after effekt installation
marvinborner Feb 6, 2024
af5dc13
Fix "Promise already completed" on repeated diagnostics
marvinborner Feb 7, 2024
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
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ jobs:
- name: Set up NodeJS
uses: actions/setup-node@v1
with:
node-version: '12.x'
node-version: '14.x'

- name: Run tests
run: sbt clean test
- name: Run effekt tests
run: sbt clean effektJVM/test

- name: Assemble fully optimized js file
run: sbt effektJS/fullOptJS
Expand All @@ -56,3 +56,6 @@ jobs:

- name: Run effekt binary
run: effekt.sh --help

- name: Run lspTest tests
run: sbt lspTest/test
16 changes: 16 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import sbtcrossproject.CrossProject

import scala.sys.process.Process
import scalajsbundler.util.JSON.{obj,str}
import benchmarks._

// additional targets that can be used in sbt
Expand Down Expand Up @@ -186,6 +187,21 @@ lazy val effekt: CrossProject = crossProject(JSPlatform, JVMPlatform).in(file("e
Compile / sourceGenerators += stdLibGenerator.taskValue
)

lazy val lspTest = project
.in(file("lspTest"))
.enablePlugins(ScalablyTypedConverterPlugin)
.settings(commonSettings)
.settings(
Compile / npmDependencies ++= Seq(
"@types/node" -> "20.11.5",
"vscode-languageserver-protocol" -> "3.17.5",
),
Global / stQuiet := true,
Test / parallelExecution := false,
libraryDependencies += "com.lihaoyi" %%% "utest" % "0.8.2" % Test,
testFrameworks += new TestFramework("utest.runner.Framework"),
scalaJSUseMainModuleInitializer := true,
)

lazy val platform = Def.task {
val platformString = System.getProperty("os.name").toLowerCase
Expand Down
2 changes: 2 additions & 0 deletions lspTest/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
target/
package-lock.json
98 changes: 98 additions & 0 deletions lspTest/src/test/scala/lspTest/Checker.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package lspTest

import scala.collection.mutable.HashMap
import scala.scalajs.js
import typings.node.fsMod

object Checker {
// This must not necessarily be the same as Tests.scala's testDir since we might run lspTest on all examples
val checkDir = "lspTest/tests"

// Overwrite .check files with test results
// TODO: this should be configurable, utest currently does not expose CLI arguments (#87)
val overwriteResults = false

def toCheckPath(testPath: String, subDir: String) =
val basename = testPath.split('/').last
s"$checkDir/$subDir/$basename"

// Objects need to be compared by structure since the ordering is not always deterministic
def objEqualStructure(a: js.Any, b: js.Any): Boolean =
(a, b) match {
case (a: js.Array[_], b: js.Array[_]) =>
a.length == b.length && a.forall { sa =>
b.exists { sb => objEqualStructure(sa.asInstanceOf[js.Any], sb.asInstanceOf[js.Any]) }
}
case (a: js.Object, b: js.Object) =>
js.Object.keys(a).sameElements(js.Object.keys(b)) &&
js.Object.keys(a).forall(key =>
objEqualStructure(a.asInstanceOf[js.Dynamic].selectDynamic(key), b.asInstanceOf[js.Dynamic].selectDynamic(key)))
case _ => a == b
}

def objEqual(a: js.Any, b: js.Any) =
js.typeOf(a) == "undefined" && js.typeOf(b) == "undefined" ||
js.typeOf(a) != "undefined" && js.typeOf(b) != "undefined" &&
objEqualStructure(a, b)

def objError(a: js.Any, b: js.Any) =
if js.typeOf(a) == "undefined" || js.typeOf(b) == "undefined" then "a or b is undefined"
else s"\nexpected ${js.JSON.stringify(a)}\ngot ${js.JSON.stringify(b)}"

def assertObjEqual(a: js.Any, b: js.Any) =
assert(objEqual(a, b), objError(a, b))

def compareWithFile(value: js.Object, path: String) =
val check = js.JSON.parse(fsMod.readFileSync(s"${path}.check.json").toString.strip)
assertObjEqual(check, value)

val checkCache = HashMap[String, js.Dynamic]()
def readChecks(path: String) =
val file = s"${path}.check.json"
if (!fsMod.existsSync(file)) js.Object().asInstanceOf[js.Dynamic]
else checkCache.getOrElseUpdate(path, js.JSON.parse(fsMod.readFileSync(file).toString.strip))

def writeChecks(path: String, checks: js.Dynamic) =
val pretty = js.JSON.stringify(checks, (_, b: js.Any) => b, "\t")
fsMod.writeFileSync(s"${path}.check.json", pretty)

def checkStats(name: String, result: js.Object) =
val path = toCheckPath(name, "stats")
if (overwriteResults) writeChecks(path, result.asInstanceOf[js.Dynamic])
compareWithFile(result, path)

def checkSample(request: String, testPath: String, result: js.Object) =
val path = toCheckPath(testPath, "samples")
val allChecks = readChecks(path)
if (overwriteResults) allChecks.updateDynamic(request)(result)
val check = allChecks.selectDynamic(request)
assertObjEqual(check, result)

def checkContextualSample(request: String, context: js.Object, testPath: String, result: js.Object) = {
val path = toCheckPath(testPath, "samples")

var allChecks = readChecks(path)
var contextualChecks = allChecks.selectDynamic(request).asInstanceOf[js.Array[js.Dynamic]]
if (contextualChecks == js.undefined) contextualChecks = js.Array()

var index = contextualChecks.indexWhere { obj => objEqual(obj.context, context) }

if (overwriteResults) {
if (index == -1) {
val updated = js.Object().asInstanceOf[js.Dynamic]
updated.updateDynamic("context")(context)
updated.updateDynamic("result")(result)
contextualChecks.push(updated)
index = contextualChecks.length - 1
} else {
contextualChecks(index).updateDynamic("result")(result)
}
allChecks.updateDynamic(request)(contextualChecks)
writeChecks(path, allChecks)
}

utest.assert(index != -1)
val checkResult = contextualChecks(index).selectDynamic("result")
assertObjEqual(checkResult, result)
}
}
176 changes: 176 additions & 0 deletions lspTest/src/test/scala/lspTest/Client.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package lspTest

import scala.collection.mutable.{HashMap}
import scala.concurrent.{ExecutionContext, Promise, Future}
import scala.scalajs.js
import org.scalablytyped.runtime.StObject
import typings.vscodeJsonrpc.libCommonConnectionMod.Logger
import typings.vscodeJsonrpc.libCommonMessagesMod.NotificationMessage
import typings.vscodeLanguageserverProtocol.anon.{Name, Text}
import typings.vscodeLanguageserverProtocol.libCommonConnectionMod.ProtocolConnection
import typings.vscodeLanguageserverProtocol.libCommonProtocolMod._
import typings.vscodeLanguageserverProtocol.mod.{TextDocumentItem, TextDocumentIdentifier, VersionedTextDocumentIdentifier, CodeActionContext, Range, FormattingOptions}
import typings.vscodeLanguageserverTypes.mod.Position
import typings.vscodeLanguageserverTypes.mod.ReferenceContext

class Client(val connection: ProtocolConnection)(implicit ec: ExecutionContext) {
def toURI(file: String) = s"file:///$file"

// TODO: use different capabilities and test whether the server respects them
def capabilities = ClientCapabilities()
.setExperimentalUndefined
.setGeneralUndefined
.setNotebookDocumentUndefined
.setTextDocumentUndefined
.setWindowUndefined
.setWorkspaceUndefined

val diagnostics = HashMap[String, Promise[NotificationMessage]]()

// this is rather complex logic but is needed to prevent race conditions,
// since relevant notifications may arrive before or after calling this function
def waitForDiagnostics(file: String) =
val uri = toURI(file)
diagnostics.get(uri) match
case None => {
val promise = Promise[NotificationMessage]()
diagnostics(uri) = promise
promise.future.flatMap { notification =>
diagnostics.remove(uri)
Future.successful(notification)
}
}
case Some(promise) => {
diagnostics.remove(uri)
promise.future.flatMap { notification =>
Future.successful(notification)
}
}

def initialize() = {
connection.onUnhandledNotification { notification =>
notification.method match
case "textDocument/publishDiagnostics" => {
val uri = notification.params.asInstanceOf[PublishDiagnosticsParams].uri
diagnostics.get(uri) match {
case Some(promise: Promise[NotificationMessage]) if !promise.isCompleted => promise.success(notification)
case _ => diagnostics(uri) = Promise().success(notification)
}
}
case _ => assert(false, "unexpected notification")
}

val params = _InitializeParams(capabilities)
.setClientInfo(Name("LSP testing client"))
.setLocaleUndefined
.setRootPathNull
.setRootUriNull
.setTraceUndefined
.setWorkDoneTokenUndefined
// TODO: also test showIR and showTree settings
.setInitializationOptions(js.JSON.parse("{\"showIR\": \"none\", \"showTree\": false}"))

// (1) send initialization request
connection.sendRequest("initialize", params).toFuture.flatMap { (result: InitializeResult[js.Any]) =>
// (2) on success, send initialized notification
connection.sendNotification(InitializedNotification.`type`, StObject().asInstanceOf[InitializedParams]).toFuture.flatMap { _ =>
Future.successful(result)
}
}
}

def exit() = {
connection.sendNotification(ExitNotification.`type`).toFuture
}

def openDocument(file: String, content: String) = {
val document = TextDocumentItem.create(
toURI(file),
"effekt",
1,
content
)
val params = DidOpenTextDocumentParams(document)
connection.sendNotification(DidOpenTextDocumentNotification.`type`, params).toFuture
}

def changeDocument(file: String, content: String) = {
val params = DidChangeTextDocumentParams(
js.Array(Text(content)), // TODO: support/use ranges
VersionedTextDocumentIdentifier.create(toURI(file), 1)
)
connection.sendNotification(DidChangeTextDocumentNotification.`type`, params).toFuture
}

def saveDocument(file: String) = {
val params = DidSaveTextDocumentParams(
TextDocumentIdentifier.create(toURI(file))
)
connection.sendNotification(DidSaveTextDocumentNotification.`type`, params).toFuture
}

def closeDocument(file: String) = {
val params = DidCloseTextDocumentParams(
TextDocumentIdentifier.create(toURI(file))
)
connection.sendNotification(DidCloseTextDocumentNotification.`type`, params).toFuture
}

def requestDocumentSymbol(file: String) = {
val params = DocumentSymbolParams(
TextDocumentIdentifier.create(toURI(file))
)
connection.sendRequest(DocumentSymbolRequest.`type`, params).toFuture
}

def requestCodeAction(file: String, start: Position, end: Position) = {
val params = CodeActionParams(
CodeActionContext.create(js.Array(), js.Array()),
Range.create(start, end),
TextDocumentIdentifier.create(toURI(file))
)
connection.sendRequest(CodeActionRequest.`type`, params).toFuture
}

def requestDefinition(file: String, position: Position) = {
val params = DefinitionParams(
position,
TextDocumentIdentifier.create(toURI(file))
)
connection.sendRequest(DefinitionRequest.`type`, params).toFuture
}

def requestFormatting(file: String, indent: Int) = {
val params = DocumentFormattingParams(
FormattingOptions.create(indent, false),
TextDocumentIdentifier.create(toURI(file))
)
connection.sendRequest(DocumentFormattingRequest.`type`, params).toFuture
}

def requestHover(file: String, position: Position) = {
val params = HoverParams(
position,
TextDocumentIdentifier.create(toURI(file))
)
connection.sendRequest(HoverRequest.`type`, params).toFuture
}

def requestReferences(file: String, position: Position) = {
val params = ReferenceParams(
ReferenceContext(true),
position,
TextDocumentIdentifier.create(toURI(file))
)
connection.sendRequest(ReferencesRequest.`type`, params).toFuture
}

def executeCommand(file: String, command: String) = {
// kiama wants the first argument to be JSON with uri
val argument = js.JSON.parse(s"{\"uri\":\"${toURI(file)}\"}")

val params = ExecuteCommandParams(command)
params.setArguments(js.Array(argument))
connection.sendRequest(ExecuteCommandRequest.`type`, params).toFuture
}
}
Loading
Loading