feat: add video tour functionality with helpers, configuration, and homepage walkthrough

This commit is contained in:
2026-02-26 02:42:16 +00:00
parent 20eaa50935
commit e8fd38e98a
8 changed files with 295 additions and 1 deletions

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ test-results/
playwright-report/
playwright/.cache/
visual-review/
video-tours/output/
.yarn/*
!.yarn/cache
!.yarn/patches

View File

@@ -44,6 +44,16 @@ Tibi CMS starter template — Svelte 5 SPA with esbuild, SSR via goja, and Playw
- After code changes, run only affected spec files: `npx playwright test tests/e2e/filename.spec.ts`.
- Write unit tests for new functionality and ensure existing tests pass.
## Video tours
- Video tours are Playwright-based screen recordings (not tests) in `video-tours/`.
- Run all tours (desktop): `yarn tour`
- Run all tours (mobile): `yarn tour:mobile`
- Videos are saved to `video-tours/output/` (git-ignored).
- Tour files use `.tour.ts` suffix in `video-tours/tours/`.
- Helpers: `injectVisibleCursor()`, `moveThenClick()`, `moveThenType()`, `smoothScroll()` in `video-tours/helpers.ts`.
- Fixtures provide a `tourPage` with visible cursor overlay via `video-tours/tours/fixtures.ts`.
## API access
- API access to collections uses the reverse proxy: `CODING_URL/api/<collection>` (e.g. `CODING_URL/api/content`).

View File

@@ -19,7 +19,9 @@
"test:api": "playwright test tests/api",
"test:visual": "playwright test --project=visual-desktop --project=visual-iphonese --project=visual-ipad",
"test:visual:update": "playwright test --project=visual-desktop --project=visual-iphonese --project=visual-ipad --update-snapshots",
"test:report": "playwright show-report"
"test:report": "playwright show-report",
"tour": "playwright test --config=video-tours/playwright.config.ts --project=tour-desktop",
"tour:mobile": "playwright test --config=video-tours/playwright.config.ts --project=tour-mobile"
},
"devDependencies": {
"@babel/cli": "^7.28.6",

141
video-tours/helpers.ts Normal file
View File

@@ -0,0 +1,141 @@
import type { Page } from "@playwright/test"
/**
* Injects a visible CSS cursor overlay that follows mouse movements.
* Features a glowing blue dot with an expanding ripple ring on click.
*
* Must be called BEFORE the first page.goto() (uses addInitScript).
*/
export async function injectVisibleCursor(page: Page, options?: { color?: string; size?: number }): Promise<void> {
const color = options?.color ?? "59, 130, 246" // blue-500
const size = options?.size ?? 24
await page.addInitScript(
({ color, size }) => {
document.addEventListener("DOMContentLoaded", () => {
// Avoid duplicates on soft-navigations
if (document.getElementById("pw-cursor")) return
// ── Cursor dot ──
const cursor = document.createElement("div")
cursor.id = "pw-cursor"
Object.assign(cursor.style, {
width: `${size}px`,
height: `${size}px`,
borderRadius: "50%",
background: `radial-gradient(circle, rgba(${color}, 0.9) 0%, rgba(${color}, 0.4) 60%, transparent 100%)`,
boxShadow: `0 0 ${size / 2}px rgba(${color}, 0.5), 0 0 ${size}px rgba(${color}, 0.2)`,
position: "fixed",
zIndex: "999999",
pointerEvents: "none",
transition: "transform 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94), box-shadow 0.12s ease",
transform: "translate(-50%, -50%)",
top: "-100px",
left: "-100px",
})
document.body.appendChild(cursor)
// ── Click ripple animation ──
const style = document.createElement("style")
style.textContent = `
@keyframes pw-ripple {
0% { transform: translate(-50%, -50%) scale(0.3); opacity: 0.8; }
100% { transform: translate(-50%, -50%) scale(2.5); opacity: 0; }
}
.pw-ripple {
position: fixed;
width: ${size * 2.5}px;
height: ${size * 2.5}px;
border-radius: 50%;
border: 2.5px solid rgba(${color}, 0.7);
pointer-events: none;
z-index: 999998;
animation: pw-ripple 0.5s cubic-bezier(0, 0, 0.2, 1) forwards;
}
`
document.head.appendChild(style)
let cx = -100,
cy = -100
document.addEventListener("mousemove", (e) => {
cx = e.clientX
cy = e.clientY
cursor.style.left = cx + "px"
cursor.style.top = cy + "px"
})
document.addEventListener("mousedown", () => {
// Shrink cursor + intensify glow
cursor.style.transform = "translate(-50%, -50%) scale(0.5)"
cursor.style.boxShadow = `0 0 ${size}px rgba(${color}, 0.8), 0 0 ${size * 1.5}px rgba(${color}, 0.4)`
// Spawn expanding ripple ring
const ripple = document.createElement("div")
ripple.className = "pw-ripple"
ripple.style.left = cx + "px"
ripple.style.top = cy + "px"
document.body.appendChild(ripple)
ripple.addEventListener("animationend", () => ripple.remove())
})
document.addEventListener("mouseup", () => {
cursor.style.transform = "translate(-50%, -50%) scale(1)"
cursor.style.boxShadow = `0 0 ${size / 2}px rgba(${color}, 0.5), 0 0 ${size}px rgba(${color}, 0.2)`
})
})
},
{ color, size }
)
}
/**
* Moves the visible cursor smoothly to a target element before clicking.
* This ensures mousemove events fire so the injected cursor follows along.
*/
export async function moveThenClick(page: Page, locator: ReturnType<Page["locator"]>): Promise<void> {
const box = await locator.boundingBox()
if (box) {
const x = box.x + box.width / 2
const y = box.y + box.height / 2
// Smooth move in steps
await page.mouse.move(x, y, { steps: 15 })
await page.waitForTimeout(150)
}
await locator.click()
}
/**
* Moves the visible cursor to a target element and types text character by character.
* Simulates realistic typing for demo purposes.
*/
export async function moveThenType(
page: Page,
locator: ReturnType<Page["locator"]>,
text: string,
options?: { delay?: number }
): Promise<void> {
const box = await locator.boundingBox()
if (box) {
const x = box.x + box.width / 2
const y = box.y + box.height / 2
await page.mouse.move(x, y, { steps: 15 })
await page.waitForTimeout(150)
}
await locator.click()
await page.waitForTimeout(200)
await page.keyboard.type(text, { delay: options?.delay ?? 80 })
}
/**
* Scrolls the page smoothly to a given position.
*/
export async function smoothScroll(page: Page, y: number, duration = 600): Promise<void> {
await page.evaluate(
({ y, duration }) => {
window.scrollTo({ top: y, behavior: "smooth" })
return new Promise((r) => setTimeout(r, duration))
},
{ y, duration }
)
}

