/ tests / capture / image-optimizer.test.js
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  });