Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/sync-docs-to-site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 5 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@
Test / forkOptions := (Test / forkOptions).value
.withWorkingDirectory((ThisBuild / baseDirectory).value),
Test / testGrouping := (Test / definedTests).value.map { suite =>
Tests.Group(suite.name, Seq(suite), Tests.SubProcess((Test / forkOptions).value))

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / build / amber (ubuntu-latest, 17)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / build / amber-integration (ubuntu-latest, 17)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / build / amber-integration (macos-latest, 17)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (file-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform-integration (config-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform-integration (workflow-compiling-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform-integration (access-control-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform-integration (file-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform-integration (computing-unit-managing-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (workflow-compiling-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (config-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (access-control-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (computing-unit-managing-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform-integration (notebook-migration-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / build / platform (notebook-migration-service)

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / Bench

The evaluation of `/` inside an anonymous function is prohibited.

Check warning on line 231 in build.sbt

View workflow job for this annotation

GitHub Actions / Bench

The evaluation of `/` inside an anonymous function is prohibited.
}
)

Expand Down Expand Up @@ -279,6 +279,9 @@
)
.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(
Expand All @@ -299,7 +302,8 @@
FileService,
WorkflowCompilingService,
WorkflowExecutionService,
NotebookMigrationService
NotebookMigrationService,
DemoVideoGenerator
)
.settings(
name := "texera",
Expand Down
52 changes: 52 additions & 0 deletions docs/operator-demo-videos/build.sbt
Original file line number Diff line number Diff line change
@@ -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.20" % Test
)
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
* 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.
// 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';
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);
};
// 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.
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))
}
}
Loading
Loading