Repository: kaliber5/ember-responsive-image
Pull request #180 (2021-03-05 15:08:19)

Refactor internal meta format, add own fingerprinting implementation
This implements the changes suggested in #179, which should lead to substantially smaller size of the meta data.

There is a breaking change here, in that the configuration options have changed: what was an array (with image options) before , is now an object, containing the previous image options array under the `images` key, and a new `fingerprint` option. The breaking change seems moreover justified, as this now allows extending the top-level config, having only an array of image options did not allow this.


Patch:
diff --git a/README.md b/README.md
index ae51e70b..fa819eee 100644
--- a/README.md
+++ b/README.md
@@ -51,12 +51,14 @@ Add a basic configuration to your `ember-cli-build.js`, to point the addon to wh
 ```js
 module.exports = function (defaults) {
   let app = new EmberAddon(defaults, {
-    'responsive-image': [
-      {
-        include: 'assets/images/**/*',
-        widths: [2048, 1536, 1080, 750, 640],
-      }
-    ]
+    'responsive-image': {
+      images: [
+        {
+          include: 'assets/images/**/*',
+          widths: [2048, 1536, 1080, 750, 640],
+        }
+      ],
+    }
   });
 };
 ```
@@ -197,40 +199,49 @@ is less suited if you have just a few images, but shines if you need placeholder
 
 ## Configuration
 
-Configuration of the addon is done in your `ember-cli-build.js`. It expects an array of configuration items, with a number
-of different available options:
+Configuration of the addon is done in your `ember-cli-build.js`:
 
 ```js
 let app = new EmberAddon(defaults, {
-  'responsive-image': [
-    {
-      include: ['path/to/images/**/*'],
-      exclude: ['path/to/images/but-not-this/**/*'],
-      widths: [2048, 1536, 1080, 750, 640],
-      formats: ['avif', 'webp'],
-      quality: 50,
-      lqip: {
-        type: 'inline', 
-        targetPixels: 60,
+  'responsive-image': {
+    fingerprint: true,
+    images: [
+      {
+        include: ['path/to/images/**/*'],
+        exclude: ['path/to/images/but-not-this/**/*'],
+        widths: [2048, 1536, 1080, 750, 640],
+        formats: ['avif', 'webp'],
+        quality: 50,
+        lqip: {
+          type: 'inline',
+          targetPixels: 60,
+        },
+        removeSource: true,
+        justCopy: false,
       },
-      removeSource: true,
-      justCopy: false,
-    },
-    // possible more items
-  ]
+      // possible more items
+    ],
+  }
 });
 ```
 
-You must define at least one configuration item, with at least `include` defined. But you can provide more, to create separate
-configurations for different images. 
+### Options
+
+* **fingerprint:** Can be used to enable/disable fingerprinting of the generated image files. In most cases you can omit
+setting this explicitly, as it will follow whatever you have set under the main `fingerprint` options (used by the `broccoli-asset-rev` addon), 
+with the default being to enable fingerprinting only in production builds.
+* **images**: The main configuration how the addon generated images happens here, see the following section for details.
+
+### Image Options
+
+The main configuration happens with the `images` array. There you must define at least one configuration item, with at least `include` defined. 
+But you can provide more, to create separate configurations for different images. 
 
 For example if you have a gallery of logos, of which all will be displayed with a width of max. 300px or less,it makes no sense to create very 
 large images for these, so a setting of `widths: [300, 600],` would make sense here (600px for the `2x` version aka "retina").
 
 > Make sure you don't have multiple `include` definitions accidentally overlapping! You can use `exclude` in this case to prevent this.
 
-### Options
-
 * **include:** Glob pattern for which images should be processed based on this configuration.
 * **exclude:** Optional pattern which images to exclude, takes precedence over `include`.
 * **widths:** These are the widths of the resized images.
diff --git a/addon/services/responsive-image.ts b/addon/services/responsive-image.ts
index 78aa016c..b13559e8 100644
--- a/addon/services/responsive-image.ts
+++ b/addon/services/responsive-image.ts
@@ -36,10 +36,15 @@ export interface ImageMeta {
 }
 
 export interface Meta {
-  images: ImageMeta[];
+  widths: number[];
+  formats: ImageType[];
+  aspectRatio: number;
+  fingerprint?: string;
   lqip?: LqipInline | LqipColor | LqipBlurhash;
 }
 
+const imageExtensions: Map<ImageType, string> = new Map([['jpeg', 'jpg']]);
+
 /**
  * Service class to provides images generated by the responsive images package
  */
