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 f6d6f0bea9b791eb9c63416ec80cff124e5fc14d Mon Sep 17 00:00:00 2001 From: Xuan Gu <162244362+xuang7@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:39:23 -0700 Subject: [PATCH 3/3] fix(operator-demo-video): run the cursor init script on navigation Co-Authored-By: Claude Fable 5 --- .../texera/demovideos/controllers/Utils.scala | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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..07e709136b8 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 @@ -36,9 +36,12 @@ object Utils { // 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. + // An IIFE (addInitScript executes raw source, so a bare function expression would + // never run), deferring DOM setup until the document exists. val script = """ - () => { + (() => { + const setup = () => { if (document.getElementById('pw-cursor-style')) return; const style = document.createElement('style'); style.id = 'pw-cursor-style'; @@ -95,7 +98,14 @@ object Utils { 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); - } + }; + // Init scripts run before the document exists; the DOM work must wait. + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', setup); + } else { + setup(); + } + })() """ // Persistent across navigations.