diff --git a/build.sbt b/build.sbt index effb961215..300bbe277b 100644 --- a/build.sbt +++ b/build.sbt @@ -105,6 +105,7 @@ lazy val backend = project Dependencies.libraryManagement, Dependencies.sourcecode, Dependencies.monix, + Dependencies.monixTest, Dependencies.directoryWatcher, Dependencies.zt, Dependencies.brave, @@ -156,6 +157,7 @@ lazy val frontend: Project = project Dependencies.jsoniterMacros % Provided, Dependencies.scalazCore, Dependencies.monix, + Dependencies.monixTest, Dependencies.caseApp, Dependencies.scalaDebugAdapter, Dependencies.bloopConfig, diff --git a/frontend/src/main/scala/bloop/Bloop.scala b/frontend/src/main/scala/bloop/Bloop.scala index fb687c0451..c378690f02 100644 --- a/frontend/src/main/scala/bloop/Bloop.scala +++ b/frontend/src/main/scala/bloop/Bloop.scala @@ -1,22 +1,14 @@ package bloop import scala.annotation.tailrec - import bloop.cli.CliOptions import bloop.cli.Commands import bloop.cli.ExitStatus import bloop.data.ClientInfo import bloop.data.WorkspaceSettings -import bloop.engine.Build -import bloop.engine.BuildLoader -import bloop.engine.Exit -import bloop.engine.Interpreter -import bloop.engine.NoPool -import bloop.engine.Run -import bloop.engine.State +import bloop.engine.{Build, BuildLoader, ExecutionContext, Exit, Interpreter, NoPool, Run, State} import bloop.io.AbsolutePath import bloop.logging.BloopLogger - import _root_.bloop.task.Task import caseapp.CaseApp import caseapp.RemainingArgs @@ -50,7 +42,7 @@ object Bloop extends CaseApp[CliOptions] { // Ignore the exit status here, all we want is the task to finish execution or fail. Cli.waitUntilEndOfWorld(options, state.pool, config, state.logger) { t.map(s => { State.stateCache.updateBuild(s.copy(status = ExitStatus.Ok)); s.status }) - } + }(ExecutionContext.ioScheduler) // Recover the state if the previous task has been successful. State.stateCache diff --git a/frontend/src/main/scala/bloop/Cli.scala b/frontend/src/main/scala/bloop/Cli.scala index b20b9e7f35..116c3b33c1 100644 --- a/frontend/src/main/scala/bloop/Cli.scala +++ b/frontend/src/main/scala/bloop/Cli.scala @@ -3,11 +3,7 @@ package bloop import java.io.InputStream import java.io.PrintStream import java.nio.file.Path -import java.util.concurrent.CompletableFuture -import java.util.concurrent.ConcurrentHashMap - import scala.util.control.NonFatal - import bloop.cli.CliOptions import bloop.cli.Commands import bloop.cli.CommonOptions @@ -21,20 +17,29 @@ import bloop.logging.BloopLogger import bloop.logging.DebugFilter import bloop.logging.Logger import bloop.task.Task +import monix.eval.{Task => MonixTask} +import monix.eval.TaskApp import bloop.util.JavaRuntime - import caseapp.core.help.Help +import cats.effect.ExitCode +import cats.effect.concurrent.{Deferred, Ref} import com.martiansoftware.nailgun.NGContext +import monix.execution.Scheduler import monix.execution.atomic.AtomicBoolean +import scala.concurrent.Await +import scala.concurrent.duration.Duration + class Cli -object Cli { +object Cli extends TaskApp { implicit private val filter: DebugFilter.All.type = DebugFilter.All - def main(args: Array[String]): Unit = { - val action = parse(args, CommonOptions.default) - val exitStatus = run(action, NoPool) - sys.exit(exitStatus.code) + def run(args: List[String]): MonixTask[ExitCode] = { + val action = parse(args.toArray, CommonOptions.default) + for { + activeCliSessions <- Ref.of[MonixTask, Map[Path, List[CliSession]]](Map.empty) + exitStatus <- run(action, NoPool, activeCliSessions) + } yield ExitCode(exitStatus.code) } def reflectMain( @@ -44,8 +49,9 @@ object Cli { out: PrintStream, err: PrintStream, props: java.util.Properties, - cancel: CompletableFuture[java.lang.Boolean] - ): Int = { + cancel: Deferred[MonixTask, Boolean], + activeCliSessions: Ref[MonixTask, Map[Path, List[CliSession]]] + ): MonixTask[Int] = { val env = CommonOptions.PrettyProperties.from(props) val nailgunOptions = CommonOptions( in = in, @@ -58,12 +64,12 @@ object Cli { ) val cmd = parse(args, nailgunOptions) - val exitStatus = run(cmd, NoPool, cancel) - exitStatus.code + val exitStatus = run(cmd, NoPool, cancel, activeCliSessions) + exitStatus.map(_.code) } def nailMain(ngContext: NGContext): Unit = { - val env = CommonOptions.PrettyProperties.from(ngContext.getEnv()) + val env = CommonOptions.PrettyProperties.from(ngContext.getEnv) val nailgunOptions = CommonOptions( in = ngContext.in, out = ngContext.out, @@ -87,18 +93,24 @@ object Cli { else parse(args, nailgunOptions) } println(nailgunOptions.workingDirectory) + println(nailgunOptions.workingPath) - try { - val exitStatus = run(cmd, NailgunPool(ngContext)) - ngContext.exit(exitStatus.code) - } catch { - case x: java.util.concurrent.ExecutionException => - // print stack trace of fatal errors thrown in asynchronous code, see https://stackoverflow.com/questions/17265022/what-is-a-boxed-error-in-scala - // the stack trace is somehow propagated all the way to the client when printing this - x.getCause.printStackTrace(ngContext.out) - ngContext.exit(ExitStatus.UnexpectedError.code) - } + val handle = + Ref + .of[MonixTask, Map[Path, List[CliSession]]](Map.empty) + .flatMap(run(cmd, NailgunPool(ngContext), _)) + .onErrorHandle { + case x: java.util.concurrent.ExecutionException => + // print stack trace of fatal errors thrown in asynchronous code, see https://stackoverflow.com/questions/17265022/what-is-a-boxed-error-in-scala + // the stack trace is somehow propagated all the way to the client when printing this + x.getCause.printStackTrace(ngContext.out) + ExitStatus.UnexpectedError.code + } + .runToFuture(ExecutionContext.ioScheduler) + + Await.result(handle, Duration.Inf) + () } val commands: Seq[String] = Commands.RawCommand.help.messages.flatMap(_._1.headOption.toSeq) @@ -298,8 +310,16 @@ object Cli { } } - def run(action: Action, pool: ClientPool): ExitStatus = { - run(action, pool, FalseCancellation) + def run( + action: Action, + pool: ClientPool, + activeCliSessions: Ref[MonixTask, Map[Path, List[CliSession]]] + ): MonixTask[ExitStatus] = { + for { + baseCancellation <- Deferred[MonixTask, Boolean] + _ <- baseCancellation.complete(false) + result <- run(action, pool, baseCancellation, activeCliSessions) + } yield result } // Attempt to load JDI when we initialize the CLI class @@ -307,8 +327,9 @@ object Cli { private def run( action: Action, pool: ClientPool, - cancel: CompletableFuture[java.lang.Boolean] - ): ExitStatus = { + cancel: Deferred[MonixTask, Boolean], + activeCliSessions: Ref[MonixTask, Map[Path, List[CliSession]]] + ): MonixTask[ExitStatus] = { import bloop.io.AbsolutePath def getConfigDir(cliOptions: CliOptions): AbsolutePath = { val cwd = AbsolutePath(cliOptions.common.workingDirectory) @@ -339,26 +360,34 @@ object Cli { !(cliOptions.noColor || commonOpts.env.containsKey("NO_COLOR")), debugFilter ) - action match { case Print(msg, _, Exit(exitStatus)) => logger.info(msg) - exitStatus + MonixTask.now(exitStatus) case _ => - runWithState(action, pool, cancel, configDirectory, cliOptions, commonOpts, logger) + runWithState( + action, + pool, + cancel, + activeCliSessions, + configDirectory, + cliOptions, + commonOpts, + logger + ) } } private def runWithState( action: Action, pool: ClientPool, - cancel: CompletableFuture[java.lang.Boolean], + cancel: Deferred[MonixTask, Boolean], + activeCliSessions: Ref[MonixTask, Map[Path, List[CliSession]]], configDirectory: AbsolutePath, cliOptions: CliOptions, commonOpts: CommonOptions, logger: Logger - ): ExitStatus = { - + ): MonixTask[ExitStatus] = { // Set the proxy settings right before loading the state of the build bloop.util.ProxySetup.updateProxySettings(commonOpts.env.toMap, logger) @@ -366,7 +395,7 @@ object Cli { waitUntilEndOfWorld(cliOptions, pool, configDir, logger, cancel) { val taskToInterpret = { (cli: CliClientInfo) => val state = State.loadActiveStateFor(configDirectory, cli, pool, cliOptions.common, logger) - Interpreter.execute(action, state).map { newState => + val interpret = Interpreter.execute(action, state).map { newState => action match { case Run(_: Commands.ValidatedBsp, _) => () // Ignore, BSP services auto-update the build @@ -375,32 +404,38 @@ object Cli { newState } + MonixTask.defer(interpret.toMonixTask(ExecutionContext.scheduler)) } - val session = runTaskWithCliClient(configDirectory, action, taskToInterpret, pool, logger) - val exitSession = Task.defer { - cleanUpNonStableCliDirectories(session.client) + val session = runTaskWithCliClient( + configDirectory, + action, + taskToInterpret, + activeCliSessions, + pool + ) + val exitSession = MonixTask.defer { + session.flatMap { session => + cleanUpNonStableCliDirectories(session.client, logger).flatMap { _ => + activeCliSessions.update(_ - configDirectory.underlying) + } + } } - session.task - .doOnCancel(exitSession) - .doOnFinish(_ => exitSession) + session + .flatMap(_.task) + .guarantee(exitSession) } } - private final val FalseCancellation = - CompletableFuture.completedFuture[java.lang.Boolean](false) - - private val activeCliSessions = new ConcurrentHashMap[Path, List[CliSession]]() - - case class CliSession(client: CliClientInfo, task: Task[ExitStatus]) + case class CliSession(client: CliClientInfo, task: MonixTask[ExitStatus]) def runTaskWithCliClient( configDir: AbsolutePath, action: Action, - processCliTask: CliClientInfo => Task[State], - pool: ClientPool, - logger: Logger - ): CliSession = { + processCliTask: CliClientInfo => MonixTask[State], + activeCliSessions: Ref[MonixTask, Map[Path, List[CliSession]]], + pool: ClientPool + ): MonixTask[CliSession] = { val isClientConnected = AtomicBoolean(true) pool.addListener(_ => isClientConnected.set(false)) val defaultClient = CliClientInfo(useStableCliDirs = true, () => isClientConnected.get) @@ -410,66 +445,91 @@ object Cli { CliSession(client, cliTask) } - val defaultClientSession = sessionFor(defaultClient) action match { - case Exit(_) => defaultClientSession + case Exit(_) => MonixTask.now(sessionFor(defaultClient)) // Don't synchronize on commands that don't use compilation products and can run concurrently - case Run(_: Commands.About, _) => defaultClientSession - case Run(_: Commands.Projects, _) => defaultClientSession - case Run(_: Commands.Autocomplete, _) => defaultClientSession - case Run(_: Commands.Bsp, _) => defaultClientSession - case Run(_: Commands.ValidatedBsp, _) => defaultClientSession - case _ => - val activeSessions = activeCliSessions.compute( - configDir.underlying, - (_: Path, sessions: List[CliSession]) => { - if (sessions == null || sessions.isEmpty) List(defaultClientSession) - else { - logger.debug("Detected connected cli clients, starting CLI with unique dirs...") - val newClient = CliClientInfo(useStableCliDirs = false, () => isClientConnected.get) - val newClientSession = sessionFor(newClient) - newClientSession :: sessions + case Run(_: Commands.About, _) => MonixTask.now(sessionFor(defaultClient)) + case Run(_: Commands.Projects, _) => MonixTask.now(sessionFor(defaultClient)) + case Run(_: Commands.Autocomplete, _) => MonixTask.now(sessionFor(defaultClient)) + case Run(_: Commands.Bsp, _) => MonixTask.now(sessionFor(defaultClient)) + case Run(_: Commands.ValidatedBsp, _) => MonixTask.now(sessionFor(defaultClient)) + case a @ _ => + activeCliSessions + .modify { sessionsMap => + sessionsMap.get(configDir.underlying) match { + case Some(sessions) => + val newClient = CliClientInfo(useStableCliDirs = false, () => isClientConnected.get) + val newClientSession = sessionFor(newClient) + ( + sessionsMap.updated(configDir.underlying, newClientSession :: sessions), + newClientSession + ) + case None => + val newSession = sessionFor(defaultClient) + ( + sessionsMap.updated(configDir.underlying, List(newSession)), + newSession + ) } } - ) - - activeSessions.head } } def cleanUpNonStableCliDirectories( - client: CliClientInfo - ): Task[Unit] = { - if (client.useStableCliDirs) Task.unit + client: CliClientInfo, + logger: Logger + ): MonixTask[Unit] = { + if (client.useStableCliDirs) MonixTask.unit else { + logger.debug( + s"Cleaning up non-stable CLI directories ${client.getCreatedCliDirectories.mkString(",")}" + ) val deleteTasks = client.getCreatedCliDirectories.map { freshDir => - if (!freshDir.exists) Task.unit + if (!freshDir.exists) MonixTask.unit else { - Task.eval(Paths.delete(freshDir)).asyncBoundary + MonixTask.eval(Paths.delete(freshDir)).asyncBoundary } } val groups = deleteTasks .grouped(4) - .map(group => Task.gatherUnordered(group).map(_ => ())) + .map(group => MonixTask.parSequenceUnordered(group).map(_ => ())) .toList - Task + MonixTask .sequence(groups) .map(_ => ()) .executeOn(ExecutionContext.ioScheduler) } } - import scala.concurrent.Await - import scala.concurrent.duration.Duration + private[bloop] def waitUntilEndOfWorld( + cliOptions: CliOptions, + pool: ClientPool, + configDirectory: Path, + logger: Logger + )(task: Task[ExitStatus])(implicit s: Scheduler): ExitStatus = { + val handler = + for { + cancel <- Deferred[MonixTask, Boolean] + exitStatus <- waitUntilEndOfWorld(cliOptions, pool, configDirectory, logger, cancel)( + task.toMonixTask + ) + } yield exitStatus + Await.result( + handler.runToFuture(ExecutionContext.ioScheduler), + Duration.Inf + ) + } + private[bloop] def waitUntilEndOfWorld( cliOptions: CliOptions, pool: ClientPool, configDirectory: Path, logger: Logger, - cancel: CompletableFuture[java.lang.Boolean] = FalseCancellation - )(task: Task[ExitStatus]): ExitStatus = { + cancel: Deferred[MonixTask, Boolean] + )(task: MonixTask[ExitStatus]): MonixTask[ExitStatus] = { + val ngout = cliOptions.common.ngout def logElapsed(since: Long): Unit = { val elapsed = (System.nanoTime() - since).toDouble / 1e6 @@ -477,58 +537,80 @@ object Cli { } // Simulate try-catch-finally with monix tasks to time the task execution - val handle = - Task - .now(System.nanoTime()) - .flatMap(start => task.materialize.map(s => (s, start))) - .map { case (state, start) => logElapsed(start); state } - .dematerialize - .runAsync(ExecutionContext.scheduler) - - if (!cancel.isDone) { - // Add support for a client to cancel bloop via Java's completable future - import bloop.util.Java8Compat.JavaCompletableFutureUtils - Task - .deferFutureAction(cancel.asScala(_)) - .map { cancel => - if (cancel) { - cliOptions.common.out.println( - s"Client in $configDirectory triggered cancellation. Cancelling tasks..." - ) - handle.cancel() - } + val handle: MonixTask[ExitStatus] = + for { + start <- MonixTask.now(System.nanoTime()) + tryState <- task.materialize + _ = logElapsed(start) + state <- MonixTask.fromTry(tryState) + } yield state + + def waitForCanceled: MonixTask[ExitStatus] = + for { + isCanceled <- cancel.get + status <- + if (isCanceled) + MonixTask + .now { + cliOptions.common.out.println( + s"Client in $configDirectory triggered cancellation. Cancelling tasks..." + ) + } + .map(_ => ExitStatus.UnexpectedError) + else MonixTask.now(ExitStatus.Ok) + } yield status + + def handleException(t: Throwable, completed: Boolean): MonixTask[ExitStatus] = + for { + _ <- if (completed) cancel.complete(completed) else MonixTask.unit + } yield { + logger.error(s"Caught $t") + logger.trace(t) + ExitStatus.UnexpectedError + } + // Let's cancel tasks (if supported by the underlying implementation) when clients disconnect + def registerListener( + cancelSignal: Deferred[MonixTask, Boolean] + )(implicit s: Scheduler): MonixTask[Unit] = { + MonixTask { + pool.addListener { e: CloseEvent => + MonixTask + .defer { + cancelSignal + .complete(true) + .attempt + .map { + case Right(()) => + ngout.println( + s"Client in $configDirectory disconnected with a '$e' event. Cancelling tasks..." + ) + case Left(_) => + ngout.println( + s"Client in $configDirectory disconnected with a '$e' event. Cancelling tasks..." + ) + } + } + .uncancelable // TODO: do we need it? + .runAsyncAndForget } - .runAsync(ExecutionContext.ioScheduler) - } - - def handleException(t: Throwable) = { - handle.cancel() - if (!cancel.isDone) - cancel.complete(false) - logger.error(s"Caught $t") - logger.trace(t) - ExitStatus.UnexpectedError + } } - try { - // Let's cancel tasks (if supported by the underlying implementation) when clients disconnect - pool.addListener { - case e: CloseEvent => - if (!handle.isCompleted) { - ngout.println( - s"Client in $configDirectory disconnected with a '$e' event. Cancelling tasks..." - ) - handle.cancel() - if (!cancel.isDone) - cancel.complete(false) - () - } + MonixTask + .racePair( + registerListener(cancel)(Scheduler.singleThread("cancel-signal")) *> waitForCanceled, + handle + ) + .flatMap { + case Left((result, fiber)) => + if (result.isOk) fiber.join else fiber.cancel *> MonixTask.now(result) + case Right((fiber, _)) => fiber.join + } + .onErrorHandleWith { + case i: InterruptedException => handleException(i, completed = true) + } + .onErrorRecoverWith { + case NonFatal(t) => handleException(t, completed = false) } - - Await.result(handle, Duration.Inf) - } catch { - case i: InterruptedException => handleException(i) - case NonFatal(t) => handleException(t) - } } } diff --git a/frontend/src/main/scala/bloop/engine/tasks/CompileTask.scala b/frontend/src/main/scala/bloop/engine/tasks/CompileTask.scala index daeb365bb0..725655d488 100644 --- a/frontend/src/main/scala/bloop/engine/tasks/CompileTask.scala +++ b/frontend/src/main/scala/bloop/engine/tasks/CompileTask.scala @@ -484,6 +484,7 @@ object CompileTask { compilerResult: Compiler.Result, logger: Logger ): Task[Unit] = { + logger.debug("cleaning up previous results") val previousClassesDir = previousSuccessful.classesDir val currentlyUsedCounter = previousSuccessful.counterForClassesDir.decrementAndGet(1) diff --git a/frontend/src/main/scala/bloop/engine/tasks/compilation/CompileGatekeeper.scala b/frontend/src/main/scala/bloop/engine/tasks/compilation/CompileGatekeeper.scala index 339135343c..6028b4e837 100644 --- a/frontend/src/main/scala/bloop/engine/tasks/compilation/CompileGatekeeper.scala +++ b/frontend/src/main/scala/bloop/engine/tasks/compilation/CompileGatekeeper.scala @@ -95,7 +95,7 @@ object CompileGatekeeper { * inputs. The call-site ensures that only one compilation can exist for the * same inputs for a period of time. */ - def scheduleCompilation( + private def scheduleCompilation( inputs: BundleInputs, bundle: SuccessfulCompileBundle, client: ClientInfo, diff --git a/frontend/src/test/scala/bloop/BaseCompileSpec.scala b/frontend/src/test/scala/bloop/BaseCompileSpec.scala index 173eea1c42..6812062663 100644 --- a/frontend/src/test/scala/bloop/BaseCompileSpec.scala +++ b/frontend/src/test/scala/bloop/BaseCompileSpec.scala @@ -1664,58 +1664,6 @@ abstract class BaseCompileSpec extends bloop.testing.BaseSuite { } } - test("don't compile build in two concurrent CLI clients") { - TestUtil.withinWorkspace { workspace => - val sources = List( - """/main/scala/Foo.scala - |class Foo - """.stripMargin - ) - val testOut = new ByteArrayOutputStream() - val options = CommonOptions.default.copy(out = new PrintStream(testOut)) - val `A` = TestProject(workspace, "a", sources) - val configDir = TestProject.populateWorkspace(workspace, List(`A`)) - val compileArgs = Array("compile", "a", "--config-dir", configDir.syntax) - val compileAction = Cli.parse(compileArgs, options) - def runCompileAsync = Task.eval(Cli.run(compileAction, NoPool)).executeAsync - val runCompile = Task.gatherUnordered(List(runCompileAsync, runCompileAsync)).map(_ => ()) - Await.result(runCompile.runAsync(ExecutionContext.ioScheduler), FiniteDuration(10, "s")) - - val actionsOutput = new String(testOut.toByteArray(), StandardCharsets.UTF_8) - def removeAsciiColorCodes(line: String): String = line.replaceAll("\u001B\\[[;\\d]*m", "") - - val obtained = actionsOutput.splitLines - .filterNot(_.startsWith("Compiled")) - .map(removeAsciiColorCodes) - .map(msg => RecordingLogger.replaceTimingInfo(msg)) - .mkString(lineSeparator) - .replaceAll("'(bloop-cli-.*)'", "'bloop-cli'") - .replaceAll("'bloop-cli'", "???") - - try { - assertNoDiff( - processOutput(obtained), - s"""Compiling a (1 Scala source) - |Deduplicating compilation of a from cli client ??? (since ??? - |Compiling a (1 Scala source) - |$extraCompilationMessageOutput - |""".stripMargin - ) - } catch { - case _: DiffAssertions.TestFailedException => - assertNoDiff( - processOutput(obtained), - s""" - |Deduplicating compilation of a from cli client ??? (since ??? - |Compiling a (1 Scala source) - |Compiling a (1 Scala source) - |$extraCompilationMessageOutput - |""".stripMargin - ) - } - } - } - test("compile a project that redundantly lists an exact file as well as parent directory") { TestUtil.withinWorkspace { workspace => val filename = "/main/scala/Foo.scala" diff --git a/frontend/src/test/scala/bloop/MonixBaseCompileSpec.scala b/frontend/src/test/scala/bloop/MonixBaseCompileSpec.scala new file mode 100644 index 0000000000..3f8b82c169 --- /dev/null +++ b/frontend/src/test/scala/bloop/MonixBaseCompileSpec.scala @@ -0,0 +1,83 @@ +package bloop + +import bloop.Cli.CliSession +import bloop.cli.{CommonOptions, ExitStatus} +import bloop.engine.NoPool +import bloop.io.Environment.{LineSplitter, lineSeparator} +import bloop.logging.RecordingLogger +import monix.eval.Task +import bloop.testing.DiffAssertions +import bloop.util.{BaseTestProject, TestUtil} +import cats.effect.concurrent.Ref + +import java.io.{ByteArrayOutputStream, PrintStream} +import java.nio.charset.StandardCharsets +import java.nio.file.Path + +abstract class MonixBaseCompileSpec extends bloop.testing.MonixBaseSuite { + protected def TestProject: BaseTestProject + + protected def extraCompilationMessageOutput: String = "" + protected def processOutput(output: String) = output + + test("don't compile build in two concurrent CLI clients") { + TestUtil.withinWorkspaceV2 { workspace => + val sources = List( + """/main/scala/Foo.scala + |class Foo + """.stripMargin + ) + val testOut = new ByteArrayOutputStream() + val options = CommonOptions.default.copy(out = new PrintStream(testOut)) + val `A` = TestProject(workspace, "a", sources) + val configDir = TestProject.populateWorkspace(workspace, List(`A`)) + val compileArgs = + Array("compile", "a", "--config-dir", configDir.syntax) + val compileAction = Cli.parse(compileArgs, options) + def runCompileAsync( + activeSessions: Ref[Task, Map[Path, List[CliSession]]] + ): Task[ExitStatus] = + Cli.run(compileAction, NoPool, activeSessions) + for { + activeSessions <- Ref.of[Task, Map[Path, List[CliSession]]](Map.empty) + _ <- Task.parSequenceUnordered( + List(runCompileAsync(activeSessions), runCompileAsync(activeSessions)) + ) + } yield { + val actionsOutput = new String(testOut.toByteArray, StandardCharsets.UTF_8) + def removeAsciiColorCodes(line: String): String = line.replaceAll("\u001B\\[[;\\d]*m", "") + + val obtained = actionsOutput.splitLines + .filterNot(_.startsWith("Compiled")) + .map(removeAsciiColorCodes) + .map(msg => RecordingLogger.replaceTimingInfo(msg)) + .mkString(lineSeparator) + .replaceAll("'(bloop-cli-.*)'", "'bloop-cli'") + .replaceAll("'bloop-cli'", "???") + + try { + assertNoDiff( + processOutput(obtained), + s"""Compiling a (1 Scala source) + |Deduplicating compilation of a from cli client ??? (since ??? + |Compiling a (1 Scala source) + |$extraCompilationMessageOutput + |""".stripMargin + ) + } catch { + case _: DiffAssertions.TestFailedException => + assertNoDiff( + processOutput(obtained), + s""" + |Deduplicating compilation of a from cli client ??? (since ??? + |Compiling a (1 Scala source) + |Compiling a (1 Scala source) + |$extraCompilationMessageOutput + |""".stripMargin + ) + } + } + } + } + +} diff --git a/frontend/src/test/scala/bloop/MonixCompileSpec.scala b/frontend/src/test/scala/bloop/MonixCompileSpec.scala new file mode 100644 index 0000000000..0a9351bd1d --- /dev/null +++ b/frontend/src/test/scala/bloop/MonixCompileSpec.scala @@ -0,0 +1,5 @@ +package bloop + +object MonixCompileSpec extends MonixBaseCompileSpec { + override protected val TestProject = util.TestProject +} diff --git a/frontend/src/test/scala/bloop/dap/DebugServerSpec.scala b/frontend/src/test/scala/bloop/dap/DebugServerSpec.scala index 2e3ec92a42..e162f5ab41 100644 --- a/frontend/src/test/scala/bloop/dap/DebugServerSpec.scala +++ b/frontend/src/test/scala/bloop/dap/DebugServerSpec.scala @@ -6,7 +6,6 @@ import java.net.SocketException import java.net.SocketTimeoutException import java.util.NoSuchElementException import java.util.concurrent.TimeUnit.MILLISECONDS - import scala.collection.JavaConverters._ import scala.collection.mutable import scala.concurrent.Future @@ -15,12 +14,11 @@ import scala.concurrent.TimeoutException import scala.concurrent.duration.Duration import scala.concurrent.duration.FiniteDuration import scala.concurrent.duration._ - import ch.epfl.scala.bsp import ch.epfl.scala.bsp.ScalaMainClass import ch.epfl.scala.debugadapter._ - import bloop.Cli +import bloop.Cli.CliSession import bloop.ScalaInstance import bloop.cli.CommonOptions import bloop.cli.ExitStatus @@ -45,15 +43,18 @@ import bloop.reporter.ReporterAction import bloop.task.Task import bloop.util.TestProject import bloop.util.TestUtil - +import cats.effect.concurrent.Ref import com.microsoft.java.debug.core.protocol.Requests.SetBreakpointArguments import com.microsoft.java.debug.core.protocol.Types import com.microsoft.java.debug.core.protocol.Types.SourceBreakpoint import coursierapi.Dependency import coursierapi.Fetch +import monix.eval.{Task => MonixTask} import monix.execution.Ack import monix.reactive.Observer +import java.nio.file.Path + object DebugServerSpec extends DebugBspBaseSuite { private val ServerNotListening = new IllegalStateException("Server is not accepting connections") private val Success: ExitStatus = ExitStatus.Ok @@ -1005,7 +1006,11 @@ object DebugServerSpec extends DebugBspBaseSuite { def cliCompile(project: TestProject) = { val compileArgs = Array("compile", project.config.name, "--config-dir", configDir.syntax) val compileAction = Cli.parse(compileArgs, CommonOptions.default) - Task.eval(Cli.run(compileAction, NoPool)).executeAsync + Task.liftMonixTaskUncancellable { + Ref + .of[MonixTask, Map[Path, List[CliSession]]](Map.empty) + .flatMap(Cli.run(compileAction, NoPool, _)) + }.executeAsync } def bspCommand() = createBspCommand(configDir) diff --git a/frontend/src/test/scala/bloop/testing/MonixBaseSuite.scala b/frontend/src/test/scala/bloop/testing/MonixBaseSuite.scala new file mode 100644 index 0000000000..5841e5e2ee --- /dev/null +++ b/frontend/src/test/scala/bloop/testing/MonixBaseSuite.scala @@ -0,0 +1,561 @@ +package bloop.testing + +import bloop.Compiler +import bloop.cli.ExitStatus +import bloop.engine.caches.LastSuccessfulResult +import bloop.io.AbsolutePath +import bloop.io.Paths.AttributedPath +import bloop.logging.{BspServerLogger, RecordingLogger} +import bloop.reporter.Problem +import bloop.util.{TestProject, TestUtil} +import monix.eval.Task +import monix.execution.{ExecutionModel, Scheduler} +import monix.testing.utest.MonixTaskTest +import utest.Tests +import utest.asserts.Asserts +import utest.framework.{Formatter, TestCallTree, Tree} +import utest.ufansi.{Attrs, Color, Str} + +import scala.annotation.nowarn +import scala.collection.JavaConverters._ +import scala.concurrent.duration._ +import scala.language.experimental.macros +import scala.reflect.ClassTag +import scala.util.control.NonFatal + +abstract class MonixBaseSuite extends MonixTaskTest with BloopHelpers { + override implicit val scheduler: Scheduler = + Scheduler.global + override val timeout: FiniteDuration = 300.second + val pprint = _root_.pprint.PPrinter.BlackWhite + def isWindows: Boolean = bloop.util.CrossPlatform.isWindows + def isMac: Boolean = bloop.util.CrossPlatform.isMac + def isAppveyor: Boolean = "True" == System.getenv("APPVEYOR") + def beforeAll(): Unit = () + def afterAll(): Unit = () + def intercept[T: ClassTag](exprs: Unit): T = macro Asserts.interceptProxy[T] + + def assertNotEmpty(string: String): Unit = { + if (string.isEmpty) { + fail( + s"expected non-empty string, obtained empty string." + ) + } + } + + def assertEmpty(string: String): Unit = { + if (!string.isEmpty) { + fail( + s"expected empty string, obtained: $string" + ) + } + } + + def assertContains(string: String, substring: String): Unit = { + assert(string.contains(substring)) + } + + def assertNotContains(string: String, substring: String): Unit = { + assert(!string.contains(substring)) + } + + def assert(exprs: Boolean*): Unit = macro Asserts.assertProxy + + def assertNotEquals[T](obtained: T, expected: T, hint: String = ""): Unit = { + if (obtained == expected) { + val hintMsg = if (hint.isEmpty) "" else s" (hint: $hint)" + assertNoDiff(obtained.toString, expected.toString, hint) + fail(s"obtained=<$obtained> == expected=<$expected>$hintMsg") + } + } + + def assertEquals[T](obtained: T, expected: T, hint: String = ""): Unit = { + if (obtained != expected) { + val hintMsg = if (hint.isEmpty) "" else s" (hint: $hint)" + assertNoDiff(obtained.toString, expected.toString, hint) + fail(s"obtained=<$obtained> != expected=<$expected>$hintMsg") + } + } + + def assertNotFile(path: AbsolutePath): Unit = { + if (path.isFile) { + fail(s"file exists: $path", stackBump = 1) + } + } + + def assertIsFile(path: AbsolutePath): Unit = { + if (!path.isFile) { + fail(s"no such file: $path", stackBump = 1) + } + } + + def assertIsDirectory(path: AbsolutePath): Unit = { + if (!path.isDirectory) { + fail(s"directory doesn't exist: $path", stackBump = 1) + } + } + + def assertIsNotDirectory(path: AbsolutePath): Unit = { + if (path.isDirectory) { + fail(s"directory exists: $path", stackBump = 1) + } + } + + def assertExitStatus(obtainedState: TestState, expected: ExitStatus): Unit = { + val obtained = obtainedState.status + try assert(obtained == expected) + catch { + case NonFatal(t) => + obtainedState.state.logger match { + case logger: RecordingLogger => logger.dump(); throw t + case logger: BspServerLogger => + logger.underlying match { + case logger: RecordingLogger => logger.dump(); throw t + case _ => throw t + } + case _ => throw t + } + } + } + + def assertSameResult( + a: LastSuccessfulResult, + b: LastSuccessfulResult + ): Unit = { + assert(a.previous == b.previous) + assert(a.classesDir == b.classesDir) + } + + import bloop.logging.NoopLogger + def takeDirectorySnapshot( + dir: AbsolutePath + ): List[AttributedPath] = { + import java.io.File + val files = bloop.io.Paths + .attributedPathFilesUnder(dir, "glob:**.*", NoopLogger) + .map { ap => + val prefixPath = dir.syntax.stripSuffix("/") + val osInsensitivePath = ap.path.syntax.replace(prefixPath, "").replace(File.separator, "/") + val maskedRelativePath = AbsolutePath(osInsensitivePath) + if (!maskedRelativePath.syntax.startsWith("/classes-")) { + ap.withPath(maskedRelativePath) + } else { + // Remove '/classes-*' from path + val newPath = maskedRelativePath.syntax.split(File.separatorChar).tail.tail.mkString("/") + ap.withPath(AbsolutePath("/" + newPath)) + } + } + + files.sortBy(_.toString) + } + + def assertDifferentExternalClassesDirs( + s1: TestState, + s2: TestState, + projects: List[TestProject] + )(implicit filename: sourcecode.File, line: sourcecode.Line): Unit = { + projects.foreach { project => + assertDifferentExternalClassesDirs(s1, s2, project) + } + } + + def assertDifferentExternalClassesDirs( + s1: TestState, + s2: TestState, + project: TestProject + )(implicit filename: sourcecode.File, line: sourcecode.Line): Unit = { + try { + assertSameExternalClassesDirs(s1, s2, project, printDiff = false) + fail("External classes dirs of ${project.config.name} in both states is the same!") + } catch { + case _: DiffAssertions.TestFailedException => () + } + } + + def assertSameExternalClassesDirs( + s1: TestState, + s2: TestState, + project: TestProject, + printDiff: Boolean = true + )(implicit filename: sourcecode.File, line: sourcecode.Line): Unit = { + val projectName = project.config.name + val a = s1.client + .getUniqueClassesDirFor(s1.build.getProjectFor(projectName).get, forceGeneration = true) + val b = s2.client + .getUniqueClassesDirFor(s2.build.getProjectFor(projectName).get, forceGeneration = true) + val filesA = takeDirectorySnapshot(a) + val filesB = takeDirectorySnapshot(b) + assertNoDiff( + pprint.apply(filesA, height = Int.MaxValue).render, + pprint.apply(filesB, height = Int.MaxValue).render, + print = printDiff + ) + } + + def assertSameExternalClassesDirs( + s1: TestState, + s2: TestState, + projects: List[TestProject] + )(implicit filename: sourcecode.File, line: sourcecode.Line): Unit = { + projects.foreach { project => + assertSameExternalClassesDirs(s1, s2, project) + } + } + + def assertSameFilesIn( + a: AbsolutePath, + b: AbsolutePath + )(implicit filename: sourcecode.File, line: sourcecode.Line): Unit = { + val filesA = takeDirectorySnapshot(a) + val filesB = takeDirectorySnapshot(b) + assertNoDiff( + pprint.apply(filesA, height = Int.MaxValue).render, + pprint.apply(filesB, height = Int.MaxValue).render, + a.syntax, + b.syntax + ) + } + + def assertCancelledCompilation(state: TestState, projects: List[TestProject]): Unit = { + projects.foreach { project => + state.getLastResultFor(project) match { + case _: Compiler.Result.Cancelled => () + case result => fail(s"Result ${result} is not cancelled!") + } + } + } + + def assertSuccessfulCompilation( + state: TestState, + projects: List[TestProject], + isNoOp: Boolean + ): Unit = { + projects.foreach { project => + state.getLastResultFor(project) match { + case s: Compiler.Result.Success => if (isNoOp) assert(s.isNoOp) + case result => fail(s"Result ${result} is not cancelled!") + } + } + } + + def assertDiagnosticsResult( + result: Compiler.Result, + errors: Int, + warnings: Int = 0, + expectFatalWarnings: Boolean = false + ): Unit = { + if (errors > 0) { + result match { + case Compiler.Result.Failed(problems, t, _, _, _) => + val count = Problem.count(problems) + if (count.errors == 0 && errors != 0) { + // If there's an exception count it as one error + val errors = t match { + case Some(_) => 1 + case None => 0 + } + + assert(count.errors == errors) + } else { + assert(count.errors == errors) + } + assert(count.warnings == warnings) + case _ => fail(s"Result ${result} != Failed") + } + } else { + result match { + case Compiler.Result.Success(_, reporter, _, _, _, _, reportedFatalWarnings) => + val count = Problem.count(reporter.allProblemsPerPhase.toList) + assert(count.errors == 0) + assert(expectFatalWarnings == reportedFatalWarnings) + assert(count.warnings == warnings) + case _ => fail("Result ${result} != Success, but expected errors == 0") + } + } + } + + def assertInvalidCompilationState( + state: TestState, + projects: List[TestProject], + existsAnalysisFile: Boolean, + hasPreviousSuccessful: Boolean, + hasSameContentsInClassesDir: Boolean + )(implicit filename: sourcecode.File, line: sourcecode.Line): Unit = { + val buildProjects = projects.flatMap(p => state.build.getProjectFor(p.config.name).toList) + assert(projects.size == buildProjects.size) + projects.zip(buildProjects).foreach { + case (testProject, buildProject) => + if (!existsAnalysisFile) assertNotFile(buildProject.analysisOut) + else assertIsFile(buildProject.analysisOut) + val latestResult = state.getLastSuccessfulResultFor(testProject) + if (hasPreviousSuccessful) { + val result = latestResult.get + val analysis = result.previous.analysis().get() + val stamps = analysis.readStamps + assert(stamps.getAllProductStamps.asScala.nonEmpty) + assert(stamps.getAllSourceStamps.asScala.nonEmpty) + + if (hasSameContentsInClassesDir) { + val projectClassesDir = + state.client.getUniqueClassesDirFor(buildProject, forceGeneration = true) + assert(takeDirectorySnapshot(result.classesDir).nonEmpty) + assertSameFilesIn(projectClassesDir, result.classesDir) + } + } else { + assert(latestResult.isEmpty) + val lastResult = state.getLastResultFor(testProject) + assert(lastResult != Compiler.Result.Empty) + lastResult match { + case Compiler.Result.NotOk(_) => () + case r => fail(s"Result $r is considered a success!") + } + } + } + } + + import bloop.io.RelativePath + def assertCompileProduct( + state: TestState, + project: TestProject, + classFile: RelativePath, + existing: Boolean + ): Unit = { + val buildProject = state.build.getProjectFor(project.config.name).get + val externalClassesDir = + state.client.getUniqueClassesDirFor(buildProject, forceGeneration = true) + if (existing) assert(externalClassesDir.resolve(classFile).exists) + else assert(!externalClassesDir.resolve(classFile).exists) + } + + def assertNonExistingCompileProduct( + state: TestState, + project: TestProject, + classFile: RelativePath + ): Unit = { + assertCompileProduct(state, project, classFile, false) + } + + def assertExistingCompileProduct( + state: TestState, + project: TestProject, + classFile: RelativePath + ): Unit = { + assertCompileProduct(state, project, classFile, true) + } + + def assertExistingInternalClassesDir(lastState: TestState)( + stateToCheck: TestState, + projects: List[TestProject] + ): Unit = { + assertExistenceOfInternalClassesDir(lastState, stateToCheck, projects, checkExists = true) + } + + def assertNonExistingInternalClassesDir(lastState: TestState)( + stateToCheck: TestState, + projects: List[TestProject] + ): Unit = { + assertExistenceOfInternalClassesDir(lastState, stateToCheck, projects, checkExists = false) + } + + private def assertExistenceOfInternalClassesDir( + lastState: TestState, + stateToCheck: TestState, + projects: List[TestProject], + checkExists: Boolean + ): Unit = { + val buildProjects = + projects.flatMap(p => stateToCheck.build.getProjectFor(p.config.name).toList) + assert(projects.size == buildProjects.size) + projects.zip(buildProjects).foreach { + case (testProject, buildProject) => + // Force the execution of the next last successful to delete the directory + lastState.results.lastSuccessfulResult(buildProject).foreach { s => + import scala.concurrent.duration.FiniteDuration + val _ = TestUtil.await(FiniteDuration(5, "s"))(s.populatingProducts) + } + + val last = stateToCheck.getLastSuccessfulResultFor(testProject).get + val classesDir = last.classesDir + // Sleep for 20 ms to give time to classes dir to be deleted (runs inside `doOnFinish`) + Thread.sleep(20) + if (checkExists) assert(classesDir.exists) + else assert(!classesDir.exists) + } + } + + def assertEmptyCompilationState( + state: TestState, + projects: List[TestProject] + ): Unit = { + val buildProjects = projects.flatMap(p => state.build.getProjectFor(p.config.name).toList) + assert(projects.size == buildProjects.size) + projects.zip(buildProjects).foreach { + case (testProject, buildProject) => + assertNotFile(buildProject.analysisOut) + assert(state.getLastSuccessfulResultFor(testProject).isEmpty) + assert(state.getLastResultFor(testProject) == Compiler.Result.Empty) + val classesDir = state.client.getUniqueClassesDirFor(buildProject, forceGeneration = true) + assert(takeDirectorySnapshot(classesDir).isEmpty) + } + } + + def assertValidCompilationState( + state: TestState, + projects: List[TestProject] + )(implicit filename: sourcecode.File, line: sourcecode.Line): Unit = { + val buildProjects = projects.flatMap(p => state.build.getProjectFor(p.config.name).toList) + assert(projects.size == buildProjects.size) + projects.zip(buildProjects).foreach { + case (testProject, buildProject) => + assertIsFile(buildProject.analysisOut) + val latestResult = state + .getLastSuccessfulResultFor(testProject) + .getOrElse( + sys.error(s"No latest result for ${testProject.config.name}, results: ${state.results}") + ) + + val analysis = latestResult.previous.analysis().get() + val stamps = analysis.readStamps + assert(stamps.getAllProductStamps.asScala.nonEmpty) + assert(stamps.getAllSourceStamps.asScala.nonEmpty) + + assert(takeDirectorySnapshot(latestResult.classesDir).nonEmpty) + val projectClassesDir = + state.client.getUniqueClassesDirFor(buildProject, forceGeneration = true) + assertSameFilesIn(projectClassesDir, latestResult.classesDir) + } + } + + def assertNoDiff( + obtained: String, + expected: String, + obtainedTitle: String, + expectedTitle: String + )(implicit filename: sourcecode.File, line: sourcecode.Line): Unit = { + colored { + DiffAssertions.assertNoDiffOrPrintExpected( + obtained, + expected, + obtainedTitle, + expectedTitle, + true + ) + () + } + } + + def assertNoDiff( + obtained: String, + expected: String, + title: String = "", + print: Boolean = true + )(implicit filename: sourcecode.File, line: sourcecode.Line): Unit = { + colored { + DiffAssertions.assertNoDiffOrPrintExpected(obtained, expected, title, title, print) + () + } + } + + def colored[T]( + thunk: => T + )(implicit filename: sourcecode.File, line: sourcecode.Line): T = { + try { + thunk + } catch { + case scala.util.control.NonFatal(e) => + val message = e.getMessage.linesIterator + .map { line => + if (line.startsWith("+")) Color.Green(line) + else if (line.startsWith("-")) Color.LightRed(line) + else Color.Reset(line) + } + .mkString("\n") + val location = s"failed assertion at ${filename.value}:${line.value}\n" + throw new DiffAssertions.TestFailedException(location + message) + } + } + + def unsafeGetResource(resourceId: String): AbsolutePath = { + val resource = this.getClass.getClassLoader().getResource(resourceId) + if (resource == null) sys.error(s"Missing resource $resourceId") + else AbsolutePath(resource.toURI()) + } + + override def utestAfterAll(): Unit = afterAll() + override def utestFormatter: Formatter = new Formatter { + override def exceptionMsgColor: Attrs = Attrs.Empty + override def exceptionStackFrameHighlighter( + s: StackTraceElement + ): Boolean = { + s.getClassName.startsWith("bloop.") && + !(s.getClassName.startsWith("bloop.util") || + s.getClassName.startsWith("bloop.testing")) + } + + override def formatWrapWidth: Int = 3000 + override def formatException(x: Throwable, leftIndent: String): Str = + super.formatException(x, "") + } + + case class FlatTest(name: String, thunk: () => Task[Any]) + private val myTests = IndexedSeq.newBuilder[FlatTest] + + @nowarn("msg=parameter value fun in method ignore is never used") + def ignore(name: String, label: String = "IGNORED")(fun: => Task[Any]): Unit = { + test(utest.ufansi.Color.LightRed(s"$label - $name").toString())(Task.unit) + } + + def testOnlyOnJava8(name: String)(fun: => Task[Any]): Unit = { + if (TestUtil.isJdk8) test(name)(fun) + else ignore(name, label = s"IGNORED ON JAVA v${TestUtil.jdkVersion}")(fun) + } + +// def flakyTest(name: String, attempts: Int)(fun: => Task[Any]): Unit = { +// assert(attempts >= 0) +// def retry(fun: => Task[Any], attempts: Int): Task[Any] = { +// try { +// fun; () +// } catch { +// case NonFatal(t) => +// if (attempts == 0) throw t +// else { +// System.err.println( +// s"Caught exception on flaky test run (remaining $attempts), restarting..." +// ) +// t.printStackTrace(System.err) +// Thread.sleep(10000) +// retry(fun, attempts - 1) +// } +// } +// } +// +// myTests += FlatTest(name, () => retry(fun, attempts)) +// } + + def test(name: String)(fun: => Task[Any]): Unit = { + myTests += FlatTest(name, () => fun) + } + +// def testTask(name: String, maxDuration: Duration = Duration("20s"))(fun: => Task[Unit]): Unit = { +// myTests += FlatTest( +// name, +// () => { TestUtil.await(maxDuration, ExecutionContext.ioScheduler)(fun) } +// ) +// } + + def fail(msg: String, stackBump: Int = 0): Nothing = { + val ex = new DiffAssertions.TestFailedException(msg) + ex.setStackTrace(ex.getStackTrace.slice(1 + stackBump, 5 + stackBump)) + throw ex + } + + override def tests: Tests = { + val ts = myTests.result() + val names = Tree("", ts.map(x => Tree(x.name)): _*) + val thunks = new TestCallTree({ + this.beforeAll() + Right(ts.map(x => new TestCallTree(Left(x.thunk())))) + }) + Tests(names, thunks) + } +} diff --git a/frontend/src/test/scala/bloop/util/TestUtil.scala b/frontend/src/test/scala/bloop/util/TestUtil.scala index 58f8645376..203c56553c 100644 --- a/frontend/src/test/scala/bloop/util/TestUtil.scala +++ b/frontend/src/test/scala/bloop/util/TestUtil.scala @@ -7,7 +7,6 @@ import java.nio.file.Path import java.nio.file.attribute.FileTime import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException - import scala.concurrent.Await import scala.concurrent.ExecutionException import scala.concurrent.duration.Duration @@ -15,7 +14,6 @@ import scala.concurrent.duration.FiniteDuration import scala.concurrent.duration.TimeUnit import scala.tools.nsc.Properties import scala.util.control.NonFatal - import bloop.CompilerCache import bloop.DependencyResolution import bloop.ScalaInstance @@ -50,8 +48,8 @@ import bloop.logging.BufferedLogger import bloop.logging.DebugFilter import bloop.logging.Logger import bloop.logging.RecordingLogger - import _root_.bloop.task.Task +import _root_.monix.eval.{Task => MonixTask} import _root_.monix.execution.Scheduler import org.junit.Assert import sbt.internal.inc.BloopComponentCompiler @@ -483,6 +481,15 @@ object TestUtil { finally delete(AbsolutePath(temp)) } + /** Creates an empty workspace where operations can happen. */ + def withinWorkspaceV2[T](op: AbsolutePath => MonixTask[T]): MonixTask[T] = { + MonixTask + .now(Files.createTempDirectory("bloop-test-workspace").toRealPath()) + .flatMap { temp => + op(AbsolutePath(temp)).guarantee(MonixTask(delete(AbsolutePath(temp)))) + } + } + /** Creates an empty workspace where operations can happen. */ def withinWorkspace[T](op: AbsolutePath => Task[T]): Task[T] = { val temp = Files.createTempDirectory("bloop-test-workspace").toRealPath() diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 7445357699..6a3c08792f 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -27,6 +27,7 @@ object Dependencies { val junitVersion = "0.13.3" val directoryWatcherVersion = "0.8.0+6-f651bd93" val monixVersion = "3.2.0" + val monixTestVersion = "0.3.0" val jsoniterVersion = "2.13.3.2" val shapelessVersion = "2.3.4" val scalaNative04Version = "0.4.17" @@ -88,6 +89,7 @@ object Dependencies { val difflib = "com.googlecode.java-diff-utils" % "diffutils" % difflibVersion val monix = "io.monix" %% "monix" % monixVersion + val monixTest = "io.monix" %% "monix-testing-utest" % monixTestVersion % "test" val jsoniterCore = "com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-core" % jsoniterVersion val jsoniterMacros =