# 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" ```