[2.0.x] fix: Notify listeners before LinkageError escapes suites (#9667)

TestRunner now calls endGroup(name, error) before rethrowing an otherwise escaping LinkageError. This completes the TestReportListener lifecycle while preserving the original error propagation and non-zero task result.

Previously, the outer suite catch handled only NonFatal errors. A suite failing with ExceptionInInitializerError could therefore start a listener group and terminate without a terminal callback, forcing integrations to infer failure from rendered output. The direct TestRunner regression checks the error callback, absence of a normal result callback, and rethrow of the same error.

Co-authored-by: Codex <[email protected]>
This commit is contained in:
Dmitrii Naumenko
2026-08-24 13:04:28 -04:00
committed by GitHub
co-authored by Codex
parent 6913814ffd
commit bfd744b2c7
5 changed files with 226 additions and 0 deletions
@@ -171,6 +171,9 @@ private[sbt] final class TestRunner(
safeListenersCall(_.endGroup(name, suiteResult.result))
(suiteResult, nestedTasks)
} catch {
case e: LinkageError =>
safeListenersCall(_.endGroup(name, e))
throw e
case NonFatal(e) =>
safeListenersCall(_.endGroup(name, e))
(SuiteResult.Error, Seq.empty[TestTask])
@@ -0,0 +1,111 @@
/*
* sbt
* Copyright 2026, Scala center
* Copyright 2008 - 2025, Lightbend, Inc.
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt
import scala.collection.mutable.ArrayBuffer
import sbt.protocol.testing.TestResult
import sbt.util.LoggerContext
import testing.{
AnnotatedFingerprint,
EventHandler,
Logger as TestLogger,
Runner,
SuiteSelector,
Task as TestTask,
TaskDef
}
import verify.BasicTestSuite
object TestRunnerSpec extends BasicTestSuite {
// The direct counterpart to sbt-app/src/sbt-test/tests/test-report-listener-linkage-error.
// This test isolates TestRunner's catch path; the scripted fixture additionally verifies the
// public testListeners setting with both implicitly created and explicitly thrown errors.
test("TestRunner should end the group and rethrow linkage errors") {
val suiteName = "example.InitializationFailure"
val linkageError = new ExceptionInInitializerError("suite initialization failed")
val listener = new RecordingListener
val testTaskDef = taskDef(suiteName)
val thrown = withTestRunner(listener) { runner =>
try {
runner.run(testTaskDef, new ThrowingTask(testTaskDef, linkageError))
None
} catch {
case error: ExceptionInInitializerError => Some(error)
}
}
assert(thrown.contains(linkageError), s"expected $linkageError to be rethrown, got $thrown")
assert(
listener.startedGroups.toSeq == Seq(suiteName),
s"unexpected started groups: ${listener.startedGroups}"
)
assert(
listener.errorEnds.toSeq == Seq(suiteName -> linkageError),
s"unexpected error ends: ${listener.errorEnds}"
)
assert(
listener.resultEnds.isEmpty,
s"expected no result-based group end, got ${listener.resultEnds}"
)
}
private final class RecordingListener extends TestReportListener {
val startedGroups: ArrayBuffer[String] = ArrayBuffer.empty
val errorEnds: ArrayBuffer[(String, Throwable)] = ArrayBuffer.empty
val resultEnds: ArrayBuffer[(String, TestResult)] = ArrayBuffer.empty
override def startGroup(name: String): Unit = startedGroups += name
override def testEvent(event: TestEvent): Unit = ()
override def endGroup(name: String, error: Throwable): Unit = errorEnds += name -> error
override def endGroup(name: String, result: TestResult): Unit = resultEnds += name -> result
}
private final class ThrowingTask(testTaskDef: TaskDef, error: LinkageError) extends TestTask {
override def tags(): Array[String] = Array.empty
override def taskDef(): TaskDef = testTaskDef
override def execute(handler: EventHandler, loggers: Array[TestLogger]): Array[TestTask] = {
throw error
}
}
private object NoOpRunner extends Runner {
override def tasks(taskDefs: Array[TaskDef]): Array[TestTask] = Array.empty
override def done(): String = ""
override def remoteArgs(): Array[String] = Array.empty
override def args(): Array[String] = Array.empty
}
private def taskDef(name: String): TaskDef = {
new TaskDef(
name,
new AnnotatedFingerprint {
override def isModule(): Boolean = false
override def annotationName(): String = "example.Test"
},
false,
Array(new SuiteSelector)
)
}
private def withTestRunner[T](listener: TestReportListener)(f: TestRunner => T): T = {
val loggerContext = LoggerContext()
try {
f(
new TestRunner(
NoOpRunner,
Vector(listener),
loggerContext.logger("TestRunnerSpec", None, None)
)
)
} finally {
loggerContext.close()
}
}
}