image-optimizer.test.js
1 /** 2 * Tests for Image Optimization Module 3 */ 4 5 import { test, describe } from 'node:test'; 6 import assert from 'node:assert'; 7 import sharp from 'sharp'; 8 import { 9 optimizeScreenshot, 10 calculateSavings, 11 optimizeScreenshots, 12 } from '../../src/utils/image-optimizer.js'; 13 14 describe('Image Optimizer Module', () => { 15 describe('optimizeScreenshot', () => { 16 test('should return single buffer when includeUncropped is false', async () => { 17 // Create a test image buffer 18 const testImage = await sharp({ 19 create: { 20 width: 1440, 21 height: 900, 22 channels: 3, 23 background: { r: 255, g: 255, b: 255 }, 24 }, 25 }) 26 .png() 27 .toBuffer(); 28 29 const result = await optimizeScreenshot(testImage, { 30 type: 'desktop_above', 31 includeUncropped: false, 32 }); 33 34 assert.ok(Buffer.isBuffer(result), 'Should return a Buffer'); 35 assert.ok(result.length > 0, 'Buffer should not be empty'); 36 }); 37 38 test('should return object with cropped and uncropped when includeUncropped is true', async () => { 39 const testImage = await sharp({ 40 create: { 41 width: 1440, 42 height: 900, 43 channels: 3, 44 background: { r: 255, g: 255, b: 255 }, 45 }, 46 }) 47 .png() 48 .toBuffer(); 49 50 const result = await optimizeScreenshot(testImage, { 51 type: 'desktop_above', 52 includeUncropped: true, 53 }); 54 55 assert.ok(typeof result === 'object', 'Should return an object'); 56 assert.ok(result.cropped, 'Should have cropped property'); 57 assert.ok('uncropped' in result, 'Should have uncropped property (can be null or Buffer)'); 58 assert.ok(Buffer.isBuffer(result.cropped), 'cropped should be a Buffer'); 59 // uncropped can be null if identical to cropped, or a Buffer if different 60 assert.ok( 61 result.uncropped === null || Buffer.isBuffer(result.uncropped), 62 'uncropped should be null (if identical) or a Buffer (if different)' 63 ); 64 }); 65 66 test('uncropped version should be larger than cropped (less aggressive cropping)', async () => { 67 const testImage = await sharp({ 68 create: { 69 width: 1440, 70 height: 900, 71 channels: 3, 72 background: { r: 255, g: 255, b: 255 }, 73 }, 74 }) 75 .png() 76 .toBuffer(); 77 78 const result = await optimizeScreenshot(testImage, { 79 type: 'desktop_above', 80 includeUncropped: true, 81 smartCrop: true, 82 cropBoundaries: { 83 topCrop: 100, 84 leftCrop: 50, 85 rightCrop: 50, 86 metadata: { navReasoning: 'Test significant crop' }, 87 }, 88 }); 89 90 // With significant cropping, uncropped should exist and have content 91 // Both should be resized to same dimensions but uncropped preserves more original content 92 assert.ok( 93 Buffer.isBuffer(result.uncropped), 94 'Uncropped should be a Buffer when significantly different' 95 ); 96 assert.ok(result.uncropped.length > 0, 'Uncropped should have content'); 97 assert.ok(result.cropped.length > 0, 'Cropped should have content'); 98 }); 99 100 test('uncropped version should preserve aspect ratio without cropping', async () => { 101 // Create a tall image (1440x1800) with visual variation 102 // Add a colored rectangle to differentiate from solid background 103 const redSquare = await sharp({ 104 create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 0, b: 255 } }, 105 }) 106 .png() 107 .toBuffer(); 108 109 const tallImage = await sharp({ 110 create: { 111 width: 1440, 112 height: 1800, 113 channels: 3, 114 background: { r: 255, g: 200, b: 200 }, 115 }, 116 }) 117 .composite([{ input: redSquare, top: 100, left: 100 }]) 118 .png() 119 .toBuffer(); 120 121 const result = await optimizeScreenshot(tallImage, { 122 type: 'desktop_above', 123 includeUncropped: true, 124 smartCrop: false, // Disable cropping to test pure aspect ratio preservation 125 }); 126 127 // Check uncropped dimensions - should maintain aspect ratio 128 // Note: uncropped might be null if identical to cropped (within 5KB & 1% threshold) 129 assert.ok(result.cropped, 'Should have cropped version'); 130 if (!result.uncropped) { 131 // If null, it means cropped and uncropped were identical - that's valid 132 assert.ok( 133 result.metadata?.uncroppedSkipped, 134 'If uncropped is null, metadata should indicate it was skipped' 135 ); 136 return; // Test passes - identical versions handled correctly 137 } 138 139 const uncroppedMeta = await sharp(result.uncropped).metadata(); 140 const croppedMeta = await sharp(result.cropped).metadata(); 141 142 // Uncropped: width should be 768, height proportional (not fixed at 432) 143 assert.strictEqual(uncroppedMeta.width, 768, 'Uncropped width should be 768'); 144 assert.ok( 145 uncroppedMeta.height > 432, 146 'Uncropped height should be more than 432 (preserves aspect)' 147 ); 148 149 // Cropped: with DOM-aware cropping and fit:'inside', dimensions will be proportional to aspect ratio after crop 150 // For 1440x1800 image with topCrop 80, leftCrop 72, rightCrop 72: 1296x1720 aspect ratio (~0.75) 151 // Resizing with fit:'inside' to 768x432: height-constrained to 432, width becomes ~324 152 assert.ok(croppedMeta.width > 0, 'Cropped should have width'); 153 assert.strictEqual( 154 croppedMeta.height, 155 432, 156 'Cropped height should be 432 (height-constrained)' 157 ); 158 assert.ok( 159 croppedMeta.width < 768, 160 'Cropped width should be less than 768 (tall image, height-constrained)' 161 ); 162 }); 163 164 test('uncropped version should not crop content from tall images', async () => { 165 // Create a very tall image to ensure no content is cropped 166 const veryTallImage = await sharp({ 167 create: { 168 width: 390, 169 height: 2000, // Very tall mobile page 170 channels: 3, 171 background: { r: 0, g: 255, b: 0 }, 172 }, 173 }) 174 .png() 175 .toBuffer(); 176 177 const result = await optimizeScreenshot(veryTallImage, { 178 type: 'mobile_above', 179 includeUncropped: true, 180 smartCrop: true, 181 cropBoundaries: { 182 topCrop: 50, 183 leftCrop: 20, 184 rightCrop: 20, 185 metadata: { navReasoning: 'Mobile navigation crop' }, 186 }, 187 }); 188 189 const uncroppedMeta = await sharp(result.uncropped).metadata(); 190 191 // Should preserve the tall aspect ratio 192 // Original aspect ratio: 390/2000 = 0.195 193 // Resized to width 768: height should be 768 / 0.195 H 3938 194 // But with fit:'inside', it won't enlarge, so max width is 390 195 assert.ok(uncroppedMeta.width <= 768, 'Width should not exceed target'); 196 assert.ok(uncroppedMeta.height >= 432, 'Height should preserve tall content'); 197 }); 198 199 test('uncropped with smartCrop disabled preserves full dimensions', async () => { 200 const testImage = await sharp({ 201 create: { 202 width: 1440, 203 height: 900, 204 channels: 3, 205 background: { r: 0, g: 0, b: 255 }, 206 }, 207 }) 208 .png() 209 .toBuffer(); 210 211 const result = await optimizeScreenshot(testImage, { 212 type: 'desktop_above', 213 includeUncropped: true, 214 smartCrop: false, // Important: no cropping at all 215 }); 216 217 // When smartCrop is disabled, uncropped may still exist due to processing differences 218 // (cropped uses 'entropy' positioning, uncropped doesn't) 219 // Verify cropped is resized appropriately 220 const croppedMeta = await sharp(result.cropped).metadata(); 221 // Original aspect ratio: 1440/900 = 1.6 222 // With fit:'inside' and entropy positioning, dimensions may vary 223 assert.ok(croppedMeta.width <= 768, 'Width should be <= 768'); 224 assert.ok(croppedMeta.height <= 480, 'Height should be <= 480'); 225 assert.ok(croppedMeta.width > 0, 'Width should be positive'); 226 assert.ok(croppedMeta.height > 0, 'Height should be positive'); 227 228 // If uncropped exists, it should also be resized appropriately 229 if (result.uncropped) { 230 const uncroppedMeta = await sharp(result.uncropped).metadata(); 231 assert.ok(uncroppedMeta.width <= 768, 'Uncropped width should be <= 768'); 232 assert.ok(uncroppedMeta.height <= 480, 'Uncropped height should be <= 480'); 233 } 234 }); 235 236 test('should handle different screenshot types', async () => { 237 // Create image with visual variation to avoid identical threshold 238 const blueSquare = await sharp({ 239 create: { width: 100, height: 100, channels: 3, background: { r: 0, g: 0, b: 255 } }, 240 }) 241 .png() 242 .toBuffer(); 243 244 const testImage = await sharp({ 245 create: { 246 width: 1440, 247 height: 900, 248 channels: 3, 249 background: { r: 240, g: 240, b: 255 }, 250 }, 251 }) 252 .composite([{ input: blueSquare, top: 50, left: 50 }]) 253 .png() 254 .toBuffer(); 255 256 const types = ['desktop_above', 'desktop_below', 'mobile_above']; 257 258 for (const type of types) { 259 const result = await optimizeScreenshot(testImage, { 260 type, 261 includeUncropped: true, 262 smartCrop: false, // Disable to test type handling without cropping 263 }); 264 265 assert.ok(result.cropped, `Should have cropped for ${type}`); 266 267 // Uncropped might be null if identical to cropped (within 5KB & 1% threshold) 268 // This is expected behavior - the test verifies type handling works correctly 269 if (result.uncropped) { 270 assert.ok(Buffer.isBuffer(result.uncropped), `Uncropped should be Buffer for ${type}`); 271 } else { 272 // If null, metadata should indicate it was skipped 273 assert.ok( 274 result.metadata?.uncroppedSkipped, 275 `If uncropped is null for ${type}, metadata should indicate it was skipped` 276 ); 277 } 278 } 279 }); 280 }); 281 282 describe('calculateSavings', () => { 283 test('should calculate size savings correctly', async () => { 284 const testImage = await sharp({ 285 create: { 286 width: 1440, 287 height: 900, 288 channels: 3, 289 background: { r: 255, g: 255, b: 255 }, 290 }, 291 }) 292 .png() 293 .toBuffer(); 294 295 const original = { 296 desktop_above: testImage, 297 desktop_below: testImage, 298 mobile_above: testImage, 299 }; 300 301 const optimized = { 302 desktop_above: await optimizeScreenshot(testImage, { type: 'desktop_above' }), 303 desktop_below: await optimizeScreenshot(testImage, { type: 'desktop_below' }), 304 mobile_above: await optimizeScreenshot(testImage, { type: 'mobile_above' }), 305 }; 306 307 const savings = calculateSavings(original, optimized); 308 309 assert.ok(savings.originalKB > 0, 'Should have original size'); 310 assert.ok(savings.optimizedKB > 0, 'Should have optimized size'); 311 assert.ok(savings.savingsKB >= 0, 'Should have savings'); 312 assert.ok(savings.savingsPercent >= 0, 'Should have savings percentage'); 313 assert.ok(savings.savingsPercent <= 100, 'Savings percentage should be <= 100'); 314 }); 315 316 test('should skip uncropped version when very similar to cropped', async () => { 317 // Create a simple solid color image where compression will be very similar 318 const testImage = await sharp({ 319 create: { 320 width: 1440, 321 height: 900, 322 channels: 3, 323 background: { r: 128, g: 128, b: 128 }, 324 }, 325 }) 326 .png() 327 .toBuffer(); 328 329 const result = await optimizeScreenshot(testImage, { 330 type: 'desktop_above', 331 includeUncropped: true, 332 smartCrop: true, 333 cropBoundaries: { 334 topCrop: 0, // No cropping 335 leftCrop: 0, 336 rightCrop: 0, 337 metadata: { navReasoning: 'No crop needed' }, 338 }, 339 }); 340 341 // When crop boundaries are zero, versions should be very similar 342 // May or may not be skipped depending on compression differences 343 assert.ok(result.cropped, 'Should have cropped version'); 344 assert.ok(result.metadata, 'Should have metadata'); 345 346 // If uncropped was skipped, verify metadata 347 if (result.uncropped === null) { 348 assert.strictEqual( 349 result.metadata.uncroppedSkipped, 350 true, 351 'metadata should indicate uncropped was skipped' 352 ); 353 assert.strictEqual( 354 result.metadata.reason, 355 'identical_to_cropped', 356 'Should have skip reason' 357 ); 358 } else { 359 // If not skipped, versions should be very close in size 360 assert.ok( 361 result.metadata.sizeDiff < 10240, 362 'Size difference should be small (< 10KB) when not cropping' 363 ); 364 } 365 }); 366 367 test('should keep uncropped version when significantly different from cropped', async () => { 368 // Create an image with significant cropping 369 const testImage = await sharp({ 370 create: { 371 width: 1440, 372 height: 900, 373 channels: 3, 374 background: { r: 128, g: 128, b: 128 }, 375 }, 376 }) 377 .png() 378 .toBuffer(); 379 380 const result = await optimizeScreenshot(testImage, { 381 type: 'desktop_above', 382 includeUncropped: true, 383 smartCrop: true, 384 cropBoundaries: { 385 topCrop: 150, // Significant crop 386 leftCrop: 200, 387 rightCrop: 200, 388 metadata: { navReasoning: 'Large navigation header' }, 389 }, 390 }); 391 392 // With significant cropping, versions should be different 393 assert.ok(result.cropped, 'Should have cropped version'); 394 assert.ok(Buffer.isBuffer(result.uncropped), 'Uncropped should be a Buffer when different'); 395 assert.ok(result.metadata, 'Should have metadata'); 396 assert.strictEqual( 397 result.metadata.uncroppedSkipped, 398 false, 399 'metadata should indicate uncropped was kept' 400 ); 401 }); 402 }); 403 404 describe('optimizeScreenshots', () => { 405 test('should batch optimize all screenshot types', async () => { 406 const testImage = await sharp({ 407 create: { 408 width: 1440, 409 height: 900, 410 channels: 3, 411 background: { r: 255, g: 255, b: 255 }, 412 }, 413 }) 414 .png() 415 .toBuffer(); 416 417 const screenshots = { 418 desktop_above: testImage, 419 desktop_below: testImage, 420 mobile_above: testImage, 421 }; 422 423 const optimized = await optimizeScreenshots(screenshots); 424 425 assert.ok(optimized.desktop_above, 'Should have desktop_above'); 426 assert.ok(optimized.desktop_below, 'Should have desktop_below'); 427 assert.ok(optimized.mobile_above, 'Should have mobile_above'); 428 assert.ok(Buffer.isBuffer(optimized.desktop_above), 'desktop_above should be Buffer'); 429 assert.ok(Buffer.isBuffer(optimized.desktop_below), 'desktop_below should be Buffer'); 430 assert.ok(Buffer.isBuffer(optimized.mobile_above), 'mobile_above should be Buffer'); 431 }); 432 433 test('should handle partial screenshot sets', async () => { 434 const testImage = await sharp({ 435 create: { 436 width: 1440, 437 height: 900, 438 channels: 3, 439 background: { r: 255, g: 255, b: 255 }, 440 }, 441 }) 442 .png() 443 .toBuffer(); 444 445 const screenshots = { 446 desktop_above: testImage, 447 // Missing desktop_below and mobile_above 448 }; 449 450 const optimized = await optimizeScreenshots(screenshots); 451 452 assert.ok(optimized.desktop_above, 'Should have desktop_above'); 453 assert.strictEqual(optimized.desktop_below, undefined, 'Should not have desktop_below'); 454 assert.strictEqual(optimized.mobile_above, undefined, 'Should not have mobile_above'); 455 }); 456 }); 457 458 describe('smartCrop', () => { 459 test('should apply smart crop with default type', async () => { 460 const testImage = await sharp({ 461 create: { 462 width: 1440, 463 height: 900, 464 channels: 3, 465 background: { r: 255, g: 255, b: 255 }, 466 }, 467 }) 468 .png() 469 .toBuffer(); 470 471 // Test with unknown type (should use default case) 472 const result = await optimizeScreenshot(testImage, { 473 type: 'unknown_type', 474 smartCrop: true, 475 }); 476 477 assert.ok(Buffer.isBuffer(result), 'Should return a Buffer'); 478 assert.ok(result.length > 0, 'Buffer should not be empty'); 479 }); 480 481 test('should work without smart crop', async () => { 482 const testImage = await sharp({ 483 create: { 484 width: 1440, 485 height: 900, 486 channels: 3, 487 background: { r: 255, g: 255, b: 255 }, 488 }, 489 }) 490 .png() 491 .toBuffer(); 492 493 const result = await optimizeScreenshot(testImage, { 494 type: 'desktop_above', 495 smartCrop: false, 496 }); 497 498 assert.ok(Buffer.isBuffer(result), 'Should return a Buffer'); 499 assert.ok(result.length > 0, 'Buffer should not be empty'); 500 }); 501 }); 502 503 describe('error handling', () => { 504 test('should throw error for invalid image buffer', async () => { 505 const invalidBuffer = Buffer.from('not an image'); 506 507 await assert.rejects( 508 async () => { 509 await optimizeScreenshot(invalidBuffer, { type: 'desktop_above' }); 510 }, 511 Error, 512 'Should throw error for invalid image' 513 ); 514 }); 515 516 test('should throw error for non-buffer input', async () => { 517 await assert.rejects( 518 async () => { 519 await optimizeScreenshot('not a buffer', { type: 'desktop_above' }); 520 }, 521 Error, 522 'Should throw error for non-buffer input' 523 ); 524 }); 525 }); 526 });