Skip to content

Commit 0a3203e

Browse files
feat(mill): add Mill plugin
1 parent a82c4ae commit 0a3203e

43 files changed

Lines changed: 1362 additions & 125 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
java-version: ${{ matrix.java-version }}
2525
- name: Run tests
2626
run: sbt 'compile; ++2.13 test'
27-
sbt-scripted:
27+
sbt-integration:
2828
strategy:
2929
matrix:
3030
os: [ubuntu-latest, windows-latest]
@@ -34,16 +34,15 @@ jobs:
3434
- uses: actions/checkout@v6
3535
- uses: ./.github/setup
3636
- name: Run tests
37-
# sbtTestRunner3 is required by test-subproject (Scala 3 LTS); 2_12 by test-1.
38-
run: sbt 'sbtTestRunner2_12/publishLocal; sbtTestRunner3/publishLocal; sbtPlugin/scripted'
39-
sbt-scripted-2:
40-
name: sbt 2 plugin scripted tests
37+
run: sbt 'sbtPlugin/scripted; sbtPlugin3/scripted'
38+
mill-integration:
39+
name: Mill plugin integration test
4140
runs-on: ubuntu-latest
4241
steps:
4342
- uses: actions/checkout@v6
4443
- uses: ./.github/setup
45-
- name: Run tests
46-
run: sbt 'sbtTestRunner2_12/publishLocal; sbtTestRunner3/publishLocal; sbtPlugin3/scripted'
44+
- name: Run Mill integration test
45+
run: sbt 'millPlugin/millScripted'
4746
maven-plugin:
4847
name: Test Maven plugin
4948
runs-on: ubuntu-latest
@@ -74,7 +73,7 @@ jobs:
7473
- name: Test formatting
7574
run: ./bin/scalafmt --test
7675
release:
77-
needs: [test, sbt-scripted, sbt-scripted-2, maven-plugin, formatting]
76+
needs: [test, sbt-integration, mill-integration, maven-plugin, formatting]
7877
if: ${{ github.ref == 'refs/heads/master' || startsWith(github.event.ref, 'refs/tags/') }}
7978
runs-on: ubuntu-latest
8079
steps:

