import { spawn } from 'node:child_process' import { mkdir, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' const chromePath = process.env.CHROME_PATH ?? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' const appUrl = process.env.REVIEW_APP_URL ?? 'http://127.0.0.1:5174' const apiUrl = process.env.REVIEW_API_URL ?? 'http://127.0.0.1:8800' const outputDirectory = resolve(process.cwd(), 'docs', 'visual-review') const debuggingPort = 9223 const profile = join(tmpdir(), `microlearn-visual-${Date.now()}`) const screenPrefix = process.env.REVIEW_SCREEN_PREFIX const screenFiles = process.env.REVIEW_SCREEN_FILES?.split(',').map(value => value.trim()).filter(Boolean) await mkdir(outputDirectory, { recursive: true }) const login = await fetch(`${apiUrl}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ email: 'designer@microlearn.test', password: 'password' }) }) if (!login.ok) throw new Error(`Review login failed with ${login.status}`) const { data: session } = await login.json() const learnerLogin = await fetch(`${apiUrl}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ email: 'maryam@microlearn.test', password: 'password' }) }) if (!learnerLogin.ok) throw new Error(`Learner review login failed with ${learnerLogin.status}`) const { data: learnerSession } = await learnerLogin.json() const managerLogin = await fetch(`${apiUrl}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ email: 'manager@microlearn.test', password: 'password' }) }) if (!managerLogin.ok) throw new Error(`Manager review login failed with ${managerLogin.status}`) const { data: managerSession } = await managerLogin.json() const learnerHome = await fetch(`${apiUrl}/api/v1/learner/home`, { headers: { Accept: 'application/json', Authorization: `Bearer ${learnerSession.token}` } }).then(response => response.json()) const learnerAssignment = learnerHome.data?.assigned?.[0] const courseList = await fetch(`${apiUrl}/api/v1/courses?perPage=1`, { headers: { Accept: 'application/json', Authorization: `Bearer ${session.token}` } }).then(response => response.json()) const reviewCourseId = courseList.data?.[0]?.id const chrome = spawn(chromePath, [`--headless=new`, `--remote-debugging-port=${debuggingPort}`, `--user-data-dir=${profile}`, '--disable-gpu', '--no-first-run', '--no-default-browser-check', 'about:blank'], { stdio: 'ignore' }) try { const target = await waitForTarget() const socket = new WebSocket(target.webSocketDebuggerUrl) await new Promise((resolveOpen, reject) => { socket.addEventListener('open', resolveOpen, { once: true }); socket.addEventListener('error', reject, { once: true }) }) let sequence = 0 const pending = new Map() socket.addEventListener('message', event => { const message = JSON.parse(event.data); if (!message.id) return; const request = pending.get(message.id); if (!request) return; pending.delete(message.id); message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result) }) const call = (method, params = {}) => new Promise((resolveCall, reject) => { const id = ++sequence; pending.set(id, { resolve: resolveCall, reject }); socket.send(JSON.stringify({ id, method, params })) }) await call('Page.enable') await call('Runtime.enable') await call('Page.navigate', { url: appUrl }) await delay(500) await call('Runtime.evaluate', { expression: `localStorage.setItem('microlearn.access_token', ${JSON.stringify(session.token)})` }) const screens = [ { file: 'phase3-users-desktop.png', path: '/app/users', width: 1440, height: 1000, locale: 'fa', theme: 'light' }, { file: 'phase3-teams-mobile-dark.png', path: '/app/teams', width: 390, height: 844, locale: 'fa', theme: 'dark' }, { file: 'phase3-subscription-ltr.png', path: '/app/subscription', width: 1280, height: 900, locale: 'en', theme: 'light' }, { file: 'phase4-courses-desktop.png', path: '/app/courses', width: 1440, height: 1000, locale: 'fa', theme: 'light' }, { file: 'phase4-new-course-mobile-dark.png', path: '/app/courses/new', width: 390, height: 844, locale: 'fa', theme: 'dark' }, ...(reviewCourseId ? [{ file: 'phase4-workspace-ltr.png', path: `/app/courses/${reviewCourseId}?tab=content`, width: 1280, height: 900, locale: 'en', theme: 'light' }] : []), { file: 'phase5-builder-desktop-rtl.png', path: '/app/builder-preview', width: 1600, height: 1000, locale: 'fa', theme: 'light' }, { file: 'phase5-builder-tablet-dark.png', path: '/app/builder-preview', width: 1440, height: 1000, locale: 'fa', theme: 'dark', action: `[aria-label="tablet"]` }, { file: 'phase5-builder-mobile-preview-ltr.png', path: '/app/builder-preview', width: 1440, height: 1000, locale: 'en', theme: 'light', action: `[aria-label="mobile"]` }, { file: 'phase6-library-desktop-rtl.png', path: '/app/library', width: 1440, height: 1000, locale: 'fa', theme: 'light' }, { file: 'phase6-library-mobile-ltr-dark.png', path: '/app/library', width: 390, height: 844, locale: 'en', theme: 'dark' }, { file: 'phase6-builder-navigator-ltr.png', path: '/app/builder-preview', width: 1440, height: 1000, locale: 'en', theme: 'light', actions: ['.block-library .panel-tabs button:nth-child(2)'] }, { file: 'phase6-builder-accessibility-rtl-dark.png', path: '/app/builder-preview', width: 1440, height: 1000, locale: 'fa', theme: 'dark', actions: ['.canvas-block', '.inspector .panel-tabs button:nth-child(2)'] }, { file: 'pre-phase7-library-cards-5cm.png', path: '/app/library', width: 1440, height: 1000, locale: 'fa', theme: 'light', action: '[aria-label="نمایش کارتی"]' }, { file: 'pre-phase7-library-rows.png', path: '/app/library', width: 1440, height: 1000, locale: 'fa', theme: 'light', action: '[aria-label="نمایش ردیفی"]' }, { file: 'pre-phase7-users-import.png', path: '/app/users', width: 1440, height: 1000, locale: 'fa', theme: 'light', action: '.page-actions .button--success' }, { file: 'pre-phase7-teams-enhanced.png', path: '/app/teams', width: 1440, height: 1000, locale: 'fa', theme: 'light' }, { file: 'pre-phase7-courses-compact.png', path: '/app/courses', width: 1440, height: 1000, locale: 'fa', theme: 'light' }, ...(reviewCourseId ? [{ file: 'pre-phase7-course-cover.png', path: `/app/courses/${reviewCourseId}`, width: 1440, height: 1000, locale: 'fa', theme: 'light' }] : []), { file: 'phase7-question-bank-rtl.png', path: '/app/question-bank', width: 1440, height: 1000, locale: 'fa', theme: 'light' }, { file: 'phase7-assessment-mapping-rtl.png', path: '/app/question-bank', width: 1440, height: 1000, locale: 'fa', theme: 'light', actions: ['.studio-tabs button:nth-child(2)', '.assessment-list > button:first-child', '.assessment-question-list .button--quiet'] }, { file: 'phase7-scenarios-mobile-dark.png', path: '/app/question-bank', width: 390, height: 844, locale: 'fa', theme: 'dark', actions: ['.studio-tabs button:nth-child(3)'] }, { file: 'phase9-learner-home-desktop-rtl.png', path: '/learn/home', width: 1280, height: 900, locale: 'fa', theme: 'light', token: learnerSession.token }, { file: 'phase9-learner-home-mobile-dark.png', path: '/learn/home', width: 390, height: 844, locale: 'fa', theme: 'dark', token: learnerSession.token }, ...(learnerAssignment ? [ { file: 'phase9-player-desktop-rtl.png', path: `/learn/player/${learnerAssignment.id}?course=${learnerAssignment.courseVersionId}&lesson=${learnerAssignment.lessonId}`, width: 1280, height: 900, locale: 'fa', theme: 'light', token: learnerSession.token }, { file: 'phase9-player-mobile-dark.png', path: `/learn/player/${learnerAssignment.id}?course=${learnerAssignment.courseVersionId}&lesson=${learnerAssignment.lessonId}`, width: 390, height: 844, locale: 'fa', theme: 'dark', token: learnerSession.token }, ] : []), { file: 'phase10-manager-overview-desktop-rtl.png', path: '/manager/overview', width: 1440, height: 1000, locale: 'fa', theme: 'light', token: managerSession.token }, { file: 'phase10-manager-overview-desktop-ltr.png', path: '/manager/overview', width: 1280, height: 900, locale: 'en', theme: 'light', token: managerSession.token }, { file: 'phase10-manager-team-tablet-dark.png', path: '/manager/team', width: 900, height: 1000, locale: 'fa', theme: 'dark', token: managerSession.token }, { file: 'phase10-manager-attention-mobile-rtl.png', path: '/manager/attention', width: 390, height: 844, locale: 'fa', theme: 'light', token: managerSession.token }, { file: 'phase10-learner-home-ios-small.png', path: '/learn/home', width: 375, height: 812, locale: 'fa', theme: 'light', token: learnerSession.token }, { file: 'phase10-learner-home-ios-desktop-ltr.png', path: '/learn/home', width: 1280, height: 900, locale: 'en', theme: 'light', token: learnerSession.token }, { file: 'phase10-learner-more-ios-dark.png', path: '/learn/more', width: 430, height: 932, locale: 'fa', theme: 'dark', token: learnerSession.token }, ...(learnerAssignment ? [{ file: 'phase10-learner-player-ios.png', path: `/learn/player/${learnerAssignment.id}?course=${learnerAssignment.courseVersionId}&lesson=${learnerAssignment.lessonId}`, width: 390, height: 844, locale: 'fa', theme: 'light', token: learnerSession.token }] : []), { file: 'phase11-monitoring-overview-desktop-rtl.png', path: '/app/monitoring', width: 1440, height: 1000, locale: 'fa', theme: 'light', wait: 5000 }, { file: 'phase11-monitoring-assessments-tablet-dark.png', path: '/app/monitoring/assessments', width: 900, height: 1000, locale: 'fa', theme: 'dark', wait: 5000 }, { file: 'phase11-monitoring-risk-mobile-rtl.png', path: '/app/monitoring/risk', width: 390, height: 844, locale: 'fa', theme: 'light', wait: 5000 }, { file: 'phase11-monitoring-skills-375-dark.png', path: '/app/monitoring/skills', width: 375, height: 812, locale: 'en', theme: 'dark', wait: 5000 }, { file: 'phase11-monitoring-overview-desktop-ltr.png', path: '/app/monitoring', width: 1280, height: 900, locale: 'en', theme: 'light', wait: 5000 }, ] for (const screen of screens.filter(screen => (!screenPrefix || screen.file.startsWith(screenPrefix)) && (!screenFiles?.length || screenFiles.includes(screen.file)))) { await call('Emulation.setDeviceMetricsOverride', { width: screen.width, height: screen.height, deviceScaleFactor: 1, mobile: screen.width < 600 }) const initialization = await call('Page.addScriptToEvaluateOnNewDocument', { source: `localStorage.setItem('microlearn.access_token', ${JSON.stringify(screen.token ?? session.token)}); localStorage.setItem('microlearn.locale', '${screen.locale}'); localStorage.setItem('microlearn.theme', '${screen.theme}')` }) await call('Runtime.evaluate', { expression: `localStorage.setItem('microlearn.access_token', ${JSON.stringify(screen.token ?? session.token)}); localStorage.setItem('microlearn.locale', '${screen.locale}'); localStorage.setItem('microlearn.theme', '${screen.theme}')` }) await call('Page.navigate', { url: `${appUrl}${screen.path}` }) await delay(300) await call('Page.reload', { ignoreCache: true }) await delay(screen.wait ?? (screen.token ? 5000 : 1600)) await call('Page.removeScriptToEvaluateOnNewDocument', { identifier: initialization.identifier }) if (screen.locale === 'en') { await call('Runtime.evaluate', { expression: `if (document.documentElement.lang !== 'en') { const direct = document.querySelector('[aria-label="تغییر زبان"]'); if (direct) direct.click(); else document.querySelector('[aria-label="بازکردن حساب کاربری"]')?.click() }` }) await delay(350) await call('Runtime.evaluate', { expression: `if (document.documentElement.lang !== 'en') document.querySelector('.learner-account-actions button:first-child')?.click()` }) await delay(350) await call('Runtime.evaluate', { expression: `document.querySelector('[aria-label="Close"]')?.click()` }) await call('Runtime.evaluate', { expression: `document.documentElement.lang = 'en'; document.documentElement.dir = 'ltr'` }) } if (screen.action) { await call('Runtime.evaluate', { expression: `document.querySelector(${JSON.stringify(screen.action)})?.click()` }) await delay(500) } for (const selector of screen.actions ?? []) { await call('Runtime.evaluate', { expression: `document.querySelector(${JSON.stringify(selector)})?.click()` }) await delay(350) } const result = await call('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }) await writeFile(join(outputDirectory, screen.file), Buffer.from(result.data, 'base64')) process.stdout.write(`${screen.file}\n`) } socket.close() } finally { chrome.kill() } async function waitForTarget() { const deadline = Date.now() + 15000 while (Date.now() < deadline) { try { const targets = await fetch(`http://127.0.0.1:${debuggingPort}/json/list`).then(response => response.json()) const page = targets.find(target => target.type === 'page') if (page) return page } catch { /* Chrome is still starting. */ } await delay(200) } throw new Error('Chrome DevTools target did not become available.') } function delay(milliseconds) { return new Promise(resolveDelay => setTimeout(resolveDelay, milliseconds)) }