veza/apps/web/e2e/visual-complete.spec.ts
senke 39b2b642d2 feat(web): UI premium Discord/Spotify-like — tokens, shadows, focus, layout
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>
2026-02-08 17:15:58 +01:00

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' });
});
}
});
});