1364 lines
55 KiB
JavaScript
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);
|
|
});
|