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); });