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
53 changes: 53 additions & 0 deletions .github/workflows/mutation-testing.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Mutation Testing

# Opt-in mutation testing (PIT). Not part of the PR gate — it is far slower than the unit
# tests, so it runs on a nightly schedule and on demand. The per-module mutationThreshold
# configured in each build.gradle.kts fails the job when mutation coverage regresses.
on:
workflow_dispatch:
schedule:
# Nightly at 03:00 UTC (off-peak).
- cron: '0 3 * * *'

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
pitest:
name: Run PIT
runs-on: ubuntu-latest

steps:
# Step 1: Checkout code
- name: Checkout Code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6

# Step 2: Setup Gradle
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6

# Step 3: Set up JDK 21
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
with:
java-version: '21'
distribution: 'temurin'

# Step 4: Run mutation testing for the logic-bearing modules.
- name: Run PIT
run: |
./gradlew --no-configuration-cache --continue --warning-mode all \
:bpmn-to-code-core:pitest \
:bpmn-to-code-runtime:pitest \
:bpmn-to-code-web:pitest \
:bpmn-to-code-testing:pitest

# Step 5: Publish HTML/XML reports for inspection (even when the gate fails).
- name: Upload PIT reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: pitest-reports
path: '**/build/reports/pitest/**'
if-no-files-found: warn
9 changes: 9 additions & 0 deletions bpmn-to-code-core/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.pitest)
jacoco
}

Expand Down Expand Up @@ -63,3 +64,11 @@ tasks.jacocoTestCoverageVerification {
files(classDirectories.files.map { fileTree(it) { exclude(coverageExclusions) } })
)
}

pitest {
excludedClasses.addAll(
"io.miragon.bpmn.adapter.outbound.engine.*Constants*",
"io.miragon.bpmn.adapter.outbound.json.model.*",
)
mutationThreshold.set(80)
}
6 changes: 6 additions & 0 deletions bpmn-to-code-runtime/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.mavenPublish)
alias(libs.plugins.dokka)
alias(libs.plugins.pitest)
jacoco
}

Expand All @@ -27,6 +28,11 @@ tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
exclude("**/io/miragon/bpmn/runtime/path/example/**")
}

pitest {
excludedClasses.addAll("io.miragon.bpmn.runtime.path.example.*")
mutationThreshold.set(95)
}

