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]
