✨ 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-report/
|
||||||
playwright/.cache/
|
playwright/.cache/
|
||||||
visual-review/
|
visual-review/
|
||||||
|
video-tours/output/
|
||||||
.yarn/*
|
.yarn/*
|
||||||
!.yarn/cache
|
!.yarn/cache
|
||||||
!.yarn/patches
|
!.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`.
|
- 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.
|
- 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
|
||||||
|
|
||||||
- API access to collections uses the reverse proxy: `CODING_URL/api/<collection>` (e.g. `CODING_URL/api/content`).
|
- 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:api": "playwright test tests/api",
|
||||||
"test:visual": "playwright test --project=visual-desktop --project=visual-iphonese --project=visual-ipad",
|
"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: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": {
|
"devDependencies": {
|
||||||
"@babel/cli": "^7.28.6",
|
"@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