Repository: kotest/kotest (kotlin)

Issue:
Improve the "Placeholder test" style errors to include the spec name

Pull request:
Closes #4635 4635

Patch:
diff --git a/buildSrc/src/main/kotlin/kotlin-conventions.gradle.kts b/buildSrc/src/main/kotlin/kotlin-conventions.gradle.kts
index 8b4211b0575..f69cdbeb323 100644
--- a/buildSrc/src/main/kotlin/kotlin-conventions.gradle.kts
+++ b/buildSrc/src/main/kotlin/kotlin-conventions.gradle.kts
@@ -25,6 +25,7 @@ kotlin {
    @OptIn(ExperimentalKotlinGradlePluginApi::class)
    compilerOptions {
       freeCompilerArgs.add("-Xexpect-actual-classes")
+      freeCompilerArgs.add("-Xwhen-guards")
       apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0)
       languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0)
 //      allWarningsAsErrors = true


Test patch:
diff --git a/kotest-framework/kotest-framework-engine/api/kotest-framework-engine.api b/kotest-framework/kotest-framework-engine/api/kotest-framework-engine.api
index c21f22e572a..b4e3feb3fd9 100644
--- a/kotest-framework/kotest-framework-engine/api/kotest-framework-engine.api
+++ b/kotest-framework/kotest-framework-engine/api/kotest-framework-engine.api
@@ -3592,7 +3592,8 @@ public final class io/kotest/engine/extensions/ExtensionException$AfterInvocatio
 }
 
 public final class io/kotest/engine/extensions/ExtensionException$AfterProjectException : io/kotest/engine/extensions/ExtensionException {
-	public fun <init> (Ljava/lang/Throwable;)V
+	public fun <init> (Ljava/lang/Throwable;Lio/kotest/core/listeners/AfterProjectListener;)V
+	public final fun getListener ()Lio/kotest/core/listeners/AfterProjectListener;
 }
 
 public final class io/kotest/engine/extensions/ExtensionException$AfterSpecException : io/kotest/engine/extensions/ExtensionException {
@@ -3616,7 +3617,8 @@ public final class io/kotest/engine/extensions/ExtensionException$BeforeInvocati
 }
 
 public final class io/kotest/engine/extensions/ExtensionException$BeforeProjectException : io/kotest/engine/extensions/ExtensionException {
-	public fun <init> (Ljava/lang/Throwable;)V
+	public fun <init> (Ljava/lang/Throwable;Lio/kotest/core/listeners/BeforeProjectListener;)V
+	public final fun getListener ()Lio/kotest/core/listeners/BeforeProjectListener;
 }
 
 public final class io/kotest/engine/extensions/ExtensionException$BeforeSpecException : io/kotest/engine/extensions/ExtensionException {
diff --git a/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/core/listeners/project.kt b/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/core/listeners/project.kt
index cd7a3b6eec8..9e993ef7a1f 100644
--- a/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/core/listeners/project.kt
+++ b/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/core/listeners/project.kt
@@ -1,6 +1,7 @@
 package io.kotest.core.listeners
 
 import io.kotest.core.extensions.Extension
+import io.kotest.core.spec.AfterProject
 
 /**
  * Union of [BeforeProjectListener] and [AfterProjectListener].
@@ -21,3 +22,12 @@ interface AfterProjectListener : Extension {
     */
    suspend fun afterProject() {}
 }
+
+internal class ContextAwareAfterProjectListener(
+   val context: String?,
+   private val f: AfterProject
+) : AfterProjectListener {
+   override suspend fun afterProject() {
+      f()
+   }
+}
diff --git a/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/core/spec/DslDrivenSpec.kt b/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/core/spec/DslDrivenSpec.kt
index eb922110a7a..925a5d14d23 100644
--- a/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/core/spec/DslDrivenSpec.kt
+++ b/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/core/spec/DslDrivenSpec.kt
@@ -3,6 +3,7 @@ package io.kotest.core.spec
 import io.kotest.core.Tag
 import io.kotest.core.factory.TestFactory
 import io.kotest.core.listeners.AfterProjectListener
+import io.kotest.core.listeners.ContextAwareAfterProjectListener
 import io.kotest.core.spec.style.scopes.RootScope
 
 /**
@@ -69,11 +70,7 @@ abstract class DslDrivenSpec : Spec(), RootScope {
     * it with project configuration.
     */
    fun afterProject(f: AfterProject) {
-      afterProjectListeners.add(object : AfterProjectListener {
-         override suspend fun afterProject() {
-            f()
-         }
-      })
+      afterProjectListeners.add(ContextAwareAfterProjectListener(this::class.simpleName, f))
    }
 }
 
