✨ feat: add video tour functionality with helpers, configuration, and homepage walkthrough
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
visual-review/
|
||||
video-tours/output/
|
||||
.yarn/*
|
||||
!.yarn/cache
|
||||
!.yarn/patches
|
||||
|
||||
10
AGENTS.md
10
AGENTS.md
@@ -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`).
|
||||
|
||||
@@ -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
141
video-tours/helpers.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
46
video-tours/playwright.config.ts
Normal file
46
video-tours/playwright.config.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
16
video-tours/tour-constants.ts
Normal file
16
video-tours/tour-constants.ts
Normal 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
|
||||
29
video-tours/tours/fixtures.ts
Normal file
29
video-tours/tours/fixtures.ts
Normal 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"
|
||||
49
video-tours/tours/homepage.tour.ts
Normal file
49
video-tours/tours/homepage.tour.ts
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user