Plan UI premium 6–8 semaines (design system, shell, Storybook, a11y): - Design system: DESIGN_TOKENS.md, APP_SHELL.md, FULL_LAYOUT_PAGE.md. Single source for layout/shell (index.css), shadows (design-system.css), durations/easing. - Tokens: shadow-cover-depth, shadow-gold-glow, shadow-fab-glow; layout max-height (max-h-layout-drawer, max-h-layout-panel, max-h-layout-list). All duration-200/300/500 replaced by --duration-fast/normal/slow. Arbitrary shadows replaced by token classes. - Shell & player: Sidebar, Header, GlobalPlayer, MiniPlayer, PlayerQueue, PlayerControls, AudioPlayer use tokens; focus-visible on Sidebar, PlayerQueue, DropdownMenuTrigger/Item, TabsTrigger. Typography: text-[10px]/[9px] → text-xs where applicable. - ESLint: no-restricted-syntax (warn) for w-/h-/rounded-/shadow-/text-/spacing arbitrary. - Scripts: report-arbitrary-values.mjs, capture/compare/generate visual; visual-complete.spec.ts. - Stories full layout: Dashboard, Playlists, Library, Settings, Profile in DashboardLayout.stories. - .cursorrules + README: DESIGN_TOKENS, APP_SHELL, visual commands, no arbitrary without justification. - apps/web/.gitignore: e2e test artifacts (test-results-visual, playwright-report-visual). Co-authored-by: Cursor <cursoragent@cursor.com>
171 lines
6.6 KiB
TypeScript
171 lines
6.6 KiB
TypeScript
/**
|
|
* Suite complète de capture visuelle pour régression pixel-perfect.
|
|
*
|
|
* - Boucle sur URLs critiques (Login, Dashboard, Playlists, etc.)
|
|
* - Auth via storageState pour pages protégées ; pas d'auth pour login/register
|
|
* - Full page + screenshots ciblés (Sidebar, Player, Header)
|
|
* - waitForStableNetwork, masquage éléments dynamiques (dates, avatars)
|
|
* - Nommage : {screen-name}-desktop-dark.png
|
|
*
|
|
* Sortie : visual-tests/current/ (visual:capture) ou visual-tests/baselines/ (visual:update)
|
|
*/
|
|
|
|
import { test } from '@playwright/test';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { visualOutputDir, screenshotName } from '../playwright.config.visual';
|
|
|
|
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || process.env.VITE_FRONTEND_URL || 'http://localhost:5173';
|
|
const ANIMATION_SETTLE_MS = 800;
|
|
const NETWORK_IDLE_MS = 500;
|
|
|
|
/** Désactive les animations/transitions CSS pour captures stables */
|
|
async function disableAnimations(page: import('@playwright/test').Page) {
|
|
await page.addStyleTag({
|
|
content: `
|
|
*, *::before, *::after {
|
|
animation-duration: 0s !important;
|
|
animation-delay: 0s !important;
|
|
transition-duration: 0s !important;
|
|
transition-delay: 0s !important;
|
|
}
|
|
`,
|
|
});
|
|
}
|
|
|
|
/** Force le thème sombre sur le document */
|
|
async function ensureDarkTheme(page: import('@playwright/test').Page) {
|
|
await page.evaluate(() => {
|
|
document.documentElement.classList.add('dark');
|
|
document.documentElement.setAttribute('data-theme', 'dark');
|
|
});
|
|
await page.waitForTimeout(100);
|
|
}
|
|
|
|
/** Attend réseau inactif puis un court délai pour éviter skeletons / images en cours de chargement */
|
|
async function waitForStableNetwork(page: import('@playwright/test').Page) {
|
|
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
|
|
await page.waitForTimeout(NETWORK_IDLE_MS);
|
|
}
|
|
|
|
/** Locators des éléments dynamiques à masquer (dates, avatars, temps) */
|
|
async function getDynamicMasks(page: import('@playwright/test').Page): Promise<import('@playwright/test').Locator[]> {
|
|
const candidates = [
|
|
page.locator('img[alt="Avatar"], img[alt*="avatar"]').first(),
|
|
page.locator('[role="timer"]').first(),
|
|
page.locator('time').first(),
|
|
];
|
|
const out: import('@playwright/test').Locator[] = [];
|
|
for (const loc of candidates) {
|
|
if ((await loc.count()) > 0) out.push(loc);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Écrit un screenshot dans visual-tests/current ou baselines */
|
|
async function saveScreenshot(
|
|
page: import('@playwright/test').Page,
|
|
name: string,
|
|
options: { fullPage?: boolean; locator?: import('@playwright/test').Locator } = {}
|
|
) {
|
|
fs.mkdirSync(visualOutputDir, { recursive: true });
|
|
const filePath = path.join(visualOutputDir, screenshotName(name));
|
|
const mask = await getDynamicMasks(page);
|
|
const screenshotOpts = { path: filePath, mask: mask.length > 0 ? mask : undefined };
|
|
|
|
if (options.locator) {
|
|
await options.locator.screenshot(screenshotOpts);
|
|
} else {
|
|
await page.screenshot({ fullPage: options.fullPage ?? true, ...screenshotOpts });
|
|
}
|
|
test.info().attach(name, { path: filePath, contentType: 'image/png' });
|
|
}
|
|
|
|
const SCREENS: Array<{
|
|
name: string;
|
|
url: string;
|
|
auth: boolean;
|
|
full: boolean;
|
|
locator?: string;
|
|
}> = [
|
|
{ name: 'login', url: '/login', auth: false, full: true },
|
|
{ name: 'register', url: '/register', auth: false, full: true },
|
|
{ name: 'dashboard', url: '/dashboard', auth: true, full: true },
|
|
{ name: 'playlists', url: '/playlists', auth: true, full: true },
|
|
{ name: 'library', url: '/library', auth: true, full: true },
|
|
{ name: '404', url: '/non-existent-route-404', auth: false, full: true },
|
|
];
|
|
|
|
const COMPONENT_CAPTURES: Array<{ name: string; url: string; locator: string }> = [
|
|
{ name: 'sidebar', url: '/dashboard', locator: '[data-testid="app-sidebar"]' },
|
|
{ name: 'header', url: '/dashboard', locator: 'header' },
|
|
{ name: 'player', url: '/dashboard', locator: '[data-testid="global-player"]' },
|
|
];
|
|
|
|
test.describe('Visual capture (complete)', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: 'dark' });
|
|
});
|
|
|
|
test.describe('Full-page screens (no auth)', () => {
|
|
test.use({ storageState: { cookies: [], origins: [] } });
|
|
for (const screen of SCREENS.filter((s) => !s.auth)) {
|
|
test(`${screen.name}`, async ({ page }) => {
|
|
const url = BASE_URL.replace(/\/$/, '') + screen.url;
|
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
await waitForStableNetwork(page);
|
|
await page.waitForSelector('body', { timeout: 8000 }).catch(() => {});
|
|
|
|
await disableAnimations(page);
|
|
await ensureDarkTheme(page);
|
|
await page.waitForTimeout(ANIMATION_SETTLE_MS);
|
|
|
|
await saveScreenshot(page, screen.name, { fullPage: true });
|
|
});
|
|
}
|
|
});
|
|
|
|
test.describe('Full-page screens (authenticated)', () => {
|
|
for (const screen of SCREENS.filter((s) => s.auth)) {
|
|
test(`${screen.name}`, async ({ page }) => {
|
|
const url = BASE_URL.replace(/\/$/, '') + screen.url;
|
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
await waitForStableNetwork(page);
|
|
await page.waitForSelector('body', { timeout: 8000 }).catch(() => {});
|
|
|
|
await disableAnimations(page);
|
|
await ensureDarkTheme(page);
|
|
await page.waitForTimeout(ANIMATION_SETTLE_MS);
|
|
|
|
await saveScreenshot(page, screen.name, { fullPage: true });
|
|
});
|
|
}
|
|
});
|
|
|
|
test.describe('Component screenshots (authenticated)', () => {
|
|
for (const comp of COMPONENT_CAPTURES) {
|
|
test(comp.name, async ({ page }) => {
|
|
const url = BASE_URL.replace(/\/$/, '') + comp.url;
|
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
await waitForStableNetwork(page);
|
|
|
|
const loc = page.locator(comp.locator).first();
|
|
const visible = await loc.waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
|
|
if (!visible) {
|
|
test.skip(true, `${comp.name} locator not visible`);
|
|
return;
|
|
}
|
|
|
|
await disableAnimations(page);
|
|
await ensureDarkTheme(page);
|
|
await page.waitForTimeout(ANIMATION_SETTLE_MS);
|
|
|
|
const mask = await getDynamicMasks(page);
|
|
fs.mkdirSync(visualOutputDir, { recursive: true });
|
|
const filePath = path.join(visualOutputDir, screenshotName(comp.name));
|
|
await loc.screenshot({ path: filePath, mask: mask.length > 0 ? mask : undefined });
|
|
test.info().attach(comp.name, { path: filePath, contentType: 'image/png' });
|
|
});
|
|
}
|
|
});
|
|
});
|