diff --git a/modules/build/src/main/scala/scala/build/Build.scala b/modules/build/src/main/scala/scala/build/Build.scala index 84e3fdd57c..3b140fb8ef 100644 --- a/modules/build/src/main/scala/scala/build/Build.scala +++ b/modules/build/src/main/scala/scala/build/Build.scala @@ -305,9 +305,7 @@ object Build { testDocOpt: Option[Build] ) - def doBuild( - overrideOptions: BuildOptions - ): Either[BuildException, NonCrossBuilds] = either { + def doBuild(overrideOptions: BuildOptions): Either[BuildException, NonCrossBuilds] = either { val inputs0 = updateInputs( inputs, @@ -685,9 +683,9 @@ object Build { val threads = BuildThreads.create() val classesDir0 = classesRootDir(inputs.workspace, inputs.projectName) val info = either { - val (crossSources, inputs0) = value(allInputs(inputs, options, logger)) - val sharedOptions = crossSources.sharedOptions(options) - val compiler = value { + val (crossSources: CrossSources, inputs0: Inputs) = value(allInputs(inputs, options, logger)) + val sharedOptions = crossSources.sharedOptions(options) + val compiler: ScalaCompiler = value { compilerMaker.create( inputs0.workspace / Constants.workspaceDirName, classesDir0, @@ -696,7 +694,7 @@ object Build { sharedOptions ) } - val docCompilerOpt = docCompilerMakerOpt.map(_.create( + val docCompilerOpt: Option[ScalaCompiler] = docCompilerMakerOpt.map(_.create( inputs0.workspace / Constants.workspaceDirName, classesDir0, buildClient, @@ -711,20 +709,26 @@ object Build { def run(): Unit = { try { res = - info.flatMap { case (compiler, docCompilerOpt, crossSources, inputs) => - build( - inputs, - crossSources, - options, - logger, - buildClient, - compiler, - docCompilerOpt, - crossBuilds = crossBuilds, - buildTests = buildTests, - partial = partial, - actionableDiagnostics = actionableDiagnostics - ) + info.flatMap { + case ( + compiler: ScalaCompiler, + docCompilerOpt: Option[ScalaCompiler], + crossSources: CrossSources, + inputs: Inputs + ) => + build( + inputs = inputs, + crossSources = crossSources, + options = options, + logger = logger, + buildClient = buildClient, + compiler = compiler, + docCompilerOpt = docCompilerOpt, + crossBuilds = crossBuilds, + buildTests = buildTests, + partial = partial, + actionableDiagnostics = actionableDiagnostics + ) } action(res) } @@ -742,7 +746,7 @@ object Build { def doWatch(): Unit = { val elements: Seq[Element] = - if (res == null) inputs.elements + if res == null then inputs.elements else res .map { builds => @@ -777,10 +781,7 @@ object Build { case _: Virtual => } watcher0.addObserver { - onChangeBufferedObserver { event => - if (eventFilter(event)) - watcher.schedule() - } + onChangeBufferedObserver(event => if eventFilter(event) then watcher.schedule()) } } @@ -797,14 +798,10 @@ object Build { } .getOrElse(Nil) for (artifact <- artifacts) { - val depth = if (os.isFile(artifact)) -1 else Int.MaxValue + val depth = if os.isFile(artifact) then -1 else Int.MaxValue val watcher0 = watcher.newWatcher() watcher0.register(artifact.toNIO, depth) - watcher0.addObserver { - onChangeBufferedObserver { _ => - watcher.schedule() - } - } + watcher0.addObserver(onChangeBufferedObserver(_ => watcher.schedule())) } } diff --git a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala index cdb997af3d..68c2b2a86a 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala @@ -223,8 +223,7 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers { Some(name), inputArgs ) - if (CommandUtils.shouldCheckUpdate) - Update.checkUpdateSafe(logger) + if CommandUtils.shouldCheckUpdate then Update.checkUpdateSafe(logger) val configDb = ConfigDbUtils.configDb.orExit(logger) val actionableDiagnostics = @@ -232,7 +231,7 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers { configDb.get(Keys.actions).getOrElse(None) ) - if (options.sharedRun.watch.watchMode) { + if options.sharedRun.watch.watchMode then { /** A handle to the Runner process, used to kill the process if it's still alive when a change * occured and restarts are allowed or to wait for it if restarts are not allowed @@ -251,20 +250,18 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers { val mainThreadOpt = AtomicReference(Option.empty[Thread]) val watcher = Build.watch( - inputs, - initialBuildOptions, - compilerMaker, - None, - logger, + inputs = inputs, + options = initialBuildOptions, + compilerMaker = compilerMaker, + docCompilerMakerOpt = None, + logger = logger, crossBuilds = cross, buildTests = false, partial = None, actionableDiagnostics = actionableDiagnostics, postAction = () => - if (processOpt.get().exists(_._1.isAlive())) - WatchUtil.printWatchWhileRunningMessage() - else - WatchUtil.printWatchMessage() + if processOpt.get().exists(_._1.isAlive()) then WatchUtil.printWatchWhileRunningMessage() + else WatchUtil.printWatchMessage() ) { res => for ((process, onExitProcess) <- processOpt.get()) { onExitProcess.cancel(true) @@ -296,8 +293,7 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers { (proc, onExit) } s.copyOutput(options.shared) - if (options.sharedRun.watch.restart) - processOpt.set(maybeProcess) + if options.sharedRun.watch.restart then processOpt.set(maybeProcess) else { for ((proc, onExit) <- maybeProcess) ProcUtil.waitForProcess(proc, onExit) diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala index d15c5cdf24..61ccd1e5ed 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala @@ -6,8 +6,6 @@ import java.io.{ByteArrayOutputStream, File} import java.nio.charset.Charset import scala.cli.integration.util.DockerServer -import scala.concurrent.ExecutionContext -import scala.concurrent.duration.Duration import scala.io.Codec import scala.jdk.CollectionConverters.* import scala.util.Properties @@ -1507,99 +1505,6 @@ abstract class RunTestDefinitions } } - test("watch with interactive, with multiple main classes") { - val fileName = "watch.scala" - - val inputs = TestInputs( - os.rel / fileName -> - """object Run1 extends App {println("Run1 launched")} - |object Run2 extends App {println("Run2 launched")} - |""".stripMargin - ) - inputs.fromRoot { root => - val confDir = root / "config" - val confFile = confDir / "test-config.json" - - os.write(confFile, "{}", createFolders = true) - - if (!Properties.isWin) - os.perms.set(confDir, "rwx------") - - val configEnv = Map("SCALA_CLI_CONFIG" -> confFile.toString) - - val proc = os.proc(TestUtil.cli, "run", "--watch", "--interactive", fileName) - .spawn( - cwd = root, - mergeErrIntoOut = true, - stdout = os.Pipe, - stdin = os.Pipe, - env = Map("SCALA_CLI_INTERACTIVE" -> "true") ++ configEnv - ) - - try - TestUtil.withThreadPool("run-watch-interactive-multi-main-class-test", 2) { pool => - val timeout = Duration("60 seconds") - implicit val ec = ExecutionContext.fromExecutorService(pool) - - def lineReaderIter = Iterator.continually { - val line = TestUtil.readLine(proc.stdout, ec, timeout) - println(s"Line read: $line") - line - } - - def checkLinesForError(lines: Seq[String]) = munit.Assertions.assert( - !lines.exists { line => - TestUtil.removeAnsiColors(line).contains("[error]") - }, - clues(lines.toSeq) - ) - - def answerInteractivePrompt(id: Int) = { - val interactivePromptLines = lineReaderIter - .takeWhile(!_.startsWith("[1]" /* probably [1] Run2 or [1] No*/ )) - .toList - expect(interactivePromptLines.nonEmpty) - checkLinesForError(interactivePromptLines) - proc.stdin.write(s"$id\n") - proc.stdin.flush() - } - - def analyzeRunOutput(restart: Boolean) = { - val runResultLines = lineReaderIter - .takeWhile(!_.contains("press Enter to re-run")) - .toList - expect(runResultLines.nonEmpty) - checkLinesForError(runResultLines) - if (restart) - proc.stdin.write("\n") - proc.stdin.flush() - } - - // You have run the current scala-cli command with the --interactive mode turned on. - // Would you like to leave it on permanently? - answerInteractivePrompt(0) - - // Found several main classes. Which would you like to run? - answerInteractivePrompt(0) - expect(TestUtil.readLine(proc.stdout, ec, timeout) == "Run1 launched") - - analyzeRunOutput( /* restart */ true) - - answerInteractivePrompt(1) - expect(TestUtil.readLine(proc.stdout, ec, timeout) == "Run2 launched") - - analyzeRunOutput( /* restart */ false) - } - finally - if (proc.isAlive()) { - proc.destroy() - Thread.sleep(200L) - if (proc.isAlive()) - proc.destroyForcibly() - } - } - } - test("decoded classNames in interactive ask") { val fileName = "watch.scala" diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunTestsDefault.scala b/modules/integration/src/test/scala/scala/cli/integration/RunTestsDefault.scala index ab4787ea08..eedcf98c12 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunTestsDefault.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunTestsDefault.scala @@ -2,11 +2,11 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect -import scala.concurrent.ExecutionContext -import scala.concurrent.duration.Duration -import scala.util.{Properties, Try} +import scala.util.Properties -class RunTestsDefault extends RunTestDefinitions with TestDefault { +class RunTestsDefault extends RunTestDefinitions + with RunWithWatchTestDefinitions + with TestDefault { def archLinuxTest(): Unit = { val message = "Hello from Scala CLI on Arch Linux" val inputs = TestInputs( @@ -69,139 +69,6 @@ class RunTestsDefault extends RunTestDefinitions with TestDefault { } } - if (!Properties.isMac || !TestUtil.isNativeCli || !TestUtil.isCI) - // TODO make this pass reliably on Mac CI - test("watch artifacts") { - val libSourcePath = os.rel / "lib" / "Messages.scala" - def libSource(hello: String) = - s"""//> using publish.organization "test-org" - |//> using publish.name "messages" - |//> using publish.version "0.1.0" - | - |package messages - | - |object Messages { - | def hello(name: String) = s"$hello $$name" - |} - |""".stripMargin - val inputs = TestInputs( - libSourcePath -> libSource("Hello"), - os.rel / "app" / "TestApp.scala" -> - """//> using lib "test-org::messages:0.1.0" - | - |package testapp - | - |import messages.Messages - | - |@main - |def run(): Unit = - | println(Messages.hello("user")) - |""".stripMargin - ) - inputs.fromRoot { root => - val testRepo = root / "test-repo" - - def publishLib(): Unit = - os.proc( - TestUtil.cli, - "--power", - "publish", - "--offline", - "--publish-repo", - testRepo, - "lib" - ) - .call(cwd = root) - - publishLib() - - val proc = os.proc( - TestUtil.cli, - "--power", - "run", - "--offline", - "app", - "-w", - "-r", - testRepo.toNIO.toUri.toASCIIString - ) - .spawn(cwd = root) - - try - TestUtil.withThreadPool("watch-artifacts-test", 2) { pool => - val timeout = Duration("90 seconds") - val ec = ExecutionContext.fromExecutorService(pool) - - val output = TestUtil.readLine(proc.stdout, ec, timeout) - expect(output == "Hello user") - - os.write.over(root / libSourcePath, libSource("Hola")) - publishLib() - - val secondOutput = TestUtil.readLine(proc.stdout, ec, timeout) - expect(secondOutput == "Hola user") - } - finally - if (proc.isAlive()) { - proc.destroy() - Thread.sleep(200L) - if (proc.isAlive()) - proc.destroyForcibly() - } - } - } - - test("watch test - no infinite loop") { - - val fileName = "watch.scala" - - val inputs = TestInputs( - os.rel / fileName -> - """//> using lib "org.scalameta::munit::0.7.29" - | - |class MyTests extends munit.FunSuite { - | test("is true true") { assert(true) } - |} - |""".stripMargin - ) - inputs.fromRoot { root => - val proc = os.proc(TestUtil.cli, "test", "-w", "watch.scala") - .spawn(cwd = root, mergeErrIntoOut = true) - - val watchingMsg = "Watching sources, press Ctrl+C to exit, or press Enter to re-run." - val testingMsg = "MyTests:" - - try - TestUtil.withThreadPool("watch-test-test", 2) { pool => - val timeout = Duration("10 seconds") - implicit val ec = ExecutionContext.fromExecutorService(pool) - - def lineReadIter = Iterator.continually(Try(TestUtil.readLine(proc.stdout, ec, timeout))) - .takeWhile(_.isSuccess) - .map(_.get) - - val beforeAppendOut = lineReadIter.toSeq - expect(beforeAppendOut.count(_.contains(testingMsg)) == 1) - expect(beforeAppendOut.count(_.contains(watchingMsg)) == 1) - expect(beforeAppendOut.last.contains(watchingMsg)) - - os.write.append(root / fileName, "\n//comment") - - val afterAppendOut = lineReadIter.toSeq - expect(afterAppendOut.count(_.contains(testingMsg)) == 1) - expect(afterAppendOut.count(_.contains(watchingMsg)) == 1) - expect(afterAppendOut.last.contains(watchingMsg)) - } - finally - if (proc.isAlive()) { - proc.destroy() - Thread.sleep(200L) - if (proc.isAlive()) - proc.destroyForcibly() - } - } - } - test("as jar") { val inputs = TestInputs( os.rel / "CheckCp.scala" -> diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunWithWatchTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunWithWatchTestDefinitions.scala new file mode 100644 index 0000000000..84a4c70f43 --- /dev/null +++ b/modules/integration/src/test/scala/scala/cli/integration/RunWithWatchTestDefinitions.scala @@ -0,0 +1,223 @@ +package scala.cli.integration + +import com.eed3si9n.expecty.Expecty.expect + +import scala.concurrent.duration.DurationInt +import scala.util.{Properties, Try} + +trait RunWithWatchTestDefinitions { _: RunTestDefinitions => + if (!Properties.isMac || !TestUtil.isCI) + // TODO make this pass reliably on Mac CI + test("simple --watch .scala source") { + val expectedMessage1 = "Hello" + val inputPath = os.rel / "smth.scala" + TestInputs(inputPath -> s"""object Smth extends App { println("$expectedMessage1") }""") + .fromRoot { root => + TestUtil.withProcessWatching( + proc = os.proc(TestUtil.cli, "run", ".", "--watch", extraOptions) + .spawn(cwd = root, stderr = os.Pipe), + timeout = 120.seconds + ) { (proc, timeout, ec) => + val output1 = TestUtil.readLine(proc.stdout, ec, timeout) + expect(output1 == expectedMessage1) + val expectedMessage2 = "World" + while (!TestUtil.readLine(proc.stderr, ec, timeout).contains("re-run")) + Thread.sleep(100L) + os.write.over( + root / inputPath, + s"""object Smth extends App { println("$expectedMessage2") }""" + ) + val output2 = TestUtil.readLine(proc.stdout, ec, timeout) + expect(output2 == expectedMessage2) + } + } + } + + test("watch with interactive, with multiple main classes") { + val fileName = "watch.scala" + TestInputs( + os.rel / fileName -> + """object Run1 extends App {println("Run1 launched")} + |object Run2 extends App {println("Run2 launched")} + |""".stripMargin + ).fromRoot { root => + val confDir = root / "config" + val confFile = confDir / "test-config.json" + + os.write(confFile, "{}", createFolders = true) + + if (!Properties.isWin) + os.perms.set(confDir, "rwx------") + + val configEnv = Map("SCALA_CLI_CONFIG" -> confFile.toString) + + TestUtil.withProcessWatching( + proc = os.proc(TestUtil.cli, "run", "--watch", "--interactive", fileName) + .spawn( + cwd = root, + mergeErrIntoOut = true, + stdout = os.Pipe, + stdin = os.Pipe, + env = Map("SCALA_CLI_INTERACTIVE" -> "true") ++ configEnv + ), + timeout = 60.seconds + ) { (proc, timeout, ec) => + def lineReaderIter: Iterator[String] = Iterator.continually { + val line = TestUtil.readLine(proc.stdout, ec, timeout) + println(s"Line read: $line") + line + } + + def checkLinesForError(lines: Seq[String]): Unit = munit.Assertions.assert( + !lines.exists { line => + TestUtil.removeAnsiColors(line).contains("[error]") + }, + clues(lines.toSeq) + ) + + def answerInteractivePrompt(id: Int): Unit = { + val interactivePromptLines = lineReaderIter + .takeWhile(!_.startsWith("[1]" /* probably [1] Run2 or [1] No*/ )) + .toList + expect(interactivePromptLines.nonEmpty) + checkLinesForError(interactivePromptLines) + proc.stdin.write(s"$id\n") + proc.stdin.flush() + } + + def analyzeRunOutput(restart: Boolean): Unit = { + val runResultLines = lineReaderIter + .takeWhile(!_.contains("press Enter to re-run")) + .toList + expect(runResultLines.nonEmpty) + checkLinesForError(runResultLines) + if (restart) + proc.stdin.write("\n") + proc.stdin.flush() + } + + // You have run the current scala-cli command with the --interactive mode turned on. + // Would you like to leave it on permanently? + answerInteractivePrompt(0) + + // Found several main classes. Which would you like to run? + answerInteractivePrompt(0) + expect(TestUtil.readLine(proc.stdout, ec, timeout) == "Run1 launched") + + analyzeRunOutput( /* restart */ true) + + answerInteractivePrompt(1) + expect(TestUtil.readLine(proc.stdout, ec, timeout) == "Run2 launched") + + analyzeRunOutput( /* restart */ false) + } + } + } + + if (!Properties.isMac || !TestUtil.isNativeCli || !TestUtil.isCI) + // TODO make this pass reliably on Mac CI + test("watch artifacts") { + val libSourcePath = os.rel / "lib" / "Messages.scala" + def libSource(hello: String) = + s"""//> using publish.organization "test-org" + |//> using publish.name "messages" + |//> using publish.version "0.1.0" + | + |package messages + | + |object Messages { + | def hello(name: String) = s"$hello $$name" + |} + |""".stripMargin + TestInputs( + libSourcePath -> libSource("Hello"), + os.rel / "app" / "TestApp.scala" -> + """//> using lib "test-org::messages:0.1.0" + | + |package testapp + | + |import messages.Messages + | + |@main + |def run(): Unit = + | println(Messages.hello("user")) + |""".stripMargin + ).fromRoot { root => + val testRepo = root / "test-repo" + + def publishLib(): Unit = + os.proc( + TestUtil.cli, + "--power", + "publish", + "--offline", + "--publish-repo", + testRepo, + "lib" + ) + .call(cwd = root) + + publishLib() + + TestUtil.withProcessWatching( + os.proc( + TestUtil.cli, + "--power", + "run", + "--offline", + "app", + "-w", + "-r", + testRepo.toNIO.toUri.toASCIIString + ).spawn(cwd = root) + ) { (proc, timeout, ec) => + val output = TestUtil.readLine(proc.stdout, ec, timeout) + expect(output == "Hello user") + + os.write.over(root / libSourcePath, libSource("Hola")) + publishLib() + + val secondOutput = TestUtil.readLine(proc.stdout, ec, timeout) + expect(secondOutput == "Hola user") + } + } + } + + test("watch test - no infinite loop") { + val fileName = "watch.scala" + TestInputs( + os.rel / fileName -> + """//> using lib "org.scalameta::munit::0.7.29" + | + |class MyTests extends munit.FunSuite { + | test("is true true") { assert(true) } + |} + |""".stripMargin + ).fromRoot { root => + TestUtil.withProcessWatching( + proc = os.proc(TestUtil.cli, "test", "-w", "watch.scala") + .spawn(cwd = root, mergeErrIntoOut = true), + timeout = 10.seconds + ) { (proc, timeout, ec) => + val watchingMsg = "Watching sources, press Ctrl+C to exit, or press Enter to re-run." + val testingMsg = "MyTests:" + + def lineReadIter = Iterator.continually(Try(TestUtil.readLine(proc.stdout, ec, timeout))) + .takeWhile(_.isSuccess) + .map(_.get) + + val beforeAppendOut = lineReadIter.toSeq + expect(beforeAppendOut.count(_.contains(testingMsg)) == 1) + expect(beforeAppendOut.count(_.contains(watchingMsg)) == 1) + expect(beforeAppendOut.last.contains(watchingMsg)) + + os.write.append(root / fileName, "\n//comment") + + val afterAppendOut = lineReadIter.toSeq + expect(afterAppendOut.count(_.contains(testingMsg)) == 1) + expect(afterAppendOut.count(_.contains(watchingMsg)) == 1) + expect(afterAppendOut.last.contains(watchingMsg)) + } + } + } +} diff --git a/modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala b/modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala index 78fc58bf58..022d0bf29c 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala @@ -4,9 +4,9 @@ import com.eed3si9n.expecty.Expecty.expect import java.io.File import java.net.ServerSocket -import java.util.Locale import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.{ExecutorService, Executors, ScheduledExecutorService, ThreadFactory} +import java.util.{Locale, UUID} import scala.Console.* import scala.annotation.tailrec @@ -338,4 +338,19 @@ object TestUtil { res } } + + def withProcessWatching( + proc: os.SubProcess, + threadName: String = UUID.randomUUID().toString, + poolSize: Int = 2, + timeout: Duration = 90.seconds + )(f: (os.SubProcess, Duration, ExecutionContext) => Unit): Unit = + try withThreadPool(threadName, poolSize) { pool => + f(proc, timeout, ExecutionContext.fromExecutorService(pool)) + } + finally if (proc.isAlive()) { + proc.destroy() + Thread.sleep(200L) + if (proc.isAlive()) proc.destroyForcibly() + } }