const fs = require("fs"); const path = require("path"); const { execFileSync } = require("child_process"); const ROOT_DIR = path.resolve(__dirname, ".."); const OUTPUT_DIR = path.join(ROOT_DIR, "assets", "screenshots"); 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 ADMIN_USERNAME = process.env.QLOCKIFY_ADMIN_USERNAME || "09900000000"; const ADMIN_PASSWORD = process.env.QLOCKIFY_ADMIN_PASSWORD || "QlockifyLocalAdmin!2026"; const CHROME_EXECUTABLE = process.env.CHROME_EXECUTABLE || process.env.QLOCKIFY_CHROME_PATH || ""; const CAPTURE_ADMIN = process.env.QLOCKIFY_CAPTURE_ADMIN === "1"; const CLEAN_OUTPUT = process.env.QLOCKIFY_CLEAN_SCREENSHOTS !== "0"; function printHelp() { console.log(` Usage: node scripts/capture-screenshots.js Required: - Qlockify frontend running at ${FRONTEND_URL} - Qlockify backend running at ${BACKEND_URL} - npm install run inside scripts/ so Playwright is available Useful environment variables: QLOCKIFY_FRONTEND_URL Frontend URL. Default: http://localhost:5173 QLOCKIFY_BACKEND_URL Backend URL. Default: http://localhost:8000 QLOCKIFY_BACKEND_REPO Local backend repo path. Default: ../qlockify-backend QLOCKIFY_DEMO_TOKEN_FILE JSON token file with access, refresh, workspace_id, expires_at QLOCKIFY_CAPTURE_ADMIN=1 Also capture Django admin screenshots QLOCKIFY_ADMIN_USERNAME Local admin mobile for admin screenshots QLOCKIFY_ADMIN_PASSWORD Local admin password for admin screenshots QLOCKIFY_CHROME_PATH Optional installed Chrome executable QLOCKIFY_CLEAN_SCREENSHOTS=0 Do not delete old PNG screenshots before capture The script does not start frontend, backend, PostgreSQL, Redis, or Celery. `); } if (process.argv.includes("--help") || process.argv.includes("-h")) { printHelp(); process.exit(0); } function stripTrailingSlash(value) { return value.replace(/\/+$/, ""); } function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); } function cleanScreenshots() { if (!CLEAN_OUTPUT || !fs.existsSync(OUTPUT_DIR)) return; for (const file of fs.readdirSync(OUTPUT_DIR)) { if (file.endsWith(".png")) { fs.rmSync(path.join(OUTPUT_DIR, file), { force: true }); } } } async function assertReachable(url, label) { try { const response = await fetch(url, { method: "GET" }); 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}`); } } async function fetchJson(url, options = {}) { const response = await fetch(url, options); const body = await response.text(); if (!response.ok) { throw new Error(`${url} failed: ${response.status} ${body}`); } return body ? JSON.parse(body) : {}; } 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); } function getDemoFromDjango() { const code = ` import json from apps.demos.models import DemoEnvironment from apps.users.services.auth import get_tokens_for_user env = DemoEnvironment.objects.select_related("owner_user", "workspace").filter(status="active").order_by("-created_at").first() if env is None: raise SystemExit("No active demo environment exists. Start one from the app or clear demo rate limit.") print(json.dumps({**get_tokens_for_user(env.owner_user), "workspace_id": str(env.workspace_id), "expires_at": env.expires_at.isoformat(), "owner_user_id": str(env.owner_user_id)})) `; return JSON.parse(runDjangoShell(code)); } function prepareAdminFixtures(demo) { if (!CAPTURE_ADMIN) return; const adminUsername = JSON.stringify(ADMIN_USERNAME); const adminPassword = JSON.stringify(ADMIN_PASSWORD); const ownerId = JSON.stringify(demo.owner_user_id || ""); const code = ` from django.contrib.auth import get_user_model from apps.users.models import UserSocialAccount User = get_user_model() admin, _ = User.objects.update_or_create( mobile=${adminUsername}, defaults={ "first_name": "Showcase", "last_name": "Admin", "email": "showcase-admin@qlockify.local", "is_staff": True, "is_superuser": True, "is_active": True, }, ) admin.set_password(${adminPassword}) admin.save(update_fields=["password", "first_name", "last_name", "email", "is_staff", "is_superuser", "is_active"]) owner_id = ${ownerId} if owner_id: owner = User.objects.filter(id=owner_id).first() if owner: UserSocialAccount.objects.update_or_create( provider="google", provider_user_id="showcase-demo-google", defaults={ "user": owner, "email": owner.email or "demo-owner@demo.qlockify.local", "avatar_url": "https://qlockify.ir/favicon.svg", }, ) print("ok") `; runDjangoShell(code); } async function readDemoTokenFile() { const tokenFile = process.env.QLOCKIFY_DEMO_TOKEN_FILE; if (!tokenFile || !fs.existsSync(tokenFile)) return null; const token = JSON.parse(fs.readFileSync(tokenFile, "utf8").replace(/^\uFEFF/, "")); if (token?.access && token?.refresh && token?.workspace_id) return token; return null; } async function resolveDemoSession() { const fileToken = await readDemoTokenFile(); if (fileToken) return fileToken; try { return await fetchJson(`${BACKEND_URL}/api/demo/start/`, { method: "POST" }); } catch (error) { console.warn(`Demo start failed, trying latest active demo from Django shell: ${error.message}`); return getDemoFromDjango(); } } async function settle(page, delay = 2000) { await page.waitForLoadState("domcontentloaded"); await page.waitForLoadState("networkidle", { timeout: 12000 }).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: 12000 }, ) .catch(() => {}); await wait(delay); } async function saveScreenshot(page, name, { fullPage = false, delay = 1000 } = {}) { await settle(page, delay); await page.screenshot({ path: path.join(OUTPUT_DIR, name), fullPage }); console.log(`saved ${name}`); } async function openAppRoute(page, route, delay = 2500) { await page.goto(`${FRONTEND_URL}${route}`, { waitUntil: "domcontentloaded" }); await settle(page, delay); } async function clickRoleButton(page, names, timeout = 5000) { for (const name of names) { const button = page.getByRole("button", { name }).first(); if (await button.click({ timeout }).then(() => true).catch(() => false)) { await settle(page, 1200); return true; } } return false; } async function clickSelector(page, selectors, timeout = 5000) { for (const selector of selectors) { const locator = page.locator(selector).first(); if (await locator.click({ timeout }).then(() => true).catch(() => false)) { await settle(page, 1200); return true; } } return false; } async function createBrowser() { const { chromium } = require("playwright"); const options = { headless: true }; if (CHROME_EXECUTABLE) { options.executablePath = CHROME_EXECUTABLE; } return chromium.launch(options); } async function createPublicContext(browser) { const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: "dark", locale: "fa-IR", timezoneId: "Asia/Tehran", }); await context.addInitScript(() => { localStorage.setItem("language", "fa"); localStorage.setItem("theme", "dark"); }); return context; } async function createAppContext(browser, demo, viewport = { width: 1440, height: 900 }) { const context = await browser.newContext({ viewport, colorScheme: "dark", locale: "fa-IR", timezoneId: "Asia/Tehran", }); await context.addInitScript(({ access, refresh, expiresAt }) => { localStorage.setItem("language", "fa"); localStorage.setItem("theme", "dark"); localStorage.setItem("accessToken", access); localStorage.setItem("refreshToken", refresh); localStorage.setItem("demoExpiresAt", expiresAt || new Date(Date.now() + 86400000).toISOString()); }, { access: demo.access, refresh: demo.refresh, expiresAt: demo.expires_at }); return context; } async function getChartUserId(demo) { const chart = await fetchJson( `${BACKEND_URL}/api/reports/chart/?workspace=${demo.workspace_id}&period=this_month&language=fa`, { headers: { Authorization: `Bearer ${demo.access}` } }, ); const ownerSeries = chart.series?.find((series) => series.user?.name === "Demo Owner") || chart.series?.find((series) => series.user); return ownerSeries?.user?.id || null; } async function capturePublicScreenshots(browser) { const context = await createPublicContext(browser); const page = await context.newPage(); await openAppRoute(page, "/", 2400); await saveScreenshot(page, "01-landing.png"); await openAppRoute(page, "/about", 2400); await saveScreenshot(page, "02-about.png", { fullPage: true }); await openAppRoute(page, "/auth", 2400); await saveScreenshot(page, "03-auth-mobile.png"); await context.close(); } async function captureAppScreenshots(browser, demo) { const context = await createAppContext(browser, demo); const page = await context.newPage(); await openAppRoute(page, "/timesheet", 4500); await saveScreenshot(page, "10-timesheet.png"); if (await clickRoleButton(page, [/تعرفه.*من/i, /My rates/i])) { await saveScreenshot(page, "11-my-rates-panel.png"); await page.keyboard.press("Escape"); await wait(800); } await openAppRoute(page, "/reports?tab=table", 4500); await clickRoleButton(page, [/جدول/i, /Table/i]); await saveScreenshot(page, "20-reports-table.png"); const chartUserId = await getChartUserId(demo); const chartRoute = chartUserId ? `/reports?tab=chart&period=this_month&user=${encodeURIComponent(chartUserId)}` : "/reports?tab=chart&period=this_month"; await openAppRoute(page, chartRoute, 7000); await clickRoleButton(page, [/نمودار/i, /Chart/i]); await page.evaluate(() => { for (const el of document.querySelectorAll(".overflow-x-auto")) { el.scrollLeft = -el.scrollWidth; } }); await wait(1200); await saveScreenshot(page, "21-reports-chart.png"); await openAppRoute(page, "/clients", 3500); await saveScreenshot(page, "30-clients.png"); if (await clickRoleButton(page, [/افزودن مشتری/i, /افزودن/i, /Create/i, /Add/i])) { await saveScreenshot(page, "31-client-create-modal.png"); await page.keyboard.press("Escape"); await wait(800); } if (await clickSelector(page, ['button[title="ویرایش"]', 'button[title="Edit"]', "button:has(svg.lucide-edit-2)"])) { await saveScreenshot(page, "32-client-edit-modal.png"); await page.keyboard.press("Escape"); await wait(800); } await openAppRoute(page, "/projects", 3500); await saveScreenshot(page, "40-projects.png"); if (await clickSelector(page, ['button[title="ایجاد پروژه جدید"]', 'button[title="Create new project"]'])) { await saveScreenshot(page, "41-project-create-modal.png"); await page.keyboard.press("Escape"); await wait(800); } if (await clickRoleButton(page, [/پروژه.*نرخ/i, /Projects and rates/i])) { await saveScreenshot(page, "42-project-access-modal.png", { delay: 2200 }); await page.keyboard.press("Escape"); await wait(800); } await openAppRoute(page, "/tags", 3500); await saveScreenshot(page, "50-tags.png"); if (await clickSelector(page, ['button[title="ایجاد تگ"]', 'button[title="Create Tag"]', "button:has(svg.lucide-plus)"])) { await saveScreenshot(page, "51-tags-create-modal.png"); await page.keyboard.press("Escape"); await wait(800); } await openAppRoute(page, "/workspaces", 3500); await saveScreenshot(page, "60-workspaces.png"); if (demo.workspace_id) { await openAppRoute(page, `/workspaces/${demo.workspace_id}/edit`, 4500); await saveScreenshot(page, "61-workspace-edit-page.png"); } await openAppRoute(page, "/profile", 3500); await saveScreenshot(page, "70-profile.png"); if (await clickSelector(page, ["button:has(svg.lucide-edit-2)", 'button[title="ویرایش"]', 'button[title="Edit"]'])) { await saveScreenshot(page, "71-profile-edit-page.png"); } await context.close(); } async function loginAdmin(page) { await page.goto(`${BACKEND_URL}/admin/login/?next=/admin/`, { waitUntil: "domcontentloaded" }); await settle(page, 800); await page.locator('input[name="username"]').fill(ADMIN_USERNAME); await page.locator('input[name="password"]').fill(ADMIN_PASSWORD); await page.locator('button[type="submit"], input[type="submit"]').first().click(); await settle(page, 2200); } async function hideDebugToolbar(page) { await page .addStyleTag({ content: '#djDebug, .djdt-hidden, [id^="djDebug"] { display: none !important; visibility: hidden !important; opacity: 0 !important; pointer-events: none !important; }', }) .catch(() => {}); await page .evaluate(() => { for (const node of document.querySelectorAll('#djDebug, .djdt-hidden, [id^="djDebug"]')) { node.remove(); } }) .catch(() => {}); } async function saveAdminScreenshot(page, name, fullPage = false) { await hideDebugToolbar(page); await saveScreenshot(page, name, { fullPage }); } async function captureAdminScreenshots(browser, demo) { if (!CAPTURE_ADMIN) { console.log("skipping admin screenshots; set QLOCKIFY_CAPTURE_ADMIN=1 to enable"); return; } const context = await browser.newContext({ viewport: { width: 1440, height: 1100 }, colorScheme: "dark", locale: "en-US", }); const page = await context.newPage(); await loginAdmin(page); await saveAdminScreenshot(page, "80-admin-dashboard.png"); await page.goto(`${BACKEND_URL}/admin/users/user/`, { waitUntil: "domcontentloaded" }); await saveAdminScreenshot(page, "81-admin-users-list.png"); const userId = process.env.QLOCKIFY_ADMIN_USER_ID || demo.owner_user_id; if (userId) { await page.goto(`${BACKEND_URL}/admin/users/user/${userId}/change/`, { waitUntil: "domcontentloaded" }); await saveAdminScreenshot(page, "82-admin-user-detail-social-inline.png", true); } await page.goto(`${BACKEND_URL}/admin/workspaces/workspace/`, { waitUntil: "domcontentloaded" }); await saveAdminScreenshot(page, "83-admin-workspaces-list.png"); if (demo.workspace_id) { await page.goto(`${BACKEND_URL}/admin/workspaces/workspace/${demo.workspace_id}/change/`, { waitUntil: "domcontentloaded" }); await saveAdminScreenshot(page, "84-admin-workspace-detail-members-inline.png", true); } await page.goto(`${BACKEND_URL}/admin/projects/project/`, { waitUntil: "domcontentloaded" }); await saveAdminScreenshot(page, "85-admin-projects-list.png"); await page.goto(`${BACKEND_URL}/admin/time_entries/timeentry/`, { waitUntil: "domcontentloaded" }); await saveAdminScreenshot(page, "86-admin-time-entries-list.png"); await page.goto(`${BACKEND_URL}/admin/users/usersocialaccount/`, { waitUntil: "domcontentloaded" }); await saveAdminScreenshot(page, "87-admin-social-accounts-list.png"); await page.goto(`${BACKEND_URL}/admin/contacts/contactsubmission/`, { waitUntil: "domcontentloaded" }); await saveAdminScreenshot(page, "88-admin-contact-submissions-list.png"); await context.close(); } async function main() { ensureDir(OUTPUT_DIR); cleanScreenshots(); await assertReachable(`${FRONTEND_URL}/`, "Frontend"); await assertReachable(`${BACKEND_URL}/api/`, "Backend"); const demo = await resolveDemoSession(); prepareAdminFixtures(demo); const browser = await createBrowser(); try { await capturePublicScreenshots(browser); await captureAppScreenshots(browser, demo); await captureAdminScreenshots(browser, demo); } finally { await browser.close(); } } main().catch((error) => { console.error(error); process.exit(1); });