478 lines
17 KiB
JavaScript
478 lines
17 KiB
JavaScript
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);
|
|
});
|