diff --git a/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/errors/ExtensionExceptionExtractor.kt b/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/errors/ExtensionExceptionExtractor.kt
index 28f09b76245..7e60ec7e63f 100644
--- a/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/errors/ExtensionExceptionExtractor.kt
+++ b/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/errors/ExtensionExceptionExtractor.kt
@@ -1,6 +1,7 @@
 package io.kotest.engine.errors
 
 import io.kotest.common.KotestInternal
+import io.kotest.core.listeners.ContextAwareAfterProjectListener
 import io.kotest.engine.extensions.ExtensionException
 
 /**
@@ -11,7 +12,7 @@ import io.kotest.engine.extensions.ExtensionException
  * that don't allow failed parent tests.
  *
  * For example, on intellij, a "TestSuite" cannot have a state attached, so if an 'afterSpec'
- * callback was to fail, we cannot display the failure. The workaround is to add a dummy test
+ * callback was to fail on a container, we cannot display the failure. The workaround is to add a dummy test
  * to the test suite, with some name like 'After Spec Error' and attach the exception there.
  */
 @KotestInternal
@@ -25,6 +26,7 @@ object ExtensionExceptionExtractor {
             is ExtensionException.AfterContainerException -> "After Container Error"
             is ExtensionException.AfterEachException -> "After Each Error"
             is ExtensionException.AfterInvocationException -> "After Invocation Error"
+            is ExtensionException.AfterProjectException if t.listener is ContextAwareAfterProjectListener -> "After Project Error: ${t.listener.context}"
             is ExtensionException.AfterProjectException -> "After Project Error"
             is ExtensionException.AfterSpecException -> "After Spec Error"
             is ExtensionException.BeforeAnyException -> "Before Any Error"
@@ -37,6 +39,7 @@ object ExtensionExceptionExtractor {
             is ExtensionException.IgnoredSpecException -> "Ignored Spec Error"
             is ExtensionException.PrepareSpecException -> "Prepare Spec Error"
          }
+
          else -> t::class.simpleName ?: "<error>"
       }
       return Pair(name, cause)
