Files
qlockify-demo/scripts/capture-demo-video.js

1364 lines
55 KiB
JavaScript

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