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/3] 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/3] 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/3] 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;