@@ -61,18 +66,25 @@ export default class ResponsiveImageService extends Service {
    * return the images with the different widths
    */
   getImages(imageName: string, type?: ImageType): ImageMeta[] {
-    let images = this.getMeta(imageName).images;
-    if (type) {
-      images = images.filter((image) => image.type === type);
+    imageName = this.normalizeImageName(imageName);
+    const meta = this.getMeta(imageName);
+    const images: ImageMeta[] = [];
+
+    for (const width of meta.widths) {
+      if (type) {
+        images.push(this.getImageMetaByWidth(imageName, width, type));
+      } else {
+        for (const type of meta.formats) {
+          images.push(this.getImageMetaByWidth(imageName, width, type));
+        }
+      }
     }
 
     return images;
   }
 
   getMeta(imageName: string): Meta {
-    if (imageName.charAt(0) === '/') {
-      imageName = imageName.slice(1);
-    }
+    imageName = this.normalizeImageName(imageName);
     assert(
       `There is no data for image ${imageName}: ${this.meta}`,
       Object.prototype.hasOwnProperty.call(this.meta, imageName)
@@ -81,6 +93,10 @@ export default class ResponsiveImageService extends Service {
     return this.meta[imageName];
   }
 
+  private normalizeImageName(imageName: string): string {
+    return imageName.charAt(0) === '/' ? imageName.slice(1) : imageName;
+  }
+
   private getType(imageName: string): ImageType {
     const extension = imageName.split('.').pop();
     assert(`No extension found for ${imageName}`, extension);
@@ -88,12 +104,7 @@ export default class ResponsiveImageService extends Service {
   }
 
   getAvailableTypes(imageName: string): ImageType[] {
-    return (
-      this.getImages(imageName)
-        .map((image) => image.type)
-        // unique
-        .filter((value, index, self) => self.indexOf(value) === index)
-    );
+    return this.getMeta(imageName).formats;
   }
 
   /**
@@ -115,26 +126,36 @@ export default class ResponsiveImageService extends Service {
     imageName: string,
     width: number,
     type: ImageType = this.getType(imageName)
-  ): ImageMeta | undefined {
-    return this.getImages(imageName)
-      .filter((img) => img.type === type)
-      .reduce((prevValue: ImageMeta | undefined, imageMeta: ImageMeta) => {
-        if (prevValue === undefined) {
-          return imageMeta;
-        }
-
-        if (imageMeta.width >= width && prevValue.width >= width) {
-          return imageMeta.width >= prevValue.width ? prevValue : imageMeta;
+  ): ImageMeta {
+    const imageWidth = this.getMeta(imageName).widths.reduce(
+      (prevValue: number, w: number) => {
+        if (w >= width && prevValue >= width) {
+          return w >= prevValue ? prevValue : w;
         } else {
-          return imageMeta.width >= prevValue.width ? imageMeta : prevValue;
+          return w >= prevValue ? w : prevValue;
         }
-      }, undefined);
+      },
+      0
+    );
+    const height = Math.round(imageWidth / this.getAspectRatio(imageName));
+    return {
+      image: this.getImageFilename(imageName, imageWidth, type),
+      width: imageWidth,
+      type,
+      height,
+    };
   }
 
-  getAspectRatio(imageName: string): number | undefined {
-    const meta = this.getImages(imageName)[0];
+  getAspectRatio(imageName: string): number {
+    return this.getMeta(imageName).aspectRatio;
+  }
 
-    return meta ? meta.width / meta.height : undefined;
+  getImageFilename(image: string, width: number, format: ImageType): string {
+    // this must match `generateFilename()` of ImageWriter broccoli plugin!
+    const ext = imageExtensions.get(format) ?? format;
+    const base = image.substr(0, image.lastIndexOf('.'));
+    const fingerprint = this.getMeta(image).fingerprint;
+    return `/${base}${width}w${fingerprint ? '-' + fingerprint : ''}.${ext}`;
   }
 
   private getDestinationWidthBySize(size: number): number {
diff --git a/ember-cli-build.js b/ember-cli-build.js
index 29cad9c5..72b8245f 100644
--- a/ember-cli-build.js
+++ b/ember-cli-build.js
@@ -9,88 +9,94 @@ module.exports = function (defaults) {
       enabled: true,
       extensions: ['js', 'css', 'png', 'jpg', 'gif', 'map', 'webp', 'avif'],
       exclude: ['testem.js'],
-      customHash: '00e24234f1b58e32b935b1041432916f',
     },
-    'responsive-image': [
-      {
-        include: 'assets/images/tests/**/*',
-        exclude: [
-          'assets/images/tests/small.png',
-          'assets/images/tests/lqip/**/*',
-        ],
-        quality: 50,
-        widths: [50, 100, 640],
-        lqip: {
-          type: 'color',
+    'responsive-image': {
+      fingerprint:
+        // used only for testing here, to override the md5 fingerprint with a constant (for deterministic testing)
+        process.env.ERI_FINGERPRINT !== undefined
+          ? process.env.ERI_FINGERPRINT
+          : true,
+      images: [
+        {
+          include: 'assets/images/tests/**/*',
+          exclude: [
+            'assets/images/tests/small.png',
+            'assets/images/tests/lqip/**/*',
+          ],
+          quality: 50,
+          widths: [50, 100, 640],
+          lqip: {
+            type: 'color',
+          },
+          removeSource: true,
         },
-        removeSource: true,
-      },
-      {
-        include: 'assets/images/tests/small.png',
-        quality: 10,
-        removeSource: false,
-        widths: [10, 25],
-      },
-      {
-        include: 'assets/images/tests/lqip/inline.jpg',
-        quality: 50,
-        widths: [100, 640],
-        lqip: {
-          type: 'inline',
+        {
+          include: 'assets/images/tests/small.png',
+          quality: 10,
+          removeSource: false,
+          widths: [10, 25],
         },
-        removeSource: true,
-      },
-      {
-        include: 'assets/images/tests/lqip/color.jpg',
-        quality: 50,
-        widths: [100, 640],
-        lqip: {
-          type: 'color',
+        {
+          include: 'assets/images/tests/lqip/inline.jpg',
+          quality: 50,
+          widths: [100, 640],
+          lqip: {
+            type: 'inline',
+          },
+          removeSource: true,
         },
-        removeSource: true,
-        justCopy: false,
-      },
-      {
-        include: 'assets/images/tests/lqip/blurhash.jpg',
-        quality: 50,
-        widths: [100, 640],
-        lqip: {
-          type: 'blurhash',
+        {
+          include: 'assets/images/tests/lqip/color.jpg',
+          quality: 50,
+          widths: [100, 640],
+          lqip: {
+            type: 'color',
+          },
+          removeSource: true,
+          justCopy: false,
         },
-        removeSource: true,
-      },
-      {
-        include: 'assets/images/docs/**/*',
-        exclude: [
-          'assets/images/docs/lqip-color.jpg',
-          'assets/images/docs/lqip-blurhash.jpg',
-        ],
-        quality: 50,
-        widths: [1920, 1280, 640, 320],
-        lqip: {
-          type: 'inline',
+        {
+          include: 'assets/images/tests/lqip/blurhash.jpg',
+          quality: 50,
+          widths: [100, 640],
+          lqip: {
+            type: 'blurhash',
+          },
+          removeSource: true,
         },
-        removeSource: true,
-      },
-      {
-        include: 'assets/images/docs/lqip-color.jpg',
-        quality: 50,
-        widths: [1920, 1280, 640, 320],
-        lqip: {
-          type: 'color',
+        {
+          include: 'assets/images/docs/**/*',
+          exclude: [
+            'assets/images/docs/lqip-color.jpg',
+            'assets/images/docs/lqip-blurhash.jpg',
+          ],
+          quality: 50,
+          widths: [1920, 1280, 640, 320],
+          lqip: {
+            type: 'inline',
+          },
+          removeSource: true,
         },
-        removeSource: true,
-      },
-      {
-        include: 'assets/images/docs/lqip-blurhash.jpg',
-        quality: 50,
-        widths: [1920, 1280, 640, 320],
-        lqip: {
-          type: 'blurhash',
+        {
+          include: 'assets/images/docs/lqip-color.jpg',
+          quality: 50,
+          widths: [1920, 1280, 640, 320],
+          lqip: {
+            type: 'color',
+          },
+          removeSource: true,
         },
-        removeSource: true,
-      },
-    ],
+        {
+          include: 'assets/images/docs/lqip-blurhash.jpg',
+          quality: 50,
+          widths: [1920, 1280, 640, 320],
+          lqip: {
+            type: 'blurhash',
+          },
+          removeSource: true,
+        },
+      ],
+    },
   });
 
   /*
diff --git a/index.js b/index.js
index 966d1836..5f1ec554 100644
--- a/index.js
+++ b/index.js
@@ -155,13 +155,15 @@ module.exports = {
     return this.extendedMetaData;
   },
 
-  included(parent) {
+  included(app) {
     this._super.included.apply(this, arguments);
-    this.initConfig(parent);
+    this.app = app;
+    this.initConfig();
+    this.setupFingerprinting();
     this.initPlugins();
     this.processingTree = this.createProcessingTree();
 
-    this.usesBlurhash = this.addonOptions.some(
+    this.usesBlurhash = this.addonOptions.images.some(
       (imageConfig) => imageConfig.lqip && imageConfig.lqip.type === 'blurhash'
     );
     this.options[
@@ -169,22 +171,23 @@ module.exports = {
     ].setOwnConfig.usesBlurhash = this.usesBlurhash;
   },
 
-  initConfig(app) {
-    let config = app.options['responsive-image'];
+  initConfig() {
+    this.addonOptions = this.app.options['responsive-image'];
 
-    if (!config) {
+    if (!this.addonOptions) {
       this.ui.writeWarnLine(
         'Could not find config for ember-responsive-image, skipping image processing...'
       );
-      this.addonOptions = [];
-      return;
+      this.addonOptions = { images: [] };
     }
 
-    if (!Array.isArray(config)) {
-      config = [config];
+    if (!Array.isArray(this.addonOptions.images)) {
+      throw new SilentError(
+        'Config for ember-responsive-image must include an `images` array.'
+      );
     }
 
-    this.addonOptions = config.map((item) => {
+    this.addonOptions.images = this.addonOptions.images.map((item) => {
       this.validateConfigItem(item);
       let extendedConfig = { ...defaultConfig, ...item };
       // extendedConfig.rootURL = url;
@@ -209,6 +212,38 @@ module.exports = {
     );
   },
 
+  setupFingerprinting() {
+    if (this.app.project.findAddonByName('broccoli-asset-rev')) {
+      const assetRevOptions = this.app.options.fingerprint;
+      this.addonOptions.fingerprint =
+        this.addonOptions.fingerprint !== undefined
+          ? this.addonOptions.fingerprint
+          : assetRevOptions === false
+          ? false
+          : assetRevOptions && assetRevOptions.enabled !== undefined
+          ? assetRevOptions.enabled
+          : this.app.env === 'production';
+
+      if (this.addonOptions.fingerprint) {
+        // exclude our own images from broccoli-asset-rev, as we will handle fingerprinting on our own
+
+        const excludeGlobs = this.addonOptions.images.reduce(
+          (globs, imageConfig) => [...globs, ...imageConfig.include],
+          []
+        );
+
+        assetRevOptions.exclude = assetRevOptions.exclude
+          ? [...assetRevOptions.exclude, ...excludeGlobs]
+          : excludeGlobs;
+      }
+    } else {
+      this.addonOptions.fingerprint =
+        this.addonOptions.fingerprint !== undefined
+          ? this.addonOptions.fingerprint
+          : this.app.env === 'production';
+    }
+  },
+
   validateConfigItem(config) {
     if (!config.include) {
       throw new SilentError(
@@ -345,8 +380,9 @@ module.exports = {
 
   createProcessingTree() {
     const tree = this._findHost().trees.public;
-    const trees = this.addonOptions.map((options) => {
-      return this.resizeImages(tree, options);
+    const { fingerprint } = this.addonOptions;
+    const trees = this.addonOptions.images.map((options) => {
+      return this.resizeImages(tree, { ...options, fingerprint });
     });
 
     return mergeTrees(trees, { overwrite: true });
@@ -355,7 +391,7 @@ module.exports = {
   postBuild(result) {
     // remove original images that have `removeSource` set
     const processedImages = Object.keys(this.metaData);
-    this.addonOptions.forEach((options) => {
+    this.addonOptions.images.forEach((options) => {
       if (options.removeSource) {
         const globs = processedImages
           .filter((file) => {
diff --git a/lib/image-writer.js b/lib/image-writer.js
index f6307246..cf7224c3 100644
--- a/lib/image-writer.js
+++ b/lib/image-writer.js
@@ -5,6 +5,7 @@ const fs = require('fs-extra');
 const CachingWriter = require('broccoli-caching-writer');
 const async = require('async-q');
 const sharp = require('sharp');
+const crypto = require('crypto');
 
 const imageExtensions = {
   jpeg: 'jpg',
@@ -76,12 +77,28 @@ class ImageResizer extends CachingWriter {
       this.addConfigData(file);
 
       const meta = await image.metadata();
+      const fingerprint = this.image_options.fingerprint
+        ? await this.generateFingerprint(image)
+        : false;
       let newTasks;
       if (justCopy) {
-        newTasks = this.copyImages(file, image, meta, destinationPath);
+        newTasks = this.copyImages(
+          file,
+          image,
+          meta,
+          destinationPath,
+          fingerprint
+        );
       } else {
-        newTasks = this.generateImages(file, image, meta, destinationPath);
+        newTasks = this.generateImages(
+          file,
+          image,
+          meta,
+          destinationPath,
+          fingerprint
+        );
       }
+      this.generateMetaData(file, image, meta, fingerprint);
 
       tasks = [...tasks, ...newTasks];
     }
@@ -95,9 +112,10 @@ class ImageResizer extends CachingWriter {
    * @param image
    * @param meta
    * @param destinationPath
+   * @param fingerprint
    * @returns {Array} an array of promise-functions
    */
-  generateImages(filename, image, meta, destinationPath) {
+  generateImages(filename, image, meta, destinationPath, fingerprint) {
     return this.image_options.widths.map((width) => {
       return async () => {
         await this.generateResizedImages(
@@ -105,7 +123,8 @@ class ImageResizer extends CachingWriter {
           image,
           destinationPath,
           width,
-          meta
+          meta,
+          fingerprint
         );
       };
     });
@@ -130,9 +149,10 @@ class ImageResizer extends CachingWriter {
    * @param image
    * @param meta
    * @param destinationPath
+   * @param fingerprint
    * @returns {Array} an array of promise-functions
    */
-  copyImages(filename, image, meta, destinationPath) {
+  copyImages(filename, image, meta, destinationPath, fingerprint) {
     const source = path.join(this.inputPaths[0], filename);
     const formats = this.imageFormatsFor(meta);
     const tasks = [];
@@ -143,10 +163,10 @@ class ImageResizer extends CachingWriter {
           const generatedFilename = this.generateFilename(
             filename,
             width,
-            format
+            format,
+            fingerprint
           );
           const destination = path.join(destinationPath, generatedFilename);
-          this.insertMetadata(filename, generatedFilename, format, width, meta);
           const preProcessedSharp = await this.preProcessImage(
             sharp(source),
             filename,
@@ -171,6 +191,7 @@ class ImageResizer extends CachingWriter {
    * @param destinationPath
    * @param width
    * @param {Object} meta
+   * @param fingerprint
    * @returns {deferred.promise|*}
    */
   async generateResizedImages(
@@ -178,7 +199,8 @@ class ImageResizer extends CachingWriter {
     sharpObject,
     destinationPath,
     width,
-    meta
+    meta,
+    fingerprint
   ) {
     const preProcessedSharp = await this.preProcessImage(
       sharpObject.clone(),
@@ -201,17 +223,28 @@ class ImageResizer extends CachingWriter {
           destinationPath,
           format,
           width,
-          meta
+          fingerprint
         )
       )
     );
   }
 
-  async saveImage(filename, image, destinationPath, format, width, meta) {
-    const generatedFilename = this.generateFilename(filename, width, format);
+  async saveImage(
+    filename,
+    image,
+    destinationPath,
+    format,
+    width,
+    fingerprint
+  ) {
+    const generatedFilename = this.generateFilename(
+      filename,
+      width,
+      format,
+      fingerprint
+    );
     const destination = path.join(destinationPath, generatedFilename);
     await fs.ensureDir(path.dirname(destination));
-    this.insertMetadata(filename, generatedFilename, format, width, meta);
 
     await image.toFormat(format).toFile(destination);
   }
@@ -250,7 +283,7 @@ class ImageResizer extends CachingWriter {
   async postProcessImage(sharp, filename, width) {
     let result = sharp;
     for (let processor of this.imagePostProcessors) {
-      let result = await processor.callback.call(
+      result = await processor.callback.call(
         processor.target,
         result,
         filename,
@@ -262,34 +295,37 @@ class ImageResizer extends CachingWriter {
     return result;
   }
 
-  generateFilename(file, width, format) {
+  generateFilename(file, width, format, fingerprint) {
     const ext = imageExtensions[format] || format;
     const base = file.substr(0, file.lastIndexOf('.'));
-    return `${base}${width}w.${ext}`;
+    const fp = fingerprint !== false ? `-${fingerprint}` : '';
+    return `${base}${width}w${fp}.${ext}`;
   }
 
-  insertMetadata(filename, imagename, format, width, meta) {
-    let image = path.join(this.image_options.destinationDir, imagename);
-    if (process.platform === 'win32') {
-      image = image.replace(/\\/g, '/');
+  async generateFingerprint(sharpImage) {
+    if (typeof this.image_options.fingerprint === 'string') {
+      return this.image_options.fingerprint;
     }
+    const md5 = crypto.createHash('md5');
+    md5.update(await sharpImage.toBuffer());
+    md5.update(JSON.stringify(this.image_options));
+    return md5.digest('hex');
+  }
+
+  generateMetaData(imageWebPath, sharpImage, sharpMeta, fingerprint) {
     let aspectRatio = 1;
-    if (meta.height > 0) {
-      aspectRatio = Math.round((meta.width / meta.height) * 100) / 100;
+    if (sharpMeta.height > 0) {
+      aspectRatio =
+        Math.round((sharpMeta.width / sharpMeta.height) * 100) / 100;
     }
-    let height = Math.round(width / aspectRatio);
-    let metadata = {
-      image,
-      type: format,
-      width,
-      height,
+    const formats = this.imageFormatsFor(sharpMeta);
+
+    this.metaData[imageWebPath] = {
+      widths: this.image_options.widths,
+      formats,
+      aspectRatio,
+      fingerprint: fingerprint !== false ? fingerprint : undefined,
     };
-    if (
-      Object.prototype.hasOwnProperty.call(this.metaData, filename) === false
-    ) {
-      this.metaData[filename] = { images: [] };
-    }
-    this.metaData[filename].images.push(metadata);
   }
 
   /**


Test patch:
diff --git a/node-tests/generate.test.js b/node-tests/generate.test.js
index 925f95bb..1f3205e5 100644
--- a/node-tests/generate.test.js
+++ b/node-tests/generate.test.js
@@ -5,7 +5,6 @@ const fs = require('fs');
 const path = require('path');
 
 const hash = '00e24234f1b58e32b935b1041432916f';
-// compare with tests/dummy/config/environment.js
 const images = [
   {
     file: 'assets/images/tests/image.jpg',
@@ -87,7 +86,11 @@ const appDir = './dist';
 
 beforeAll(function () {
   jest.setTimeout(300000);
-  return execa('./node_modules/ember-cli/bin/ember', ['build']);
+  return execa('./node_modules/ember-cli/bin/ember', ['build'], {
+    env: {
+      ERI_FINGERPRINT: hash,
+    },
+  });
 });
 
 images.forEach((img) => {
@@ -98,7 +101,7 @@ images.forEach((img) => {
       const imageData = fs.readFileSync(
         path.join(appDir, `${filename}${width}w-${hash}.${ext}`)
       );
-      const originalSource = path.join(appDir, `${filename}-${hash}.${ext}`);
+      const originalSource = path.join(appDir, `${filename}.${ext}`);
       const meta = await sharp(imageData).metadata();
 
       expect(meta).toBeDefined();
diff --git a/tests/fastboot/image-test.js b/tests/fastboot/image-test.js
index 86add4bf..d4fb6ad3 100644
--- a/tests/fastboot/image-test.js
+++ b/tests/fastboot/image-test.js
@@ -13,7 +13,10 @@ module('FastBoot | image', function (hooks) {
     assert.dom('img[data-test-simple-image]').exists();
     assert
       .dom('img[data-test-simple-image]')
-      .hasAttribute('src', '/assets/images/tests/test640w.png');
+      .hasAttribute(
+        'src',
+        new RegExp('/assets/images/tests/test640w(-\\w+)?.png')
+      );
   });
 
   test('it renders lqip color', async function (assert) {
diff --git a/tests/integration/components/responsive-image-test.js b/tests/integration/components/responsive-image-test.js
index 2786bf41..d520b305 100644
--- a/tests/integration/components/responsive-image-test.js
+++ b/tests/integration/components/responsive-image-test.js
@@ -40,19 +40,19 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/png"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/test640w.png 640w')
+          new RegExp('/assets/images/tests/test640w(-\\w+)?.png 640w')
         );
       assert
         .dom('picture source[type="image/png"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/test100w.png 100w')
+          new RegExp('/assets/images/tests/test100w(-\\w+)?.png 100w')
         );
       assert
         .dom('picture source[type="image/png"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/test50w.png 50w')
+          new RegExp('/assets/images/tests/test50w(-\\w+)?.png 50w')
         );
 
       // webp
@@ -60,19 +60,19 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/webp"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/test640w.webp 640w')
+          new RegExp('/assets/images/tests/test640w(-\\w+)?.webp 640w')
         );
       assert
         .dom('picture source[type="image/webp"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/test100w.webp 100w')
+          new RegExp('/assets/images/tests/test100w(-\\w+)?.webp 100w')
         );
       assert
         .dom('picture source[type="image/webp"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/test50w.webp 50w')
+          new RegExp('/assets/images/tests/test50w(-\\w+)?.webp 50w')
         );
 
       // avif
@@ -80,19 +80,19 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/avif"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/test640w.avif 640w')
+          new RegExp('/assets/images/tests/test640w(-\\w+)?.avif 640w')
         );
       assert
         .dom('picture source[type="image/avif"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/test100w.avif 100w')
+          new RegExp('/assets/images/tests/test100w(-\\w+)?.avif 100w')
         );
       assert
         .dom('picture source[type="image/avif"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/test50w.avif 50w')
+          new RegExp('/assets/images/tests/test50w(-\\w+)?.avif 50w')
         );
 
       await render(
@@ -103,13 +103,13 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/png"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/small10w.png 10w')
+          new RegExp('/assets/images/tests/small10w(-\\w+)?.png 10w')
         );
       assert
         .dom('picture source[type="image/png"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/small25w.png 25w')
+          new RegExp('/assets/images/tests/small25w(-\\w+)?.png 25w')
         );
 
       // webp
@@ -117,13 +117,13 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/webp"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/small10w.webp 10w')
+          new RegExp('/assets/images/tests/small10w(-\\w+)?.webp 10w')
         );
       assert
         .dom('picture source[type="image/webp"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/small25w.webp 25w')
+          new RegExp('/assets/images/tests/small25w(-\\w+)?.webp 25w')
         );
 
       // avif
@@ -131,13 +131,13 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/avif"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/small10w.avif 10w')
+          new RegExp('/assets/images/tests/small10w(-\\w+)?.avif 10w')
         );
       assert
         .dom('picture source[type="image/avif"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/small25w.avif 25w')
+          new RegExp('/assets/images/tests/small25w(-\\w+)?.avif 25w')
         );
 
       await render(
@@ -148,13 +148,17 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/png"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/recursive/dir/test100w.png 100w')
+          new RegExp(
+            '/assets/images/tests/recursive/dir/test100w(-\\w+)?.png 100w'
+          )
         );
       assert
         .dom('picture source[type="image/png"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/recursive/dir/test50w.png 50w')
+          new RegExp(
+            '/assets/images/tests/recursive/dir/test50w(-\\w+)?.png 50w'
+          )
         );
 
       // webp
@@ -162,13 +166,17 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/webp"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/recursive/dir/test100w.webp 100w')
+          new RegExp(
+            '/assets/images/tests/recursive/dir/test100w(-\\w+)?.webp 100w'
+          )
         );
       assert
         .dom('picture source[type="image/webp"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/recursive/dir/test50w.webp 50w')
+          new RegExp(
+            '/assets/images/tests/recursive/dir/test50w(-\\w+)?.webp 50w'
+          )
         );
 
       // avif
@@ -176,13 +184,17 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/avif"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/recursive/dir/test100w.avif 100w')
+          new RegExp(
+            '/assets/images/tests/recursive/dir/test100w(-\\w+)?.avif 100w'
+          )
         );
       assert
         .dom('picture source[type="image/avif"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/recursive/dir/test50w.avif 50w')
+          new RegExp(
+            '/assets/images/tests/recursive/dir/test50w(-\\w+)?.avif 50w'
+          )
         );
     });
 
@@ -190,40 +202,60 @@ module('Integration: Responsive Image Component', function (hooks) {
       let service = this.owner.lookup('service:responsive-image');
       service.set('physicalWidth', 45);
       await render(hbs`<ResponsiveImage @src="assets/images/tests/test.png"/>`);
-      assert.dom('img').hasAttribute('src', '/assets/images/tests/test50w.png');
+      assert
+        .dom('img')
+        .hasAttribute(
+          'src',
+          new RegExp('/assets/images/tests/test50w(-\\w+)?.png')
+        );
       service.set('physicalWidth', 51);
       await render(hbs`<ResponsiveImage @src="assets/images/tests/test.png"/>`);
       assert
         .dom('img')
-        .hasAttribute('src', '/assets/images/tests/test100w.png');
+        .hasAttribute(
+          'src',
+          new RegExp('/assets/images/tests/test100w(-\\w+)?.png')
+        );
       service.set('physicalWidth', 9);
       await render(
         hbs`<ResponsiveImage @src="assets/images/tests/small.png"/>`
       );
       assert
         .dom('img')
-        .hasAttribute('src', '/assets/images/tests/small10w.png');
+        .hasAttribute(
+          'src',
+          new RegExp('/assets/images/tests/small10w(-\\w+)?.png')
+        );
       service.set('physicalWidth', 11);
       await render(
         hbs`<ResponsiveImage @src="assets/images/tests/small.png"/>`
       );
       assert
         .dom('img')
-        .hasAttribute('src', '/assets/images/tests/small25w.png');
+        .hasAttribute(
+          'src',
+          new RegExp('/assets/images/tests/small25w(-\\w+)?.png')
+        );
       service.set('physicalWidth', 45);
       await render(
         hbs`<ResponsiveImage @src="assets/images/tests/recursive/dir/test.png"/>`
       );
       assert
         .dom('img')
-        .hasAttribute('src', '/assets/images/tests/recursive/dir/test50w.png');
+        .hasAttribute(
+          'src',
+          new RegExp('/assets/images/tests/recursive/dir/test50w(-\\w+)?.png')
+        );
       service.set('physicalWidth', 51);
       await render(
         hbs`<ResponsiveImage @src="assets/images/tests/recursive/dir/test.png"/>`
       );
       assert
         .dom('img')
-        .hasAttribute('src', '/assets/images/tests/recursive/dir/test100w.png');
+        .hasAttribute(
+          'src',
+          new RegExp('/assets/images/tests/recursive/dir/test100w(-\\w+)?.png')
+        );
     });
 
     test('it renders a given size as sizes', async function (assert) {
@@ -304,13 +336,13 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/png"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/test100w.png 2x')
+          new RegExp('/assets/images/tests/test100w(-\\w+)?.png 2x')
         );
       assert
         .dom('picture source[type="image/png"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/test50w.png 1x')
+          new RegExp('/assets/images/tests/test50w(-\\w+)?.png 1x')
         );
 
       // webp
@@ -318,13 +350,13 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/webp"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/test100w.webp 2x')
+          new RegExp('/assets/images/tests/test100w(-\\w+)?.webp 2x')
         );
       assert
         .dom('picture source[type="image/webp"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/test50w.webp 1x')
+          new RegExp('/assets/images/tests/test50w(-\\w+)?.webp 1x')
         );
 
       // avif
@@ -332,13 +364,13 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/avif"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/test100w.avif 2x')
+          new RegExp('/assets/images/tests/test100w(-\\w+)?.avif 2x')
         );
       assert
         .dom('picture source[type="image/avif"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/test50w.avif 1x')
+          new RegExp('/assets/images/tests/test50w(-\\w+)?.avif 1x')
         );
 
       await render(
@@ -349,13 +381,13 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/png"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/small10w.png 1x')
+          new RegExp('/assets/images/tests/small10w(-\\w+)?.png 1x')
         );
       assert
         .dom('picture source[type="image/png"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/small25w.png 2x')
+          new RegExp('/assets/images/tests/small25w(-\\w+)?.png 2x')
         );
 
       // webp
@@ -363,13 +395,13 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/webp"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/small10w.webp 1x')
+          new RegExp('/assets/images/tests/small10w(-\\w+)?.webp 1x')
         );
       assert
         .dom('picture source[type="image/webp"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/small25w.webp 2x')
+          new RegExp('/assets/images/tests/small25w(-\\w+)?.webp 2x')
         );
 
       // avif
@@ -377,13 +409,13 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/avif"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/small10w.avif 1x')
+          new RegExp('/assets/images/tests/small10w(-\\w+)?.avif 1x')
         );
       assert
         .dom('picture source[type="image/avif"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/small25w.avif 2x')
+          new RegExp('/assets/images/tests/small25w(-\\w+)?.avif 2x')
         );
 
       await render(
@@ -394,13 +426,17 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/png"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/recursive/dir/test100w.png 100w')
+          new RegExp(
+            '/assets/images/tests/recursive/dir/test100w(-\\w+)?.png 100w'
+          )
         );
       assert
         .dom('picture source[type="image/png"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/recursive/dir/test50w.png 50w')
+          new RegExp(
+            '/assets/images/tests/recursive/dir/test50w(-\\w+)?.png 50w'
+          )
         );
 
       // webp
@@ -408,13 +444,17 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/webp"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/recursive/dir/test100w.webp 100w')
+          new RegExp(
+            '/assets/images/tests/recursive/dir/test100w(-\\w+)?.webp 100w'
+          )
         );
       assert
         .dom('picture source[type="image/webp"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/recursive/dir/test50w.webp 50w')
+          new RegExp(
+            '/assets/images/tests/recursive/dir/test50w(-\\w+)?.webp 50w'
+          )
         );
 
       // avif
@@ -422,13 +462,17 @@ module('Integration: Responsive Image Component', function (hooks) {
         .dom('picture source[type="image/avif"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/recursive/dir/test100w.avif 100w')
+          new RegExp(
+            '/assets/images/tests/recursive/dir/test100w(-\\w+)?.avif 100w'
+          )
         );
       assert
         .dom('picture source[type="image/avif"]')
         .hasAttribute(
           'srcset',
-          new RegExp('/assets/images/tests/recursive/dir/test50w.avif 50w')
+          new RegExp(
+            '/assets/images/tests/recursive/dir/test50w(-\\w+)?.avif 50w'
+          )
         );
     });
 
@@ -438,33 +482,50 @@ module('Integration: Responsive Image Component', function (hooks) {
       );
       assert
         .dom('img')
-        .hasAttribute('src', '/assets/images/tests/test640w.png');
+        .hasAttribute(
+          'src',
+          new RegExp('/assets/images/tests/test640w(-\\w+)?.png')
+        );
 
       await render(
         hbs`<ResponsiveImage @width={{101}} @src="assets/images/tests/test.png"/>`
       );
       assert
         .dom('img')
-        .hasAttribute('src', '/assets/images/tests/test640w.png');
+        .hasAttribute(
+          'src',
+          new RegExp('/assets/images/tests/test640w(-\\w+)?.png')
+        );
 
       await render(
         hbs`<ResponsiveImage @width={{100}} @src="assets/images/tests/test.png"/>`
       );
       assert
         .dom('img')
-        .hasAttribute('src', '/assets/images/tests/test100w.png');
+        .hasAttribute(
+          'src',
+          new RegExp('/assets/images/tests/test100w(-\\w+)?.png')
+        );
 
       await render(
         hbs`<ResponsiveImage @width={{51}} @src="assets/images/tests/test.png"/>`
       );
       assert
         .dom('img')
-        .hasAttribute('src', '/assets/images/tests/test100w.png');
+        .hasAttribute(
+          'src',
+          new RegExp('/assets/images/tests/test100w(-\\w+)?.png')
+        );
 
       await render(
         hbs`<ResponsiveImage @width={{50}} @src="assets/images/tests/test.png"/>`
       );
-      assert.dom('img').hasAttribute('src', '/assets/images/tests/test50w.png');
+      assert
+        .dom('img')
+        .hasAttribute(
+          'src',
+          new RegExp('/assets/images/tests/test50w(-\\w+)?.png')
+        );
     });
   });
 
diff --git a/tests/integration/helpers/responsive-image-resolve-test.js b/tests/integration/helpers/responsive-image-resolve-test.js
index b66fcf4b..ecdea9c4 100644
--- a/tests/integration/helpers/responsive-image-resolve-test.js
+++ b/tests/integration/helpers/responsive-image-resolve-test.js
@@ -10,7 +10,9 @@ module('Helper: ResponsiveImageResolve', function (hooks) {
     await render(
       hbs`<h1>{{responsive-image-resolve "assets/images/tests/test.png"}}</h1>`
     );
-    assert.dom('h1').hasText('/assets/images/tests/test640w.png');
+    assert
+      .dom('h1')
+      .hasText(new RegExp('/assets/images/tests/test640w(-\\w+)?.png'));
   });
 
   test('supports size', async function (assert) {
@@ -20,7 +22,9 @@ module('Helper: ResponsiveImageResolve', function (hooks) {
       hbs`<h1>{{responsive-image-resolve "assets/images/tests/test.png" size=45}}</h1>`
     );
 
-    assert.dom('h1').hasText('/assets/images/tests/test50w.png');
+    assert
+      .dom('h1')
+      .hasText(new RegExp('/assets/images/tests/test50w(-\\w+)?.png'));
   });
 
   test('supports format', async function (assert) {
@@ -28,6 +32,8 @@ module('Helper: ResponsiveImageResolve', function (hooks) {
       hbs`<h1>{{responsive-image-resolve "assets/images/tests/test.png" format="webp"}}</h1>`
     );
 
-    assert.dom('h1').hasText('/assets/images/tests/test640w.webp');
+    assert
+      .dom('h1')
+      .hasText(new RegExp('/assets/images/tests/test640w(-\\w+)?.webp'));
   });
 });
diff --git a/tests/unit/services/responsive-image-test.js b/tests/unit/services/responsive-image-test.js
deleted file mode 100644
index d5f072d4..00000000
--- a/tests/unit/services/responsive-image-test.js
+++ /dev/null
@@ -1,94 +0,0 @@
-import { setupTest } from 'ember-qunit';
-import { module, test } from 'qunit';
-
-const meta = {
-  prepend: '',
-  'test.png': {
-    images: [
-      {
-        image: '/assets/images/responsive/test100w.png',
-        width: 100,
-        height: 100,
-        type: 'png',
-      },
-      {
-        image: '/assets/images/responsive/test50w.png',
-        width: 50,
-        height: 50,
-        type: 'png',
-      },
-      {
-        image:
-          '/assets/images/responsive/test100w-00e24234f1b58e32b935b1041432916f.webp',
-        width: 100,
-        height: 100,
-        type: 'webp',
-      },
-      {
-        image:
-          '/assets/images/responsive/test50w-00e24234f1b58e32b935b1041432916f.webp',
-        width: 50,
-        height: 50,
-        type: 'webp',
-      },
-    ],
-  },
-};
-
-module('ResponsiveImageService', function (hooks) {
-  setupTest(hooks);
-
-  hooks.beforeEach(function () {
-    let service = this.owner.lookup('service:responsive-image');
-    service.set('meta', meta);
-  });
-
-  test('retrieve generated images by name', function (assert) {
-    let service = this.owner.lookup('service:responsive-image');
-    let images = service.getImages('test.png');
-    assert.deepEqual(images, meta['test.png'].images);
-  });
-
-  test('handle absolute pats', function (assert) {
-    let service = this.owner.lookup('service:responsive-image');
-    let images = service.getImages('/test.png');
-    assert.deepEqual(images, meta['test.png'].images);
-  });
-
-  test('retrieve generated images by name and type', function (assert) {
-    let service = this.owner.lookup('service:responsive-image');
-    let images = service.getImages('test.png', 'png');
-    assert.deepEqual(images, meta['test.png'].images.slice(0, 2));
-
-    images = service.getImages('test.png', 'webp');
-    assert.deepEqual(images, meta['test.png'].images.slice(2, 4));
-  });
-
-  test('get available types', function (assert) {
-    let service = this.owner.lookup('service:responsive-image');
-    let types = service.getAvailableTypes('test.png');
-    assert.deepEqual(types, ['png', 'webp']);
-  });
-
-  test('retrieve generated image data by size', function (assert) {
-    let service = this.owner.lookup('service:responsive-image');
-    service.physicalWidth = 100;
-    let images = service.getImageMetaBySize('test.png', 120);
-    assert.deepEqual(images, meta['test.png'].images[0]);
-    images = service.getImageMetaBySize('test.png', 60);
-    assert.deepEqual(images, meta['test.png'].images[0]);
-    images = service.getImageMetaBySize('test.png', 45);
-    assert.deepEqual(images, meta['test.png'].images[1]);
-  });
-
-  test('retrieve generated image data by size and type', function (assert) {
-    let service = this.owner.lookup('service:responsive-image');
-    service.physicalWidth = 100;
-    let images = service.getImageMetaBySize('test.png', 120, 'webp');
-    assert.deepEqual(images, meta['test.png'].images[2]);
-    images = service.getImageMetaBySize('test.png', 60, 'webp');
-    assert.deepEqual(images, meta['test.png'].images[2]);
-    images = service.getImageMetaBySize('test.png', 45, 'webp');
-    assert.deepEqual(images, meta['test.png'].images[3]);
-  });
-});
diff --git a/tests/unit/services/responsive-image-test.ts b/tests/unit/services/responsive-image-test.ts
new file mode 100644
index 00000000..24d027de
--- /dev/null
+++ b/tests/unit/services/responsive-image-test.ts
@@ -0,0 +1,149 @@
+import { setupTest } from 'ember-qunit';
+import { module, test } from 'qunit';
+import {
+  ImageMeta,
+  Meta,
+} from 'ember-responsive-image/services/responsive-image';
+
+interface TestCase {
+  moduleTitle: string;
+  meta: Record<string, Meta>;
+  imageMetas: ImageMeta[];
+}
+const testCases: TestCase[] = [
+  {
+    moduleTitle: 'without fingerprinting',
+    meta: {
+      'test.png': {
+        widths: [50, 100],
+        formats: ['png', 'webp'],
+        aspectRatio: 1,
+      },
+    },
+    imageMetas: [
+      {
+        image: '/test50w.png',
+        width: 50,
+        height: 50,
+        type: 'png',
+      },
+      {
+        image: '/test50w.webp',
+        width: 50,
+        height: 50,
+        type: 'webp',
+      },
+      {
+        image: '/test100w.png',
+        width: 100,
+        height: 100,
+        type: 'png',
+      },
+      {
+        image: '/test100w.webp',
+        width: 100,
+        height: 100,
+        type: 'webp',
+      },
+    ],
+  },
+  {
+    moduleTitle: 'with fingerprinting',
+    meta: {
+      'test.png': {
+        widths: [50, 100],
+        formats: ['png', 'webp'],
+        aspectRatio: 1,
+        fingerprint: '1234567890',
+      },
+    },
+    imageMetas: [
+      {
+        image: '/test50w-1234567890.png',
+        width: 50,
+        height: 50,
+        type: 'png',
+      },
+      {
+        image: '/test50w-1234567890.webp',
+        width: 50,
+        height: 50,
+        type: 'webp',
+      },
+      {
+        image: '/test100w-1234567890.png',
+        width: 100,
+        height: 100,
+        type: 'png',
+      },
+      {
+        image: '/test100w-1234567890.webp',
+        width: 100,
+        height: 100,
+        type: 'webp',
+      },
+    ],
+  },
+];
+
+module('ResponsiveImageService', function (hooks) {
+  setupTest(hooks);
+
+  testCases.forEach(({ moduleTitle, meta, imageMetas }) => {
+    module(moduleTitle, function (hooks) {
+      hooks.beforeEach(function () {
+        const service = this.owner.lookup('service:responsive-image');
+        service.set('meta', meta);
+      });
+
+      test('retrieve generated images by name', function (assert) {
+        const service = this.owner.lookup('service:responsive-image');
+        const images = service.getImages('test.png');
+        assert.deepEqual(images, imageMetas);
+      });
+
+      test('handle absolute paths', function (assert) {
+        const service = this.owner.lookup('service:responsive-image');
+        const images = service.getImages('/test.png');
+        assert.deepEqual(images, imageMetas);
+      });
+
+      test('retrieve generated images by name and type', function (assert) {
+        const service = this.owner.lookup('service:responsive-image');
+        let images = service.getImages('test.png', 'png');
+        assert.deepEqual(images, [imageMetas[0], imageMetas[2]]);
+
+        images = service.getImages('test.png', 'webp');
+        assert.deepEqual(images, [imageMetas[1], imageMetas[3]]);
+      });
+
+      test('get available types', function (assert) {
+        const service = this.owner.lookup('service:responsive-image');
+        const types = service.getAvailableTypes('test.png');
+        assert.deepEqual(types, ['png', 'webp']);
+      });
+
+      test('retrieve generated image data by size', function (assert) {
+        const service = this.owner.lookup('service:responsive-image');
+        service.physicalWidth = 100;
+        let images = service.getImageMetaBySize('test.png', 120);
+        assert.deepEqual(images, imageMetas[2]);
+        images = service.getImageMetaBySize('test.png', 60);
+        assert.deepEqual(images, imageMetas[2]);
+        images = service.getImageMetaBySize('test.png', 45);
+        assert.deepEqual(images, imageMetas[0]);
+      });
+
+      test('retrieve generated image data by size and type', function (assert) {
+        const service = this.owner.lookup('service:responsive-image');
+        service.physicalWidth = 100;
+        let images = service.getImageMetaBySize('test.png', 120, 'webp');
+        assert.deepEqual(images, imageMetas[3]);
+        images = service.getImageMetaBySize('test.png', 60, 'webp');
+        assert.deepEqual(images, imageMetas[3]);
+        images = service.getImageMetaBySize('test.png', 45, 'webp');
+        assert.deepEqual(images, imageMetas[1]);
+      });
+    });
+  });
+});