Repository: NoumenaDigital/npl-cli
Pull request #26: feat: add test coverage option
Author: login: mike-noumena
Created: 2025-05-20T09:09:34Z | Merged: 2025-05-22T08:52:15Z

Ticket: ST-4650
Add test coverage option to the existing test command



Diff:
diff --git a/README.md b/README.md
index fedaf8a7..c62afef5 100644
--- a/README.md
+++ b/README.md
@@ -5,17 +5,17 @@ offers several useful commands for interacting with your NPL projects.
 
 ## Commands
 
-- `npl version` - Displays the current version of the NPL CLI.
-- `npl help` - Displays help information for the NPL CLI.
-- `npl check` - Checks the NPL for compilation errors and warnings. An optional path to a directory can be provided as
-  an argument. If no path is provided, the current working directory is used.
-- `npl test` - Run the NPL tests. An optional path to a directory can be provided as an argument. If no path is
-  provided, the current working directory is used.
-- `npl puml` - Generate a puml diagram from source in the given directory and its sub-directories. If no path is
-  provided, the current directory will be used.
-- `npl openapi` - Generates the openapi specs for NPL protocols. An optional path to a directory can be provided as an
-  argument. If no path is provided, the current working directory is used.
-- `npl deploy` - Deploys NPL sources to a configured Noumena Engine target. [See details](#deploy-command).
+To see a description of how to use each command, run `npl help`
+
+| Command       | Description                                                                               |
+| ------------- | ----------------------------------------------------------------------------------------- |
+| `npl version` | Displays the current version of the NPL CLI                                               |
+| `npl help`    | Displays help information for the NPL CLI                                                 |
+| `npl check`   | Checks the NPL for compilation errors and warnings                                        |
+| `npl test`    | Runs the NPL tests                                                                        |
+| `npl puml`    | Generates a puml diagram from NPL source                                                  |
+| `npl openapi` | Generates the openapi specs for NPL protocols                                             |
+| `npl deploy`  | Deploys NPL sources to a configured Noumena Engine target. [See details](#deploy-command) |
 
 ## Supported Operating Systems and architectures
 
diff --git a/src/main/kotlin/com/noumenadigital/npl/cli/commands/registry/TestCommand.kt b/src/main/kotlin/com/noumenadigital/npl/cli/commands/registry/TestCommand.kt
index e44d5757..789c84bb 100644
--- a/src/main/kotlin/com/noumenadigital/npl/cli/commands/registry/TestCommand.kt
+++ b/src/main/kotlin/com/noumenadigital/npl/cli/commands/registry/TestCommand.kt
@@ -5,15 +5,20 @@ import com.noumenadigital.npl.cli.exception.CommandExecutionException
 import com.noumenadigital.npl.cli.service.ColorWriter
 import com.noumenadigital.npl.cli.service.SourcesManager
 import com.noumenadigital.npl.cli.service.TestHarness
+import com.noumenadigital.npl.testing.coverage.CoverageAnalyzer
+import com.noumenadigital.npl.testing.coverage.LineCoverageAnalyzer
+import com.noumenadigital.npl.testing.coverage.NoCoverageAnalyzer
+import com.noumenadigital.npl.testing.coverage.SonarQubeReporter
 import org.tap4j.util.StatusValues
+import java.io.File
 import java.time.Duration
 
 data class TestCommand(
-    private val targetDir: String = ".",
-    private val testHarness: TestHarness = TestHarness(SourcesManager(targetDir)),
+    private val params: List<String> = emptyList(),
 ) : CommandExecutor {
     companion object {
         const val MIN_PADDING = 25
+        const val COVERAGE_FILE = "coverage.xml"
     }
 
     override val commandName: String = "test"
@@ -21,21 +26,43 @@ data class TestCommand(
 
     override val parameters: List<CommandParameter> =
         listOf(
-            PositionalParameter(
-                name = "directory",
+            NamedParameter(
+                name = "--sourceDir",
                 description = "Source directory containing NPL tests to run",
                 defaultValue = ".",
                 isRequired = false,
+                valuePlaceholder = "<directory>",
+            ),
+            NamedParameter(
+                name = "--coverage",
+                description = "Report test coverage details (printed to console as well as coverage.xml)",
+                isRequired = false,
             ),
         )
 
-    override fun createInstance(params: List<String>): CommandExecutor {
-        val targetDir = params.firstOrNull() ?: parameters.find { it.name == "directory" }?.defaultValue ?: "."
-        return TestCommand(targetDir = targetDir)
-    }
+    override fun createInstance(params: List<String>): CommandExecutor = TestCommand(params)
 
     override fun execute(output: ColorWriter): ExitCode {
         try {
+            val parser = CommandArgumentParser()
+            val parsedArgs = parser.parse(params, parameters)
+
+            if (parsedArgs.unexpectedArgs.isNotEmpty()) {
+                output.error("Unknown arguments: ${parsedArgs.unexpectedArgs.joinToString(" ")}")
+                return ExitCode.USAGE_ERROR
+            }
+
+            val sourcePath = parsedArgs.getValue("--sourceDir") ?: "."
+            val sourceDir = File(sourcePath)
+            if (!sourceDir.isDirectory || !sourceDir.exists()) {
+                output.error("Given source directory is either not a directory or does not exist. ${sourceDir.canonicalPath}")
+                return ExitCode.GENERAL_ERROR
+            }
+
+            val showCoverage = parsedArgs.hasFlag("--coverage")
+            val coverageAnalyzer: CoverageAnalyzer = coverageAnalyzer(showCoverage, sourceDir)
+            val testHarness = TestHarness(SourcesManager(sourceDir.absolutePath), coverageAnalyzer)
+
             val start = System.nanoTime()
             val testResults = testHarness.runTest()
             handleResults(testResults, output)
@@ -48,6 +75,8 @@ data class TestCommand(
                 output.error("No NPL tests found.")
                 return ExitCode.DATA_ERROR
             }
+
+            coverageAnalyzer.writeSummary(output::info)
             output.success(
                 "\nNPL test completed successfully in ${
                     Duration.ofNanos(System.nanoTime() - start).toMillis()
@@ -62,6 +91,17 @@ data class TestCommand(
         }
     }
 
+    private fun coverageAnalyzer(
+        showCoverage: Boolean,
+        sourceDir: File,
+    ): CoverageAnalyzer =
+        if (showCoverage) {
+            val coverageFile = File(".").canonicalFile.resolve(COVERAGE_FILE)
+            LineCoverageAnalyzer(sourceDir.canonicalFile, SonarQubeReporter(coverageFile))
+        } else {
+            NoCoverageAnalyzer
+        }
+
     private fun handleResults(
         testResults: List<TestHarness.TestHarnessResults>,
         output: ColorWriter,
diff --git a/src/main/kotlin/com/noumenadigital/npl/cli/service/TestHarness.kt b/src/main/kotlin/com/noumenadigital/npl/cli/service/TestHarness.kt
index 35489281..63420165 100644
--- a/src/main/kotlin/com/noumenadigital/npl/cli/service/TestHarness.kt
+++ b/src/main/kotlin/com/noumenadigital/npl/cli/service/TestHarness.kt
@@ -4,11 +4,13 @@ import com.noumenadigital.npl.lang.TestValue
 import com.noumenadigital.npl.testing.TapListener
 import com.noumenadigital.npl.testing.TestExecutor
 import com.noumenadigital.npl.testing.TestPlan
+import com.noumenadigital.npl.testing.coverage.CoverageAnalyzer
 import com.noumenadigital.npl.testing.coverage.NoCoverageAnalyzer
 import org.tap4j.consumer.TapConsumerFactory
 import org.tap4j.model.TestSet
 import java.io.ByteArrayInputStream
 import java.io.ByteArrayOutputStream
+import java.io.File
 import java.io.OutputStreamWriter
 import java.io.PrintWriter
 import java.time.Duration
@@ -17,16 +19,21 @@ import java.util.concurrent.Executors
 
 class TestHarness(
     private val sourcesManager: SourcesManager,
+    private val analyzer: CoverageAnalyzer = NoCoverageAnalyzer,
 ) {
     fun runTest(): List<TestHarnessResults> {
         val sources = sourcesManager.getNplSources()
         val compilationResult =
             runTest("compilation") { listener ->
-                TestExecutor.compile(
-                    listener,
-                    sources,
-                    compilerConfiguration(sourcesManager.nplContribLibrary),
-                )
+                TestExecutor
+                    .compile(
+                        listener,
+                        sources,
+                        compilerConfiguration(sourcesManager.nplContribLibrary),
+                    )?.also {
+                        analyzer.registerSources(sources.map { File(it.location.path) })
+                        analyzer.registerCodeToCover(it.protoMap)
+                    }
             }
         val isCompilationSuccess =
             !compilationResult.tapResult.containsNotOk() && !compilationResult.tapResult.containsBailOut()
@@ -34,7 +41,7 @@ class TestHarness(
             val result =
                 Executors.newFixedThreadPool(Math.max(1, Runtime.getRuntime().availableProcessors() - 1)).run {
                     compilationResult.result?.let { compilationOutput ->
-                        val results = invokeAll(createCallables(compilationOutput)).map { c -> c.get() }
+                        val results = invokeAll(createCallables(compilationOutput, analyzer)).map { c -> c.get() }
                         shutdown()
                         results
                             .filter { it.tapResult.testResults.isNotEmpty() }
@@ -42,6 +49,8 @@ class TestHarness(
                             .map { TestHarnessResults(it.tapResult, it.duration, it.description) }
                     }
                 }
+
+            analyzer.writeReport()
             return result ?: emptyList()
         } else {
             return listOf(
@@ -74,21 +83,23 @@ class TestHarness(
         return TestResult(result, tapResult, duration, description)
     }
 
-    private fun createCallables(compilationOutput: TestExecutor.Output) =
-        sourcesManager
-            .getNplSources()
-            .map { source ->
-                Callable {
-                    runTest(source.location.path) { listener ->
-                        TestExecutor.run(
-                            listener,
-                            compilationOutput,
-                            TestPlan(mapOf(source.location to TestPlan.FilePlan())),
-                            NoCoverageAnalyzer,
-                        )
-                    }
+    private fun createCallables(
+        compilationOutput: TestExecutor.Output,
+        analyzer: CoverageAnalyzer,
+    ) = sourcesManager
+        .getNplSources()
+        .map { source ->
+            Callable {
+                runTest(source.location.path) { listener ->
+                    TestExecutor.run(
+                        listener,
+                        compilationOutput,
+                        TestPlan(mapOf(source.location to TestPlan.FilePlan())),
+                        analyzer,
+                    )
                 }
             }
+        }
 
     data class TestResult<R>(
         val result: R?,
diff --git a/src/main/resources/META-INF/native-image/reachability-metadata.json b/src/main/resources/META-INF/native-image/reachability-metadata.json
index b5cf501d..f6722d70 100644
--- a/src/main/resources/META-INF/native-image/reachability-metadata.json
+++ b/src/main/resources/META-INF/native-image/reachability-metadata.json
@@ -425,6 +425,24 @@
         }
       ]
     },
+    {
+      "type": "com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl",
+      "methods": [
+        {
+          "name": "<init>",
+          "parameterTypes": []
+        }
+      ]
+    },
+    {
+      "type": "com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl",
+      "methods": [
+        {
+          "name": "<init>",
+          "parameterTypes": []
+        }
+      ]
+    },
     {
       "type": "com.sun.org.apache.xerces.internal.jaxp.SAXParserFactoryImpl",
       "methods": [
@@ -2075,12 +2093,21 @@
     {
       "glob": "META-INF/services/java.net.spi.URLStreamHandlerProvider"
     },
+    {
+      "glob": "META-INF/services/java.nio.charset.spi.CharsetProvider"
+    },
     {
       "glob": "META-INF/services/java.time.zone.ZoneRulesProvider"
     },
+    {
+      "glob": "META-INF/services/javax.xml.parsers.DocumentBuilderFactory"
+    },
     {
       "glob": "META-INF/services/javax.xml.parsers.SAXParserFactory"
     },
+    {
+      "glob": "META-INF/services/javax.xml.transform.TransformerFactory"
+    },
     {
       "glob": "META-INF/services/kotlin.reflect.jvm.internal.impl.resolve.ExternalOverridabilityCondition"
     },
@@ -2203,12 +2230,41 @@
       "module": "java.base",
       "glob": "sun/net/www/content-types.properties"
     },
+    {
+      "module": "java.xml",
+      "glob": "com/sun/org/apache/xml/internal/serializer/Encodings.properties"
+    },
+    {
+      "module": "java.xml",
+      "glob": "com/sun/org/apache/xml/internal/serializer/XMLEntities.properties"
+    },
+    {
+      "module": "java.xml",
+      "glob": "com/sun/org/apache/xml/internal/serializer/XMLEntities_en.properties"
+    },
+    {
+      "module": "java.xml",
+      "glob": "com/sun/org/apache/xml/internal/serializer/XMLEntities_en_CH.properties"
+    },
+    {
+      "module": "java.xml",
+      "glob": "com/sun/org/apache/xml/internal/serializer/XMLEntities_en_US.properties"
+    },
     {
       "module": "java.xml",
       "glob": "jdk/xml/internal/jdkcatalog/JDKCatalog.xml"
     }
   ],
-  "bundles": [],
+  "bundles": [
+    {
+      "name": "com.sun.org.apache.xml.internal.serializer.XMLEntities",
+      "locales": [
+        "en",
+        "en-CH",
+        "en-US"
+      ]
+    }
+  ],
   "jni": [
     {
       "type": "com.noumenadigital.npl.cli.MainKt",
diff --git a/src/test/kotlin/com/noumenadigital/npl/cli/BinaryCommandsIT.kt b/src/test/kotlin/com/noumenadigital/npl/cli/BinaryCommandsIT.kt
index d4df3e9b..2694af88 100644
--- a/src/test/kotlin/com/noumenadigital/npl/cli/BinaryCommandsIT.kt
+++ b/src/test/kotlin/com/noumenadigital/npl/cli/BinaryCommandsIT.kt
@@ -49,7 +49,8 @@ class BinaryCommandsIT :
                     check      Validate the correctness of NPL sources
                                  <directory>  Target directory containing NPL source files to check (defaults to current directory)
                     test       Run the NPL tests
-                                 <directory>  Source directory containing NPL tests to run (defaults to current directory)
+                                 --sourceDir <directory>  Source directory containing NPL tests to run (defaults to current directory)
+                                 --coverage  Report test coverage details (printed to console as well as coverage.xml)
                     openapi    Generate the openapi specifications of NPL api
                                  <directory>  Source directory containing NPL protocols (defaults to current directory)
                     puml       Generate a puml diagram from source in the given directory
diff --git a/src/test/kotlin/com/noumenadigital/npl/cli/TestCommandIT.kt b/src/test/kotlin/com/noumenadigital/npl/cli/TestCommandIT.kt
index 577dd71f..4310f9f8 100644
--- a/src/test/kotlin/com/noumenadigital/npl/cli/TestCommandIT.kt
+++ b/src/test/kotlin/com/noumenadigital/npl/cli/TestCommandIT.kt
@@ -3,18 +3,37 @@ package com.noumenadigital.npl.cli
 import com.noumenadigital.npl.cli.TestUtils.getTestResourcesPath
 import com.noumenadigital.npl.cli.TestUtils.normalize
 import com.noumenadigital.npl.cli.TestUtils.runCommand
+import com.noumenadigital.npl.cli.commands.registry.TestCommand.Companion.COVERAGE_FILE
 import io.kotest.core.spec.style.FunSpec
 import io.kotest.matchers.shouldBe
+import java.io.File
+import kotlin.io.path.pathString
 
 class TestCommandIT :
     FunSpec(
         {
+            val coverageFile = File(".").resolve(COVERAGE_FILE)
+
+            beforeEach {
+                if (coverageFile.exists()) {
+                    coverageFile.delete()
+                }
+            }
+
+            afterEach {
+                if (coverageFile.exists()) {
+                    coverageFile.delete()
+                }
+            }
+
             context("success") {
                 test("both main and test sources") {
+                    coverageFile.exists() shouldBe false
+
                     val testDirPath =
                         getTestResourcesPath(listOf("success", "both_sources")).toAbsolutePath().toString()
                     runCommand(
-                        commands = listOf("test", testDirPath),
+                        commands = listOf("test", "--sourceDir", testDirPath),
                     ) {
                         process.waitFor()
 
@@ -23,17 +42,136 @@ class TestCommandIT :
                         '$testDirPath/src/test/npl/objects/test_iou.npl' .. PASS           2    tests in XXX ms
 
                         NPL test completed successfully in XXX ms.
-                    """.normalize()
+                        """.normalize()
+
+                        coverageFile.exists() shouldBe false
+                        output.normalize() shouldBe expectedOutput
+                        process.exitValue() shouldBe ExitCode.SUCCESS.code
+                    }
+                }
+
+                test("both main and test sources - with coverage using relative path") {
+                    coverageFile.exists() shouldBe false
+
+                    val testPath = getTestResourcesPath(listOf("success", "both_sources")).toAbsolutePath()
+                    val relativePath = File(".").canonicalFile.toPath().relativize(testPath)
+
+                    runCommand(
+                        commands = listOf("test", "--sourceDir", relativePath.pathString, "--coverage"),
+                    ) {
+                        process.waitFor()
+
+                        val expectedOutput =
+                            """
+                        '$testPath/src/test/npl/objects/test_iou.npl' .. PASS           2    tests in XXX ms
+                        Line coverage summary
+                        --------------------------------------
+                        src/main/npl/objects/iou/iou.npl   XXX%
+                        src/test/npl/objects/test_iou.npl XXX%
+                        --------------------------------------
+                        Overall                            XXX%
+
+
+                        NPL test completed successfully in XXX ms.
+                        """.normalize()
+
+                        coverageFile.exists() shouldBe true
+                        coverageFile.readText() shouldBe
+                            """
+                            <?xml version="1.0" encoding="UTF-8" standalone="no"?>
+                            <coverage version="1">
+                                <file path="$testPath/src/main/npl/objects/iou/iou.npl">
+                                    <lineToCover covered="true" lineNumber="8"/>
+                                    <lineToCover covered="true" lineNumber="12"/>
+                                    <lineToCover covered="true" lineNumber="18"/>
+                                    <lineToCover covered="true" lineNumber="20"/>
+                                    <lineToCover covered="true" lineNumber="24"/>
+                                    <lineToCover covered="true" lineNumber="25"/>
+                                    <lineToCover covered="true" lineNumber="27"/>
+                                    <lineToCover covered="true" lineNumber="29"/>
+                                    <lineToCover covered="true" lineNumber="31"/>
+                                    <lineToCover covered="false" lineNumber="38"/>
+                                    <lineToCover covered="true" lineNumber="43"/>
+                                </file>
+                                <file path="$testPath/src/test/npl/objects/test_iou.npl">
+                                    <lineToCover covered="true" lineNumber="10"/>
+                                    <lineToCover covered="true" lineNumber="12"/>
+                                    <lineToCover covered="true" lineNumber="17"/>
+                                    <lineToCover covered="true" lineNumber="18"/>
+                                    <lineToCover covered="true" lineNumber="20"/>
+                                </file>
+                            </coverage>
+
+                            """.trimIndent()
+
+                        output.normalize() shouldBe expectedOutput
+                        process.exitValue() shouldBe ExitCode.SUCCESS.code
+                    }
+                }
+
+                test("both main and test sources - with coverage using absolute path") {
+                    coverageFile.exists() shouldBe false
+
+                    val testDirPath =
+                        getTestResourcesPath(listOf("success", "both_sources")).toAbsolutePath().toString()
+                    runCommand(
+                        commands = listOf("test", "--sourceDir", testDirPath, "--coverage"),
+                    ) {
+                        process.waitFor()
+
+                        val expectedOutput =
+                            """
+                        '$testDirPath/src/test/npl/objects/test_iou.npl' .. PASS           2    tests in XXX ms
+                        Line coverage summary
+                        --------------------------------------
+                        src/main/npl/objects/iou/iou.npl   XXX%
+                        src/test/npl/objects/test_iou.npl XXX%
+                        --------------------------------------
+                        Overall                            XXX%
+
+
+                        NPL test completed successfully in XXX ms.
+                        """.normalize()
+
+                        coverageFile.exists() shouldBe true
+                        coverageFile.readText() shouldBe
+                            """
+                            <?xml version="1.0" encoding="UTF-8" standalone="no"?>
+                            <coverage version="1">
+                                <file path="$testDirPath/src/main/npl/objects/iou/iou.npl">
+                                    <lineToCover covered="true" lineNumber="8"/>
+                                    <lineToCover covered="true" lineNumber="12"/>
+                                    <lineToCover covered="true" lineNumber="18"/>
+                                    <lineToCover covered="true" lineNumber="20"/>
+                                    <lineToCover covered="true" lineNumber="24"/>
+                                    <lineToCover covered="true" lineNumber="25"/>
+                                    <lineToCover covered="true" lineNumber="27"/>
+                                    <lineToCover covered="true" lineNumber="29"/>
+                                    <lineToCover covered="true" lineNumber="31"/>
+                                    <lineToCover covered="false" lineNumber="38"/>
+                                    <lineToCover covered="true" lineNumber="43"/>
+                                </file>
+                                <file path="$testDirPath/src/test/npl/objects/test_iou.npl">
+                                    <lineToCover covered="true" lineNumber="10"/>
+                                    <lineToCover covered="true" lineNumber="12"/>
+                                    <lineToCover covered="true" lineNumber="17"/>
+                                    <lineToCover covered="true" lineNumber="18"/>
+                                    <lineToCover covered="true" lineNumber="20"/>
+                                </file>
+                            </coverage>
+
+                            """.trimIndent()
 
                         output.normalize() shouldBe expectedOutput
                         process.exitValue() shouldBe ExitCode.SUCCESS.code
                     }
                 }
+
                 test("test compilation failure") {
                     val testDirPath =
                         getTestResourcesPath(listOf("success", "test_failure")).toAbsolutePath().toString()
                     runCommand(
-                        commands = listOf("test", testDirPath),
+                        commands = listOf("test", "--sourceDir", testDirPath),
                     ) {
                         process.waitFor()
 
@@ -55,7 +193,7 @@ class TestCommandIT :
                     val testDirPath =
                         getTestResourcesPath(listOf("failure", "test_assertion_failed")).toAbsolutePath().toString()
                     runCommand(
-                        commands = listOf("test", testDirPath),
+                        commands = listOf("test", "--sourceDir", testDirPath),
                     ) {
                         process.waitFor()
 
@@ -75,7 +213,7 @@ class TestCommandIT :
                     val testDirPath =
                         getTestResourcesPath(listOf("failure", "test_failed")).toAbsolutePath().toString()
                     runCommand(
-                        commands = listOf("test", testDirPath),
+                        commands = listOf("test", "--sourceDir", testDirPath),
                     ) {
                         process.waitFor()
 
@@ -99,7 +237,7 @@ class TestCommandIT :
                     val testDirPath =
                         getTestResourcesPath(listOf("success", "empty_tests_folder")).toAbsolutePath().toString()
                     runCommand(
-                        commands = listOf("test", testDirPath),
+                        commands = listOf("test", "--sourceDir", testDirPath),
                     ) {
                         process.waitFor()
 
@@ -116,7 +254,7 @@ class TestCommandIT :
                     val testDirPath =
                         getTestResourcesPath(listOf("failure", "single_file")).toAbsolutePath().toString()
                     runCommand(
-                        commands = listOf("test", testDirPath),
+                        commands = listOf("test", "--sourceDir", testDirPath),
                     ) {
                         process.waitFor()
 
@@ -138,7 +276,7 @@ class TestCommandIT :
                     val testDirPath =
                         getTestResourcesPath(listOf("warnings", "compilation")).toAbsolutePath().toString()
                     runCommand(
-                        commands = listOf("test", testDirPath),
+                        commands = listOf("test", "--sourceDir", testDirPath),
                     ) {
                         process.waitFor()
 
diff --git a/src/test/kotlin/com/noumenadigital/npl/cli/TestUtils.kt b/src/test/kotlin/com/noumenadigital/npl/cli/TestUtils.kt
index c6af8d4c..e4d2c58e 100644
--- a/src/test/kotlin/com/noumenadigital/npl/cli/TestUtils.kt
+++ b/src/test/kotlin/com/noumenadigital/npl/cli/TestUtils.kt
@@ -42,6 +42,7 @@ object TestUtils {
         replace("\r\n", "\n")
             // Normalize durations
             .replace(Regex("in \\d+ ms"), "in XXX ms")
+            .replace(Regex("\\d+%"), "XXX%")
             // Remove any ANSI color codes
             .replace(Regex("\\e\\[[0-9;]*m"), "")
             .replace(Regex("(\\.*FAIL)[ \\t]+"), "$1")
diff --git a/src/test/kotlin/com/noumenadigital/npl/cli/service/CommandProcessorServiceTest.kt b/src/test/kotlin/com/noumenadigital/npl/cli/service/CommandProcessorServiceTest.kt
index 7489913d..f6a3e8d3 100644
--- a/src/test/kotlin/com/noumenadigital/npl/cli/service/CommandProcessorServiceTest.kt
+++ b/src/test/kotlin/com/noumenadigital/npl/cli/service/CommandProcessorServiceTest.kt
@@ -38,7 +38,8 @@ class CommandProcessorServiceTest :
                     check      Validate the correctness of NPL sources
                                  <directory>  Target directory containing NPL source files to check (defaults to current directory)
                     test       Run the NPL tests
-                                 <directory>  Source directory containing NPL tests to run (defaults to current directory)
+                                 --sourceDir <directory>  Source directory containing NPL tests to run (defaults to current directory)
+                                 --coverage  Report test coverage details (printed to console as well as coverage.xml)
                     openapi    Generate the openapi specifications of NPL api
                                  <directory>  Source directory containing NPL protocols (defaults to current directory)
                     puml       Generate a puml diagram from source in the given directory
diff --git a/src/test/kotlin/com/noumenadigital/npl/cli/service/TestHarnessTest.kt b/src/test/kotlin/com/noumenadigital/npl/cli/service/TestHarnessTest.kt
index 5c04362b..b797cacd 100644
--- a/src/test/kotlin/com/noumenadigital/npl/cli/service/TestHarnessTest.kt
+++ b/src/test/kotlin/com/noumenadigital/npl/cli/service/TestHarnessTest.kt
@@ -1,7 +1,11 @@
 package com.noumenadigital.npl.cli.service
 
+import com.noumenadigital.npl.cli.commands.registry.TestCommand.Companion.COVERAGE_FILE
+import com.noumenadigital.npl.testing.coverage.LineCoverageAnalyzer
+import com.noumenadigital.npl.testing.coverage.SonarQubeReporter
 import io.kotest.core.spec.style.FunSpec
 import io.kotest.engine.spec.tempdir
+import io.kotest.matchers.booleans.shouldBeTrue
 import io.kotest.matchers.collections.shouldHaveSize
 import io.kotest.matchers.equals.shouldBeEqual
 import io.kotest.matchers.shouldBe
@@ -12,9 +16,19 @@ class TestHarnessTest :
 
         data class TestContext(
             val tempDir: File = tempdir(),
+            val withCoverage: Boolean,
         ) {
             val sourcesManager = SourcesManager(tempDir.absolutePath)
-            val harness = TestHarness(sourcesManager)
+            val harness =
+                if (withCoverage) {
+                    val coverageFile = File(".").resolve(COVERAGE_FILE)
+                    TestHarness(
+                        sourcesManager,
+                        LineCoverageAnalyzer(tempDir, SonarQubeReporter(coverageFile)),
+                    )
+                } else {
+                    TestHarness(sourcesManager)
+                }
 
             fun createSourceFile(
                 name: String,
@@ -39,8 +53,11 @@ class TestHarnessTest :
             }
         }
 
-        fun withTestContext(test: TestContext.() -> Unit) {
-            TestContext().apply(test)
+        fun withTestContext(
+            withCoverage: Boolean = false,
+            test: TestContext.() -> Unit,
+        ) {
+            TestContext(withCoverage = withCoverage).apply(test)
         }
 
         test("should return results for successful compilation and test execution") {
@@ -59,6 +76,26 @@ class TestHarnessTest :
             }
         }
 
+        test("should create coverage.xml when coverage is selected") {
+            withTestContext(withCoverage = true) {
+                createSourceFile("main")
+                createTestFile("test_case")
+
+                val results = harness.runTest()
+
+                results shouldHaveSize 1
+
+                val coverageFile = File(COVERAGE_FILE)
+                coverageFile.exists().shouldBeTrue()
+                with(coverageFile.readLines()) {
+                    size shouldBe 5
+                    filter { it.contains("file path") } shouldHaveSize 2
+                    filter { it.contains("main.npl") } shouldHaveSize 1
+                    filter { it.contains("test_case.npl") } shouldHaveSize 1
+                }
+            }
+        }
+
         test("should return compilation-only result if compilation fails") {
             withTestContext {
                 createSourceFile("bad_source", "non_existing_function()")


Reviews:
user: login: vnord
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T11:04:05Z
user: login: vnord
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T11:06:07Z
user: login: vnord
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T11:07:47Z
user: login: vnord
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T11:11:38Z
user: login: vnord
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T11:12:12Z
user: login: mike-noumena
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-21T12:21:38Z
user: login: mike-noumena
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-21T12:21:40Z
user: login: mike-noumena
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-21T12:21:44Z
user: login: vnord
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T12:25:16Z
user: login: vnord
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T07:48:01Z
user: login: vnord
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T08:27:47Z
user: login: vnord
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T08:28:26Z
user: login: vnord
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T08:51:38Z

Review comments:
diff_hunk: @@ -5,37 +5,64 @@ import com.noumenadigital.npl.cli.exception.CommandExecutionException
 import com.noumenadigital.npl.cli.service.ColorWriter
 import com.noumenadigital.npl.cli.service.SourcesManager
 import com.noumenadigital.npl.cli.service.TestHarness
+import com.noumenadigital.npl.testing.coverage.CoverageAnalyzer
+import com.noumenadigital.npl.testing.coverage.LineCoverageAnalyzer
+import com.noumenadigital.npl.testing.coverage.NoCoverageAnalyzer
+import com.noumenadigital.npl.testing.coverage.SonarQubeReporter
 import org.tap4j.util.StatusValues
+import java.io.File
 import java.time.Duration
 
 data class TestCommand(
-    private val targetDir: String = ".",
-    private val testHarness: TestHarness = TestHarness(SourcesManager(targetDir)),
+    private val params: List<String> = emptyList(),
 ) : CommandExecutor {
     companion object {
         const val MIN_PADDING = 25
+        const val COVERAGE_FILE = "coverage.xml"
     }
 
     override val commandName: String = "test"
     override val description: String = "Run the NPL tests"
 
     override val parameters: List<CommandParameter> =
         listOf(
-            PositionalParameter(
-                name = "directory",
+            NamedParameter(
+                name = "--sourceDir",
                 description = "Source directory containing NPL tests to run",
                 defaultValue = ".",
                 isRequired = false,
+                valuePlaceholder = "<directory>",
+            ),
path: src/main/kotlin/com/noumenadigital/npl/cli/commands/registry/TestCommand.kt
user: login: vnord
body: Not for now, but just realized that we need this ticket: https://noumenadigital.atlassian.net/browse/ST-4684
created_at: 2025-05-21T11:04:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -5,37 +5,64 @@ import com.noumenadigital.npl.cli.exception.CommandExecutionException
 import com.noumenadigital.npl.cli.service.ColorWriter
 import com.noumenadigital.npl.cli.service.SourcesManager
 import com.noumenadigital.npl.cli.service.TestHarness
+import com.noumenadigital.npl.testing.coverage.CoverageAnalyzer
+import com.noumenadigital.npl.testing.coverage.LineCoverageAnalyzer
+import com.noumenadigital.npl.testing.coverage.NoCoverageAnalyzer
+import com.noumenadigital.npl.testing.coverage.SonarQubeReporter
 import org.tap4j.util.StatusValues
+import java.io.File
 import java.time.Duration
 
 data class TestCommand(
-    private val targetDir: String = ".",
-    private val testHarness: TestHarness = TestHarness(SourcesManager(targetDir)),
+    private val params: List<String> = emptyList(),
 ) : CommandExecutor {
     companion object {
         const val MIN_PADDING = 25
+        const val COVERAGE_FILE = "coverage.xml"
     }
 
     override val commandName: String = "test"
     override val description: String = "Run the NPL tests"
 
     override val parameters: List<CommandParameter> =
         listOf(
-            PositionalParameter(
-                name = "directory",
+            NamedParameter(
+                name = "--sourceDir",
                 description = "Source directory containing NPL tests to run",
                 defaultValue = ".",
                 isRequired = false,
+                valuePlaceholder = "<directory>",
+            ),
+            NamedParameter(
+                name = "--coverage",
+                description = "Use to indicate to print report with test coverage details",
path: src/main/kotlin/com/noumenadigital/npl/cli/commands/registry/TestCommand.kt
user: login: vnord
body: ```suggestion
                description = "Report test coverage details (printed to console as well as coverage.xml)",
```
created_at: 2025-05-21T11:06:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -59,6 +76,26 @@ class TestHarnessTest :
             }
         }
 
+        test("should create coverage.txt when coverage is selected") {
path: src/test/kotlin/com/noumenadigital/npl/cli/service/TestHarnessTest.kt
user: login: vnord
body: .txt?
created_at: 2025-05-21T11:07:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,17 +41,49 @@ class TestCommandIT :
                         '$testDirPath/src/test/npl/objects/test_iou.npl' .. PASS           2    tests in XXX ms
 
                         NPL test completed successfully in XXX ms.
-                    """.normalize()
+                        """.normalize()
 
+                        coverageFile.exists() shouldBe false
                         output.normalize() shouldBe expectedOutput
                         process.exitValue() shouldBe ExitCode.SUCCESS.code
                     }
                 }
+
+                test("both main and test sources - with coverage") {
+                    coverageFile.exists() shouldBe false
+
+                    val testDirPath =
+                        getTestResourcesPath(listOf("success", "both_sources")).toAbsolutePath().toString()
+                    runCommand(
+                        commands = listOf("test", "--sourceDir", testDirPath, "--coverage"),
+                    ) {
+                        process.waitFor()
+
+                        val expectedOutput =
+                            """
+                        '$testDirPath/src/test/npl/objects/test_iou.npl' .. PASS           2    tests in XXX ms
+                        Line coverage summary
+                        --------------------------------------
+                        src/main/npl/objects/iou/iou.npl   XXX%
+                        src/test/npl/objects/test_iou.npl XXX%
+                        --------------------------------------
+                        Overall                            XXX%
+
+
+                        NPL test completed successfully in XXX ms.
+                        """.normalize()
+
+                        coverageFile.exists() shouldBe true
path: src/test/kotlin/com/noumenadigital/npl/cli/TestCommandIT.kt
user: login: vnord
body: Maybe a bit overkill, but maybe we want to read the file as well to make sure that its content is correct?
created_at: 2025-05-21T11:11:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,17 +41,49 @@ class TestCommandIT :
                         '$testDirPath/src/test/npl/objects/test_iou.npl' .. PASS           2    tests in XXX ms
 
                         NPL test completed successfully in XXX ms.
-                    """.normalize()
+                        """.normalize()
 
+                        coverageFile.exists() shouldBe false
                         output.normalize() shouldBe expectedOutput
                         process.exitValue() shouldBe ExitCode.SUCCESS.code
                     }
                 }
+
+                test("both main and test sources - with coverage") {
+                    coverageFile.exists() shouldBe false
+
+                    val testDirPath =
+                        getTestResourcesPath(listOf("success", "both_sources")).toAbsolutePath().toString()
+                    runCommand(
+                        commands = listOf("test", "--sourceDir", testDirPath, "--coverage"),
+                    ) {
+                        process.waitFor()
+
+                        val expectedOutput =
+                            """
+                        '$testDirPath/src/test/npl/objects/test_iou.npl' .. PASS           2    tests in XXX ms
+                        Line coverage summary
+                        --------------------------------------
+                        src/main/npl/objects/iou/iou.npl   XXX%
+                        src/test/npl/objects/test_iou.npl XXX%
+                        --------------------------------------
+                        Overall                            XXX%
+
+
+                        NPL test completed successfully in XXX ms.
+                        """.normalize()
+
+                        coverageFile.exists() shouldBe true
path: src/test/kotlin/com/noumenadigital/npl/cli/TestCommandIT.kt
user: login: vnord
body: My reasoning here being that we're using xml libraries and stuff, so there is the possibility that graalvm messes those up
created_at: 2025-05-21T11:12:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,17 +41,49 @@ class TestCommandIT :
                         '$testDirPath/src/test/npl/objects/test_iou.npl' .. PASS           2    tests in XXX ms
 
                         NPL test completed successfully in XXX ms.
-                    """.normalize()
+                        """.normalize()
 
+                        coverageFile.exists() shouldBe false
                         output.normalize() shouldBe expectedOutput
                         process.exitValue() shouldBe ExitCode.SUCCESS.code
                     }
                 }
+
+                test("both main and test sources - with coverage") {
+                    coverageFile.exists() shouldBe false
+
+                    val testDirPath =
+                        getTestResourcesPath(listOf("success", "both_sources")).toAbsolutePath().toString()
+                    runCommand(
+                        commands = listOf("test", "--sourceDir", testDirPath, "--coverage"),
+                    ) {
+                        process.waitFor()
+
+                        val expectedOutput =
+                            """
+                        '$testDirPath/src/test/npl/objects/test_iou.npl' .. PASS           2    tests in XXX ms
+                        Line coverage summary
+                        --------------------------------------
+                        src/main/npl/objects/iou/iou.npl   XXX%
+                        src/test/npl/objects/test_iou.npl XXX%
+                        --------------------------------------
+                        Overall                            XXX%
+
+
+                        NPL test completed successfully in XXX ms.
+                        """.normalize()
+
+                        coverageFile.exists() shouldBe true
path: src/test/kotlin/com/noumenadigital/npl/cli/TestCommandIT.kt
user: login: mike-noumena
body: added
created_at: 2025-05-21T12:21:38Z
author_association: COLLABORATOR
diff_hunk: @@ -59,6 +76,26 @@ class TestHarnessTest :
             }
         }
 
+        test("should create coverage.txt when coverage is selected") {
path: src/test/kotlin/com/noumenadigital/npl/cli/service/TestHarnessTest.kt
user: login: mike-noumena
body: fixed.
created_at: 2025-05-21T12:21:40Z
author_association: COLLABORATOR
diff_hunk: @@ -5,37 +5,64 @@ import com.noumenadigital.npl.cli.exception.CommandExecutionException
 import com.noumenadigital.npl.cli.service.ColorWriter
 import com.noumenadigital.npl.cli.service.SourcesManager
 import com.noumenadigital.npl.cli.service.TestHarness
+import com.noumenadigital.npl.testing.coverage.CoverageAnalyzer
+import com.noumenadigital.npl.testing.coverage.LineCoverageAnalyzer
+import com.noumenadigital.npl.testing.coverage.NoCoverageAnalyzer
+import com.noumenadigital.npl.testing.coverage.SonarQubeReporter
 import org.tap4j.util.StatusValues
+import java.io.File
 import java.time.Duration
 
 data class TestCommand(
-    private val targetDir: String = ".",
-    private val testHarness: TestHarness = TestHarness(SourcesManager(targetDir)),
+    private val params: List<String> = emptyList(),
 ) : CommandExecutor {
     companion object {
         const val MIN_PADDING = 25
+        const val COVERAGE_FILE = "coverage.xml"
     }
 
     override val commandName: String = "test"
     override val description: String = "Run the NPL tests"
 
     override val parameters: List<CommandParameter> =
         listOf(
-            PositionalParameter(
-                name = "directory",
+            NamedParameter(
+                name = "--sourceDir",
                 description = "Source directory containing NPL tests to run",
                 defaultValue = ".",
                 isRequired = false,
+                valuePlaceholder = "<directory>",
+            ),
+            NamedParameter(
+                name = "--coverage",
+                description = "Use to indicate to print report with test coverage details",
path: src/main/kotlin/com/noumenadigital/npl/cli/commands/registry/TestCommand.kt
user: login: mike-noumena
body: fixed.
created_at: 2025-05-21T12:21:44Z
author_association: COLLABORATOR
diff_hunk: @@ -5,17 +5,17 @@ offers several useful commands for interacting with your NPL projects.
 
 ## Commands
 
-- `npl version` - Displays the current version of the NPL CLI.
-- `npl help` - Displays help information for the NPL CLI.
-- `npl check` - Checks the NPL for compilation errors and warnings. An optional path to a directory can be provided as
-  an argument. If no path is provided, the current working directory is used.
-- `npl test` - Run the NPL tests. An optional path to a directory can be provided as an argument. If no path is
-  provided, the current working directory is used.
-- `npl puml` - Generate a puml diagram from source in the given directory and its sub-directories. If no path is
-  provided, the current directory will be used.
-- `npl openapi` - Generates the openapi specs for NPL protocols. An optional path to a directory can be provided as an
-  argument. If no path is provided, the current working directory is used.
-- `npl deploy` - Deploys NPL sources to a configured Noumena Engine target. [See details](#deploy-command).
+To see a description of how to use each command, run `npl help`
+
+| Command       | Description                                                                               |
+| ------------- | ----------------------------------------------------------------------------------------- |
+| `npl version` | Displays the current version of the NPL CLI                                               |
+| `npl help`    | Displays help information for the NPL CLI                                                 |
+| `npl check`   | Checks the NPL for compilation errors and warnings                                        |
+| `npl test`    | Run the NPL tests                                                                         |
+| `npl puml`    | Generate a puml diagram from NPL source                                                   |
path: README.md
user: login: vnord
body: nit

```suggestion
| `npl test`    | Runs the NPL tests                                                                        |
| `npl puml`    | Generates a puml diagram from NPL source                                                  |
```
created_at: 2025-05-22T08:27:47Z
author_association: CONTRIBUTOR

PR comments:
user: login: mikhail-mozhzherin
created_at: 2025-05-21T13:22:18Z
body: Maybe would be nice to update readme.md also

Is taht expected behavior? I cannot understand the output :) 
![image](https://github.com/user-attachments/assets/c0117ee6-88fb-4c7a-981f-e84e3a49c8da)

should we add some magic with coverage file or something..

author_association: CONTRIBUTOR
user: login: vnord
created_at: 2025-05-21T14:01:18Z
body: > Maybe would be nice to update readme.md also
> 
> Is taht expected behavior? I cannot understand the output :) ![image](https://private-user-images.githubusercontent.com/197595913/446100401-c0117ee6-88fb-4c7a-981f-e84e3a49c8da.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDc4MzYyNjQsIm5iZiI6MTc0NzgzNTk2NCwicGF0aCI6Ii8xOTc1OTU5MTMvNDQ2MTAwNDAxLWMwMTE3ZWU2LTg4ZmItNGM3YS05ODFmLWU4NGUzYTQ5YzhkYS5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjUwNTIxJTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI1MDUyMVQxMzU5MjRaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT1lOGRhNTViNzgwMjhhYWRiMzRiYzU5MGFhMWVkYmY0ZDIxNDcwOGQ5YzkxNWM5ZGQ3NGUwYmMyNTdmYjkyODIxJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.gKfB74vH3yObEd_dN1jlGj1W0AtiAajIQm2lhramyL0)
> 
> should we add some magic with coverage file or something..

I get the same thing
author_association: CONTRIBUTOR