From b2cf837f27b40e06a5cfc6b52a4086b72d02d99f Mon Sep 17 00:00:00 2001 From: Xuan Gu <162244362+xuang7@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:27:14 -0700 Subject: [PATCH 1/4] feat(operator-demo-video): add DemoVideoGenerator module and Playwright automation foundation Co-Authored-By: Claude Fable 5 --- .github/workflows/sync-docs-to-site.yml | 7 + build.sbt | 6 +- docs/operator-demo-videos/build.sbt | 52 +++++++ .../demovideos/config/TestDataConfig.scala | 49 +++++++ .../controllers/ControllerContext.scala | 74 ++++++++++ .../texera/demovideos/controllers/Utils.scala | 130 ++++++++++++++++++ .../demovideos/scripts/OperatorScript.scala | 35 +++++ 7 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 docs/operator-demo-videos/build.sbt create mode 100644 docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/config/TestDataConfig.scala create mode 100644 docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/ControllerContext.scala create mode 100644 docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/Utils.scala create mode 100644 docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/scripts/OperatorScript.scala diff --git a/.github/workflows/sync-docs-to-site.yml b/.github/workflows/sync-docs-to-site.yml index d0de7aee761..9801efcfa76 100644 --- a/.github/workflows/sync-docs-to-site.yml +++ b/.github/workflows/sync-docs-to-site.yml @@ -26,6 +26,8 @@ on: - main paths: - 'docs/**' + # Build tooling, not website content. + - '!docs/operator-demo-videos/**' workflow_dispatch: # Run one sync at a time. @@ -96,11 +98,16 @@ jobs: source_rels = set() created = updated = deleted = 0 + # Build tooling, not website content; never mirrored. + excluded_dirs = ("operator-demo-videos",) + # Mirror every file: .md keeps the target front matter, others copied as-is. for sfile in sorted(source.rglob("*")): if sfile.is_dir(): continue rel = sfile.relative_to(source) + if rel.parts and rel.parts[0] in excluded_dirs: + continue source_rels.add(rel) tfile = target / rel existed = tfile.exists() diff --git a/build.sbt b/build.sbt index 3779413e041..e6f9d69d559 100644 --- a/build.sbt +++ b/build.sbt @@ -279,6 +279,9 @@ lazy val NotebookMigrationService = (project in file("notebook-migration-service ) .dependsOn(DAO % "test->test") // test scope dependency +// Playwright automation recording the per-operator demo videos linked from docs/reference/operators/. +lazy val DemoVideoGenerator = (project in file("docs/operator-demo-videos")) + // root project definition lazy val TexeraProject = (project in file(".")) .aggregate( @@ -299,7 +302,8 @@ lazy val TexeraProject = (project in file(".")) FileService, WorkflowCompilingService, WorkflowExecutionService, - NotebookMigrationService + NotebookMigrationService, + DemoVideoGenerator ) .settings( name := "texera", diff --git a/docs/operator-demo-videos/build.sbt b/docs/operator-demo-videos/build.sbt new file mode 100644 index 00000000000..0da66f0bb93 --- /dev/null +++ b/docs/operator-demo-videos/build.sbt @@ -0,0 +1,52 @@ +// 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. + +import scala.collection.Seq + +///////////////////////////////////////////////////////////////////////////// +// Project Settings +///////////////////////////////////////////////////////////////////////////// + +name := "operator-demo-videos" + +// Enable semanticdb for Scalafix +ThisBuild / semanticdbEnabled := true +ThisBuild / semanticdbVersion := scalafixSemanticdb.revision + +// Restrict parallel execution of tests to avoid conflicts +Global / concurrentRestrictions += Tags.limit(Tags.Test, 1) + +///////////////////////////////////////////////////////////////////////////// +// Compiler Options +///////////////////////////////////////////////////////////////////////////// + +Compile / scalacOptions ++= Seq( + "-Xelide-below", "WARNING", // Turn on optimizations with "WARNING" as the threshold + "-feature", // Check feature warnings + "-deprecation", // Check deprecation warnings + "-Ywarn-unused:imports" // Check for unused imports +) + +///////////////////////////////////////////////////////////////////////////// +// Dependencies +///////////////////////////////////////////////////////////////////////////// + +libraryDependencies ++= Seq( + // Drives a real browser to record the operator demos. + "com.microsoft.playwright" % "playwright" % "1.57.0", + "org.scalatest" %% "scalatest" % "3.2.15" % Test +) diff --git a/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/config/TestDataConfig.scala b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/config/TestDataConfig.scala new file mode 100644 index 00000000000..721d2554a8e --- /dev/null +++ b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/config/TestDataConfig.scala @@ -0,0 +1,49 @@ +/* + * 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.config + +case class UiConfig( + recordWidth: Int, + recordHeight: Int, + slowMo: Int, + resultPanelHoldMs: Int, + propertyPanelResizeHeight: Double, + operatorPosX: Double, + operatorPosY: Double +) + +object TestDataConfig { + val baseUrl = "http://localhost:4200" + + val uiConfig = UiConfig( + // 1080p: the canvas, the left operator panel, and the property panel all have to be + // usable at once. Below this the operator list needs scrolling before an item can be + // dragged, and the recording is cramped to watch. + recordWidth = 1920, + recordHeight = 1080, + slowMo = 400, + resultPanelHoldMs = 5000, + propertyPanelResizeHeight = 300.0, + operatorPosX = 0.33, + operatorPosY = 0.4 + ) + + val videoOutputDir = "docs/operator-demo-videos/generated" +} diff --git a/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/ControllerContext.scala b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/ControllerContext.scala new file mode 100644 index 00000000000..a35f2485b14 --- /dev/null +++ b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/ControllerContext.scala @@ -0,0 +1,74 @@ +/* + * 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.Page +import scala.collection.mutable.ArrayBuffer + +/** + * Shared context wrapping Playwright Page, passed through all builders. + */ +class ControllerContext(val page: Page) { + private var _fakeCursorInstalled: Boolean = false + + def ensureFakeCursor(): Unit = { + if (!_fakeCursorInstalled) { + Utils.installFakeCursor(page) + _fakeCursorInstalled = true + } + } +} + +/** A single named step that runs against a ControllerContext. */ +trait ControllerStep { + def name: String + def run(ctx: ControllerContext): Unit +} + +object ControllerStep { + def apply(stepName: String)(action: ControllerContext => Unit): ControllerStep = + new ControllerStep { + override def name: String = stepName + override def run(ctx: ControllerContext): Unit = action(ctx) + } +} + +/** + * Base builder — subclasses accumulate steps via fluent API, then execute(). + * + * Usage: + * new LoginControllerBuilder(ctx).login("u","p").logout().execute() + */ +abstract class ControllerBuilder(protected val context: ControllerContext) { + private val steps: ArrayBuffer[ControllerStep] = ArrayBuffer.empty + + protected def addStep(step: ControllerStep): this.type = { + steps += step + this + } + + def execute(): Unit = { + steps.foreach { step => + println(s"[${step.name}] Executing...") + step.run(context) + println(s"[${step.name}] Done") + } + } +} diff --git a/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/Utils.scala b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/Utils.scala new file mode 100644 index 00000000000..ee6325fc333 --- /dev/null +++ b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/Utils.scala @@ -0,0 +1,130 @@ +/* + * 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.options.WaitForSelectorState +import com.microsoft.playwright.{Locator, Mouse, Page} + +// ═══════════════════════════════════════════════════════════════════ +// Utils +// ═══════════════════════════════════════════════════════════════════ + +object Utils { + def waitVisible(loc: Locator): Locator = { + loc.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE)) + loc + } + + def installFakeCursor(page: Page): Unit = { + // Both the styles and the cursor element are wiped on every page navigation + // (e.g., `page.navigate(".../dashboard")` in createNewWorkflow). `addInitScript` + // re-runs after every load so the cursor follows the user across pages. + val script = + """ + () => { + if (document.getElementById('pw-cursor-style')) return; + const style = document.createElement('style'); + style.id = 'pw-cursor-style'; + style.textContent = ` + #pw-cursor { + position: fixed; left: 0; top: 0; + width: 14px; height: 14px; border-radius: 50%; + background: rgba(255, 0, 0, 0.9); + box-shadow: 0 0 0 3px rgba(255, 0, 0, 0.25); + pointer-events: none; z-index: 2147483647; + transform: translate(-50%, -50%); + } + .pw-click { + position: fixed; left: 0; top: 0; + width: 18px; height: 18px; border-radius: 50%; + border: 3px solid rgba(255, 0, 0, 0.85); + pointer-events: none; z-index: 2147483647; + transform: translate(-50%, -50%); + animation: pw-click-pop 600ms ease-out forwards; + } + @keyframes pw-click-pop { + 0% { opacity: 0.9; transform: translate(-50%, -50%) scale(0.6); } + 70% { opacity: 0.6; transform: translate(-50%, -50%) scale(2.2); } + 100% { opacity: 0.0; transform: translate(-50%, -50%) scale(2.8); } + } + `; + (document.head || document.documentElement).appendChild(style); + + const ensureCursor = () => { + if (document.getElementById('pw-cursor')) return document.getElementById('pw-cursor'); + const cursor = document.createElement('div'); + cursor.id = 'pw-cursor'; + (document.body || document.documentElement).appendChild(cursor); + return cursor; + }; + + const move = (x, y) => { + const c = ensureCursor(); + c.style.left = x + 'px'; + c.style.top = y + 'px'; + }; + + document.addEventListener('mousemove', (e) => move(e.clientX, e.clientY), true); + document.addEventListener('pointermove', (e) => move(e.clientX, e.clientY), true); + + const clickRing = (x, y) => { + const ring = document.createElement('div'); + ring.className = 'pw-click'; + ring.style.left = x + 'px'; + ring.style.top = y + 'px'; + (document.body || document.documentElement).appendChild(ring); + setTimeout(() => ring.remove(), 650); + }; + + document.addEventListener('mousedown', (e) => { move(e.clientX, e.clientY); clickRing(e.clientX, e.clientY); }, true); + document.addEventListener('pointerdown', (e) => { move(e.clientX, e.clientY); clickRing(e.clientX, e.clientY); }, true); + } + """ + + // Persistent across navigations. + page.addInitScript(script) + // Run once now so the cursor is visible immediately on the current page + // (addInitScript only fires on subsequent loads, not retroactively). + page.evaluate(script) + } + + // `holdMs` is the gap between mousedown and mouseup. The default 0 fires both in the same + // tick, which some Angular handlers (notably the Run/Pause toolbar button) can miss, so + // callers that need a click to reliably register pass a short hold. + def clickWithCursor(page: Page, loc: Locator, steps: Int = 20, holdMs: Int = 0): Unit = { + waitVisible(loc) + val box = loc.boundingBox() + if (box == null) throw new RuntimeException("No bounding box") + val x = box.x + box.width / 2.0 + val y = box.y + box.height / 2.0 + page.mouse().move(x, y, new Mouse.MoveOptions().setSteps(steps)) + if (holdMs > 0) page.mouse().click(x, y, new Mouse.ClickOptions().setDelay(holdMs.toDouble)) + else page.mouse().click(x, y) + } + + def hoverWithCursor(page: Page, loc: Locator, steps: Int = 20): Unit = { + waitVisible(loc) + val box = loc.boundingBox() + if (box == null) throw new RuntimeException("No bounding box") + val x = box.x + box.width / 2.0 + val y = box.y + box.height / 2.0 + page.mouse().move(x, y, new Mouse.MoveOptions().setSteps(steps)) + } +} diff --git a/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/scripts/OperatorScript.scala b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/scripts/OperatorScript.scala new file mode 100644 index 00000000000..a0097218855 --- /dev/null +++ b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/scripts/OperatorScript.scala @@ -0,0 +1,35 @@ +/* + * 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.scripts + +import org.apache.texera.demovideos.controllers.ControllerContext + +/** `prepare` (create + import, unrecorded) runs before recording starts; `execute` is the + * recorded demo. Login is the runner's job — once per run, session shared across scenarios. + */ +trait OperatorScript { + def operatorName: String + def operatorType: String = operatorName.replaceAll("\\s+", "") + def category: String + def outputFileName: String + + def prepare(ctx: ControllerContext): Unit = () + def execute(ctx: ControllerContext): Unit +} From d35c1207ef321f9003b36ee8c93f306da7526ca4 Mon Sep 17 00:00:00 2001 From: Xuan Gu <162244362+xuang7@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:34:22 -0700 Subject: [PATCH 2/4] chore(operator-demo-video): align scalatest with the version other modules use Co-Authored-By: Claude Fable 5 --- docs/operator-demo-videos/build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/operator-demo-videos/build.sbt b/docs/operator-demo-videos/build.sbt index 0da66f0bb93..d9a15153b68 100644 --- a/docs/operator-demo-videos/build.sbt +++ b/docs/operator-demo-videos/build.sbt @@ -48,5 +48,5 @@ Compile / scalacOptions ++= Seq( libraryDependencies ++= Seq( // Drives a real browser to record the operator demos. "com.microsoft.playwright" % "playwright" % "1.57.0", - "org.scalatest" %% "scalatest" % "3.2.15" % Test + "org.scalatest" %% "scalatest" % "3.2.20" % Test ) From 3cd0ab3e1424602fda12b235ee71398c38a3b5a6 Mon Sep 17 00:00:00 2001 From: Xuan Gu <162244362+xuang7@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:10:40 -0700 Subject: [PATCH 3/4] feat(operator-demo-video): add login and workflow-navigation controllers Co-Authored-By: Claude Fable 5 --- .../demovideos/controllers/Constants.scala | 35 ++++ .../controllers/ControllerContext.scala | 2 +- .../controllers/LoginControllerBuilder.scala | 149 +++++++++++++++++ .../NavigationControllerBuilder.scala | 153 ++++++++++++++++++ .../user-workflow.component.html | 2 + .../user-workflow.component.spec.ts | 7 + .../login/texera-login.component.html | 3 + .../login/texera-login.component.spec.ts | 10 ++ .../workflow-editor.component.html | 1 + .../workflow-editor.component.spec.ts | 6 + 10 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/Constants.scala create mode 100644 docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/LoginControllerBuilder.scala create mode 100644 docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/NavigationControllerBuilder.scala diff --git a/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/Constants.scala b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/Constants.scala new file mode 100644 index 00000000000..7c9ced5eac4 --- /dev/null +++ b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/Constants.scala @@ -0,0 +1,35 @@ +/* + * 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 + +// Timing constants shared by the controllers; tuned against the real UI. +// Later controllers extend these objects as they need more tiers. + +/** Semantic wait tiers for `page.waitForTimeout(...)`, named by what is being waited on. */ +private[controllers] object Delays { + val Long: Int = 300 // major UI transition: panel collapse, page settling + val Network: Int = 700 // backend round trip: upload, login submission +} + +/** Upper bounds for `Locator.waitFor(...)` and similar. */ +private[controllers] object Timeouts { + val Medium: Int = 5000 // navigation, or a dialog that has to open + val Long: Int = 20000 // page load with heavy assets +} diff --git a/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/ControllerContext.scala b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/ControllerContext.scala index a35f2485b14..abf35d85824 100644 --- a/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/ControllerContext.scala +++ b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/ControllerContext.scala @@ -54,7 +54,7 @@ object ControllerStep { * Base builder — subclasses accumulate steps via fluent API, then execute(). * * Usage: - * new LoginControllerBuilder(ctx).login("u","p").logout().execute() + * new LoginControllerBuilder(ctx).login("u","p").execute() */ abstract class ControllerBuilder(protected val context: ControllerContext) { private val steps: ArrayBuffer[ControllerStep] = ArrayBuffer.empty diff --git a/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/LoginControllerBuilder.scala b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/LoginControllerBuilder.scala new file mode 100644 index 00000000000..e48bb604d8d --- /dev/null +++ b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/LoginControllerBuilder.scala @@ -0,0 +1,149 @@ +/* + * 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.{LoadState, WaitForSelectorState} +import org.apache.texera.demovideos.config.TestDataConfig + +// ═══════════════════════════════════════════════════════════════════ +// 1. LoginControllerBuilder +// new LoginControllerBuilder(ctx).login("user","pass").execute() +// ═══════════════════════════════════════════════════════════════════ + +class LoginControllerBuilder(ctx: ControllerContext) extends ControllerBuilder(ctx) { + + def login(username: String, password: String): this.type = + addStep(new ControllerStep { + override def name = "Login" + override def run(ctx: ControllerContext): Unit = { + val page = ctx.page + // The login form lives at /login; an already-signed-in visitor is + // redirected away by the component itself. + page.navigate(s"${TestDataConfig.baseUrl}/login") + try { + page.waitForLoadState( + LoadState.NETWORKIDLE, + new Page.WaitForLoadStateOptions().setTimeout(Timeouts.Medium) + ) + } catch { + case _: Exception => + } + page.waitForTimeout(Delays.Long) + + // "access_token" is the frontend JWT key (auth.service.ts TOKEN_KEY). + val loggedIn = + try page.evaluate("() => !!window.localStorage.getItem('access_token')") == true + catch { case _: Exception => false } + if (loggedIn) { + println("[Login] Already authenticated, skipping login form") + return + } + ctx.ensureFakeCursor() + + try { + page + .getByTestId("login-submit") + .first() + .waitFor( + new Locator.WaitForOptions() + .setState(WaitForSelectorState.VISIBLE) + .setTimeout(Timeouts.Long) + ) + } catch { + case _: Exception => + val shotPath = + java.nio.file.Paths.get(TestDataConfig.videoOutputDir, "debug_login_failed.png") + try { + java.nio.file.Files.createDirectories(shotPath.getParent) + page.screenshot(new Page.ScreenshotOptions().setPath(shotPath).setFullPage(true)) + println(s"[Login] Screenshot saved: $shotPath") + } catch { case _: Exception => } + throw new RuntimeException(s"Login page not visible. URL: ${page.url()}") + } + + val usernameField = page + .getByTestId("login-username") + .or(page.getByPlaceholder("Username")) + .first() + usernameField.waitFor( + // Wait for text box visible + new Locator.WaitForOptions() + .setState(WaitForSelectorState.VISIBLE) + .setTimeout(Timeouts.Medium) + ) + + Utils.clickWithCursor(page, usernameField) + usernameField.fill(username) + + // Verify input (username, password) is filled + // if not, reenter + val usernameVal = + try usernameField.inputValue() + catch { case _: Exception => "" } + if (usernameVal != username) { + usernameField.click(new Locator.ClickOptions().setForce(true)) + usernameField.fill("") + usernameField.fill(username) + } + + val passwordField = page + .getByTestId("login-password") + .or(page.getByPlaceholder("Password")) + .first() + passwordField.waitFor( + new Locator.WaitForOptions() + .setState(WaitForSelectorState.VISIBLE) + .setTimeout(Timeouts.Medium) + ) + Utils.clickWithCursor(page, passwordField) + passwordField.fill(password) + val passwordVal = + try passwordField.inputValue() + catch { case _: Exception => "" } + if (passwordVal != password) { + passwordField.click() + passwordField.fill(password) + } + + val signInBtn = page + .getByTestId("login-submit") + .or(page.locator("button[type='submit']:has-text('Sign in')")) + .first() + Utils.clickWithCursor(page, signInBtn) + page.waitForLoadState(LoadState.NETWORKIDLE) + page.waitForTimeout(Delays.Network) + + // If still on login page, try pressing Enter once. + if (page.getByTestId("login-submit").count() > 0) { + passwordField.press("Enter") + page.waitForLoadState(LoadState.NETWORKIDLE) + page.waitForTimeout(Delays.Network) + } + + // If still not logged in, surface the error (if any) for debugging. + if (page.getByTestId("login-submit").count() > 0) { + val err = page.locator("p.error").first() + val msg = if (err.count() > 0) err.innerText() else "Login still visible" + throw new RuntimeException(s"Login failed: $msg") + } + } + }) +} diff --git a/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/NavigationControllerBuilder.scala b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/NavigationControllerBuilder.scala new file mode 100644 index 00000000000..e6346908be8 --- /dev/null +++ b/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() + try entry.scrollIntoViewIfNeeded() + catch { case _: Exception => } + Utils.clickWithCursor(page, entry) + + waitForCanvas(page) + page.waitForTimeout(Delays.Network) + } + }) +} diff --git a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.html b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.html index 492efc53766..d950ce6c8f4 100644 --- a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.html +++ b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.html @@ -24,6 +24,7 @@

