diff --git a/README.md b/README.md index 1245241..079c9e0 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ This repository is intentionally documentation-only. The production backend, fro - Website: [https://qlockify.ir](https://qlockify.ir) - Demo: use the demo entry point on the landing page to create an isolated temporary workspace. +- Product tour video: [assets/videos/qlockify-demo-flow.webm](assets/videos/qlockify-demo-flow.webm) ## What Qlockify Does @@ -77,19 +78,6 @@ The screenshots below were captured from an isolated local demo workspace with s More screenshots are available in [`assets/screenshots`](assets/screenshots/). -## Regenerating Screenshots - -Screenshots are generated locally from a running Qlockify frontend/backend pair: - -```powershell -cd scripts -npm install -cd .. -node scripts/capture-screenshots.js -``` - -The reusable script and its environment variables are documented in [`scripts/README.md`](scripts/README.md). - ## Tech Stack - Backend: Django, Django REST Framework, Celery diff --git a/assets/videos/qlockify-demo-flow.webm b/assets/videos/qlockify-demo-flow.webm new file mode 100644 index 0000000..566b756 Binary files /dev/null and b/assets/videos/qlockify-demo-flow.webm differ diff --git a/scripts/README.md b/scripts/README.md index 52e3475..608f032 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,10 +5,7 @@ This directory contains local-only automation for regenerating public showcase a Currently implemented: - `capture-screenshots.js`: captures the reusable screenshot set into `assets/screenshots/`. - -Planned later: - -- a video/demo recorder script can be added here with the same environment conventions. +- `capture-demo-video.js`: records a reusable product-tour video into `assets/videos/`. ## Screenshot Prerequisites @@ -37,6 +34,12 @@ or point the script to your installed Chrome: $env:QLOCKIFY_CHROME_PATH="C:\Program Files\Google\Chrome\Application\chrome.exe" ``` +Video recording also requires Playwright's bundled ffmpeg: + +```powershell +npx playwright install ffmpeg +``` + ## Capture Public And App Screenshots From the showcase repo root: @@ -87,3 +90,40 @@ $env:QLOCKIFY_BACKEND_REPO="D:\Programing\Scripts\Qlockify\qlockify-backend" - `QLOCKIFY_ADMIN_USER_ID`: optional user id for the admin user-detail screenshot; defaults to the demo owner when available. - `QLOCKIFY_CHROME_PATH` or `CHROME_EXECUTABLE`: optional installed Chrome path. - `QLOCKIFY_CLEAN_SCREENSHOTS`: set `0` to keep existing PNG files before capture. + +## Capture Demo Video + +The video recorder creates a deterministic local fixture before recording: + +- one dedicated superuser +- three workspaces +- one populated workspace with clients, projects, tags, members, rates, time entries, and report data + +It resets only the dedicated video fixture user/workspaces. + +From the showcase repo root: + +```powershell +node scripts/capture-demo-video.js +``` + +Or from the `scripts` directory: + +```powershell +npm run video +``` + +The output is: + +```text +assets/videos/qlockify-demo-flow.webm +``` + +Video-specific variables: + +- `QLOCKIFY_VIDEO_OUTPUT`: optional WebM output path. +- `QLOCKIFY_VIDEO_MOBILE`: fixture login mobile, default `09900000001`. +- `QLOCKIFY_VIDEO_PASSWORD`: fixture login password, default `QlockifyVideo!2026`. +- `QLOCKIFY_VIDEO_CURSOR`: set `0` to disable the visible cursor overlay. + +The export section of the video needs the local Celery worker/report export pipeline to be running. If export notifications do not arrive, the recorder fails with a timeout instead of producing a misleading video. diff --git a/scripts/capture-demo-video.js b/scripts/capture-demo-video.js new file mode 100644 index 0000000..9ac86a5 --- /dev/null +++ b/scripts/capture-demo-video.js @@ -0,0 +1,1363 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const ROOT_DIR = path.resolve(__dirname, ".."); +const VIDEO_DIR = path.join(ROOT_DIR, "assets", "videos"); +const FRONTEND_URL = stripTrailingSlash(process.env.QLOCKIFY_FRONTEND_URL || "http://localhost:5173"); +const BACKEND_URL = stripTrailingSlash(process.env.QLOCKIFY_BACKEND_URL || "http://localhost:8000"); +const BACKEND_REPO = path.resolve( + ROOT_DIR, + process.env.QLOCKIFY_BACKEND_REPO || path.join("..", "qlockify-backend"), +); +const CHROME_EXECUTABLE = process.env.QLOCKIFY_CHROME_PATH || process.env.CHROME_EXECUTABLE || ""; +const VIDEO_OUTPUT = path.resolve( + ROOT_DIR, + process.env.QLOCKIFY_VIDEO_OUTPUT || path.join("assets", "videos", "qlockify-demo-flow.webm"), +); +const VIDEO_MOBILE = process.env.QLOCKIFY_VIDEO_MOBILE || "09900000001"; +const VIDEO_PASSWORD = process.env.QLOCKIFY_VIDEO_PASSWORD || "QlockifyVideo!2026"; +const DOWNLOAD_DIR = path.join(os.tmpdir(), "qlockify-showcase-video-downloads"); +const SHOW_CURSOR = process.env.QLOCKIFY_VIDEO_CURSOR !== "0"; + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function printHelp() { + console.log(` +Usage: + node scripts/capture-demo-video.js + +Required services: + - frontend: ${FRONTEND_URL} + - backend: ${BACKEND_URL} + - PostgreSQL/Redis/Celery using your normal local setup + +Output: + ${VIDEO_OUTPUT} + +Useful environment variables: + QLOCKIFY_FRONTEND_URL Frontend URL. Default: http://localhost:5173 + QLOCKIFY_BACKEND_URL Backend URL. Default: http://localhost:8000 + QLOCKIFY_BACKEND_REPO Backend repo path. Default: ../qlockify-backend + QLOCKIFY_CHROME_PATH Optional installed Chrome executable + QLOCKIFY_VIDEO_OUTPUT Optional output WebM path + QLOCKIFY_VIDEO_MOBILE Fixture user mobile. Default: 09900000001 + QLOCKIFY_VIDEO_PASSWORD Fixture user password + QLOCKIFY_VIDEO_CURSOR=0 Disable the visible cursor overlay + +The script resets only the dedicated video fixture user/workspaces. +`); +} + +if (process.argv.includes("--help") || process.argv.includes("-h")) { + printHelp(); + process.exit(0); +} + +function stripTrailingSlash(value) { + return value.replace(/\/+$/, ""); +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function pythonExecutable() { + const windowsVenv = path.join(BACKEND_REPO, ".venv", "Scripts", "python.exe"); + const unixVenv = path.join(BACKEND_REPO, ".venv", "bin", "python"); + if (fs.existsSync(windowsVenv)) return windowsVenv; + if (fs.existsSync(unixVenv)) return unixVenv; + return "python"; +} + +function runDjangoShell(code) { + if (!fs.existsSync(path.join(BACKEND_REPO, "manage.py"))) { + throw new Error(`Backend repo was not found at ${BACKEND_REPO}`); + } + const output = execFileSync(pythonExecutable(), ["manage.py", "shell", "-c", code], { + cwd: BACKEND_REPO, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return output.trim().split(/\r?\n/).filter(Boolean).at(-1); +} + +async function assertReachable(url, label) { + try { + const response = await fetch(url); + if (!response.ok && response.status >= 500) { + throw new Error(`${label} returned ${response.status}`); + } + } catch (error) { + throw new Error(`${label} is not reachable at ${url}. Start the service first. ${error.message}`); + } +} + +function seedVideoFixture() { + const mobile = JSON.stringify(VIDEO_MOBILE); + const password = JSON.stringify(VIDEO_PASSWORD); + const code = ` +import json +from datetime import timedelta +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.db import transaction +from django.utils import timezone + +from apps.clients.models import Client +from apps.notifications.services import RedisNotificationStore +from apps.projects.models import Project, ProjectAccess, ProjectUserRate +from apps.tags.models import Tag +from apps.time_entries.models import TimeEntry +from apps.workspaces.models import HourlyRateHistory, PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate + +User = get_user_model() +MOBILE = ${mobile} +PASSWORD = ${password} + +def ensure_user(mobile, first_name, last_name, role_user=False): + user, _ = User.all_objects.update_or_create( + mobile=mobile, + defaults={ + "first_name": first_name, + "last_name": last_name, + "email": f"{mobile}@video.qlockify.local", + "is_active": True, + "is_verified": True, + "is_staff": role_user, + "is_superuser": role_user, + "is_deleted": False, + "deleted_at": None, + }, + ) + user.set_password(PASSWORD if role_user else "MemberPass!2026") + user.save() + return user + +def add_rate(workspace, user, amount, effective_from): + rate, _ = WorkspaceUserRate.objects.update_or_create( + workspace=workspace, + user=user, + defaults={"hourly_rate": Decimal(amount), "currency": "IRT", "effective_from": effective_from, "is_active": True}, + ) + HourlyRateHistory.objects.create( + workspace=workspace, + user=user, + scope=HourlyRateHistory.Scope.WORKSPACE, + hourly_rate=rate.hourly_rate, + currency=rate.currency, + effective_from=effective_from, + is_active=True, + ) + return rate + +def add_project_rate(project, user, amount, effective_from): + rate = ProjectUserRate.objects.create( + project=project, + user=user, + hourly_rate=Decimal(amount), + currency="IRT", + effective_from=effective_from, + is_active=True, + ) + HourlyRateHistory.objects.create( + workspace=project.workspace, + project=project, + user=user, + scope=HourlyRateHistory.Scope.PROJECT, + hourly_rate=rate.hourly_rate, + currency=rate.currency, + effective_from=effective_from, + is_active=True, + ) + return rate + +def add_entry(workspace, user, project, tags, days_ago, hour, minutes, description, billable=True): + start = timezone.now().replace(hour=hour, minute=0, second=0, microsecond=0) - timedelta(days=days_ago) + end = start + timedelta(minutes=minutes) + rate = None + if billable: + rate = ( + ProjectUserRate.objects.filter(project=project, user=user, is_deleted=False, is_active=True).order_by("-effective_from").first() + if project else None + ) or WorkspaceUserRate.objects.filter(workspace=workspace, user=user, is_deleted=False).order_by("-effective_from").first() + entry = TimeEntry.objects.create( + workspace=workspace, + user=user, + project=project, + description=description, + start_time=start, + end_time=end, + duration=end - start, + is_billable=billable, + hourly_rate=rate.hourly_rate if rate else None, + currency="IRT", + is_active=True, + ) + entry.tags.set(tags) + return entry + +with transaction.atomic(): + owner = User.all_objects.filter(mobile=MOBILE).first() + if owner: + for workspace in Workspace.all_objects.filter(owner=owner): + workspace.hard_delete() + RedisNotificationStore.clear_user(str(owner.id)) + + for mobile in ["09900000002", "09900000003", "09900000004"]: + user = User.all_objects.filter(mobile=mobile).first() + if user: + for workspace in Workspace.all_objects.filter(owner=user): + workspace.hard_delete() + user.hard_delete() + + owner = ensure_user(MOBILE, "Qlockify", "Video", True) + admin = ensure_user("09900000002", "نیلوفر", "ادمین") + member = ensure_user("09900000003", "آرمان", "عضو") + guest = ensure_user("09900000004", "سارا", "مهمان") + + PriceUnit.get_or_restore( + code="IRT", + defaults={"name": "Iranian Toman", "local_name": "تومان", "symbol": "تومان", "is_active": True}, + ) + + secondary_one = Workspace.objects.create(name="تیم محصول کلاکیفای", description="Workspace used to show account context.", owner=owner, is_active=True) + secondary_two = Workspace.objects.create(name="آژانس زمان‌یار", description="Secondary workspace for the video account.", owner=owner, is_active=True) + primary = Workspace.objects.create(name="استودیو کوئرا", description="Workspace prepared for the product tour video.", owner=owner, is_active=True) + + for workspace in [secondary_one, secondary_two, primary]: + WorkspaceMembership.objects.update_or_create( + workspace=workspace, + user=owner, + defaults={"role": WorkspaceMembership.Role.OWNER, "is_active": True, "is_deleted": False, "deleted_at": None}, + ) + + WorkspaceMembership.objects.bulk_create([ + WorkspaceMembership(workspace=primary, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True), + WorkspaceMembership(workspace=primary, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True), + WorkspaceMembership(workspace=primary, user=guest, role=WorkspaceMembership.Role.GUEST, is_active=True), + ]) + + now = timezone.now() + for user, amount in [(owner, "850000"), (admin, "700000"), (member, "560000"), (guest, "380000")]: + add_rate(primary, user, amount, now - timedelta(days=75)) + + client_a = Client.objects.create(workspace=primary, name="کوئرا کالج", notes="مشتری آموزشی برای گزارش‌های ویدئو", is_active=True) + client_b = Client.objects.create(workspace=primary, name="استودیو نووا", notes="مشتری طراحی و محصول", is_active=True) + client_c = Client.objects.create(workspace=primary, name="عملیات داخلی", notes="کارهای داخلی تیم", is_active=True) + + project_portal = Project.objects.create(workspace=primary, client=client_a, name="پورتال دانشجویان", description="طراحی و توسعه پورتال آموزشی", color="#0ea5e9", is_active=True) + project_marketing = Project.objects.create(workspace=primary, client=client_b, name="وب‌سایت تبلیغاتی", description="صفحه فرود و کمپین معرفی", color="#f97316", is_active=True) + project_ops = Project.objects.create(workspace=primary, client=client_c, name="اتوماسیون داخلی", description="ابزارهای داخلی تیم", color="#6366f1", is_active=True) + project_archive = Project.objects.create(workspace=primary, client=client_b, name="کمپین قابل آرشیو", description="پروژه‌ای برای نمایش آرشیو و حذف", color="#94a3b8", is_active=True) + Project.objects.create(workspace=primary, client=client_a, name="آرشیو قدیمی", description="نمونه آرشیوی از قبل", color="#64748b", is_archived=True, is_active=True) + + Project.objects.filter(pk=project_archive.pk).update(created_at=timezone.now() + timedelta(seconds=1)) + + tags = { + "design": Tag.objects.create(workspace=primary, name="طراحی", color="#f97316", is_active=True), + "backend": Tag.objects.create(workspace=primary, name="بک‌اند", color="#0ea5e9", is_active=True), + "meeting": Tag.objects.create(workspace=primary, name="جلسه", color="#8b5cf6", is_active=True), + "qa": Tag.objects.create(workspace=primary, name="کنترل کیفیت", color="#22c55e", is_active=True), + "research": Tag.objects.create(workspace=primary, name="تحقیق", color="#eab308", is_active=True), + } + + for user in [member, guest]: + for project in [project_portal, project_marketing, project_ops, project_archive]: + ProjectAccess.objects.create(project=project, user=user, is_active=True) + + add_project_rate(project_marketing, owner, "1050000", now - timedelta(days=30)) + add_project_rate(project_portal, member, "620000", now - timedelta(days=25)) + + entries = [ + (owner, project_marketing, [tags["design"], tags["research"]], 1, 9, 150, "بازبینی تجربه کاربری صفحه فرود", True), + (owner, project_ops, [tags["backend"], tags["qa"]], 2, 10, 190, "بهبود خروجی گزارش‌ها", True), + (owner, project_portal, [tags["meeting"]], 3, 13, 75, "هماهنگی با تیم محصول", True), + (owner, None, [tags["meeting"]], 4, 16, 45, "جلسه داخلی بدون پروژه", False), + (admin, project_portal, [tags["backend"]], 1, 8, 240, "کنترل دسترسی پروژه‌ها", True), + (admin, project_ops, [tags["qa"]], 5, 11, 120, "تست گزارش ماهانه", True), + (member, project_portal, [tags["backend"], tags["qa"]], 2, 9, 300, "اصلاح فرم تایم‌شیت", True), + (member, project_marketing, [tags["design"]], 6, 14, 180, "پولیش رابط کاربری", True), + (guest, project_portal, [tags["meeting"]], 3, 10, 90, "همگام‌سازی با مشتری", True), + (guest, None, [], 7, 15, 60, "کار دسته‌بندی‌نشده", False), + ] + for entry in entries: + add_entry(primary, entry[0], entry[1], entry[2], entry[3], entry[4], entry[5], entry[6], entry[7]) + +print(json.dumps({ + "user_id": str(owner.id), + "workspace_id": str(primary.id), + "workspace_name": "استودیو کوئرا", + "mobile": MOBILE, + "password": PASSWORD, + "project_to_archive_id": str(project_archive.id), + "project_to_archive": "کمپین قابل آرشیو", + "project_for_timer": "وب‌سایت تبلیغاتی", + "filter_project": "پورتال دانشجویان", + "tag_one": "طراحی", + "tag_two": "تحقیق", +})) +`; + return JSON.parse(runDjangoShell(code)); +} + +async function createBrowser() { + const { chromium } = require("playwright"); + const options = { headless: true, slowMo: 55 }; + if (CHROME_EXECUTABLE) { + options.executablePath = CHROME_EXECUTABLE; + } + return chromium.launch(options); +} + +async function settle(page, delay = 250) { + await page.waitForLoadState("domcontentloaded", { timeout: 3000 }).catch(() => {}); + await page.waitForLoadState("networkidle", { timeout: 900 }).catch(() => {}); + await page + .waitForFunction( + () => { + const text = document.body.innerText || ""; + const skeletons = document.querySelectorAll('[class*="animate-pulse"], [class*="skeleton"], [aria-busy="true"]').length; + return skeletons === 0 && !/Loading|در حال|بارگذاری|لطفا صبر/i.test(text); + }, + { timeout: 2500 }, + ) + .catch(() => {}); + await wait(delay); +} + +async function smoothScrollBy(page, totalDelta, steps = 8, delay = 180) { + const stepDelta = totalDelta / steps; + for (let index = 0; index < steps; index += 1) { + await page.mouse.wheel(0, stepDelta); + await wait(delay); + } + await settle(page, 120); +} + +async function goto(page, route, delay = 1200) { + await page.goto(`${FRONTEND_URL}${route}`, { waitUntil: "domcontentloaded" }); + await settle(page, delay); +} + +async function clickAppNav(page, route, delay = 450) { + await closeAnyOpenModal(page); + let clicked = await clickFirst(page, [ + `aside a[href="${route}"]:visible`, + `nav a[href="${route}"]:visible`, + `a[href="${route}"]:visible`, + ], 2500); + if (!clicked) { + const target = await page.evaluate((route) => { + const links = Array.from(document.querySelectorAll(`a[href="${route}"]`)); + const visible = links.find((link) => { + const rect = link.getBoundingClientRect(); + const style = window.getComputedStyle(link); + return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; + }); + if (!visible) return null; + const rect = visible.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + }, route); + if (target) { + await moveVisibleCursor(page, target.x, target.y); + await page.mouse.click(target.x, target.y); + await moveVisibleCursor(page, target.x, target.y, { click: true }); + clicked = true; + } + } + if (!clicked) { + throw new Error(`Could not navigate via UI to ${route}`); + } + await page.waitForURL(new RegExp(`${route.replace("/", "\\/")}(?:$|[?#])`), { timeout: 10000 }); + await settle(page, delay); +} + +async function openProfileFromAvatar(page) { + await clickFirst(page, [ + page.locator("button").filter({ hasText: /^Q$/ }), + page.locator("button").filter({ hasText: /Qlockify Video/i }), + ], 2500); + await clickFirst(page, [ + 'a[href="/profile"]', + page.getByRole("link", { name: /Profile|پروفایل|حساب/i }), + page.getByText(/Profile|پروفایل|حساب/i), + ], 2500); + await page.waitForURL(/\/profile(?:$|[?#])/, { timeout: 10000 }).catch(() => {}); + await settle(page, 450); +} + +async function clickFirst(page, locators, timeout = 5000) { + for (const locator of locators) { + const item = typeof locator === "string" ? page.locator(locator).first() : locator.first(); + if (await clickLocatorWithCursor(page, item, timeout).then(() => true).catch(() => false)) { + await settle(page, 180); + return true; + } + } + return false; +} + +async function ensureVisibleCursor(page) { + if (!SHOW_CURSOR) return; + await page.evaluate(() => { + if (!document.body || document.getElementById("qlockify-video-cursor")) return; + + const style = document.createElement("style"); + style.id = "qlockify-video-cursor-style"; + style.textContent = ` + #qlockify-video-cursor { + position: fixed; + left: 0; + top: 0; + z-index: 2147483647; + width: 24px; + height: 24px; + pointer-events: none; + transform: translate(28px, 28px); + transition: transform 160ms cubic-bezier(.2,.8,.2,1); + filter: drop-shadow(0 8px 18px rgba(2, 6, 23, .55)); + } + #qlockify-video-cursor::before { + content: ""; + position: absolute; + left: 3px; + top: 2px; + width: 0; + height: 0; + border-right: 14px solid transparent; + border-bottom: 22px solid #38bdf8; + transform: rotate(-28deg); + } + #qlockify-video-cursor::after { + content: ""; + position: absolute; + left: 7px; + top: 6px; + width: 0; + height: 0; + border-right: 8px solid transparent; + border-bottom: 14px solid #ffffff; + transform: rotate(-28deg); + opacity: .95; + } + .qlockify-video-click-ring { + position: fixed; + z-index: 2147483646; + width: 16px; + height: 16px; + margin-left: -8px; + margin-top: -8px; + border-radius: 999px; + border: 2px solid rgba(56, 189, 248, .9); + pointer-events: none; + animation: qlockify-video-click-ring 420ms ease-out forwards; + } + @keyframes qlockify-video-click-ring { + from { transform: scale(.8); opacity: .95; } + to { transform: scale(3.6); opacity: 0; } + } + `; + + const cursor = document.createElement("div"); + cursor.id = "qlockify-video-cursor"; + document.head.appendChild(style); + document.body.appendChild(cursor); + }); +} + +async function moveVisibleCursor(page, x, y, { click = false } = {}) { + if (!SHOW_CURSOR) return; + await ensureVisibleCursor(page).catch(() => {}); + await page.evaluate( + ({ x, y, click }) => { + const cursor = document.getElementById("qlockify-video-cursor"); + if (!cursor) return; + cursor.style.transform = `translate(${x}px, ${y}px)`; + if (click) { + const ring = document.createElement("span"); + ring.className = "qlockify-video-click-ring"; + ring.style.left = `${x}px`; + ring.style.top = `${y}px`; + document.body.appendChild(ring); + window.setTimeout(() => ring.remove(), 460); + } + }, + { x, y, click }, + ); + await wait(click ? 70 : 170); +} + +async function clickLocatorWithCursor(page, locator, timeout = 5000) { + await locator.waitFor({ state: "visible", timeout }); + await locator.scrollIntoViewIfNeeded({ timeout }).catch(() => {}); + const box = await locator.boundingBox().catch(() => null); + if (!box) { + await locator.click({ timeout }); + return; + } + + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await moveVisibleCursor(page, x, y); + await locator.click({ timeout }); + await moveVisibleCursor(page, x, y, { click: true }); +} + +async function fillFirst(page, locators, value, timeout = 5000) { + for (const locator of locators) { + const item = typeof locator === "string" ? page.locator(locator).first() : locator.first(); + if (await item.waitFor({ state: "visible", timeout }).then(() => true).catch(() => false)) { + const box = await item.boundingBox().catch(() => null); + if (box) { + await moveVisibleCursor(page, box.x + Math.min(24, box.width / 2), box.y + box.height / 2); + } + if (!(await item.fill(value, { timeout }).then(() => true).catch(() => false))) { + continue; + } + await wait(120); + return true; + } + } + throw new Error(`Could not fill input with value: ${value}`); +} + +async function clickText(page, text, timeout = 5000) { + return clickFirst(page, [ + page.getByRole("button", { name: text }), + page.getByRole("link", { name: text }), + page.getByText(text, { exact: false }), + ], timeout); +} + +async function chooseDropdownOption(page, triggerText, optionText) { + await clickFirst(page, [ + page.getByRole("button", { name: new RegExp(triggerText, "i") }), + page.getByText(new RegExp(triggerText, "i")), + ]); + await clickFirst(page, [ + page.getByRole("option", { name: optionText }), + page.getByRole("button", { name: optionText }), + page.getByText(optionText, { exact: true }), + ]); +} + +async function clickActiveTimerControl(page, controlType) { + const target = await page.evaluate((controlType) => { + const inputs = Array.from(document.querySelectorAll("input")); + const descriptionInput = inputs.find((input) => /روی چه چیزی|What are you working on/i.test(input.placeholder || "")); + if (!descriptionInput) return null; + + let container = descriptionInput.parentElement; + while (container && container !== document.body) { + const buttons = Array.from(container.querySelectorAll("button")); + const hasTimerAction = buttons.some((button) => { + const title = button.getAttribute("title") || button.getAttribute("aria-label") || ""; + return /شروع|توقف|Start|Stop/i.test(title); + }); + if (buttons.length >= 3 && hasTimerAction) break; + container = container.parentElement; + } + if (!container) return null; + + const buttons = Array.from(container.querySelectorAll("button")); + let button = null; + if (controlType === "tags") { + button = buttons.find((candidate) => candidate.querySelector("svg.lucide-tag")); + } else { + button = buttons.find((candidate) => { + const title = candidate.getAttribute("title") || candidate.getAttribute("aria-label") || ""; + const text = candidate.textContent || ""; + if (/شروع|توقف|حذف|دور ریختن|Billable|Start|Stop|Delete|Discard/i.test(title + text)) return false; + if (candidate.querySelector("svg.lucide-tag, svg.lucide-dollar-sign, svg.lucide-play, svg.lucide-square, svg.lucide-trash-2")) return false; + return /پروژه|Project/i.test(title + text) || candidate.querySelector("svg.lucide-chevron-down"); + }); + } + + if (!button) return null; + const rect = button.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + }, controlType); + + if (!target) { + throw new Error(`Could not find active timer ${controlType} control.`); + } + await moveVisibleCursor(page, target.x, target.y); + await page.mouse.click(target.x, target.y); + await moveVisibleCursor(page, target.x, target.y, { click: true }); + await settle(page, 350); +} + +async function clickVisibleTimerMenuButton(page, text) { + const target = await page.evaluate((text) => { + const isVisible = (element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; + }; + const candidates = Array.from(document.querySelectorAll("button")) + .filter((button) => isVisible(button) && (button.textContent || "").trim() === text) + .map((button) => { + const rect = button.getBoundingClientRect(); + const isCompactMenu = rect.width <= 320 && rect.height <= 52 && rect.x < window.innerWidth * 0.58 && rect.y > 260 && rect.y < window.innerHeight - 40; + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + score: (isCompactMenu ? 0 : 100000) + rect.x + rect.y, + }; + }) + .sort((a, b) => a.score - b.score); + return candidates[0] || null; + }, text); + + if (!target) { + throw new Error(`Could not find timer dropdown option: ${text}`); + } + await moveVisibleCursor(page, target.x, target.y); + await page.mouse.click(target.x, target.y); + await moveVisibleCursor(page, target.x, target.y, { click: true }); + await settle(page, 350); +} + +async function clickActiveTimerStop(page) { + const stopButton = page.locator("button:visible").filter({ has: page.locator("svg.lucide-square") }).first(); + if (await stopButton.waitFor({ state: "visible", timeout: 5000 }).then(() => true).catch(() => false)) { + const box = await stopButton.boundingBox(); + if (!box) { + throw new Error("Could not resolve active timer stop button position."); + } + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await moveVisibleCursor(page, x, y); + await page.mouse.click(x, y); + await moveVisibleCursor(page, x, y, { click: true }); + await settle(page, 700); + return; + } + + const target = await page.evaluate(() => { + const isVisible = (element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; + }; + const candidates = Array.from(document.querySelectorAll("button")) + .filter((button) => { + if (!isVisible(button)) return false; + const label = [button.getAttribute("title"), button.getAttribute("aria-label"), button.textContent].filter(Boolean).join(" "); + return /Stop|توقف/.test(label) || button.querySelector("svg.lucide-square"); + }) + .map((button) => { + const rect = button.getBoundingClientRect(); + const isActiveTimerRow = rect.y > 180 && rect.y < 300 && rect.width >= 40 && rect.height >= 40; + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + score: isActiveTimerRow ? 0 : 100000 + rect.y, + }; + }) + .sort((a, b) => a.score - b.score); + return candidates[0] || null; + }); + + if (!target) { + throw new Error("Could not find active timer stop button."); + } + await moveVisibleCursor(page, target.x, target.y); + await page.mouse.click(target.x, target.y); + await moveVisibleCursor(page, target.x, target.y, { click: true }); + await settle(page, 700); +} + +async function selectActiveTimerProject(page, projectName) { + await clickActiveTimerControl(page, "project"); + await clickVisibleTimerMenuButton(page, projectName); +} + +async function selectActiveTimerTags(page, tagNames) { + await clickActiveTimerControl(page, "tags"); + for (const tagName of tagNames) { + await clickVisibleTimerMenuButton(page, tagName); + } + await moveVisibleCursor(page, 720, 180); + await page.mouse.click(720, 180); + await moveVisibleCursor(page, 720, 180, { click: true }); + await settle(page, 220); +} + +async function clickCardAction(page, cardText, actionTitles) { + const target = await page.evaluate(({ cardText, actionTitles }) => { + const actionSvgNames = actionTitles.some((value) => /delete|حذف/i.test(value)) + ? ["svg.lucide-trash-2", "svg.lucide-trash"] + : ["svg.lucide-pencil", "svg.lucide-edit-2", "svg.lucide-edit"]; + const matchesTitle = (button) => { + const title = button.getAttribute("title") || button.getAttribute("aria-label") || button.textContent || ""; + return actionTitles.some((value) => title.includes(value)) || actionSvgNames.some((selector) => button.querySelector(selector)); + }; + + const cards = Array.from(document.querySelectorAll("div, article, section")) + .filter((node) => { + const text = node.textContent || ""; + if (!text.includes(cardText)) return false; + const buttons = node.querySelectorAll("button"); + if (!buttons.length) return false; + const rect = node.getBoundingClientRect(); + return rect.width > 120 && rect.height > 80; + }) + .sort((a, b) => { + const rectA = a.getBoundingClientRect(); + const rectB = b.getBoundingClientRect(); + return rectA.width * rectA.height - rectB.width * rectB.height; + }); + + for (const card of cards) { + const button = Array.from(card.querySelectorAll("button")).find(matchesTitle); + if (button) { + const rect = button.getBoundingClientRect(); + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + } + } + return null; + }, { cardText, actionTitles }); + + if (!target) { + throw new Error(`Could not click ${actionTitles.join("/")} for card: ${cardText}`); + } + await moveVisibleCursor(page, target.x, target.y); + await page.mouse.click(target.x, target.y); + await moveVisibleCursor(page, target.x, target.y, { click: true }); + await settle(page, 650); +} + +async function clickProjectCardAction(page, projectName, action) { + const target = await page.evaluate(({ projectName, action }) => { + const isDelete = action === "delete"; + const iconSelectors = isDelete + ? ["svg.lucide-trash-2", "svg.lucide-trash2", "svg.lucide-trash"] + : ["svg.lucide-pencil", "svg.lucide-edit-2", "svg.lucide-edit"]; + const titlePatterns = isDelete ? [/delete/i, /حذف/] : [/edit/i, /ویرایش/]; + const isVisible = (element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; + }; + const matchesAction = (button) => { + const label = [ + button.getAttribute("title"), + button.getAttribute("aria-label"), + button.textContent, + ].filter(Boolean).join(" "); + return titlePatterns.some((pattern) => pattern.test(label)) + || iconSelectors.some((selector) => button.querySelector(selector)); + }; + + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + const textElements = []; + while (walker.nextNode()) { + const node = walker.currentNode; + if ((node.nodeValue || "").includes(projectName) && node.parentElement) { + textElements.push(node.parentElement); + } + } + + const candidates = []; + for (const textElement of textElements) { + let node = textElement; + for (let depth = 0; node && node !== document.body && depth < 10; depth += 1) { + const rect = node.getBoundingClientRect(); + if (isVisible(node) && rect.width > 150 && rect.height > 80 && rect.width < window.innerWidth * 0.92) { + const button = Array.from(node.querySelectorAll("button")).find((candidate) => isVisible(candidate) && matchesAction(candidate)); + if (button) { + const buttonRect = button.getBoundingClientRect(); + candidates.push({ + x: buttonRect.left + buttonRect.width / 2, + y: buttonRect.top + buttonRect.height / 2, + area: rect.width * rect.height, + }); + } + } + node = node.parentElement; + } + } + candidates.sort((a, b) => a.area - b.area); + return candidates[0] || null; + }, { projectName, action }); + + if (!target) { + throw new Error(`Could not click ${action} for project: ${projectName}`); + } + await moveVisibleCursor(page, target.x, target.y); + await page.mouse.click(target.x, target.y); + await moveVisibleCursor(page, target.x, target.y, { click: true }); + await settle(page, 650); +} + +async function clickProjectRowAction(page, projectName, action) { + const target = await page.evaluate(({ projectName, action }) => { + const isDelete = action === "delete"; + const iconSelectors = isDelete + ? ["svg.lucide-trash-2", "svg.lucide-trash2", "svg.lucide-trash"] + : ["svg.lucide-pencil", "svg.lucide-edit-2", "svg.lucide-edit"]; + const titlePatterns = isDelete ? [/delete/i, /حذف/] : [/edit/i, /ویرایش/]; + const isVisible = (element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; + }; + const matchesAction = (button) => { + const label = [button.getAttribute("title"), button.getAttribute("aria-label"), button.textContent].filter(Boolean).join(" "); + return titlePatterns.some((pattern) => pattern.test(label)) + || iconSelectors.some((selector) => button.querySelector(selector)); + }; + + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + const titleRects = []; + while (walker.nextNode()) { + const node = walker.currentNode; + if (!(node.nodeValue || "").includes(projectName) || !node.parentElement) continue; + const rect = node.parentElement.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + titleRects.push(rect); + } + } + if (!titleRects.length) return null; + + const buttons = Array.from(document.querySelectorAll("button")) + .filter((button) => isVisible(button) && matchesAction(button)) + .map((button) => { + const rect = button.getBoundingClientRect(); + const centerY = rect.top + rect.height / 2; + const distance = Math.min(...titleRects.map((titleRect) => Math.abs(centerY - (titleRect.top + titleRect.height / 2)))); + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + distance, + }; + }) + .filter((candidate) => candidate.distance < 90) + .sort((a, b) => a.distance - b.distance); + + return buttons[0] || null; + }, { projectName, action }); + + if (!target) { + throw new Error(`Could not click ${action} for project row: ${projectName}`); + } + await moveVisibleCursor(page, target.x, target.y); + await page.mouse.click(target.x, target.y); + await moveVisibleCursor(page, target.x, target.y, { click: true }); + await settle(page, 650); +} + +async function clickProjectActionByOrder(page, action, index = 0) { + const waitSelector = action === "delete" ? "svg.lucide-trash-2" : "svg.lucide-pencil"; + await page.locator("button").filter({ has: page.locator(waitSelector) }).first().waitFor({ state: "visible", timeout: 12000 }); + const target = await page.evaluate(({ action, index }) => { + const isDelete = action === "delete"; + const iconSelectors = isDelete + ? ["svg.lucide-trash-2", "svg.lucide-trash2", "svg.lucide-trash"] + : ["svg.lucide-pencil", "svg.lucide-edit-2", "svg.lucide-edit"]; + const titlePatterns = isDelete ? [/delete/i, /حذف/] : [/edit/i, /ویرایش/]; + const isVisible = (element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; + }; + const matchesAction = (button) => { + const label = [button.getAttribute("title"), button.getAttribute("aria-label"), button.textContent].filter(Boolean).join(" "); + return titlePatterns.some((pattern) => pattern.test(label)) + || iconSelectors.some((selector) => button.querySelector(selector)); + }; + const buttons = Array.from(document.querySelectorAll("button")) + .filter((button) => isVisible(button) && matchesAction(button)) + .map((button) => { + const rect = button.getBoundingClientRect(); + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + top: rect.top, + left: rect.left, + }; + }) + .sort((a, b) => (a.top - b.top) || (b.left - a.left)); + return buttons[index] || null; + }, { action, index }); + + if (!target) { + throw new Error(`Could not click ${action} project action at index ${index}`); + } + await moveVisibleCursor(page, target.x, target.y); + await page.mouse.click(target.x, target.y); + await moveVisibleCursor(page, target.x, target.y, { click: true }); + await settle(page, 650); +} + +async function clickIconNearestText(page, text, iconSelector, label) { + const target = await page.evaluate(({ text, iconSelector }) => { + const isVisible = (element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; + }; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + const textCenters = []; + while (walker.nextNode()) { + const node = walker.currentNode; + if (!(node.nodeValue || "").includes(text) || !node.parentElement || !isVisible(node.parentElement)) continue; + const rect = node.parentElement.getBoundingClientRect(); + textCenters.push({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }); + } + if (!textCenters.length) return null; + return Array.from(document.querySelectorAll("button")) + .filter((button) => isVisible(button) && button.querySelector(iconSelector)) + .map((button) => { + const rect = button.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + const distance = Math.min(...textCenters.map((center) => Math.abs(center.y - y) + Math.abs(center.x - x) * 0.08)); + return { x, y, distance }; + }) + .sort((a, b) => a.distance - b.distance)[0] || null; + }, { text, iconSelector }); + + if (!target) { + throw new Error(`Could not click ${label} near text: ${text}`); + } + await moveVisibleCursor(page, target.x, target.y); + await page.mouse.click(target.x, target.y); + await moveVisibleCursor(page, target.x, target.y, { click: true }); + await settle(page, 550); +} + +async function clickVisibleEnabledSelector(page, selector, label) { + const target = await page.evaluate((selector) => { + const isVisible = (element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; + }; + const element = Array.from(document.querySelectorAll(selector)).find((candidate) => { + return isVisible(candidate) && !candidate.disabled; + }); + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + }, selector); + + if (!target) { + throw new Error(`Could not click visible enabled element: ${label}`); + } + await moveVisibleCursor(page, target.x, target.y); + await page.mouse.click(target.x, target.y); + await moveVisibleCursor(page, target.x, target.y, { click: true }); +} + +async function closeAnyOpenModal(page) { + const target = await page.evaluate(() => { + const overlays = Array.from(document.querySelectorAll(".fixed.inset-0")); + const overlay = overlays.reverse().find((element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; + }); + if (!overlay) return null; + const buttons = Array.from(overlay.querySelectorAll("button")); + const closeButton = buttons.find((button) => button.querySelector("svg.lucide-x")) + || buttons.find((button) => /cancel|close/i.test([button.textContent, button.getAttribute("title"), button.getAttribute("aria-label")].filter(Boolean).join(" "))) + || buttons[0]; + if (!closeButton) return null; + const rect = closeButton.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + }); + if (target) { + await moveVisibleCursor(page, target.x, target.y); + await page.mouse.click(target.x, target.y); + await moveVisibleCursor(page, target.x, target.y, { click: true }); + } + await settle(page, 250); +} + +async function deleteFixtureProjectByApiIfNeeded(page, fixture) { + if (!fixture.project_to_archive_id) return; + const stillOpen = await page.locator("#delete-project-form").isVisible().catch(() => false); + if (!stillOpen) return; + await page.evaluate(async ({ backendUrl, projectId }) => { + const accessToken = localStorage.getItem("accessToken"); + await fetch(`${backendUrl}/api/projects/${projectId}/`, { + method: "DELETE", + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {}, + }); + }, { backendUrl: BACKEND_URL, projectId: fixture.project_to_archive_id }); + await closeAnyOpenModal(page); +} + +async function clickPagePlusButton(page) { + const plusButton = page.locator("button:visible").filter({ has: page.locator("svg.lucide-plus") }).first(); + await clickLocatorWithCursor(page, plusButton, 8000); + await settle(page, 200); +} + +async function loginThroughUi(page, fixture) { + await goto(page, "/", 700); + await wait(3300); + await clickFirst(page, [ + page.getByRole("link", { name: /پنل کاربری|ورود|شروع|Dashboard|Login/i }), + page.getByRole("button", { name: /پنل کاربری|ورود|شروع|Dashboard|Login/i }), + 'a[href="/auth"]', + ], 4000); + if (!page.url().includes("/auth")) { + await goto(page, "/auth", 800); + } + + await fillFirst(page, ['#login-mobile', 'input[type="tel"]'], fixture.mobile); + await clickFirst(page, [ + page.getByRole("button", { name: /ادامه|Continue/i }), + 'button[type="submit"]', + ]); + await fillFirst(page, ['#login-password', 'input[type="password"]'], fixture.password); + await clickFirst(page, [ + page.getByRole("button", { name: /ورود|Sign in|Login/i }), + 'button[type="submit"]', + ]); + await page.waitForURL(/\/profile|\/timesheet|\/workspaces/, { timeout: 25000 }).catch(() => {}); + await settle(page, 450); +} + +async function showProfile(page) { + await settle(page, 500); +} + +async function showTimesheetAndTimerFlow(page, fixture) { + await clickAppNav(page, "/timesheet", 400); + await clickFirst(page, [ + page.getByRole("button", { name: /شروع|Start/i }), + 'button[title="شروع"]', + 'button[title="Start"]', + ]); + await settle(page, 250); + + await fillFirst(page, [ + page.getByPlaceholder(/روی چه چیزی|What are you working on/i), + 'input[type="text"]', + ], "ضبط ویدئوی معرفی محصول"); + await selectActiveTimerProject(page, fixture.project_for_timer); + await selectActiveTimerTags(page, [fixture.tag_one, fixture.tag_two]); + await wait(5000); + await clickActiveTimerStop(page); + await settle(page, 400); + await wait(3000); +} + +async function showWorkspaceOverviewAndEdit(page, fixture) { + await clickAppNav(page, "/workspaces", 450); + await clickIconNearestText(page, fixture.workspace_name, "svg.lucide-eye", "workspace view"); + await page.waitForURL(/\/workspaces\/[^/]+(?:$|[?#])/, { timeout: 10000 }); + await settle(page, 450); + + await smoothScrollBy(page, 2600, 13, 170); + await smoothScrollBy(page, -1700, 9, 150); + + await clickFirst(page, [ + page.getByRole("button", { name: /مدیریت اعضا|Manage members/i }), + page.locator("button:visible").filter({ has: page.locator("svg.lucide-edit-2") }).first(), + ], 8000); + await page.waitForURL(/\/workspaces\/[^/]+\/edit(?:$|[?#])/, { timeout: 10000 }); + await settle(page, 350); + + await fillFirst(page, [ + page.locator("textarea").first(), + ], "فضای کاری دمو برای نمایش مدیریت اعضا، نرخ‌ها و داده‌های گزارش‌گیری."); + await clickFirst(page, [ + page.locator('button[type="submit"]').first(), + page.getByRole("button", { name: /ذخیره|Save/i }), + ], 5000); + await settle(page, 400); +} + +async function createClient(page) { + await clickAppNav(page, "/clients", 500); + await clickFirst(page, [ + 'button[title="افزودن مشتری"]', + 'button[title="Add Client"]', + page.getByRole("button", { name: /افزودن مشتری|Add Client/i }), + page.locator("button").filter({ has: page.locator("svg.lucide-plus") }), + ]); + await fillFirst(page, [ + page.getByPlaceholder(/نام مشتری|Client name/i), + page.locator("#create-client-form input").nth(1), + ], "مشتری ویدئویی"); + await fillFirst(page, [ + page.getByPlaceholder(/یادداشت|Notes/i), + page.locator("#create-client-form textarea"), + ], "این مشتری در جریان ضبط ویدئو ساخته شده است."); + await wait(1000); + await clickFirst(page, [ + page.getByRole("button", { name: /ایجاد|Create/i }), + '#create-client-form button[type="submit"]', + ]); + await settle(page, 300); +} + +async function archiveAndDeleteProject(page, fixture) { + await clickAppNav(page, "/projects", 500); + await clickFirst(page, [ + page.locator("button:visible").filter({ has: page.locator("svg.lucide-pencil") }).first(), + page.locator("button:visible").filter({ has: page.locator("svg.lucide-edit-2") }).first(), + ], 8000); + await page.locator("#edit-project-form").waitFor({ timeout: 10000 }); + await clickFirst(page, [ + page.locator("button").filter({ has: page.locator("svg.lucide-archive") }), + page.getByRole("button", { name: /Archive/i }), + ]); + await page.locator("#edit-project-form").waitFor({ state: "detached", timeout: 12000 }).catch(() => {}); + await settle(page, 120); + + await clickFirst(page, [ + page.getByRole("switch", { name: /Archived/i }), + page.locator('button[role="switch"]').first(), + ]); + await settle(page, 250); + await clickFirst(page, [ + page.getByRole("button", { name: /Clear filters/i }), + page.locator('button[aria-label*="Clear"], button[aria-label*="پاک"]').first(), + ], 3000).catch(() => {}); + await settle(page, 350); + return; + await clickIconNearestText(page, fixture.project_to_archive, "svg.lucide-trash-2", "project delete"); + await page.locator("#delete-project-form input").waitFor({ timeout: 10000 }); + await fillFirst(page, [ + page.locator("#delete-project-form input"), + ], fixture.project_to_archive); + await page.waitForFunction(() => { + const button = document.querySelector('button[form="delete-project-form"][type="submit"]'); + return button && !button.disabled; + }, { timeout: 5000 }); + const deleteResponse = page.waitForResponse( + (response) => response.request().method() === "DELETE" && response.url().includes("/api/projects/"), + { timeout: 15000 }, + ); + await clickVisibleEnabledSelector(page, 'button[form="delete-project-form"][type="submit"]', "project delete submit"); + await deleteResponse.catch(() => null); + await page.locator("#delete-project-form").waitFor({ state: "detached", timeout: 2500 }).catch(() => {}); + await deleteFixtureProjectByApiIfNeeded(page, fixture); + await closeAnyOpenModal(page); + await settle(page, 500); + await clickFirst(page, [ + page.getByRole("button", { name: /Clear filters/i }), + page.locator('button[aria-label*="Clear"], button[aria-label*="پاک"]').first(), + ], 3000).catch(() => {}); + await settle(page, 350); + return; + await clickCardAction(page, fixture.project_to_archive, ["ویرایش", "Edit"]); + await clickFirst(page, [ + page.getByRole("button", { name: /آرشیو|Archive/i }), + ]); + await settle(page, 900); + + await clickFirst(page, [ + page.getByRole("switch", { name: /آرشیو|Archived/i }), + page.locator('button[role="switch"]').first(), + ]); + await settle(page, 900); + + await page.getByText(fixture.project_to_archive).first().waitFor({ timeout: 10000 }); + await clickCardAction(page, fixture.project_to_archive, ["حذف", "Delete"]); + await page.locator("#delete-project-form input").waitFor({ timeout: 10000 }); + await fillFirst(page, [ + page.locator('#delete-project-form input'), + ], fixture.project_to_archive); + await clickFirst(page, [ + page.getByRole("button", { name: /حذف|Delete/i }), + ]); + await settle(page, 900); + + await clickFirst(page, [ + page.getByRole("button", { name: /پاک کردن فیلتر|Clear filters/i }), + page.locator('button[aria-label="پاک کردن فیلترها"], button[aria-label="Clear filters"]').first(), + ]); + await settle(page, 700); +} + +async function createTag(page) { + await clickAppNav(page, "/tags", 500); + await clickPagePlusButton(page); + await page.locator("#tag-form").waitFor({ state: "visible", timeout: 8000 }); + await fillFirst(page, [ + page.locator("#tag-form input").first(), + ], "ویدئو"); + await wait(1000); + await clickFirst(page, [ + page.locator('button[form="tag-form"][type="submit"]:not([disabled])'), + page.getByRole("button", { name: /ایجاد|Create/i }), + ], 5000); + await page.locator("#tag-form").waitFor({ state: "detached", timeout: 8000 }).catch(() => {}); + await settle(page, 300); +} + +async function waitForReportDetailModalLoaded(page, timeout = 12000) { + await page.waitForFunction(() => { + const overlays = Array.from(document.querySelectorAll(".fixed.inset-0")); + const overlay = overlays.reverse().find((element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; + }); + if (!overlay) return false; + + const text = overlay.innerText || ""; + const loadingNodes = overlay.querySelectorAll('[class*="animate-spin"], [class*="animate-pulse"], [aria-busy="true"]').length; + const hasLoadingText = /Loading|در حال|بارگذاری|لطفا صبر/i.test(text); + return text.trim().length > 120 && loadingNodes === 0 && !hasLoadingText; + }, { timeout }).catch(() => {}); +} + +async function showReportsAndExports(page) { + await clickAppNav(page, "/reports", 900); + await page.evaluate(() => window.scrollTo({ top: document.body.scrollHeight * 0.45, behavior: "smooth" })); + await wait(400); + await clickFirst(page, [ + page.getByRole("button", { name: /جدول|Table/i }), + ]); + await settle(page, 650); + await page.evaluate(() => window.scrollTo({ top: document.body.scrollHeight * 0.35, behavior: "smooth" })); + await wait(350); + await clickFirst(page, [ + page.getByRole("button", { name: /جزئیات|Details|مشاهده/i }), + page.locator("button").filter({ has: page.locator("svg.lucide-eye") }), + ], 3000).catch(() => {}); + await waitForReportDetailModalLoaded(page); + await wait(2200); + await closeAnyOpenModal(page); + await settle(page, 250); + + await clickFirst(page, [page.getByRole("button", { name: /PDF|پی‌دی‌اف/i })]); + await waitForNotificationText(page, /PDF|گزارش|آماده|خروجی/i, 90000); + await clickFirst(page, [page.getByRole("button", { name: /Excel|اکسل/i })]); + await waitForNotificationText(page, /EXCEL|Excel|گزارش|آماده|خروجی/i, 90000); + + await clickFirst(page, [ + page.getByRole("button", { name: /اعلان|Notifications/i }), + page.locator('button[aria-label*="اعلان"], button[aria-label*="notification" i]').first(), + ]); + await settle(page, 450); + await clickFirst(page, [ + page.getByRole("link", { name: /همه اعلان‌ها|View all notifications/i }), + page.getByText(/مشاهده همه|View all/i), + ]); + await settle(page, 500); + const downloadPromise = page.waitForEvent("download", { timeout: 15000 }).catch(() => null); + await clickFirst(page, [ + page.getByText(/آماده دانلود|ready/i).first(), + page.locator("button").filter({ hasText: /PDF|Excel|گزارش|report/i }).first(), + ]); + const download = await downloadPromise; + if (download) { + await download.saveAs(path.join(DOWNLOAD_DIR, await download.suggestedFilename())); + } + await wait(400); +} + +async function waitForNotificationText(page, pattern, timeout) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const text = await page.locator("body").innerText().catch(() => ""); + if (pattern.test(text)) { + await wait(700); + return; + } + await wait(1000); + } + throw new Error(`Timed out waiting for export notification: ${pattern}`); +} + +async function showLogsAndLogout(page) { + await clickAppNav(page, "/logs", 700); + for (let i = 0; i < 3; i += 1) { + await page.mouse.wheel(0, 1800); + await settle(page, 250); + const loadMore = page.getByRole("button", { name: /بارگذاری بیشتر|Load more/i }); + if (await loadMore.isVisible().catch(() => false)) { + await clickLocatorWithCursor(page, loadMore, 5000); + await settle(page, 350); + } + } + await clickFirst(page, [ + page.locator("button").filter({ hasText: /ایجاد|ویرایش|حذف|Create|Update|Delete/i }).first(), + page.locator("button").nth(5), + ], 4000).catch(() => {}); + await wait(500); + await clickFirst(page, [ + page.getByRole("button", { name: /خروج|Logout/i }), + page.locator('button[title="خروج"], button[title="Logout"]').first(), + page.locator("button").filter({ hasText: /خروج|Logout/i }).first(), + ], 5000); + await wait(300); +} + +async function runStep(label, action) { + const startedAt = Date.now(); + console.log(`[video] ${label}`); + await action(); + console.log(`[video] ${label} done in ${Math.round((Date.now() - startedAt) / 1000)}s`); +} + +async function runVideoFlow(fixture) { + ensureDir(VIDEO_DIR); + ensureDir(DOWNLOAD_DIR); + fs.rmSync(DOWNLOAD_DIR, { recursive: true, force: true }); + ensureDir(DOWNLOAD_DIR); + + const browser = await createBrowser(); + const tempVideoDir = fs.mkdtempSync(path.join(os.tmpdir(), "qlockify-video-")); + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + colorScheme: "dark", + locale: "fa-IR", + timezoneId: "Asia/Tehran", + acceptDownloads: true, + recordVideo: { dir: tempVideoDir, size: { width: 1440, height: 900 } }, + }); + await context.addInitScript(() => { + localStorage.setItem("language", "fa"); + localStorage.setItem("theme", "dark"); + }); + const page = await context.newPage(); + await ensureVisibleCursor(page).catch(() => {}); + + try { + await runStep("login", () => loginThroughUi(page, fixture)); + await runStep("profile", () => showProfile(page)); + await runStep("timesheet", () => showTimesheetAndTimerFlow(page, fixture)); + await runStep("workspace overview", () => showWorkspaceOverviewAndEdit(page, fixture)); + await runStep("clients", () => createClient(page)); + await runStep("projects", () => archiveAndDeleteProject(page, fixture)); + await runStep("tags", () => createTag(page)); + await runStep("reports and exports", () => showReportsAndExports(page)); + await runStep("logs and logout", () => showLogsAndLogout(page)); + } finally { + await context.close(); + await browser.close(); + } + + const videoPath = await page.video().path(); + ensureDir(path.dirname(VIDEO_OUTPUT)); + fs.copyFileSync(videoPath, VIDEO_OUTPUT); + console.log(`saved ${VIDEO_OUTPUT}`); +} + +async function main() { + await assertReachable(`${FRONTEND_URL}/`, "Frontend"); + await assertReachable(`${BACKEND_URL}/api/`, "Backend"); + const fixture = seedVideoFixture(); + await runVideoFlow(fixture); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/package-lock.json b/scripts/package-lock.json new file mode 100644 index 0000000..72451b3 --- /dev/null +++ b/scripts/package-lock.json @@ -0,0 +1,56 @@ +{ + "name": "scripts", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "playwright": "^1.57.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://package-mirror.liara.ir/repository/npm/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://package-mirror.liara.ir/repository/npm/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://package-mirror.liara.ir/repository/npm/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/scripts/package.json b/scripts/package.json index e0bddb2..8582281 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -2,7 +2,8 @@ "private": true, "type": "commonjs", "scripts": { - "screenshots": "node capture-screenshots.js" + "screenshots": "node capture-screenshots.js", + "video": "node capture-demo-video.js" }, "dependencies": { "playwright": "^1.57.0"