View File

@@ -0,0 +1,46 @@
import { defineConfig, devices } from "@playwright/test"
/**
* Playwright config for video tours (demos, not tests).
*
* Usage:
* yarn tour # run all tours (desktop)
* yarn tour:mobile # run all tours (mobile)
*
* Videos are saved to video-tours/output/
*/
export default defineConfig({
testDir: "./tours",
testMatch: "**/*.tour.ts",
outputDir: "./output",
fullyParallel: false,
retries: 0,
workers: 1,
reporter: "list",
/* No global setup/teardown tours are self-contained */
use: {
baseURL: process.env.CODING_URL || "https://localhost:3000",
headless: true,
ignoreHTTPSErrors: true,
video: { mode: "on", size: { width: 1280, height: 720 } },
navigationTimeout: 30000,
launchOptions: {
args: ["--no-sandbox"],
},
},
projects: [
{
name: "tour-desktop",
use: { ...devices["Desktop Chrome"] },
},
{
name: "tour-mobile",
use: {
...devices["iPhone 12"],
defaultBrowserType: "chromium",
},
},
],
})

View File

@@ -0,0 +1,16 @@
/**
* Constants for video tour configuration.
*
* Adapt these values to your project. The tour user is designed
* to persist across runs so that on repeat runs the tour simply
* logs in instead of re-registering.
*/
// ── Tour User (adjust for your project) ──────────────────────────────
export const TOUR_USER = {
email: "tour-demo@test.example.com",
password: "TourDemo2025!",
firstName: "Max",
lastName: "Mustermann",
organization: "Mustermann GmbH",
} as const

View File

@@ -0,0 +1,29 @@
import { test as base, type Page } from "@playwright/test"
import { injectVisibleCursor } from "../helpers"
/**
* Extended test fixtures for video tours.
*
* - `tourPage`: A page with visible cursor overlay, ready for recording.
*
* Add project-specific setup (cookie consent, modals, etc.) here.
*/
type TourFixtures = {
tourPage: Page
}
export const tour = base.extend<TourFixtures>({
tourPage: async ({ page }, use) => {
// Inject visible cursor (must be before first goto)
await injectVisibleCursor(page)
// Override navigation to always use domcontentloaded (BrowserSync keeps WS open)
const origGoto = page.goto.bind(page)
page.goto = ((url: string, opts?: any) =>
origGoto(url, { waitUntil: "domcontentloaded", ...opts })) as typeof page.goto
await use(page)
},
})
export { expect } from "@playwright/test"

View File

@@ -0,0 +1,49 @@
import { tour } from "./fixtures"
import { moveThenClick, smoothScroll } from "../helpers"
/**
* Video tour: Homepage walkthrough
*
* Demonstrates the main page navigation:
* 1. Homepage overview with scroll
* 2. Navigation links
*
* Adapt the steps below when the UI changes.
* Run: yarn tour
*/
tour("Homepage Walkthrough", async ({ tourPage: page }) => {
tour.setTimeout(60000)
// ── 1. Homepage ──────────────────────────────────────────────────
await page.goto("/")
await page.waitForTimeout(2500)
// Scroll down to see page content
await smoothScroll(page, 400)
await page.waitForTimeout(2000)
// Scroll back up
await smoothScroll(page, 0)
await page.waitForTimeout(1000)
// ── 2. Navigate via links ────────────────────────────────────────
const navLinks = page.locator("nav a")
const count = await navLinks.count()
for (let i = 0; i < Math.min(count, 3); i++) {
const link = navLinks.nth(i)
if (await link.isVisible({ timeout: 2000 }).catch(() => false)) {
await moveThenClick(page, link)
await page.waitForTimeout(2000)
// Scroll a bit on each page
await smoothScroll(page, 300)
await page.waitForTimeout(1500)
await smoothScroll(page, 0)
await page.waitForTimeout(500)
}
}
// Final pause
await page.waitForTimeout(1500)
})