From 67e1e4a9ffb5a2faa47f8d4696204760a83d584c Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Sat, 24 Mar 2018 02:07:28 +0900 Subject: [PATCH 1/2] improve server testing previously I was using separate thread in a forked test to test the server, but that is not enough isolation to run multiple server tests. This adds `RunFromSourceMain.fork(workingDirectory: File)`, which allows us to run a fresh sbt on the given working directory. Next, I've refactored the stateful client-side buffer to a class `TestServer`. --- build.sbt | 6 +- .../src/test/scala/sbt/std/TestUtil.scala | 2 +- .../test/scala/sbt/RunFromSourceMain.scala | 21 +- sbt/src/test/scala/sbt/ServerSpec.scala | 193 ------------------ sbt/src/test/scala/testpkg/ServerSpec.scala | 184 +++++++++++++++++ 5 files changed, 207 insertions(+), 199 deletions(-) delete mode 100644 sbt/src/test/scala/sbt/ServerSpec.scala create mode 100644 sbt/src/test/scala/testpkg/ServerSpec.scala diff --git a/build.sbt b/build.sbt index 5e50c04aa..41723a157 100644 --- a/build.sbt +++ b/build.sbt @@ -497,7 +497,6 @@ lazy val sbtProj = (project in file("sbt")) normalizedName := "sbt", crossScalaVersions := Seq(baseScalaVersion), crossPaths := false, - javaOptions ++= Seq("-Xdebug", "-Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005"), mimaSettings, mimaBinaryIssueFilters ++= sbtIgnoredProblems, BuildInfoPlugin.buildInfoDefaultSettings, @@ -506,10 +505,9 @@ lazy val sbtProj = (project in file("sbt")) buildInfoKeys in Test := Seq[BuildInfoKey]( // WORKAROUND https://github.com/sbt/sbt-buildinfo/issues/117 BuildInfoKey.map((fullClasspath in Compile).taskValue) { case (ident, cp) => ident -> cp.files }, + classDirectory in Compile, + classDirectory in Test, ), - connectInput in run in Test := true, - outputStrategy in run in Test := Some(StdoutOutput), - fork in Test := true, ) .configure(addSbtCompilerBridge) diff --git a/main-settings/src/test/scala/sbt/std/TestUtil.scala b/main-settings/src/test/scala/sbt/std/TestUtil.scala index df02fb29c..dc0098f19 100644 --- a/main-settings/src/test/scala/sbt/std/TestUtil.scala +++ b/main-settings/src/test/scala/sbt/std/TestUtil.scala @@ -27,6 +27,6 @@ object TestUtil { val mainClassesDir = buildinfo.TestBuildInfo.classDirectory val testClassesDir = buildinfo.TestBuildInfo.test_classDirectory val depsClasspath = buildinfo.TestBuildInfo.dependencyClasspath - mainClassesDir +: testClassesDir +: depsClasspath mkString ":" + mainClassesDir +: testClassesDir +: depsClasspath mkString java.io.File.pathSeparator } } diff --git a/sbt/src/test/scala/sbt/RunFromSourceMain.scala b/sbt/src/test/scala/sbt/RunFromSourceMain.scala index 6816e24c7..2f4e81a51 100644 --- a/sbt/src/test/scala/sbt/RunFromSourceMain.scala +++ b/sbt/src/test/scala/sbt/RunFromSourceMain.scala @@ -7,14 +7,33 @@ package sbt +import scala.concurrent.Future +import sbt.util.LogExchange import scala.annotation.tailrec - +import buildinfo.TestBuildInfo import xsbti._ object RunFromSourceMain { private val sbtVersion = "1.0.3" // "dev" private val scalaVersion = "2.12.4" + def fork(workingDirectory: File): Future[Unit] = { + val fo = ForkOptions() + .withWorkingDirectory(workingDirectory) + .withOutputStrategy(OutputStrategy.StdoutOutput) + implicit val runner = new ForkRun(fo) + val cp = { + TestBuildInfo.test_classDirectory +: TestBuildInfo.fullClasspath + } + val options = Vector(workingDirectory.toString) + val log = LogExchange.logger("RunFromSourceMain.fork", None, None) + import scala.concurrent.ExecutionContext.Implicits.global + Future { + Run.run("sbt.RunFromSourceMain", cp, options, log) + () + } + } + def main(args: Array[String]): Unit = args match { case Array() => sys.error(s"Must specify working directory as the first argument") case Array(wd, args @ _*) => run(file(wd), args) diff --git a/sbt/src/test/scala/sbt/ServerSpec.scala b/sbt/src/test/scala/sbt/ServerSpec.scala deleted file mode 100644 index a328c6dfe..000000000 --- a/sbt/src/test/scala/sbt/ServerSpec.scala +++ /dev/null @@ -1,193 +0,0 @@ -/* - * sbt - * Copyright 2011 - 2017, Lightbend, Inc. - * Copyright 2008 - 2010, Mark Harrah - * Licensed under BSD-3-Clause license (see LICENSE) - */ - -package sbt - -import org.scalatest._ -import scala.concurrent._ -import scala.annotation.tailrec -import java.io.{ InputStream, OutputStream } -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.{ ThreadFactory, ThreadPoolExecutor } -import sbt.protocol.ClientSocket - -class ServerSpec extends AsyncFlatSpec with Matchers { - import ServerSpec._ - - "server" should "start" in { - withBuildSocket("handshake") { (out, in, tkn) => - writeLine( - """{ "jsonrpc": "2.0", "id": 3, "method": "sbt/setting", "params": { "setting": "root/name" } }""", - out) - Thread.sleep(100) - assert(waitFor(in, 10) { s => - s contains """"id":3""" - }) - } - } - - @tailrec - private[this] def waitFor(in: InputStream, num: Int)(f: String => Boolean): Boolean = { - if (num < 0) false - else - readFrame(in) match { - case Some(x) if f(x) => true - case _ => - waitFor(in, num - 1)(f) - } - } -} - -object ServerSpec { - private val serverTestBase: File = new File(".").getAbsoluteFile / "sbt" / "src" / "server-test" - private val nextThreadId = new AtomicInteger(1) - private val threadGroup = Thread.currentThread.getThreadGroup() - val readBuffer = new Array[Byte](4096) - var buffer: Vector[Byte] = Vector.empty - var bytesRead = 0 - private val delimiter: Byte = '\n'.toByte - private val RetByte = '\r'.toByte - - private val threadFactory = new ThreadFactory() { - override def newThread(runnable: Runnable): Thread = { - val thread = - new Thread(threadGroup, - runnable, - s"sbt-test-server-threads-${nextThreadId.getAndIncrement}") - // Do NOT setDaemon because then the code in TaskExit.scala in sbt will insta-kill - // the backgrounded process, at least for the case of the run task. - thread - } - } - - private val executor = new ThreadPoolExecutor( - 0, /* corePoolSize */ - 1, /* maxPoolSize, max # of servers */ - 2, - java.util.concurrent.TimeUnit.SECONDS, - /* keep alive unused threads this long (if corePoolSize < maxPoolSize) */ - new java.util.concurrent.SynchronousQueue[Runnable](), - threadFactory - ) - - def backgroundRun(baseDir: File, args: Seq[String]): Unit = { - executor.execute(new Runnable { - def run(): Unit = { - RunFromSourceMain.run(baseDir, args) - } - }) - } - - def shutdown(): Unit = executor.shutdown() - - def withBuildSocket(testBuild: String)( - f: (OutputStream, InputStream, Option[String]) => Future[Assertion]): Future[Assertion] = { - IO.withTemporaryDirectory { temp => - IO.copyDirectory(serverTestBase / testBuild, temp / testBuild) - withBuildSocket(temp / testBuild)(f) - } - } - - def sendJsonRpc(message: String, out: OutputStream): Unit = { - writeLine(s"""Content-Length: ${message.size + 2}""", out) - writeLine("", out) - writeLine(message, out) - } - - def readFrame(in: InputStream): Option[String] = { - val l = contentLength(in) - readLine(in) - readLine(in) - readContentLength(in, l) - } - - def contentLength(in: InputStream): Int = { - readLine(in) map { line => - line.drop(16).toInt - } getOrElse (0) - } - - def readLine(in: InputStream): Option[String] = { - if (buffer.isEmpty) { - val bytesRead = in.read(readBuffer) - if (bytesRead > 0) { - buffer = buffer ++ readBuffer.toVector.take(bytesRead) - } - } - val delimPos = buffer.indexOf(delimiter) - if (delimPos > 0) { - val chunk0 = buffer.take(delimPos) - buffer = buffer.drop(delimPos + 1) - // remove \r at the end of line. - val chunk1 = if (chunk0.lastOption contains RetByte) chunk0.dropRight(1) else chunk0 - Some(new String(chunk1.toArray, "utf-8")) - } else None // no EOL yet, so skip this turn. - } - - def readContentLength(in: InputStream, length: Int): Option[String] = { - if (buffer.isEmpty) { - val bytesRead = in.read(readBuffer) - if (bytesRead > 0) { - buffer = buffer ++ readBuffer.toVector.take(bytesRead) - } - } - if (length <= buffer.size) { - val chunk = buffer.take(length) - buffer = buffer.drop(length) - Some(new String(chunk.toArray, "utf-8")) - } else None // have not read enough yet, so skip this turn. - } - - def writeLine(s: String, out: OutputStream): Unit = { - def writeEndLine(): Unit = { - val retByte: Byte = '\r'.toByte - val delimiter: Byte = '\n'.toByte - out.write(retByte.toInt) - out.write(delimiter.toInt) - out.flush - } - - if (s != "") { - out.write(s.getBytes("UTF-8")) - } - writeEndLine - } - - def withBuildSocket(baseDirectory: File)( - f: (OutputStream, InputStream, Option[String]) => Future[Assertion]): Future[Assertion] = { - backgroundRun(baseDirectory, Nil) - - val portfile = baseDirectory / "project" / "target" / "active.json" - - def waitForPortfile(n: Int): Unit = - if (portfile.exists) () - else { - if (n <= 0) sys.error(s"Timeout. $portfile is not found.") - else { - Thread.sleep(1000) - waitForPortfile(n - 1) - } - } - waitForPortfile(10) - val (sk, tkn) = ClientSocket.socket(portfile) - val out = sk.getOutputStream - val in = sk.getInputStream - - sendJsonRpc( - """{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "initializationOptions": { } } }""", - out) - - try { - f(out, in, tkn) - } finally { - sendJsonRpc( - """{ "jsonrpc": "2.0", "id": 9, "method": "sbt/exec", "params": { "commandLine": "exit" } }""", - out) - shutdown() - } - } -} diff --git a/sbt/src/test/scala/testpkg/ServerSpec.scala b/sbt/src/test/scala/testpkg/ServerSpec.scala new file mode 100644 index 000000000..f34eee6b1 --- /dev/null +++ b/sbt/src/test/scala/testpkg/ServerSpec.scala @@ -0,0 +1,184 @@ +/* + * sbt + * Copyright 2011 - 2017, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under BSD-3-Clause license (see LICENSE) + */ + +package testpkg + +import org.scalatest._ +import scala.concurrent._ +import scala.annotation.tailrec +import sbt.protocol.ClientSocket +import TestServer.withTestServer +import java.io.File +import sbt.io.syntax._ +import sbt.io.IO +import sbt.RunFromSourceMain + +class ServerSpec extends AsyncFreeSpec with Matchers { + "server" - { + "should start" in withTestServer("handshake") { p => + p.writeLine( + """{ "jsonrpc": "2.0", "id": "3", "method": "sbt/setting", "params": { "setting": "root/name" } }""") + assert(p.waitForString(10) { s => + s contains """"id":"3"""" + }) + } + + "return number id when number id is sent" in withTestServer("handshake") { p => + p.writeLine( + """{ "jsonrpc": "2.0", "id": 3, "method": "sbt/setting", "params": { "setting": "root/name" } }""") + assert(p.waitForString(10) { s => + s contains """"id":3""" + }) + } + } +} + +object TestServer { + private val serverTestBase: File = new File(".").getAbsoluteFile / "sbt" / "src" / "server-test" + + def withTestServer(testBuild: String)(f: TestServer => Future[Assertion]): Future[Assertion] = { + IO.withTemporaryDirectory { temp => + IO.copyDirectory(serverTestBase / testBuild, temp / testBuild) + withTestServer(temp / testBuild)(f) + } + } + + def withTestServer(baseDirectory: File)(f: TestServer => Future[Assertion]): Future[Assertion] = { + val testServer = TestServer(baseDirectory) + try { + f(testServer) + } finally { + testServer.bye() + } + } + + def hostLog(s: String): Unit = { + println(s"""[${scala.Console.MAGENTA}build-1${scala.Console.RESET}] $s""") + } +} + +case class TestServer(baseDirectory: File) { + import TestServer.hostLog + + val readBuffer = new Array[Byte](4096) + var buffer: Vector[Byte] = Vector.empty + var bytesRead = 0 + private val delimiter: Byte = '\n'.toByte + private val RetByte = '\r'.toByte + + hostLog("fork to a new sbt instance") + RunFromSourceMain.fork(baseDirectory) + lazy val portfile = baseDirectory / "project" / "target" / "active.json" + + hostLog("wait 30s until the server is ready to respond") + def waitForPortfile(n: Int): Unit = + if (portfile.exists) () + else { + if (n <= 0) sys.error(s"Timeout. $portfile is not found.") + else { + Thread.sleep(1000) + waitForPortfile(n - 1) + } + } + waitForPortfile(30) + + // make connection to the socket described in the portfile + val (sk, tkn) = ClientSocket.socket(portfile) + val out = sk.getOutputStream + val in = sk.getInputStream + + // initiate handshake + sendJsonRpc( + """{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "initializationOptions": { } } }""") + + def test(f: TestServer => Future[Assertion]): Future[Assertion] = { + f(this) + } + + def bye(): Unit = { + hostLog("sending exit") + sendJsonRpc( + """{ "jsonrpc": "2.0", "id": 9, "method": "sbt/exec", "params": { "commandLine": "exit" } }""") + } + + def sendJsonRpc(message: String): Unit = { + writeLine(s"""Content-Length: ${message.size + 2}""") + writeLine("") + writeLine(message) + } + + def writeLine(s: String): Unit = { + def writeEndLine(): Unit = { + val retByte: Byte = '\r'.toByte + val delimiter: Byte = '\n'.toByte + out.write(retByte.toInt) + out.write(delimiter.toInt) + out.flush + } + + if (s != "") { + out.write(s.getBytes("UTF-8")) + } + writeEndLine + } + + def readFrame: Option[String] = { + def getContentLength: Int = { + readLine map { line => + line.drop(16).toInt + } getOrElse (0) + } + + val l = getContentLength + readLine + readLine + readContentLength(l) + } + + @tailrec + final def waitForString(num: Int)(f: String => Boolean): Boolean = { + if (num < 0) false + else + readFrame match { + case Some(x) if f(x) => true + case _ => + waitForString(num - 1)(f) + } + } + + def readLine: Option[String] = { + if (buffer.isEmpty) { + val bytesRead = in.read(readBuffer) + if (bytesRead > 0) { + buffer = buffer ++ readBuffer.toVector.take(bytesRead) + } + } + val delimPos = buffer.indexOf(delimiter) + if (delimPos > 0) { + val chunk0 = buffer.take(delimPos) + buffer = buffer.drop(delimPos + 1) + // remove \r at the end of line. + val chunk1 = if (chunk0.lastOption contains RetByte) chunk0.dropRight(1) else chunk0 + Some(new String(chunk1.toArray, "utf-8")) + } else None // no EOL yet, so skip this turn. + } + + def readContentLength(length: Int): Option[String] = { + if (buffer.isEmpty) { + val bytesRead = in.read(readBuffer) + if (bytesRead > 0) { + buffer = buffer ++ readBuffer.toVector.take(bytesRead) + } + } + if (length <= buffer.size) { + val chunk = buffer.take(length) + buffer = buffer.drop(length) + Some(new String(chunk.toArray, "utf-8")) + } else None // have not read enough yet, so skip this turn. + } + +} From 1ec07c1867d2b7eb01503f68e0fc43d84f445911 Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Mon, 26 Mar 2018 10:37:25 -0400 Subject: [PATCH 2/2] Recover sbtOn --- build.sbt | 4 ++++ sbt/src/test/scala/sbt/RunFromSourceMain.scala | 17 +++++++++-------- sbt/src/test/scala/testpkg/ServerSpec.scala | 6 +++++- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/build.sbt b/build.sbt index 41723a157..01f18a2a8 100644 --- a/build.sbt +++ b/build.sbt @@ -497,6 +497,7 @@ lazy val sbtProj = (project in file("sbt")) normalizedName := "sbt", crossScalaVersions := Seq(baseScalaVersion), crossPaths := false, + javaOptions ++= Seq("-Xdebug", "-Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005"), mimaSettings, mimaBinaryIssueFilters ++= sbtIgnoredProblems, BuildInfoPlugin.buildInfoDefaultSettings, @@ -508,6 +509,9 @@ lazy val sbtProj = (project in file("sbt")) classDirectory in Compile, classDirectory in Test, ), + Test / run / connectInput := true, + Test / run / outputStrategy := Some(StdoutOutput), + Test / run / fork := true, ) .configure(addSbtCompilerBridge) diff --git a/sbt/src/test/scala/sbt/RunFromSourceMain.scala b/sbt/src/test/scala/sbt/RunFromSourceMain.scala index 2f4e81a51..c0bd9cfc1 100644 --- a/sbt/src/test/scala/sbt/RunFromSourceMain.scala +++ b/sbt/src/test/scala/sbt/RunFromSourceMain.scala @@ -7,7 +7,7 @@ package sbt -import scala.concurrent.Future +import scala.util.Try import sbt.util.LogExchange import scala.annotation.tailrec import buildinfo.TestBuildInfo @@ -17,21 +17,22 @@ object RunFromSourceMain { private val sbtVersion = "1.0.3" // "dev" private val scalaVersion = "2.12.4" - def fork(workingDirectory: File): Future[Unit] = { + def fork(workingDirectory: File): Try[Unit] = { val fo = ForkOptions() - .withWorkingDirectory(workingDirectory) .withOutputStrategy(OutputStrategy.StdoutOutput) + fork(fo, workingDirectory) + } + + def fork(fo0: ForkOptions, workingDirectory: File): Try[Unit] = { + val fo = fo0 + .withWorkingDirectory(workingDirectory) implicit val runner = new ForkRun(fo) val cp = { TestBuildInfo.test_classDirectory +: TestBuildInfo.fullClasspath } val options = Vector(workingDirectory.toString) val log = LogExchange.logger("RunFromSourceMain.fork", None, None) - import scala.concurrent.ExecutionContext.Implicits.global - Future { - Run.run("sbt.RunFromSourceMain", cp, options, log) - () - } + Run.run("sbt.RunFromSourceMain", cp, options, log) } def main(args: Array[String]): Unit = args match { diff --git a/sbt/src/test/scala/testpkg/ServerSpec.scala b/sbt/src/test/scala/testpkg/ServerSpec.scala index f34eee6b1..259e5c248 100644 --- a/sbt/src/test/scala/testpkg/ServerSpec.scala +++ b/sbt/src/test/scala/testpkg/ServerSpec.scala @@ -71,7 +71,11 @@ case class TestServer(baseDirectory: File) { private val RetByte = '\r'.toByte hostLog("fork to a new sbt instance") - RunFromSourceMain.fork(baseDirectory) + import scala.concurrent.ExecutionContext.Implicits.global + Future { + RunFromSourceMain.fork(baseDirectory) + () + } lazy val portfile = baseDirectory / "project" / "target" / "active.json" hostLog("wait 30s until the server is ready to respond")