This is an automated email from the ASF dual-hosted git repository.
aglinxinyuan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 5a5ee19541 test(amber): add unit tests for ErrorUtils (#4709)
5a5ee19541 is described below
commit 5a5ee195418dae593699eb06c7a19566096a5a15
Author: Yicong Huang <[email protected]>
AuthorDate: Sat May 2 21:28:51 2026 -0700
test(amber): add unit tests for ErrorUtils (#4709)
### What changes were proposed in this PR?
Adds scalatest coverage for
`amber/src/main/scala/org/apache/texera/amber/error/ErrorUtils.scala`.
The module had no dedicated spec.
### Any related issues, documentation, discussions?
Closes #4708.
### How was this PR tested?
```
sbt scalafmtCheckAll
sbt "WorkflowExecutionService/testOnly
org.apache.texera.amber.error.ErrorUtilsSpec"
```
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (claude-opus-4-7)
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
Co-authored-by: Xinyuan Lin <[email protected]>
---
.../apache/texera/amber/error/ErrorUtilsSpec.scala | 207 +++++++++++++++++++++
1 file changed, 207 insertions(+)
diff --git
a/amber/src/test/scala/org/apache/texera/amber/error/ErrorUtilsSpec.scala
b/amber/src/test/scala/org/apache/texera/amber/error/ErrorUtilsSpec.scala
new file mode 100644
index 0000000000..7390071e32
--- /dev/null
+++ b/amber/src/test/scala/org/apache/texera/amber/error/ErrorUtilsSpec.scala
@@ -0,0 +1,207 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.amber.error
+
+import org.apache.texera.amber.core.virtualidentity.ActorVirtualIdentity
+import
org.apache.texera.amber.engine.architecture.rpc.controlcommands.ConsoleMessageType.ERROR
+import
org.apache.texera.amber.engine.architecture.rpc.controlreturns.{ControlError,
ErrorLanguage}
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import scala.util.control.ControlThrowable
+
+class ErrorUtilsSpec extends AnyFlatSpec with Matchers {
+
+ // ----- safely -----
+
+ "safely" should "rethrow ControlThrowable even when the handler is defined
for it" in {
+ val ct = new ControlThrowable {}
+ val swallowAll: PartialFunction[Throwable, String] = { case _ =>
"swallowed" }
+ val wrapped = ErrorUtils.safely(swallowAll)
+ val thrown = intercept[ControlThrowable](wrapped(ct))
+ thrown should be theSameInstanceAs ct
+ }
+
+ it should "delegate to the supplied handler when it is defined for the
throwable" in {
+ val handler: PartialFunction[Throwable, String] = {
+ case e: IllegalStateException => s"handled:${e.getMessage}"
+ }
+ val wrapped = ErrorUtils.safely(handler)
+ wrapped(new IllegalStateException("boom")) shouldBe "handled:boom"
+ }
+
+ it should "leave the wrapped partial function undefined for unhandled
throwables" in {
+ // The wrapped PartialFunction must report isDefinedAt=false for inputs the
+ // user's handler does not cover, so callers can fall through to other
+ // catch clauses.
+ val handler: PartialFunction[Throwable, String] = {
+ case _: IllegalStateException => "ok"
+ }
+ val wrapped = ErrorUtils.safely(handler)
+ wrapped.isDefinedAt(new RuntimeException("nope")) shouldBe false
+ }
+
+ // ----- mkConsoleMessage -----
+
+ "mkConsoleMessage" should "use Unknown Source when the throwable has no
stack frames" in {
+ val err = new RuntimeException("kaboom")
+ err.setStackTrace(Array.empty)
+ val msg = ErrorUtils.mkConsoleMessage(ActorVirtualIdentity("worker-A"),
err)
+ msg.workerId shouldBe "worker-A"
+ msg.source shouldBe "(Unknown Source)"
+ msg.title shouldBe err.toString
+ msg.msgType shouldBe ERROR
+ msg.message shouldBe ""
+ }
+
+ it should "encode the top stack frame as (file:line) when available" in {
+ val err = new RuntimeException("kaboom")
+ err.setStackTrace(
+ Array(new StackTraceElement("com.x.Foo", "bar", "Foo.scala", 42))
+ )
+ val msg = ErrorUtils.mkConsoleMessage(ActorVirtualIdentity("worker-A"),
err)
+ msg.source shouldBe "(Foo.scala:42)"
+ msg.message should include("Foo.scala")
+ }
+
+ // ----- mkControlError -----
+
+ "mkControlError" should "leave errorDetails empty and language=SCALA when
the cause is null" in {
+ val err = new RuntimeException("no-cause")
+ err.setStackTrace(Array(new StackTraceElement("Cls", "m", "F.scala", 7)))
+ val ce = ErrorUtils.mkControlError(err)
+ ce.errorMessage shouldBe err.toString
+ ce.errorDetails shouldBe ""
+ ce.language shouldBe ErrorLanguage.SCALA
+ ce.stackTrace should startWith("at ")
+ ce.stackTrace should include("F.scala:7")
+ }
+
+ it should "populate errorDetails with the cause's toString when present" in {
+ val cause = new IllegalStateException("root")
+ val err = new RuntimeException("outer", cause)
+ val ce = ErrorUtils.mkControlError(err)
+ ce.errorMessage shouldBe err.toString
+ ce.errorDetails shouldBe cause.toString
+ }
+
+ // ----- reconstructThrowable -----
+
+ "reconstructThrowable" should "skip stack-trace parsing for PYTHON-language
errors" in {
+ // Pin: PYTHON path returns a bare new Throwable(message) and never
+ // touches the supplied errorDetails/stackTrace strings. The reconstructed
+ // throwable will still carry the JVM-captured stack from `new Throwable`,
+ // so the test only asserts what's specific to this branch.
+ val ce = ControlError(
+ "py.boom",
+ "ignored-details",
+ "at com.x.Foo.bar(Foo.scala:42)",
+ ErrorLanguage.PYTHON
+ )
+ val reconstructed = ErrorUtils.reconstructThrowable(ce)
+ reconstructed.getMessage shouldBe "py.boom"
+ reconstructed.getCause shouldBe null
+ // None of the parsed-stack frames should leak through on the Python path.
+ reconstructed.getStackTrace.exists(f => f.getClassName == "com.x.Foo.bar")
shouldBe false
+ }
+
+ it should "leave the cause null when errorDetails is empty for SCALA errors"
in {
+ val ce = ControlError("scala.boom", "", "", ErrorLanguage.SCALA)
+ val reconstructed = ErrorUtils.reconstructThrowable(ce)
+ reconstructed.getMessage shouldBe "scala.boom"
+ reconstructed.getCause shouldBe null
+ }
+
+ it should "attach a cause Throwable when errorDetails is non-empty" in {
+ val ce = ControlError("scala.boom", "root-cause", "", ErrorLanguage.SCALA)
+ val reconstructed = ErrorUtils.reconstructThrowable(ce)
+ reconstructed.getCause should not be null
+ reconstructed.getCause.getMessage shouldBe "root-cause"
+ }
+
+ it should "parse stacktrace lines that match the at-className(location)
pattern" in {
+ val ce = ControlError(
+ "scala.boom",
+ "",
+ "at com.x.Foo.bar(Foo.scala:42)\nat com.x.Baz.qux(Baz.scala:7)",
+ ErrorLanguage.SCALA
+ )
+ val reconstructed = ErrorUtils.reconstructThrowable(ce)
+ val frames = reconstructed.getStackTrace
+ frames.length shouldBe 2
+ frames(0).getClassName shouldBe "com.x.Foo.bar"
+ frames(0).getFileName shouldBe "Foo.scala:42"
+ frames(1).getClassName shouldBe "com.x.Baz.qux"
+ }
+
+ it should "drop lines that do not match the at-className(location) pattern"
in {
+ val ce = ControlError(
+ "scala.boom",
+ "",
+ "garbage line\nat com.x.Foo.bar(Foo.scala:42)\nmore garbage",
+ ErrorLanguage.SCALA
+ )
+ val reconstructed = ErrorUtils.reconstructThrowable(ce)
+ reconstructed.getStackTrace.length shouldBe 1
+ }
+
+ // ----- getStackTraceWithAllCauses -----
+
+ "getStackTraceWithAllCauses" should "use the developer header at the top
level" in {
+ val err = new RuntimeException("top")
+ err.setStackTrace(Array.empty)
+ val out = ErrorUtils.getStackTraceWithAllCauses(err)
+ out should startWith("Stack trace for developers:")
+ out should include(err.toString)
+ }
+
+ it should "recurse into nested causes with a Caused by section" in {
+ val cause = new IllegalStateException("inner")
+ cause.setStackTrace(Array.empty)
+ val err = new RuntimeException("outer", cause)
+ err.setStackTrace(Array.empty)
+ val out = ErrorUtils.getStackTraceWithAllCauses(err)
+ out should include("Caused by:")
+ out should include("inner")
+ out should include("outer")
+ }
+
+ // ----- getOperatorFromActorIdOpt -----
+
+ "getOperatorFromActorIdOpt" should "default to unknown operator and empty
worker id when the option is empty" in {
+ ErrorUtils.getOperatorFromActorIdOpt(None) shouldBe ("unknown operator",
"")
+ }
+
+ it should "extract operator id from a worker actor name following the
WF/op/layer pattern" in {
+ val actor = ActorVirtualIdentity("Worker:WF1-E1-myOp-main-0")
+ val (operatorId, workerId) =
ErrorUtils.getOperatorFromActorIdOpt(Some(actor))
+ // The pattern is Worker:WF<n>-<operator>-<layer>-<id>; greedy on operator,
+ // so layer=`main`, workerIdx=`0`, and the operator captures `E1-myOp`.
+ operatorId shouldBe "E1-myOp"
+ workerId shouldBe "Worker:WF1-E1-myOp-main-0"
+ }
+
+ it should "fall back to the dummy operator id for actor names that do not
match the pattern" in {
+ val actor = ActorVirtualIdentity("CONTROLLER")
+ val (operatorId, workerId) =
ErrorUtils.getOperatorFromActorIdOpt(Some(actor))
+ operatorId shouldBe "__DummyOperator"
+ workerId shouldBe "CONTROLLER"
+ }
+}