diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts new file mode 100644 index 0000000..00dc2e3 --- /dev/null +++ b/scripts/screenshots.ts @@ -0,0 +1,106 @@ +import { chromium } from '@playwright/test'; +import { spawn, type ChildProcess } from 'child_process'; +import { existsSync, unlinkSync, mkdirSync } from 'fs'; +import { seed, login } from './screenshots/seed'; +import { captureDesktop, captureMobile } from './screenshots/capture'; +import { + BASE_URL, + SCREENSHOT_DB, + DESKTOP_VIEWPORT, + MOBILE_VIEWPORT, + OUTPUT_DIR +} from './screenshots/constants'; + +function cleanDatabase(): void { + for (const ext of ['', '-journal', '-wal', '-shm']) { + const path = `${SCREENSHOT_DB}${ext}`; + if (existsSync(path)) { + unlinkSync(path); + console.log(` deleted ${path}`); + } + } +} + +function startServer(): Promise { + return new Promise((resolve, reject) => { + const dir = SCREENSHOT_DB.substring(0, SCREENSHOT_DB.lastIndexOf('/')); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + + const server = spawn('pnpm', ['preview', '--port', '4173'], { + env: { ...process.env, DATABASE_URL: SCREENSHOT_DB, NODE_ENV: 'production' }, + stdio: 'pipe' + }); + + const timeout = setTimeout(() => reject(new Error('Server start timeout (30s)')), 30_000); + + function checkReady(data: Buffer) { + const text = data.toString(); + if (text.includes('localhost:4173') || text.includes('0.0.0.0:4173')) { + clearTimeout(timeout); + resolve(server); + } + } + + server.stdout?.on('data', checkReady); + server.stderr?.on('data', checkReady); + + server.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + + server.on('exit', (code) => { + clearTimeout(timeout); + if (code !== null && code !== 0) reject(new Error(`Server exited with code ${code}`)); + }); + }); +} + +async function main(): Promise { + console.log('Screenshot automation starting...\n'); + + // 1. Clean DB + console.log('Cleaning database...'); + cleanDatabase(); + + // 2. Ensure output dir exists + if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true }); + + // 3. Start preview server + console.log('Starting preview server...'); + const server = await startServer(); + console.log('Server ready.\n'); + + const browser = await chromium.launch(); + + try { + // 4. Seed data (in desktop context) + console.log('Seeding data...'); + const desktopContext = await browser.newContext({ viewport: DESKTOP_VIEWPORT }); + const desktopPage = await desktopContext.newPage(); + await seed(desktopPage); + console.log('Seed complete.\n'); + + // 5. Desktop screenshots + await captureDesktop(desktopPage); + await desktopContext.close(); + console.log(''); + + // 6. Mobile screenshots (fresh context with mobile viewport) + const mobileContext = await browser.newContext({ viewport: MOBILE_VIEWPORT }); + const mobilePage = await mobileContext.newPage(); + await login(mobilePage); + await captureMobile(mobilePage); + await mobileContext.close(); + + console.log('\nAll screenshots captured successfully!'); + } finally { + await browser.close(); + server.kill('SIGTERM'); + } +} + +main().catch((err) => { + console.error('Screenshot automation failed:', err); + process.exit(1); +}); diff --git a/scripts/screenshots/capture.ts b/scripts/screenshots/capture.ts index 33833ba..ef89156 100644 --- a/scripts/screenshots/capture.ts +++ b/scripts/screenshots/capture.ts @@ -1,4 +1,4 @@ -import type { Page } from 'playwright'; +import type { Page, Locator } from '@playwright/test'; import { join } from 'path'; import { BASE_URL, OUTPUT_DIR } from './constants'; @@ -12,6 +12,11 @@ async function waitForApp(page: Page): Promise { await page.waitForTimeout(300); } +/** Click a note card by its title (targets the h3 inside note-card to avoid content substring matches) */ +function noteCard(page: Page, title: string): Locator { + return page.locator('[data-testid="note-card"]', { has: page.locator('h3', { hasText: title }) }); +} + export async function captureDesktop(page: Page): Promise { await page.emulateMedia({ reducedMotion: 'reduce' }); console.log('Capturing desktop screenshots...'); @@ -22,7 +27,7 @@ export async function captureDesktop(page: Page): Promise { await screenshot(page, 'screenshot-grid.png'); // D2: Rich text editor — open "Self-Hosting Stack" - await page.getByText('Self-Hosting Stack').first().click(); + await noteCard(page, 'Self-Hosting Stack').click(); await page.waitForSelector('[data-testid="note-editor"]'); await waitForApp(page); await screenshot(page, 'screenshot-editor.png'); @@ -30,7 +35,7 @@ export async function captureDesktop(page: Page): Promise { await waitForApp(page); // D3: Checklist — open "Bretzel Ingredients" - await page.getByText('Bretzel Ingredients').first().click(); + await noteCard(page, 'Bretzel Ingredients').click(); await page.waitForSelector('[data-testid="note-editor"]'); await waitForApp(page); await screenshot(page, 'screenshot-checklist.png'); @@ -38,7 +43,7 @@ export async function captureDesktop(page: Page): Promise { await waitForApp(page); // D4: Sharing dialog — open "Weekend Plans", click share - await page.getByText('Weekend Plans').first().click(); + await noteCard(page, 'Weekend Plans').click(); await page.waitForSelector('[data-testid="note-editor"]'); await page.getByTestId('share-toggle').click(); await page.waitForSelector('[data-testid="share-dialog"]'); @@ -49,21 +54,26 @@ export async function captureDesktop(page: Page): Promise { await waitForApp(page); // D5: Version history — open "Self-Hosting Stack", click history - await page.getByText('Self-Hosting Stack').first().click(); + await noteCard(page, 'Self-Hosting Stack').click(); await page.waitForSelector('[data-testid="note-editor"]'); await page.getByTestId('history-toggle').click(); await page.waitForSelector('[data-testid="history-panel"]'); + // Wait for version items to load from API + await page.waitForSelector('[data-testid="version-item"]', { timeout: 10_000 }); const versionItems = page.getByTestId('version-item'); if ((await versionItems.count()) > 1) { await versionItems.nth(1).click(); } await waitForApp(page); await screenshot(page, 'screenshot-history.png'); + // Close history panel first (overlay blocks close-editor-btn) + await page.getByTestId('close-history-btn').click(); + await waitForApp(page); await page.getByTestId('close-editor-btn').click(); await waitForApp(page); // D6: Attachments — open "Bretzel Ingredients" (has featured bretzel image) - await page.getByText('Bretzel Ingredients').first().click(); + await noteCard(page, 'Bretzel Ingredients').click(); await page.waitForSelector('[data-testid="note-editor"]'); await page.getByTestId('image-toggle').click(); await page.waitForSelector('[data-testid="image-upload"]'); @@ -88,7 +98,7 @@ export async function captureMobile(page: Page): Promise { await screenshot(page, 'screenshot-mobile-grid.png'); // M2: Mobile editor — open "Self-Hosting Stack" - await page.getByText('Self-Hosting Stack').first().click(); + await noteCard(page, 'Self-Hosting Stack').click(); await page.waitForSelector('[data-testid="note-editor"]'); await waitForApp(page); await screenshot(page, 'screenshot-mobile-editor.png'); @@ -96,7 +106,7 @@ export async function captureMobile(page: Page): Promise { await waitForApp(page); // M3: Mobile checklist — open "Bretzel Ingredients" - await page.getByText('Bretzel Ingredients').first().click(); + await noteCard(page, 'Bretzel Ingredients').click(); await page.waitForSelector('[data-testid="note-editor"]'); await waitForApp(page); await screenshot(page, 'screenshot-mobile-checklist.png'); diff --git a/scripts/screenshots/seed.ts b/scripts/screenshots/seed.ts index ea172bd..cce8f01 100644 --- a/scripts/screenshots/seed.ts +++ b/scripts/screenshots/seed.ts @@ -1,4 +1,4 @@ -import type { Page } from 'playwright'; +import type { Page } from '@playwright/test'; import { generateBretzelPng, generateSaltShakerPng } from './bretzel-pixel'; import { BASE_URL, ADMIN, COLLABORATOR } from './constants'; @@ -24,6 +24,7 @@ async function uploadAttachment( featured: boolean ): Promise { const res = await page.request.post(`${BASE_URL}/api/notes/${noteId}/attachments`, { + headers: { Origin: BASE_URL }, multipart: { file: { name: filename,