Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions .scalafmt.conf
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,18 @@ 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
}
"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
}
}
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,29 @@ The Maven plugin can be added as follows in `pom.xml` under `<plugins>` [![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:<version>
Comment on lines +56 to +57
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`).
Comment thread
hugo-vrijswijk marked this conversation as resolved.

## 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`:
Expand Down
49 changes: 36 additions & 13 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Dependencies.*
import MillScripted.*
import Settings.*
import sbt.internal.ProjectMatrix

Expand All @@ -9,49 +10,71 @@ 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)

// sbt plugins have to use Scala 2.12
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)

Expand Down
17 changes: 17 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ✅ |

Expand Down Expand Up @@ -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[_]]`:
Comment thread
hugo-vrijswijk marked this conversation as resolved.

```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.
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
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
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
Expand All @@ -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] = {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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] =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package stryker4s.sbt.runner
package stryker4s.run.testrunner

import cats.effect.{IO, Resource}
import com.comcast.ip4s.{GenSocketAddress, UnixSocketAddress}
Expand Down
7 changes: 7 additions & 0 deletions modules/mill/src/main/scala/mill/api/daemon/package.scala
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions modules/mill/src/main/scala/stryker4s/log/MillLogger.scala
Original file line number Diff line number Diff line change
@@ -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)
}
Loading