diff --git a/modules/cli/src/main/scala/scala/cli/commands/WatchUtil.scala b/modules/cli/src/main/scala/scala/cli/commands/WatchUtil.scala index 4881c18b66..69e5bb4677 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/WatchUtil.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/WatchUtil.scala @@ -22,6 +22,13 @@ object WatchUtil { s"$gray$message$reset" } + def clearScreen(): Unit = { + // \u001b[2J clears the entire screen + // \u001b[H moves the cursor to the top-left corner (home position) + System.out.print("\u001b[2J\u001b[H") + System.out.flush() + } + def printWatchMessage(): Unit = System.err.println(waitMessage("Watching sources")) diff --git a/modules/cli/src/main/scala/scala/cli/commands/compile/Compile.scala b/modules/cli/src/main/scala/scala/cli/commands/compile/Compile.scala index bb0203273f..0d4b89d3c3 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/compile/Compile.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/compile/Compile.scala @@ -4,6 +4,7 @@ import caseapp.* import caseapp.core.help.HelpFormat import java.io.File +import java.util.concurrent.atomic.AtomicBoolean import scala.build.options.Scope import scala.build.{Build, BuildThreads, Builds, Logger} @@ -103,7 +104,8 @@ object Compile extends ScalaCommand[CompileOptions] with BuildCommandHelpers { val shouldBuildTestScope = options.shared.scope.test.getOrElse(false) if (options.watch.watchMode) { - val watcher = Build.watch( + val isFirstRun = new AtomicBoolean(true) + val watcher = Build.watch( inputs, buildOptions, compilerMaker, @@ -115,6 +117,8 @@ object Compile extends ScalaCommand[CompileOptions] with BuildCommandHelpers { actionableDiagnostics = actionableDiagnostics, postAction = () => WatchUtil.printWatchMessage() ) { res => + if (options.watch.watchClearScreen && !isFirstRun.getAndSet(false)) + WatchUtil.clearScreen() for (builds <- res.orReport(logger)) postBuild(builds, allowExit = false) } diff --git a/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala b/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala index 8adb90cd5c..bb4fb9f845 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala @@ -16,6 +16,7 @@ import packager.windows.WindowsPackage import java.io.{ByteArrayOutputStream, OutputStream} import java.nio.file.attribute.FileTime +import java.util.concurrent.atomic.AtomicBoolean import java.util.zip.{ZipEntry, ZipOutputStream} import scala.build.* @@ -89,6 +90,7 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { val withTestScope = options.shared.scope.test.getOrElse(false) if options.watch.watchMode then { var expectedModifyEpochSecondOpt = Option.empty[Long] + val isFirstRun = new AtomicBoolean(true) val watcher = Build.watch( inputs, initialBuildOptions, @@ -101,6 +103,8 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { actionableDiagnostics = actionableDiagnostics, postAction = () => WatchUtil.printWatchMessage() ) { res => + if (options.watch.watchClearScreen && !isFirstRun.getAndSet(false)) + WatchUtil.clearScreen() res.orReport(logger).map(_.builds).foreach { case b if b.forall(_.success) => val successfulBuilds = b.collect { case s: Build.Successful => s } diff --git a/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala b/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala index 2799bbb40f..ef2f5fd5c2 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala @@ -18,6 +18,7 @@ import java.nio.charset.StandardCharsets import java.nio.file.Paths import java.time.{Instant, LocalDateTime, ZoneOffset} import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean import scala.build.* import scala.build.EitherCps.{either, value} @@ -255,7 +256,8 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers { publishLocal = false, forceSigningExternally = options.signingCli.forceSigningExternally.getOrElse(false), parallelUpload = options.parallelUpload, - options.watch.watch, + watch = options.watch.watch, + watchClearScreen = options.watch.watchClearScreen, isCi = options.publishParams.isCi, () => configDb, options.mainClass, @@ -279,6 +281,7 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers { forceSigningExternally: Boolean, parallelUpload: Option[Boolean], watch: Boolean, + watchClearScreen: Boolean, isCi: Boolean, configDb: () => ConfigDb, mainClassOptions: MainClassOptions, @@ -288,7 +291,8 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers { val actionableDiagnostics = configDb().get(Keys.actions).getOrElse(None) if watch then { - val watcher = Build.watch( + val isFirstRun = new AtomicBoolean(true) + val watcher = Build.watch( inputs = inputs, options = initialBuildOptions, compilerMaker = compilerMaker, @@ -299,8 +303,10 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers { partial = None, actionableDiagnostics = actionableDiagnostics, postAction = () => WatchUtil.printWatchMessage() - ) { - _.orReport(logger).foreach { builds => + ) { res => + if (watchClearScreen && !isFirstRun.getAndSet(false)) + WatchUtil.clearScreen() + res.orReport(logger).foreach { builds => maybePublish( builds = builds, workingDir = workingDir, diff --git a/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocal.scala b/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocal.scala index 8355c2df8c..f50da72aef 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocal.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocal.scala @@ -81,6 +81,7 @@ object PublishLocal extends ScalaCommand[PublishLocalOptions] { forceSigningExternally = options.scalaSigning.forceSigningExternally.getOrElse(false), parallelUpload = Some(true), watch = options.watch.watch, + watchClearScreen = options.watch.watchClearScreen, isCi = options.publishParams.isCi, configDb = () => ConfigDb.empty, // shouldn't be used, no need of repo credentials here mainClassOptions = options.mainClass, diff --git a/modules/cli/src/main/scala/scala/cli/commands/repl/Repl.scala b/modules/cli/src/main/scala/scala/cli/commands/repl/Repl.scala index de45c9c607..ee5e1bdafa 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/repl/Repl.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/repl/Repl.scala @@ -6,6 +6,7 @@ import coursier.error.ResolutionError import dependency.* import java.io.File +import java.util.concurrent.atomic.AtomicBoolean import java.util.zip.ZipFile import scala.build.* @@ -208,7 +209,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { } } else if (options.sharedRepl.watch.watchMode) { - val watcher = Build.watch( + val isFirstRun = new AtomicBoolean(true) + val watcher = Build.watch( inputs, initialBuildOptions, compilerMaker, @@ -220,6 +222,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { actionableDiagnostics = actionableDiagnostics, postAction = () => WatchUtil.printWatchMessage() ) { res => + if (options.sharedRepl.watch.watchClearScreen && !isFirstRun.getAndSet(false)) + WatchUtil.clearScreen() for (builds <- res.orReport(logger)) postBuild(builds, allowExit = false) { successfulBuilds => 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 b74604aaf4..c74b46ae4b 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 @@ -7,7 +7,7 @@ import caseapp.core.help.HelpFormat import java.io.File import java.util.Locale import java.util.concurrent.CompletableFuture -import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference} import scala.build.* import scala.build.EitherCps.{either, value} @@ -252,7 +252,8 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers { */ val mainThreadOpt = AtomicReference(Option.empty[Thread]) - val watcher = Build.watch( + val isFirstRun = new AtomicBoolean(true) + val watcher = Build.watch( inputs = inputs, options = initialBuildOptions, compilerMaker = compilerMaker, @@ -266,6 +267,8 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers { if processOpt.get().exists(_._1.isAlive()) then WatchUtil.printWatchWhileRunningMessage() else WatchUtil.printWatchMessage() ) { res => + if (options.sharedRun.watch.watchClearScreen && !isFirstRun.getAndSet(false)) + WatchUtil.clearScreen() for ((process, onExitProcess) <- processOpt.get()) { onExitProcess.cancel(true) ProcUtil.interruptProcess(process, logger) diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedWatchOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedWatchOptions.scala index df4e28ec7e..11f43c9fed 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedWatchOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedWatchOptions.scala @@ -18,7 +18,13 @@ final case class SharedWatchOptions( @Tag(tags.should) @Tag(tags.inShortHelp) @Name("revolver") - restart: Boolean = false + restart: Boolean = false, + @Group(HelpGroup.Watch.toString) + @HelpMessage("Clear the screen each time watch mode detects changes and re-compiles or re-runs") + @Tag(tags.implementation) + @Name("watchCls") + @Name("watchClear") + watchClearScreen: Boolean = false ) { // format: on lazy val watchMode: Boolean = watch || restart diff --git a/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala b/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala index c1d528a530..1c201ec0b1 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala @@ -4,6 +4,7 @@ import caseapp.* import caseapp.core.help.HelpFormat import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean import scala.build.* import scala.build.EitherCps.{either, value} @@ -146,7 +147,8 @@ object Test extends ScalaCommand[TestOptions] { } if (options.watch.watchMode) { - val watcher = Build.watch( + val isFirstRun = new AtomicBoolean(true) + val watcher = Build.watch( inputs, initialBuildOptions, compilerMaker, @@ -158,6 +160,8 @@ object Test extends ScalaCommand[TestOptions] { actionableDiagnostics = actionableDiagnostics, postAction = () => WatchUtil.printWatchMessage() ) { res => + if (options.watch.watchClearScreen && !isFirstRun.getAndSet(false)) + WatchUtil.clearScreen() for (builds <- res.orReport(logger)) maybeTest(builds, allowExit = false) } diff --git a/modules/cli/src/test/scala/cli/tests/WatchUtilTests.scala b/modules/cli/src/test/scala/cli/tests/WatchUtilTests.scala new file mode 100644 index 0000000000..9eb4783d40 --- /dev/null +++ b/modules/cli/src/test/scala/cli/tests/WatchUtilTests.scala @@ -0,0 +1,55 @@ +package cli.tests + +import com.eed3si9n.expecty.Expecty.expect +import munit.FunSuite +import scala.cli.commands.WatchUtil +import java.io.ByteArrayOutputStream +import java.io.PrintStream + +class WatchUtilTests extends FunSuite { + + test("clearScreen prints correct ANSI escape codes") { + val out = new ByteArrayOutputStream() + val ps = new PrintStream(out) + val oldOut = System.out + try { + System.setOut(ps) + WatchUtil.clearScreen() + ps.flush() + val output = out.toString() + expect(output == "\u001b[2J\u001b[H") + } + finally + System.setOut(oldOut) + } + + test("printWatchMessage prints to stderr") { + val err = new ByteArrayOutputStream() + val ps = new PrintStream(err) + val oldErr = System.err + try { + System.setErr(ps) + WatchUtil.printWatchMessage() + ps.flush() + val output = err.toString() + expect(output.contains("Watching sources")) + } + finally + System.setErr(oldErr) + } + + test("printWatchWhileRunningMessage prints to stderr") { + val err = new ByteArrayOutputStream() + val ps = new PrintStream(err) + val oldErr = System.err + try { + System.setErr(ps) + WatchUtil.printWatchWhileRunningMessage() + ps.flush() + val output = err.toString() + expect(output.contains("Watching sources")) + } + finally + System.setErr(oldErr) + } +} diff --git a/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala index 311eef2601..ce111ab4ab 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala @@ -5,6 +5,7 @@ import com.eed3si9n.expecty.Expecty.expect import java.io.File import scala.cli.integration.util.BloopUtil +import scala.concurrent.duration.DurationInt import scala.util.Properties abstract class CompileTestDefinitions @@ -903,4 +904,40 @@ abstract class CompileTestDefinitions ) } } + + // TODO make this pass reliably on Mac CI + if (!Properties.isMac || !TestUtil.isCI) + test("compile --watch with --watch-clear-screen clears screen on recompile") { + val inputPath = os.rel / "example.scala" + + def code(hasError: Boolean) = + if (hasError) """object Example { val x: String = 1 }""" // compile error + else """object Example { val x: Int = 1 }""" // compiles fine + + TestInputs(inputPath -> code(hasError = false)).fromRoot { root => + TestUtil.withProcessWatching( + proc = os.proc( + TestUtil.cli, + "compile", + inputPath.toString(), + "--watch", + "--watch-clear-screen", + extraOptions + ) + .spawn(cwd = root, mergeErrIntoOut = true), + timeout = 120.seconds + ) { (proc, timeout, ec) => + var line = TestUtil.readLine(proc.stdout, ec, timeout) + while (!line.contains("Watching sources")) + line = TestUtil.readLine(proc.stdout, ec, timeout) + os.write.over(root / inputPath, code(hasError = true)) + line = TestUtil.readLine(proc.stdout, ec, timeout) + while (!line.contains("error") && !line.contains("\u001b[2J")) + line = TestUtil.readLine(proc.stdout, ec, timeout) + while (!line.toLowerCase.contains("error")) + line = TestUtil.readLine(proc.stdout, ec, timeout) + expect(line.toLowerCase.contains("error")) + } + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala index b5f24f1cc3..8c0b6dd4a7 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala @@ -9,6 +9,7 @@ import java.util import java.util.zip.ZipFile import scala.cli.integration.TestUtil.* +import scala.concurrent.duration.DurationInt import scala.jdk.CollectionConverters.* import scala.util.{Properties, Using} @@ -1560,4 +1561,38 @@ abstract class PackageTestDefinitions extends ScalaCliSuite with TestScalaVersio expect(res.out.trim().contains(s"$moduleName.js")) } } + + // TODO make this pass reliably on Mac CI + if (!Properties.isMac || !TestUtil.isCI) + test("package --watch with --watch-clear-screen clears screen on repackage") { + val inputPath = os.rel / "example.scala" + + def code(message: String) = + s"""object Example extends App { println("$message") }""" + + TestInputs(inputPath -> code("Hello1")).fromRoot { root => + TestUtil.withProcessWatching( + proc = os.proc( + TestUtil.cli, + "--power", + "package", + inputPath.toString(), + "--watch", + "--watch-clear-screen", + extraOptions + ) + .spawn(cwd = root, mergeErrIntoOut = true), + timeout = 120.seconds + ) { (proc, timeout, ec) => + var line = TestUtil.readLine(proc.stdout, ec, timeout) + while (!line.contains("Watching sources")) + line = TestUtil.readLine(proc.stdout, ec, timeout) + os.write.over(root / inputPath, code("Hello2")) + line = TestUtil.readLine(proc.stdout, ec, timeout) + while (!line.contains("Watching sources") && !line.contains("\u001b[2J")) + line = TestUtil.readLine(proc.stdout, ec, timeout) + expect(line.contains("Watching sources") || line.contains("\u001b[2J")) + } + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/PublishLocalTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/PublishLocalTestDefinitions.scala index d26469eeb2..a573401d24 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/PublishLocalTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/PublishLocalTestDefinitions.scala @@ -2,6 +2,9 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect +import scala.concurrent.duration.DurationInt +import scala.util.Properties + abstract class PublishLocalTestDefinitions extends ScalaCliSuite with TestScalaVersionArgs { this: TestScalaVersion => protected def extraOptions: Seq[String] = @@ -384,4 +387,53 @@ abstract class PublishLocalTestDefinitions extends ScalaCliSuite with TestScalaV .call(cwd = root) } } + + // TODO make this pass reliably on Mac CI + if (!Properties.isMac || !TestUtil.isCI) + test("publish local --watch with --watch-clear-screen clears screen on republish") { + TestInputs( + os.rel / "project.scala" -> + s"""//> using publish.organization test.org + |//> using publish.name test-proj + |//> using publish.version 1.0.0 + | + |object Project { def value = 1 } + |""".stripMargin + ).fromRoot { root => + val ivy2Local = root / "ivy2-local" + TestUtil.withProcessWatching( + proc = os.proc( + TestUtil.cli, + "--power", + "publish", + "local", + ".", + "--watch", + "--watch-clear-screen", + "--ivy2-home", + ivy2Local.toString, + extraOptions + ) + .spawn(cwd = root, mergeErrIntoOut = true), + timeout = 180.seconds + ) { (proc, timeout, ec) => + var line = TestUtil.readLine(proc.stdout, ec, timeout) + while (!line.contains("Watching sources")) + line = TestUtil.readLine(proc.stdout, ec, timeout) + os.write.over( + root / "project.scala", + s"""//> using publish.organization test.org + |//> using publish.name test-proj + |//> using publish.version 1.0.0 + | + |object Project { def value = 2 } + |""".stripMargin + ) + line = TestUtil.readLine(proc.stdout, ec, timeout) + while (!line.contains("Watching sources") && !line.contains("\u001b[2J")) + line = TestUtil.readLine(proc.stdout, ec, timeout) + expect(line.contains("Watching sources") || line.contains("\u001b[2J")) + } + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala index 7460946d60..f61d87503b 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala @@ -3,6 +3,7 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect import scala.cli.integration.TestUtil.removeAnsiColors +import scala.concurrent.duration.DurationInt import scala.util.Properties abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionArgs { @@ -287,4 +288,37 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr } } } + + // TODO make this pass reliably on Mac CI + if (!Properties.isMac || !TestUtil.isCI) + test("repl --watch with --watch-clear-screen clears screen on rerun") { + val inputPath = os.rel / "deps.scala" + + def code(value: Int) = s"""object Deps { val x = $value }""" + + TestInputs(inputPath -> code(1)).fromRoot { root => + TestUtil.withProcessWatching( + proc = os.proc( + TestUtil.cli, + "repl", + inputPath.toString(), + "--watch", + "--watch-clear-screen", + "--repl-dry-run", + extraOptions + ) + .spawn(cwd = root, mergeErrIntoOut = true), + timeout = 120.seconds + ) { (proc, timeout, ec) => + var line = TestUtil.readLine(proc.stdout, ec, timeout) + while (!line.contains("Watching sources")) + line = TestUtil.readLine(proc.stdout, ec, timeout) + os.write.over(root / inputPath, code(2)) + line = TestUtil.readLine(proc.stdout, ec, timeout) + while (!line.contains("Watching sources") && !line.contains("\u001b[2J")) + line = TestUtil.readLine(proc.stdout, ec, timeout) + expect(line.contains("Watching sources") || line.contains("\u001b[2J")) + } + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunWithWatchTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunWithWatchTestDefinitions.scala index 9975cf68bb..37f6e47cc1 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunWithWatchTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunWithWatchTestDefinitions.scala @@ -2,6 +2,7 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect +import scala.annotation.tailrec import scala.cli.integration.TestUtil.ProcOps import scala.concurrent.duration.DurationInt import scala.util.{Properties, Try} @@ -428,4 +429,145 @@ trait RunWithWatchTestDefinitions { this: RunTestDefinitions => test("watch mode doesnt hang on Bloop when rebuilding repeatedly") { testRepeatedRerunsWithWatch() } + + // TODO make this pass reliably on Mac CI + if (!Properties.isMac || !TestUtil.isCI) + test("--watch with --watch-clear-screen clears screen on rerun") { + val expectedMessage1 = "Hello1" + val expectedMessage2 = "Hello2" + val inputPath = os.rel / "example.scala" + + def code(message: String) = s"""object Example extends App { println("$message") }""" + + TestInputs(inputPath -> code(expectedMessage1)).fromRoot { root => + TestUtil.withProcessWatching( + proc = os.proc( + TestUtil.cli, + "run", + inputPath.toString(), + "--watch", + "--watch-clear-screen", + extraOptions + ) + .spawn(cwd = root, mergeErrIntoOut = true), + timeout = 120.seconds + ) { (proc, timeout, ec) => + def readLine(): String = TestUtil.readLine(proc.stdout, ec, timeout) + @tailrec + def readNextStableLine(): String = { + val line = readLine() + if (line.contains("Compiling project") || line.contains("Compiled project")) + readNextStableLine() + else line + } + val output1 = readNextStableLine() + expect(output1 == expectedMessage1) + var line = readLine() + while (!line.contains("Watching sources")) + line = readLine() + Thread.sleep(1000) + os.write.over(root / inputPath, code(expectedMessage2)) + line = readLine() + while (!line.contains(expectedMessage2) && !line.contains("\u001b[2J")) + line = readLine() + while (!line.contains(expectedMessage2)) + line = readLine() + expect(line.contains(expectedMessage2)) + } + } + } + + if (!Properties.isMac || !TestUtil.isCI) + test("compile --watch with --watch-clear-screen clears screen on rerun") { + val inputPath = os.rel / "example.scala" + def code = s"""object Example extends App { println("Hello") }""" + + TestInputs(inputPath -> code).fromRoot { root => + TestUtil.withProcessWatching( + proc = os.proc( + TestUtil.cli, + "compile", + inputPath.toString(), + "--watch", + "--watch-clear-screen", + extraOptions + ) + .spawn(cwd = root, mergeErrIntoOut = true), + timeout = 120.seconds + ) { (proc, timeout, ec) => + def readLine(): String = TestUtil.readLine(proc.stdout, ec, timeout) + @tailrec + def readNextStableLine(): String = { + val line = readLine() + if (line.contains("Compiling project") || line.contains("Compiled project")) + readNextStableLine() + else line + } + // First run + var line = readLine() + while (!line.contains("Watching sources")) + line = readLine() + + Thread.sleep(1000) + os.write.append(root / inputPath, "\n// comment") + + line = readLine() + // We expect the clear screen escape code to be present before the next "Compiling project" or "Watching sources" + while (!line.contains("\u001b[2J") && !line.contains("Watching sources")) + line = readLine() + + expect(line.contains("\u001b[2J")) + } + } + } + + if (!Properties.isMac || !TestUtil.isCI) + test("test --watch with --watch-clear-screen clears screen on rerun") { + val inputPath = os.rel / "example.test.scala" + def code(message: String) = + s"""//> using dep org.scalameta::munit::0.7.29 + |class MyTests extends munit.FunSuite { + | test("test") { println("$message"); assert(true) } + |} + |""".stripMargin + + TestInputs(inputPath -> code("Hello1")).fromRoot { root => + TestUtil.withProcessWatching( + proc = os.proc( + TestUtil.cli, + "test", + inputPath.toString(), + "--watch", + "--watch-clear-screen", + extraOptions + ) + .spawn(cwd = root, mergeErrIntoOut = true), + timeout = 120.seconds + ) { (proc, timeout, ec) => + def readLine(): String = TestUtil.readLine(proc.stdout, ec, timeout) + @tailrec + def readNextStableLine(): String = { + val line = readLine() + if (line.contains("Compiling project") || line.contains("Compiled project")) + readNextStableLine() + else line + } + + // Wait for first run to finish + var line = readLine() + while (!line.contains("Watching sources")) + line = readLine() + + Thread.sleep(1000) + os.write.over(root / inputPath, code("Hello2")) + + line = readLine() + // We expect the clear screen escape code to be present + while (!line.contains("\u001b[2J") && !line.contains("Hello2")) + line = readLine() + + expect(line.contains("\u001b[2J")) + } + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala index 2e3bdd6f55..a13f301f28 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala @@ -5,6 +5,8 @@ import com.eed3si9n.expecty.Expecty.expect import scala.annotation.tailrec import scala.cli.integration.Constants.munitVersion import scala.cli.integration.TestUtil.StringOps +import scala.concurrent.duration.DurationInt +import scala.util.Properties abstract class TestTestDefinitions extends ScalaCliSuite with TestScalaVersionArgs { this: TestScalaVersion => @@ -1038,4 +1040,49 @@ abstract class TestTestDefinitions extends ScalaCliSuite with TestScalaVersionAr expect(err.countOccurrences(expectedWarning) == 1) } } + + // TODO make this pass reliably on Mac CI + if (!Properties.isMac || !TestUtil.isCI) + test("test --watch with --watch-clear-screen clears screen on rerun") { + val inputPath = os.rel / "MyTests.test.scala" + val expectedMessage1 = "test1" + val expectedMessage2 = "test2" + + def code(message: String) = + s"""//> using dep org.scalameta::munit::$munitVersion + | + |class MyTests extends munit.FunSuite { + | test("foo") { + | assert(2 + 2 == 4) + | println("$message") + | } + |} + |""".stripMargin + + TestInputs(inputPath -> code(expectedMessage1)).fromRoot { root => + TestUtil.withProcessWatching( + proc = os.proc( + TestUtil.cli, + "test", + inputPath.toString(), + "--watch", + "--watch-clear-screen", + extraOptions + ) + .spawn(cwd = root, mergeErrIntoOut = true), + timeout = 180.seconds + ) { (proc, timeout, ec) => + var line = TestUtil.readLine(proc.stdout, ec, timeout) + while (!line.contains("Watching sources")) + line = TestUtil.readLine(proc.stdout, ec, timeout) + os.write.over(root / inputPath, code(expectedMessage2)) + line = TestUtil.readLine(proc.stdout, ec, timeout) + while (!line.contains(expectedMessage2) && !line.contains("\u001b[2J")) + line = TestUtil.readLine(proc.stdout, ec, timeout) + while (!line.contains(expectedMessage2)) + line = TestUtil.readLine(proc.stdout, ec, timeout) + expect(line.contains(expectedMessage2)) + } + } + } } diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index a10e1b29e7..8c143c497b 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1957,6 +1957,12 @@ Aliases: `--revolver` Run the application in the background, automatically kill the process and restart if sources have been changed +### `--watch-clear-screen` + +Aliases: `--watch-clear`, `--watch-cls` + +Clear the screen each time watch mode detects changes and re-compiles or re-runs + ## Internal options ### Add path options diff --git a/website/docs/reference/scala-command/cli-options.md b/website/docs/reference/scala-command/cli-options.md index fe34ea5834..4299c80742 100644 --- a/website/docs/reference/scala-command/cli-options.md +++ b/website/docs/reference/scala-command/cli-options.md @@ -1459,6 +1459,14 @@ Aliases: `--revolver` Run the application in the background, automatically kill the process and restart if sources have been changed +### `--watch-clear-screen` + +Aliases: `--watch-clear`, `--watch-cls` + +`IMPLEMENTATION specific` per Scala Runner specification + +Clear the screen each time watch mode detects changes and re-compiles or re-runs + ## Internal options ### Bsp options diff --git a/website/docs/reference/scala-command/runner-specification.md b/website/docs/reference/scala-command/runner-specification.md index 32d84cd048..90a8523215 100644 --- a/website/docs/reference/scala-command/runner-specification.md +++ b/website/docs/reference/scala-command/runner-specification.md @@ -650,6 +650,12 @@ Aliases: `--toolkit` Exclude sources +**--watch-clear-screen** + +Clear the screen each time watch mode detects changes and re-compiles or re-runs + +Aliases: `--watch-cls` ,`--watch-clear` + --- @@ -2055,6 +2061,12 @@ Add java properties. Note that options equal `-Dproperty=value` are assumed to b Aliases: `--java-prop` +**--watch-clear-screen** + +Clear the screen each time watch mode detects changes and re-compiles or re-runs + +Aliases: `--watch-cls` ,`--watch-clear` + **--repl-dry-run** Don't actually run the REPL, just fetch it @@ -2694,6 +2706,12 @@ Add java properties. Note that options equal `-Dproperty=value` are assumed to b Aliases: `--java-prop` +**--watch-clear-screen** + +Clear the screen each time watch mode detects changes and re-compiles or re-runs + +Aliases: `--watch-cls` ,`--watch-clear` + **--scratch-dir** Temporary / working directory where to write generated launchers @@ -3342,6 +3360,12 @@ Add java properties. Note that options equal `-Dproperty=value` are assumed to b Aliases: `--java-prop` +**--watch-clear-screen** + +Clear the screen each time watch mode detects changes and re-compiles or re-runs + +Aliases: `--watch-cls` ,`--watch-clear` + **--scratch-dir** Temporary / working directory where to write generated launchers @@ -4632,6 +4656,12 @@ Add java properties. Note that options equal `-Dproperty=value` are assumed to b Aliases: `--java-prop` +**--watch-clear-screen** + +Clear the screen each time watch mode detects changes and re-compiles or re-runs + +Aliases: `--watch-cls` ,`--watch-clear` + ---