mavenPublishing {

publishToMavenCentral()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ class RuntimeTypesTest {
assertThat(SignalName("CancelRequested").toString()).isEqualTo("CancelRequested")
}

@Test
fun `identifier wrappers expose the raw value property`() {
assertThat(ProcessId("order-process").value).isEqualTo("order-process")
assertThat(ElementId("place-order").value).isEqualTo("place-order")
assertThat(MessageName("OrderPlaced").value).isEqualTo("OrderPlaced")
assertThat(SignalName("CancelRequested").value).isEqualTo("CancelRequested")
}

@Test
fun `identifier wrappers implement value equality`() {
assertThat(ProcessId("a")).isEqualTo(ProcessId("a"))
Expand Down Expand Up @@ -44,11 +52,29 @@ class RuntimeTypesTest {
@Test
fun `BpmnFlow defaults nullable fields to null`() {
val flow = BpmnFlow(id = "f1", sourceRef = "s", targetRef = "t")
assertThat(flow.id).isEqualTo("f1")
assertThat(flow.sourceRef).isEqualTo("s")
assertThat(flow.targetRef).isEqualTo("t")
assertThat(flow.name).isNull()
assertThat(flow.condition).isNull()
assertThat(flow.isDefault).isFalse()
}

@Test
fun `BpmnFlow retains a default flag and its labelled fields`() {
val flow = BpmnFlow(
id = "f2",
name = "yes",
sourceRef = "gateway",
targetRef = "approve",
condition = "\${approved}",
isDefault = true,
)
assertThat(flow.name).isEqualTo("yes")
assertThat(flow.condition).isEqualTo("\${approved}")
assertThat(flow.isDefault).isTrue()
}

@Test
fun `BpmnRelations retains list and nullable fields`() {
val relations = BpmnRelations(
Expand All @@ -60,12 +86,30 @@ class RuntimeTypesTest {
attachedElements = listOf("boundary-timer"),
elementType = "USER_TASK",
)
assertThat(relations.name).isEqualTo("Approve")
assertThat(relations.parentId).isNull()
assertThat(relations.attachedToRef).isNull()
assertThat(relations.previousElements).containsExactly("start")
assertThat(relations.followingElements).containsExactly("end")
assertThat(relations.attachedElements).containsExactly("boundary-timer")
assertThat(relations.elementType).isEqualTo("USER_TASK")
}

@Test
fun `BpmnRelations exposes parent and boundary host for nested elements`() {
val relations = BpmnRelations(
name = "Send reminder",
previousElements = listOf("start"),
followingElements = listOf("end"),
parentId = "confirmation-subprocess",
attachedToRef = "confirm-task",
attachedElements = emptyList(),
elementType = "TIMER_BOUNDARY_EVENT",
)
assertThat(relations.parentId).isEqualTo("confirmation-subprocess")
assertThat(relations.attachedToRef).isEqualTo("confirm-task")
}

@Test
fun `BpmnEngine covers all supported dialects`() {
assertThat(BpmnEngine.entries).containsExactly(
Expand All @@ -77,11 +121,33 @@ class RuntimeTypesTest {

@Test
fun `BpmnTimer, BpmnError, BpmnEscalation carry their pair of strings`() {
assertThat(BpmnTimer("Duration", "PT5M").timerValue).isEqualTo("PT5M")
assertThat(BpmnError("NotFound", "E_404").code).isEqualTo("E_404")
assertThat(BpmnEscalation("OutOfHours", "E_HRS").name).isEqualTo("OutOfHours")
val timer = BpmnTimer("Duration", "PT5M")
assertThat(timer.type).isEqualTo("Duration")
assertThat(timer.timerValue).isEqualTo("PT5M")

val error = BpmnError("NotFound", "E_404")
assertThat(error.name).isEqualTo("NotFound")
assertThat(error.code).isEqualTo("E_404")

val escalation = BpmnEscalation("OutOfHours", "E_HRS")
assertThat(escalation.name).isEqualTo("OutOfHours")
assertThat(escalation.code).isEqualTo("E_HRS")
}

@Test
fun `AbstractFlowNode derives identity and hash from the element id`() {
val node = flowNode("approve-task")
val same = flowNode("approve-task")
val other = flowNode("reject-task")

assertThat(node).isEqualTo(same)
assertThat(node).isNotEqualTo(other)
assertThat(node.hashCode()).isEqualTo(ElementId("approve-task").hashCode())
assertThat(node.hashCode()).isEqualTo(same.hashCode())
}

private fun flowNode(id: String): AbstractFlowNode = object : AbstractFlowNode(ElementId(id), "SERVICE_TASK") {}

@Test
fun `InputOutputMapping keeps target plus source or sourceExpression`() {
val plain = InputOutputMapping(target = "childSubscriptionId", source = "subscriptionId")
Expand Down
5 changes: 5 additions & 0 deletions bpmn-to-code-testing/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.mavenPublish)
alias(libs.plugins.dokka)
alias(libs.plugins.pitest)
jacoco
}

Expand Down Expand Up @@ -37,6 +38,10 @@ tasks.named<Test>("test") {
useJUnitPlatform()
}

pitest {
mutationThreshold.set(95)
}

mavenPublishing {

publishToMavenCentral()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import io.miragon.bpmn.domain.shared.ProcessEngine
import io.miragon.bpmn.domain.validation.SingleModelValidationRule
import io.miragon.bpmn.domain.validation.model.Severity
import io.miragon.bpmn.domain.validation.model.SingleModelValidationContext
import io.miragon.bpmn.domain.validation.model.ValidationPhase
import io.miragon.bpmn.domain.validation.model.ValidationViolation
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
Expand Down Expand Up @@ -99,6 +101,67 @@ class BpmnValidatorTest {
.assertNoErrors()
}

@Test
fun `failOnWarning promotes warnings to errors`() {
val result = BpmnValidator
.fromClasspath("bpmn/valid-process.bpmn")
.engine(ProcessEngine.CAMUNDA_7)
.withRules(AlwaysViolatingRule("warn-rule", Severity.WARN))
.failOnWarning()
.validate()
.result()

assertThat(result.errors.map { it.ruleId }).contains("warn-rule")
assertThat(result.warnings).isEmpty()
}

@Test
fun `warnings in the pre-merge phase do not short-circuit post-merge rules`() {
val result = BpmnValidator
.fromClasspath("bpmn/valid-process.bpmn")
.engine(ProcessEngine.CAMUNDA_7)
.withRules(
AlwaysViolatingRule("pre-warn", Severity.WARN, ValidationPhase.PRE_MERGE),
AlwaysViolatingRule("post-warn", Severity.WARN, ValidationPhase.POST_MERGE),
)
.validate()
.result()

assertThat(result.violations.map { it.ruleId }).contains("pre-warn", "post-warn")
}

@Test
fun `an error in the pre-merge phase short-circuits post-merge rules`() {
val result = BpmnValidator
.fromClasspath("bpmn/valid-process.bpmn")
.engine(ProcessEngine.CAMUNDA_7)
.withRules(
AlwaysViolatingRule("pre-error", Severity.ERROR, ValidationPhase.PRE_MERGE),
AlwaysViolatingRule("post-warn", Severity.WARN, ValidationPhase.POST_MERGE),
)
.validate()
.result()

assertThat(result.violations.map { it.ruleId }).contains("pre-error")
assertThat(result.violations.map { it.ruleId }).doesNotContain("post-warn")
}

private class AlwaysViolatingRule(
override val id: String,
override val severity: Severity,
override val phase: ValidationPhase = ValidationPhase.PRE_MERGE,
) : SingleModelValidationRule {
override fun validate(context: SingleModelValidationContext): List<ValidationViolation> = listOf(
ValidationViolation(
ruleId = id,
severity = severity,
elementId = null,
processId = context.model.processId,
message = "violation from $id",
),
)
}

private class AlwaysFailingMandatoryRule : SingleModelValidationRule {
override val id = "always-failing-mandatory"
override val severity = Severity.ERROR
Expand Down
11 changes: 11 additions & 0 deletions bpmn-to-code-web/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ktor)
alias(libs.plugins.pitest)
application
jacoco
}
Expand Down Expand Up @@ -117,6 +118,16 @@ tasks.jacocoTestCoverageVerification {
)
}

pitest {
excludedClasses.addAll(
"io.miragon.bpmn.web.routes.*",
"io.miragon.bpmn.web.Application*",
"io.miragon.bpmn.web.config.*",
"io.miragon.bpmn.web.model.ConfigResponse*",
)
mutationThreshold.set(90)
}

tasks.named<ProcessResources>("processResources") {
val projectVersion = project.version.toString()
filesMatching("version.properties") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ class LibrarySourceProvider {
val properties = stream.use {
Properties().apply { load(it) }
}
return properties.getProperty("projectVersion")?.takeIf { it.isNotBlank() } ?: "unknown"
return properties.getProperty("version")?.takeIf { it.isNotBlank() } ?: "unknown"
}

private fun readResource(path: String): String? {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package io.miragon.bpmn.web.service

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class LibrarySourceProviderTest {

private val underTest = LibrarySourceProvider()

@Test
fun `libraryFiles exposes the bundled runtime Kotlin sources`() {
val files = underTest.libraryFiles()

assertThat(files).isNotEmpty()
assertThat(files).allSatisfy {
assertThat(it.fileName).endsWith(".kt")
assertThat(it.content).isNotBlank()
assertThat(it.processId).isEqualTo("bpmn-to-code-runtime")
}
}

@Test
fun `libraryFiles are loaded once and cached`() {
assertThat(underTest.libraryFiles()).isSameAs(underTest.libraryFiles())
}

@Test
fun `runtimeDependency describes the published runtime artifact with a resolved version`() {
val dependency = underTest.runtimeDependency()

assertThat(dependency.group).isEqualTo("io.miragon")
assertThat(dependency.artifact).isEqualTo("bpmn-to-code-runtime")
assertThat(dependency.version).isNotEqualTo("unknown")
assertThat(dependency.version).matches("\\d+\\..*")
assertThat(dependency.gradleSnippet)
.isEqualTo("implementation(\"io.miragon:bpmn-to-code-runtime:${dependency.version}\")")
assertThat(dependency.mavenSnippet)
.contains("<groupId>io.miragon</groupId>")
.contains("<artifactId>bpmn-to-code-runtime</artifactId>")
.contains("<version>${dependency.version}</version>")
}
}
Loading
Loading