diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d67352e9c..040e9407c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,26 +24,28 @@ jobs: java-version: ${{ matrix.java-version }} - name: Run tests run: sbt 'compile; ++2.13 test' - sbt-scripted: + sbt-integration: strategy: matrix: os: [ubuntu-latest, windows-latest] - name: sbt 1 plugin scripted tests (${{ matrix.os }}) + name: sbt plugin integration tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 - uses: ./.github/setup - name: Run tests - # sbtTestRunner3 is required by test-subproject (Scala 3 LTS); 2_12 by test-1. - run: sbt 'sbtTestRunner2_12/publishLocal; sbtTestRunner3/publishLocal; sbtPlugin/scripted' - sbt-scripted-2: - name: sbt 2 plugin scripted tests - runs-on: ubuntu-latest + run: sbt 'sbtPlugin/scripted; sbtPlugin3/scripted' + mill-integration: + name: Mill plugin integration tests (${{ matrix.os }}) + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 - uses: ./.github/setup - - name: Run tests - run: sbt 'sbtTestRunner2_12/publishLocal; sbtTestRunner3/publishLocal; sbtPlugin3/scripted' + - name: Run Mill integration tests + run: sbt 'millPlugin/millScripted' maven-plugin: name: Test Maven plugin runs-on: ubuntu-latest @@ -74,7 +76,7 @@ jobs: - name: Test formatting run: ./bin/scalafmt --test release: - needs: [test, sbt-scripted, sbt-scripted-2, maven-plugin, formatting] + needs: [test, sbt-integration, mill-integration, maven-plugin, formatting] if: ${{ github.ref == 'refs/heads/master' || startsWith(github.event.ref, 'refs/tags/') }} runs-on: ubuntu-latest steps: diff --git a/.scalafmt.conf b/.scalafmt.conf index 81e96d8e9..d95b545bc 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -19,6 +19,9 @@ newlines.topLevelStatementBlankLines = [ onTestFailure = "To fix this, run './bin/scalafmt' from the project root directory" +# Mill plugin integration-test fixtures are verbatim Mill projects, not project sources +project.excludePaths = ["glob:**/modules/mill/src/mill-test/**"] + fileOverride { "glob:**/core/src/test/resources/scalaFiles/scala3File.scala" { runner.dialect = Scala3 @@ -26,4 +29,8 @@ fileOverride { "glob:**/src/*/scala-3/**.scala" { runner.dialect = Scala3 } + # The Mill plugin is built on Scala 3, but its sources live in the default `src/main` layout + "glob:**/modules/mill/src/**.scala" { + runner.dialect = Scala3 + } } diff --git a/README.md b/README.md index 973b4ceef..82e004b69 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,29 @@ The Maven plugin can be added as follows in `pom.xml` under `` [![Maven You can then run Stryker4s with the command `mvn stryker4s:run`. Note that this is different than the command for the sbt plugin. +## Mill plugin + +Stryker4s provides a plugin for the [Mill](https://mill-build.org/) build tool. Import the plugin in your `build.mill` and mix the `Stryker4sModule` trait into the `ScalaModule` you want to mutation test [![Maven Central](https://img.shields.io/maven-central/v/io.stryker-mutator/mill-stryker4s_mill1_3.svg?label=Maven%20Central&colorB=brightgreen)](https://central.sonatype.com/artifact/io.stryker-mutator/mill-stryker4s_mill1_3): + +```scala +//| mvnDeps: +//| - io.stryker-mutator:::mill-stryker4s: +package build +import mill.*, scalalib.* +import stryker4s.mill.Stryker4sModule + +object foo extends ScalaModule, Stryker4sModule { + // ... + object test extends ScalaTests { + // ... + } +} +``` + +You can then run Stryker4s with `./mill foo.stryker`, where `foo` is the name of your module. The tests of the module are run via its first child test module (e.g. `object test extends ScalaTests`); override `strykerTestModule` to point it elsewhere. + +Configuration is read from `stryker`-prefixed overrides on the module (e.g. `def strykerConcurrency = Some(4)`), from `stryker4s.conf`, or from command-line arguments (e.g. `./mill foo.stryker --thresholds.break 80`). + ## Pre-release versions We also publish SNAPSHOT versions of each commit on master. To use a pre-release, add the following setting to your `build.sbt` and `plugins.sbt`: diff --git a/build.sbt b/build.sbt index 5d4c2f98c..808990f3e 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,5 @@ import Dependencies.* +import MillScripted.* import Settings.* import sbt.internal.ProjectMatrix @@ -9,36 +10,44 @@ lazy val root = (project withId "stryker4s" in file(".")) // Publish locally for sbt plugin testing addCommandAlias( "publishPluginLocal", - "set ThisBuild / version := \"0.0.0-TEST-SNAPSHOT\"; sbtPlugin/publishLocal; sbtTestRunner/publishLocal; sbtTestRunner3/publishLocal" + "set ThisBuild / version := \"0.0.0-TEST-SNAPSHOT\"; sbtPlugin/publishLocal" ), // Publish to .m2 folder for Maven plugin testing addCommandAlias( "publishM2Local", - "set ThisBuild / version := \"SET-BY-SBT-SNAPSHOT\"; core/publishM2;" + "set ThisBuild / version := \"SET-BY-SBT-SNAPSHOT\"; core/publishM2; testkit/publishM2" ), // Publish to .ivy folder for command runner local testing addCommandAlias( "publishCommandRunnerLocal", "set ThisBuild / version := \"0.0.0-TEST-SNAPSHOT\"; commandRunner/publishLocal" + ), + // Publish Mill plugin + test runner for Mill plugin local testing + addCommandAlias( + "publishMillLocal", + "set ThisBuild / version := \"0.0.0-TEST-SNAPSHOT\"; millPlugin/publishLocal" ) ) .aggregate( - (core.projectRefs ++ - commandRunner.projectRefs ++ - sbtPlugin.projectRefs ++ - sbtTestRunner.projectRefs ++ - testRunnerApi.projectRefs ++ + ( api.projectRefs ++ - testkit.projectRefs) * + commandRunner.projectRefs ++ + core.projectRefs ++ + millPlugin.projectRefs ++ + sbtPlugin.projectRefs ++ + testRunner.projectRefs ++ + testkit.projectRefs ++ + testRunnerApi.projectRefs + ) * ) lazy val core = (projectMatrix in file("modules") / "core") - .settings(commonSettings, coreSettings, publishLocalDependsOn(api, testRunnerApi, testkit)) + .settings(commonSettings, coreSettings, publishLocalDependsOn(api, testRunnerApi, testRunner)) .dependsOn(api, testRunnerApi, testkit % Test) .jvmPlatform(scalaVersions = versions.crossScalaVersions) lazy val commandRunner = (projectMatrix in file("modules") / "commandRunner") - .settings(commonSettings, commandRunnerSettings, publishLocalDependsOn(core, testkit)) + .settings(commonSettings, commandRunnerSettings, publishLocalDependsOn(core)) .dependsOn(core, testkit % Test) .jvmPlatform(scalaVersions = versions.crossScalaVersions) @@ -46,12 +55,26 @@ lazy val commandRunner = (projectMatrix in file("modules") / "commandRunner") lazy val sbtPlugin = (projectMatrix in file("modules") / "sbt") .enablePlugins(SbtPlugin) .defaultAxes(VirtualAxis.scalaPartialVersion("2.12"), VirtualAxis.jvm) - .settings(commonSettings, sbtPluginSettings, publishLocalDependsOn(core)) + .settings(commonSettings, sbtPluginSettings, publishLocalDependsOn(core, testRunner)) .dependsOn(core) .jvmPlatform(scalaVersions = Seq(versions.scala212, versions.scala3Lts)) -lazy val sbtTestRunner = (projectMatrix in file("modules") / "sbtTestRunner") - .settings(commonSettings, sbtTestRunnerSettings, publishLocalDependsOn(testRunnerApi)) +// Mill plugins are compiled with the Scala version of the minimum supported Mill version +lazy val millPlugin = (projectMatrix in file("modules") / "mill") + .defaultAxes(VirtualAxis.scalaABIVersion(versions.scala3Lts), VirtualAxis.jvm) + .settings( + commonSettings, + millPluginSettings, + publishLocalDependsOn(core, testRunner), + millScripted := millScriptedTask + .dependsOn(publishLocal, testRunner.jvm(versions.scala3Lts) / publishLocal) + .value + ) + .dependsOn(core, testkit % Test) + .jvmPlatform(scalaVersions = Seq(versions.scala3Lts)) + +lazy val testRunner = (projectMatrix in file("modules") / "testRunner") + .settings(commonSettings, testRunnerSettings, publishLocalDependsOn(testRunnerApi)) .dependsOn(testRunnerApi) .jvmPlatform(scalaVersions = versions.crossScalaVersions) diff --git a/docs/configuration.md b/docs/configuration.md index 95185b818..bf7e2ec1e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,6 +15,7 @@ Configuration methods can be combined seamlessly. The priority is as follows: | Source | [CLI args](#cli-args) | [Build tool settings](#build-tool-settings-sbt) | [Config file](#config-file) | | -------------- | --------------------- | ----------------------------------------------- | --------------------------- | | Sbt plugin | ✅ | ✅ | ✅ | +| Mill plugin | ✅ | ✅ | ✅ | | Maven plugin | ❌ | ❌ | ✅ | | Command runner | ✅ | N/A | ✅ | @@ -48,6 +49,20 @@ You can also see the value of a key by running e.g. `sbt show strykerBaseDir`. In `*.sbt` files the keys are imported automatically, in `project/*.scala` files you can import them from `import stryker4s.sbt.Stryker4sPlugin.autoImport._`. Your editor will autocomplete the keys for you. ::: +### Build tool settings (Mill) + +The Mill plugin reads some default values from the module, but you can override them by defining `stryker`-prefixed members on the module the `Stryker4sModule` trait is mixed into. Each member is named `stryker` followed by the camelCase name of the config key and returns an `Task[Option[_]]`: + +```scala +import stryker4s.mill.Stryker4sModule + +object core extends ScalaModule, Stryker4sModule { + override def strykerMutate = Task(Some(Seq("src/main/scala/**/*.scala"))) + override def strykerConcurrency = Task(Some(4)) + override def strykerDashboardModule = Task(Some("core")) +} +``` + ### Config file The `stryker4s.conf` file is read from the root of the project. This file is read in the HOCON-format. All configuration should be in the "stryker4s" namespace and in kebab-case. @@ -362,6 +377,8 @@ How to adjust the loglevel depends on how you run stryker4s: - sbt plugin - Add `stryker / logLevel := Level.Debug` to your build.sbt. Or use `set stryker / logLevel := Level.Debug` if you are in a sbt session. - Options: `Debug`, `Info`, `Warn`, `Error` +- Mill plugin + - Pass Mill's `--debug` flag, like so: `./mill --debug foo.stryker` - Commandrunner - Pass the loglevel as a parameter when running, like so: `--debug` - Options: `--debug`, `--info`, `--warn`, `--error` (not case sensitive) diff --git a/docs/getting-started.md b/docs/getting-started.md index 78cd10662..5df0eb287 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -6,7 +6,7 @@ sidebar_position: 1 Stryker4s is a mutation testing framework for Scala. It allows you to test your tests by temporarily inserting bugs. -This guide is for the sbt plugin for Stryker4s. For other ways to run Stryker4s, such as on Maven projects, look at our [README](https://github.com/stryker-mutator/stryker4s/blob/master/README.md#getting-started). +This guide is for the sbt plugin for Stryker4s. For other ways to run Stryker4s, such as on Mill or Maven projects, look at our [README](https://github.com/stryker-mutator/stryker4s/blob/master/README.md#getting-started). ## 1 Install diff --git a/modules/sbt/src/main/scala/stryker4s/model/TestRunnerId.scala b/modules/core/src/main/scala/stryker4s/model/TestRunnerId.scala similarity index 100% rename from modules/sbt/src/main/scala/stryker4s/model/TestRunnerId.scala rename to modules/core/src/main/scala/stryker4s/model/TestRunnerId.scala diff --git a/modules/sbt/src/main/scala/stryker4s/sbt/runner/ProcessTestRunner.scala b/modules/core/src/main/scala/stryker4s/run/testrunner/ProcessTestRunner.scala similarity index 84% rename from modules/sbt/src/main/scala/stryker4s/sbt/runner/ProcessTestRunner.scala rename to modules/core/src/main/scala/stryker4s/run/testrunner/ProcessTestRunner.scala index e368e9556..59e890c77 100644 --- a/modules/sbt/src/main/scala/stryker4s/sbt/runner/ProcessTestRunner.scala +++ b/modules/core/src/main/scala/stryker4s/run/testrunner/ProcessTestRunner.scala @@ -1,7 +1,7 @@ -package stryker4s.sbt.runner +package stryker4s.run.testrunner import cats.data.NonEmptyList -import cats.effect.{IO, Resource} +import cats.effect.{Deferred, IO, Resource} import cats.syntax.all.* import com.comcast.ip4s.* import fs2.Stream @@ -9,8 +9,6 @@ import fs2.io.file import fs2.io.file.Files import fs2.io.process.{Process, ProcessBuilder} import mutationtesting.{MutantResult, MutantStatus} -import sbt.Tests -import sbt.testing.Framework import stryker4s.config.Config import stryker4s.extension.DurationExtensions.* import stryker4s.log.Logger @@ -25,6 +23,8 @@ import java.nio.file.Path import java.util.concurrent.TimeUnit import scala.concurrent.duration.* +/** A TestRunner that runs tests in a forked JVM (the `stryker4s-testrunner` process), communicating over a socket. + */ class ProcessTestRunner(testProcess: TestRunnerConnection) extends TestRunner { override def runMutant(mutant: MutantWithId, testsToRun: Seq[TestFile]): IO[MutantResult] = { @@ -106,22 +106,49 @@ class ProcessTestRunner(testProcess: TestRunnerConnection) extends TestRunner { } } -object ProcessTestRunner extends TestInterfaceMapper { +object ProcessTestRunner { private val classPathSeparator = java.io.File.pathSeparator + /** Create a [[ProcessTestRunner]], wrapped with timeout, max-reuse and retry handling. + */ + def create( + javaHome: Option[File], + classpath: Seq[Path], + javaOpts: Seq[String], + testGroups: Seq[TestGroup], + id: TestRunnerId, + timeout: Deferred[IO, FiniteDuration] + )(implicit + config: Config, + log: Logger + ): Resource[IO, TestRunner] = { + // Timeout will be set by timeoutRunner after initialTestRun + val innerTestRunner = newProcess(javaHome, classpath, javaOpts, testGroups, id) + + val withTimeout = TestRunner.timeoutRunner(timeout, innerTestRunner) + + val maybeWithMaxReuse = config.maxTestRunnerReuse.filter(_ > 0) match { + case Some(reuses) => TestRunner.maxReuseTestRunner(reuses, withTimeout) + case None => withTimeout + } + + val withRetryReuseAndTimeout = TestRunner.retryRunner(maybeWithMaxReuse) + + withRetryReuseAndTimeout + } + def newProcess( javaHome: Option[File], classpath: Seq[Path], javaOpts: Seq[String], - frameworks: Seq[Framework], - testGroups: Seq[Tests.Group], + testGroups: Seq[TestGroup], id: TestRunnerId )(implicit config: Config, log: Logger): Resource[IO, ProcessTestRunner] = for { socketAddress <- getSocketAddress(id).toResource _ <- createProcess(javaHome, classpath, javaOpts, socketAddress, id) conn <- connectToProcess(socketAddress) - _ <- setupTestRunner(conn, frameworks, testGroups).toResource + _ <- setupTestRunner(conn, testGroups).toResource } yield new ProcessTestRunner(conn) def createProcess( @@ -174,7 +201,8 @@ object ProcessTestRunner extends TestInterfaceMapper { } private def args(address: GenSocketAddress)(implicit config: Config): Seq[String] = { - val mainClass = "stryker4s.sbt.testrunner.SbtTestRunnerMain" + // The main class of the `stryker4s-testrunner` artifact, which must be on the classpath + val mainClass = "stryker4s.testrunner.TestRunnerMain" val sysProps = address match { case SocketAddress(_, port) => s"-D${TestProcessProperties.port}=$port" case UnixSocketAddress(path) => s"-D${TestProcessProperties.unixSocketPath}=$path" @@ -211,12 +239,19 @@ object ProcessTestRunner extends TestInterfaceMapper { def setupTestRunner( testProcess: TestRunnerConnection, - frameworks: Seq[Framework], - testGroups: Seq[Tests.Group] + testGroups: Seq[TestGroup] ): IO[Unit] = { - val testContext = TestProcessContext(toApiTestGroups(frameworks, testGroups)) + val testContext = TestProcessContext(testGroups) - testProcess.sendMessage(testContext).void + testProcess + .sendMessage(testContext) + .ensureOr(r => + new RuntimeException(s"Expected a SetupTestContextSuccessful, but got ${r.getClass().getSimpleName()}") + ) { + case SetupTestContextSuccessful() => true + case _ => false + } + .void } def getSocketAddress(id: TestRunnerId): IO[GenSocketAddress] = diff --git a/modules/sbt/src/main/scala/stryker4s/sbt/runner/TestRunnerConnection.scala b/modules/core/src/main/scala/stryker4s/run/testrunner/TestRunnerConnection.scala similarity index 98% rename from modules/sbt/src/main/scala/stryker4s/sbt/runner/TestRunnerConnection.scala rename to modules/core/src/main/scala/stryker4s/run/testrunner/TestRunnerConnection.scala index 5729e72ff..ee4aac38a 100644 --- a/modules/sbt/src/main/scala/stryker4s/sbt/runner/TestRunnerConnection.scala +++ b/modules/core/src/main/scala/stryker4s/run/testrunner/TestRunnerConnection.scala @@ -1,4 +1,4 @@ -package stryker4s.sbt.runner +package stryker4s.run.testrunner import cats.effect.{IO, Resource} import com.comcast.ip4s.{GenSocketAddress, UnixSocketAddress} diff --git a/modules/mill/src/main/scala/mill/api/daemon/package.scala b/modules/mill/src/main/scala/mill/api/daemon/package.scala new file mode 100644 index 000000000..8d54ff9b0 --- /dev/null +++ b/modules/mill/src/main/scala/mill/api/daemon/package.scala @@ -0,0 +1,7 @@ +package mill.api.daemon + +import mill.api.daemon.Logger + +/** Forwards `logger.prompt.colored` which is private in mill + */ +def loggerColorEnabled(logger: Logger): Boolean = logger.prompt.colored diff --git a/modules/mill/src/main/scala/stryker4s/log/MillLogger.scala b/modules/mill/src/main/scala/stryker4s/log/MillLogger.scala new file mode 100644 index 000000000..2248a6457 --- /dev/null +++ b/modules/mill/src/main/scala/stryker4s/log/MillLogger.scala @@ -0,0 +1,20 @@ +package stryker4s.log + +import mill.api.daemon.Logger as MillInternalLogger + +class MillLogger(millLogger: MillInternalLogger) extends Logger { + + override def log(level: Level, msg: => String): Unit = level match { + case Level.Debug => if millLogger.debugEnabled then millLogger.debug(msg) + case Level.Info => millLogger.info(msg) + case Level.Warn => millLogger.warn(msg) + case Level.Error => millLogger.error(msg) + } + + override def log(level: Level, msg: => String, e: => Throwable): Unit = { + log(level, msg) + log(level, e.toString()) + } + + override protected def colorEnabled: Boolean = mill.api.daemon.loggerColorEnabled(millLogger) +} diff --git a/modules/mill/src/main/scala/stryker4s/mill/MillConfigSource.scala b/modules/mill/src/main/scala/stryker4s/mill/MillConfigSource.scala new file mode 100644 index 000000000..6ed2ec084 --- /dev/null +++ b/modules/mill/src/main/scala/stryker4s/mill/MillConfigSource.scala @@ -0,0 +1,121 @@ +package stryker4s.mill + +import cats.syntax.option.* +import ciris.{ConfigKey, ConfigValue} +import fs2.io.file.Path +import stryker4s.config.codec.CirisConfigDecoders +import stryker4s.config.source.ConfigSource +import stryker4s.config.{ConfigOrder, DashboardReportType, ExcludedMutation, ReporterType} +import sttp.model.Uri + +import scala.concurrent.duration.FiniteDuration +import scala.meta.Dialect + +/** A [[stryker4s.config.source.ConfigSource]] of (pre-evaluated) values from the Mill build, provided by the `Stryker` + * config defs on [[stryker4s.mill.Stryker4sModule]] + */ +class MillConfigSource[F[_]]( + baseDirValue: Path, + mutateValue: Option[Seq[String]], + filesValue: Option[Seq[String]], + testFilterValue: Option[Seq[String]], + reportersValue: Option[Seq[String]], + excludedMutationsValue: Option[Seq[String]], + thresholdsHighValue: Option[Int], + thresholdsLowValue: Option[Int], + thresholdsBreakValue: Option[Int], + dashboardBaseUrlValue: Option[String], + dashboardReportTypeValue: Option[String], + dashboardProjectValue: Option[String], + dashboardVersionValue: Option[String], + dashboardModuleValue: Option[String], + timeoutValue: Option[FiniteDuration], + timeoutFactorValue: Option[Double], + maxTestRunnerReuseValue: Option[Int], + scalaDialectValue: Option[Dialect], + concurrencyValue: Option[Int], + debugLogTestRunnerStdoutValue: Option[Boolean], + debugDebugTestRunnerValue: Option[Boolean], + staticTmpDirValue: Option[Boolean], + cleanTmpDirValue: Option[Boolean], + openReportValue: Option[Boolean] +) extends ConfigSource[F] + with CirisConfigDecoders { + + override def name: String = "mill build" + + override def priority: ConfigOrder = ConfigOrder(15) + + private def millKey(key: String): ConfigKey = + ConfigKey(s"mill config $key") + + private def millValue[A](value: Option[A], key: String): ConfigValue[F, A] = value match { + case None => ConfigValue.missing(millKey(key)) + case Some(value) => ConfigValue.loaded(millKey(key), value) + } + + override def mutate: ConfigValue[F, Seq[String]] = millValue(mutateValue, "strykerMutate") + + override def baseDir: ConfigValue[F, Path] = + ConfigValue.loaded(millKey("moduleDir"), baseDirValue.absolute) + + override def testFilter: ConfigValue[F, Seq[String]] = millValue(testFilterValue, "strykerTestFilter") + + override def reporters: ConfigValue[F, Seq[ReporterType]] = + millValue(reportersValue, "strykerReporters").as[Seq[ReporterType]] + + override def files: ConfigValue[F, Seq[String]] = millValue(filesValue, "strykerFiles") + + override def excludedMutations: ConfigValue[F, Seq[ExcludedMutation]] = + millValue(excludedMutationsValue, "strykerExcludedMutations").as[Seq[ExcludedMutation]] + + override def thresholdsHigh: ConfigValue[F, Int] = millValue(thresholdsHighValue, "strykerThresholdsHigh") + override def thresholdsLow: ConfigValue[F, Int] = millValue(thresholdsLowValue, "strykerThresholdsLow") + override def thresholdsBreak: ConfigValue[F, Int] = millValue(thresholdsBreakValue, "strykerThresholdsBreak") + + override def dashboardBaseUrl: ConfigValue[F, Uri] = + millValue(dashboardBaseUrlValue, "strykerDashboardBaseUrl").as[Uri] + + override def dashboardReportType: ConfigValue[F, DashboardReportType] = + millValue(dashboardReportTypeValue, "strykerDashboardReportType").as[DashboardReportType] + + override def dashboardProject: ConfigValue[F, Option[String]] = + millValue(dashboardProjectValue, "strykerDashboardProject").map(_.some) + + override def dashboardVersion: ConfigValue[F, Option[String]] = + millValue(dashboardVersionValue, "strykerDashboardVersion").map(_.some) + + override def dashboardModule: ConfigValue[F, Option[String]] = + millValue(dashboardModuleValue, "strykerDashboardModule").map(_.some) + + override def timeout: ConfigValue[F, FiniteDuration] = millValue(timeoutValue, "strykerTimeout") + + override def timeoutFactor: ConfigValue[F, Double] = millValue(timeoutFactorValue, "strykerTimeoutFactor") + + override def maxTestRunnerReuse: ConfigValue[F, Option[Int]] = + millValue(maxTestRunnerReuseValue, "strykerMaxTestRunnerReuse").map(_.some) + + override def scalaDialect: ConfigValue[F, Dialect] = millValue(scalaDialectValue, "strykerScalaDialect") + + override def concurrency: ConfigValue[F, Int] = millValue(concurrencyValue, "strykerConcurrency") + + override def debugLogTestRunnerStdout: ConfigValue[F, Boolean] = + millValue(debugLogTestRunnerStdoutValue, "strykerDebugLogTestRunnerStdout") + + override def debugDebugTestRunner: ConfigValue[F, Boolean] = + millValue(debugDebugTestRunnerValue, "strykerDebugDebugTestRunner") + + override def staticTmpDir: ConfigValue[F, Boolean] = millValue(staticTmpDirValue, "strykerStaticTmpDir") + + override def cleanTmpDir: ConfigValue[F, Boolean] = millValue(cleanTmpDirValue, "strykerCleanTmpDir") + + override def openReport: ConfigValue[F, Boolean] = millValue(openReportValue, "strykerOpenReport") + + // The legacy test runner is sbt-only + override def legacyTestRunner: ConfigValue[F, Boolean] = notSupported + + override def testRunnerCommand: ConfigValue[F, String] = notSupported + override def testRunnerArgs: ConfigValue[F, String] = notSupported + + override def showHelpMessage: ConfigValue[F, Option[String]] = notSupported +} diff --git a/modules/mill/src/main/scala/stryker4s/mill/StrykerModule.scala b/modules/mill/src/main/scala/stryker4s/mill/StrykerModule.scala new file mode 100644 index 000000000..7754f4085 --- /dev/null +++ b/modules/mill/src/main/scala/stryker4s/mill/StrykerModule.scala @@ -0,0 +1,252 @@ +package stryker4s.mill + +import cats.effect.unsafe.IORuntime +import cats.effect.{Deferred, IO} +import cats.syntax.all.* +import mill.* +import mill.api.TaskCtx +import mill.javalib.api.JvmWorkerUtil +import mill.javalib.{Dep, TestModule} +import mill.scalalib.* +import stryker4s.config.codec.CirisConfigDecoders +import stryker4s.config.source.CliConfigSource +import stryker4s.log.{Logger, MillLogger} +import stryker4s.mill.runner.{MillTestDiscovery, Stryker4sMillRunner, StrykerMillContext} +import stryker4s.run.threshold.ErrorStatus +import upickle.ReadWriter + +import scala.concurrent.duration.FiniteDuration +import scala.meta.{dialects, Dialect} + +/** Mix this trait into a `ScalaModule` to add the `stryker` command, which runs Stryker4s mutation testing on the + * module. + * + * Tests are run with the test module of this module (e.g. `object test extends ScalaTests`), which is looked up + * automatically. Override `strykerTestModule` if it cannot be found. + */ +trait Stryker4sModule extends ScalaModule { + + /** The test module used to run tests during mutation testing. Defaults to the first child test module (e.g. `object + * test extends ScalaTests`). + */ + def strykerTestModule: TestModule = { + this.getClass.getMethods + .filter(m => m.getParameterCount == 0 && m.getName != "strykerTestModule") + .find { m => + classOf[TestModule].isAssignableFrom(m.getReturnType) + } + .map(_.invoke(this).asInstanceOf[TestModule]) + .getOrElse( + throw RuntimeException( + s"No test module found for module '$this'. Override with `def strykerTestModule = myTestModule` to point Stryker4s to the test module to run tests with." + ) + ) + } + + /** Pattern(s) of files to mutate. Defaults to all Scala files in `sources` */ + def strykerMutate: Task[Option[Seq[String]]] = Task(sourceGlobs(sources().map(_.path), "**.scala").some) + + /** Pattern(s) of files to include in mutation testing. Defaults to all files in `sources` */ + def strykerFiles: Task[Option[Seq[String]]] = Task(sourceGlobs(sources().map(_.path), "**").some) + + /** Filter out tests to run */ + def strykerTestFilter: Task[Option[Seq[String]]] = Task(None) + + /** Reporter(s) to use */ + def strykerReporters: Task[Option[Seq[String]]] = Task(None) + + /** Mutations to exclude from mutation testing */ + def strykerExcludedMutations: Task[Option[Seq[String]]] = Task(None) + + /** Threshold for high mutation score */ + def strykerThresholdsHigh: Task[Option[Int]] = Task(None) + + /** Threshold for low mutation score */ + def strykerThresholdsLow: Task[Option[Int]] = Task(None) + + /** Threshold score for breaking the build */ + def strykerThresholdsBreak: Task[Option[Int]] = Task(None) + + /** Base-url for the dashboard reporter */ + def strykerDashboardBaseUrl: Task[Option[String]] = Task(None) + + /** Type of dashboard report (`full`, `mutationScoreOnly`) */ + def strykerDashboardReportType: Task[Option[String]] = Task(None) + + /** Dashboard project identifier. Format of `gitProvider/organization/repository` */ + def strykerDashboardProject: Task[Option[String]] = Task(None) + + /** Dashboard version identifier */ + def strykerDashboardVersion: Task[Option[String]] = Task(None) + + /** Dashboard module identifier. Defaults to `artifactName` */ + def strykerDashboardModule: Task[Option[String]] = Task(artifactName().some) + + /** Timeout for a single mutation test run. timeoutForTestRun = netTime * timeoutFactor + timeout */ + def strykerTimeout: Task[Option[FiniteDuration]] = Task(None) + + /** Timeout factor for a single mutation test run. timeoutForTestRun = netTime * timeoutFactor + timeout */ + def strykerTimeoutFactor: Task[Option[Double]] = Task(None) + + /** Restart the test-runner after every `n` runs */ + def strykerMaxTestRunnerReuse: Task[Option[Int]] = Task(None) + + /** Dialect for parsing Scala files (e.g. `scala213source3`). Defaults to a dialect derived from `scalaVersion` */ + def strykerScalaDialect: Task[Option[Dialect]] = Task(strykerDialect(scalaVersion(), scalacOptions()).some) + + /** Number of test-runners to start */ + def strykerConcurrency: Task[Option[Int]] = Task(None) + + /** Log test-runner output to debug log */ + def strykerDebugLogTestRunnerStdout: Task[Option[Boolean]] = Task(None) + + /** Pass JVM debugging parameters to the test-runner */ + def strykerDebugDebugTestRunner: Task[Option[Boolean]] = Task(None) + + /** Force temporary dir to a static path (`target/stryker4s-tmpDir`) */ + def strykerStaticTmpDir: Task[Option[Boolean]] = Task(None) + + /** Remove the tmpDir after a successful mutation test run */ + def strykerCleanTmpDir: Task[Option[Boolean]] = Task(None) + + /** Open the report after a mutation test run */ + def strykerOpenReport: Task[Option[Boolean]] = Task(None) + + /** Run Stryker4s mutation testing. */ + def stryker(args: String*): Command[Unit] = Task.Command { + + given Logger = new MillLogger(Task.log) + given IORuntime = IORuntime.global + + val testRunnerClasspath = resolveTestRunnerArtifact()() + val testRunClasspath = strykerTestModule.runClasspath().map(_.path) + val testGroups = MillTestDiscovery.discover( + strykerTestModule.testFramework(), + strykerTestModule.testClasspath().map(_.path), + testRunClasspath + ) + + val context = StrykerMillContext( + taskCtx = summon[TaskCtx], + worker = jvmWorker().worker(), + upstreamCompileOutput = upstreamCompileOutput(), + sourceFiles = allSourceFiles().map(_.path), + compileClasspath = compileClasspath().map(_.path), + javaHome = javaHome().map(_.path), + javacOptions = javacOptions() ++ mandatoryJavacOptions(), + scalaVersion = scalaVersion(), + scalacOptions = allScalacOptions().filterNot(blocklistedScalacOptions.contains), + compilerClasspath = scalaCompilerClasspath(), + scalacPluginClasspath = scalacPluginClasspath(), + compilerBridge = scalaCompilerBridge(), + testRunClasspath = testRunClasspath, + forkArgs = strykerTestModule.forkArgs(), + testGroups = testGroups, + testRunnerClasspath = testRunnerClasspath + ) + + val millConfigSource = new MillConfigSource[IO]( + baseDirValue = fs2.io.file.Path.fromNioPath(moduleDir.toNIO), + mutateValue = strykerMutate(), + filesValue = strykerFiles(), + testFilterValue = strykerTestFilter(), + reportersValue = strykerReporters(), + excludedMutationsValue = strykerExcludedMutations(), + thresholdsHighValue = strykerThresholdsHigh(), + thresholdsLowValue = strykerThresholdsLow(), + thresholdsBreakValue = strykerThresholdsBreak(), + dashboardBaseUrlValue = strykerDashboardBaseUrl(), + dashboardReportTypeValue = strykerDashboardReportType(), + dashboardProjectValue = strykerDashboardProject(), + dashboardVersionValue = strykerDashboardVersion(), + dashboardModuleValue = strykerDashboardModule(), + timeoutValue = strykerTimeout(), + timeoutFactorValue = strykerTimeoutFactor(), + maxTestRunnerReuseValue = strykerMaxTestRunnerReuse(), + scalaDialectValue = strykerScalaDialect(), + concurrencyValue = strykerConcurrency(), + debugLogTestRunnerStdoutValue = strykerDebugLogTestRunnerStdout(), + debugDebugTestRunnerValue = strykerDebugDebugTestRunner(), + staticTmpDirValue = strykerStaticTmpDir(), + cleanTmpDirValue = strykerCleanTmpDir(), + openReportValue = strykerOpenReport() + ) + val cliConfigSource = new CliConfigSource[IO](args) + + val scoreStatus = Deferred[IO, FiniteDuration] // Create shared timeout between test-runners + .map(new Stryker4sMillRunner(context, _, List(millConfigSource, cliConfigSource))) + .flatMap(_.run()) + .unsafeRunSync() + + scoreStatus match { + case ErrorStatus => Task.fail("Mutation score is below configured threshold") + case _ => () + } + } + + /** Resolve the `stryker4s-testrunner` artifact (which is build-tool agnostic, despite its name) that is added to the + * classpath of the test-runner process. + */ + private def resolveTestRunnerArtifact(): Task[Seq[os.Path]] = Task.Anon { + val stryker4sVersion = Version.pkgVersion.getOrElse(Task.fail("Could not resolve stryker4s version")) + Task.log.debug(s"Resolved stryker4s version $stryker4sVersion") + val scalaBinaryVersion = JvmWorkerUtil.scalaBinaryVersion(scalaVersion()) + val testRunnerDep: Dep = + Dep.parse(s"io.stryker-mutator:stryker4s-testrunner_$scalaBinaryVersion:$stryker4sVersion") + + defaultResolver().classpath(Seq(testRunnerDep)).map(_.path) + } + + /** Glob patterns (relative to `moduleDir`) for all source directories of this module */ + private def sourceGlobs(sourceDirs: Seq[os.Path], postfix: String): Seq[String] = + sourceDirs.collect { + case dir if dir.startsWith(moduleDir) => (dir.relativeTo(moduleDir).segments :+ postfix).mkString("/") + } + + /** The scalameta dialect for the configured Scala version */ + private def strykerDialect(scalaVersion: String, scalacOptions: Seq[String]): Dialect = { + val hasSource3 = scalacOptions.exists(_.startsWith("-Xsource:3")) + def reader(major: String, minor: String, source3: Boolean) = + dialectDecoders.dialectReader + .decode(none, s"scala$major$minor${if source3 then "source3" else ""}") + .toOption + + scalaVersion.split('.').toList match { + case "3" :: minor :: _ => reader("3", minor, false).getOrElse(dialects.Scala3) + case major :: minor :: _ => + reader(major, minor, hasSource3) + .getOrElse(if hasSource3 then dialects.Scala213Source3 else dialects.Scala213) + case _ => if hasSource3 then dialects.Scala213Source3 else dialects.Scala213 + } + } + + private object dialectDecoders extends CirisConfigDecoders + + // Remove scalacOptions that are very likely to cause errors with generated code + // https://github.com/stryker-mutator/stryker4s/issues/321 + private def blocklistedScalacOptions: Seq[String] = + Seq( + "unused:patvars", + "unused:locals", + "unused:params", + "unused:explicits" + // -Ywarn for Scala 2.12, -W for Scala 2.13 + ).flatMap(opt => Seq(s"-Ywarn-$opt", s"-W$opt")) ++ Seq( + // Disable fatal warnings, as they will cause a lot of mutation switching statements to not compile + "-Xfatal-warnings", + "-Werror", + "-Ycheck-all-patmat" + ) + + given ReadWriter[Option[Dialect]] = upickle + .readwriter[String] + .bimap(_.map(_.toString.toLowerCase()).orEmpty, s => dialectDecoders.dialectReader.decode(None, s).toOption) +} + +private[mill] object Version { + + /** Separate out of the [[Stryker4sModule]] to get the package of stryker4s, not the build object + */ + def pkgVersion = Option(this.getClass.getPackage().getImplementationVersion()) + +} diff --git a/modules/mill/src/main/scala/stryker4s/mill/runner/MillTestDiscovery.scala b/modules/mill/src/main/scala/stryker4s/mill/runner/MillTestDiscovery.scala new file mode 100644 index 000000000..01037b2d1 --- /dev/null +++ b/modules/mill/src/main/scala/stryker4s/mill/runner/MillTestDiscovery.scala @@ -0,0 +1,62 @@ +package stryker4s.mill.runner + +import cats.syntax.option.* +import mill.javalib.testrunner.{Framework, TestRunnerUtils} +import mill.util.Jvm +import stryker4s.log.Logger +import stryker4s.testrunner.api.* + +import scala.util.Using + +/** Discovers tests in a compiled test module and maps them to the protobuf [[TestGroup]] format that is sent to the + * test-runner process. + * + * Mill has a single test framework per test module (unlike sbt), so this always results in a single [[TestGroup]]. + */ +object MillTestDiscovery { + + /** @param frameworkName + * the class name of the test framework (`TestModule#testFramework`) + * @param testClasspath + * classpath entries containing the compiled test classes to scan for tests + * @param runClasspath + * the full classpath the test classes (and framework) can be loaded with + */ + def discover( + frameworkName: String, + testClasspath: Seq[os.Path], + runClasspath: Seq[os.Path] + )(implicit log: Logger): Seq[TestGroup] = { + // Parent-first classloader so `sbt.testing` interfaces are shared with this plugin + Using.resource(Jvm.createClassLoader(runClasspath, parent = getClass().getClassLoader())) { classLoader => + val framework = Framework.framework(frameworkName)(classLoader) + // Note: `TestRunnerUtils` is marked `@internal` in Mill, but is the same discovery Mill itself uses to run tests + val discovered = TestRunnerUtils.discoverTests(classLoader, framework, testClasspath, none) + + if discovered.isEmpty then + log.warn( + s"No tests found for test framework $frameworkName. " + + "Will likely result in no tests being run and a NoCoverage result for all mutants." + ) + + val taskDefs = discovered.map { case (cls, fingerprint) => + TaskDefinition( + cls.getName().stripSuffix("$"), + toApiFingerprint(fingerprint), + explicitlySpecified = false, + Seq(SuiteSelector()) + ) + } + val runnerOptions = RunnerOptions(Seq.empty, Seq.empty) + Seq(TestGroup(framework.getClass().getCanonicalName(), taskDefs, runnerOptions.some)) + } + } + + private def toApiFingerprint(fp: sbt.testing.Fingerprint): Fingerprint = + fp match { + case a: sbt.testing.AnnotatedFingerprint => AnnotatedFingerprint(a.isModule(), a.annotationName()) + case s: sbt.testing.SubclassFingerprint => + SubclassFingerprint(s.isModule(), s.superclassName(), s.requireNoArgConstructor()) + case other: sbt.testing.Fingerprint => throw new IllegalArgumentException(s"Unknown fingerprint type: $other") + } +} diff --git a/modules/mill/src/main/scala/stryker4s/mill/runner/Stryker4sMillRunner.scala b/modules/mill/src/main/scala/stryker4s/mill/runner/Stryker4sMillRunner.scala new file mode 100644 index 000000000..2f9979ee2 --- /dev/null +++ b/modules/mill/src/main/scala/stryker4s/mill/runner/Stryker4sMillRunner.scala @@ -0,0 +1,170 @@ +package stryker4s.mill.runner + +import cats.data.NonEmptyList +import cats.effect.{Deferred, IO, Resource} +import fs2.io.file.Path +import mill.api.TaskCtx +import mill.api.daemon.Result +import mill.api.daemon.internal.CompileProblemReporter +import mill.javalib.api.{CompilationResult, JvmWorkerApi} +import stryker4s.config.source.ConfigSource +import stryker4s.config.{Config, TestFilter} +import stryker4s.exception.TestSetupException +import stryker4s.extension.FileExtensions.* +import stryker4s.log.Logger +import stryker4s.model.{CompilerErrMsg, TestRunnerId} +import stryker4s.mutants.tree.InstrumenterOptions +import stryker4s.run.testrunner.ProcessTestRunner +import stryker4s.run.{Stryker4sRunner, TestRunner} +import stryker4s.testrunner.api.TestGroup + +import java.io.File +import scala.collection.mutable.ListBuffer +import scala.concurrent.duration.FiniteDuration + +/** All required (pre-evaluated) Mill task values needed to compile the mutated sources and run the test-runner. + */ +final case class StrykerMillContext( + taskCtx: TaskCtx, + worker: JvmWorkerApi, + upstreamCompileOutput: Seq[CompilationResult], + sourceFiles: Seq[os.Path], + compileClasspath: Seq[os.Path], + javaHome: Option[os.Path], + javacOptions: Seq[String], + scalaVersion: String, + scalacOptions: Seq[String], + compilerClasspath: Seq[mill.api.PathRef], + scalacPluginClasspath: Seq[mill.api.PathRef], + compilerBridge: Option[mill.api.PathRef], + testRunClasspath: Seq[os.Path], + forkArgs: Seq[String], + testGroups: Seq[TestGroup], + testRunnerClasspath: Seq[os.Path] +) + +/** This Runner runs Stryker mutations from a Mill `stryker` command + */ +class Stryker4sMillRunner( + ctx: StrykerMillContext, + sharedTimeout: Deferred[IO, FiniteDuration], + override val extraConfigSources: List[ConfigSource[IO]] +)(using log: Logger) + extends Stryker4sRunner { + + override def resolveTestRunners( + tmpDir: Path + )(using config: Config): Either[NonEmptyList[CompilerErrMsg], NonEmptyList[Resource[IO, TestRunner]]] = + compileMutatedSources(tmpDir).map { mutatedClasses => + val classpath = (mutatedClasses +: ctx.testRunClasspath) ++ ctx.testRunnerClasspath + + val concurrency = if config.debug.debugTestRunner then { + log.warn( + "'debug.debug-test-runner' config is 'true', creating 1 test-runner with debug arguments enabled on port 8000." + ) + 1 + } else { + log.info(s"Creating ${config.concurrency} test-runners") + config.concurrency + } + + val testRunnerIds = NonEmptyList.fromListUnsafe( + (1 to concurrency).map(TestRunnerId(_)).toList + ) + + val testGroups = + if config.testFilter.isEmpty then ctx.testGroups + else { + val testFilter = new TestFilter() + ctx.testGroups.map(group => + group.copy(taskDefs = group.taskDefs.filter(taskDef => testFilter.filter(taskDef.fullyQualifiedName))) + ) + } + + testRunnerIds.map { id => + ProcessTestRunner.create( + ctx.javaHome.map(p => new File(p.toString())), + classpath.map(_.toNIO), + ctx.forkArgs, + testGroups, + id, + sharedTimeout + ) + } + } + + /** Compile the module's sources, with sources that were copied into the mutation tmpDir replaced by their mutated + * counterparts. Uses Mill's zinc worker with the same inputs as the module's own `compile` task. + * + * @return + * the path to the compiled classes, or the compiler errors if the mutated sources failed to compile + */ + private def compileMutatedSources( + tmpDir: Path + )(using Config): Either[NonEmptyList[CompilerErrMsg], os.Path] = { + val mutatedSources = ctx.sourceFiles.map { source => + val mutated = Path(source.toString()).inSubDir(tmpDir) + // Files not matched by the 'files' config are not copied to the tmpDir, for those use the original + if os.exists(os.Path(mutated.absolute.toNioPath)) then os.Path(mutated.absolute.toNioPath) else source + } + + log.debug(s"Compiling ${mutatedSources.size} mutated sources to ${ctx.taskCtx.dest}") + val reporter = new CompileErrorCollector(tmpDir) + + val result = ctx.worker.compileMixed( + upstreamCompileOutput = ctx.upstreamCompileOutput, + sources = mutatedSources, + compileClasspath = ctx.compileClasspath ++ ctx.testRunnerClasspath, + javaHome = ctx.javaHome, + javacOptions = ctx.javacOptions, + scalaVersion = ctx.scalaVersion, + scalaOrganization = "org.scala-lang", + scalacOptions = ctx.scalacOptions, + compilerClasspath = ctx.compilerClasspath, + scalacPluginClasspath = ctx.scalacPluginClasspath, + compilerBridgeOpt = ctx.compilerBridge, + reporter = Some(reporter), + reportCachedProblems = true, + incrementalCompilation = true, + auxiliaryClassFileExtensions = Seq.empty, + workDir = ctx.taskCtx.dest + )(using ctx.taskCtx) + + result match { + case Result.Success(compilationResult) => Right(compilationResult.classes.path) + case failure: Result.Failure => + NonEmptyList + .fromList(reporter.errors.toList) + .toLeft(throw TestSetupException(s"Failed to compile mutated sources: ${failure.error}")) + } + } + + override def instrumenterOptions(using Config): InstrumenterOptions = + InstrumenterOptions.testRunner +} + +/** Collects compile errors, relativized to the mutation tmpDir, so failing mutants can be rolled back. + */ +private class CompileErrorCollector(tmpDir: Path) extends CompileProblemReporter { + val errors = ListBuffer.empty[CompilerErrMsg] + + override def logError(problem: mill.api.daemon.internal.Problem): Unit = { + val path = problem.position.sourceFile + .map(f => Path.fromNioPath(f.toPath()).relativePath(tmpDir).toString) + .getOrElse("") + errors += CompilerErrMsg( + msg = problem.message, + path = path, + line = problem.position.line.getOrElse(0), + offset = problem.position.offset + ) + } + + override def start(): Unit = () + override def logWarning(problem: mill.api.daemon.internal.Problem): Unit = () + override def logInfo(problem: mill.api.daemon.internal.Problem): Unit = () + override def fileVisited(file: java.nio.file.Path): Unit = () + override def printSummary(): Unit = () + override def finish(): Unit = () + override def notifyProgress(progress: Long, total: Long): Unit = () +} diff --git a/modules/mill/src/mill-test/mutate/.mill-version b/modules/mill/src/mill-test/mutate/.mill-version new file mode 100644 index 000000000..0664a8fd2 --- /dev/null +++ b/modules/mill/src/mill-test/mutate/.mill-version @@ -0,0 +1 @@ +1.1.6 diff --git a/modules/mill/src/mill-test/mutate/build.mill b/modules/mill/src/mill-test/mutate/build.mill new file mode 100644 index 000000000..09550fae6 --- /dev/null +++ b/modules/mill/src/mill-test/mutate/build.mill @@ -0,0 +1,19 @@ +//| mvnDeps: +//| - io.stryker-mutator::mill-stryker4s_mill1:@STRYKER4S_VERSION@ +package build +import mill.*, scalalib.* +import stryker4s.mill.Stryker4sModule + +object foo extends ScalaModule, Stryker4sModule { + def scalaVersion = "3.3.8" + + // Sanity-check that build-tool config overrides are picked up. The sample scores ~66%, so a + // break threshold of 40 passes; the integration test also overrides this via CLI to force a break. + def strykerThresholdsBreak = Task(Some(40)) + def strykerReporters = Task(Some(Seq("console", "html"))) + + object test extends ScalaTests { + def testFramework = "munit.Framework" + def mvnDeps = Seq(mvn"org.scalameta::munit:1.3.3") + } +} diff --git a/modules/mill/src/mill-test/mutate/foo/src/foo/Calc.scala b/modules/mill/src/mill-test/mutate/foo/src/foo/Calc.scala new file mode 100644 index 000000000..116d1326b --- /dev/null +++ b/modules/mill/src/mill-test/mutate/foo/src/foo/Calc.scala @@ -0,0 +1,6 @@ +package foo + +object Calc { + def add(a: Int, b: Int): Int = a + b + def isPositive(n: Int): Boolean = n > 0 +} diff --git a/modules/mill/src/mill-test/mutate/foo/test/src/foo/CalcTest.scala b/modules/mill/src/mill-test/mutate/foo/test/src/foo/CalcTest.scala new file mode 100644 index 000000000..53096b811 --- /dev/null +++ b/modules/mill/src/mill-test/mutate/foo/test/src/foo/CalcTest.scala @@ -0,0 +1,14 @@ +package foo + +class CalcTest extends munit.FunSuite { + test("add") { + assertEquals(Calc.add(2, 3), 5) + } + + // `isPositive` is covered but only weakly asserted, so some of its mutants are killed while + // others survive. This gives a deterministic, partial mutation score for the test to assert on. + test("isPositive") { + assert(Calc.isPositive(1)) + assert(!Calc.isPositive(-1)) + } +} diff --git a/modules/mill/src/mill-test/mutate/mill b/modules/mill/src/mill-test/mutate/mill new file mode 100755 index 000000000..5268dce7b --- /dev/null +++ b/modules/mill/src/mill-test/mutate/mill @@ -0,0 +1,199 @@ +#!/usr/bin/env sh + +set -e + +if [ -z "${DEFAULT_MILL_VERSION}" ] ; then DEFAULT_MILL_VERSION="1.1.6"; fi + +if [ -z "${GITHUB_RELEASE_CDN}" ] ; then GITHUB_RELEASE_CDN=""; fi + +if [ -z "$MILL_MAIN_CLI" ] ; then MILL_MAIN_CLI="${0}"; fi + +MILL_REPO_URL="https://github.com/com-lihaoyi/mill" + +MILL_BUILD_SCRIPT="" + +if [ -f "build.mill" ] ; then + MILL_BUILD_SCRIPT="build.mill" +elif [ -f "build.mill.scala" ] ; then + MILL_BUILD_SCRIPT="build.mill.scala" +elif [ -f "build.sc" ] ; then + MILL_BUILD_SCRIPT="build.sc" +fi + +# `s/.*://`: +# This is a greedy match that removes everything from the beginning of the line up to (and including) the last +# colon (:). This effectively isolates the value part of the declaration. +# +# `s/#.*//`: +# This removes any comments at the end of the line. +# +# `s/['\"]//g`: +# This removes all single and double quotes from the string, wherever they appear (g is for "global"). +# +# `s/^[[:space:]]*//; s/[[:space:]]*$//`: +# These two expressions trim any leading or trailing whitespace ([[:space:]] matches spaces and tabs). +TRIM_VALUE_SED="s/.*://; s/#.*//; s/['\"]//g; s/^[[:space:]]*//; s/[[:space:]]*$//" + +if [ -z "${MILL_VERSION}" ] ; then + if [ -f ".mill-version" ] ; then + MILL_VERSION="$(tr '\r' '\n' < .mill-version | head -n 1 2> /dev/null)" + elif [ -f ".config/mill-version" ] ; then + MILL_VERSION="$(tr '\r' '\n' < .config/mill-version | head -n 1 2> /dev/null)" + elif [ -f "build.mill.yaml" ] ; then + MILL_VERSION="$(grep -E "mill-version:" "build.mill.yaml" | sed -E "$TRIM_VALUE_SED")" + elif [ -n "${MILL_BUILD_SCRIPT}" ] ; then + MILL_VERSION="$(grep -E "//\|.*mill-version" "${MILL_BUILD_SCRIPT}" | sed -E "$TRIM_VALUE_SED")" + fi +fi + +if [ -z "${MILL_VERSION}" ] ; then MILL_VERSION="${DEFAULT_MILL_VERSION}"; fi + +MILL_USER_CACHE_DIR="${XDG_CACHE_HOME:-${HOME}/.cache}/mill" + +if [ -z "${MILL_FINAL_DOWNLOAD_FOLDER}" ] ; then MILL_FINAL_DOWNLOAD_FOLDER="${MILL_USER_CACHE_DIR}/download"; fi + +MILL_NATIVE_SUFFIX="-native" +MILL_JVM_SUFFIX="-jvm" +ARTIFACT_SUFFIX="" + +# Check if GLIBC version is at least the required version +# Returns 0 (true) if GLIBC >= required version, 1 (false) otherwise +check_glibc_version() { + required_version="2.39" + required_major=$(echo "$required_version" | cut -d. -f1) + required_minor=$(echo "$required_version" | cut -d. -f2) + # Get GLIBC version from ldd --version (first line contains version like "ldd (GNU libc) 2.31") + glibc_version=$(ldd --version 2>/dev/null | head -n 1 | grep -oE '[0-9]+\.[0-9]+$' || echo "") + if [ -z "$glibc_version" ]; then + # If we can't determine GLIBC version, assume it's too old + return 1 + fi + glibc_major=$(echo "$glibc_version" | cut -d. -f1) + glibc_minor=$(echo "$glibc_version" | cut -d. -f2) + if [ "$glibc_major" -gt "$required_major" ]; then + return 0 + elif [ "$glibc_major" -eq "$required_major" ] && [ "$glibc_minor" -ge "$required_minor" ]; then + return 0 + else + return 1 + fi +} + +set_artifact_suffix() { + if [ "$(uname -s 2>/dev/null | cut -c 1-5)" = "Linux" ]; then + # Native binaries require new enough GLIBC; fall back to JVM launcher if older + if ! check_glibc_version; then + return + fi + if [ "$(uname -m)" = "aarch64" ]; then ARTIFACT_SUFFIX="-native-linux-aarch64" + else ARTIFACT_SUFFIX="-native-linux-amd64"; fi + elif [ "$(uname)" = "Darwin" ]; then + if [ "$(uname -m)" = "arm64" ]; then ARTIFACT_SUFFIX="-native-mac-aarch64" + else ARTIFACT_SUFFIX="-native-mac-amd64"; fi + else + echo "This native mill launcher supports only Linux and macOS." 1>&2 + exit 1 + fi +} + +case "$MILL_VERSION" in + *"$MILL_NATIVE_SUFFIX") + MILL_VERSION=${MILL_VERSION%"$MILL_NATIVE_SUFFIX"} + set_artifact_suffix + ;; + + *"$MILL_JVM_SUFFIX") + MILL_VERSION=${MILL_VERSION%"$MILL_JVM_SUFFIX"} + ;; + + *) + case "$MILL_VERSION" in + 0.1.* | 0.2.* | 0.3.* | 0.4.* | 0.5.* | 0.6.* | 0.7.* | 0.8.* | 0.9.* | 0.10.* | 0.11.* | 0.12.*) + ;; + *) + set_artifact_suffix + ;; + esac + ;; +esac + +MILL="${MILL_FINAL_DOWNLOAD_FOLDER}/$MILL_VERSION$ARTIFACT_SUFFIX" + +# If not already downloaded, download it +if [ ! -s "${MILL}" ] || [ "$MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT" = "1" ] ; then + case $MILL_VERSION in + 0.0.* | 0.1.* | 0.2.* | 0.3.* | 0.4.*) + MILL_DOWNLOAD_SUFFIX="" + MILL_DOWNLOAD_FROM_MAVEN=0 + ;; + 0.5.* | 0.6.* | 0.7.* | 0.8.* | 0.9.* | 0.10.* | 0.11.0-M*) + MILL_DOWNLOAD_SUFFIX="-assembly" + MILL_DOWNLOAD_FROM_MAVEN=0 + ;; + *) + MILL_DOWNLOAD_SUFFIX="-assembly" + MILL_DOWNLOAD_FROM_MAVEN=1 + ;; + esac + case $MILL_VERSION in + 0.12.0 | 0.12.1 | 0.12.2 | 0.12.3 | 0.12.4 | 0.12.5 | 0.12.6 | 0.12.7 | 0.12.8 | 0.12.9 | 0.12.10 | 0.12.11) + MILL_DOWNLOAD_EXT="jar" + ;; + 0.12.*) + MILL_DOWNLOAD_EXT="exe" + ;; + 0.*) + MILL_DOWNLOAD_EXT="jar" + ;; + *) + MILL_DOWNLOAD_EXT="exe" + ;; + esac + + MILL_TEMP_DOWNLOAD_FILE="${MILL_OUTPUT_DIR:-out}/mill-temp-download" + mkdir -p "$(dirname "${MILL_TEMP_DOWNLOAD_FILE}")" + + if [ "$MILL_DOWNLOAD_FROM_MAVEN" = "1" ] ; then + MILL_DOWNLOAD_URL="https://repo1.maven.org/maven2/com/lihaoyi/mill-dist${ARTIFACT_SUFFIX}/${MILL_VERSION}/mill-dist${ARTIFACT_SUFFIX}-${MILL_VERSION}.${MILL_DOWNLOAD_EXT}" + else + MILL_VERSION_TAG=$(echo "$MILL_VERSION" | sed -E 's/([^-]+)(-M[0-9]+)?(-.*)?/\1\2/') + MILL_DOWNLOAD_URL="${GITHUB_RELEASE_CDN}${MILL_REPO_URL}/releases/download/${MILL_VERSION_TAG}/${MILL_VERSION}${MILL_DOWNLOAD_SUFFIX}" + unset MILL_VERSION_TAG + fi + + + if [ "$MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT" = "1" ] ; then + echo "$MILL_DOWNLOAD_URL" + echo "$MILL" + exit 0 + fi + + echo "Downloading mill ${MILL_VERSION} from ${MILL_DOWNLOAD_URL} ..." 1>&2 + curl -f -L -o "${MILL_TEMP_DOWNLOAD_FILE}" "${MILL_DOWNLOAD_URL}" + + chmod +x "${MILL_TEMP_DOWNLOAD_FILE}" + + mkdir -p "${MILL_FINAL_DOWNLOAD_FOLDER}" + mv "${MILL_TEMP_DOWNLOAD_FILE}" "${MILL}" + + unset MILL_TEMP_DOWNLOAD_FILE + unset MILL_DOWNLOAD_SUFFIX +fi + +MILL_FIRST_ARG="" +if [ "$1" = "--bsp" ] || [ "${1#"-i"}" != "$1" ] || [ "$1" = "--interactive" ] || [ "$1" = "--no-server" ] || [ "$1" = "--no-daemon" ] || [ "$1" = "--help" ] ; then + # Need to preserve the first position of those listed options + MILL_FIRST_ARG=$1 + shift +fi + +unset MILL_FINAL_DOWNLOAD_FOLDER +unset MILL_OLD_DOWNLOAD_PATH +unset OLD_MILL +unset MILL_VERSION +unset MILL_REPO_URL + +# -D mill.main.cli is for compatibility with Mill 0.10.9 - 0.13.0-M2 +# We don't quote MILL_FIRST_ARG on purpose, so we can expand the empty value without quotes +# shellcheck disable=SC2086 +exec "${MILL}" $MILL_FIRST_ARG -D "mill.main.cli=${MILL_MAIN_CLI}" "$@" diff --git a/modules/mill/src/mill-test/mutate/mill.bat b/modules/mill/src/mill-test/mutate/mill.bat new file mode 100644 index 000000000..c479d075f --- /dev/null +++ b/modules/mill/src/mill-test/mutate/mill.bat @@ -0,0 +1,296 @@ +@echo off + +setlocal enabledelayedexpansion + +if [!DEFAULT_MILL_VERSION!]==[] ( set "DEFAULT_MILL_VERSION=1.1.6" ) + +if [!MILL_GITHUB_RELEASE_CDN!]==[] ( set "MILL_GITHUB_RELEASE_CDN=" ) + +if [!MILL_MAIN_CLI!]==[] ( set "MILL_MAIN_CLI=%~f0" ) + +set "MILL_REPO_URL=https://github.com/com-lihaoyi/mill" + +SET MILL_BUILD_SCRIPT= + +if exist "build.mill" ( + set MILL_BUILD_SCRIPT=build.mill +) else ( + if exist "build.mill.scala" ( + set MILL_BUILD_SCRIPT=build.mill.scala + ) else ( + if exist "build.sc" ( + set MILL_BUILD_SCRIPT=build.sc + ) else ( + rem no-op + ) + ) +) + +if [!MILL_VERSION!]==[] ( + if exist .mill-version ( + set /p MILL_VERSION=<.mill-version + ) else ( + if exist .config\mill-version ( + set /p MILL_VERSION=<.config\mill-version + ) else ( + rem Determine which config file to use for version extraction + set "MILL_VERSION_CONFIG_FILE=" + set "MILL_VERSION_SEARCH_PATTERN=" + + if exist build.mill.yaml ( + set "MILL_VERSION_CONFIG_FILE=build.mill.yaml" + set "MILL_VERSION_SEARCH_PATTERN=mill-version:" + ) else ( + if not "%MILL_BUILD_SCRIPT%"=="" ( + set "MILL_VERSION_CONFIG_FILE=%MILL_BUILD_SCRIPT%" + set "MILL_VERSION_SEARCH_PATTERN=//\|.*mill-version" + ) + ) + + rem Process the config file if found + if not "!MILL_VERSION_CONFIG_FILE!"=="" ( + rem Find the line and process it + for /f "tokens=*" %%a in ('findstr /R /C:"!MILL_VERSION_SEARCH_PATTERN!" "!MILL_VERSION_CONFIG_FILE!"') do ( + set "line=%%a" + + rem --- 1. Replicate sed 's/.*://' --- + rem This removes everything up to and including the first colon + set "line=!line:*:=!" + + rem --- 2. Replicate sed 's/#.*//' --- + rem Split on '#' and keep the first part + for /f "tokens=1 delims=#" %%b in ("!line!") do ( + set "line=%%b" + ) + + rem --- 3. Replicate sed 's/['"]//g' --- + rem Remove all quotes + set "line=!line:'=!" + set "line=!line:"=!" + + rem --- 4. Replicate sed's trim/space removal --- + rem Remove all space characters from the result. This is more robust. + set "MILL_VERSION=!line: =!" + + rem We found the version, so we can exit the loop + goto :version_found + ) + + :version_found + rem no-op + ) + ) + ) +) + +if [!MILL_VERSION!]==[] ( + set MILL_VERSION=%DEFAULT_MILL_VERSION% +) + +if [!MILL_FINAL_DOWNLOAD_FOLDER!]==[] set MILL_FINAL_DOWNLOAD_FOLDER=%USERPROFILE%\.cache\mill\download + +rem without bat file extension, cmd doesn't seem to be able to run it + +set "MILL_NATIVE_SUFFIX=-native" +set "MILL_JVM_SUFFIX=-jvm" +set "MILL_FULL_VERSION=%MILL_VERSION%" +set "MILL_DOWNLOAD_EXT=.bat" +set "ARTIFACT_SUFFIX=" +REM Check if MILL_VERSION contains MILL_NATIVE_SUFFIX +echo !MILL_VERSION! | findstr /C:"%MILL_NATIVE_SUFFIX%" >nul +if !errorlevel! equ 0 ( + set "MILL_VERSION=%MILL_VERSION:-native=%" + REM -native images compiled with graal do not support windows-arm + REM https://github.com/oracle/graal/issues/9215 + IF /I NOT "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set "ARTIFACT_SUFFIX=-native-windows-amd64" + set "MILL_DOWNLOAD_EXT=.exe" + ) else ( + rem no-op + ) +) else ( + echo !MILL_VERSION! | findstr /C:"%MILL_JVM_SUFFIX%" >nul + if !errorlevel! equ 0 ( + set "MILL_VERSION=%MILL_VERSION:-jvm=%" + ) else ( + set "SKIP_VERSION=false" + set "MILL_PREFIX=%MILL_VERSION:~0,4%" + if "!MILL_PREFIX!"=="0.1." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.2." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.3." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.4." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.5." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.6." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.7." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.8." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.9." set "SKIP_VERSION=true" + set "MILL_PREFIX=%MILL_VERSION:~0,5%" + if "!MILL_PREFIX!"=="0.10." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.11." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.12." set "SKIP_VERSION=true" + + if "!SKIP_VERSION!"=="false" ( + IF /I NOT "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set "ARTIFACT_SUFFIX=-native-windows-amd64" + set "MILL_DOWNLOAD_EXT=.exe" + ) + ) else ( + rem no-op + ) + ) +) + +set MILL=%MILL_FINAL_DOWNLOAD_FOLDER%\!MILL_FULL_VERSION!!MILL_DOWNLOAD_EXT! + +set MILL_RESOLVE_DOWNLOAD= + +if not exist "%MILL%" ( + set MILL_RESOLVE_DOWNLOAD=true +) else ( + if defined MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT ( + set MILL_RESOLVE_DOWNLOAD=true + ) else ( + rem no-op + ) +) + + +if [!MILL_RESOLVE_DOWNLOAD!]==[true] ( + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,4% + set MILL_SHORT_VERSION_PREFIX=%MILL_VERSION:~0,2% + rem Since 0.5.0 + set MILL_DOWNLOAD_SUFFIX=-assembly + rem Since 0.11.0 + set MILL_DOWNLOAD_FROM_MAVEN=1 + if [!MILL_VERSION_PREFIX!]==[0.0.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.1.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.2.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.3.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.4.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.5.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.6.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.7.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.8.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.9.] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,5% + if [!MILL_VERSION_PREFIX!]==[0.10.] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,8% + if [!MILL_VERSION_PREFIX!]==[0.11.0-M] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,5% + set DOWNLOAD_EXT=exe + if [!MILL_SHORT_VERSION_PREFIX!]==[0.] set DOWNLOAD_EXT=jar + if [!MILL_VERSION_PREFIX!]==[0.12.] set DOWNLOAD_EXT=exe + if [!MILL_VERSION!]==[0.12.0] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.1] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.2] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.3] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.4] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.5] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.6] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.7] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.8] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.9] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.10] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.11] set DOWNLOAD_EXT=jar + + set MILL_VERSION_PREFIX= + set MILL_SHORT_VERSION_PREFIX= + + for /F "delims=- tokens=1" %%A in ("!MILL_VERSION!") do set MILL_VERSION_BASE=%%A + set MILL_VERSION_MILESTONE= + for /F "delims=- tokens=2" %%A in ("!MILL_VERSION!") do set MILL_VERSION_MILESTONE=%%A + set MILL_VERSION_MILESTONE_START=!MILL_VERSION_MILESTONE:~0,1! + if [!MILL_VERSION_MILESTONE_START!]==[M] ( + set MILL_VERSION_TAG=!MILL_VERSION_BASE!-!MILL_VERSION_MILESTONE! + ) else ( + set MILL_VERSION_TAG=!MILL_VERSION_BASE! + ) + if [!MILL_DOWNLOAD_FROM_MAVEN!]==[1] ( + set MILL_DOWNLOAD_URL=https://repo1.maven.org/maven2/com/lihaoyi/mill-dist!ARTIFACT_SUFFIX!/!MILL_VERSION!/mill-dist!ARTIFACT_SUFFIX!-!MILL_VERSION!.!DOWNLOAD_EXT! + ) else ( + set MILL_DOWNLOAD_URL=!MILL_GITHUB_RELEASE_CDN!%MILL_REPO_URL%/releases/download/!MILL_VERSION_TAG!/!MILL_VERSION!!MILL_DOWNLOAD_SUFFIX! + ) + + if defined MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT ( + echo !MILL_DOWNLOAD_URL! + echo !MILL! + exit /b 0 + ) + + rem there seems to be no way to generate a unique temporary file path (on native Windows) + if defined MILL_OUTPUT_DIR ( + set MILL_TEMP_DOWNLOAD_FILE=%MILL_OUTPUT_DIR%\mill-temp-download + if not exist "%MILL_OUTPUT_DIR%" mkdir "%MILL_OUTPUT_DIR%" + ) else ( + set MILL_TEMP_DOWNLOAD_FILE=out\mill-bootstrap-download + if not exist "out" mkdir "out" + ) + + echo Downloading mill !MILL_VERSION! from !MILL_DOWNLOAD_URL! ... 1>&2 + + curl -f -L "!MILL_DOWNLOAD_URL!" -o "!MILL_TEMP_DOWNLOAD_FILE!" + + if not exist "%MILL_FINAL_DOWNLOAD_FOLDER%" mkdir "%MILL_FINAL_DOWNLOAD_FOLDER%" + move /y "!MILL_TEMP_DOWNLOAD_FILE!" "%MILL%" + + set MILL_TEMP_DOWNLOAD_FILE= + set MILL_DOWNLOAD_SUFFIX= +) + +set MILL_FINAL_DOWNLOAD_FOLDER= +set MILL_VERSION= +set MILL_REPO_URL= + +rem Need to preserve the first position of those listed options +set MILL_FIRST_ARG= +if [%~1%]==[--bsp] ( + set MILL_FIRST_ARG=%1% +) else ( + if [%~1%]==[-i] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--interactive] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--no-server] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--no-daemon] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--help] ( + set MILL_FIRST_ARG=%1% + ) + ) + ) + ) + ) +) +set "MILL_PARAMS=%*%" + +if not [!MILL_FIRST_ARG!]==[] ( + for /f "tokens=1*" %%a in ("%*") do ( + set "MILL_PARAMS=%%b" + ) +) + +rem -D mill.main.cli is for compatibility with Mill 0.10.9 - 0.13.0-M2 +"%MILL%" %MILL_FIRST_ARG% -D "mill.main.cli=%MILL_MAIN_CLI%" %MILL_PARAMS% diff --git a/modules/mill/src/test/resources/no-test-module-project/bar/src/bar/Bar.scala b/modules/mill/src/test/resources/no-test-module-project/bar/src/bar/Bar.scala new file mode 100644 index 000000000..ecdc43290 --- /dev/null +++ b/modules/mill/src/test/resources/no-test-module-project/bar/src/bar/Bar.scala @@ -0,0 +1,5 @@ +package bar + +object Bar { + def greeting: String = "hello" +} diff --git a/modules/mill/src/test/resources/unit-test-project/foo/src/foo/Calc.scala b/modules/mill/src/test/resources/unit-test-project/foo/src/foo/Calc.scala new file mode 100644 index 000000000..116d1326b --- /dev/null +++ b/modules/mill/src/test/resources/unit-test-project/foo/src/foo/Calc.scala @@ -0,0 +1,6 @@ +package foo + +object Calc { + def add(a: Int, b: Int): Int = a + b + def isPositive(n: Int): Boolean = n > 0 +} diff --git a/modules/mill/src/test/resources/unit-test-project/foo/test/src/foo/CalcTest.scala b/modules/mill/src/test/resources/unit-test-project/foo/test/src/foo/CalcTest.scala new file mode 100644 index 000000000..ff6ce68bb --- /dev/null +++ b/modules/mill/src/test/resources/unit-test-project/foo/test/src/foo/CalcTest.scala @@ -0,0 +1,7 @@ +package foo + +class CalcTest extends munit.FunSuite { + test("add") { + assertEquals(Calc.add(2, 3), 5) + } +} diff --git a/modules/mill/src/test/scala/stryker4s/mill/MillConfigSourceTest.scala b/modules/mill/src/test/scala/stryker4s/mill/MillConfigSourceTest.scala new file mode 100644 index 000000000..efd61e10e --- /dev/null +++ b/modules/mill/src/test/scala/stryker4s/mill/MillConfigSourceTest.scala @@ -0,0 +1,94 @@ +package stryker4s.mill + +import cats.effect.IO +import fs2.io.file.Path +import stryker4s.testkit.Stryker4sIOSuite + +import scala.concurrent.duration.* +import scala.meta.dialects + +class MillConfigSourceTest extends Stryker4sIOSuite { + + def emptyConfigSource: MillConfigSource[IO] = new MillConfigSource[IO]( + baseDirValue = Path("/tmp/project"), + mutateValue = None, + filesValue = None, + testFilterValue = None, + reportersValue = None, + excludedMutationsValue = None, + thresholdsHighValue = None, + thresholdsLowValue = None, + thresholdsBreakValue = None, + dashboardBaseUrlValue = None, + dashboardReportTypeValue = None, + dashboardProjectValue = None, + dashboardVersionValue = None, + dashboardModuleValue = None, + timeoutValue = None, + timeoutFactorValue = None, + maxTestRunnerReuseValue = None, + scalaDialectValue = None, + concurrencyValue = None, + debugLogTestRunnerStdoutValue = None, + debugDebugTestRunnerValue = None, + staticTmpDirValue = None, + cleanTmpDirValue = None, + openReportValue = None + ) + + test("should load filled-in values") { + val config = new MillConfigSource[IO]( + baseDirValue = Path("/tmp/project"), + mutateValue = Some(Seq("src/main/scala/**.scala")), + filesValue = None, + testFilterValue = Some(Seq("*MyTest")), + reportersValue = Some(Seq("html", "console")), + excludedMutationsValue = Some(Seq("BooleanLiteral")), + thresholdsHighValue = Some(85), + thresholdsLowValue = Some(60), + thresholdsBreakValue = Some(0), + dashboardBaseUrlValue = None, + dashboardReportTypeValue = None, + dashboardProjectValue = Some("github.com/foo/bar"), + dashboardVersionValue = Some("main"), + dashboardModuleValue = Some("my-module"), + timeoutValue = Some(5.seconds), + timeoutFactorValue = Some(1.5), + maxTestRunnerReuseValue = Some(10), + scalaDialectValue = Some(dialects.Scala3), + concurrencyValue = Some(4), + debugLogTestRunnerStdoutValue = Some(true), + debugDebugTestRunnerValue = Some(false), + staticTmpDirValue = Some(false), + cleanTmpDirValue = Some(true), + openReportValue = Some(false) + ) + + for { + _ <- config.mutate.load.assertEquals(Seq("src/main/scala/**.scala")) + _ <- config.testFilter.load.assertEquals(Seq("*MyTest")) + _ <- config.thresholdsHigh.load.assertEquals(85) + _ <- config.dashboardProject.load.assertEquals(Some("github.com/foo/bar")) + _ <- config.dashboardModule.load.assertEquals(Some("my-module")) + _ <- config.timeout.load.assertEquals(5.seconds) + _ <- config.concurrency.load.assertEquals(4) + _ <- config.cleanTmpDir.load.assertEquals(true) + } yield () + } + + test("should make the baseDir absolute") { + emptyConfigSource.baseDir.load.assertEquals(Path("/tmp/project").absolute) + } + + test("missing values are reported as missing") { + emptyConfigSource.thresholdsHigh.attempt + .map(_.leftValue.messages.loneElement) + .assertEquals("Missing mill config strykerThresholdsHigh") + } + + test("fails on values not supported by the mill plugin") { + emptyConfigSource.legacyTestRunner.attempt + .map(_.leftValue.messages.loneElement) + .assertEquals("Missing key legacyTestRunner is not supported by mill build") + } +} diff --git a/modules/mill/src/test/scala/stryker4s/mill/StrykerModuleTest.scala b/modules/mill/src/test/scala/stryker4s/mill/StrykerModuleTest.scala new file mode 100644 index 000000000..4f0060c69 --- /dev/null +++ b/modules/mill/src/test/scala/stryker4s/mill/StrykerModuleTest.scala @@ -0,0 +1,60 @@ +package stryker4s.mill + +import mill.api.Discover +import mill.scalalib.* +import mill.testkit.{TestRootModule, UnitTester} +import mill.util.TokenReaders.* +import stryker4s.testkit.Stryker4sSuite + +import java.nio.file.Paths + +class Stryker4sModuleTest extends Stryker4sSuite { + + /** Locate a test-resource project directory copied onto the test classpath by sbt. */ + private def resourceProject(name: String): os.Path = + os.Path(Paths.get(getClass().getClassLoader().getResource(name).toURI())) + + object unitTestProject extends TestRootModule { + object foo extends ScalaModule, Stryker4sModule { + def scalaVersion = "3.3.8" + + object test extends ScalaTests { + def testFramework = "munit.Framework" + } + } + + lazy val millDiscover = Discover[this.type] + } + + object noTestModuleProject extends TestRootModule { + object bar extends ScalaModule, Stryker4sModule { + def scalaVersion = "3.3.8" + } + + lazy val millDiscover = Discover[this.type] + } + + test("strykerTestModule resolves the child test module") { + UnitTester(unitTestProject, resourceProject("unit-test-project")).scoped { _ => + assertEquals(unitTestProject.foo.strykerTestModule, unitTestProject.foo.test) + } + } + + test("strykerTestModule fails with a helpful message when there is no test module") { + UnitTester(noTestModuleProject, resourceProject("no-test-module-project")).scoped { _ => + interceptMessage[RuntimeException]( + "No test module found for module 'bar'. Override with `def strykerTestModule = myTestModule` to point Stryker4s to the test module to run tests with." + )(noTestModuleProject.bar.strykerTestModule) + } + } + + test("the module discovers its source files to mutate") { + UnitTester(unitTestProject, resourceProject("unit-test-project")).scoped { eval => + val result = eval(unitTestProject.foo.allSourceFiles).value + assert( + result.value.exists(_.path.last == "Calc.scala"), + s"Calc.scala not found in ${result.value.map(_.path)}" + ) + } + } +} diff --git a/modules/mill/src/test/scala/stryker4s/mill/runner/MillTestDiscoveryTest.scala b/modules/mill/src/test/scala/stryker4s/mill/runner/MillTestDiscoveryTest.scala new file mode 100644 index 000000000..1d7f555cb --- /dev/null +++ b/modules/mill/src/test/scala/stryker4s/mill/runner/MillTestDiscoveryTest.scala @@ -0,0 +1,34 @@ +package stryker4s.mill.runner + +import stryker4s.testkit.{LogMatchers, Stryker4sSuite} + +import java.io.File +import java.nio.file.Paths + +class MillTestDiscoveryTest extends Stryker4sSuite with LogMatchers { + + /** The directory holding this test module's own compiled classes. */ + private def testClassesDir: os.Path = + os.Path(Paths.get(getClass().getProtectionDomain().getCodeSource().getLocation().toURI())) + + private def fullClasspath: Seq[os.Path] = + sys.props("java.class.path").split(File.pathSeparator).filter(_.nonEmpty).map(os.Path(_)).toSeq + + test("discovers munit test suites and maps them to a single TestGroup") { + val groups = MillTestDiscovery.discover("munit.Framework", Seq(testClassesDir), fullClasspath) + + val group = groups.loneElement + assertEquals(group.frameworkClass, "munit.Framework") + assert( + group.taskDefs.exists(_.fullyQualifiedName == "stryker4s.mill.runner.MillTestDiscoveryTest"), + s"This test suite should be discovered, but got: ${group.taskDefs.map(_.fullyQualifiedName)}" + ) + } + + test("warns when no tests are found for the framework") { + val groups = MillTestDiscovery.discover("munit.Framework", Seq.empty, fullClasspath) + + assertEquals(groups.loneElement.taskDefs, Seq.empty) + assertLoggedWarn("No tests found for test framework munit.Framework") + } +} diff --git a/modules/sbt/src/main/scala/stryker4s/model/TestInterfaceMapper.scala b/modules/sbt/src/main/scala/stryker4s/model/TestInterfaceMapper.scala index 43bbf1b43..46e3ef5f8 100644 --- a/modules/sbt/src/main/scala/stryker4s/model/TestInterfaceMapper.scala +++ b/modules/sbt/src/main/scala/stryker4s/model/TestInterfaceMapper.scala @@ -6,6 +6,8 @@ import sbt.{TestDefinition as SbtTestDefinition, TestFramework as SbtTestFramewo import stryker4s.sbt.PluginCompat import stryker4s.testrunner.api.* +object TestInterfaceMapper extends TestInterfaceMapper + trait TestInterfaceMapper { def toApiTestGroups(frameworks: Seq[SbtFramework], sbtTestGroups: Seq[Tests.Group]): Seq[TestGroup] = { val mapped = testMap(frameworks, sbtTestGroups.flatMap(_.tests)) diff --git a/modules/sbt/src/main/scala/stryker4s/sbt/Stryker4sPlugin.scala b/modules/sbt/src/main/scala/stryker4s/sbt/Stryker4sPlugin.scala index 904ea7ca9..1ea1cffef 100644 --- a/modules/sbt/src/main/scala/stryker4s/sbt/Stryker4sPlugin.scala +++ b/modules/sbt/src/main/scala/stryker4s/sbt/Stryker4sPlugin.scala @@ -180,8 +180,9 @@ object Stryker4sPlugin extends AutoPlugin { val cliConfig = new CliConfigSource(parsed) val extraConfigSources = List(sbtConfig, cliConfig) + val ctx = StrykerSbtContext(state.value, javaHome.value, targetProject) Deferred[IO, FiniteDuration] // Create shared timeout between testrunners - .map(new Stryker4sSbtRunner(state.value, javaHome.value, targetProject, _, extraConfigSources)) + .map(new Stryker4sSbtRunner(ctx, _, extraConfigSources)) .flatMap(_.run()) .flatMap { case ErrorStatus => IO.raiseError(new MessageOnlyException("Mutation score is below configured threshold")) diff --git a/modules/sbt/src/main/scala/stryker4s/sbt/Stryker4sSbtRunner.scala b/modules/sbt/src/main/scala/stryker4s/sbt/Stryker4sSbtRunner.scala index f52736c12..7e3624668 100644 --- a/modules/sbt/src/main/scala/stryker4s/sbt/Stryker4sSbtRunner.scala +++ b/modules/sbt/src/main/scala/stryker4s/sbt/Stryker4sSbtRunner.scala @@ -13,11 +13,12 @@ import stryker4s.config.{Config, TestFilter} import stryker4s.exception.TestSetupException import stryker4s.extension.FileExtensions.* import stryker4s.log.Logger -import stryker4s.model.{CompilerErrMsg, TestRunnerId} +import stryker4s.model.{CompilerErrMsg, TestInterfaceMapper, TestRunnerId} import stryker4s.mutants.applymutants.ActiveMutationContext import stryker4s.mutants.tree.InstrumenterOptions +import stryker4s.run.testrunner.ProcessTestRunner import stryker4s.run.{Stryker4sRunner, TestRunner} -import stryker4s.sbt.runner.{LegacySbtTestRunner, SbtTestRunner} +import stryker4s.sbt.runner.LegacySbtTestRunner import xsbti.FileConverter import java.io.{File as JFile, PrintStream} @@ -25,15 +26,16 @@ import scala.concurrent.duration.FiniteDuration import Stryker4sPlugin.autoImport.stryker +final case class StrykerSbtContext( + state: State, + javaHome: Option[File], + targetProject: ProjectRef +) + /** This Runner run Stryker mutations in a single SBT session - * - * @param state - * SBT project state (contains all the settings about the project) */ class Stryker4sSbtRunner( - state: State, - javaHome: Option[File], - targetProject: ProjectRef, + ctx: StrykerSbtContext, sharedTimeout: Deferred[IO, FiniteDuration], override val extraConfigSources: List[ConfigSource[IO]] )(implicit @@ -56,14 +58,14 @@ class Stryker4sSbtRunner( LogManager.defaultManager(ConsoleOut.printStreamOut(new PrintStream((_: Int) => {}))) val fullSettings = settings ++ Seq( - targetProject / logManager := { + ctx.targetProject / logManager := { if ((stryker / logLevel).value == Level.Debug) logManager.value else emptyLogManager } ) - val newState = extracted.appendWithSession(fullSettings, state) + val newState = extracted.appendWithSession(fullSettings, ctx.state) - NonEmptyList.of(Resource.pure(new LegacySbtTestRunner(newState, fullSettings, extracted, targetProject))) + NonEmptyList.of(Resource.pure(new LegacySbtTestRunner(newState, fullSettings, extracted, ctx.targetProject))) } def setupSbtTestRunner( @@ -74,12 +76,12 @@ class Stryker4sSbtRunner( log.debug(s"Resolved stryker4s version $stryker4sVersion") val fullSettings = settings ++ Seq( - targetProject / libraryDependencies += - "io.stryker-mutator" %% "stryker4s-sbt-testrunner" % stryker4sVersion, - targetProject / resolvers ++= + ctx.targetProject / libraryDependencies += + "io.stryker-mutator" %% "stryker4s-testrunner" % stryker4sVersion, + ctx.targetProject / resolvers ++= (if (stryker4sVersion.endsWith("-SNAPSHOT")) Seq(Resolver.sonatypeCentralSnapshots) else Seq.empty) ) - val newState = extracted.appendWithSession(fullSettings, state) + val newState = extracted.appendWithSession(fullSettings, ctx.state) def extractTaskValue[T](task: TaskKey[T]): T = { PluginCompat.runTask(task, newState) match { @@ -102,7 +104,7 @@ class Stryker4sSbtRunner( } // See if the mutations compile, and if not extract the errors - val compilerErrors = PluginCompat.runTask(targetProject / Compile / compile, newState) match { + val compilerErrors = PluginCompat.runTask(ctx.targetProject / Compile / compile, newState) match { case Some(Left(cause)) => val rootCauses = getRootCause(cause) rootCauses.foreach(t => log.debug(s"Compile failed with ${t.getClass().getName()} root cause: $t")) @@ -126,18 +128,18 @@ class Stryker4sSbtRunner( } compilerErrors.toLeft { - val classpath = PluginCompat.toNioPaths(extractTaskValue(targetProject / Test / fullClasspath)) + val classpath = PluginCompat.toNioPaths(extractTaskValue(ctx.targetProject / Test / fullClasspath)) - val javaOpts = extractTaskValue(targetProject / Test / javaOptions) + val javaOpts = extractTaskValue(ctx.targetProject / Test / javaOptions) - val frameworks = extractTaskValue(targetProject / Test / loadedTestFrameworks).values.toSeq + val frameworks = extractTaskValue(ctx.targetProject / Test / loadedTestFrameworks).values.toSeq if (frameworks.isEmpty) log.warn( "No test frameworks found via loadedTestFrameworks. " + "Will likely result in no tests being run and a NoCoverage result for all mutants." ) - val testGroups = extractTaskValue(targetProject / Test / testGrouping).map { group => + val testGroups = extractTaskValue(ctx.targetProject / Test / testGrouping).map { group => if (config.testFilter.isEmpty) group else { val testFilter = new TestFilter() @@ -160,8 +162,10 @@ class Stryker4sSbtRunner( (1 to concurrency).map(TestRunnerId(_)).toList ) + val apiTestGroups = TestInterfaceMapper.toApiTestGroups(frameworks, testGroups) + testRunnerIds.map { id => - SbtTestRunner.create(javaHome, classpath, javaOpts, frameworks, testGroups, id, sharedTimeout) + ProcessTestRunner.create(ctx.javaHome, classpath, javaOpts, apiTestGroups, id, sharedTimeout) } } } @@ -193,14 +197,14 @@ class Stryker4sSbtRunner( log.debug(s"System properties added to the forked JVM: ${filteredSystemProperties.mkString(", ")}") Seq( - targetProject / scalacOptions --= blocklistedScalacOptions, - targetProject / Test / fork := true, - targetProject / Compile / unmanagedSourceDirectories ~= (_.map(tmpDirFor(_, tmpDir))), - targetProject / Test / javaOptions ++= filteredSystemProperties + ctx.targetProject / scalacOptions --= blocklistedScalacOptions, + ctx.targetProject / Test / fork := true, + ctx.targetProject / Compile / unmanagedSourceDirectories ~= (_.map(tmpDirFor(_, tmpDir))), + ctx.targetProject / Test / javaOptions ++= filteredSystemProperties ) ++ { if (config.testFilter.nonEmpty) { val testFilter = new TestFilter() - Seq(targetProject / Test / testOptions := Seq(Tests.Filter(testFilter.filter))) + Seq(ctx.targetProject / Test / testOptions := Seq(Tests.Filter(testFilter.filter))) } else Nil } @@ -210,7 +214,7 @@ class Stryker4sSbtRunner( Path.fromNioPath(source.toPath()).inSubDir(tmpDir).toNioPath.toFile().getAbsoluteFile() val sessionOverrides = targetProjectSessionOverrides(tmpDir) - val extracted = Project.extract(state) + val extracted = Project.extract(ctx.state) if (config.legacyTestRunner) { // No compiler error handling in the legacy runner diff --git a/modules/sbt/src/main/scala/stryker4s/sbt/runner/SbtTestRunner.scala b/modules/sbt/src/main/scala/stryker4s/sbt/runner/SbtTestRunner.scala deleted file mode 100644 index 45866006f..000000000 --- a/modules/sbt/src/main/scala/stryker4s/sbt/runner/SbtTestRunner.scala +++ /dev/null @@ -1,42 +0,0 @@ -package stryker4s.sbt.runner - -import cats.effect.{Deferred, IO, Resource} -import sbt.Tests -import sbt.testing.Framework -import stryker4s.config.Config -import stryker4s.log.Logger -import stryker4s.model.TestRunnerId -import stryker4s.run.TestRunner - -import java.io.File -import java.nio.file.Path -import scala.concurrent.duration.FiniteDuration - -object SbtTestRunner { - def create( - javaHome: Option[File], - classpath: Seq[Path], - javaOpts: Seq[String], - frameworks: Seq[Framework], - testGroups: Seq[Tests.Group], - id: TestRunnerId, - timeout: Deferred[IO, FiniteDuration] - )(implicit - config: Config, - log: Logger - ): Resource[IO, TestRunner] = { - // Timeout will be set by timeoutRunner after initialTestRun - val innerTestRunner = ProcessTestRunner.newProcess(javaHome, classpath, javaOpts, frameworks, testGroups, id) - - val withTimeout = TestRunner.timeoutRunner(timeout, innerTestRunner) - - val maybeWithMaxReuse = config.maxTestRunnerReuse.filter(_ > 0) match { - case Some(reuses) => TestRunner.maxReuseTestRunner(reuses, withTimeout) - case None => withTimeout - } - - val withRetryReuseAndTimeout = TestRunner.retryRunner(maybeWithMaxReuse) - - withRetryReuseAndTimeout - } -} diff --git a/modules/sbt/src/sbt-test/sbt-stryker4s/test-subproject/build.sbt b/modules/sbt/src/sbt-test/sbt-stryker4s/test-subproject/build.sbt index 641bd30cd..d01231ed9 100644 --- a/modules/sbt/src/sbt-test/sbt-stryker4s/test-subproject/build.sbt +++ b/modules/sbt/src/sbt-test/sbt-stryker4s/test-subproject/build.sbt @@ -1,4 +1,4 @@ -ThisBuild / scalaVersion := "3.3.7" +ThisBuild / scalaVersion := "3.3.8" lazy val app = (project in file("app")) .settings( diff --git a/modules/sbtTestRunner/src/main/scala/stryker4s/package.scala b/modules/testRunner/src/main/scala/stryker4s/package.scala similarity index 98% rename from modules/sbtTestRunner/src/main/scala/stryker4s/package.scala rename to modules/testRunner/src/main/scala/stryker4s/package.scala index 2a0d47c34..32cbdd86b 100644 --- a/modules/sbtTestRunner/src/main/scala/stryker4s/package.scala +++ b/modules/testRunner/src/main/scala/stryker4s/package.scala @@ -1,5 +1,5 @@ import stryker4s.model.MutantId -import stryker4s.sbt.testrunner.TestInterfaceMapper +import stryker4s.testrunner.TestInterfaceMapper import stryker4s.testrunner.api.{CoverageTestNameMap, TestDefinition, TestFile, TestFileId} import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger, AtomicReference} diff --git a/modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/Context.scala b/modules/testRunner/src/main/scala/stryker4s/testrunner/Context.scala similarity index 96% rename from modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/Context.scala rename to modules/testRunner/src/main/scala/stryker4s/testrunner/Context.scala index dd44dbeed..2611e530f 100644 --- a/modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/Context.scala +++ b/modules/testRunner/src/main/scala/stryker4s/testrunner/Context.scala @@ -1,4 +1,4 @@ -package stryker4s.sbt.testrunner +package stryker4s.testrunner import stryker4s.testrunner.api.TestProcessProperties diff --git a/modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/MessageHandler.scala b/modules/testRunner/src/main/scala/stryker4s/testrunner/MessageHandler.scala similarity index 98% rename from modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/MessageHandler.scala rename to modules/testRunner/src/main/scala/stryker4s/testrunner/MessageHandler.scala index 017d09f65..9f77cda0f 100644 --- a/modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/MessageHandler.scala +++ b/modules/testRunner/src/main/scala/stryker4s/testrunner/MessageHandler.scala @@ -1,4 +1,4 @@ -package stryker4s.sbt.testrunner +package stryker4s.testrunner import sbt.testing.Status import stryker4s.coverage.{collectCoverage, timed} diff --git a/modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/TestInterfaceMapper.scala b/modules/testRunner/src/main/scala/stryker4s/testrunner/TestInterfaceMapper.scala similarity index 99% rename from modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/TestInterfaceMapper.scala rename to modules/testRunner/src/main/scala/stryker4s/testrunner/TestInterfaceMapper.scala index c982c767d..8d40487bb 100644 --- a/modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/TestInterfaceMapper.scala +++ b/modules/testRunner/src/main/scala/stryker4s/testrunner/TestInterfaceMapper.scala @@ -1,4 +1,4 @@ -package stryker4s.sbt.testrunner +package stryker4s.testrunner import sbt.testing import sbt.testing.{Event, Status, TaskDef} diff --git a/modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/TestRunner.scala b/modules/testRunner/src/main/scala/stryker4s/testrunner/TestRunner.scala similarity index 99% rename from modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/TestRunner.scala rename to modules/testRunner/src/main/scala/stryker4s/testrunner/TestRunner.scala index 2e90ea7b3..37bbf0d08 100644 --- a/modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/TestRunner.scala +++ b/modules/testRunner/src/main/scala/stryker4s/testrunner/TestRunner.scala @@ -1,4 +1,4 @@ -package stryker4s.sbt.testrunner +package stryker4s.testrunner import sbt.testing.{Event, EventHandler, Framework, Status, Task} import stryker4s.model.MutantId diff --git a/modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/SbtTestRunnerMain.scala b/modules/testRunner/src/main/scala/stryker4s/testrunner/TestRunnerMain.scala similarity index 57% rename from modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/SbtTestRunnerMain.scala rename to modules/testRunner/src/main/scala/stryker4s/testrunner/TestRunnerMain.scala index e1904e8ca..98d8b7fc9 100644 --- a/modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/SbtTestRunnerMain.scala +++ b/modules/testRunner/src/main/scala/stryker4s/testrunner/TestRunnerMain.scala @@ -1,9 +1,9 @@ -package stryker4s.sbt.testrunner +package stryker4s.testrunner -import stryker4s.sbt.testrunner.SocketConfig.{TcpSocket, UnixSocket} -import stryker4s.sbt.testrunner.server.{TcpSocketServer, UnixSocketServer} +import stryker4s.testrunner.SocketConfig.{TcpSocket, UnixSocket} +import stryker4s.testrunner.server.{TcpSocketServer, UnixSocketServer} -object SbtTestRunnerMain { +object TestRunnerMain { def main(args: Array[String]): Unit = { println("Started testrunner") val config = Context.resolveSocketConfig() diff --git a/modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/interface/fingerprint.scala b/modules/testRunner/src/main/scala/stryker4s/testrunner/interface/fingerprint.scala similarity index 87% rename from modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/interface/fingerprint.scala rename to modules/testRunner/src/main/scala/stryker4s/testrunner/interface/fingerprint.scala index 680e7e9bd..b87c5f1ba 100644 --- a/modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/interface/fingerprint.scala +++ b/modules/testRunner/src/main/scala/stryker4s/testrunner/interface/fingerprint.scala @@ -1,4 +1,4 @@ -package stryker4s.sbt.testrunner.interface +package stryker4s.testrunner.interface final case class AnnotatedFingerprintImpl(isModule: Boolean, annotationName: String) extends sbt.testing.AnnotatedFingerprint diff --git a/modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/server/TcpSocketServer.scala b/modules/testRunner/src/main/scala/stryker4s/testrunner/server/TcpSocketServer.scala similarity index 91% rename from modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/server/TcpSocketServer.scala rename to modules/testRunner/src/main/scala/stryker4s/testrunner/server/TcpSocketServer.scala index 6b39da662..77529855a 100644 --- a/modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/server/TcpSocketServer.scala +++ b/modules/testRunner/src/main/scala/stryker4s/testrunner/server/TcpSocketServer.scala @@ -1,6 +1,6 @@ -package stryker4s.sbt.testrunner.server +package stryker4s.testrunner.server -import stryker4s.sbt.testrunner.TestRunnerMessageHandler +import stryker4s.testrunner.TestRunnerMessageHandler import stryker4s.testrunner.api.RequestMessage import java.net.{InetAddress, ServerSocket} diff --git a/modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/server/UnixSocketServer.scala b/modules/testRunner/src/main/scala/stryker4s/testrunner/server/UnixSocketServer.scala similarity index 97% rename from modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/server/UnixSocketServer.scala rename to modules/testRunner/src/main/scala/stryker4s/testrunner/server/UnixSocketServer.scala index d2447b170..290958dbf 100644 --- a/modules/sbtTestRunner/src/main/scala/stryker4s/sbt/testrunner/server/UnixSocketServer.scala +++ b/modules/testRunner/src/main/scala/stryker4s/testrunner/server/UnixSocketServer.scala @@ -1,8 +1,8 @@ -package stryker4s.sbt.testrunner.server +package stryker4s.testrunner.server import com.google.protobuf.{CodedInputStream, CodedOutputStream} -import stryker4s.sbt.testrunner.{MessageHandler, TestRunnerMessageHandler} import stryker4s.testrunner.api.{Request, RequestMessage, Response} +import stryker4s.testrunner.{MessageHandler, TestRunnerMessageHandler} import java.io.{ByteArrayOutputStream, File, IOException} import java.net.{StandardProtocolFamily, UnixDomainSocketAddress} diff --git a/modules/testkit/src/main/scala/stryker4s/testkit/FileUtil.scala b/modules/testkit/src/main/scala/stryker4s/testkit/FileUtil.scala index d183874d5..f8c62fd03 100644 --- a/modules/testkit/src/main/scala/stryker4s/testkit/FileUtil.scala +++ b/modules/testkit/src/main/scala/stryker4s/testkit/FileUtil.scala @@ -13,7 +13,7 @@ object FileUtil { def getResource(name: String): Path = Option(classLoader.getResource(name)) .map(_.toURI()) - .map(file.Path.of) + .map(file.Paths.get) .map(Path.fromNioPath) .getOrElse(throw new FileNotFoundException(s"File $name could not be found")) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index c26e10752..3ff379129 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -11,6 +11,9 @@ object Dependencies { val scala3 = "3.8.4" + // Mill plugins must be compiled with the same Scala version as the minimum supported Mill version + val scalaMill = "3.8.2" + val crossScalaVersions = Seq(scala3Lts, scala213, scala212) // Test dependencies @@ -29,9 +32,11 @@ object Dependencies { val fansi = "0.5.1" + val fs2 = "3.13.0" + val hocon = "1.4.9" - val fs2 = "3.13.0" + val mill = "1.1.6" val mutationTestingElements = "3.8.0" @@ -64,6 +69,8 @@ object Dependencies { val fs2Core = "co.fs2" %% "fs2-core" % versions.fs2 val fs2IO = "co.fs2" %% "fs2-io" % versions.fs2 val hocon = "com.typesafe" % "config" % versions.hocon + val millLibsScalalib = "com.lihaoyi" %% "mill-libs-scalalib" % versions.mill + val millTestkit = "com.lihaoyi" %% "mill-testkit" % versions.mill val mutationTestingElements = "io.stryker-mutator" % "mutation-testing-elements" % versions.mutationTestingElements val mutationTestingMetrics = Seq( "io.stryker-mutator" %% "mutation-testing-metrics" % versions.mutationTestingMetrics, diff --git a/project/MillScripted.scala b/project/MillScripted.scala new file mode 100644 index 000000000..d80f0593f --- /dev/null +++ b/project/MillScripted.scala @@ -0,0 +1,79 @@ +import MillScripted.* +import Release.* +import org.typelevel.sbt.tpolecat.* +import org.typelevel.scalacoptions.{ScalaVersion, *} +import sbt.* +import sbt.Keys.* +import sbt.ScriptedPlugin.autoImport.{scriptedBufferLog, scriptedLaunchOpts} +import sbtprotoc.ProtocPlugin.autoImport.PB + +import java.io.File.separator +import scala.sys.process.{Process, ProcessLogger} + +import TpolecatPlugin.autoImport.* + +object MillScripted { + + /** Scripted-style integration test for the Mill plugin. Runs against the sample project(s) under + * `modules/mill/src/mill-test/`. + * + * The required artifacts are published by automatically as this task depends on relevant `publishLocal` tasks. Run + * it with `sbt millPlugin/millScripted`. + */ + val millScripted = taskKey[Unit]("Run integration tests for the Mill plugin against a real Mill project") + + lazy val millScriptedTask: Def.Initialize[Task[Unit]] = Def.task { + val log = streams.value.log + val stryker4sVersion = version.value + val testProjects = (LocalRootProject / baseDirectory).value / "modules" / "mill" / "src" / "mill-test" + val workDir = target.value / "mill-scripted" + + IO.delete(workDir) + IO.createDirectory(workDir) + + val mutate = workDir / "mutate" + IO.copyDirectory(testProjects / "mutate", mutate) + // Substitute the locally-published plugin version into the build + val buildFile = mutate / "build.mill" + IO.write(buildFile, IO.read(buildFile).replace("@STRYKER4S_VERSION@", stryker4sVersion)) + (mutate / "mill").setExecutable(true) + + def runMill(args: String*): (Int, String) = { + val output = new StringBuilder + val logger = ProcessLogger { line => + output.append(line).append('\n') + log.info(s"[mill] $line") + } + // `--no-daemon` runs Mill in-process and exits cleanly, so no background daemon leaks in CI + val command = Seq(s".${separator}mill", "--no-daemon") ++ args + val os = sys.props("os.name").toLowerCase + val panderToWindows = os match { + case n if n contains "windows" => Seq("cmd", "/C") ++ command + case _ => command + } + log.info(s"Running: ${panderToWindows.mkString(" ")}") + val exit = Process(panderToWindows, mutate).!(logger) + (exit, output.toString()) + } + + log.info(s"Running Mill plugin integration test with stryker4s version $stryker4sVersion") + + // Scenario 1: a normal run should succeed and report a mutation score + val (exit1, out1) = runMill("foo.stryker") + assert(exit1 == 0, s"Expected 'foo.stryker' to succeed, but it exited with $exit1") + assert(out1.contains("Mutation run finished"), "Expected a completed mutation run in the output") + assert(out1.contains("Mutation score"), "Expected a mutation score in the output") + + // Scenario 2: a break threshold above the (~66%) score, overridden via CLI, should fail the build. + // `low`/`high` are raised too so they stay above `break` (the config requires high > low > break). + val breakArgs = Seq("--thresholds.break", "70", "--thresholds.low", "75", "--thresholds.high", "90") + val (exit2, out2) = runMill(("foo.stryker" +: breakArgs)*) + assert(exit2 != 0, s"Expected a break threshold above the score to fail the build, but it exited with $exit2") + assert( + out2.contains("Mutation score below threshold"), + "Expected a threshold-break message when the score is below the configured break threshold" + ) + + log.info("Mill plugin integration test passed") + } +} diff --git a/project/Settings.scala b/project/Settings.scala index 64acd7b22..2caa06975 100644 --- a/project/Settings.scala +++ b/project/Settings.scala @@ -83,8 +83,21 @@ object Settings { tpolecatExcludeOptions ++= Set(ScalacOptions.privateWarnUnusedImports) ) - lazy val sbtTestRunnerSettings: Seq[Setting[?]] = Seq( - moduleName := "stryker4s-sbt-testrunner", + lazy val millPluginSettings: Seq[Setting[?]] = Seq( + moduleName := "mill-stryker4s_mill1", + scalaVersion := Dependencies.versions.scalaMill, + libraryDependencies ++= Seq( + Dependencies.millLibsScalalib % Provided, + Dependencies.millTestkit % Test + ), + Test / parallelExecution := false, + // Mill (Scala 3) and scalameta (for3Use2_13) bring conflicting cross-version suffixes for + // scala-xml and scala-collection-compat. Mill is Provided and provides these at runtime itself + conflictWarning := ConflictWarning.disable + ) + + lazy val testRunnerSettings: Seq[Setting[?]] = Seq( + moduleName := "stryker4s-testrunner", libraryDependencies ++= Seq( Dependencies.testInterface ) @@ -135,7 +148,7 @@ object Settings { libraryDependencySchemes ++= Seq( "io.stryker-mutator" %% "stryker4s-core" % VersionScheme.Always, "io.stryker-mutator" %% "stryker4s-testrunner-api" % VersionScheme.Always, - "io.stryker-mutator" %% "stryker4s-sbt-testrunner" % VersionScheme.Always + "io.stryker-mutator" %% "stryker4s-testrunner" % VersionScheme.Always ), description := "Stryker4s, the mutation testing framework for Scala.", organization := "io.stryker-mutator",