diff --git a/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/extensions/ProjectExtensions.kt b/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/extensions/ProjectExtensions.kt
index a3b91513d5b..57ce5189ed1 100644
--- a/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/extensions/ProjectExtensions.kt
+++ b/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/extensions/ProjectExtensions.kt
@@ -10,27 +10,27 @@ internal class ProjectExtensions(private val projectConfigResolver: ProjectConfi
    private val logger = Logger(ProjectExtensions::class)
 
    suspend fun beforeProject(): List<ExtensionException.BeforeProjectException> {
-      val ext = projectConfigResolver.extensions().filterIsInstance<BeforeProjectListener>()
-      logger.log { Pair(null, "Invoking ${ext.size} BeforeProjectListeners") }
-      return ext.mapNotNull {
+      val extensions = projectConfigResolver.extensions().filterIsInstance<BeforeProjectListener>()
+      logger.log { Pair(null, "Invoking ${extensions.size} BeforeProjectListeners") }
+      return extensions.mapNotNull { ext ->
          try {
-            it.beforeProject()
+            ext.beforeProject()
             null
          } catch (t: Throwable) {
-            ExtensionException.BeforeProjectException(t)
+            ExtensionException.BeforeProjectException(t, ext)
          }
       }
    }
 
    suspend fun afterProject(): List<ExtensionException.AfterProjectException> {
-      val ext = projectConfigResolver.extensions().filterIsInstance<AfterProjectListener>()
-      logger.log { Pair(null, "Invoking ${ext.size} AfterProjectListeners") }
-      return ext.mapNotNull {
+      val extensions = projectConfigResolver.extensions().filterIsInstance<AfterProjectListener>()
+      logger.log { Pair(null, "Invoking ${extensions.size} AfterProjectListeners") }
+      return extensions.mapNotNull { ext ->
          try {
-            it.afterProject()
+            ext.afterProject()
             null
          } catch (t: Throwable) {
-            ExtensionException.AfterProjectException(t)
+            ExtensionException.AfterProjectException(t, ext)
          }
       }
    }
diff --git a/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/extensions/errors.kt b/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/extensions/errors.kt
index 3d5494fe39a..e652a2cd4d8 100644
--- a/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/extensions/errors.kt
+++ b/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/extensions/errors.kt
@@ -1,10 +1,15 @@
 package io.kotest.engine.extensions
 
+import io.kotest.core.listeners.AfterProjectListener
+import io.kotest.core.listeners.BeforeProjectListener
+
 sealed class ExtensionException(val t: Throwable) : Exception(t) {
    class BeforeSpecException(cause: Throwable) : ExtensionException(cause)
    class AfterSpecException(cause: Throwable) : ExtensionException(cause)
-   class AfterProjectException(cause: Throwable) : ExtensionException(cause)
-   class BeforeProjectException(cause: Throwable) : ExtensionException(cause)
+
+   class AfterProjectException(cause: Throwable, val listener: AfterProjectListener) : ExtensionException(cause)
+   class BeforeProjectException(cause: Throwable, val listener: BeforeProjectListener) : ExtensionException(cause)
+
    class BeforeInvocationException(cause: Throwable) : ExtensionException(cause)
    class AfterInvocationException(cause: Throwable) : ExtensionException(cause)
    class BeforeEachException(cause: Throwable) : ExtensionException(cause)
diff --git a/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/names/UniqueNames.kt b/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/names/UniqueNames.kt
index b18eeee1ec0..80d48f5d335 100644
--- a/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/names/UniqueNames.kt
+++ b/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/names/UniqueNames.kt
@@ -1,5 +1,8 @@
 package io.kotest.engine.names
 
+import io.kotest.common.KotestInternal
+
+@KotestInternal
 object UniqueNames {
 
    /**
diff --git a/kotest-framework/kotest-framework-engine/src/jvmTest/kotlin/io/kotest/engine/errors/ExtensionExceptionExtractorTest.kt b/kotest-framework/kotest-framework-engine/src/jvmTest/kotlin/io/kotest/engine/errors/ExtensionExceptionExtractorTest.kt
new file mode 100644
index 00000000000..39f81815a4e
--- /dev/null
+++ b/kotest-framework/kotest-framework-engine/src/jvmTest/kotlin/io/kotest/engine/errors/ExtensionExceptionExtractorTest.kt
@@ -0,0 +1,20 @@
+package io.kotest.engine.errors
+
+import io.kotest.core.listeners.ContextAwareAfterProjectListener
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.engine.extensions.ExtensionException
+import io.kotest.matchers.shouldBe
+import java.io.IOException
+
+class ExtensionExceptionExtractorTest : FunSpec() {
+   init {
+      test("AfterProjectException should have context when attached") {
+         ExtensionExceptionExtractor.resolve(
+            ExtensionException.AfterProjectException(
+               IOException("foo"),
+               ContextAwareAfterProjectListener("context mccontextface", {}),
+            )
+         ).first shouldBe "After Project Error: context mccontextface"
+      }
+   }
+}
diff --git a/kotest-runner/kotest-runner-junit5/src/jvmMain/kotlin/io/kotest/runner/junit/platform/JUnitTestEngineListener.kt b/kotest-runner/kotest-runner-junit5/src/jvmMain/kotlin/io/kotest/runner/junit/platform/JUnitTestEngineListener.kt
index 4ec77749274..b08ffbbed18 100644
--- a/kotest-runner/kotest-runner-junit5/src/jvmMain/kotlin/io/kotest/runner/junit/platform/JUnitTestEngineListener.kt
+++ b/kotest-runner/kotest-runner-junit5/src/jvmMain/kotlin/io/kotest/runner/junit/platform/JUnitTestEngineListener.kt
@@ -342,6 +342,19 @@ class JUnitTestEngineListener(
       id.segments.filter { it.type == Segment.Test.value }.joinToString("/") { it.value }
    )
 
+   /**
+    * Registers placeholder specs and marks them as failed for each throwable.
+    * See [ExtensionExceptionExtractor].
+    */
+   private fun registerExceptionPlaceholders(ts: List<Throwable>) {
+      ts.forEach {
+         val (name, cause) = ExtensionExceptionExtractor.resolve(it)
+         val container = createAndRegisterDummySpec(name)
+         listener.executionStarted(container)
+         listener.executionFinished(container, TestExecutionResult.failed(cause))
+      }
+   }
+
    /**
     * Registers a placeholder test which we can use to attach lifecycle errors.
     * The test is registered with the engine parent.
@@ -352,23 +365,9 @@ class JUnitTestEngineListener(
       val unique = UniqueNames.unique(name, dummies) { s, k -> "${s}_$k" } ?: name
       dummies.add(unique)
 
-      val descriptor =
-         createSpecTestDescriptor(root, Descriptor.SpecDescriptor(DescriptorId(unique), this::class), unique)
+      val descriptor = createSpecTestDescriptor(root, Descriptor.SpecDescriptor(DescriptorId(unique), this::class), unique)
       root.addChild(descriptor)
       listener.dynamicTestRegistered(descriptor)
       return descriptor
    }
-
-   /**
-    * Registers placeholder specs and marks them as failed for each throwable.
-    * See [ExtensionExceptionExtractor].
-    */
-   private fun registerExceptionPlaceholders(ts: List<Throwable>) {
-      ts.forEach {
-         val (name, cause) = ExtensionExceptionExtractor.resolve(it)
-         val container = createAndRegisterDummySpec(name)
-         listener.executionStarted(container)
-         listener.executionFinished(container, TestExecutionResult.failed(cause))
-      }
-   }
 }