forked from mirror/crumbs-original
feat(screenshots): add main orchestrator and fix capture selectors
- Use @playwright/test instead of playwright (matches devDependencies) - Use card-specific locators to avoid substring content matches - Close history panel before editor to avoid overlay blocking - Add Origin header for multipart upload CSRF compliance Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
82a9b4fa64
commit
92c2cf7952
@@ -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<ChildProcess> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
console.log('Capturing desktop screenshots...');
|
||||
@@ -22,7 +27,7 @@ export async function captureDesktop(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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');
|
||||
|
||||
@@ -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<void> {
|
||||
const res = await page.request.post(`${BASE_URL}/api/notes/${noteId}/attachments`, {
|
||||
headers: { Origin: BASE_URL },
|
||||
multipart: {
|
||||
file: {
|
||||
name: filename,
|
||||
|
||||
Reference in New Issue
Block a user