This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-7768-0f54e934f147721ee53b04056808f194607333db in repository https://gitbox.apache.org/repos/asf/texera.git
commit 8be174308d4e597f120253cc68dbd4f21c0af1ed Author: Eugene Gu <[email protected]> AuthorDate: Wed Aug 19 04:38:51 2026 +0000 test(workflow-operator): guard visualization output schemas (#7768) ### What changes were proposed in this PR? The frontend renders a chart by reading a single `html-content` STRING column from a visualization operator's one output port. All 48 Python visualization descriptors re-declare that contract in identical three-line `getOutputSchemas` overrides, but 24 of them never had the method called by any test, and no spec swept the operator registry for the contract as a whole — so a new chart, or an edit to an existing one, could silently ship a schema the frontend cannot render. This PR adds one new guard spec, `VisualizationOutputSchemaSpec`, instead of 24 per-operator tests. It walks `OperatorMetadataGenerator.operatorTypeMap`, selects the visualization descriptors by package, instantiates each, and asserts the contract, so the next chart operator is covered the moment it is registered. Four tests: a floor assertion (at least 48 swept) so a package rename cannot turn the sweep into a vacuous pass; an assertion that the only visualization-package registrations outside `PythonOperatorDescriptor` are exactly `HtmlVizOpDesc` and `UrlVizOpDesc`, which derive their schemas through `SchemaPropagationFunc` and are covered by their own specs, so a new non-Python visualization operator must be consciously added to the exclusion list; the main per-descriptor sweep asserting exactly one output port whose schema is a single `html-content` STRING attribute, with a per-operator `withClue` for diagnosis; and an assertion that the derived schema is independent of the input schemas. No production code changed; all 48 descriptors satisfy the contract today. The registry-wide-guard shape follows the precedents in the same package, `OutputPortReuseFlagSpec` and `AttributeTypeRuleTargetSpec` (#7249). ### Any related issues, documentation, discussions? Resolves #7766 ### How was this PR tested? This PR is test-only. The new suite passes locally: `sbt "WorkflowOperator/testOnly *VisualizationOutputSchemaSpec"` runs 4 tests sweeping all 48 descriptors, all green, and `WorkflowOperator/Test/scalafmtCheck` passes. The full `WorkflowOperator/test` suite was also run locally to confirm the new spec introduces no cross-test interference. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Fable 5) --- .../metadata/VisualizationOutputSchemaSpec.scala | 108 +++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/metadata/VisualizationOutputSchemaSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/metadata/VisualizationOutputSchemaSpec.scala new file mode 100644 index 0000000000..d1f5ee04a0 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/metadata/VisualizationOutputSchemaSpec.scala @@ -0,0 +1,108 @@ +/* + * 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.operator.metadata + +import org.apache.texera.amber.core.tuple.{AttributeType, Schema} +import org.apache.texera.amber.core.workflow.PortIdentity +import org.apache.texera.amber.operator.PythonOperatorDescriptor +import org.apache.texera.amber.operator.visualization.htmlviz.HtmlVizOpDesc +import org.apache.texera.amber.operator.visualization.urlviz.UrlVizOpDesc +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +/** + * Guard for the visualization output-schema contract, over every registered + * visualization descriptor. + * + * The frontend locates the chart HTML a visualization returns by reading a + * single `html-content` STRING column from the operator's one output port. + * Every Python visualization descriptor re-declares that contract in its own + * `getOutputSchemas` override, so a new chart (or an edit to an existing one) + * can silently ship a schema the frontend cannot render, and only that + * operator's own spec -- if it asserts the schema at all -- would notice. + * + * Sweeping the registry rather than testing the descriptors one by one is + * deliberate: the contract lives in 48 identical three-line overrides, so the + * mistake is as easy to make in the next chart as in these, and a per-operator + * test would not exist for the next chart until someone remembers to write it. + */ +class VisualizationOutputSchemaSpec extends AnyFlatSpec with Matchers { + + // HtmlViz and UrlViz are Scala map operators: they do not extend + // PythonOperatorDescriptor and derive their output schema through + // getPhysicalOp's SchemaPropagationFunc instead, covered by their own specs. + // They are excluded by explicit class so that any NEW visualization + // descriptor is swept by default and must be consciously added here to + // escape the contract. + private val nonPythonVisualizations: Set[Class[_]] = + Set(classOf[HtmlVizOpDesc], classOf[UrlVizOpDesc]) + + private val visualizationClasses = + OperatorMetadataGenerator.operatorTypeMap.keys.toSeq + .filter(_.getName.startsWith("org.apache.texera.amber.operator.visualization.")) + .sortBy(_.getSimpleName) + + private val sweptClasses = + visualizationClasses.filterNot(nonPythonVisualizations.contains) + + private def instantiate(opClass: Class[_]): PythonOperatorDescriptor = + opClass.getConstructor().newInstance().asInstanceOf[PythonOperatorDescriptor] + + "the registry" should "contain the visualization descriptors this sweep guards" in { + // If the selection ever comes back empty (say, the package is renamed), + // the per-operator assertions below would vacuously pass; pin a floor. + sweptClasses.size should be >= 48 + } + + it should "register no visualization descriptor outside the Python contract except the known two" in { + val nonPython = + visualizationClasses.filterNot(classOf[PythonOperatorDescriptor].isAssignableFrom) + nonPython.toSet shouldBe nonPythonVisualizations + } + + "every Python visualization descriptor" should "declare a single html-content STRING column on its one output port" in { + sweptClasses.foreach { opClass => + withClue(s"${opClass.getSimpleName}: ") { + val op = instantiate(opClass) + val outputPortIds = op.operatorInfo.outputPorts.map(_.id) + outputPortIds should have length 1 + + val outputSchemas = op.getOutputSchemas(Map.empty[PortIdentity, Schema]) + outputSchemas.keySet shouldBe Set(outputPortIds.head) + + val schema = outputSchemas(outputPortIds.head) + schema.getAttributeNames shouldBe List("html-content") + schema.getAttribute("html-content").getType shouldBe AttributeType.STRING + } + } + } + + it should "derive that schema independently of the input schemas" in { + val populatedInput = + Map(PortIdentity() -> Schema().add("x", AttributeType.INTEGER)) + sweptClasses.foreach { opClass => + withClue(s"${opClass.getSimpleName}: ") { + val op = instantiate(opClass) + op.getOutputSchemas(Map.empty[PortIdentity, Schema]) shouldBe + op.getOutputSchemas(populatedInput) + } + } + } +}
