This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] 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 301e3a8769 test(amber): add websocket protocol serde specs (#7099)
301e3a8769 is described below

commit 301e3a8769438446c1f4e0e6a5dcd749d34be4da
Author: Xinyuan Lin <[email protected]>
AuthorDate: Wed Jul 29 20:59:07 2026 -0700

    test(amber): add websocket protocol serde specs (#7099)
    
    ### What changes were proposed in this PR?
    
    The websocket model tier had no test anywhere — `find amber/src/test
    -path '*websocket*'` returned nothing. The uncovered classes are mostly
    bare case classes, so the thing actually worth pinning is their Jackson
    polymorphic registration, which is a **cross-language contract**:
    
    | Where | What it says |
    |---|---|
    | `TexeraWebSocketRequest` / `TexeraWebSocketEvent` | `@JsonTypeInfo(use
    = Id.NAME, include = As.PROPERTY, property = "type")` |
    | every `@JsonSubTypes.Type` entry | no `name =` — and there is no
    `@JsonTypeName` in the package |
    | ⇒ resulting wire id | Jackson's `TypeNameIdResolver` fallback = **the
    bare simple class name** |
    | `workflow-websocket.interface.ts` | the Angular map keys on exactly
    those strings |
    
    So renaming a Scala class — or adding an explicit `name =` — silently
    changes the wire protocol, with no compile error on either side. Nothing
    guarded that.
    
    ```
    client ──{"type":"ResultPaginationRequest", …}──▶ 
WorkflowWebsocketResource:81
                                                     
objectMapper.readValue(msg, classOf[TexeraWebSocketRequest])
    server ──{"type":"PaginatedResultEvent",   …}──▶ SessionState:60  
sendText(writeValueAsString(msg))
    ```
    
    Three specs, all driving the **same `JSONUtils.objectMapper` production
    uses** (a fresh `new ObjectMapper()` would test fiction — without
    `DefaultScalaModule` none of these case classes bind at all):
    
    - **`TexeraWebSocketRequestSpec`** — deserializes all 13 registered
    subtypes through `classOf[TexeraWebSocketRequest]`; asserts the
    registered id set *against the annotation itself*, so the expected list
    is anchored to production rather than to another literal; asserts no
    entry is explicitly named; rejects an unknown and a missing type id.
    Also pins `ResultPaginationRequest`'s three default arguments — the live
    path, since the client declares `columnOffset` / `columnLimit` /
    `columnSearch` optional. If `DefaultScalaModule` were ever dropped,
    `columnLimit` would bind to `0` and result pagination would silently
    return zero columns.
    - **`TexeraWebSocketEventSpec`** — events are **serialize-only** (server
    → client; nothing in `main` ever reads one back), so it asserts the
    emitted discriminator for the 11 registered subtypes *and* the 5 that
    are produced but unregistered, round-trips the 8 that are symmetric, and
    pins that the effective inclusion rule is `NON_ABSENT` and not
    `NON_EMPTY` (the result panel reads `updates` / `tableStats`
    unconditionally).
    - **`PaginatedResultEventSpec`** — the companion `apply`'s field
    projection, with `pageIndex` ≠ `pageSize` and `requestID` ≠ `operatorID`
    so any transposition fails.
    
    Deliberately **not** asserted, and noted in-file so the reasoning
    survives:
    
    - `EditingTimeCompilationRequest.toLogicalPlanPojo` — no production
    caller; the type stays in the registry assertions (registry membership
    *is* the contract) but its dead method is not pinned.
    - that the 5 unregistered events *fail* to deserialize — they do today,
    but asserting a failure would turn this suite red the moment someone
    harmlessly registers them.
    
    Two divergences found while writing this, reported rather than encoded
    as expectations:
    
    - **`SkipTupleRequest` field-name mismatch** — the client sends `{
    workers }` (`execute-workflow.service.ts:313`) while the case class
    declares `workerIds`, and the shared mapper never disables
    `FAIL_ON_UNKNOWN_PROPERTIES`, so the real client frame would be
    rejected. Latent only because the handler throws `"skipping tuple is
    temporarily disabled"` before reading the field.
    - **`ExecutionResultService:498`** does `slice(columnOffset,
    columnOffset + columnLimit)`; with the default `columnLimit =
    Int.MaxValue` and any `columnOffset > 0` that sum overflows negative and
    yields no columns. Unreachable today because the frontend's `useCache`
    short-circuit keeps the all-columns request off the wire.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7097
    
    ### How was this PR tested?
    
    Three new specs, 24 tests, run locally against Java 17:
    
    ```
    sbt "WorkflowExecutionService/testOnly 
org.apache.texera.web.model.websocket.request.TexeraWebSocketRequestSpec 
org.apache.texera.web.model.websocket.event.TexeraWebSocketEventSpec 
org.apache.texera.web.model.websocket.event.PaginatedResultEventSpec"
    ```
    
    ```
    [info] Suites: completed 3, aborted 0
    [info] Tests: succeeded 24, failed 0, canceled 0, ignored 0, pending 0
    [info] All tests passed.
    ```
    
    `Test/scalafmtCheck` and `Test/scalafix --check` both `[success]`. No
    production file is touched — the diff is three new test files.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
---
 .../websocket/event/PaginatedResultEventSpec.scala | 103 ++++++
 .../websocket/event/TexeraWebSocketEventSpec.scala | 352 +++++++++++++++++++++
 .../request/TexeraWebSocketRequestSpec.scala       | 304 ++++++++++++++++++
 3 files changed, 759 insertions(+)

diff --git 
a/amber/src/test/scala/org/apache/texera/web/model/websocket/event/PaginatedResultEventSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/model/websocket/event/PaginatedResultEventSpec.scala
new file mode 100644
index 0000000000..aa48cc81c8
--- /dev/null
+++ 
b/amber/src/test/scala/org/apache/texera/web/model/websocket/event/PaginatedResultEventSpec.scala
@@ -0,0 +1,103 @@
+/*
+ * 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.web.model.websocket.event
+
+import org.apache.texera.amber.core.tuple.{Attribute, AttributeType}
+import org.apache.texera.amber.util.JSONUtils.objectMapper
+import org.apache.texera.web.model.websocket.request.ResultPaginationRequest
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import scala.jdk.CollectionConverters.IteratorHasAsScala
+
+/**
+  * Pins the request -> event projection that answers a pagination frame.
+  *
+  * `ExecutionResultService.handleResultPagination` builds its reply through
+  * `PaginatedResultEvent.apply(request, mappedResults, attributes)` on both 
branches
+  * (the storage-backed one and the empty-storage fallback), so this companion 
`apply`
+  * is the only place where a `ResultPaginationRequest` is turned into the 
frame the
+  * result panel receives.
+  *
+  * Two things can silently break here and neither is a compile error:
+  *   1. `requestID` and `operatorID` are both `String` and adjacent in both 
the request
+  *      and the event, so swapping them type-checks. The frontend correlates 
the reply
+  *      to the pending request by `requestID`; a swap would make every page 
load hang.
+  *      The fixtures below use distinct values so a transposition fails.
+  *   2. The request carries four Ints (`pageIndex`, `pageSize`, 
`columnOffset`,
+  *      `columnLimit`) and only `pageIndex` is projected. Passing `pageSize` 
instead
+  *      type-checks too, so the fixture keeps `pageIndex` (3) far from 
`pageSize` (25).
+  *
+  * The emitted field set is pinned as well: the Angular 
`PaginatedResultEvent` declares
+  * exactly `requestID`, `operatorID`, `pageIndex`, `table` and `schema`, so 
none of the
+  * request's column/pagination bookkeeping may leak onto the wire.
+  */
+class PaginatedResultEventSpec extends AnyFlatSpec with Matchers {
+
+  // pageIndex(3) != pageSize(25), requestID != operatorID, and the column 
fields carry
+  // values that must NOT show up anywhere in the projected event.
+  private val request = ResultPaginationRequest(
+    requestID = "req-alpha",
+    operatorID = "op-beta",
+    pageIndex = 3,
+    pageSize = 25,
+    columnOffset = 4,
+    columnLimit = 6,
+    columnSearch = Some("city")
+  )
+
+  private val table = List(
+    objectMapper.createObjectNode().put("city", "Irvine"),
+    objectMapper.createObjectNode().put("city", "Anaheim")
+  )
+
+  private val schema = List(
+    new Attribute("city", AttributeType.STRING),
+    new Attribute("population", AttributeType.INTEGER)
+  )
+
+  "PaginatedResultEvent.apply" should "project requestID, operatorID and 
pageIndex" in {
+    val event = PaginatedResultEvent(request, table, schema)
+    event.requestID shouldBe "req-alpha"
+    event.operatorID shouldBe "op-beta"
+    event.pageIndex shouldBe 3
+  }
+
+  it should "pass the table and schema through untouched" in {
+    val event = PaginatedResultEvent(request, table, schema)
+    event.table should contain theSameElementsInOrderAs table
+    event.schema should contain theSameElementsInOrderAs schema
+  }
+
+  "the projected event" should "serialize to exactly the fields the Angular 
client declares" in {
+    val json = objectMapper.readTree(
+      objectMapper.writeValueAsString(PaginatedResultEvent(request, table, 
schema))
+    )
+    json.fieldNames().asScala.toSet shouldBe
+      Set("type", "requestID", "operatorID", "pageIndex", "table", "schema")
+    json.get("type").asText() shouldBe "PaginatedResultEvent"
+    json.get("requestID").asText() shouldBe "req-alpha"
+    json.get("operatorID").asText() shouldBe "op-beta"
+    json.get("pageIndex").asInt() shouldBe 3
+    json.get("table").get(0).get("city").asText() shouldBe "Irvine"
+    json.get("schema").get(1).get("attributeName").asText() shouldBe 
"population"
+    json.get("schema").get(1).get("attributeType").asText() shouldBe "integer"
+  }
+}
diff --git 
a/amber/src/test/scala/org/apache/texera/web/model/websocket/event/TexeraWebSocketEventSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/model/websocket/event/TexeraWebSocketEventSpec.scala
new file mode 100644
index 0000000000..6a1e063d59
--- /dev/null
+++ 
b/amber/src/test/scala/org/apache/texera/web/model/websocket/event/TexeraWebSocketEventSpec.scala
@@ -0,0 +1,352 @@
+/*
+ * 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.web.model.websocket.event
+
+import com.fasterxml.jackson.annotation.JsonSubTypes
+import com.google.protobuf.timestamp.Timestamp
+import org.apache.texera.amber.core.tuple.{Attribute, AttributeType}
+import org.apache.texera.amber.core.workflowruntimestate.{FatalErrorType, 
WorkflowFatalError}
+import org.apache.texera.amber.engine.architecture.rpc.controlcommands.{
+  ConsoleMessage,
+  ConsoleMessageType
+}
+import 
org.apache.texera.amber.engine.architecture.rpc.controlreturns.{EvaluatedValue, 
TypedValue}
+import org.apache.texera.amber.util.JSONUtils.objectMapper
+import org.apache.texera.web.model.websocket.event.python.ConsoleUpdateEvent
+import 
org.apache.texera.web.model.websocket.response.python.PythonExpressionEvaluateResponse
+import org.apache.texera.web.model.websocket.response.{
+  ClusterStatusUpdateEvent,
+  HeartBeatResponse,
+  ModifyLogicCompletedEvent,
+  ModifyLogicResponse,
+  RegionUpdateEvent
+}
+import org.apache.texera.web.service.ExecutionResultService.{
+  PaginationMode,
+  SetDeltaMode,
+  WebDataUpdate,
+  WebPaginationUpdate
+}
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import java.time.Instant
+
+/**
+  * Pins the server -> client half of the websocket wire contract.
+  *
+  * `SessionState.send` ships every event with
+  * `session.getAsyncRemote.sendText(objectMapper.writeValueAsString(msg))`, 
where
+  * `msg: TexeraWebSocketEvent` and the mapper is `JSONUtils.objectMapper`. 
This spec
+  * uses that exact mapper and that exact call so the emitted bytes are the 
real ones.
+  *
+  * `TexeraWebSocketEvent` carries
+  * `@JsonTypeInfo(use = Id.NAME, include = As.PROPERTY, property = "type")` 
and none of
+  * its `@JsonSubTypes.Type` entries supply `name =`, so the wire id is 
Jackson's
+  * `TypeNameIdResolver` default: the BARE SIMPLE CLASS NAME. The Angular 
client
+  * switches on those literals; 
`frontend/src/app/workspace/types/workflow-websocket.interface.ts`
+  * spells the rule out:
+  *
+  *   "Each type definition MUST follow the following rules:
+  *    in either TexeraWebsocketRequestTypeMap or TexeraWebsocketEventTypeMap
+  *    add a map entry:
+  *    1. key is the 'type' string, it must be the same as corresponding 
backend class name
+  *    2. value is the payload this request/event needs"
+  *
+  * Its `TexeraWebsocketEventTypeMap` keys include `HeartBeatResponse`,
+  * `WorkflowStateEvent`, `OperatorStatisticsUpdateEvent`, 
`WebResultUpdateEvent`,
+  * `WorkflowErrorEvent`, `ConsoleUpdateEvent`, `PaginatedResultEvent`,
+  * `CacheStatusUpdateEvent`, `PythonExpressionEvaluateResponse`,
+  * `WorkerAssignmentUpdateEvent`, `ModifyLogicResponse`, 
`ModifyLogicCompletedEvent`,
+  * `ExecutionDurationUpdateEvent`, `ClusterStatusUpdateEvent`, 
`RegionUpdateEvent` and
+  * `RegionStateEvent`. Renaming a Scala event class compiles fine on both 
sides and
+  * silently drops the corresponding UI update -- that is what this spec 
guards.
+  *
+  * Deliberate asymmetry: events are tested SERIALIZE-first. Five live event 
classes
+  * (`ExecutionDurationUpdateEvent`, `RegionStateEvent`, 
`ModifyLogicCompletedEvent`,
+  * `ClusterStatusUpdateEvent`, `RegionUpdateEvent`) are NOT listed in 
`@JsonSubTypes`.
+  * They serialize correctly through the simple-name fallback, but 
`typeFromId` cannot
+  * resolve them, so a blanket read-back loop would throw 
`InvalidTypeIdException`.
+  * That is harmless today because those events are outbound only -- and it is 
pinned
+  * below so nobody assumes the tier is symmetric.
+  */
+class TexeraWebSocketEventSpec extends AnyFlatSpec with Matchers {
+
+  private def write(e: TexeraWebSocketEvent): String = 
objectMapper.writeValueAsString(e)
+
+  private def typeIdOf(e: TexeraWebSocketEvent): String =
+    objectMapper.readTree(write(e)).get("type").asText()
+
+  private val fatalError = WorkflowFatalError(
+    FatalErrorType.EXECUTION_FAILURE,
+    Timestamp(Instant.ofEpochSecond(1_700_000_000L)),
+    "message-1",
+    "details-2",
+    "op-3",
+    "worker-4"
+  )
+
+  private val consoleMessage = ConsoleMessage(
+    "worker-5",
+    Timestamp(Instant.ofEpochSecond(1_700_000_001L)),
+    ConsoleMessageType.PRINT,
+    "source-6",
+    "title-7",
+    "message-8"
+  )
+
+  private val metrics = OperatorAggregatedMetrics(
+    operatorState = "COMPLETED",
+    aggregatedInputRowCount = 11L,
+    aggregatedInputSize = 12L,
+    inputPortMetrics = Map("in-0" -> 13L),
+    aggregatedOutputRowCount = 14L,
+    aggregatedOutputSize = 15L,
+    outputPortMetrics = Map("out-0" -> 16L),
+    numWorkers = 17L,
+    aggregatedDataProcessingTime = 18L,
+    aggregatedControlProcessingTime = 19L,
+    aggregatedIdleTime = 20L
+  )
+
+  private val resultRow = objectMapper.createObjectNode().put("city", "Irvine")
+
+  /** The 11 events that ARE in `@JsonSubTypes`, one instance each. */
+  private val registeredEvents: List[(String, TexeraWebSocketEvent)] = List(
+    "HeartBeatResponse" -> HeartBeatResponse(),
+    "WorkflowErrorEvent" -> WorkflowErrorEvent(Seq(fatalError)),
+    "WorkflowStateEvent" -> WorkflowStateEvent("Running"),
+    "OperatorStatisticsUpdateEvent" -> 
OperatorStatisticsUpdateEvent(Map("op-stats" -> metrics)),
+    "WebResultUpdateEvent" -> WebResultUpdateEvent(
+      Map("op-page" -> WebPaginationUpdate(PaginationMode(), 7L, List(1, 3))),
+      Map("op-page" -> Map("city" -> Map[String, Any]("distinct" -> 2)))
+    ),
+    "ConsoleUpdateEvent" -> ConsoleUpdateEvent("op-console", 
Seq(consoleMessage)),
+    "CacheStatusUpdateEvent" -> CacheStatusUpdateEvent(Map("op-cache" -> 
"cache valid")),
+    "PaginatedResultEvent" -> PaginatedResultEvent(
+      "req-1",
+      "op-2",
+      3,
+      List(resultRow),
+      List(new Attribute("city", AttributeType.STRING))
+    ),
+    "PythonExpressionEvaluateResponse" -> PythonExpressionEvaluateResponse(
+      "len(tuple_)",
+      Seq(EvaluatedValue(Some(TypedValue("expr-1", "ref-2", "str-3", "type-4", 
true)), Seq.empty))
+    ),
+    "WorkerAssignmentUpdateEvent" -> WorkerAssignmentUpdateEvent(
+      "op-assign",
+      Seq("worker-9", "worker-10")
+    ),
+    "ModifyLogicResponse" -> ModifyLogicResponse("op-modify", isValid = false, 
"error-11")
+  )
+
+  /**
+    * Live events with a real producer that are NOT in `@JsonSubTypes`. The 
producers are
+    * `ExecutionStatsService` (duration), `RegionExecutionManager` (region 
state),
+    * `ExecutionReconfigurationService` (modify-logic completed), 
`ClusterListener` /
+    * `WorkflowWebsocketResource` (cluster status) and `Coordinator` (region 
update).
+    *
+    * `WorkflowAvailableResultEvent` is the sixth unregistered subtype but is 
deliberately
+    * absent from this list: nothing in main constructs it, so pinning its 
wire shape would
+    * only cement dead code.
+    */
+  private val outboundOnlyEvents: List[(String, TexeraWebSocketEvent)] = List(
+    "ExecutionDurationUpdateEvent" -> ExecutionDurationUpdateEvent(1234L, 
isRunning = true),
+    "RegionStateEvent" -> RegionStateEvent(21L, "RUNNING"),
+    "ModifyLogicCompletedEvent" -> ModifyLogicCompletedEvent(List("op-22")),
+    "ClusterStatusUpdateEvent" -> ClusterStatusUpdateEvent(23),
+    "RegionUpdateEvent" -> RegionUpdateEvent(List((24L, List("op-25"))))
+  )
+
+  // The backend's registered set, spelled out rather than derived so a class 
rename
+  // surfaces as a set diff. Deliberately not phrased as "what the client 
resolves":
+  // the frontend's `TexeraWebsocketEventTypeMap` is a superset — it also keys 
on the
+  // outbound-only events below (which reach it fine via the simple-name 
fallback)
+  // plus a couple with no backend producer at all, e.g. 
OperatorCurrentTuplesUpdateEvent
+  // and RecoveryStartedEvent.
+  private val expectedTypeIds: Set[String] = Set(
+    "HeartBeatResponse",
+    "WorkflowErrorEvent",
+    "WorkflowStateEvent",
+    "OperatorStatisticsUpdateEvent",
+    "WebResultUpdateEvent",
+    "ConsoleUpdateEvent",
+    "CacheStatusUpdateEvent",
+    "PaginatedResultEvent",
+    "PythonExpressionEvaluateResponse",
+    "WorkerAssignmentUpdateEvent",
+    "ModifyLogicResponse"
+  )
+
+  "TexeraWebSocketEvent @JsonSubTypes" should
+    "register exactly the wire type ids the backend declares" in {
+    val subTypes = 
classOf[TexeraWebSocketEvent].getAnnotation(classOf[JsonSubTypes])
+    subTypes should not be null
+    subTypes.value().map(_.value().getSimpleName).toSet shouldBe 
expectedTypeIds
+  }
+
+  it should "leave every subtype unnamed so the wire id stays the simple class 
name" in {
+    val named = classOf[TexeraWebSocketEvent]
+      .getAnnotation(classOf[JsonSubTypes])
+      .value()
+      .filter(_.name().nonEmpty)
+      .map(t => s"${t.value().getSimpleName}=${t.name()}")
+    named.toList shouldBe empty
+  }
+
+  "every registered event" should "emit its simple class name as the type 
property" in {
+    registeredEvents.map(_._1).toSet shouldBe expectedTypeIds
+    registeredEvents.foreach {
+      case (expectedId, event) =>
+        withClue(s"${event.getClass.getSimpleName}: ") {
+          typeIdOf(event) shouldBe expectedId
+        }
+    }
+  }
+
+  "every outbound-only event" should "still emit a type property via the 
simple-name fallback" in {
+    // Not registered, yet the frontend keys on these ids -- serialization 
must not
+    // silently drop "type" just because the subtype is missing from 
@JsonSubTypes.
+    outboundOnlyEvents.foreach {
+      case (expectedId, event) =>
+        withClue(s"${event.getClass.getSimpleName}: ") {
+          typeIdOf(event) shouldBe expectedId
+        }
+    }
+  }
+
+  // Deliberately NOT asserted: that these five fail to deserialize through 
the base
+  // trait. They do today (InvalidTypeIdException, since @JsonSubTypes has no 
entry
+  // for them), but nothing in main ever reads an event back — the consumer is
+  // TypeScript — so pinning the failure would only make a future maintainer's
+  // harmless decision to register them turn this suite red.
+
+  "the fully symmetric events" should "survive a write/read round trip" in {
+    // Only the events whose payloads are plain Scala/Java values round-trip.
+    // WorkflowErrorEvent and ConsoleUpdateEvent cannot: their scalapb enums
+    // (FatalErrorType, ConsoleMessageType) have no Jackson creator. 
WebResultUpdateEvent
+    // cannot either: WebResultUpdate is a sealed abstract class with no 
@JsonTypeInfo.
+    val symmetricIds = Set(
+      "HeartBeatResponse",
+      "WorkflowStateEvent",
+      "OperatorStatisticsUpdateEvent",
+      "CacheStatusUpdateEvent",
+      "PaginatedResultEvent",
+      "PythonExpressionEvaluateResponse",
+      "WorkerAssignmentUpdateEvent",
+      "ModifyLogicResponse"
+    )
+    val symmetric = registeredEvents.filter { case (id, _) => 
symmetricIds.contains(id) }
+    symmetric.map(_._1).toSet shouldBe symmetricIds
+    symmetric.foreach {
+      case (id, event) =>
+        withClue(s"$id: ") {
+          objectMapper.readValue(write(event), classOf[TexeraWebSocketEvent]) 
shouldBe event
+        }
+    }
+  }
+
+  "WebResultUpdateEvent" should "emit empty maps rather than omitting the 
keys" in {
+    // JSONUtils calls setSerializationInclusion twice (NON_NULL then 
NON_ABSENT) and
+    // the second call replaces the first, so the effective rule is NON_ABSENT 
— which
+    // subsumes NON_NULL but is NOT NON_EMPTY.
+    // The Angular WorkflowResultUpdateEvent reads `updates` and `tableStats`
+    // unconditionally, so tightening the inclusion rule would break the 
result panel.
+    val json = objectMapper.readTree(write(WebResultUpdateEvent(Map.empty, 
Map.empty)))
+    json.get("type").asText() shouldBe "WebResultUpdateEvent"
+    json.has("updates") shouldBe true
+    json.get("updates").isObject shouldBe true
+    json.get("updates").size() shouldBe 0
+    json.has("tableStats") shouldBe true
+    json.get("tableStats").size() shouldBe 0
+  }
+
+  it should "tag each nested WebResultUpdate mode with its @JsonTypeName" in {
+    // WebOutputMode has its own @JsonTypeInfo(Id.NAME) with @JsonTypeName on 
each
+    // subtype, so the nested "type" is the annotated name, not the class name.
+    val paginated = objectMapper.readTree(
+      write(
+        WebResultUpdateEvent(
+          Map("op-page" -> WebPaginationUpdate(PaginationMode(), 7L, List(1, 
3))),
+          Map.empty
+        )
+      )
+    )
+    val update = paginated.get("updates").get("op-page")
+    update.get("mode").get("type").asText() shouldBe "PaginationMode"
+    update.get("totalNumTuples").asLong() shouldBe 7L
+    update.get("dirtyPageIndices").toString shouldBe "[1,3]"
+
+    val delta = objectMapper.readTree(
+      write(
+        WebResultUpdateEvent(
+          Map("op-delta" -> WebDataUpdate(SetDeltaMode(), List.empty)),
+          Map.empty
+        )
+      )
+    )
+    delta.get("updates").get("op-delta").get("mode").get("type").asText() 
shouldBe "SetDeltaMode"
+  }
+
+  "PythonExpressionEvaluateResponse" should "emit a value object even for an 
empty proto Option" in {
+    // The only Option-typed field reachable from a registered event is 
scalapb's
+    // EvaluatedValue.value. Include.NON_ABSENT would drop an Option.empty, 
but scalapb's
+    // generated `getValue` accessor wins Jackson's bean introspection and 
yields
+    // TypedValue.defaultInstance instead. The Angular EvaluatedValue declares
+    // `value: TypedValue` as REQUIRED and dereferences it, so the field must 
stay
+    // present -- and must never be emitted as null.
+    val json = objectMapper.readTree(
+      write(PythonExpressionEvaluateResponse("1 + 1", Seq(EvaluatedValue(None, 
Seq.empty))))
+    )
+    val value = json.get("values").get(0).get("value")
+    value.isNull shouldBe false
+    value.isObject shouldBe true
+    value.get("valueStr").asText() shouldBe ""
+    value.get("expandable").asBoolean() shouldBe false
+  }
+
+  "WorkflowErrorEvent" should "expose every fatal-error field the Angular 
client reads" in {
+    // The TS WorkflowFatalError reads 
message/details/operatorId/workerId/type.name and
+    // timestamp.{seconds,nanos}; all values below differ so a transposition 
fails.
+    val error = objectMapper
+      .readTree(write(WorkflowErrorEvent(Seq(fatalError))))
+      .get("fatalErrors")
+      .get(0)
+    error.get("message").asText() shouldBe "message-1"
+    error.get("details").asText() shouldBe "details-2"
+    error.get("operatorId").asText() shouldBe "op-3"
+    error.get("workerId").asText() shouldBe "worker-4"
+    error.get("type").get("name").asText() shouldBe "EXECUTION_FAILURE"
+    error.get("timestamp").get("seconds").asLong() shouldBe 1_700_000_000L
+  }
+
+  "ConsoleUpdateEvent" should "expose every console-message field the Angular 
client reads" in {
+    val message = objectMapper
+      .readTree(write(ConsoleUpdateEvent("op-console", Seq(consoleMessage))))
+      .get("messages")
+      .get(0)
+    message.get("workerId").asText() shouldBe "worker-5"
+    message.get("msgType").get("name").asText() shouldBe "PRINT"
+    message.get("source").asText() shouldBe "source-6"
+    message.get("title").asText() shouldBe "title-7"
+    message.get("message").asText() shouldBe "message-8"
+  }
+}
diff --git 
a/amber/src/test/scala/org/apache/texera/web/model/websocket/request/TexeraWebSocketRequestSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/model/websocket/request/TexeraWebSocketRequestSpec.scala
new file mode 100644
index 0000000000..fc8199a0dd
--- /dev/null
+++ 
b/amber/src/test/scala/org/apache/texera/web/model/websocket/request/TexeraWebSocketRequestSpec.scala
@@ -0,0 +1,304 @@
+/*
+ * 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.web.model.websocket.request
+
+import com.fasterxml.jackson.annotation.JsonSubTypes
+import com.fasterxml.jackson.databind.exc.InvalidTypeIdException
+import org.apache.texera.amber.operator.limit.LimitOpDesc
+import org.apache.texera.amber.util.JSONUtils.objectMapper
+import org.apache.texera.web.model.websocket.request.python.{
+  DebugCommandRequest,
+  PythonExpressionEvaluateRequest
+}
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+/**
+  * Pins the client -> server half of the websocket wire contract.
+  *
+  * `WorkflowWebsocketResource.myOnMsg` deserializes every inbound frame with
+  * `objectMapper.readValue(message, classOf[TexeraWebSocketRequest])`, so 
this spec
+  * uses the very same `JSONUtils.objectMapper` (DefaultScalaModule + 
NoCtorDeserModule
+  * + Include.NON_ABSENT). A fresh `new ObjectMapper()` would test fiction: 
without
+  * DefaultScalaModule none of the Scala case classes below bind at all.
+  *
+  * Why the discriminator strings are asserted literally: 
`TexeraWebSocketRequest`
+  * carries `@JsonTypeInfo(use = Id.NAME, include = As.PROPERTY, property = 
"type")`
+  * and every `@JsonSubTypes.Type` entry omits `name =`, so Jackson's
+  * `TypeNameIdResolver` falls back to the BARE SIMPLE CLASS NAME as the wire 
id.
+  * The Angular client hard-codes those same strings; from
+  * `frontend/src/app/workspace/types/workflow-websocket.interface.ts`:
+  *
+  *   "Each type definition MUST follow the following rules:
+  *    in either TexeraWebsocketRequestTypeMap or TexeraWebsocketEventTypeMap
+  *    add a map entry:
+  *    1. key is the 'type' string, it must be the same as corresponding 
backend class name
+  *    2. value is the payload this request/event needs"
+  *
+  * The keys of `TexeraWebsocketRequestTypeMap` there are 
`EditingTimeCompilationRequest`,
+  * `HeartBeatRequest`, `ModifyLogicRequest`, `ResultExportRequest`,
+  * `ResultPaginationRequest`, `RetryRequest`, `SkipTupleRequest`,
+  * `WorkflowExecuteRequest`, `WorkflowKillRequest`, `WorkflowPauseRequest`,
+  * `WorkflowCheckpointRequest`, `WorkflowResumeRequest`,
+  * `PythonExpressionEvaluateRequest` and `DebugCommandRequest`. Renaming a 
Scala class
+  * in this package compiles cleanly on both sides and silently breaks the UI 
at
+  * runtime -- that is the breakage this spec exists to catch.
+  *
+  * `ResultPaginationRequest`'s default arguments are the highest-value pin 
here and sit
+  * on the live pagination path: the TS `PaginationRequest` declares 
`columnOffset?`,
+  * `columnLimit?` and `columnSearch?` as OPTIONAL, so real frames omit them 
and the
+  * server must fill in 0 / Int.MaxValue / None. Those defaults only 
materialize because
+  * DefaultScalaModule calls the synthetic `$lessinit$greater$default$N` 
methods -- drop
+  * that module and `columnLimit` binds to 0, so result pagination would 
silently return
+  * zero columns for every request instead of failing loudly.
+  */
+class TexeraWebSocketRequestSpec extends AnyFlatSpec with Matchers {
+
+  private def read(json: String): TexeraWebSocketRequest =
+    objectMapper.readValue(json, classOf[TexeraWebSocketRequest])
+
+  private def frame(typeId: String, fields: String): String =
+    if (fields.isEmpty) s"""{"type":"$typeId"}""" else 
s"""{"type":"$typeId",$fields}"""
+
+  // A LogicalPlanPojo / EditingTimeCompilationRequest payload with all four 
lists empty.
+  private val emptyPlanFields =
+    """"operators":[],"links":[],"opsToViewResult":[],"opsToReuseResult":[]"""
+
+  // LogicalOp is itself polymorphic on a *different* property 
("operatorType"), so a
+  // nested op exercises both discriminators in one frame.
+  private val limitOpJson = """{"operatorType":"Limit","limit":7}"""
+
+  private val executeFields =
+    s""""executionName":"exec-alpha","engineVersion":"engine-beta",""" +
+      s""""logicalPlan":{$emptyPlanFields},"workflowSettings":{},""" +
+      s""""emailNotificationEnabled":false,"computingUnitId":3"""
+
+  /**
+    * (wire type id, extra JSON fields, expected concrete class). The payloads 
are
+    * built from each subtype's own declared field names, so the table pins 
what the
+    * *server* accepts; the expected class proves the id resolved to the right
+    * subtype rather than to a same-shaped sibling.
+    *
+    * Note this is deliberately not a claim that every payload here matches 
what the
+    * shipped Angular client sends. `SkipTupleRequest` is a known divergence — 
the
+    * client sends `workers` (execute-workflow.service.ts) while the case class
+    * declares `workerIds`, and the shared mapper never disables
+    * FAIL_ON_UNKNOWN_PROPERTIES, so the real client frame would be rejected. 
That
+    * is a production naming bug, not something to encode as an expectation 
here;
+    * the feature is disabled anyway (ExecutionRuntimeService throws
+    * "skipping tuple is temporarily disabled" before reading the field).
+    */
+  private val registeredRequests: List[(String, String, Class[_ <: 
TexeraWebSocketRequest])] =
+    List(
+      ("EditingTimeCompilationRequest", emptyPlanFields, 
classOf[EditingTimeCompilationRequest]),
+      ("HeartBeatRequest", "", classOf[HeartBeatRequest]),
+      ("ModifyLogicRequest", s""""operator":$limitOpJson""", 
classOf[ModifyLogicRequest]),
+      (
+        "ResultPaginationRequest",
+        
""""requestID":"req-1","operatorID":"op-2","pageIndex":3,"pageSize":25""",
+        classOf[ResultPaginationRequest]
+      ),
+      ("RetryRequest", """"workers":["worker-1"]""", classOf[RetryRequest]),
+      ("SkipTupleRequest", """"workerIds":["worker-1"]""", 
classOf[SkipTupleRequest]),
+      ("WorkflowExecuteRequest", executeFields, 
classOf[WorkflowExecuteRequest]),
+      ("WorkflowKillRequest", "", classOf[WorkflowKillRequest]),
+      ("WorkflowPauseRequest", "", classOf[WorkflowPauseRequest]),
+      ("WorkflowResumeRequest", "", classOf[WorkflowResumeRequest]),
+      ("WorkflowCheckpointRequest", "", classOf[WorkflowCheckpointRequest]),
+      (
+        "PythonExpressionEvaluateRequest",
+        """"expression":"1 + 1","operatorId":"op-eval"""",
+        classOf[PythonExpressionEvaluateRequest]
+      ),
+      (
+        "DebugCommandRequest",
+        """"operatorId":"op-dbg","workerId":"worker-dbg","cmd":"break 12"""",
+        classOf[DebugCommandRequest]
+      )
+    )
+
+  // The 13 "type" strings the SERVER accepts, i.e. the backend registry. 
Spelled out
+  // rather than derived so a rename shows up as a set diff instead of quietly
+  // re-deriving.
+  //
+  // Deliberately not phrased as "what the client sends": the frontend's
+  // `TexeraWebsocketRequestTypeMap` is a superset. It also declares
+  // `ResultExportRequest`, which on the backend is an HTTP model
+  // (web/model/http/request/result/, served by WorkflowExecutionsResource) 
and not a
+  // TexeraWebSocketRequest subtype at all — see the divergence test below.
+  private val expectedTypeIds: Set[String] = Set(
+    "EditingTimeCompilationRequest",
+    "HeartBeatRequest",
+    "ModifyLogicRequest",
+    "ResultPaginationRequest",
+    "RetryRequest",
+    "SkipTupleRequest",
+    "WorkflowExecuteRequest",
+    "WorkflowKillRequest",
+    "WorkflowPauseRequest",
+    "WorkflowResumeRequest",
+    "WorkflowCheckpointRequest",
+    "PythonExpressionEvaluateRequest",
+    "DebugCommandRequest"
+  )
+
+  "TexeraWebSocketRequest @JsonSubTypes" should
+    "register exactly the wire type ids the server accepts" in {
+    val subTypes = 
classOf[TexeraWebSocketRequest].getAnnotation(classOf[JsonSubTypes])
+    subTypes should not be null
+    subTypes.value().map(_.value().getSimpleName).toSet shouldBe 
expectedTypeIds
+  }
+
+  it should "leave every subtype unnamed so the wire id stays the simple class 
name" in {
+    // Adding `name = "..."` to any entry would change that subtype's wire id 
without
+    // touching the class name, which the Angular map keys on.
+    val named = classOf[TexeraWebSocketRequest]
+      .getAnnotation(classOf[JsonSubTypes])
+      .value()
+      .filter(_.name().nonEmpty)
+      .map(t => s"${t.value().getSimpleName}=${t.name()}")
+    named.toList shouldBe empty
+  }
+
+  "every registered request type" should "deserialize through the polymorphic 
base" in {
+    registeredRequests.map(_._1).toSet shouldBe expectedTypeIds
+    registeredRequests.foreach {
+      case (typeId, fields, expected) =>
+        withClue(s"""type id "$typeId": """) {
+          read(frame(typeId, fields)).getClass shouldBe expected
+        }
+    }
+  }
+
+  "an unknown type id" should "be rejected instead of silently ignored" in {
+    // A stale or typo'd client is a real path; it must fail loudly at the 
mapper
+    // rather than bind to some same-shaped sibling. The id is deliberately 
one that
+    // exists nowhere on either side, so the test cannot be misread as a claim 
about
+    // any real type.
+    val ex = 
intercept[InvalidTypeIdException](read("""{"type":"NoSuchWebSocketRequest"}"""))
+    ex.getMessage should include("NoSuchWebSocketRequest")
+  }
+
+  "the frontend-only ResultExportRequest id" should "not be accepted over the 
websocket" in {
+    // The TS `TexeraWebsocketRequestTypeMap` declares `ResultExportRequest`, 
but the
+    // backend class of that name is an HTTP model 
(web/model/http/request/result/,
+    // reached through WorkflowExecutionsResource.exportResultToDataset) and 
is not a
+    // TexeraWebSocketRequest subtype. Pinning the rejection documents the 
divergence:
+    // if result export is ever moved onto the socket, this test is the 
reminder that
+    // the subtype has to be registered too.
+    val ex = 
intercept[InvalidTypeIdException](read("""{"type":"ResultExportRequest"}"""))
+    ex.getMessage should include("ResultExportRequest")
+  }
+
+  "a frame with no type property" should "be rejected" in {
+    val ex = intercept[InvalidTypeIdException](
+      
read("""{"requestID":"req-1","operatorID":"op-2","pageIndex":3,"pageSize":25}""")
+    )
+    // The exception type is the contract here; the message check stays 
deliberately
+    // loose (just the property name) because Jackson's exact wording is 
version-specific.
+    ex.getMessage should include("type")
+  }
+
+  "ResultPaginationRequest" should "fill in the three optional column fields 
when absent" in {
+    // The TS PaginationRequest marks these optional, so most real frames omit 
them.
+    val req = read(
+      frame(
+        "ResultPaginationRequest",
+        
""""requestID":"req-1","operatorID":"op-2","pageIndex":3,"pageSize":25"""
+      )
+    ).asInstanceOf[ResultPaginationRequest]
+    req.requestID shouldBe "req-1"
+    req.operatorID shouldBe "op-2"
+    req.pageIndex shouldBe 3
+    req.pageSize shouldBe 25
+    req.columnOffset shouldBe 0
+    req.columnLimit shouldBe Int.MaxValue
+    req.columnSearch shouldBe None
+  }
+
+  it should "let explicit column values override the defaults" in {
+    val req = read(
+      frame(
+        "ResultPaginationRequest",
+        
""""requestID":"req-9","operatorID":"op-8","pageIndex":2,"pageSize":50,""" +
+          """"columnOffset":4,"columnLimit":6,"columnSearch":"city""""
+      )
+    ).asInstanceOf[ResultPaginationRequest]
+    req.columnOffset shouldBe 4
+    req.columnLimit shouldBe 6
+    req.columnSearch shouldBe Some("city")
+  }
+
+  "WorkflowExecuteRequest" should "bind an absent replayFromExecution to None" 
in {
+    val req = read(frame("WorkflowExecuteRequest", executeFields))
+      .asInstanceOf[WorkflowExecuteRequest]
+    req.replayFromExecution shouldBe None
+    // distinct values so a transposed executionName/engineVersion binding 
fails here
+    req.executionName shouldBe "exec-alpha"
+    req.engineVersion shouldBe "engine-beta"
+    req.computingUnitId shouldBe 3
+    req.emailNotificationEnabled shouldBe false
+  }
+
+  it should "bind a present replayFromExecution to Some with a nested eid and 
interaction" in {
+    // The replay path is the reason WorkflowExecuteRequest has an Option at 
all:
+    // an absent key must stay None (asserted above) while a present object 
must
+    // populate both nested fields. The values differ in kind so a swapped
+    // eid/interaction binding cannot pass.
+    val req = read(
+      frame(
+        "WorkflowExecuteRequest",
+        executeFields + 
""","replayFromExecution":{"eid":91,"interaction":"interaction-7"}"""
+      )
+    ).asInstanceOf[WorkflowExecuteRequest]
+    req.replayFromExecution shouldBe Some(ReplayExecutionInfo(91L, 
"interaction-7"))
+  }
+
+  "the python request payloads" should "bind each wire field to the matching 
parameter" in {
+    // All values differ, so any transposition (or a renamed JSON property) 
fails.
+    val evaluate = read(
+      frame(
+        "PythonExpressionEvaluateRequest",
+        """"expression":"len(tuple_)","operatorId":"op-eval""""
+      )
+    ).asInstanceOf[PythonExpressionEvaluateRequest]
+    evaluate.expression shouldBe "len(tuple_)"
+    evaluate.operatorId shouldBe "op-eval"
+
+    val debug = read(
+      frame(
+        "DebugCommandRequest",
+        """"operatorId":"op-dbg","workerId":"worker-dbg","cmd":"break 12""""
+      )
+    ).asInstanceOf[DebugCommandRequest]
+    debug.operatorId shouldBe "op-dbg"
+    debug.workerId shouldBe "worker-dbg"
+    debug.cmd shouldBe "break 12"
+  }
+
+  "ModifyLogicRequest" should "resolve the nested LogicalOp discriminator too" 
in {
+    // "type" selects the request, "operatorType" selects the operator; the two
+    // @JsonTypeInfo properties must not collide.
+    val req = read(frame("ModifyLogicRequest", s""""operator":$limitOpJson"""))
+      .asInstanceOf[ModifyLogicRequest]
+    req.operator shouldBe a[LimitOpDesc]
+    req.operator.asInstanceOf[LimitOpDesc].limit shouldBe 7
+  }
+}

Reply via email to