Copilot commented on code in PR #7641:
URL: https://github.com/apache/texera/pull/7641#discussion_r3792787767


##########
docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/OperatorControllerBuilder.scala:
##########
@@ -0,0 +1,725 @@
+/*
+ * 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.demovideos.controllers
+
+import com.microsoft.playwright._
+import com.microsoft.playwright.options.WaitForSelectorState
+import org.apache.texera.amber.operator.metadata.OperatorMetadataGenerator
+
+// ═══════════════════════════════════════════════════════════════════
+// 3. OperatorControllerBuilder
+//    new OperatorControllerBuilder(ctx)
+//      .insertViaDrag("Bar Chart", dragNextTo = Some("CSVFileScan-operator-"))
+//      .execute()
+// ═══════════════════════════════════════════════════════════════════
+
+class OperatorControllerBuilder(ctx: ControllerContext) extends 
ControllerBuilder(ctx) {
+
+  private lazy val operatorMetadata = 
OperatorMetadataGenerator.allOperatorMetadata.operators
+  private def groupPathByName: Map[String, Seq[String]] = 
OperatorGroups.pathByName
+
+  // Some list items drag from an inner handle element rather than the outer 
container.
+  private def dragHandle(item: Locator): Locator = {
+    val draggable = 
Utils.firstVisible(item.locator("[draggable='true']")).orNull
+    if (draggable != null && draggable.count() > 0) draggable else item
+  }
+
+  def insertViaDrag(
+      operatorName: String,
+      operatorType: Option[String] = None,
+      canvasPosition: (Double, Double) = (0.06, 0.2),
+      dragNextTo: Option[String] = None,
+      autoConnectToAnchor: Boolean = false,
+      connectAdditionalFrom: Option[String] = None,
+      connectAdditionalFromPortIndex: Int = 0,
+      connectAdditionalToInputIndex: Option[Int] = None,
+      // Gap (px) between the anchor's right edge and the drop; ML scripts 
pass a
+      // tighter value because their template has more intermediate nodes.
+      dragSpacing: Double = 180.0
+  ): this.type =
+    addStep(new ControllerStep {
+      override def name =
+        s"Insert '$operatorName' via Drag${dragNextTo.map(n => s" (next to 
$n)").getOrElse("")}"
+      override def run(ctx: ControllerContext): Unit = {
+        val page = ctx.page
+        ctx.ensureFakeCursor()
+        val sidebarSearchText: String = operatorName
+
+        // ── Open operator panel & resolve source ──
+        val operatorsMenu = page
+          .getByTestId("operator-left-panel-operators-button")
+          .or(page.getByText("Operators", new 
Page.GetByTextOptions().setExact(true)))
+          .first()
+        Utils.waitVisible(operatorsMenu)
+        Utils.clickWithCursor(page, operatorsMenu)
+
+        val panelSearch = page
+          .getByTestId("operator-search-input")
+          .or(page.getByPlaceholder("search operator"))
+          .first()
+        try {
+          panelSearch.waitFor(
+            new Locator.WaitForOptions()
+              .setState(WaitForSelectorState.VISIBLE)
+              .setTimeout(Timeouts.Quick)
+          )
+        } catch {
+          case _: Exception =>
+            // Some states require one more click to switch to the Operators 
tab.
+            Utils.clickWithCursor(page, operatorsMenu)
+            panelSearch.waitFor(
+              new Locator.WaitForOptions()
+                .setState(WaitForSelectorState.VISIBLE)
+                .setTimeout(Timeouts.Quick)
+            )
+        }
+
+        // Locate operator from group
+        val metadata = metadataFor(operatorName, operatorType)
+        val groupPath = metadata
+          .flatMap(m => 
groupPathByName.get(m.additionalMetadata.operatorGroupName))
+          .getOrElse(Seq.empty)
+        val hierarchyOperator = resolveByGroupPath(page, operatorName, 
operatorType)
+
+        val operator = hierarchyOperator
+          .map(dragHandle)
+          .getOrElse {
+            println(s"[Operator] Hierarchy fallback to search for 
'$operatorName'")
+            val searchInput = page
+              .getByTestId("operator-search-input")
+              .or(page.getByPlaceholder("search operator"))
+              .first()
+            Utils.waitVisible(searchInput)
+            Utils.clickWithCursor(page, searchInput)
+            searchInput.fill(sidebarSearchText)
+            page.waitForTimeout(Delays.Settle)
+            dragHandle(resolveOperatorSource(page, sidebarSearchText, 
operatorType))

Review Comment:
   The hierarchy fallback does not actually resolve the visible autocomplete 
result. `resolveOperatorSource` first returns any matching `data-testid` by 
`count()`, even when that group item is hidden; if collapsed panels are not 
rendered it searches only `#left-container`, while the autocomplete option is 
in the overlay. The subsequent drag therefore gets a null bounding box or 
throws before the Enter fallback can run. Resolve a visible autocomplete option 
explicitly (or make it carry the per-operator hook) before attempting the drag.



##########
docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/NavigationControllerBuilder.scala:
##########
@@ -0,0 +1,153 @@
+/*
+ * 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.demovideos.controllers
+
+import com.microsoft.playwright._
+import com.microsoft.playwright.options.{AriaRole, LoadState, 
WaitForSelectorState, WaitUntilState}
+import org.apache.texera.demovideos.config.TestDataConfig
+
+// ═══════════════════════════════════════════════════════════════════
+// 2. NavigationControllerBuilder
+//    new NavigationControllerBuilder(ctx).createNewWorkflow().execute()
+//    new 
NavigationControllerBuilder(ctx).importWorkflow("path/to/sample.json").execute()
+// ═══════════════════════════════════════════════════════════════════
+
+class NavigationControllerBuilder(ctx: ControllerContext) extends 
ControllerBuilder(ctx) {
+
+  private def gotoWorkflowList(page: Page): Unit = {
+    page.navigate(
+      s"${TestDataConfig.baseUrl}/user/workflow",
+      new Page.NavigateOptions()
+        .setWaitUntil(WaitUntilState.DOMCONTENTLOADED)
+        .setTimeout(Timeouts.Long)
+    )
+    try {
+      page.waitForLoadState(
+        LoadState.NETWORKIDLE,
+        new Page.WaitForLoadStateOptions().setTimeout(Timeouts.Medium)
+      )
+    } catch {
+      case _: Exception =>
+    }
+  }
+
+  private def waitForCanvas(page: Page): Unit = {
+    page
+      .getByTestId("navigation-workflow-canvas")
+      .first()
+      .waitFor(
+        new Locator.WaitForOptions()
+          .setState(WaitForSelectorState.VISIBLE)
+          .setTimeout(Timeouts.Long)
+      )
+  }
+
+  def createNewWorkflow(): this.type =
+    addStep(new ControllerStep {
+      override def name = "Create New Workflow"
+      override def run(ctx: ControllerContext): Unit = {
+        val page = ctx.page
+        ctx.ensureFakeCursor()
+        gotoWorkflowList(page)
+
+        val createBtn = page
+          .getByTestId("navigation-create-workflow-button")
+          .or(
+            page.getByRole(AriaRole.BUTTON, new 
Page.GetByRoleOptions().setName("Create Workflow"))
+          )
+          .first()
+        Utils.clickWithCursor(page, createBtn)
+
+        try waitForCanvas(page)
+        catch {
+          case _: Exception =>
+            if (!page.url().contains("/workflow/")) {
+              throw new RuntimeException("Create workflow did not open 
workflow editor.")
+            }
+        }
+      }
+    })
+
+  def importWorkflow(jsonFilePath: String): this.type =
+    addStep(new ControllerStep {
+      override def name = s"Import Workflow from 
${jsonFilePath.split("/").last}"
+      override def run(ctx: ControllerContext): Unit = {
+        val page = ctx.page
+        ctx.ensureFakeCursor()
+
+        val filePath = java.nio.file.Paths.get(jsonFilePath)
+        if (!java.nio.file.Files.exists(filePath)) {
+          throw new RuntimeException(s"Workflow JSON not found: $jsonFilePath")
+        }
+        // The dashboard names the uploaded workflow after the file, minus the 
extension.
+        val fileName = filePath.getFileName.toString
+        val workflowName = {
+          val dot = fileName.lastIndexOf('.')
+          if (dot == -1) fileName else fileName.substring(0, dot)
+        }
+
+        // Uploading from the workflow listing creates a NEW workflow named 
after the
+        // file and appends it to the list; it is not opened automatically.
+        gotoWorkflowList(page)
+
+        val uploadBtn = page
+          .getByTestId("navigation-upload-workflow-button")
+          .or(page.getByTitle("Upload ZIP/JSON file as workflow"))
+          .first()
+        Utils.waitVisible(uploadBtn)
+        val chooser = page.waitForFileChooser(
+          new Page.WaitForFileChooserOptions().setTimeout(Timeouts.Medium),
+          () => {
+            Utils.clickWithCursor(page, uploadBtn)
+          }
+        )
+        chooser.setFiles(filePath)
+
+        try {
+          page
+            .getByText("Upload Successful")
+            .first()
+            .waitFor(
+              new Locator.WaitForOptions()
+                .setState(WaitForSelectorState.VISIBLE)
+                .setTimeout(Timeouts.Long)
+            )
+        } catch {
+          case _: Exception =>
+            println("[Import] Upload confirmation not seen; falling back to 
the list entry")
+        }
+
+        // The upload handler refreshes the search afterwards, which re-sorts 
the
+        // list newest-first — so the FIRST same-named entry is this upload's 
workflow.
+        // .workflow-name is the list view, .resource-name the card view; 
which one
+        // renders depends on the user's saved view preference.
+        val entry = page
+          .locator(".workflow-name, .resource-name")
+          .filter(new Locator.FilterOptions().setHasText(workflowName))
+          .first()

Review Comment:
   `setHasText(workflowName)` performs a substring match, so importing 
`foo.json` can click a newer `foobar` entry instead of the uploaded `foo` 
workflow. Use an exact text locator or an anchored, escaped regex before 
selecting the first same-named result.



##########
docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/OperatorControllerBuilder.scala:
##########
@@ -0,0 +1,725 @@
+/*
+ * 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.demovideos.controllers
+
+import com.microsoft.playwright._
+import com.microsoft.playwright.options.WaitForSelectorState
+import org.apache.texera.amber.operator.metadata.OperatorMetadataGenerator
+
+// ═══════════════════════════════════════════════════════════════════
+// 3. OperatorControllerBuilder
+//    new OperatorControllerBuilder(ctx)
+//      .insertViaDrag("Bar Chart", dragNextTo = Some("CSVFileScan-operator-"))
+//      .execute()
+// ═══════════════════════════════════════════════════════════════════
+
+class OperatorControllerBuilder(ctx: ControllerContext) extends 
ControllerBuilder(ctx) {
+
+  private lazy val operatorMetadata = 
OperatorMetadataGenerator.allOperatorMetadata.operators
+  private def groupPathByName: Map[String, Seq[String]] = 
OperatorGroups.pathByName
+
+  // Some list items drag from an inner handle element rather than the outer 
container.
+  private def dragHandle(item: Locator): Locator = {
+    val draggable = 
Utils.firstVisible(item.locator("[draggable='true']")).orNull
+    if (draggable != null && draggable.count() > 0) draggable else item
+  }
+
+  def insertViaDrag(
+      operatorName: String,
+      operatorType: Option[String] = None,
+      canvasPosition: (Double, Double) = (0.06, 0.2),
+      dragNextTo: Option[String] = None,
+      autoConnectToAnchor: Boolean = false,
+      connectAdditionalFrom: Option[String] = None,
+      connectAdditionalFromPortIndex: Int = 0,
+      connectAdditionalToInputIndex: Option[Int] = None,
+      // Gap (px) between the anchor's right edge and the drop; ML scripts 
pass a
+      // tighter value because their template has more intermediate nodes.
+      dragSpacing: Double = 180.0
+  ): this.type =
+    addStep(new ControllerStep {
+      override def name =
+        s"Insert '$operatorName' via Drag${dragNextTo.map(n => s" (next to 
$n)").getOrElse("")}"
+      override def run(ctx: ControllerContext): Unit = {
+        val page = ctx.page
+        ctx.ensureFakeCursor()
+        val sidebarSearchText: String = operatorName
+
+        // ── Open operator panel & resolve source ──
+        val operatorsMenu = page
+          .getByTestId("operator-left-panel-operators-button")
+          .or(page.getByText("Operators", new 
Page.GetByTextOptions().setExact(true)))
+          .first()
+        Utils.waitVisible(operatorsMenu)
+        Utils.clickWithCursor(page, operatorsMenu)
+
+        val panelSearch = page
+          .getByTestId("operator-search-input")
+          .or(page.getByPlaceholder("search operator"))
+          .first()
+        try {
+          panelSearch.waitFor(
+            new Locator.WaitForOptions()
+              .setState(WaitForSelectorState.VISIBLE)
+              .setTimeout(Timeouts.Quick)
+          )
+        } catch {
+          case _: Exception =>
+            // Some states require one more click to switch to the Operators 
tab.
+            Utils.clickWithCursor(page, operatorsMenu)
+            panelSearch.waitFor(
+              new Locator.WaitForOptions()
+                .setState(WaitForSelectorState.VISIBLE)
+                .setTimeout(Timeouts.Quick)
+            )
+        }
+
+        // Locate operator from group
+        val metadata = metadataFor(operatorName, operatorType)
+        val groupPath = metadata
+          .flatMap(m => 
groupPathByName.get(m.additionalMetadata.operatorGroupName))
+          .getOrElse(Seq.empty)
+        val hierarchyOperator = resolveByGroupPath(page, operatorName, 
operatorType)
+
+        val operator = hierarchyOperator
+          .map(dragHandle)
+          .getOrElse {
+            println(s"[Operator] Hierarchy fallback to search for 
'$operatorName'")
+            val searchInput = page
+              .getByTestId("operator-search-input")
+              .or(page.getByPlaceholder("search operator"))
+              .first()
+            Utils.waitVisible(searchInput)
+            Utils.clickWithCursor(page, searchInput)
+            searchInput.fill(sidebarSearchText)
+            page.waitForTimeout(Delays.Settle)
+            dragHandle(resolveOperatorSource(page, sidebarSearchText, 
operatorType))
+          }
+        operator.scrollIntoViewIfNeeded()
+        page.waitForTimeout(Delays.Tick)
+
+        // ── Prepare canvas ──
+        val canvas = page
+          .getByTestId("navigation-workflow-canvas")
+          .or(page.locator("svg[joint-selector='svg'], svg#v-2"))
+          .first()
+        Utils.waitVisible(canvas)
+        canvas.scrollIntoViewIfNeeded()
+        page.waitForTimeout(Delays.Tick)
+
+        val beforeCount = page.locator("g.joint-cell.joint-element").count()
+        val beforeLinkCount = page.locator("g.joint-cell.joint-link").count()
+        val canvasBox = canvas.boundingBox()
+        if (canvasBox == null)
+          throw new RuntimeException("Drag failed: missing canvas bounding 
box")
+
+        // ── Calculate drop position ──
+        val anchorNode: Option[Locator] = 
dragNextTo.flatMap(findNodeByType(page, _))
+
+        if (dragNextTo.isDefined && anchorNode.isEmpty) {
+          println(
+            s"[Operator] Warning: dragNextTo='${dragNextTo.get}' not found on 
canvas, using default position"
+          )
+        }
+
+        val (tgtX, tgtY) = anchorNode
+          .flatMap { anchor =>
+            val box = Utils.cellBox(anchor)
+            if (box != null) {
+              Some(
+                (
+                  math.min(canvasBox.x + canvasBox.width - 30, box.x + 
box.width + dragSpacing),
+                  box.y + box.height / 2.0 - 40.0 // slight lift so the label 
row stays readable
+                )
+              )
+            } else None
+          }
+          .getOrElse {
+            // No anchor: place at the canvasPosition fraction, stepping a 
4-column grid
+            // (~node footprint) past any existing nodes. The min() clamps 
keep the drop
+            // inside the canvas — outside it the drop is silently lost.
+            val index = Math.max(0, beforeCount)
+            val baseX = canvasBox.x + canvasBox.width * canvasPosition._1
+            val baseY = canvasBox.y + canvasBox.height * canvasPosition._2
+            (
+              math.min(canvasBox.x + canvasBox.width - 40, baseX + (index % 4) 
* 180),
+              math.min(canvasBox.y + canvasBox.height - 40, baseY + (index / 
4) * 120)
+            )
+          }
+
+        // ── Perform drag with fallbacks ──
+        performDrag(page, operator, tgtX, tgtY)
+
+        val targetCount = beforeCount + 1
+        if (!waitForNodeCountAtLeast(page, targetCount, maxRetries = 20)) {
+          val searchInput = page
+            .getByTestId("operator-search-input")
+            .or(page.getByPlaceholder("search operator"))
+            .first()
+          Utils.waitVisible(searchInput)
+          Utils.clickWithCursor(page, searchInput)
+          searchInput.fill("")
+          page.waitForTimeout(Delays.Tick)
+          val retryOperator =
+            dragHandle(resolveOperatorSource(page, sidebarSearchText, 
operatorType))
+          performDrag(page, retryOperator, tgtX, tgtY)
+        }
+        if (!waitForNodeCountAtLeast(page, targetCount, maxRetries = 20)) {
+          // Last fallback: insert through search + Enter when drag source is 
flaky.
+          val searchInput = page
+            .getByTestId("operator-search-input")
+            .or(page.getByPlaceholder("search operator"))
+            .first()
+          Utils.waitVisible(searchInput)
+          Utils.clickWithCursor(page, searchInput)
+          searchInput.fill("")
+          page.waitForTimeout(Delays.Tick)
+          searchInput.fill(sidebarSearchText)
+          page.waitForTimeout(Delays.Tick)
+          searchInput.press("Enter")
+        }
+        if (!waitForNodeCountAtLeast(page, targetCount, maxRetries = 20)) {
+          throw new RuntimeException(
+            s"Insert failed for '$operatorName' 
(${operatorType.getOrElse("unknown")}). " +
+              s"Canvas count did not increase from $beforeCount."
+          )
+        }
+
+        val centerBtn = page.getByTitle("minimap-center-button")
+        if (centerBtn.count() > 0) {
+          Utils.clickWithCursor(page, centerBtn)
+          page.waitForTimeout(Delays.Tick)
+        }
+
+        // ── Click the new node to select it ──
+        val newNode = operatorType
+          .flatMap(findNodeByType(page, _))
+          
.getOrElse(Utils.waitVisible(page.locator("g.joint-cell.joint-element").nth(beforeCount)))

Review Comment:
   This can select an existing operator of the same type rather than the node 
just inserted because `findNodeByType` returns the first match. Connections and 
repositioning would then modify the old node and leave the new node unwired. 
Select the newly appended element, as the fallback already does.



##########
docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/OperatorControllerBuilder.scala:
##########
@@ -0,0 +1,725 @@
+/*
+ * 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.demovideos.controllers
+
+import com.microsoft.playwright._
+import com.microsoft.playwright.options.WaitForSelectorState
+import org.apache.texera.amber.operator.metadata.OperatorMetadataGenerator
+
+// ═══════════════════════════════════════════════════════════════════
+// 3. OperatorControllerBuilder
+//    new OperatorControllerBuilder(ctx)
+//      .insertViaDrag("Bar Chart", dragNextTo = Some("CSVFileScan-operator-"))
+//      .execute()
+// ═══════════════════════════════════════════════════════════════════
+
+class OperatorControllerBuilder(ctx: ControllerContext) extends 
ControllerBuilder(ctx) {
+
+  private lazy val operatorMetadata = 
OperatorMetadataGenerator.allOperatorMetadata.operators
+  private def groupPathByName: Map[String, Seq[String]] = 
OperatorGroups.pathByName
+
+  // Some list items drag from an inner handle element rather than the outer 
container.
+  private def dragHandle(item: Locator): Locator = {
+    val draggable = 
Utils.firstVisible(item.locator("[draggable='true']")).orNull
+    if (draggable != null && draggable.count() > 0) draggable else item
+  }
+
+  def insertViaDrag(
+      operatorName: String,
+      operatorType: Option[String] = None,
+      canvasPosition: (Double, Double) = (0.06, 0.2),
+      dragNextTo: Option[String] = None,
+      autoConnectToAnchor: Boolean = false,
+      connectAdditionalFrom: Option[String] = None,
+      connectAdditionalFromPortIndex: Int = 0,
+      connectAdditionalToInputIndex: Option[Int] = None,
+      // Gap (px) between the anchor's right edge and the drop; ML scripts 
pass a
+      // tighter value because their template has more intermediate nodes.
+      dragSpacing: Double = 180.0
+  ): this.type =
+    addStep(new ControllerStep {
+      override def name =
+        s"Insert '$operatorName' via Drag${dragNextTo.map(n => s" (next to 
$n)").getOrElse("")}"
+      override def run(ctx: ControllerContext): Unit = {
+        val page = ctx.page
+        ctx.ensureFakeCursor()
+        val sidebarSearchText: String = operatorName
+
+        // ── Open operator panel & resolve source ──
+        val operatorsMenu = page
+          .getByTestId("operator-left-panel-operators-button")
+          .or(page.getByText("Operators", new 
Page.GetByTextOptions().setExact(true)))
+          .first()
+        Utils.waitVisible(operatorsMenu)
+        Utils.clickWithCursor(page, operatorsMenu)
+
+        val panelSearch = page
+          .getByTestId("operator-search-input")
+          .or(page.getByPlaceholder("search operator"))
+          .first()
+        try {
+          panelSearch.waitFor(
+            new Locator.WaitForOptions()
+              .setState(WaitForSelectorState.VISIBLE)
+              .setTimeout(Timeouts.Quick)
+          )
+        } catch {
+          case _: Exception =>
+            // Some states require one more click to switch to the Operators 
tab.
+            Utils.clickWithCursor(page, operatorsMenu)
+            panelSearch.waitFor(
+              new Locator.WaitForOptions()
+                .setState(WaitForSelectorState.VISIBLE)
+                .setTimeout(Timeouts.Quick)
+            )
+        }
+
+        // Locate operator from group
+        val metadata = metadataFor(operatorName, operatorType)
+        val groupPath = metadata
+          .flatMap(m => 
groupPathByName.get(m.additionalMetadata.operatorGroupName))
+          .getOrElse(Seq.empty)
+        val hierarchyOperator = resolveByGroupPath(page, operatorName, 
operatorType)
+
+        val operator = hierarchyOperator
+          .map(dragHandle)
+          .getOrElse {
+            println(s"[Operator] Hierarchy fallback to search for 
'$operatorName'")
+            val searchInput = page
+              .getByTestId("operator-search-input")
+              .or(page.getByPlaceholder("search operator"))
+              .first()
+            Utils.waitVisible(searchInput)
+            Utils.clickWithCursor(page, searchInput)
+            searchInput.fill(sidebarSearchText)
+            page.waitForTimeout(Delays.Settle)
+            dragHandle(resolveOperatorSource(page, sidebarSearchText, 
operatorType))
+          }
+        operator.scrollIntoViewIfNeeded()
+        page.waitForTimeout(Delays.Tick)
+
+        // ── Prepare canvas ──
+        val canvas = page
+          .getByTestId("navigation-workflow-canvas")
+          .or(page.locator("svg[joint-selector='svg'], svg#v-2"))
+          .first()
+        Utils.waitVisible(canvas)
+        canvas.scrollIntoViewIfNeeded()
+        page.waitForTimeout(Delays.Tick)
+
+        val beforeCount = page.locator("g.joint-cell.joint-element").count()
+        val beforeLinkCount = page.locator("g.joint-cell.joint-link").count()
+        val canvasBox = canvas.boundingBox()
+        if (canvasBox == null)
+          throw new RuntimeException("Drag failed: missing canvas bounding 
box")
+
+        // ── Calculate drop position ──
+        val anchorNode: Option[Locator] = 
dragNextTo.flatMap(findNodeByType(page, _))
+
+        if (dragNextTo.isDefined && anchorNode.isEmpty) {
+          println(
+            s"[Operator] Warning: dragNextTo='${dragNextTo.get}' not found on 
canvas, using default position"
+          )
+        }
+
+        val (tgtX, tgtY) = anchorNode
+          .flatMap { anchor =>
+            val box = Utils.cellBox(anchor)
+            if (box != null) {
+              Some(
+                (
+                  math.min(canvasBox.x + canvasBox.width - 30, box.x + 
box.width + dragSpacing),
+                  box.y + box.height / 2.0 - 40.0 // slight lift so the label 
row stays readable
+                )
+              )
+            } else None
+          }
+          .getOrElse {
+            // No anchor: place at the canvasPosition fraction, stepping a 
4-column grid
+            // (~node footprint) past any existing nodes. The min() clamps 
keep the drop
+            // inside the canvas — outside it the drop is silently lost.
+            val index = Math.max(0, beforeCount)
+            val baseX = canvasBox.x + canvasBox.width * canvasPosition._1
+            val baseY = canvasBox.y + canvasBox.height * canvasPosition._2
+            (
+              math.min(canvasBox.x + canvasBox.width - 40, baseX + (index % 4) 
* 180),
+              math.min(canvasBox.y + canvasBox.height - 40, baseY + (index / 
4) * 120)
+            )
+          }
+
+        // ── Perform drag with fallbacks ──
+        performDrag(page, operator, tgtX, tgtY)
+
+        val targetCount = beforeCount + 1
+        if (!waitForNodeCountAtLeast(page, targetCount, maxRetries = 20)) {
+          val searchInput = page
+            .getByTestId("operator-search-input")
+            .or(page.getByPlaceholder("search operator"))
+            .first()
+          Utils.waitVisible(searchInput)
+          Utils.clickWithCursor(page, searchInput)
+          searchInput.fill("")
+          page.waitForTimeout(Delays.Tick)
+          val retryOperator =
+            dragHandle(resolveOperatorSource(page, sidebarSearchText, 
operatorType))
+          performDrag(page, retryOperator, tgtX, tgtY)
+        }
+        if (!waitForNodeCountAtLeast(page, targetCount, maxRetries = 20)) {
+          // Last fallback: insert through search + Enter when drag source is 
flaky.
+          val searchInput = page
+            .getByTestId("operator-search-input")
+            .or(page.getByPlaceholder("search operator"))
+            .first()
+          Utils.waitVisible(searchInput)
+          Utils.clickWithCursor(page, searchInput)
+          searchInput.fill("")
+          page.waitForTimeout(Delays.Tick)
+          searchInput.fill(sidebarSearchText)
+          page.waitForTimeout(Delays.Tick)
+          searchInput.press("Enter")
+        }
+        if (!waitForNodeCountAtLeast(page, targetCount, maxRetries = 20)) {
+          throw new RuntimeException(
+            s"Insert failed for '$operatorName' 
(${operatorType.getOrElse("unknown")}). " +
+              s"Canvas count did not increase from $beforeCount."
+          )
+        }
+
+        val centerBtn = page.getByTitle("minimap-center-button")
+        if (centerBtn.count() > 0) {
+          Utils.clickWithCursor(page, centerBtn)
+          page.waitForTimeout(Delays.Tick)
+        }
+
+        // ── Click the new node to select it ──
+        val newNode = operatorType
+          .flatMap(findNodeByType(page, _))
+          
.getOrElse(Utils.waitVisible(page.locator("g.joint-cell.joint-element").nth(beforeCount)))
+        if (newNode.count() > 0) {
+          val body = newNode.locator("rect.body").first()
+          if (body.count() > 0) Utils.clickWithCursor(page, body)
+          else Utils.clickWithCursor(page, newNode)
+        }
+
+        if (
+          autoConnectToAnchor && dragNextTo.isDefined && 
anchorNode.exists(_.count() > 0) && newNode
+            .count() > 0
+        ) {
+          // Check if the drag itself already created a link (canvas 
port-snapping)
+          val currentLinkCount = 
page.locator("g.joint-cell.joint-link").count()
+          if (currentLinkCount > beforeLinkCount) {
+            println(
+              s"[Operator] Drag already created a link for '$operatorName', 
skipping autoConnect"
+            )
+          } else {
+            val connected = tryAutoConnect(
+              page,
+              anchorNode.get,
+              newNode,
+              fromPortIndex = 0,
+              toPortIndex = 0
+            )
+            if (connected) {
+              var retries = 0
+              while (
+                page
+                  .locator("g.joint-cell.joint-link")
+                  .count() <= beforeLinkCount && retries < Retries.Short
+              ) {
+                page.waitForTimeout(Delays.Tick)
+                retries += 1
+              }
+              if (page.locator("g.joint-cell.joint-link").count() <= 
beforeLinkCount) {
+                println(
+                  s"[Operator] Warning: explicit connect attempted but no new 
link was detected for '$operatorName'"
+                )
+              }
+            } else {
+              println(s"[Operator] Warning: could not locate connectable ports 
for '$operatorName'")
+            }
+          }
+        }
+
+        if (connectAdditionalFrom.isDefined && newNode.count() > 0) {
+          // Clear state from the first connection before attempting the second
+          try page.keyboard().press("Escape")
+          catch { case _: Exception => }
+          page.waitForTimeout(Delays.Settle)
+
+          val additionalFromNode = findNodeByType(page, 
connectAdditionalFrom.get)
+          if (additionalFromNode.isEmpty) {
+            println(
+              s"[Operator] Warning: 
connectAdditionalFrom='${connectAdditionalFrom.get}' not found on canvas"
+            )
+          } else {
+            val expectedLinkCount = 
page.locator("g.joint-cell.joint-link").count() + 1
+            val targetInputPort = connectAdditionalToInputIndex.getOrElse(0)
+
+            val inputPorts = collectInputPortCount(page, newNode)
+            val alreadyConnected =
+              inputPorts > 0 && 
page.locator("g.joint-cell.joint-link").count() >= expectedLinkCount

Review Comment:
   The default two-input path targets input 0 twice: the anchor connection uses 
port 0, and `connectAdditionalToInputIndex = None` also resolves to 0. 
Moreover, `alreadyConnected` compares the current count with that same count 
plus one, so it can never detect the occupied port. A call that omits the 
optional target cannot wire the second input; infer an unconnected input 
(normally port 1 after auto-connect) and determine occupancy from link 
endpoints.



##########
docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/OperatorControllerBuilder.scala:
##########
@@ -0,0 +1,725 @@
+/*
+ * 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.demovideos.controllers
+
+import com.microsoft.playwright._
+import com.microsoft.playwright.options.WaitForSelectorState
+import org.apache.texera.amber.operator.metadata.OperatorMetadataGenerator
+
+// ═══════════════════════════════════════════════════════════════════
+// 3. OperatorControllerBuilder
+//    new OperatorControllerBuilder(ctx)
+//      .insertViaDrag("Bar Chart", dragNextTo = Some("CSVFileScan-operator-"))
+//      .execute()
+// ═══════════════════════════════════════════════════════════════════
+
+class OperatorControllerBuilder(ctx: ControllerContext) extends 
ControllerBuilder(ctx) {
+
+  private lazy val operatorMetadata = 
OperatorMetadataGenerator.allOperatorMetadata.operators
+  private def groupPathByName: Map[String, Seq[String]] = 
OperatorGroups.pathByName
+
+  // Some list items drag from an inner handle element rather than the outer 
container.
+  private def dragHandle(item: Locator): Locator = {
+    val draggable = 
Utils.firstVisible(item.locator("[draggable='true']")).orNull
+    if (draggable != null && draggable.count() > 0) draggable else item
+  }
+
+  def insertViaDrag(
+      operatorName: String,
+      operatorType: Option[String] = None,
+      canvasPosition: (Double, Double) = (0.06, 0.2),
+      dragNextTo: Option[String] = None,
+      autoConnectToAnchor: Boolean = false,
+      connectAdditionalFrom: Option[String] = None,
+      connectAdditionalFromPortIndex: Int = 0,
+      connectAdditionalToInputIndex: Option[Int] = None,
+      // Gap (px) between the anchor's right edge and the drop; ML scripts 
pass a
+      // tighter value because their template has more intermediate nodes.
+      dragSpacing: Double = 180.0
+  ): this.type =
+    addStep(new ControllerStep {
+      override def name =
+        s"Insert '$operatorName' via Drag${dragNextTo.map(n => s" (next to 
$n)").getOrElse("")}"
+      override def run(ctx: ControllerContext): Unit = {
+        val page = ctx.page
+        ctx.ensureFakeCursor()
+        val sidebarSearchText: String = operatorName
+
+        // ── Open operator panel & resolve source ──
+        val operatorsMenu = page
+          .getByTestId("operator-left-panel-operators-button")
+          .or(page.getByText("Operators", new 
Page.GetByTextOptions().setExact(true)))
+          .first()
+        Utils.waitVisible(operatorsMenu)
+        Utils.clickWithCursor(page, operatorsMenu)
+
+        val panelSearch = page
+          .getByTestId("operator-search-input")
+          .or(page.getByPlaceholder("search operator"))
+          .first()
+        try {
+          panelSearch.waitFor(
+            new Locator.WaitForOptions()
+              .setState(WaitForSelectorState.VISIBLE)
+              .setTimeout(Timeouts.Quick)
+          )
+        } catch {
+          case _: Exception =>
+            // Some states require one more click to switch to the Operators 
tab.
+            Utils.clickWithCursor(page, operatorsMenu)
+            panelSearch.waitFor(
+              new Locator.WaitForOptions()
+                .setState(WaitForSelectorState.VISIBLE)
+                .setTimeout(Timeouts.Quick)
+            )
+        }
+
+        // Locate operator from group
+        val metadata = metadataFor(operatorName, operatorType)
+        val groupPath = metadata
+          .flatMap(m => 
groupPathByName.get(m.additionalMetadata.operatorGroupName))
+          .getOrElse(Seq.empty)
+        val hierarchyOperator = resolveByGroupPath(page, operatorName, 
operatorType)
+
+        val operator = hierarchyOperator
+          .map(dragHandle)
+          .getOrElse {
+            println(s"[Operator] Hierarchy fallback to search for 
'$operatorName'")
+            val searchInput = page
+              .getByTestId("operator-search-input")
+              .or(page.getByPlaceholder("search operator"))
+              .first()
+            Utils.waitVisible(searchInput)
+            Utils.clickWithCursor(page, searchInput)
+            searchInput.fill(sidebarSearchText)
+            page.waitForTimeout(Delays.Settle)
+            dragHandle(resolveOperatorSource(page, sidebarSearchText, 
operatorType))
+          }
+        operator.scrollIntoViewIfNeeded()
+        page.waitForTimeout(Delays.Tick)
+
+        // ── Prepare canvas ──
+        val canvas = page
+          .getByTestId("navigation-workflow-canvas")
+          .or(page.locator("svg[joint-selector='svg'], svg#v-2"))
+          .first()
+        Utils.waitVisible(canvas)
+        canvas.scrollIntoViewIfNeeded()
+        page.waitForTimeout(Delays.Tick)
+
+        val beforeCount = page.locator("g.joint-cell.joint-element").count()
+        val beforeLinkCount = page.locator("g.joint-cell.joint-link").count()
+        val canvasBox = canvas.boundingBox()
+        if (canvasBox == null)
+          throw new RuntimeException("Drag failed: missing canvas bounding 
box")
+
+        // ── Calculate drop position ──
+        val anchorNode: Option[Locator] = 
dragNextTo.flatMap(findNodeByType(page, _))
+
+        if (dragNextTo.isDefined && anchorNode.isEmpty) {
+          println(
+            s"[Operator] Warning: dragNextTo='${dragNextTo.get}' not found on 
canvas, using default position"
+          )
+        }

Review Comment:
   When `autoConnectToAnchor` is requested, a missing anchor only emits a 
warning and the step succeeds with an unwired operator. This violates the 
requested operation and can silently produce an invalid demo; fail the step 
when connection was requested while retaining the fallback for positioning-only 
calls.
   
   This issue also appears in the following locations of the same file:
   - line 254
   - line 267
   - line 325



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to