[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
@@ -0,0 +1,59 @@
import java.io.File
import sbt.io.IO
val implicitSuite = "example.ImplicitInitializationFailure"
val explicitSuite = "example.ExplicitInitializationFailure"
ThisBuild / scalaVersion := "2.12.21"
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.20" % Test
/** Returns the callback log file for one suite. */
def callbackFile(base: File, suite: String): File = base / s"$suite.log"
/** Renders a throwable callback as the stable form asserted by this fixture. */
def throwableCallbackLine(name: String, throwable: Throwable): String = {
val causeClass = Option(throwable.getCause).fold("<none>")(_.getClass.getName)
s"throwable|$name|${throwable.getClass.getName}|$causeClass"
}
/** Verifies that a suite produced exactly one expected terminal callback. */
def checkCallback(base: File, suite: String, expected: String): Unit = {
val file = callbackFile(base, suite)
val actual = if (file.exists) IO.readLines(file) else Nil
if (actual != List(expected)) {
sys.error(s"Expected only callback '$expected', but observed: ${actual.mkString(", ")}")
}
}
// Use one listener so the fixture records only the terminal lifecycle callback under test.
Test / testListeners := {
val base = baseDirectory.value
Seq(new TestReportListener {
def startGroup(name: String): Unit = ()
def testEvent(event: TestEvent): Unit = ()
def endGroup(name: String, throwable: Throwable): Unit = {
IO.append(callbackFile(base, name), throwableCallbackLine(name, throwable) + "\n")
}
def endGroup(name: String, result: TestResult): Unit = {
IO.append(callbackFile(base, name), s"result|$name|$result\n")
}
})
}
val checkImplicitCallback = taskKey[Unit]("Check the implicit initializer error callback")
val checkExplicitCallback = taskKey[Unit]("Check the explicit initializer error callback")
checkImplicitCallback :=
checkCallback(
baseDirectory.value,
implicitSuite,
s"throwable|$implicitSuite|java.lang.ExceptionInInitializerError|java.util.NoSuchElementException"
)
checkExplicitCallback :=
checkCallback(
baseDirectory.value,
explicitSuite,
s"throwable|$explicitSuite|java.lang.ExceptionInInitializerError|java.lang.IllegalStateException"
)
@@ -0,0 +1,36 @@
package example
import java.util.ServiceLoader
import org.scalatest.funsuite.AnyFunSuite
trait RequiredCredentialProvider {
def accessToken: String
}
object RequiredCredentialProvider {
private val provider: RequiredCredentialProvider =
ServiceLoader.load(classOf[RequiredCredentialProvider]).iterator().next()
def current: RequiredCredentialProvider = provider
}
/**
* The JVM wraps the singleton initializer's NoSuchElementException in
* ExceptionInInitializerError before ScalaTest can construct this suite.
*/
class ImplicitInitializationFailure extends AnyFunSuite {
private val provider = RequiredCredentialProvider.current
test("unreachable") {
fail("suite construction must fail first")
}
}
class ExplicitInitializationFailure extends AnyFunSuite {
test("throws ExceptionInInitializerError from user test code") {
throw new ExceptionInInitializerError(
new IllegalStateException("explicitly thrown by user test code")
)
}
}
@@ -0,0 +1,17 @@
# Regression for #9666. Before rethrowing a suite's LinkageError, TestRunner must notify
# testListeners with exactly one endGroup(name, throwable) callback. `->` keeps the expected
# test command failure visible while the following check verifies the listener contract.
# This complements testing/src/test/scala/sbt/TestRunnerSpec.scala: that unit test isolates
# TestRunner, while this fixture verifies a listener configured through the public testListeners
# build setting.
# ScalaTest cannot construct this suite because the JVM wraps the singleton initializer's
# NoSuchElementException in ExceptionInInitializerError. The callback check asserts both types.
-> testOnly example.ImplicitInitializationFailure
> checkImplicitCallback
# ScalaTest also lets an ExceptionInInitializerError explicitly thrown by test code escape.
# SBT must therefore close this started group with the throwable as well.
-> testOnly example.ExplicitInitializationFailure
> checkExplicitCallback
@@ -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()
}
}
}