video-accessibility/tools/capture-help-screenshots.ts
Vadym Samoilenko 6559ccc1f9 feat(help): in-app role-based help guides + screenshot capture pipeline
- Help.tsx: role tabs, TOC scroll-spy, search, lightbox, react-markdown renderer
- 7 markdown guides (global, client, linguist, reviewer, production, PM, admin)
  with explicit click/drag/keyboard annotations throughout
- Sidebar: Help button added at bottom of nav (all roles)
- App.tsx: /help route, no RoleGate
- frontend/public/help-screenshots/{role}/: directories ready for screenshots
- tools/capture-help-screenshots.ts: Playwright screenshot script
  - Clicks "Local login" toggle before filling credentials
  - Uses test-admin local account (not SSO)
- backend/scripts/seed_test_users.py: idempotent MongoDB seed script
  creates 6 local-auth users (admin + 5 roles) for capture + local dev
- .env.screenshots.example: template with test-admin credentials
- Removes docs/video_accessibility_user_guide_v3.md (superseded by in-app guides)
- Deps: react-markdown, remark-gfm, rehype-raw added to frontend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 13:08:13 +01:00

370 lines
13 KiB
TypeScript

/**
* Screenshot capture script for the Help guides.
*
* Usage (run from frontend/ directory — needs frontend/node_modules/@playwright/test):
* cd frontend && npx tsx ../tools/capture-help-screenshots.ts
*
* Requires a .env.screenshots file at the repo root (see .env.screenshots.example).
* Before running, seed test users on optical-dev:
* docker compose exec backend python scripts/seed_test_users.py
*/
import { chromium, type Page, type BrowserContext } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import * as dotenv from 'dotenv';
// Load credentials from .env.screenshots
dotenv.config({ path: path.join(__dirname, '../.env.screenshots') });
const BASE_URL = process.env.BASE_URL || 'https://optical-dev.oliver.solutions/video-accessibility';
const OUT_DIR = path.join(__dirname, '../frontend/public/help-screenshots');
// Ensure output directories exist
const ROLES = ['global', 'client', 'linguist', 'reviewer', 'production', 'project-manager', 'admin'];
for (const role of ROLES) {
fs.mkdirSync(path.join(OUT_DIR, role), { recursive: true });
}
interface AnnotationRect {
x: number;
y: number;
width: number;
height: number;
label?: string;
}
async function annotateAndScreenshot(
page: Page,
outputPath: string,
annotations: AnnotationRect[] = []
) {
if (annotations.length > 0) {
// Inject red rectangles as overlay divs
await page.evaluate((rects) => {
for (const rect of rects) {
const div = document.createElement('div');
div.style.cssText = `
position: fixed;
left: ${rect.x}px;
top: ${rect.y}px;
width: ${rect.width}px;
height: ${rect.height}px;
border: 3px solid #EF4444;
border-radius: 4px;
z-index: 99999;
pointer-events: none;
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.3);
`;
if (rect.label) {
const label = document.createElement('span');
label.textContent = rect.label;
label.style.cssText = `
position: absolute;
top: -22px;
left: 0;
background: #EF4444;
color: white;
font-size: 11px;
font-weight: bold;
padding: 2px 6px;
border-radius: 3px;
white-space: nowrap;
font-family: system-ui, sans-serif;
`;
div.appendChild(label);
}
document.body.appendChild(div);
}
}, annotations);
}
await page.screenshot({ path: outputPath, fullPage: false });
// Clean up overlays
if (annotations.length > 0) {
await page.evaluate(() => {
document.querySelectorAll('[data-annotation-overlay]').forEach(el => el.remove());
});
}
console.log(`${path.relative(OUT_DIR, outputPath)}`);
}
async function getBBox(page: Page, selector: string): Promise<AnnotationRect | null> {
const el = page.locator(selector).first();
try {
const bb = await el.boundingBox({ timeout: 3000 });
if (!bb) return null;
return { x: bb.x, y: bb.y, width: bb.width, height: bb.height };
} catch {
return null;
}
}
async function login(page: Page, email: string, password: string) {
await page.goto(`${BASE_URL}/login`);
await page.waitForLoadState('networkidle');
// The login page defaults to Microsoft SSO view.
// Click "Local login" to reveal the email/password form.
try {
const toggle = page.getByRole('button', { name: /local login/i });
await toggle.click({ timeout: 3000 });
await page.waitForSelector('input[type="email"]', { timeout: 3000 });
} catch {
// Toggle absent (form already visible or different build) — proceed
}
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', password);
await page.click('button[type="submit"]');
await page.waitForURL(url => !url.toString().includes('/login'), { timeout: 10000 });
await page.waitForLoadState('networkidle');
}
async function captureGlobal(context: BrowserContext) {
console.log('\n📸 Global screenshots...');
const page = await context.newPage();
// 01 — Login screen
await page.goto(`${BASE_URL}/login`);
await page.waitForLoadState('networkidle');
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/01-login.png'));
// 02 — Email field highlighted
const emailBB = await getBBox(page, 'input[type="email"]');
if (emailBB) {
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/02-login-email.png'), [
{ ...emailBB, label: 'Left-click here to type email' }
]);
}
// Login as test-admin to capture authenticated global screenshots
await login(page, process.env.TEST_ADMIN_EMAIL!, process.env.TEST_ADMIN_PASSWORD!);
// 03 — Interface overview
await page.goto(`${BASE_URL}/`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/04-interface-overview.png'));
// 04 — Sidebar
const sidebarBB = await getBBox(page, 'nav');
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/05-sidebar.png'));
// 05 — Navbar
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/06-navbar.png'));
// 06 — Notifications (click bell)
const bell = page.locator('button[aria-label*="notification"], button:has-text("🔔")').first();
try {
await bell.click({ timeout: 3000 });
await page.waitForTimeout(300);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/07-notifications.png'));
await page.keyboard.press('Escape');
} catch {
// Bell might not exist or be differently structured
}
await page.close();
}
async function captureClient(context: BrowserContext) {
console.log('\n📸 Client screenshots...');
const page = await context.newPage();
await login(page, process.env.TEST_CLIENT_EMAIL!, process.env.TEST_CLIENT_PASSWORD!);
// Dashboard
await page.goto(`${BASE_URL}/`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'client/01-dashboard.png'));
// New Job page
await page.goto(`${BASE_URL}/jobs/new`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'client/02-new-job.png'));
// Drop zone
const dropzoneBB = await getBBox(page, '[data-testid="upload-dropzone"], .dropzone, input[type="file"]');
if (dropzoneBB) {
await annotateAndScreenshot(page, path.join(OUT_DIR, 'client/03-upload-dropzone.png'), [
{ ...dropzoneBB, label: 'Drag file here or left-click to browse' }
]);
}
// Jobs list
await page.goto(`${BASE_URL}/jobs`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'client/12-jobs-list.png'));
// Briefs list
await page.goto(`${BASE_URL}/briefs`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'client/16-new-brief.png'));
await page.close();
}
async function captureLinguist(context: BrowserContext) {
console.log('\n📸 Linguist screenshots...');
const page = await context.newPage();
await login(page, process.env.TEST_LINGUIST_EMAIL!, process.env.TEST_LINGUIST_PASSWORD!);
// Dashboard
await page.goto(`${BASE_URL}/`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'linguist/01-dashboard.png'));
// QC Queue
await page.goto(`${BASE_URL}/qc/queue`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'linguist/02-qc-queue.png'));
await page.close();
}
async function captureReviewer(context: BrowserContext) {
console.log('\n📸 Reviewer screenshots...');
const page = await context.newPage();
await login(page, process.env.TEST_REVIEWER_EMAIL!, process.env.TEST_REVIEWER_PASSWORD!);
await page.goto(`${BASE_URL}/`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'reviewer/01-dashboard.png'));
await page.goto(`${BASE_URL}/qc/queue`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'reviewer/02-qc-queue.png'));
await page.goto(`${BASE_URL}/qc/reviewer-queue`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'reviewer/03-reviewer-queue.png'));
await page.goto(`${BASE_URL}/admin/final`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'reviewer/08-final-list.png'));
await page.close();
}
async function captureProduction(context: BrowserContext) {
console.log('\n📸 Production screenshots...');
const page = await context.newPage();
await login(page, process.env.TEST_PRODUCTION_EMAIL!, process.env.TEST_PRODUCTION_PASSWORD!);
await page.goto(`${BASE_URL}/`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'production/01-dashboard.png'));
await page.goto(`${BASE_URL}/jobs`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'production/02-jobs-list-bulk.png'));
await page.goto(`${BASE_URL}/admin/failures`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'production/05-failures.png'));
await page.goto(`${BASE_URL}/admin/audit-log`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'production/06-audit-log.png'));
await page.close();
}
async function captureProjectManager(context: BrowserContext) {
console.log('\n📸 Project Manager screenshots...');
const page = await context.newPage();
await login(page, process.env.TEST_PM_EMAIL!, process.env.TEST_PM_PASSWORD!);
await page.goto(`${BASE_URL}/`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'project-manager/01-dashboard.png'));
await page.goto(`${BASE_URL}/admin/final`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'project-manager/02-final-list.png'));
await page.goto(`${BASE_URL}/admin/clients`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'project-manager/05-clients.png'));
await page.close();
}
async function captureAdmin(context: BrowserContext) {
console.log('\n📸 Admin screenshots...');
const page = await context.newPage();
await login(page, process.env.TEST_ADMIN_EMAIL!, process.env.TEST_ADMIN_PASSWORD!);
await page.goto(`${BASE_URL}/`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'admin/01-dashboard.png'));
// User Management
await page.goto(`${BASE_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'admin/02-user-list.png'));
// Create User modal
const createBtn = page.getByRole('button', { name: /create user/i });
try {
await createBtn.click({ timeout: 3000 });
await page.waitForTimeout(400);
await annotateAndScreenshot(page, path.join(OUT_DIR, 'admin/03-create-user-modal.png'));
await page.keyboard.press('Escape');
} catch { /* modal may not have opened */ }
await page.close();
}
async function main() {
console.log('🚀 Capturing help screenshots...');
console.log(` Target: ${BASE_URL}`);
console.log(` Output: ${OUT_DIR}\n`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
});
try {
await captureGlobal(context);
await captureClient(context);
await captureLinguist(context);
await captureReviewer(context);
await captureProduction(context);
await captureProjectManager(context);
await captureAdmin(context);
} finally {
await context.close();
await browser.close();
}
console.log('\n✅ All screenshots captured!');
}
main().catch(err => {
console.error('Screenshot capture failed:', err);
process.exit(1);
});