.scalafmt.conf

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,18 @@ newlines.topLevelStatementBlankLines = [
1919

2020
onTestFailure = "To fix this, run './bin/scalafmt' from the project root directory"
2121

22+
# Mill plugin integration-test fixtures are verbatim Mill projects, not project sources
23+
project.excludePaths = ["glob:**/modules/mill/src/mill-test/**"]
24+
2225
fileOverride {
2326
"glob:**/core/src/test/resources/scalaFiles/scala3File.scala" {
2427
runner.dialect = Scala3
2528
}
2629
"glob:**/src/*/scala-3/**.scala" {
2730
runner.dialect = Scala3
2831
}
32+
# The Mill plugin is built on Scala 3, but its sources live in the default `src/main` layout
33+
"glob:**/modules/mill/src/**.scala" {
34+
runner.dialect = Scala3
35+
}
2936
}

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,29 @@ The Maven plugin can be added as follows in `pom.xml` under `<plugins>` [![Maven
4848

4949
You can then run Stryker4s with the command `mvn stryker4s:run`. Note that this is different than the command for the sbt plugin.
5050

51+
## Mill plugin
52+
53+
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):
54+
55+
```scala
56+
//| mvnDeps:
57+
//| - io.stryker-mutator:::mill-stryker4s:<version>
58+
package build
59+
import mill.*, scalalib.*
60+
import stryker4s.mill.Stryker4sModule
61+
62+
object foo extends ScalaModule, Stryker4sModule {
63+
// ...
64+
object test extends ScalaTests {
65+
// ...
66+
}
67+
}
68+
```
69+
70+
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.
71+
72+
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`).
73+
5174
## Pre-release versions
5275

5376
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`:

build.sbt

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Dependencies.*
2+
import MillScripted.*
23
import Settings.*
34
import sbt.internal.ProjectMatrix
45

@@ -9,49 +10,71 @@ lazy val root = (project withId "stryker4s" in file("."))
910
// Publish locally for sbt plugin testing
1011
addCommandAlias(
1112
"publishPluginLocal",
12-
"set ThisBuild / version := \"0.0.0-TEST-SNAPSHOT\"; sbtPlugin/publishLocal; sbtTestRunner/publishLocal; sbtTestRunner3/publishLocal"
13+
"set ThisBuild / version := \"0.0.0-TEST-SNAPSHOT\"; sbtPlugin/publishLocal"
1314
),
1415
// Publish to .m2 folder for Maven plugin testing
1516
addCommandAlias(
1617
"publishM2Local",
17-
"set ThisBuild / version := \"SET-BY-SBT-SNAPSHOT\"; core/publishM2;"
18+
"set ThisBuild / version := \"SET-BY-SBT-SNAPSHOT\"; core/publishM2; testkit/publishM2"
1819
),
1920
// Publish to .ivy folder for command runner local testing
2021
addCommandAlias(
2122
"publishCommandRunnerLocal",
2223
"set ThisBuild / version := \"0.0.0-TEST-SNAPSHOT\"; commandRunner/publishLocal"
24+
),
25+
// Publish Mill plugin + test runner for Mill plugin local testing
26+
addCommandAlias(
27+
"publishMillLocal",
28+
"set ThisBuild / version := \"0.0.0-TEST-SNAPSHOT\"; millPlugin/publishLocal"
2329
)
2430
)
2531
.aggregate(
26-
(core.projectRefs ++
27-
commandRunner.projectRefs ++
28-
sbtPlugin.projectRefs ++
29-
sbtTestRunner.projectRefs ++
30-
testRunnerApi.projectRefs ++
32+
(
3133
api.projectRefs ++
32-
testkit.projectRefs) *
34+
commandRunner.projectRefs ++
35+
core.projectRefs ++
36+
millPlugin.projectRefs ++
37+
sbtPlugin.projectRefs ++
38+
testRunner.projectRefs ++
39+
testkit.projectRefs ++
40+
testRunnerApi.projectRefs
41+
) *
3342
)
3443

3544
lazy val core = (projectMatrix in file("modules") / "core")
36-
.settings(commonSettings, coreSettings, publishLocalDependsOn(api, testRunnerApi, testkit))
45+
.settings(commonSettings, coreSettings, publishLocalDependsOn(api, testRunnerApi, testRunner))
3746
.dependsOn(api, testRunnerApi, testkit % Test)
3847
.jvmPlatform(scalaVersions = versions.crossScalaVersions)
3948

4049
lazy val commandRunner = (projectMatrix in file("modules") / "commandRunner")
41-
.settings(commonSettings, commandRunnerSettings, publishLocalDependsOn(core, testkit))
50+
.settings(commonSettings, commandRunnerSettings, publishLocalDependsOn(core))
4251
.dependsOn(core, testkit % Test)
4352
.jvmPlatform(scalaVersions = versions.crossScalaVersions)
4453

4554
// sbt plugins have to use Scala 2.12
4655
lazy val sbtPlugin = (projectMatrix in file("modules") / "sbt")
4756
.enablePlugins(SbtPlugin)
4857
.defaultAxes(VirtualAxis.scalaPartialVersion("2.12"), VirtualAxis.jvm)
49-
.settings(commonSettings, sbtPluginSettings, publishLocalDependsOn(core))
58+
.settings(commonSettings, sbtPluginSettings, publishLocalDependsOn(core, testRunner))
5059
.dependsOn(core)
5160
.jvmPlatform(scalaVersions = Seq(versions.scala212, versions.scala3Lts))
5261

53-
lazy val sbtTestRunner = (projectMatrix in file("modules") / "sbtTestRunner")
54-
.settings(commonSettings, sbtTestRunnerSettings, publishLocalDependsOn(testRunnerApi))
62+
// Mill plugins are compiled with the Scala version of the minimum supported Mill version
63+
lazy val millPlugin = (projectMatrix in file("modules") / "mill")
64+
.defaultAxes(VirtualAxis.scalaABIVersion(versions.scala3Lts), VirtualAxis.jvm)
65+
.settings(
66+
commonSettings,
67+
millPluginSettings,
68+
publishLocalDependsOn(core, testRunner),
69+
millScripted := millScriptedTask
70+
.dependsOn(publishLocal, testRunner.jvm(versions.scala3Lts) / publishLocal)
71+
.value
72+
)
73+
.dependsOn(core, testkit % Test)
74+
.jvmPlatform(scalaVersions = Seq(versions.scala3Lts))
75+
76+
lazy val testRunner = (projectMatrix in file("modules") / "testRunner")
77+
.settings(commonSettings, testRunnerSettings, publishLocalDependsOn(testRunnerApi))
5578
.dependsOn(testRunnerApi)
5679
.jvmPlatform(scalaVersions = versions.crossScalaVersions)
5780

docs/configuration.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Configuration methods can be combined seamlessly. The priority is as follows:
1515
| Source | [CLI args](#cli-args) | [Build tool settings](#build-tool-settings-sbt) | [Config file](#config-file) |
1616
| -------------- | --------------------- | ----------------------------------------------- | --------------------------- |
1717
| Sbt plugin ||||
18+
| Mill plugin ||||
1819
| Maven plugin ||||
1920
| Command runner || N/A ||
2021

@@ -48,6 +49,20 @@ You can also see the value of a key by running e.g. `sbt show strykerBaseDir`.
4849
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.
4950
:::
5051

52+
### Build tool settings (Mill)
53+
54+
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[_]]`:
55+
56+
```scala
57+
import stryker4s.mill.Stryker4sModule
58+
59+
object core extends ScalaModule, Stryker4sModule {
60+
override def strykerMutate = Task(Some(Seq("src/main/scala/**/*.scala")))
61+
override def strykerConcurrency = Task(Some(4))
62+
override def strykerDashboardModule = Task(Some("core"))
63+
}
64+
```
65+
5166
### Config file
5267

5368
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:
362377
- sbt plugin
363378
- Add `stryker / logLevel := Level.Debug` to your build.sbt. Or use `set stryker / logLevel := Level.Debug` if you are in a sbt session.
364379
- Options: `Debug`, `Info`, `Warn`, `Error`
380+
- Mill plugin
381+
- Pass Mill's `--debug` flag, like so: `./mill --debug foo.stryker`
365382
- Commandrunner
366383
- Pass the loglevel as a parameter when running, like so: `--debug`
367384
- Options: `--debug`, `--info`, `--warn`, `--error` (not case sensitive)

docs/getting-started.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ sidebar_position: 1
66

77
Stryker4s is a mutation testing framework for Scala. It allows you to test your tests by temporarily inserting bugs.
88

9-
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).
9+
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).
1010

1111
## 1 Install
1212

modules/sbt/src/main/scala/stryker4s/model/TestRunnerId.scala renamed to modules/core/src/main/scala/stryker4s/model/TestRunnerId.scala

File renamed without changes.

modules/sbt/src/main/scala/stryker4s/sbt/runner/ProcessTestRunner.scala renamed to modules/core/src/main/scala/stryker4s/run/testrunner/ProcessTestRunner.scala

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
1-
package stryker4s.sbt.runner
1+
package stryker4s.run.testrunner
22

33
import cats.data.NonEmptyList
4-
import cats.effect.{IO, Resource}
4+
import cats.effect.{Deferred, IO, Resource}
55
import cats.syntax.all.*
66
import com.comcast.ip4s.*
77
import fs2.Stream
88
import fs2.io.file
99
import fs2.io.file.Files
1010
import fs2.io.process.{Process, ProcessBuilder}
1111
import mutationtesting.{MutantResult, MutantStatus}
12-
import sbt.Tests
13-
import sbt.testing.Framework
1412
import stryker4s.config.Config
1513
import stryker4s.extension.DurationExtensions.*
1614
import stryker4s.log.Logger
@@ -25,6 +23,8 @@ import java.nio.file.Path
2523
import java.util.concurrent.TimeUnit
2624
import scala.concurrent.duration.*
2725

26+
/** A TestRunner that runs tests in a forked JVM (the `stryker4s-testrunner` process), communicating over a socket.
27+
*/
2828
class ProcessTestRunner(testProcess: TestRunnerConnection) extends TestRunner {
2929

3030
override def runMutant(mutant: MutantWithId, testsToRun: Seq[TestFile]): IO[MutantResult] = {
@@ -106,22 +106,49 @@ class ProcessTestRunner(testProcess: TestRunnerConnection) extends TestRunner {
106106
}
107107
}
108108

109-
object ProcessTestRunner extends TestInterfaceMapper {
109+
object ProcessTestRunner {
110110
private val classPathSeparator = java.io.File.pathSeparator
111111

112+
/** Create a [[ProcessTestRunner]], wrapped with timeout, max-reuse and retry handling.
113+
*/
114+
def create(
115+
javaHome: Option[File],
116+
classpath: Seq[Path],
117+
javaOpts: Seq[String],
118+
testGroups: Seq[TestGroup],
119+
id: TestRunnerId,
120+
timeout: Deferred[IO, FiniteDuration]
121+
)(implicit
122+
config: Config,
123+
log: Logger
124+
): Resource[IO, TestRunner] = {
125+
// Timeout will be set by timeoutRunner after initialTestRun
126+
val innerTestRunner = newProcess(javaHome, classpath, javaOpts, testGroups, id)
127+
128+
val withTimeout = TestRunner.timeoutRunner(timeout, innerTestRunner)
129+
130+
val maybeWithMaxReuse = config.maxTestRunnerReuse.filter(_ > 0) match {
131+
case Some(reuses) => TestRunner.maxReuseTestRunner(reuses, withTimeout)
132+
case None => withTimeout
133+
}
134+
135+
val withRetryReuseAndTimeout = TestRunner.retryRunner(maybeWithMaxReuse)
136+
137+
withRetryReuseAndTimeout
138+
}
139+
112140
def newProcess(
113141
javaHome: Option[File],
114142
classpath: Seq[Path],
115143
javaOpts: Seq[String],
116-
frameworks: Seq[Framework],
117-
testGroups: Seq[Tests.Group],
144+
testGroups: Seq[TestGroup],
118145
id: TestRunnerId
119146
)(implicit config: Config, log: Logger): Resource[IO, ProcessTestRunner] =
120147
for {
121148
socketAddress <- getSocketAddress(id).toResource
122149
_ <- createProcess(javaHome, classpath, javaOpts, socketAddress, id)
123150
conn <- connectToProcess(socketAddress)
124-
_ <- setupTestRunner(conn, frameworks, testGroups).toResource
151+
_ <- setupTestRunner(conn, testGroups).toResource
125152
} yield new ProcessTestRunner(conn)
126153

127154
def createProcess(
@@ -174,7 +201,8 @@ object ProcessTestRunner extends TestInterfaceMapper {
174201
}
175202

176203
private def args(address: GenSocketAddress)(implicit config: Config): Seq[String] = {
177-
val mainClass = "stryker4s.sbt.testrunner.SbtTestRunnerMain"
204+
// The main class of the `stryker4s-testrunner` artifact, which must be on the classpath
205+
val mainClass = "stryker4s.testrunner.TestRunnerMain"
178206
val sysProps = address match {
179207
case SocketAddress(_, port) => s"-D${TestProcessProperties.port}=$port"
180208
case UnixSocketAddress(path) => s"-D${TestProcessProperties.unixSocketPath}=$path"
@@ -211,12 +239,19 @@ object ProcessTestRunner extends TestInterfaceMapper {
211239

212240
def setupTestRunner(
213241
testProcess: TestRunnerConnection,
214-
frameworks: Seq[Framework],
215-
testGroups: Seq[Tests.Group]
242+
testGroups: Seq[TestGroup]
216243
): IO[Unit] = {
217-
val testContext = TestProcessContext(toApiTestGroups(frameworks, testGroups))
244+
val testContext = TestProcessContext(testGroups)
218245

219-
testProcess.sendMessage(testContext).void
246+
testProcess
247+
.sendMessage(testContext)
248+
.ensureOr(r =>
249+
new RuntimeException(s"Expected a SetupTestContextSuccessful, but got ${r.getClass().getSimpleName()}")
250+
) {
251+
case SetupTestContextSuccessful() => true
252+
case _ => false
253+
}
254+
.void
220255
}
221256

222257
def getSocketAddress(id: TestRunnerId): IO[GenSocketAddress] =

modules/sbt/src/main/scala/stryker4s/sbt/runner/TestRunnerConnection.scala renamed to modules/core/src/main/scala/stryker4s/run/testrunner/TestRunnerConnection.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package stryker4s.sbt.runner
1+
package stryker4s.run.testrunner
22

33
import cats.effect.{IO, Resource}
44
import com.comcast.ip4s.{GenSocketAddress, UnixSocketAddress}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package mill.api.daemon
2+
3+
import mill.api.daemon.Logger
4+
5+
/** Forwards `logger.prompt.colored` which is private in mill
6+
*/
7+
def loggerColorEnabled(logger: Logger): Boolean = logger.prompt.colored

0 commit comments

Comments
 (0)