// Captures the page checklist in light/dark × desktop/mobile into ../screenshots/. // Usage: node screenshots.mjs [filter] e.g. `node screenshots.mjs issue` for pages whose name contains "issue". // Needs the dev server with seed data (see README). import { chromium } from 'playwright'; import { mkdir } from 'node:fs/promises'; const BASE = process.env.REDMINE_URL ?? 'http://127.0.0.1:3070'; const OUT = new URL('../screenshots/', import.meta.url).pathname; const filter = process.argv[2] ?? ''; const pages = [ ['my-page', '/my/page'], ['projects-board', '/projects?display_type=board'], ['projects-list', '/projects?display_type=list'], ['project-overview', '/projects/apollo'], ['issues-grouped', '/projects/apollo/issues?set_filter=1&f[]=status_id&op[status_id]=*&group_by=status&t[]=estimated_hours&t[]=spent_hours'], ['issue-show', '/issues/13'], ['issue-new', '/projects/apollo/issues/new'], ['issue-edit', '/issues/13/edit'], ['issue-bulk-edit', '/issues/bulk_edit?ids[]=13&ids[]=17'], ['gantt', '/projects/apollo/issues/gantt'], ['calendar', '/projects/apollo/issues/calendar'], ['roadmap', '/projects/apollo/roadmap'], ['version', '/versions/2'], ['activity', '/projects/apollo/activity'], ['wiki', '/projects/apollo/wiki'], ['wiki-edit', '/projects/apollo/wiki/Wiki/edit'], ['news', '/news/1'], ['forum-topic', '/boards/1/topics/1'], ['documents', '/projects/apollo/documents'], ['files', '/projects/apollo/files'], ['time-entries', '/projects/apollo/time_entries'], ['time-report', '/projects/apollo/time_entries/report?criteria[]=user&columns=month'], ['search', '/search?q=login'], ['my-account', '/my/account'], ['admin', '/admin'], ['settings', '/settings'], ['users', '/users'], ['permissions', '/roles/permissions'], ['workflow', '/workflows/edit?role_id=3&tracker_id=1'], ['not-found', '/issues/99999'], ['repository', '/projects/apollo/repository'], ['repository-revisions', '/projects/apollo/repository/portal/revisions'], ['repository-entry', '/projects/apollo/repository/portal/entry/lib/portal.rb'], ['repository-annotate', '/projects/apollo/repository/portal/annotate/lib/portal.rb'], ['repository-diff', '/projects/apollo/repository/portal/revisions/main/diff'], ['wiki-history', '/projects/apollo/wiki/Wiki/history'], ['wiki-diff', '/projects/apollo/wiki/Wiki/diff?version=2&version_from=1'], ['project-settings', '/projects/apollo/settings'], ['project-settings-members', '/projects/apollo/settings/members'], ['project-settings-versions', '/projects/apollo/settings/versions'], ['project-settings-repositories', '/projects/apollo/settings/repositories'], ['trackers', '/trackers'], ['issue-statuses', '/issue_statuses'], ['custom-fields', '/custom_fields'], ['custom-field-edit', '/custom_fields/1/edit'], ['enumerations', '/enumerations'], ['admin-info', '/admin/info'], ['admin-plugins', '/admin/plugins'], ['workflow-fields', '/workflows/permissions?role_id=3&tracker_id=1'], ['forums', '/projects/apollo/boards'], ]; // Pages only reachable while logged out. const publicPages = [ ['login', '/login'], ['register', '/account/register'], ['lost-password', '/account/lost_password'], ]; // Pages that need an interaction before the capture. const interactions = [ ['issue-new-error', '/projects/apollo/issues/new', async (p) => { await p.click('#issue-form input[name=commit]'); await p.waitForLoadState('networkidle'); }], ['issues-context-menu', '/projects/apollo/issues', async (p) => { await p.click('table.list.issues tr.issue td.status', { button: 'right' }); await p.waitForSelector('#context-menu ul'); }, { desktopOnly: true }], ['journal-diff', '/issues/13', async (p) => { // The journal ID differs per database, so follow the first description diff link. await Promise.all([p.waitForURL(/\/journals\/\d+\/diff/), p.click('#history a[href*="/diff"]')]); await p.waitForLoadState('networkidle'); }], ['import-mapping', '/issues/imports/new?project_id=apollo', async (p) => { await p.setInputFiles('#file', new URL('fixtures/issues.csv', import.meta.url).pathname); await Promise.all([p.waitForURL(/\/imports\/[^/]+\/settings/), p.click('input[type=submit]')]); await Promise.all([p.waitForURL(/\/imports\/[^/]+\/mapping/), p.click('input[type=submit]')]); await p.waitForLoadState('networkidle'); }], ['project-jump', '/projects/apollo-api/issues', async (p) => { await p.click('#project-jump .drdn-trigger'); await p.waitForSelector('#project-jump .drdn-items'); }, { desktopOnly: true }], ['mobile-flyout', '/projects/apollo/issues', async (p) => { await p.click('.mobile-toggle-button'); await p.waitForTimeout(300); }, { mobileOnly: true, viewportOnly: true }], ]; const viewports = { desktop: { width: 1440, height: 900 }, mobile: { width: 390, height: 844, isMobile: true, hasTouch: true }, }; await mkdir(OUT, { recursive: true }); const browser = await chromium.launch(); for (const scheme of ['light', 'dark']) { for (const [vpName, vp] of Object.entries(viewports)) { const { width, height, ...rest } = vp; const context = await browser.newContext({ viewport: { width, height }, colorScheme: scheme, ...rest }); const page = await context.newPage(); const shot = (name, fullPage = true) => page.screenshot({ path: `${OUT}${name}-${scheme}-${vpName}.png`, fullPage }); for (const [name, path] of publicPages.filter(([n]) => n.includes(filter))) { await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' }); await shot(name); } await page.goto(`${BASE}/login`); await page.fill('#username', 'admin'); await page.fill('#password', 'admin'); await page.click('#login-submit'); await page.waitForURL((url) => !url.pathname.startsWith('/login')); for (const [name, path] of pages.filter(([n]) => n.includes(filter))) { await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' }); await shot(name); } for (const [name, path, act, opts = {}] of interactions.filter(([n]) => n.includes(filter))) { if ((opts.desktopOnly && vpName !== 'desktop') || (opts.mobileOnly && vpName !== 'mobile')) continue; await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' }); await act(page); await shot(name, !opts.viewportOnly); } await context.close(); } } await browser.close(); console.log(`Screenshots written to ${OUT}`);