From aa485b49423f08e7be1e46139a1291224ed927cf Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 12 Sep 2024 14:12:00 +0200 Subject: [PATCH 1/4] Add a simple test for basic `--watch` .scala sources --- .../cli/integration/RunTestDefinitions.scala | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) 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..ef71d3689e 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala @@ -2354,4 +2354,37 @@ abstract class RunTestDefinitions } } } + + 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 => + val proc = os.proc(TestUtil.cli, "run", ".", "--watch", extraOptions) + .spawn(cwd = root, stderr = os.Pipe) + try + TestUtil.withThreadPool("simple-watch-scala-source-test", 2) { pool => + val timeout = Duration("90 seconds") + val ec = ExecutionContext.fromExecutorService(pool) + 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") }""".stripMargin + ) + val output2 = TestUtil.readLine(proc.stdout, ec, timeout) + expect(output2 == expectedMessage2) + } + finally + if (proc.isAlive()) { + proc.destroy() + Thread.sleep(200L) + if (proc.isAlive()) + proc.destroyForcibly() + } + } + } } From ecee1513cc9d7397582fac081d88bc5e72af7686 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 12 Sep 2024 16:37:23 +0200 Subject: [PATCH 2/4] NIT Refactor existing --watch tests --- .../cli/integration/RunTestDefinitions.scala | 164 ++++++++---------- .../cli/integration/RunTestsDefault.scala | 127 ++++++-------- .../scala/cli/integration/TestUtil.scala | 17 +- 3 files changed, 137 insertions(+), 171 deletions(-) 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 ef71d3689e..37b8c99657 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,7 @@ 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.concurrent.duration.DurationInt import scala.io.Codec import scala.jdk.CollectionConverters.* import scala.util.Properties @@ -1509,14 +1508,12 @@ abstract class RunTestDefinitions test("watch with interactive, with multiple main classes") { val fileName = "watch.scala" - - val inputs = TestInputs( + TestInputs( os.rel / fileName -> """object Run1 extends App {println("Run1 launched")} |object Run2 extends App {println("Run2 launched")} |""".stripMargin - ) - inputs.fromRoot { root => + ).fromRoot { root => val confDir = root / "config" val confFile = confDir / "test-config.json" @@ -1527,76 +1524,66 @@ abstract class RunTestDefinitions 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 - } + 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]) = munit.Assertions.assert( - !lines.exists { line => - TestUtil.removeAnsiColors(line).contains("[error]") - }, - clues(lines.toSeq) - ) + def checkLinesForError(lines: Seq[String]): Unit = 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 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) = { - 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() - } + 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) + // 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") + // Found several main classes. Which would you like to run? + answerInteractivePrompt(0) + expect(TestUtil.readLine(proc.stdout, ec, timeout) == "Run1 launched") - analyzeRunOutput( /* restart */ true) + analyzeRunOutput( /* restart */ true) - answerInteractivePrompt(1) - expect(TestUtil.readLine(proc.stdout, ec, timeout) == "Run2 launched") + 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() - } + analyzeRunOutput( /* restart */ false) + } } } @@ -2360,31 +2347,22 @@ abstract class RunTestDefinitions val inputPath = os.rel / "smth.scala" TestInputs(inputPath -> s"""object Smth extends App { println("$expectedMessage1") }""") .fromRoot { root => - val proc = os.proc(TestUtil.cli, "run", ".", "--watch", extraOptions) - .spawn(cwd = root, stderr = os.Pipe) - try - TestUtil.withThreadPool("simple-watch-scala-source-test", 2) { pool => - val timeout = Duration("90 seconds") - val ec = ExecutionContext.fromExecutorService(pool) - 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") }""".stripMargin - ) - val output2 = TestUtil.readLine(proc.stdout, ec, timeout) - expect(output2 == expectedMessage2) - } - finally - if (proc.isAlive()) { - proc.destroy() - Thread.sleep(200L) - if (proc.isAlive()) - proc.destroyForcibly() - } + TestUtil.withProcessWatching( + os.proc(TestUtil.cli, "run", ".", "--watch", extraOptions) + .spawn(cwd = root, stderr = os.Pipe) + ) { (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") }""".stripMargin + ) + val output2 = TestUtil.readLine(proc.stdout, ec, timeout) + expect(output2 == expectedMessage2) + } } } } 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..80f59df7ab 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunTestsDefault.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunTestsDefault.scala @@ -2,8 +2,7 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect -import scala.concurrent.ExecutionContext -import scala.concurrent.duration.Duration +import scala.concurrent.duration.DurationInt import scala.util.{Properties, Try} class RunTestsDefault extends RunTestDefinitions with TestDefault { @@ -84,7 +83,7 @@ class RunTestsDefault extends RunTestDefinitions with TestDefault { | def hello(name: String) = s"$hello $$name" |} |""".stripMargin - val inputs = TestInputs( + TestInputs( libSourcePath -> libSource("Hello"), os.rel / "app" / "TestApp.scala" -> """//> using lib "test-org::messages:0.1.0" @@ -97,8 +96,7 @@ class RunTestsDefault extends RunTestDefinitions with TestDefault { |def run(): Unit = | println(Messages.hello("user")) |""".stripMargin - ) - inputs.fromRoot { root => + ).fromRoot { root => val testRepo = root / "test-repo" def publishLib(): Unit = @@ -115,47 +113,33 @@ class RunTestsDefault extends RunTestDefinitions with TestDefault { 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() - } + 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" - - val inputs = TestInputs( + TestInputs( os.rel / fileName -> """//> using lib "org.scalameta::munit::0.7.29" | @@ -163,42 +147,31 @@ class RunTestsDefault extends RunTestDefinitions with TestDefault { | 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() - } + ).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() + } } From 2a37018380d050f38353faa9096b6d146dcecb50 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Fri, 13 Sep 2024 09:43:47 +0200 Subject: [PATCH 3/4] NIT Extract existing `--watch` tests to a dedicated trait and only run them for the default Scala version --- .../cli/integration/RunTestDefinitions.scala | 106 --------- .../cli/integration/RunTestsDefault.scala | 114 +-------- .../RunWithWatchTestDefinitions.scala | 223 ++++++++++++++++++ 3 files changed, 227 insertions(+), 216 deletions(-) create mode 100644 modules/integration/src/test/scala/scala/cli/integration/RunWithWatchTestDefinitions.scala 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 37b8c99657..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,7 +6,6 @@ import java.io.{ByteArrayOutputStream, File} import java.nio.charset.Charset import scala.cli.integration.util.DockerServer -import scala.concurrent.duration.DurationInt import scala.io.Codec import scala.jdk.CollectionConverters.* import scala.util.Properties @@ -1506,87 +1505,6 @@ abstract class RunTestDefinitions } } - 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) - } - } - } - test("decoded classNames in interactive ask") { val fileName = "watch.scala" @@ -2341,28 +2259,4 @@ abstract class RunTestDefinitions } } } - - 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( - os.proc(TestUtil.cli, "run", ".", "--watch", extraOptions) - .spawn(cwd = root, stderr = os.Pipe) - ) { (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") }""".stripMargin - ) - val output2 = TestUtil.readLine(proc.stdout, ec, timeout) - expect(output2 == expectedMessage2) - } - } - } } 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 80f59df7ab..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,10 +2,11 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect -import scala.concurrent.duration.DurationInt -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( @@ -68,113 +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 - 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)) - } - } - } - 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)) + } + } + } +} From c30f908c560fa9bb8691040c006d1e0abe0c516c Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Fri, 13 Sep 2024 11:30:12 +0200 Subject: [PATCH 4/4] NIT Minor refactor of `run --watch` logic --- .../src/main/scala/scala/build/Build.scala | 61 +++++++++---------- .../scala/scala/cli/commands/run/Run.scala | 24 +++----- 2 files changed, 39 insertions(+), 46 deletions(-) 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)