mirror of
https://github.com/sbt/sbt.git
synced 2026-09-03 08:34:34 +02:00
[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:
co-authored by
Codex
parent
6913814ffd
commit
bfd744b2c7
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user