diff --git a/docs/superpowers/plans/2026-05-15-lighthouse-optimization-plan.md b/docs/superpowers/plans/2026-05-15-lighthouse-optimization-plan.md new file mode 100644 index 0000000..7caffed --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-lighthouse-optimization-plan.md @@ -0,0 +1,1292 @@ +# Lighthouse Optimization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Optimize site loading to achieve ~100 Lighthouse score through image resizing, lazy loading, cache headers, code splitting, and meta tags. + +**Architecture:** Server-side image resizing via sharp (on-demand AVIF/WebP generation with disk cache), client `` component with srcset/sizes, route-level lazy loading for admin/me pages, Vite manual chunks for vendor splitting. + +**Tech Stack:** sharp (server), React + MUI (client), Vite, Fastify + +--- + +### Task 1: Install sharp dependency + +**Files:** +- Modify: `server/package.json` + +- [ ] **Step 1: Add sharp to server dependencies** + +Run: `npm install sharp` in `server/` directory + +```bash +cd /mnt/d/my_projects/shop/server && npm install sharp +``` + +Expected: sharp added to package.json dependencies, node_modules updated. + +--- + +### Task 2: Server image resize library + +**Files:** +- Create: `server/src/lib/image-resize.js` + +- [ ] **Step 1: Create the image resize library** + +```js +// server/src/lib/image-resize.js +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +const UPLOADS_DIR = path.join(process.cwd(), 'uploads') +const CACHE_DIR = path.join(UPLOADS_DIR, '.cache') +const VALID_WIDTHS = [320, 640, 1024, 1600] +const SUPPORTED_FORMATS = new Set(['avif', 'webp']) + +/** + * Find the original file by UUID (without extension) in the uploads directory tree. + * Searches both /uploads/ and /uploads/reviews/. + * Returns full path or null. + */ +export async function findOriginalFile(uuid, subdir = '') { + const searchDirs = subdir ? [subdir] : ['', 'reviews'] + for (const dir of searchDirs) { + for (const ext of ['.png', '.jpg', '.jpeg', '.webp']) { + const fullPath = path.join(UPLOADS_DIR, dir, `${uuid}${ext}`) + try { + await fs.promises.access(fullPath) + return fullPath + } catch { + // file not found with this extension, try next + } + } + } + return null +} + +/** + * Get or generate a resized image. Returns { path: string, isNew: boolean }. + */ +export async function getOrCreateResized(uuid, width, format, subdir = '') { + const cacheSubdir = subdir ? subdir : '' + const cacheFileName = `${uuid}_w${width}.${format}` + const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName) + + try { + await fs.promises.access(cachePath) + return { path: cachePath, isNew: false } + } catch { + // cache miss, need to generate + } + + const originalPath = await findOriginalFile(uuid, subdir) + if (!originalPath) { + return null + } + + await fs.promises.mkdir(path.dirname(cachePath), { recursive: true }) + + const sharp = (await import('sharp')).default + let pipeline = sharp(originalPath) + + if (width) { + pipeline = pipeline.resize(width, null, { withoutEnlargement: true }) + } + + const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 } + await pipeline[format](options).toFile(cachePath) + + return { path: cachePath, isNew: true } +} + +export { VALID_WIDTHS, SUPPORTED_FORMATS } +``` + +--- + +### Task 3: Server route for resized images + +**Files:** +- Create: `server/src/routes/uploads-resized.js` +- Modify: `server/src/index.js` + +- [ ] **Step 1: Create the uploads-resized route** + +```js +// server/src/routes/uploads-resized.js +import fs from 'node:fs' +import path from 'node:path' +import { findOriginalFile, getOrCreateResized, SUPPORTED_FORMATS, VALID_WIDTHS } from '../lib/image-resize.js' + +const CACHE_CONTROL_IMMUTABLE = 'public, max-age=31536000, immutable' +const CACHE_CONTROL_SHORT = 'public, max-age=86400' + +/** + * Register GET /uploads-resized/* route for on-demand image resizing. + * Must be registered BEFORE fastify-static for /uploads/. + */ +export function registerUploadsResized(fastify) { + fastify.get('/uploads-resized/*', async (request, reply) => { + const rawPath = request.params['*'] + const url = new URL(request.url, 'http://localhost') + const widthParam = url.searchParams.get('w') + + // Parse: [subdir/]filename.format + const parts = rawPath.split('/') + let filename, subdir = '' + + if (parts.length > 1) { + subdir = parts.slice(0, -1).join('/') + '/' + filename = parts[parts.length - 1] + } else { + filename = parts[0] + } + + const dotIdx = filename.lastIndexOf('.') + if (dotIdx === -1) { + return reply.code(400).send({ error: 'Invalid request: no format specified' }) + } + + const uuid = filename.slice(0, dotIdx) + const format = filename.slice(dotIdx + 1).toLowerCase() + + if (!SUPPORTED_FORMATS.has(format)) { + return reply.code(400).send({ error: `Unsupported format: ${format}. Use avif or webp.` }) + } + + // Validate width + let width = null + if (widthParam) { + const w = parseInt(widthParam, 10) + if (!VALID_WIDTHS.includes(w)) { + return reply.code(400).send({ error: `Invalid width: ${widthParam}. Use: ${VALID_WIDTHS.join(', ')}` }) + } + width = w + } + + // If no width requested, serve original with short cache + if (!width) { + const originalPath = await findOriginalFile(uuid, subdir || undefined) + if (!originalPath) { + return reply.code(404).send({ error: 'Image not found' }) + } + reply.header('Cache-Control', CACHE_CONTROL_SHORT) + reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp') + return reply.send(fs.createReadStream(originalPath)) + } + + const result = await getOrCreateResized(uuid, width, format, subdir || undefined) + if (!result) { + return reply.code(404).send({ error: 'Image not found' }) + } + + reply.header('Cache-Control', CACHE_CONTROL_IMMUTABLE) + reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp') + return reply.send(fs.createReadStream(result.path)) + }) +} +``` + +- [ ] **Step 2: Register the route in server/src/index.js** + +Read `server/src/index.js` and add the import + registration. Insert the route registration BEFORE `fastifyStatic` registration. + +```js +// Add import at top +import { registerUploadsResized } from './routes/uploads-resized.js' + +// Add BEFORE the fastifyStatic registration (line ~49): +registerUploadsResized(fastify) +``` + +The import should go after the existing imports, and the `registerUploadsResized(fastify)` call should be placed right before `const uploadsDir = path.join(process.cwd(), 'uploads')`. + +- [ ] **Step 3: Add Cache-Control headers to fastify-static for /uploads/** + +Modify the fastify-static registration in `server/src/index.js` to add cache headers: + +```js +await fastify.register(fastifyStatic, { + root: uploadsDir, + prefix: '/uploads/', + setHeaders(res, filePath) { + // .cache files get immutable headers + if (filePath.includes('/.cache/')) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + } else { + res.setHeader('Cache-Control', 'public, max-age=86400') + } + }, +}) +``` + +--- + +### Task 4: Separate review images into /uploads/reviews/ + +**Files:** +- Modify: `server/src/lib/upload-images.js` +- Modify: `server/src/routes/api/public-reviews.js` + +- [ ] **Step 1: Update saveImageBufferToUploads to accept subdir** + +Modify `server/src/lib/upload-images.js`: + +```js +/** Сохранить один буфер изображения в uploads/, вернуть путь `/uploads/...`. */ +export async function saveImageBufferToUploads(originalFilename, buffer, subdir = '') { + const ext = safeImageExt(originalFilename) + if (!ext) { + throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp') + } + + const uploadsDir = path.join(process.cwd(), 'uploads') + const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir + await fs.promises.mkdir(targetDir, { recursive: true }) + + const fileName = `${crypto.randomUUID()}${ext}` + const fullPath = path.join(targetDir, fileName) + await fs.promises.writeFile(fullPath, buffer) + return subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}` +} +``` + +- [ ] **Step 2: Update review upload to use /reviews/ subdir** + +Modify `server/src/routes/api/public-reviews.js`, the `/api/reviews/upload-image` handler: + +```js +// Change the persistMultipartImages call to use a custom save function +// that puts files in /uploads/reviews/ + +// Replace the existing handler with: +fastify.post( + '/api/reviews/upload-image', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + try { + const urls = await persistMultipartImages(request, { + maxFiles: 1, + maxFileBytes: getOtherUploadMaxFileBytes(), + subdir: 'reviews', + }) + if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' }) + return { url: urls[0] } + } catch (error) { + let message = error instanceof Error ? error.message : 'Не удалось загрузить изображение' + let statusCode = + error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) + ? Number(error.statusCode) + : 400 + if (isMultipartFileTooLargeError(error)) { + message = formatFileTooLargeMessage(getOtherUploadMaxFileBytes()) + statusCode = 413 + } + return reply.code(statusCode).send({ error: message }) + } + }, +) +``` + +- [ ] **Step 3: Update persistMultipartImages to accept subdir** + +Modify `server/src/lib/upload-images.js`, the `persistMultipartImages` function: + +```js +export async function persistMultipartImages(request, { maxFiles = 10, maxFileBytes, subdir = '' }) { + if (!request.isMultipart()) { + throw uploadError('Ожидается multipart/form-data') + } + + const uploadsDir = path.join(process.cwd(), 'uploads') + const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir + await fs.promises.mkdir(targetDir, { recursive: true }) + + const urls = [] + const parts = request.parts({ + limits: { + fileSize: maxFileBytes, + files: maxFiles, + }, + }) + for await (const part of parts) { + if (!part.file) continue + if (urls.length >= maxFiles) { + throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`) + } + const ext = safeImageExt(part.filename) + if (!ext) { + throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp') + } + + const fileName = `${crypto.randomUUID()}${ext}` + const fullPath = path.join(targetDir, fileName) + await fs.promises.writeFile(fullPath, await part.toBuffer()) + urls.push(subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}`) + } + + if (urls.length === 0) { + throw uploadError( + 'Файлы не получены. Проверьте, что запрос multipart/form-data и поля — файлы изображений (png, jpg, webp).', + ) + } + + return urls +} +``` + +--- + +### Task 5: Client OptimizedImage component + +**Files:** +- Create: `client/src/shared/ui/OptimizedImage.tsx` +- Create: `client/src/shared/ui/__tests__/OptimizedImage.test.tsx` + +- [ ] **Step 1: Create the OptimizedImage component** + +```tsx +// client/src/shared/ui/OptimizedImage.tsx +import { useMemo } from 'react' +import Box from '@mui/material/Box' +import type { SxProps, Theme } from '@mui/material/styles' + +const DEFAULT_WIDTHS = [320, 640, 1024, 1600] + +type OptimizedImageProps = { + src: string + alt: string + widths?: number[] + sizes?: string + sx?: SxProps + priority?: boolean +} + +/** Extract UUID and subdir from a /uploads/... URL */ +function parseUploadUrl(src: string): { uuid: string; ext: string; subdir: string } | null { + const match = src.match(/^\/uploads(?:\/(reviews))?\/([^.\/]+)\.(png|jpe?g|webp)/i) + if (!match) return null + return { subdir: match[1] || '', uuid: match[2], ext: match[3].toLowerCase() } +} + +function buildSrcSet(src: string, widths: number[]): string | null { + const parsed = parseUploadUrl(src) + if (!parsed) return null + + const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : '' + return widths + .map((w) => `/uploads-resized/${pathPrefix}${parsed.uuid}.avif?w=${w} ${w}w`) + .join(', ') +} + +function buildFallbackSrc(src: string, width: number): string { + const parsed = parseUploadUrl(src) + if (!parsed) return src + const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : '' + return `/uploads-resized/${pathPrefix}${parsed.uuid}.webp?w=${width}` +} + +export function OptimizedImage({ src, alt, widths = DEFAULT_WIDTHS, sizes, sx, priority = false }: OptimizedImageProps) { + const srcSet = useMemo(() => buildSrcSet(src, widths), [src, widths]) + const fallbackSrc = useMemo(() => buildFallbackSrc(src, widths[0]), [src, widths]) + + // If src is not an upload URL, render a plain img + if (!srcSet) { + return ( + + ) + } + + const sizesAttr = sizes ?? '(max-width: 600px) 320px, (max-width: 1024px) 640px, 1024px' + + return ( + + + + + + ) +} +``` + +- [ ] **Step 2: Create tests for OptimizedImage** + +```tsx +// client/src/shared/ui/__tests__/OptimizedImage.test.tsx +import { render, screen } from '@testing-library/react' +import { describe, it, expect } from 'vitest' +import { OptimizedImage } from '@/shared/ui/OptimizedImage' + +describe('OptimizedImage', () => { + it('renders a plain img for non-upload URLs', () => { + render() + const img = screen.getByAltText('test') as HTMLImageElement + expect(img.src).toBe('https://example.com/photo.jpg') + expect(img.loading).toBe('lazy') + }) + + it('renders picture element with srcset for upload URLs', () => { + render() + const picture = screen.getByAltText('product').closest('picture') + expect(picture).not.toBeNull() + + const avifSource = picture?.querySelector('source[type="image/avif"]') as HTMLSourceElement + expect(avifSource).not.toBeNull() + expect(avifSource.srcSet).toContain('/uploads-resized/abc123.avif?w=320') + expect(avifSource.srcSet).toContain('/uploads-resized/abc123.avif?w=640') + expect(avifSource.srcSet).toContain('/uploads-resized/abc123.avif?w=1024') + expect(avifSource.srcSet).toContain('/uploads-resized/abc123.avif?w=1600') + }) + + it('handles review subdir correctly', () => { + render() + const avifSource = screen.getByAltText('review').closest('picture')?.querySelector('source[type="image/avif"]') as HTMLSourceElement + expect(avifSource.srcSet).toContain('/uploads-resized/reviews/def456.avif?w=320') + }) + + it('uses eager loading when priority is true', () => { + render() + const img = screen.getByAltText('hero') as HTMLImageElement + expect(img.loading).toBe('eager') + }) + + it('respects custom widths', () => { + render() + const avifSource = screen.getByAltText('test').closest('picture')?.querySelector('source[type="image/avif"]') as HTMLSourceElement + expect(avifSource.srcSet).toContain('?w=200') + expect(avifSource.srcSet).toContain('?w=400') + expect(avifSource.srcSet).not.toContain('?w=640') + }) +}) +``` + +- [ ] **Step 3: Run tests to verify they pass** + +```bash +cd /mnt/d/my_projects/shop/client && npm test -- --run src/shared/ui/__tests__/OptimizedImage.test.tsx +``` + +Expected: All 5 tests pass. + +--- + +### Task 6: Replace images in ProductCard + +**Files:** +- Modify: `client/src/entities/product/ui/ProductCard.tsx` + +- [ ] **Step 1: Import OptimizedImage and replace img elements** + +```tsx +// Add import +import { OptimizedImage } from '@/shared/ui/OptimizedImage' + +// Replace the inside SwiperSlide (around line 88-103): +// OLD: +// {imageUrls.map((url) => ( +// +// +// +// ))} + +// NEW: + {imageUrls.map((url) => ( + + + + + + ))} +``` + +--- + +### Task 7: Replace images in ProductPage + +**Files:** +- Modify: `client/src/pages/product/ui/ProductPage.tsx` + +- [ ] **Step 1: Import OptimizedImage and replace all img elements** + +```tsx +// Add import +import { OptimizedImage } from '@/shared/ui/OptimizedImage' + +// Replace the main product image in SwiperSlide (around line 91-110): +// OLD: +// {imageUrls.map((url, idx) => ( +// +// { +// setViewerIndex(idx) +// setViewerOpen(true) +// }} +// sx={{ +// width: '100%', +// height: 420, +// objectFit: 'cover', +// display: 'block', +// cursor: 'zoom-in', +// userSelect: 'none', +// }} +// /> +// +// ))} + +// NEW: + {imageUrls.map((url, idx) => ( + + { + setViewerIndex(idx) + setViewerOpen(true) + }} + sx={{ + width: '100%', + height: 420, + cursor: 'zoom-in', + userSelect: 'none', + }} + > + + + + ))} + +// Replace the review image (around line 220-234): +// OLD: +// {rv.imageUrl && ( +// +// )} + +// NEW: + {rv.imageUrl && ( + + + + )} + +// Replace the fullscreen viewer images (around line 265-274): +// OLD: +// {imageUrls.map((url) => ( +// +// +// +// ))} + +// NEW (viewer uses original — no resize needed for fullscreen): + {imageUrls.map((url) => ( + + + + ))} +``` + +--- + +### Task 8: Replace images in CatalogSlider + +**Files:** +- Modify: `client/src/widgets/catalog-slider/ui/CatalogSlider.tsx` + +- [ ] **Step 1: Import OptimizedImage and replace img** + +```tsx +// Add import +import { OptimizedImage } from '@/shared/ui/OptimizedImage' + +// Replace the inside the slider (around line 64-78): +// OLD: +// + +// NEW: + + + +``` + +--- + +### Task 9: Replace images in GalleryGrid + +**Files:** +- Modify: `client/src/entities/gallery/ui/GalleryGrid.tsx` + +- [ ] **Step 1: Import OptimizedImage and replace img** + +```tsx +// Add import +import { OptimizedImage } from '@/shared/ui/OptimizedImage' + +// Replace the (around line 34-39): +// OLD: +// + +// NEW: + +``` + +--- + +### Task 10: Replace images in ReviewsBlock + +**Files:** +- Modify: `client/src/widgets/reviews-block/ui/ReviewsBlock.tsx` + +- [ ] **Step 1: Import OptimizedImage and replace img** + +```tsx +// Add import +import { OptimizedImage } from '@/shared/ui/OptimizedImage' + +// Replace the review image (around line 113-128): +// OLD: +// {r.imageUrl && ( +// +// )} + +// NEW: + {r.imageUrl && ( + + + + )} +``` + +--- + +### Task 11: Replace images in admin pages + +**Files:** +- Modify: `client/src/pages/admin-products/ui/AdminProductsPage.tsx` +- Modify: `client/src/pages/admin/ui/AdminPage.tsx` + +- [ ] **Step 1: Replace images in AdminProductsPage** + +```tsx +// Add import +import { OptimizedImage } from '@/shared/ui/OptimizedImage' + +// Find all patterns and replace with OptimizedImage. +// There are two locations: +// 1. Product form preview thumbnails (around line 403-429) +// 2. Gallery picker thumbnails (around line 536-541) + +// For the form preview (productForm.watch('imageUrls').map): +// OLD pattern: +// + +// NEW: + + + + +// For the gallery picker (around line 536-541): +// OLD: +// + +// NEW: + + + +``` + +- [ ] **Step 2: Replace images in AdminPage** + +```tsx +// Add import (if not already added) +import { OptimizedImage } from '@/shared/ui/OptimizedImage' + +// For the form preview thumbnails (productForm.watch('imageUrls').map, ~line 556-582): +// OLD: +// + +// NEW: + + + + +// For the gallery picker thumbnails (~line 675-683): +// OLD: +// + +// NEW: + + + +``` + +--- + +### Task 12: Vite code splitting + +**Files:** +- Modify: `client/vite.config.ts` + +- [ ] **Step 1: Add manualChunks to vite config** + +```ts +// Modify the defineConfig to include build.rollupOptions: +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(rootDir, 'src'), + '@shared': path.resolve(projectRoot, 'shared'), + }, + }, + server: { + fs: { + allow: [projectRoot], + }, + port: 5173, + proxy: { + '/api': { + target: 'http://127.0.0.1:3333', + changeOrigin: true, + }, + '/uploads': { + target: 'http://127.0.0.1:3333', + changeOrigin: true, + }, + }, + }, + build: { + rollupOptions: { + output: { + manualChunks: { + 'vendor-react': ['react', 'react-dom', 'react-router-dom'], + 'vendor-mui': ['@mui/material', '@mui/icons-material', '@emotion/react', '@emotion/styled'], + 'vendor-swiper': ['swiper/react', 'swiper/modules'], + 'vendor-query': ['@tanstack/react-query'], + 'vendor-effector': ['effector', 'effector-react'], + }, + }, + }, + }, +}) +``` + +Note: maplibre-gl and @tiptap are only imported on specific pages, so they'll be auto-split by Vite's dynamic import. No need to manually chunk them. + +--- + +### Task 13: Route-level lazy loading + +**Files:** +- Modify: `client/src/app/routes/index.tsx` + +- [ ] **Step 1: Add lazy imports for admin and me routes** + +```tsx +// OLD: +// import { AdminLayoutPage } from '@/pages/admin-layout' +// import { MeLayoutPage } from '@/pages/me' + +// NEW: +import { lazy, Suspense } from 'react' +import { Navigate, Route, Routes } from 'react-router-dom' +import { MainLayout } from '@/app/layout/MainLayout' +import { AboutPage } from '@/pages/about' +// import { AdminLayoutPage } from '@/pages/admin-layout' +// import { MeLayoutPage } from '@/pages/me' +import { AuthCallbackPage, AuthPage } from '@/pages/auth' +import { CartPage } from '@/pages/cart' +import { CheckoutPage } from '@/pages/checkout' +import { HomePage } from '@/pages/home' +import { InfoPage } from '@/pages/info' +import { PrivacyPolicyPage } from '@/pages/privacy-policy' +import { ProductPage } from '@/pages/product' +import { SkeletonPage } from '@/shared/ui/SkeletonPage' + +const AdminLayoutPage = lazy(() => import('@/pages/admin-layout')) +const MeLayoutPage = lazy(() => import('@/pages/me')) + +// Wrap lazy routes with Suspense: +// OLD: +// } /> +// } /> + +// NEW: + }>} /> + }>} /> +``` + +- [ ] **Step 2: Create SkeletonPage component** + +```tsx +// client/src/shared/ui/SkeletonPage.tsx +import Box from '@mui/material/Box' +import Skeleton from '@mui/material/Skeleton' + +export function SkeletonPage() { + return ( + + + + + + + + ) +} +``` + +--- + +### Task 14: HTML meta tags + +**Files:** +- Modify: `client/index.html` + +- [ ] **Step 1: Add meta tags to index.html** + +```html + + + + + + + + + Любимый Креатив — Изделия ручной работы + + + + + + + + +
+ + + +``` + +--- + +### Task 15: Add .cache to .gitignore + +**Files:** +- Modify: `server/.gitignore` (or root `.gitignore`) + +- [ ] **Step 1: Add uploads cache directory to gitignore** + +Check which .gitignore exists and add: + +``` +# Image resize cache +uploads/.cache/ +``` + +--- + +### Task 16: Server tests for image resizing + +**Files:** +- Create: `server/src/lib/__tests__/image-resize.test.js` + +- [ ] **Step 1: Create test for image resize library** + +```js +// server/src/lib/__tests__/image-resize.test.js +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { findOriginalFile, getOrCreateResized } from '../image-resize.js' + +const TEST_DIR = path.join(process.cwd(), 'uploads', '.test-tmp') +const UPLOADS_DIR = path.join(process.cwd(), 'uploads') + +beforeAll(async () => { + await fs.promises.mkdir(TEST_DIR, { recursive: true }) + // Create a small test PNG file + const sharp = (await import('sharp')).default + const testPng = path.join(TEST_DIR, 'test-original.png') + await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .png() + .toFile(testPng) +}) + +afterAll(async () => { + await fs.promises.rm(TEST_DIR, { recursive: true, force: true }) + // Clean up any cache files created during tests + const cacheDir = path.join(UPLOADS_DIR, '.cache') + try { + await fs.promises.rm(cacheDir, { recursive: true, force: true }) + } catch { + // ignore + } +}) + +describe('image-resize', () => { + it('findOriginalFile locates file by UUID', async () => { + const files = await fs.promises.readdir(TEST_DIR) + const pngFile = files.find((f) => f.endsWith('.png')) + const uuid = pngFile.replace('.png', '') + + // Temporarily override UPLOADS_DIR by copying file to actual uploads + const destPath = path.join(UPLOADS_DIR, pngFile) + await fs.promises.copyFile(path.join(TEST_DIR, pngFile), destPath) + + const found = await findOriginalFile(uuid) + expect(found).not.toBeNull() + expect(found).toBe(destPath) + + // Cleanup + await fs.promises.unlink(destPath) + }) + + it('getOrCreateResized generates AVIF file', async () => { + // Create a test file in uploads/ + const sharp = (await import('sharp')).default + const uuid = crypto.randomUUID() + const testPath = path.join(UPLOADS_DIR, `${uuid}.png`) + await sharp({ + create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 255, b: 0 } }, + }) + .png() + .toFile(testPath) + + const result = await getOrCreateResized(uuid, 100, 'avif') + expect(result).not.toBeNull() + expect(result.isNew).toBe(true) + expect(result.path).toContain('.cache') + expect(result.path).toContain('_w100.avif') + + const exists = await fs.promises.access(result.path).then(() => true).catch(() => false) + expect(exists).toBe(true) + + // Verify it's actually AVIF + const info = await sharp(result.path).metadata() + expect(info.format).toBe('avif') + + // Cleanup + await fs.promises.unlink(testPath) + await fs.promises.unlink(result.path) + }) + + it('getOrCreateResized returns cached file on second call', async () => { + const sharp = (await import('sharp')).default + const uuid = crypto.randomUUID() + const testPath = path.join(UPLOADS_DIR, `${uuid}.png`) + await sharp({ + create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 0, b: 255 } }, + }) + .png() + .toFile(testPath) + + const first = await getOrCreateResized(uuid, 100, 'webp') + expect(first.isNew).toBe(true) + + const second = await getOrCreateResized(uuid, 100, 'webp') + expect(second.isNew).toBe(false) + expect(second.path).toBe(first.path) + + // Cleanup + await fs.promises.unlink(testPath) + await fs.promises.unlink(first.path) + }) +}) +``` + +- [ ] **Step 2: Run server tests** + +```bash +cd /mnt/d/my_projects/shop/server && npm test +``` + +Expected: All tests pass including new image-resize tests. + +--- + +### Task 17: Build and verify + +**Files:** N/A + +- [ ] **Step 1: Build the client** + +```bash +cd /mnt/d/my_projects/shop/client && npm run build +``` + +Expected: Build succeeds with no TypeScript errors. Check that output has multiple chunk files (vendor-react, vendor-mui, etc.). + +- [ ] **Step 2: Run client lint** + +```bash +cd /mnt/d/my_projects/shop/client && npm run lint +``` + +Expected: No errors. + +- [ ] **Step 3: Run client tests** + +```bash +cd /mnt/d/my_projects/shop/client && npm test +``` + +Expected: All tests pass. + +- [ ] **Step 4: Run server tests** + +```bash +cd /mnt/d/my_projects/shop/server && npm test +``` + +Expected: All tests pass. + +- [ ] **Step 5: Commit all changes** + +```bash +git add -A +git commit -m "perf: optimize lighthouse score with image resizing, lazy loading, and code splitting + +- Add sharp-based on-demand AVIF/WebP image resizing with disk cache +- Add component with srcset/sizes/lazy loading +- Separate review images into /uploads/reviews/ +- Add Cache-Control headers for uploads +- Code split vendor bundles (react, mui, swiper, etc.) +- Lazy load admin and me routes +- Add meta tags for SEO and OG" +```