Workflows

diff --git a/frontend/src/app/hub/component/login/texera-login.component.spec.ts b/frontend/src/app/hub/component/login/texera-login.component.spec.ts index c17b595c700..6f88a3f631a 100644 --- a/frontend/src/app/hub/component/login/texera-login.component.spec.ts +++ b/frontend/src/app/hub/component/login/texera-login.component.spec.ts @@ -92,6 +92,16 @@ describe("TexeraLoginComponent", () => { expect(component).toBeTruthy(); }); + // Guard for the demo-video automation: it locates these controls by data-testid, + // so removing or renaming one must fail here rather than silently break recording. + it("keeps the automation data-testid hooks on the login form", () => { + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('[data-testid="login-username"]')).toBeTruthy(); + expect(el.querySelector('[data-testid="login-password"]')).toBeTruthy(); + expect(el.querySelector('[data-testid="login-submit"]')).toBeTruthy(); + }); + describe("ngOnInit", () => { it("prefills username/password from defaultLocalUser when populated", () => { const config = TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService; diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html index ed0b7cc748b..94f6e258b78 100644 --- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html +++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.html @@ -20,6 +20,7 @@
{ expect(component).toBeTruthy(); }); + // Guard for the demo-video automation (see texera-login.component.spec.ts). + it("keeps the automation data-testid hook on the canvas", () => { + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('[data-testid="navigation-workflow-canvas"]')).toBeTruthy(); + }); + it("should hide operator status on the canvas by default", () => { // keeps the Status toggle off until the user enables it const editor = (component as any).editor as HTMLElement; From d8042327f354e3b8809e3ae7b054f99009682434 Mon Sep 17 00:00:00 2001 From: Xuan Gu <162244362+xuang7@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:10:37 -0700 Subject: [PATCH 4/4] feat(operator-demo-video): add the operator drag-and-connect controller Co-Authored-By: Claude Fable 5 --- build.sbt | 4 + docs/operator-demo-videos/build.sbt | 10 + .../demovideos/controllers/Constants.scala | 9 + .../OperatorControllerBuilder.scala | 725 ++++++++++++++++++ .../controllers/OperatorGroups.scala | 39 + .../texera/demovideos/controllers/Utils.scala | 74 +- .../left-panel/left-panel.component.html | 2 + .../left-panel/left-panel.component.spec.ts | 6 + .../operator-menu.component.html | 4 +- .../operator-menu.component.spec.ts | 11 + .../property-editor.component.html | 1 + .../property-editor.component.spec.ts | 6 + 12 files changed, 889 insertions(+), 2 deletions(-) create mode 100644 docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/OperatorControllerBuilder.scala create mode 100644 docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/OperatorGroups.scala diff --git a/build.sbt b/build.sbt index e6f9d69d559..e425a582e74 100644 --- a/build.sbt +++ b/build.sbt @@ -281,6 +281,10 @@ lazy val NotebookMigrationService = (project in file("notebook-migration-service // Playwright automation recording the per-operator demo videos linked from docs/reference/operators/. lazy val DemoVideoGenerator = (project in file("docs/operator-demo-videos")) + .dependsOn( + // Reads each operator's metadata (groups, ports) to locate and wire it on the canvas. + WorkflowExecutionService + ) // root project definition lazy val TexeraProject = (project in file(".")) diff --git a/docs/operator-demo-videos/build.sbt b/docs/operator-demo-videos/build.sbt index d9a15153b68..a3ce079fbb1 100644 --- a/docs/operator-demo-videos/build.sbt +++ b/docs/operator-demo-videos/build.sbt @@ -50,3 +50,13 @@ libraryDependencies ++= Seq( "com.microsoft.playwright" % "playwright" % "1.57.0", "org.scalatest" %% "scalatest" % "3.2.20" % Test ) + +// Playwright's transitive jackson (2.21.x) breaks jackson-module-scala 2.18.x from the +// operator-metadata modules; keep jackson on the repo-wide version (root build.sbt). +val jacksonVersion = "2.18.8" + +dependencyOverrides ++= Seq( + "com.fasterxml.jackson.core" % "jackson-databind" % jacksonVersion, + "com.fasterxml.jackson.core" % "jackson-core" % jacksonVersion, + "com.fasterxml.jackson.core" % "jackson-annotations" % jacksonVersion +) diff --git a/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/Constants.scala b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/Constants.scala index 7c9ced5eac4..e9fc0695f43 100644 --- a/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/Constants.scala +++ b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/Constants.scala @@ -24,12 +24,21 @@ package org.apache.texera.demovideos.controllers /** Semantic wait tiers for `page.waitForTimeout(...)`, named by what is being waited on. */ private[controllers] object Delays { + val Tick: Int = 80 // DOM micro-updates between user-like actions + val Settle: Int = 150 // dropdown / collapse / toggle animation settling val Long: Int = 300 // major UI transition: panel collapse, page settling val Network: Int = 700 // backend round trip: upload, login submission } /** Upper bounds for `Locator.waitFor(...)` and similar. */ private[controllers] object Timeouts { + val Quick: Int = 2000 // element expected to already be present val Medium: Int = 5000 // navigation, or a dialog that has to open val Long: Int = 20000 // page load with heavy assets } + +/** Iteration counts for predicate polling loops. */ +private[controllers] object Retries { + val Short: Int = 8 + val Medium: Int = 16 +} diff --git a/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/OperatorControllerBuilder.scala b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/OperatorControllerBuilder.scala new file mode 100644 index 00000000000..c3db0b7f633 --- /dev/null +++ b/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 + if (alreadyConnected) { + println( + s"[Operator] Additional input port likely already connected for '$operatorName', skipping" + ) + } else { + try additionalFromNode.get.scrollIntoViewIfNeeded() + catch { case _: Exception => } + try newNode.scrollIntoViewIfNeeded() + catch { case _: Exception => } + page.waitForTimeout(Delays.Tick) + + val beforeExtraLinkCount = page.locator("g.joint-cell.joint-link").count() + var connected = tryAutoConnect( + page, + additionalFromNode.get, + newNode, + fromPortIndex = connectAdditionalFromPortIndex, + toPortIndex = targetInputPort + ) + if (!connected) { + page.waitForTimeout(Delays.Network) + try page.keyboard().press("Escape") + catch { case _: Exception => } + page.waitForTimeout(Delays.Settle) + connected = tryAutoConnect( + page, + additionalFromNode.get, + newNode, + fromPortIndex = connectAdditionalFromPortIndex, + toPortIndex = targetInputPort + ) + } + if (connected) { + var retries = 0 + while ( + page + .locator("g.joint-cell.joint-link") + .count() <= beforeExtraLinkCount && retries < Retries.Short + ) { + page.waitForTimeout(Delays.Tick) + retries += 1 + } + if (page.locator("g.joint-cell.joint-link").count() <= beforeExtraLinkCount) { + println( + s"[Operator] Warning: additional connect attempted but no new link was detected for '$operatorName'" + ) + } + } else { + println( + s"[Operator] Warning: could not connect additional input for '$operatorName'" + ) + } + } + } + } + + // ── Reposition (default placement only): with no anchor but nodes already on + // the canvas, nudge the drop next to the last node. + if (dragNextTo.isEmpty) { + val referenceNode = anchorNode + .filter(_.count() > 0) + .orElse( + if (beforeCount > 0) + Some(page.locator("g.joint-cell.joint-element").nth(beforeCount - 1)) + else None + ) + .orNull + + if (referenceNode != null && referenceNode.count() > 0) { + val refBox = Utils.cellBox(referenceNode) + val newBox = Utils.cellBox(newNode) + if (refBox != null && newBox != null) { + val targetCenterX = refBox.x + refBox.width + 220.0 + newBox.width / 2.0 + val targetCenterY = refBox.y + refBox.height / 2.0 + val currentCenterX = newBox.x + newBox.width / 2.0 + val currentCenterY = newBox.y + newBox.height / 2.0 + Utils.nudgeCell( + page, + newNode, + targetCenterX - currentCenterX, + targetCenterY - currentCenterY + ) + page.waitForTimeout(Delays.Tick) + } + Utils.ensureSeparated(page, referenceNode, newNode) + } + } + } + }) + + private def findNodeByType(page: Page, operatorTypeOrName: String): Option[Locator] = { + val normalized = Utils.normalize(operatorTypeOrName) + val relaxed = normalized.replace("operator", "") + + // Prefer startsWith over contains to avoid "csvfilescan" matching "csvoldfilescan" + def matches(candidate: String): Boolean = { + val c = Utils.normalize(candidate) + c.nonEmpty && ( + c.startsWith(normalized) || + c.contains(normalized + "-") || + c.contains(normalized) || + (relaxed.nonEmpty && c.startsWith(relaxed)) + ) + } + + val cells = page.locator("g.joint-cell.joint-element") + val count = cells.count() + var i = 0 + while (i < count) { + val cell = cells.nth(i) + val testId = + try Option(cell.getAttribute("data-testid")).getOrElse("") + catch { case _: Exception => "" } + if (matches(testId)) return Some(cell) + + val modelId = + try Option(cell.getAttribute("model-id")).getOrElse("") + catch { case _: Exception => "" } + if (matches(modelId)) return Some(cell) + + val label = cell.locator("text.operator-name, .texera-operator-label, text").first() + val labelText = + try { + if (label.count() > 0) Option(label.innerText()).getOrElse("") else "" + } catch { case _: Exception => "" } + if (matches(labelText)) { + return Some(cell) + } + i += 1 + } + None + } + + private def collectInputPortCount(page: Page, node: Locator): Int = { + val selector = Seq( + "[port-group='input'][port]", + "[port-group='in'][port]", + "[port*='input']", + "circle[port-group='input']" + ).mkString(", ") + node.locator(selector).count() + } + + private def waitForNodeCountAtLeast(page: Page, targetCount: Int, maxRetries: Int): Boolean = { + var retries = 0 + while ( + page.locator("g.joint-cell.joint-element").count() < targetCount && retries < maxRetries + ) { + page.waitForTimeout(Delays.Tick) + retries += 1 + } + page.locator("g.joint-cell.joint-element").count() >= targetCount + } + + private def performDrag(page: Page, source: Locator, targetX: Double, targetY: Double): Unit = { + val src = dragHandle(source) + val srcBox = src.boundingBox() + if (srcBox == null) throw new RuntimeException("Drag failed: missing source bounding box") + val srcX = srcBox.x + srcBox.width / 2.0 + val srcY = srcBox.y + srcBox.height / 2.0 + page.mouse().move(srcX, srcY, new Mouse.MoveOptions().setSteps(25)) + page.mouse().down() + page.mouse().move(targetX, targetY, new Mouse.MoveOptions().setSteps(35)) + page.mouse().up() + } + + private def tryAutoConnect( + page: Page, + fromNode: Locator, + toNode: Locator, + fromPortIndex: Int = 0, + toPortIndex: Int = 0 + ): Boolean = { + def collectPortCenters(node: Locator, io: String): Seq[(Option[Int], Double, Double)] = { + val selectors = if (io == "output") Seq("output") else Seq("input", "in") + val selector = selectors.map(p => s"[port*='$p']").mkString(", ") + + val ports = node.locator(selector) + val total = ports.count() + var i = 0 + val seenIndices = scala.collection.mutable.Set.empty[Int] + val buf = scala.collection.mutable.ArrayBuffer.empty[(Option[Int], Double, Double)] + while (i < total) { + val p = ports.nth(i) + try { + val visible = p.isVisible() + val rawAttr = Option(p.getAttribute("port")) + .orElse(Option(p.getAttribute("data-port"))) + .getOrElse("") + if (visible) { + val attr = rawAttr + val idx = (".*?(?:input|output|in|out)-([0-9]+).*").r + .findFirstMatchIn(attr) + .map(_.group(1).toInt) + if (!idx.exists(seenIndices.contains)) { + // Drill down to circle.port-body for precise bounding box; + // the wrapper includes the label text and gives an offset center. + val portBody = p.locator("circle.port-body, circle").first() + val target = + if ( + portBody.count() > 0 && (try portBody.isVisible() + catch { case _: Exception => false }) + ) + portBody + else p + val b = target.boundingBox() + if (b != null) { + idx.foreach(seenIndices.add) + buf += ((idx, b.x + b.width / 2.0, b.y + b.height / 2.0)) + } + } + } + } catch { + case _: Exception => + } + i += 1 + } + buf.sortBy { case (idx, _, y) => (idx.getOrElse(Int.MaxValue), y) }.toSeq + } + + def pickCenter( + ports: Seq[(Option[Int], Double, Double)], + index: Int + ): Option[(Double, Double)] = { + ports + .find(_._1.contains(index)) + .orElse(ports.lift(index)) + .map { case (_, x, y) => (x, y) } + } + + // Brief pause to let the DOM settle (important when called after another connection) + page.waitForTimeout(Delays.Tick) + + val fromPort = pickCenter(collectPortCenters(fromNode, "output"), fromPortIndex) + val toPort = pickCenter(collectPortCenters(toNode, "input"), toPortIndex) + + val beforeLinks = page.locator("g.joint-cell.joint-link").count() + + if (fromPort.isEmpty || toPort.isEmpty) { + println( + s"[tryAutoConnect] Port not found: fromPort=$fromPort toPort=$toPort (fromIdx=$fromPortIndex toIdx=$toPortIndex)" + ) + + // If a specific non-zero port index was requested, do NOT fall back to a + // center-to-center drag — that would route the link to whichever port is + // visible (typically port 0), silently aliasing two connections onto the + // same port. Common case: HashJoin's port-1 has `dependencies=List(port-0)` + // and is hidden until port-0 has schema. Returning false lets the caller's + // retry loop wait and re-collect ports. + if (fromPortIndex > 0 || toPortIndex > 0) return false + + val fromBox = Utils.cellBox(fromNode) + val toBox = Utils.cellBox(toNode) + if (fromBox == null || toBox == null) return false + val startX = fromBox.x + fromBox.width - 2 + val startY = fromBox.y + fromBox.height / 2.0 + val endX = toBox.x + 2 + val endY = toBox.y + toBox.height / 2.0 + page.mouse().move(startX, startY, new Mouse.MoveOptions().setSteps(12)) + page.mouse().down() + page.mouse().move(endX, endY, new Mouse.MoveOptions().setSteps(20)) + page.mouse().up() + page.waitForTimeout(Delays.Settle) + return page.locator("g.joint-cell.joint-link").count() > beforeLinks + } + + page.mouse().move(fromPort.get._1, fromPort.get._2, new Mouse.MoveOptions().setSteps(12)) + page.mouse().down() + page.waitForTimeout(Delays.Tick) + page.mouse().move(toPort.get._1, toPort.get._2, new Mouse.MoveOptions().setSteps(25)) + page.mouse().up() + page.waitForTimeout(Delays.Settle) + page.locator("g.joint-cell.joint-link").count() > beforeLinks + } + + private def metadataFor(operatorName: String, operatorType: Option[String]) = { + val normalizedName = Utils.normalize(operatorName) + operatorType + .filter(_.nonEmpty) + .flatMap(t => operatorMetadata.find(_.operatorType == t)) + .orElse( + operatorMetadata.find(m => + Utils.normalize(m.additionalMetadata.userFriendlyName) == normalizedName + ) + ) + } + + private def resolveOperatorSource( + page: Page, + operatorName: String, + operatorType: Option[String] + ): Locator = { + val byType = + operatorType.filter(_.nonEmpty).map(t => page.getByTestId(s"operator-item-$t").first()) + byType.foreach { loc => + if (loc.count() > 0) return dragHandle(loc) + } + + val leftPanel = page.locator("#left-container") + val exactLabel = Utils.firstVisible( + leftPanel.getByText(operatorName, new Locator.GetByTextOptions().setExact(true)) + ) + exactLabel.foreach { label => + val row = label + .locator( + "xpath=ancestor-or-self::*[@data-testid and starts-with(@data-testid,'operator-item-')][1]" + ) + .first() + if (row.count() > 0) return dragHandle(row) + return dragHandle(label) + } + + resolveByGroupPath(page, operatorName, operatorType).foreach(item => return dragHandle(item)) + + val resultItems = page.locator("#left-container [data-testid^='operator-item-']") + Utils.firstVisible(resultItems).foreach(item => return dragHandle(item)) + + val fuzzy = + Utils.firstVisible(leftPanel.locator(s".operator-label:has-text('$operatorName')")) + fuzzy.map(dragHandle).getOrElse { + throw new RuntimeException( + s"Cannot find operator source for '$operatorName' (${operatorType.getOrElse("unknown")})" + ) + } + } + + private def resolveByGroupPath( + page: Page, + operatorName: String, + operatorType: Option[String] + ): Option[Locator] = { + val metadata = metadataFor(operatorName, operatorType) + val path = metadata + .flatMap(m => groupPathByName.get(m.additionalMetadata.operatorGroupName)) + .getOrElse(Seq.empty) + if (path.isEmpty) return None + + val leftPanel = page.locator("#left-container") + var scope: Locator = leftPanel + + path.zipWithIndex.foreach { + case (group, depth) => + val header = + findHeaderInScope(scope, group).orElse(findHeaderByDepth(leftPanel, group, depth)).orNull + if (header == null || header.count() == 0) return None + + val panel = + header.locator("xpath=ancestor::*[contains(@class,'ant-collapse-item')][1]").first() + if (panel.count() > 0) { + val panelClass = Option(panel.getAttribute("class")).getOrElse("") + if (!panelClass.contains("ant-collapse-item-active")) { + clickGroupHeader(page, header) + page.waitForTimeout(Delays.Tick) + val afterClass = Option(panel.getAttribute("class")).getOrElse("") + if (!afterClass.contains("ant-collapse-item-active")) { + clickGroupHeader(page, header) + page.waitForTimeout(Delays.Tick) + } + } + scope = panel + } else { + // Fallback for non-collapse style groups. + clickGroupHeader(page, header) + page.waitForTimeout(Delays.Tick) + scope = leftPanel + } + } + + operatorType.filter(_.nonEmpty).foreach { t => + val candidate = scope.getByTestId(s"operator-item-$t").first() + if (candidate.count() > 0) { + try candidate.scrollIntoViewIfNeeded() + catch { case _: Exception => } + page.waitForTimeout(Delays.Tick) + return Some(candidate) + } + } + + val exact = scope.getByText(operatorName, new Locator.GetByTextOptions().setExact(true)).first() + if (exact.count() == 0) return None + val row = exact + .locator( + "xpath=ancestor-or-self::*[@data-testid and starts-with(@data-testid,'operator-item-')][1]" + ) + .first() + val resolved = if (row.count() > 0) row else exact + try resolved.scrollIntoViewIfNeeded() + catch { case _: Exception => } + page.waitForTimeout(Delays.Tick) + Some(resolved) + } + + // One evaluateAll instead of per-header visibility/text reads: each read is a + // driver round-trip, and a 10-header list adds visible idle time to the recording. + private def scanHeaders(headers: Locator): Seq[(Boolean, String)] = { + import scala.jdk.CollectionConverters._ + val raw = headers.evaluateAll("els => els.map(e => [e.offsetParent !== null, e.innerText])") + raw + .asInstanceOf[java.util.List[java.util.List[Object]]] + .asScala + .toSeq + .map { pair => + val visible = pair.get(0).asInstanceOf[Boolean] + val label = Option(pair.get(1)).map(_.toString).getOrElse("") + (visible, label.replaceAll("\\s+", " ").trim) + } + } + + private def findHeaderInScope(scope: Locator, groupName: String): Option[Locator] = { + val headers = scope.locator(".ant-collapse-header") + val infos = scanHeaders(headers) + val target = Utils.normalize(groupName) + + def pick(matches: String => Boolean): Option[Locator] = + infos.zipWithIndex.collectFirst { + case ((true, label), i) if matches(Utils.normalize(label)) => headers.nth(i) + } + + // Exact first — avoids matching a parent header whose name is a substring + // of the target (e.g. "Sklearn" when looking for "Sklearn Training"). + pick(_ == target).orElse(pick(n => n.contains(target) || target.contains(n))) + } + + private def findHeaderByDepth(root: Locator, groupName: String, depth: Int): Option[Locator] = { + val headers = root.locator(s".operator-group[data-depth='$depth'] .ant-collapse-header") + val infos = scanHeaders(headers) + val target = Utils.normalize(groupName) + infos.zipWithIndex.collectFirst { + case ((true, label), i) if { + val n = Utils.normalize(label) + n == target || n.contains(target) || target.contains(n) + } => + headers.nth(i) + } + } + + private def clickGroupHeader(page: Page, header: Locator): Unit = { + try header.scrollIntoViewIfNeeded() + catch { case _: Exception => } + val arrow = header.locator(".ant-collapse-arrow, i.anticon-right, i.anticon-down").first() + if (arrow.count() > 0) { + try Utils.clickWithCursor(page, arrow, steps = 10) + catch { case _: Exception => Utils.clickWithCursor(page, header, steps = 10) } + } else { + Utils.clickWithCursor(page, header, steps = 10) + } + } +} diff --git a/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/OperatorGroups.scala b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/OperatorGroups.scala new file mode 100644 index 00000000000..aee4162f425 --- /dev/null +++ b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/OperatorGroups.scala @@ -0,0 +1,39 @@ +/* + * 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 org.apache.texera.amber.operator.metadata.{GroupInfo, OperatorGroupConstants} + +/** Flattens the operator group tree into group-name -> full path + * (e.g. "Basic" -> Seq("Visualization", "Basic")). + */ +private[demovideos] object OperatorGroups { + lazy val pathByName: Map[String, Seq[String]] = + walk(OperatorGroupConstants.OperatorGroupOrderList, Seq.empty) + + private def walk(items: List[GroupInfo], prefix: Seq[String]): Map[String, Seq[String]] = { + items.flatMap { g => + val path = prefix :+ g.groupName + val current = Map(g.groupName -> path) + val children = Option(g.children).getOrElse(List.empty) + current ++ walk(children, path) + }.toMap + } +} diff --git a/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/Utils.scala b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/Utils.scala index ee6325fc333..f21a93488ef 100644 --- a/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/Utils.scala +++ b/docs/operator-demo-videos/src/main/scala/org/apache/texera/demovideos/controllers/Utils.scala @@ -19,7 +19,7 @@ package org.apache.texera.demovideos.controllers -import com.microsoft.playwright.options.WaitForSelectorState +import com.microsoft.playwright.options.{BoundingBox, WaitForSelectorState} import com.microsoft.playwright.{Locator, Mouse, Page} // ═══════════════════════════════════════════════════════════════════ @@ -32,6 +32,26 @@ object Utils { loc } + // First visible match of a locator, or None; skips detached/hidden nodes. + def firstVisible(locator: Locator): Option[Locator] = { + val count = locator.count() + var i = 0 + while (i < count) { + val nth = locator.nth(i) + try { + if (nth.isVisible()) return Some(nth) + } catch { + case _: Exception => + } + i += 1 + } + None + } + + // Lowercase-alphanumeric key for loose name matching ("Bar Chart" == "barchart"). + def normalize(s: String): String = + s.toLowerCase.replaceAll("[^a-z0-9]", "") + def installFakeCursor(page: Page): Unit = { // Both the styles and the cursor element are wiped on every page navigation // (e.g., `page.navigate(".../dashboard")` in createNewWorkflow). `addInitScript` @@ -127,4 +147,56 @@ object Utils { val y = box.y + box.height / 2.0 page.mouse().move(x, y, new Mouse.MoveOptions().setSteps(steps)) } + + // ── Shared geometry helpers ── + + def cellBox(cell: Locator): BoundingBox = { + val body = cell.locator("rect.body").first() + if (body.count() > 0) body.boundingBox() else cell.boundingBox() + } + + def nudgeCell(page: Page, cell: Locator, dx: Double, dy: Double): Unit = { + val body = cell.locator("rect.body").first() + val box = if (body.count() > 0) body.boundingBox() else cell.boundingBox() + if (box == null) return + val startX = box.x + box.width / 2.0 + val startY = box.y + box.height / 2.0 + page.mouse().move(startX, startY, new Mouse.MoveOptions().setSteps(20)) + page.mouse().down() + page.mouse().move(startX + dx, startY + dy, new Mouse.MoveOptions().setSteps(40)) + page.mouse().up() + } + + def overlaps(a: BoundingBox, b: BoundingBox): Boolean = { + val ax2 = a.x + a.width; val ay2 = a.y + a.height + val bx2 = b.x + b.width; val by2 = b.y + b.height + ax2 > b.x && bx2 > a.x && ay2 > b.y && by2 > a.y + } + + def centerDx(a: BoundingBox, b: BoundingBox): Double = { + val ax = a.x + a.width / 2.0 + val bx = b.x + b.width / 2.0 + math.abs(ax - bx) + } + + def ensureSeparated( + page: Page, + source: Locator, + target: Locator, + minSpacing: Double = 140.0 + ): Unit = { + val srcBox = cellBox(source) + val tgtBox = cellBox(target) + if (srcBox == null || tgtBox == null) return + + if (overlaps(srcBox, tgtBox) || centerDx(srcBox, tgtBox) < minSpacing) { + nudgeCell(page, target, dx = 260, dy = 0) + page.waitForTimeout(200) + val t1 = cellBox(target) + if (t1 != null && (overlaps(srcBox, t1) || centerDx(srcBox, t1) < minSpacing)) { + nudgeCell(page, target, dx = 360, dy = 120) + page.waitForTimeout(200) + } + } + } } diff --git a/frontend/src/app/workspace/component/left-panel/left-panel.component.html b/frontend/src/app/workspace/component/left-panel/left-panel.component.html index 5ea092352b4..8c64e38026c 100644 --- a/frontend/src/app/workspace/component/left-panel/left-panel.component.html +++ b/frontend/src/app/workspace/component/left-panel/left-panel.component.html @@ -40,6 +40,7 @@ cdkDrag nzTooltipPlacement="right" nz-tooltip="{{items[i].title}}" + [attr.data-testid]="items[i].title === 'Operators' ? 'operator-left-panel-operators-button' : null" nzSelected="{{currentComponent === items[i].component}}" *ngIf="items[i].enabled && !width" (click)="openFrame(i)"> @@ -86,6 +87,7 @@ cdkDrag nzTooltipPlacement="right" nz-tooltip="{{items[i].title}}" + [attr.data-testid]="items[i].title === 'Operators' ? 'operator-left-panel-operators-button' : null" nzSelected="{{currentComponent === items[i].component}}" *ngIf="items[i].enabled" (click)="openFrame(i)"> diff --git a/frontend/src/app/workspace/component/left-panel/left-panel.component.spec.ts b/frontend/src/app/workspace/component/left-panel/left-panel.component.spec.ts index 57e65c8c843..0a623f10d36 100644 --- a/frontend/src/app/workspace/component/left-panel/left-panel.component.spec.ts +++ b/frontend/src/app/workspace/component/left-panel/left-panel.component.spec.ts @@ -82,6 +82,12 @@ describe("LeftPanelComponent", () => { expect(component).toBeTruthy(); }); + // Guard for the demo-video automation (see texera-login.component.spec.ts). + it("keeps the automation data-testid hook on the Operators button", () => { + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('[data-testid="operator-left-panel-operators-button"]')).toBeTruthy(); + }); + it("should switch to versions frame component when get all versions is clicked", fakeAsync(() => { const jointGraphWrapper = workflowActionService.getJointGraphWrapper(); diff --git a/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.html b/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.html index ff8bb97a296..7e0833fb130 100644 --- a/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.html +++ b/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.html @@ -20,6 +20,7 @@ diff --git a/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.spec.ts b/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.spec.ts index a9e133827f6..86ed2b3cbb6 100644 --- a/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.spec.ts +++ b/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.spec.ts @@ -78,6 +78,17 @@ describe("OperatorPanelComponent", () => { expect(component).toBeTruthy(); }); + // Guard for the demo-video automation (see texera-login.component.spec.ts). + it("keeps the automation data-testid hooks in the operator menu", () => { + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('[data-testid="operator-search-input"]')).toBeTruthy(); + const header = el.querySelector(".ant-collapse-header") as HTMLElement; + expect(header).toBeTruthy(); + header.click(); + fixture.detectChanges(); + expect(el.querySelector('[data-testid^="operator-item-"]')).toBeTruthy(); + }); + it("should search an operator by its user friendly name", () => { component.searchInputValue = "Source: Scan"; component.onInput({ target: { value: "Source: Scan" } } as unknown as Event); diff --git a/frontend/src/app/workspace/component/property-editor/property-editor.component.html b/frontend/src/app/workspace/component/property-editor/property-editor.component.html index 7ca4a328a87..dabcf27f765 100644 --- a/frontend/src/app/workspace/component/property-editor/property-editor.component.html +++ b/frontend/src/app/workspace/component/property-editor/property-editor.component.html @@ -86,6 +86,7 @@ #contentWrapper>

Property diff --git a/frontend/src/app/workspace/component/property-editor/property-editor.component.spec.ts b/frontend/src/app/workspace/component/property-editor/property-editor.component.spec.ts index e0713c37b11..ae6b2cde4f7 100644 --- a/frontend/src/app/workspace/component/property-editor/property-editor.component.spec.ts +++ b/frontend/src/app/workspace/component/property-editor/property-editor.component.spec.ts @@ -80,6 +80,12 @@ describe("PropertyEditorComponent", () => { expect(component).toBeTruthy(); }); + // Guard for the demo-video automation (see texera-login.component.spec.ts). + it("keeps the automation data-testid hook on the property panel title", () => { + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('[data-testid="property-panel-title"]')).toBeTruthy(); + }); + /** * test if the property editor correctly receives the operator unhighlight stream * and clears all the operator data, and hide the form.