/
githubmirror
/
angular
Обзор
Документация
Войти
/
githubmirror
/
angular
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/common/test/directives/ng_optimized_image_spec.ts
2 696 строк
116 KB
SkyZeroZx
refactor(common): modernize directive tests to rely on whenStable
04 авг 2026, 18:45
04 авг 2026, 18:45
2f0be5b
Код
Авторство
О чём код?
/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ import {ChangeDetectionStrategy, Component, PLATFORM_ID, Provider, Type} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser'; import {isBrowser, isNode, withHead} from '@angular/private/testing'; import {expect} from '@angular/private/testing/matchers'; import {DOCUMENT, IMAGE_CONFIG, ImageConfig} from '../../index'; import {RuntimeErrorCode} from '../../src/errors'; import {PLATFORM_SERVER_ID} from '../../src/platform_id'; import {PRELOADED_IMAGES} from '../..//src/directives/ng_optimized_image/tokens'; import { createImageLoader, IMAGE_LOADER, ImageLoader, ImageLoaderConfig, } from '../../src/directives/ng_optimized_image/image_loaders/image_loader'; import { ABSOLUTE_SRCSET_DENSITY_CAP, assertValidNgSrcset, DATA_URL_ERROR_LIMIT, DATA_URL_WARN_LIMIT, NgOptimizedImage, RECOMMENDED_SRCSET_DENSITY_CAP, resetImagePriorityCount, } from '../../src/directives/ng_optimized_image/ng_optimized_image'; import {PRECONNECT_CHECK_BLOCKLIST} from '../../src/directives/ng_optimized_image/preconnect_link_checker'; import {escapeCssUrl} from '../../src/directives/ng_optimized_image/url'; describe('Image directive', () => { const PLACEHOLDER_BLUR_AMOUNT = 15; describe('preload <link> element on a server', () => { describe('server', () => { beforeEach(() => { globalThis['ngServerMode'] = true; }); afterEach(() => { globalThis['ngServerMode'] = undefined; }); it('should create `<link>` element when the image priority attr is true', async () => { // Only run this test in a browser since the Node-based DOM mocks don't // allow to override `HTMLImageElement.prototype.setAttribute` easily. if (!isBrowser) return; const src = 'preload1/img.png'; setupTestingModule({ extraProviders: [ {provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID}, { provide: IMAGE_LOADER, useValue: (config: ImageLoaderConfig) => config.width ? `https://angular.dev/${config.src}?width=${config.width}` : `https://angular.dev/${config.src}`, }, ], }); const template = `<img ngSrc="${src}" width="150" height="50" priority sizes="10vw" ngSrcset="100w" crossorigin="anonymous">`; TestBed.overrideComponent(TestComponent, {set: {template: template}}); const _document = TestBed.inject(DOCUMENT); const _window = _document.defaultView!; const setAttributeSpy = spyOn( _window.HTMLLinkElement.prototype, 'setAttribute', ).and.callThrough(); const fixture = TestBed.createComponent(TestComponent); await fixture.whenStable(); const head = _document.head; const rewrittenSrc = `https://angular.dev/${src}`; const preloadLink = head.querySelector(`link[href="${rewrittenSrc}"]`); expect(preloadLink).toBeTruthy(); const [name, value] = setAttributeSpy.calls.argsFor(0); expect(name).toEqual('as'); expect(value).toEqual('image'); expect(preloadLink!.getAttribute('rel')).toEqual('preload'); expect(preloadLink!.getAttribute('as')).toEqual('image'); expect(preloadLink!.getAttribute('imagesizes')).toEqual('10vw'); expect(preloadLink!.getAttribute('imagesrcset')).toEqual(`${rewrittenSrc}?width=100 100w`); expect(preloadLink!.getAttribute('fetchpriority')).toEqual('high'); expect(preloadLink!.getAttribute('crossorigin')).toEqual('anonymous'); preloadLink!.remove(); }); it('should not create a preload `<link>` element when src is already preloaded.', async () => { // Only run this test in a browser since the Node-based DOM mocks don't // allow to override `HTMLImageElement.prototype.setAttribute` easily. if (!isBrowser) return; const src = `preload2/img.png`; const rewrittenSrc = `https://angular.dev/${src}`; setupTestingModule({ extraProviders: [ {provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID}, { provide: IMAGE_LOADER, useValue: (config: ImageLoaderConfig) => `https://angular.dev/${config.src}`, }, ], }); const template = `<img ngSrc="${src}" width="150" height="50" priority><img ngSrc="${src}" width="150" height="50" priority>`; TestBed.overrideComponent(TestComponent, {set: {template: template}}); const _document = TestBed.inject(DOCUMENT); const fixture = TestBed.createComponent(TestComponent); await fixture.whenStable(); const head = _document.head; const preloadImages = TestBed.inject(PRELOADED_IMAGES); expect(preloadImages.has(`${rewrittenSrc}:null`)).toBeTruthy(); const preloadLinks = head.querySelectorAll(`link[href="${rewrittenSrc}"]`); expect(preloadLinks.length).toEqual(1); preloadLinks[0]!.remove(); }); it('should create separate preload links for images with different CORS modes', async () => { if (!isBrowser) return; const src = 'preload-cors/img.png'; const rewrittenSrc = `https://angular.dev/${src}`; setupTestingModule({ extraProviders: [ {provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID}, { provide: IMAGE_LOADER, useValue: (config: ImageLoaderConfig) => `https://angular.dev/${config.src}`, }, ], }); const template = ` <img ngSrc="${src}" width="150" height="50" priority> <img ngSrc="${src}" width="150" height="50" priority crossorigin="anonymous"> `; TestBed.overrideComponent(TestComponent, {set: {template}}); const _document = TestBed.inject(DOCUMENT); const fixture = TestBed.createComponent(TestComponent); await fixture.whenStable(); const preloadLinks = _document.head.querySelectorAll(`link[href="${rewrittenSrc}"]`); expect(preloadLinks.length).toEqual(2); expect(preloadLinks[0]!.hasAttribute('crossorigin')).toBeFalse(); expect(preloadLinks[1]!.getAttribute('crossorigin')).toEqual('anonymous'); preloadLinks.forEach((link) => link.remove()); }); it('should warn when the number of preloaded images is larger than the limit', async () => { // Only run this test in a browser since the Node-based DOM mocks don't // allow to override `HTMLImageElement.prototype.setAttribute` easily. if (!isBrowser) return; setupTestingModule({ extraProviders: [ {provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID}, { provide: IMAGE_LOADER, useValue: (config: ImageLoaderConfig) => `https://angular.dev/${config.src}`, }, ], }); const template = ` <img ngSrc="preloaderror2/img.png" width="150" height="50" priority> <img ngSrc="preloaderror3/img.png" width="150" height="50" priority> <img ngSrc="preloaderro4/img.png" width="150" height="50" priority> <img ngSrc="preloaderror5/img.png" width="150" height="50" priority> <img ngSrc="preloaderror6/img.png" width="150" height="50" priority> <img ngSrc="preloaderror7/img.png" width="150" height="50" priority> <img ngSrc="preloaderror8/img.png" width="150" height="50" priority> <img ngSrc="preloaderror9/img.png" width="150" height="50" priority> <img ngSrc="preloaderror10/img.png" width="150" height="50" priority> `; const consoleWarnSpy = spyOn(console, 'warn'); const fixture = createTestComponent(template); await fixture.whenStable(); expect(consoleWarnSpy.calls.count()).toBe(1); expect(consoleWarnSpy.calls.argsFor(0)[0]).toMatch( /NG02961: The `NgOptimizedImage` directive has detected that more than 5 images were marked as priority/, ); }); }); it('should not hit max preload limit when not on the server', async () => { // Only run this test in a browser since the Node-based DOM mocks don't // allow to override `HTMLImageElement.prototype.setAttribute` easily. if (!isBrowser) return; setupTestingModule({ extraProviders: [ { provide: IMAGE_LOADER, useValue: (config: ImageLoaderConfig) => `https://angular.dev/${config.src}`, }, ], }); const template = ` <img ngSrc="preloadbrowser1/img.png" width="150" height="50" priority> <img ngSrc="preloadbrowser2/img.png" width="150" height="50" priority> <img ngSrc="preloadbrowser3/img.png" width="150" height="50" priority> <img ngSrc="preloadbrowser4/img.png" width="150" height="50" priority> <img ngSrc="preloadbrowser5/img.png" width="150" height="50" priority> <img ngSrc="preloadbrowser6/img.png" width="150" height="50" priority> <img ngSrc="preloadbrowser7/img.png" width="150" height="50" priority> <img ngSrc="preloadbrowser8/img.png" width="150" height="50" priority> <img ngSrc="preloadbrowser9/img.png" width="150" height="50" priority> `; TestBed.overrideComponent(TestComponent, {set: {template: template}}); const _document = TestBed.inject(DOCUMENT); const fixture = TestBed.createComponent(TestComponent); await fixture.whenStable(); const head = _document.head; const preloadImages = TestBed.inject(PRELOADED_IMAGES); const preloadLinks = head.querySelectorAll(`link[preload]`); expect(preloadImages.size).toEqual(0); expect(preloadLinks.length).toEqual(0); }); }); it('should set `loading` and `fetchpriority` attributes before `src`', async () => { // Only run this test in a browser since the Node-based DOM mocks don't // allow to override `HTMLImageElement.prototype.setAttribute` easily. if (!isBrowser) return; setupTestingModule(); const template = '<img ngSrc="path/img.png" width="150" height="50" priority>'; TestBed.overrideComponent(TestComponent, {set: {template: template}}); const _document = TestBed.inject(DOCUMENT); const _window = _document.defaultView!; const setAttributeSpy = spyOn( _window.HTMLImageElement.prototype, 'setAttribute', ).and.callThrough(); const fixture = TestBed.createComponent(TestComponent); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('loading')).toBe('eager'); let _imgInstance = null; let _loadingAttrId = -1; let _fetchpriorityAttrId = -1; let _srcAttrId = -1; const count = setAttributeSpy.calls.count(); for (let i = 0; i < count; i++) { if (!_imgInstance) { _imgInstance = setAttributeSpy.calls.thisFor(i); } else if (_imgInstance !== setAttributeSpy.calls.thisFor(i)) { // Verify that the <img> instance is the same during the test. fail('Unexpected instance of a second <img> instance present in a test.'); } // Note: spy.calls.argsFor(i) returns args as an array: ['src', 'eager'] const attrName = setAttributeSpy.calls.argsFor(i)[0]; if (attrName == 'loading') _loadingAttrId = i; if (attrName == 'fetchpriority') _fetchpriorityAttrId = i; if (attrName == 'src') _srcAttrId = i; } // Verify that both `loading` and `fetchpriority` are set *before* `src`: expect(_loadingAttrId).toBeGreaterThan(-1); // was actually set expect(_loadingAttrId).toBeLessThan(_srcAttrId); // was set after `src` expect(_fetchpriorityAttrId).toBeGreaterThan(-1); // was actually set expect(_fetchpriorityAttrId).toBeLessThan(_srcAttrId); // was set after `src` }); it('should always reflect the width/height attributes if bound', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" [width]="width" [height]="height">'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('width')).toBe('100'); expect(img.getAttribute('height')).toBe('50'); }); describe('setup error handling', () => { it('should throw if both `src` and `ngSrc` are present', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" src="path/img2.png" width="100" height="50">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02950: The NgOptimizedImage directive (activated on an <img> ' + 'element with the `ngSrc="path/img.png"`) has detected that both ' + '`src` and `ngSrc` have been set. Supplying both of these attributes ' + 'breaks lazy loading. The NgOptimizedImage directive sets `src` ' + 'itself based on the value of `ngSrc`. To fix this, please remove ' + 'the `src` attribute.', ); }); it('should throw if both `ngSrcet` and `srcset` is present', async () => { setupTestingModule(); const template = '<img ngSrc="img-100.png" ngSrcset="100w, 200w" srcset="img-100.png 100w, img-200.png 200w" width="100" height="50">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02951: The NgOptimizedImage directive (activated on an <img> ' + 'element with the `ngSrc="img-100.png"`) has detected that both ' + '`srcset` and `ngSrcset` have been set. Supplying both of these ' + 'attributes breaks lazy loading. ' + 'The NgOptimizedImage directive sets `srcset` itself based ' + 'on the value of `ngSrcset`. To fix this, please remove the `srcset` ' + 'attribute.', ); }); it('should throw if `ngSrc` contains a Base64-encoded image (that starts with `data:`)', async () => { setupTestingModule(); const template = '<img ngSrc="' + ANGULAR_LOGO_BASE64 + '" width="50" height="50">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02952: The NgOptimizedImage directive has detected that `ngSrc` ' + 'is a Base64-encoded string (data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDov...). ' + 'NgOptimizedImage does not support Base64-encoded strings. ' + 'To fix this, disable the NgOptimizedImage directive for this element ' + 'by removing `ngSrc` and using a standard `src` attribute instead.', ); }); it('should throw if `ngSrc` contains a `blob:` URL', (done) => { // Domino does not support canvas elements properly, // so run this test only in a browser. if (!isBrowser) { done(); return; } const canvas = document.createElement('canvas'); canvas.toBlob(async function (blob) { const blobURL = URL.createObjectURL(blob!); setupTestingModule(); // Note: use RegExp to partially match the error message, since the blob URL // is created dynamically, so it might be different for each invocation. const errorMessageRegExp = /NG02952: The NgOptimizedImage directive (.*?) has detected that `ngSrc` was set to a blob URL \(blob:/; const template = '<img ngSrc="' + blobURL + '" width="50" height="50">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError(errorMessageRegExp); done(); }); }); it('should throw if `width` and `height` are not set', async () => { setupTestingModule(); const template = '<img ngSrc="img.png">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02954: The NgOptimizedImage directive (activated on an <img> ' + 'element with the `ngSrc="img.png"`) has detected that these ' + 'required attributes are missing: "width", "height". Including "width" and ' + '"height" attributes will prevent image-related layout shifts. ' + 'To fix this, include "width" and "height" attributes on the image tag or turn on ' + '"fill" mode with the `fill` attribute.', ); }); it('should throw if `width` is not set', async () => { setupTestingModule(); const template = '<img ngSrc="img.png" height="10">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02954: The NgOptimizedImage directive (activated on an <img> ' + 'element with the `ngSrc="img.png"`) has detected that these ' + 'required attributes are missing: "width". Including "width" and ' + '"height" attributes will prevent image-related layout shifts. ' + 'To fix this, include "width" and "height" attributes on the image tag or turn on ' + '"fill" mode with the `fill` attribute.', ); }); it('should throw if `width` is 0', async () => { setupTestingModule(); const template = '<img ngSrc="img.png" width="0" height="10">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02952: The NgOptimizedImage directive (activated on an <img> ' + 'element with the `ngSrc="img.png"`) has detected that `width` ' + 'has an invalid value. To fix this, provide `width` as ' + 'a number greater than 0.', ); }); it('should throw if `width` has an invalid value', async () => { setupTestingModule(); const template = '<img ngSrc="img.png" width="10px" height="10">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02952: The NgOptimizedImage directive (activated on an <img> ' + 'element with the `ngSrc="img.png"`) has detected that `width` ' + 'has an invalid value. To fix this, provide `width` ' + 'as a number greater than 0.', ); }); it('should throw if `height` is not set', async () => { setupTestingModule(); const template = '<img ngSrc="img.png" width="10">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02954: The NgOptimizedImage directive (activated on an <img> ' + 'element with the `ngSrc="img.png"`) has detected that these required ' + 'attributes are missing: "height". Including "width" and "height" ' + 'attributes will prevent image-related layout shifts. ' + 'To fix this, include "width" and "height" attributes on the image tag or turn on ' + '"fill" mode with the `fill` attribute.', ); }); it('should throw if `height` is 0', async () => { setupTestingModule(); const template = '<img ngSrc="img.png" width="10" height="0">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02952: The NgOptimizedImage directive (activated on an <img> ' + 'element with the `ngSrc="img.png"`) has detected that `height` ' + 'has an invalid value. To fix this, provide `height` as a number ' + 'greater than 0.', ); }); it('should throw if `height` has an invalid value', async () => { setupTestingModule(); const template = '<img ngSrc="img.png" width="10" height="10%">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02952: The NgOptimizedImage directive (activated on an <img> element ' + 'with the `ngSrc="img.png"`) has detected that `height` has an invalid ' + 'value. To fix this, provide `height` as a number greater than 0.', ); }); it('should throw if `ngSrc` value is not provided', async () => { setupTestingModule(); const template = '<img ngSrc>'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02952: The NgOptimizedImage directive (activated on an <img> ' + 'element with the `ngSrc=""`) has detected that `ngSrc` has an ' + 'invalid value (``). ' + 'To fix this, change the value to a non-empty string.', ); }); it('should throw if `ngSrc` value is set to an empty string', async () => { setupTestingModule(); const template = '<img ngSrc=" ">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02952: The NgOptimizedImage directive (activated on an <img> element ' + 'with the `ngSrc=" "`) has detected that `ngSrc` has an invalid value ' + '(` `). To fix this, change the value to a non-empty string.', ); }); describe('invalid `ngSrcset` values', () => { const mockDirectiveInstance = {ngSrc: 'img.png'} as NgOptimizedImage; it('should throw for empty ngSrcSet', async () => { const imageLoader = (config: ImageLoaderConfig) => { const width = config.width ? `-${config.width}` : ``; return window.location.origin + `/path/${config.src}${width}.png`; }; setupTestingModule({imageLoader}); const template = ` <img ngSrc="img" ngSrcset="" width="100" height="50"> `; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02952: The NgOptimizedImage directive (activated on an <img> ' + 'element with the `ngSrc="img"`) has detected that `ngSrcset` ' + 'has an invalid value (``). ' + 'To fix this, change the value to a non-empty string.', ); }); it('should throw for invalid ngSrcSet', async () => { const imageLoader = (config: ImageLoaderConfig) => { const width = config.width ? `-${config.width}` : ``; return window.location.origin + `/path/${config.src}${width}.png`; }; setupTestingModule({imageLoader}); const template = ` <img ngSrc="img" ngSrcset="100q, 200q" width="100" height="50"> `; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02952: The NgOptimizedImage directive (activated on an <img> element ' + 'with the `ngSrc="img"`) has detected that `ngSrcset` has an invalid value ' + '(`100q, 200q`). To fix this, supply `ngSrcset` using a comma-separated list ' + 'of one or more width descriptors (e.g. "100w, 200w") or density descriptors ' + '(e.g. "1x, 2x").', ); }); it('should throw if ngSrcset exceeds the density cap', async () => { const imageLoader = (config: ImageLoaderConfig) => { const width = config.width ? `-${config.width}` : ``; return window.location.origin + `/path/${config.src}${width}.png`; }; setupTestingModule({imageLoader}); const template = ` <img ngSrc="img" ngSrcset="1x, 2x, 3x, 4x, 5x" width="100" height="50"> `; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( `NG0${RuntimeErrorCode.INVALID_INPUT}: The NgOptimizedImage directive (activated on an <img> element with the \`ngSrc="img"\`) ` + `has detected that the \`ngSrcset\` contains an unsupported image density:` + `\`1x, 2x, 3x, 4x, 5x\`. NgOptimizedImage generally recommends a max image density of ` + `${RECOMMENDED_SRCSET_DENSITY_CAP}x but supports image densities up to ` + `${ABSOLUTE_SRCSET_DENSITY_CAP}x. The human eye cannot distinguish between image densities ` + `greater than ${RECOMMENDED_SRCSET_DENSITY_CAP}x - which makes them unnecessary for ` + `most use cases. Images that will be pinch-zoomed are typically the primary use case for ` + `${ABSOLUTE_SRCSET_DENSITY_CAP}x images. Please remove the high density descriptor and try again.`, ); }); it('should throw if ngSrcset exceeds the density cap with multiple digits', async () => { const imageLoader = (config: ImageLoaderConfig) => { const width = config.width ? `-${config.width}` : ``; return window.location.origin + `/path/${config.src}${width}.png`; }; setupTestingModule({imageLoader}); const template = ` <img ngSrc="img" ngSrcset="1x, 200x" width="100" height="50"> `; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( `NG0${RuntimeErrorCode.INVALID_INPUT}: The NgOptimizedImage directive (activated on an <img> element with the \`ngSrc="img"\`) ` + `has detected that the \`ngSrcset\` contains an unsupported image density:` + `\`1x, 200x\`. NgOptimizedImage generally recommends a max image density of ` + `${RECOMMENDED_SRCSET_DENSITY_CAP}x but supports image densities up to ` + `${ABSOLUTE_SRCSET_DENSITY_CAP}x. The human eye cannot distinguish between image densities ` + `greater than ${RECOMMENDED_SRCSET_DENSITY_CAP}x - which makes them unnecessary for ` + `most use cases. Images that will be pinch-zoomed are typically the primary use case for ` + `${ABSOLUTE_SRCSET_DENSITY_CAP}x images. Please remove the high density descriptor and try again.`, ); }); it('should throw if width srcset is missing a comma', () => { expect(() => { assertValidNgSrcset(mockDirectiveInstance, '100w 200w'); }).toThrowError(); }); it('should throw if density srcset is missing a comma', () => { expect(() => { assertValidNgSrcset(mockDirectiveInstance, '1x 2x'); }).toThrowError(); }); it('should throw if density srcset has too many digits', () => { expect(() => { assertValidNgSrcset(mockDirectiveInstance, '100x, 2x'); }).toThrowError(); }); it('should throw if width srcset includes a file name', () => { expect(() => { assertValidNgSrcset(mockDirectiveInstance, 'a.png 100w, b.png 200w'); }).toThrowError(); }); it('should throw if density srcset includes a file name', () => { expect(() => { assertValidNgSrcset(mockDirectiveInstance, 'a.png 1x, b.png 2x'); }).toThrowError(); }); it('should throw if srcset starts with a letter', () => { expect(() => { assertValidNgSrcset(mockDirectiveInstance, 'a100w, 200w'); }).toThrowError(); }); it('should throw if srcset starts with another non-digit', () => { expect(() => { assertValidNgSrcset(mockDirectiveInstance, '--100w, 200w'); }).toThrowError(); }); it('should throw if first descriptor in srcset is junk', () => { expect(() => { assertValidNgSrcset(mockDirectiveInstance, 'foo, 1x'); }).toThrowError(); }); it('should throw if later descriptors in srcset are junk', () => { expect(() => { assertValidNgSrcset(mockDirectiveInstance, '100w, foo'); }).toThrowError(); }); it('should throw if srcset has a density descriptor after a width descriptor', () => { expect(() => { assertValidNgSrcset(mockDirectiveInstance, '100w, 1x'); }).toThrowError(); }); it('should throw if srcset has a width descriptor after a density descriptor', () => { expect(() => { assertValidNgSrcset(mockDirectiveInstance, '1x, 200w'); }).toThrowError(); }); }); const inputs = [ ['width', 10], ['height', 20], ['priority', true], ['fill', true], ['loading', true], ['sizes', '90vw'], ['disableOptimizedSrcset', true], ['loaderParams', '{foo: "test1"}'], ]; inputs.forEach(([inputName, value]) => { it(`should throw if the \`${inputName}\` input changed after directive initialized the input`, async () => { @Component({ selector: 'test-cmp', template: `<img [ngSrc]="ngSrc" [width]="width" [height]="height" [priority]="priority" [fill]="fill" [loading]="loading" [sizes]="sizes" [disableOptimizedSrcset]="disableOptimizedSrcset" [loaderParams]="loaderParams" />`, imports: [NgOptimizedImage], changeDetection: ChangeDetectionStrategy.Eager, }) class TestComponent { width = 100; height = 50; ngSrc = 'img.png'; priority = false; fill = false; loading = false; sizes = '100vw'; disableOptimizedSrcset = false; loaderParams = {bar: 'test2'}; } setupTestingModule({component: TestComponent}); // Initial render const fixture = TestBed.createComponent(TestComponent); await fixture.whenStable(); const expectedErrorMessage = // `NG02953: The NgOptimizedImage directive (.*)? ` + `has detected that \`${inputName}\` was updated after initialization`; // Update input (expect to throw) (fixture.componentInstance as unknown as {[key: string]: unknown})[inputName as string] = value; fixture.changeDetectorRef.markForCheck(); await expectAsync(fixture.whenStable()).toBeRejectedWithError( new RegExp(expectedErrorMessage), ); }); }); it(`should not throw if ngSrc changed after directive is initialized`, async () => { @Component({ selector: 'test-cmp', template: `<img [ngSrc]="ngSrc" [width]="width" [height]="height" [loading]="loading" [sizes]="sizes" />`, imports: [NgOptimizedImage], }) class TestComponent { width = 100; height = 50; ngSrc = 'img.png'; loading = false; sizes = '100vw'; } setupTestingModule({component: TestComponent}); // Initial render const fixture = TestBed.createComponent(TestComponent); await fixture.whenStable(); fixture.componentInstance.ngSrc = 'newImg.png'; fixture.changeDetectorRef.markForCheck(); await expectAsync(fixture.whenStable()).toBeResolved(); }); it('should accept a safeUrl ngSrc value', async () => { @Component({ selector: 'test-cmp', template: `<img [ngSrc]="bypassImage" width="400" height="600" />`, imports: [NgOptimizedImage], }) class TestComponent { rawImage = `javascript:alert("Hi there")`; bypassImage: SafeResourceUrl; constructor(private sanitizer: DomSanitizer) { this.bypassImage = sanitizer.bypassSecurityTrustResourceUrl(this.rawImage); } } setupTestingModule({component: TestComponent}); const fixture = TestBed.createComponent(TestComponent); await fixture.whenStable(); let nativeElement = fixture.nativeElement as HTMLElement; let img = nativeElement.querySelector('img')!; expect(img.src).toContain(`${IMG_BASE_URL}/javascript:alert`); }); }); describe('lazy loading', () => { it('should eagerly load priority images', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="150" height="50" priority>'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('loading')).toBe('eager'); }); it('should lazily load non-priority images', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="150" height="50">'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('loading')).toBe('lazy'); }); }); describe('decoding attribute', () => { it('should throw for invalid loading inputs', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="150" height="150" decoding="unknown_value">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02952: The NgOptimizedImage directive ' + '(activated on an <img> element with the `ngSrc="path/img.png"`) has detected ' + 'that the `decoding` attribute has an invalid value (`unknown_value`). ' + 'To fix this, provide a valid value ("sync", "async", or "auto").', ); }); it('should set the decoding to "auto" by default', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="150" height="150">'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('decoding')).toEqual('auto'); }); it('should set the decoding to sync for priority images', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="150" height="50" priority>'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('decoding')).toEqual('sync'); }); it('should override the default decoding behavior', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="150" height="150" decoding="async">'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('decoding')).toEqual('async'); }); }); describe('loading attribute', () => { it('should override the default loading behavior for non-priority images', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="150" height="50" loading="eager">'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('loading')).toBe('eager'); }); it('should throw if used with priority images', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="150" height="50" loading="eager" priority>'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02952: The NgOptimizedImage directive (activated on an <img> element ' + 'with the `ngSrc="path/img.png"`) has detected that the `loading` attribute ' + 'was used on an image that was marked "priority". Setting `loading` on priority ' + 'images is not allowed because these images will always be eagerly loaded. ' + 'To fix this, remove the “loading” attribute from the priority image.', ); }); it('should support setting loading priority to "auto"', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="150" height="50" loading="auto">'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('loading')).toBe('auto'); }); it('should throw for invalid loading inputs', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="150" height="150" loading="fast">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02952: The NgOptimizedImage directive (activated on an <img> element ' + 'with the `ngSrc="path/img.png"`) has detected that the `loading` attribute ' + 'has an invalid value (`fast`). To fix this, provide a valid value ("lazy", ' + '"eager", or "auto").', ); }); }); describe('fetch priority', () => { it('should be "high" for priority images', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="150" height="50" priority>'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('fetchpriority')).toBe('high'); }); it('should be "auto" for non-priority images', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="150" height="50">'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('fetchpriority')).toBe('auto'); }); it( 'should log a warning if the priority attribute is used too often', withHead('<link rel="preconnect" href="https://angular.dev/">', async () => { // This test is running both on server and in the browser. globalThis['ngServerMode'] = !isBrowser; // We need to reset the count as previous test might have incremented it already resetImagePriorityCount(); const imageLoader = () => { // We need something different from the `localhost` (as we don't want to produce // a preconnect warning for local environments). return 'https://angular.dev/assets/images/logos/path/img.png'; }; setupTestingModule({imageLoader}); // 11 priority attributes, threshold is 10 const template = ` <img ngSrc="path/img.png" width="150" height="50" priority> <img ngSrc="path/img.png" width="150" height="50" priority> <img ngSrc="path/img.png" width="150" height="50" priority> <img ngSrc="path/img.png" width="150" height="50" priority> <img ngSrc="path/img.png" width="150" height="50" priority> <img ngSrc="path/img.png" width="150" height="50" priority> <img ngSrc="path/img.png" width="150" height="50" priority> <img ngSrc="path/img.png" width="150" height="50" priority> <img ngSrc="path/img.png" width="150" height="50" priority> <img ngSrc="path/img.png" width="150" height="50" priority> <img ngSrc="path/img.png" width="150" height="50" priority> `; const consoleWarnSpy = spyOn(console, 'warn'); const fixture = createTestComponent(template); await fixture.whenStable(); // trick to wait for the whenStable() to fire in the directive await new Promise((resolve) => setTimeout(resolve)); if (isBrowser) { expect(consoleWarnSpy.calls.count()).toBe(1); expect(consoleWarnSpy.calls.argsFor(0)[0]).toMatch( new RegExp(`NG0${RuntimeErrorCode.TOO_MANY_PRIORITY_ATTRIBUTES}`), ); } else { // The warning is only logged on browsers expect(consoleWarnSpy.calls.count()).toBe(0); } globalThis['ngServerMode'] = undefined; }), ); }); describe('meta data', () => { it('should add a data attribute to the element for identification', async () => { setupTestingModule(); const template = '<img ngSrc="a.png" width="100" height="50">'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('ng-img')).not.toBeNull(); }); it('should add a data attribute to the element for identification, when ngSrc bound', async () => { setupTestingModule(); const template = `<img [ngSrc]="'a.png'" width="100" height="50">`; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('ng-img')).not.toBeNull(); }); }); describe('fill mode', () => { it('should allow unsized images in fill mode', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" fill>'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeResolved(); }); it('should throw if width is provided for fill mode image', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="500" fill>'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02952: The NgOptimizedImage directive (activated on an <img> element with the ' + '`ngSrc="path/img.png"`) has detected that the attributes `height` and/or `width` ' + 'are present along with the `fill` attribute. Because `fill` mode causes an image ' + 'to fill its containing element, the size attributes have no effect and should be removed.', ); }); it('should throw if height is provided for fill mode image', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" height="500" fill>'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02952: The NgOptimizedImage directive (activated on an <img> element with the ' + '`ngSrc="path/img.png"`) has detected that the attributes `height` and/or `width` ' + 'are present along with the `fill` attribute. Because `fill` mode causes an image ' + 'to fill its containing element, the size attributes have no effect and should be removed.', ); }); it('should apply appropriate styles in fill mode', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" fill>'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('style')?.replace(/\s/g, '')).toMatch( 'position:absolute;width:100%;height:100%;inset:0', ); }); it('should augment existing styles in fill mode', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" style="border-radius: 5px; padding: 10px" fill>'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('style')?.replace(/\s/g, '')).toMatch( 'border-radius:5px;padding:10px;position:absolute;width:100%;height:100%;inset:0', ); }); it('should not add fill styles if not in fill mode', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="400" height="300" style="position: relative; border-radius: 5px">'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('style')?.replace(/\s/g, '')).toBe( 'position:relative;border-radius:5px;', ); }); it('should add default sizes value in fill mode', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" fill priority>'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('sizes')).toBe('100vw'); }); it('should add auto sizes to default in fill mode when lazy', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" fill>'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('sizes')).toBe('auto, 100vw'); }); it('should not overwrite sizes value in fill mode', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" sizes="50vw" fill loading="eager">'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('sizes')).toBe('50vw'); }); it('should prepend "auto" to sizes in fill mode when lazy', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" sizes="50vw" fill>'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('sizes')).toBe('auto, 50vw'); }); it('should cause responsive srcset to be generated in fill mode', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" fill>'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('srcset')).toBe( `${IMG_BASE_URL}/path/img.png 640w, ${IMG_BASE_URL}/path/img.png 750w, ${IMG_BASE_URL}/path/img.png 828w, ` + `${IMG_BASE_URL}/path/img.png 1080w, ${IMG_BASE_URL}/path/img.png 1200w, ${IMG_BASE_URL}/path/img.png 1920w, ` + `${IMG_BASE_URL}/path/img.png 2048w, ${IMG_BASE_URL}/path/img.png 3840w`, ); }); }); describe('placeholder attribute', () => { const imageLoader = (config: ImageLoaderConfig) => { let paramsString = ''; if (config.isPlaceholder) { paramsString = 'ph=true'; } let queryString = `${config.width ? 'w=' + config.width + '&' : ''}${paramsString}`; return `${IMG_BASE_URL}/${config.src}?${queryString}`; }; it('should add background size, position, and repeat styling if the placeholder attribute is present', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="400" height="300" placeholder="path/img.png" />'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; const styles = parseInlineStyles(img); expect(styles.get('background-size')).toBe('cover'); expect(styles.get('background-position')).toBe('50%50%'); expect(styles.get('background-repeat')).toBe('no-repeat'); }); it('should not add background styles if the placeholder attribute is not present', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="400" height="300">'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; const styles = parseInlineStyles(img); expect(styles.get('background-size')).toBeUndefined(); expect(styles.get('background-position')).toBeUndefined(); expect(styles.get('background-repeat')).toBeUndefined(); expect(styles.get('background-image')).toBeUndefined(); }); it('should add a background-image tag when placeholder is provided as a boolean', async () => { setupTestingModule({imageLoader}); const template = '<img ngSrc="path/img.png" width="400" height="300" placeholder="true" />'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; const styles = parseInlineStyles(img); // Double quotes removed to account for different browser behavior. expect(styles.get('background-image')?.replace(/"/g, '')).toBe( `url(${IMG_BASE_URL}/path/img.png?w=30&ph=true)`, ); }); it('should escape loader output when placeholder is provided as a boolean (security regression)', async () => { const maliciousLoader = (config: ImageLoaderConfig) => { if (config.isPlaceholder) { return 'https://mysite.com/img.png"); color: red;/*'; } return `${IMG_BASE_URL}/${config.src}`; }; setupTestingModule({imageLoader: maliciousLoader}); const template = '<img ngSrc="path/img.png" width="400" height="300" placeholder="true" />'; const fixture = createTestComponent(template); await fixture.whenStable(); const imageDirective = fixture.debugElement.children[0].injector.get(NgOptimizedImage); const placeholderValue = (imageDirective as any).generatePlaceholder(true) as string; expect(placeholderValue).toBe('url("https://mysite.com/img.png\\"); color: red;/*")'); }); if (!isNode) { // DataURLs get stripped from background-image attribute in Node, but not browsers. it('should add a background-image tag when placeholder is provided as a data URL', async () => { setupTestingModule({imageLoader}); const template = '<img ngSrc="path/img.png" width="400" height="300" placeholder="data:image/png;base64,iVBORw0KGgoAAAANSUhEU" />'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; // Double quotes removed to account for different browser behavior. expect(img.getAttribute('style')?.replace(/"/g, '').replace(/\s/g, '')).toBe( `background-size:cover;background-position:50%50%;background-repeat:no-repeat;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEU);filter:blur(${PLACEHOLDER_BLUR_AMOUNT}px);`, ); }); } it('should add a background-image tag when placeholder is provided as a URL', async () => { setupTestingModule({imageLoader}); const template = '<img ngSrc="path/img.png" width="400" height="300" placeholder="https://mysite.com/assets/my-image.png" />'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; // Double quotes removed to account for different browser behavior. expect(img.getAttribute('style')?.replace(/"/g, '').replace(/\s/g, '')).toBe( `background-size:cover;background-position:50%50%;background-repeat:no-repeat;background-image:url(https://mysite.com/assets/my-image.png);filter:blur(${PLACEHOLDER_BLUR_AMOUNT}px);`, ); }); // DataURLs get stripped from background-image attribute in Node, but not browsers. it('should add a background-image tag when placeholder is provided as relative URL', async () => { setupTestingModule({imageLoader}); const template = '<img ngSrc="path/img.png" width="400" height="300" placeholder="../../assets/my-image.png" />'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; // Double quotes removed to account for different browser behavior. expect(img.getAttribute('style')?.replace(/"/g, '').replace(/\s/g, '')).toBe( `background-size:cover;background-position:50%50%;background-repeat:no-repeat;background-image:url(../../assets/my-image.png);filter:blur(${PLACEHOLDER_BLUR_AMOUNT}px);`, ); }); it('should prevent CSS injection through placeholder URL values', async () => { setupTestingModule({imageLoader}); const maliciousPlaceholder = 'https://mysite.com/img.png"); color: red;'; const template = `<img ngSrc="path/img.png" width="400" height="300" placeholder='${maliciousPlaceholder}' />`; const fixture = createTestComponent(template); await fixture.whenStable(); const imageDirective = fixture.debugElement.children[0].injector.get(NgOptimizedImage); const placeholderValue = (imageDirective as any).generatePlaceholder( maliciousPlaceholder, ) as string; expect(placeholderValue).toBe('url("https://mysite.com/img.png\\"); color: red;")'); }); it('should add a background-image tag when placeholder is provided without value', async () => { setupTestingModule({imageLoader}); const template = '<img ngSrc="path/img.png" width="400" height="300" placeholder />'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; const styles = parseInlineStyles(img); // Double quotes removed to account for different browser behavior. expect(styles.get('background-image')?.replace(/"/g, '')).toBe( `url(${IMG_BASE_URL}/path/img.png?w=30&ph=true)`, ); }); it('should remove placeholder event listeners once view is removed', async () => { const addEventListenerSpy = spyOn(HTMLImageElement.prototype, 'addEventListener'); const removeEventListenerSpy = spyOn(HTMLImageElement.prototype, 'removeEventListener'); setupTestingModule(); const template = '<img ngSrc="path/img.png" width="400" height="300" placeholder="https://mysite.com/assets/my-image.png" />'; const fixture = createTestComponent(template); await fixture.whenStable(); // The `load` event listener is being set up twice: once in // `assertNoImageDistortion` and once in `removePlaceholderOnLoad`. expect( addEventListenerSpy.calls.all().filter((info) => info.args[0] === 'load').length, ).toEqual(2); fixture.destroy(); expect( removeEventListenerSpy.calls.all().filter((info) => info.args[0] === 'load').length, ).toEqual(2); }); it('should replace the placeholder with the actual image on load', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="400" height="300" placeholder="true" />'; const fixture = createTestComponent(template, ChangeDetectionStrategy.OnPush); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(parseInlineStyles(img).has('background-image')).toBe(true); img.dispatchEvent(new Event('load')); await fixture.whenStable(); expect(parseInlineStyles(img).has('background-image')).toBe(false); }); it('should use the placeholderResolution set in imageConfig', async () => { const imageConfig = { placeholderResolution: 30, }; setupTestingModule({imageLoader, imageConfig}); const template = '<img ngSrc="path/img.png" width="400" height="300" placeholder />'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; const styles = parseInlineStyles(img); // Double quotes removed to account for different browser behavior. expect(styles.get('background-image')?.replace(/"/g, '')).toBe( `url(${IMG_BASE_URL}/path/img.png?w=30&ph=true)`, ); }); it('should pass calculated height to placeholder loader based on aspect ratio', async () => { const placeholderLoaderWithHeight = (config: ImageLoaderConfig) => { const widthStr = config.width ? `w=${config.width}` : ''; const heightStr = config.height ? `h=${config.height}` : ''; const phStr = config.isPlaceholder ? 'ph=true' : ''; const params = [widthStr, heightStr, phStr].filter((p) => p).join('&'); return `${IMG_BASE_URL}/${config.src}${params ? '?' + params : ''}`; }; const imageConfig = { placeholderResolution: 30, }; setupTestingModule({imageLoader: placeholderLoaderWithHeight, imageConfig}); const template = '<img ngSrc="path/img.png" width="400" height="200" placeholder />'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; const styles = parseInlineStyles(img); // Aspect ratio is 400/200 = 2, placeholderResolution is 30 // Expected height: 30 / 2 = 15 // Double quotes removed to account for different browser behavior. expect(styles.get('background-image')?.replace(/"/g, '')).toBe( `url(${IMG_BASE_URL}/path/img.png?w=30&h=15&ph=true)`, ); }); it('should apply a background blur to images with a placeholder', async () => { setupTestingModule({imageLoader}); const template = '<img ngSrc="path/img.png" width="400" height="300" placeholder="data:image/png;base64,iVBORw0KGgoAAAANSUhEU" />'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; const styles = parseInlineStyles(img); expect(styles.get('filter')).toBe(`blur(${PLACEHOLDER_BLUR_AMOUNT}px)`); }); it('should not apply a background blur to placeholders with blur=false', async () => { setupTestingModule({imageLoader}); const template = '<img ngSrc="path/img.png" width="400" height="300" placeholder="data:image/png;base64,iVBORw0KGgoAAAANSUhEU" [placeholderConfig]="{blur: false}" />'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; const styles = parseInlineStyles(img); expect(styles.get('filter')).toBeUndefined(); }); it('should throw if placeholderConfig is provided without placeholder', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="400" height="300" [placeholderConfig]="{blur: false}">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( `NG02952: The NgOptimizedImage directive has detected that \`placeholderConfig\` options were ` + `provided for an image that does not use the \`placeholder\` attribute, and will have no effect.`, ); }); it('should throw if placeholderConfig with blur=true is provided without placeholder', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="400" height="300" [placeholderConfig]="{blur: true}">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( `NG02952: The NgOptimizedImage directive has detected that \`placeholderConfig\` options were ` + `provided for an image that does not use the \`placeholder\` attribute, and will have no effect.`, ); }); it('should throw if there is no image loader but `ngSrcset` is present', async () => { setUpModuleNoLoader(); const template = `<img ngSrc="img.png" width="150" height="50" placeholder="true">`; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( `NG0${RuntimeErrorCode.MISSING_NECESSARY_LOADER}: The NgOptimizedImage directive (activated on an <img> element with the \`ngSrc="img.png"\`) ` + `has detected that the \`placeholder\` attribute is set to true but no image loader is configured (i.e. the default one is being used), which ` + `would result in the same image being used for the primary image and its placeholder. To fix this, provide a loader or remove the \`placeholder\` attribute from the image.`, ); }); it('should throw if a very large inline placeholder is supplied', async () => { setupTestingModule(); const template = `<img ngSrc="path/img.png" width="400" height="300" placeholder="data:image/png;base64,${'a'.repeat( DATA_URL_ERROR_LIMIT + 100, )}">`; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( `NG0${RuntimeErrorCode.OVERSIZED_PLACEHOLDER}: The NgOptimizedImage directive (activated on an <img> element with the \`ngSrc="path/img.png"\`) ` + `has detected that the \`placeholder\` attribute is set to a data URL which is longer than ${DATA_URL_ERROR_LIMIT} characters. This is strongly ` + `discouraged, as large inline placeholders directly increase the bundle size of Angular and hurt page load performance. To fix this, generate a smaller data URL placeholder.`, ); }); it('should warn if a large inline placeholder is supplied', async () => { setUpModuleNoLoader(); const template = `<img ngSrc="path/img.png" width="400" height="300" placeholder="data:image/png;base64,${'a'.repeat( DATA_URL_WARN_LIMIT + 100, )}">`; const fixture = createTestComponent(template); const consoleWarnSpy = spyOn(console, 'warn'); await fixture.whenStable(); expect(consoleWarnSpy.calls.count()).toBe(1); expect(consoleWarnSpy.calls.argsFor(0)[0]).toBe( `NG0${RuntimeErrorCode.OVERSIZED_PLACEHOLDER}: The NgOptimizedImage directive (activated on an <img> element with the \`ngSrc="path/img.png"\`) ` + `has detected that the \`placeholder\` attribute is set to a data URL which is longer than ${DATA_URL_WARN_LIMIT} characters. This is discouraged, ` + `as large inline placeholders directly increase the bundle size of Angular and hurt page load performance. For better loading ` + `performance, generate a smaller data URL placeholder.`, ); }); if (isBrowser) { describe('browser', () => { beforeEach(() => { globalThis['ngServerMode'] = false; }); afterEach(() => { globalThis['ngServerMode'] = undefined; }); it('should throw if the placeholder height exceeds the threshold', async () => { setUpModuleNoLoader(); const template = `<img ngSrc="path/img.png" width="100" height="100" style="width:1001px; height: 300px" placeholder="data:image/png;base64,${'a'.repeat( 100, )}">`; const consoleWarnSpy = spyOn(console, 'warn'); const fixture = createTestComponent(template); await fixture.whenStable(); expect(consoleWarnSpy.calls.count()).toBe(1); expect(consoleWarnSpy.calls.argsFor(0)[0]).toMatch( new RegExp(`NG0${RuntimeErrorCode.PLACEHOLDER_DIMENSION_LIMIT_EXCEEDED}:`), ); }); it('should throw if the placeholder width exceeds the threshold', async () => { setUpModuleNoLoader(); const template = `<img ngSrc="path/img.png" width="100" height="100" style="height:1001px; width: 300px" placeholder="data:image/png;base64,${'a'.repeat( 100, )}">`; const consoleWarnSpy = spyOn(console, 'warn'); const fixture = createTestComponent(template); await fixture.whenStable(); expect(consoleWarnSpy.calls.count()).toBe(1); expect(consoleWarnSpy.calls.argsFor(0)[0]).toMatch( new RegExp(`NG0${RuntimeErrorCode.PLACEHOLDER_DIMENSION_LIMIT_EXCEEDED}:`), ); }); }); } }); describe('preconnect detector', () => { const imageLoader = () => { // We need something different from the `localhost` (as we don't want to produce // a preconnect warning for local environments). return 'https://angular.dev/assets/images/logos/angular/angular.svg'; }; it( 'should log a warning if there is no preconnect link for a priority image', withHead('', async () => { // The warning is only logged on the client if (!isBrowser) return; setupTestingModule({imageLoader}); const consoleWarnSpy = spyOn(console, 'warn'); const template = '<img ngSrc="a.png" width="100" height="50" priority>'; const fixture = createTestComponent(template); await fixture.whenStable(); expect(consoleWarnSpy.calls.count()).toBe(1); expect(consoleWarnSpy.calls.argsFor(0)[0]).toBe( 'NG02956: The NgOptimizedImage directive (activated on an <img> element ' + 'with the `ngSrc="a.png"`) has detected that there is no preconnect tag ' + 'present for this image. Preconnecting to the origin(s) that serve ' + 'priority images ensures that these images are delivered as soon as ' + 'possible. To fix this, please add the following element into the <head> ' + 'of the document:' + '\n <link rel="preconnect" href="https://angular.dev">', ); }), ); it( 'should not log a warning if there is no preconnect link, but the image is not set as a priority', withHead('', async () => { setupTestingModule({imageLoader}); const consoleWarnSpy = spyOn(console, 'warn'); const template = '<img ngSrc="a.png" width="100" height="50">'; const fixture = createTestComponent(template); await fixture.whenStable(); // Expect no warnings in the console. expect(consoleWarnSpy.calls.count()).toBe(0); }), ); it( "should log a warning if there is a preconnect, but it doesn't match the priority image", withHead('<link rel="preconnect" href="http://angular.dev">', async () => { // The warning is only logged on the client if (!isBrowser) return; setupTestingModule({imageLoader}); const consoleWarnSpy = spyOn(console, 'warn'); const template = '<img ngSrc="a.png" width="100" height="50" priority>'; const fixture = createTestComponent(template); await fixture.whenStable(); expect(consoleWarnSpy.calls.count()).toBe(1); expect(consoleWarnSpy.calls.argsFor(0)[0]).toBe( 'NG02956: The NgOptimizedImage directive (activated on an <img> element ' + 'with the `ngSrc="a.png"`) has detected that there is no preconnect tag ' + 'present for this image. Preconnecting to the origin(s) that serve priority ' + 'images ensures that these images are delivered as soon as possible. ' + 'To fix this, please add the following element into the <head> of the document:' + '\n <link rel="preconnect" href="https://angular.dev">', ); }), ); it( 'should log a warning if there is no matching preconnect link for a priority image, but there is a preload tag', withHead( '<link rel="preload" href="https://angular.dev/assets/images/logos/angular/angular.svg" as="image">', async () => { // The warning is only logged on the client if (!isBrowser) return; setupTestingModule({imageLoader}); const consoleWarnSpy = spyOn(console, 'warn'); const template = '<img ngSrc="a.png" width="100" height="50" priority>'; const fixture = createTestComponent(template); await fixture.whenStable(); expect(consoleWarnSpy.calls.count()).toBe(1); expect(consoleWarnSpy.calls.argsFor(0)[0]).toBe( 'NG02956: The NgOptimizedImage directive (activated on an <img> element ' + 'with the `ngSrc="a.png"`) has detected that there is no preconnect tag ' + 'present for this image. Preconnecting to the origin(s) that serve priority ' + 'images ensures that these images are delivered as soon as possible. ' + 'To fix this, please add the following element into the <head> of the document:' + '\n <link rel="preconnect" href="https://angular.dev">', ); }, ), ); it( 'should not log a warning if there is a matching preconnect link for a priority image (with an extra `/` at the end)', withHead('<link rel="preconnect" href="https://angular.dev/">', async () => { setupTestingModule({imageLoader}); const consoleWarnSpy = spyOn(console, 'warn'); const template = '<img ngSrc="a.png" width="100" height="50" priority>'; const fixture = createTestComponent(template); await fixture.whenStable(); // Expect no warnings in the console. expect(consoleWarnSpy.calls.count()).toBe(0); }), ); ['localhost', '127.0.0.1', '0.0.0.0', '[::1]'].forEach((blocklistedHostname) => { it( `should not log a warning if an origin domain is blocklisted ` + `(checking ${blocklistedHostname})`, withHead('', async () => { const imageLoader = () => { return `http://${blocklistedHostname}/a.png`; }; setupTestingModule({imageLoader}); const consoleWarnSpy = spyOn(console, 'warn'); const template = '<img ngSrc="a.png" width="100" height="50" priority>'; const fixture = createTestComponent(template); await fixture.whenStable(); // Expect no warnings in the console. expect(consoleWarnSpy.calls.count()).toBe(0); }), ); }); describe('PRECONNECT_CHECK_BLOCKLIST token', () => { it( `should allow passing host names`, withHead('', async () => { const providers = [{provide: PRECONNECT_CHECK_BLOCKLIST, useValue: 'angular.dev'}]; setupTestingModule({imageLoader, extraProviders: providers}); const consoleWarnSpy = spyOn(console, 'warn'); const template = '<img ngSrc="a.png" width="100" height="50" priority>'; const fixture = createTestComponent(template); await fixture.whenStable(); // Expect no warnings in the console. expect(consoleWarnSpy.calls.count()).toBe(0); }), ); it( `should allow passing origins`, withHead('', async () => { const providers = [ {provide: PRECONNECT_CHECK_BLOCKLIST, useValue: 'https://angular.dev'}, ]; setupTestingModule({imageLoader, extraProviders: providers}); const consoleWarnSpy = spyOn(console, 'warn'); const template = '<img ngSrc="a.png" width="100" height="50" priority>'; const fixture = createTestComponent(template); await fixture.whenStable(); // Expect no warnings in the console. expect(consoleWarnSpy.calls.count()).toBe(0); }), ); it( `should allow passing arrays of host names`, withHead('', async () => { const providers = [ {provide: PRECONNECT_CHECK_BLOCKLIST, useValue: ['https://angular.dev']}, ]; setupTestingModule({imageLoader, extraProviders: providers}); const consoleWarnSpy = spyOn(console, 'warn'); const template = '<img ngSrc="a.png" width="100" height="50" priority>'; const fixture = createTestComponent(template); await fixture.whenStable(); // Expect no warnings in the console. expect(consoleWarnSpy.calls.count()).toBe(0); }), ); it( `should allow passing nested arrays of host names`, withHead('', async () => { const providers = [ {provide: PRECONNECT_CHECK_BLOCKLIST, useValue: [['https://angular.dev']]}, ]; setupTestingModule({imageLoader, extraProviders: providers}); const consoleWarnSpy = spyOn(console, 'warn'); const template = '<img ngSrc="a.png" width="100" height="50" priority>'; const fixture = createTestComponent(template); await fixture.whenStable(); // Expect no warnings in the console. expect(consoleWarnSpy.calls.count()).toBe(0); }), ); }); }); describe('loaders', () => { const imageLoaderWithData = (config: ImageLoaderConfig) => { let paramsString = ''; if (config.loaderParams) { paramsString = Object.entries(config.loaderParams) .map((entry) => `${entry[0]}=${entry[1]}`) .join('&'); } let queryString = `${config.width ? 'w=' + config.width + '&' : ''}${paramsString}`; return `${config.src}?${queryString}`; }; // Test complex loaderParams schema with nesting: // loaderParams = { // transforms1: {example1: "foo"}, // transforms2: {example2: "bar"} // } const nestedImageLoader = (config: ImageLoaderConfig) => { return `${config.src}/${config.loaderParams?.['transforms1'].example1}/${config.loaderParams?.['transforms2'].example2}`; }; it('should set `src` to match `ngSrc` if image loader is not provided', async () => { setupTestingModule(); const template = `<img ngSrc="${IMG_BASE_URL}/img.png" width="100" height="50">`; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.src).toBe(`${IMG_BASE_URL}/img.png`); }); it('should warn if there is no image loader but using Imgix URL', async () => { setUpModuleNoLoader(); const template = `<img ngSrc="https://some.imgix.net/img.png" width="100" height="50">`; const fixture = createTestComponent(template); const consoleWarnSpy = spyOn(console, 'warn'); await fixture.whenStable(); expect(consoleWarnSpy.calls.count()).toBe(1); expect(consoleWarnSpy.calls.argsFor(0)[0]).toMatch( /your images may be hosted on the Imgix CDN/, ); }); it('should warn if there is no image loader but using ImageKit URL', async () => { setUpModuleNoLoader(); const template = `<img ngSrc="https://some.imagekit.io/img.png" width="100" height="50">`; const fixture = createTestComponent(template); const consoleWarnSpy = spyOn(console, 'warn'); await fixture.whenStable(); expect(consoleWarnSpy.calls.count()).toBe(1); expect(consoleWarnSpy.calls.argsFor(0)[0]).toMatch( /your images may be hosted on the ImageKit CDN/, ); }); it('should warn if there is no image loader but using Cloudinary URL', async () => { setUpModuleNoLoader(); const template = `<img ngSrc="https://some.cloudinary.com/img.png" width="100" height="50">`; const fixture = createTestComponent(template); const consoleWarnSpy = spyOn(console, 'warn'); await fixture.whenStable(); expect(consoleWarnSpy.calls.count()).toBe(1); expect(consoleWarnSpy.calls.argsFor(0)[0]).toMatch( /your images may be hosted on the Cloudinary CDN/, ); }); it('should warn if there is no image loader but using Netlify URL', async () => { setUpModuleNoLoader(); const template = `<img ngSrc="https://example.netlify.app/img.png" width="100" height="50">`; const fixture = createTestComponent(template); const consoleWarnSpy = spyOn(console, 'warn'); await fixture.whenStable(); expect(consoleWarnSpy.calls.count()).toBe(1); expect(consoleWarnSpy.calls.argsFor(0)[0]).toMatch( /your images may be hosted on the Netlify CDN/, ); }); it('should NOT warn if there is a custom loader but using CDN URL', async () => { setupTestingModule(); const template = `<img ngSrc="https://some.cloudinary.com/img.png" width="100" height="50">`; const fixture = createTestComponent(template); const consoleWarnSpy = spyOn(console, 'warn'); await fixture.whenStable(); expect(consoleWarnSpy.calls.count()).toBe(0); }); it('should warn if there is no image loader but `ngSrcset` is present', async () => { setUpModuleNoLoader(); const template = `<img ngSrc="img.png" ngSrcset="100w, 200w" width="150" height="50">`; const fixture = createTestComponent(template); const consoleWarnSpy = spyOn(console, 'warn'); await fixture.whenStable(); expect(consoleWarnSpy.calls.count()).toBe(1); expect(consoleWarnSpy.calls.argsFor(0)[0]).toBe( `NG0${RuntimeErrorCode.MISSING_NECESSARY_LOADER}: The NgOptimizedImage directive (activated on an <img> element ` + 'with the `ngSrc="img.png"`) has detected that the `ngSrcset` attribute is ' + 'present but no image loader is configured (i.e. the default one is being used), ' + `which would result in the same image being used for all configured sizes. ` + 'To fix this, provide a loader or remove the `ngSrcset` attribute from the image.', ); }); it('should warn if there is no image loader but `loaderParams` is present', async () => { setUpModuleNoLoader(); const template = `<img ngSrc="img.png" width="150" height="50" [loaderParams]="{foo: 'test'}">`; const fixture = createTestComponent(template); const consoleWarnSpy = spyOn(console, 'warn'); await fixture.whenStable(); expect(consoleWarnSpy.calls.count()).toBe(1); expect(consoleWarnSpy.calls.argsFor(0)[0]).toBe( `NG0${RuntimeErrorCode.MISSING_NECESSARY_LOADER}: The NgOptimizedImage directive (activated on an <img> element ` + 'with the `ngSrc="img.png"`) has detected that the `loaderParams` attribute is ' + 'present but no image loader is configured (i.e. the default one is being used), ' + `which means that the loaderParams data will not be consumed and will not affect the URL. ` + 'To fix this, provide a custom loader or remove the `loaderParams` attribute from the image.', ); }); it('should set `src` using the image loader provided via the `IMAGE_LOADER` token to compose src URL', async () => { const imageLoader = (config: ImageLoaderConfig) => `${IMG_BASE_URL}/${config.src}`; setupTestingModule({imageLoader}); const template = ` <img ngSrc="img.png" width="150" height="50"> <img ngSrc="img-2.png" width="150" height="50"> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const imgs = nativeElement.querySelectorAll('img')!; expect(imgs[0].src.trim()).toBe(`${IMG_BASE_URL}/img.png`); expect(imgs[1].src.trim()).toBe(`${IMG_BASE_URL}/img-2.png`); }); it('should use the image loader to update `src` if `ngSrc` updated', async () => { @Component({ selector: 'test-cmp', template: `<img [ngSrc]="ngSrc" width="300" height="300" />`, imports: [NgOptimizedImage], changeDetection: ChangeDetectionStrategy.Eager, }) class TestComponent { ngSrc = `img.png`; } const imageLoader = (config: ImageLoaderConfig) => `${IMG_BASE_URL}/${config.src}`; setupTestingModule({imageLoader, component: TestComponent}); const fixture = TestBed.createComponent(TestComponent); await fixture.whenStable(); let nativeElement = fixture.nativeElement as HTMLElement; let imgs = nativeElement.querySelectorAll('img')!; expect(imgs[0].src).toBe(`${IMG_BASE_URL}/img.png`); fixture.componentInstance.ngSrc = 'updatedImg.png'; fixture.changeDetectorRef.markForCheck(); await fixture.whenStable(); expect(imgs[0].src).toBe(`${IMG_BASE_URL}/updatedImg.png`); }); it('should use the image loader to update `srcset` if `ngSrc` updated', async () => { @Component({ selector: 'test-cmp', template: `<img [ngSrc]="ngSrc" width="300" height="300" sizes="100vw" />`, imports: [NgOptimizedImage], changeDetection: ChangeDetectionStrategy.Eager, }) class TestComponent { ngSrc = `img.png`; } const imageLoader = (config: ImageLoaderConfig) => { const width = config.width ? `?w=${config.width}` : ``; return `${IMG_BASE_URL}/${config.src}${width}`; }; setupTestingModule({imageLoader, component: TestComponent}); const fixture = TestBed.createComponent(TestComponent); await fixture.whenStable(); let nativeElement = fixture.nativeElement as HTMLElement; let imgs = nativeElement.querySelectorAll('img')!; expect(imgs[0].getAttribute('srcset')).toBe( `${IMG_BASE_URL}/img.png?w=640 640w, ${IMG_BASE_URL}/img.png?w=750 750w, ${IMG_BASE_URL}/img.png?w=828 828w, ${IMG_BASE_URL}/img.png?w=1080 1080w, ${IMG_BASE_URL}/img.png?w=1200 1200w, ${IMG_BASE_URL}/img.png?w=1920 1920w, ${IMG_BASE_URL}/img.png?w=2048 2048w, ${IMG_BASE_URL}/img.png?w=3840 3840w`, ); fixture.componentInstance.ngSrc = 'updatedImg.png'; fixture.changeDetectorRef.markForCheck(); nativeElement = fixture.nativeElement as HTMLElement; imgs = nativeElement.querySelectorAll('img')!; await fixture.whenStable(); expect(imgs[0].getAttribute('srcset')).toBe( `${IMG_BASE_URL}/updatedImg.png?w=640 640w, ${IMG_BASE_URL}/updatedImg.png?w=750 750w, ${IMG_BASE_URL}/updatedImg.png?w=828 828w, ${IMG_BASE_URL}/updatedImg.png?w=1080 1080w, ${IMG_BASE_URL}/updatedImg.png?w=1200 1200w, ${IMG_BASE_URL}/updatedImg.png?w=1920 1920w, ${IMG_BASE_URL}/updatedImg.png?w=2048 2048w, ${IMG_BASE_URL}/updatedImg.png?w=3840 3840w`, ); }); it('should pass absolute URLs defined in the `ngSrc` to custom image loaders provided via the `IMAGE_LOADER` token', async () => { const imageLoader = (config: ImageLoaderConfig) => `${config.src}?rewritten=true`; setupTestingModule({imageLoader}); const template = ` <img ngSrc="${IMG_BASE_URL}/img.png" width="150" height="50"> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const imgs = nativeElement.querySelectorAll('img')!; expect(imgs[0].src.trim()).toBe(`${IMG_BASE_URL}/img.png?rewritten=true`); }); it('should pass data payload from loaderParams to custom image loaders', async () => { setupTestingModule({imageLoader: imageLoaderWithData}); const template = ` <img ngSrc="${IMG_BASE_URL}/img.png" width="150" height="50" [loaderParams]="{testProp1: 'testValue1', testProp2: 'testValue2'}" /> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const imgs = nativeElement.querySelectorAll('img')!; expect(imgs[0].src).toBe(`${IMG_BASE_URL}/img.png?testProp1=testValue1&testProp2=testValue2`); }); it('should pass nested data payloads from loaderParams to custom image loaders', async () => { @Component({ selector: 'test-cmp', template: `<img [ngSrc]="ngSrc" [width]="width" [height]="height" [loaderParams]="params" />`, imports: [NgOptimizedImage], }) class TestComponent { ngSrc = `${IMG_BASE_URL}/img.png`; width = 300; height = 300; params = {transforms1: {example1: 'foo'}, transforms2: {example2: 'bar'}}; } setupTestingModule({imageLoader: nestedImageLoader, component: TestComponent}); const fixture = TestBed.createComponent(TestComponent); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const imgs = nativeElement.querySelectorAll('img')!; expect(imgs[0].src).toBe(`${IMG_BASE_URL}/img.png/foo/bar`); }); it('should pass data payload from loaderParams to loader when generating srcsets', async () => { setupTestingModule({imageLoader: imageLoaderWithData}); const template = ` <img ngSrc="${IMG_BASE_URL}/img.png" width="150" height="50" [loaderParams]="{testProp1: 'testValue1', testProp2: 'testValue2'}" /> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const imgs = nativeElement.querySelectorAll('img')!; expect(imgs[0].srcset).toBe( `${IMG_BASE_URL}/img.png?w=150&testProp1=testValue1&testProp2=testValue2 1x, ${IMG_BASE_URL}/img.png?w=300&testProp1=testValue1&testProp2=testValue2 2x`, ); }); it('should pass data payload from loaderParams to loader when generating responsive srcsets', async () => { setupTestingModule({imageLoader: imageLoaderWithData}); const template = ` <img ngSrc="${IMG_BASE_URL}/img.png" width="150" height="50" sizes="100vw" [loaderParams]="{testProp1: 'testValue1', testProp2: 'testValue2'}" /> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const imgs = nativeElement.querySelectorAll('img')!; expect(imgs[0].srcset).toBe( `${IMG_BASE_URL}/img.png?w=640&testProp1=testValue1&testProp2=testValue2 640w, ${IMG_BASE_URL}/img.png?w=750&testProp1=testValue1&testProp2=testValue2 750w, ${IMG_BASE_URL}/img.png?w=828&testProp1=testValue1&testProp2=testValue2 828w, ${IMG_BASE_URL}/img.png?w=1080&testProp1=testValue1&testProp2=testValue2 1080w, ${IMG_BASE_URL}/img.png?w=1200&testProp1=testValue1&testProp2=testValue2 1200w, ${IMG_BASE_URL}/img.png?w=1920&testProp1=testValue1&testProp2=testValue2 1920w, ${IMG_BASE_URL}/img.png?w=2048&testProp1=testValue1&testProp2=testValue2 2048w, ${IMG_BASE_URL}/img.png?w=3840&testProp1=testValue1&testProp2=testValue2 3840w`, ); }); it('should pass height to custom image loader based on aspect ratio', async () => { const imageLoader = (config: ImageLoaderConfig) => { const widthStr = config.width ? `w=${config.width}` : ''; const heightStr = config.height ? `h=${config.height}` : ''; const params = [widthStr, heightStr].filter((p) => p).join('&'); return `${IMG_BASE_URL}/${config.src}${params ? '?' + params : ''}`; }; setupTestingModule({imageLoader}); const template = '<img ngSrc="img.png" width="150" height="50">'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; // For src without width, height should not be passed expect(img.src).toBe(`${IMG_BASE_URL}/img.png`); }); it('should pass calculated height to custom image loader when generating srcsets', async () => { const imageLoader = (config: ImageLoaderConfig) => { const widthStr = config.width ? `w=${config.width}` : ''; const heightStr = config.height ? `h=${config.height}` : ''; const params = [widthStr, heightStr].filter((p) => p).join('&'); return `${IMG_BASE_URL}/${config.src}${params ? '?' + params : ''}`; }; setupTestingModule({imageLoader}); const template = '<img ngSrc="img.png" width="150" height="50">'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; // Aspect ratio is 150/50 = 3, so for widths 150 and 300: // height should be 50 and 100 respectively expect(img.srcset).toBe( `${IMG_BASE_URL}/img.png?w=150&h=50 1x, ${IMG_BASE_URL}/img.png?w=300&h=100 2x`, ); }); it('should pass calculated height to custom image loader when generating responsive srcsets', async () => { const imageLoader = (config: ImageLoaderConfig) => { const widthStr = config.width ? `w=${config.width}` : ''; const heightStr = config.height ? `h=${config.height}` : ''; const params = [widthStr, heightStr].filter((p) => p).join('&'); return `${IMG_BASE_URL}/${config.src}${params ? '?' + params : ''}`; }; setupTestingModule({imageLoader}); const template = '<img ngSrc="img.png" width="150" height="50" sizes="100vw">'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; // Aspect ratio is 150/50 = 3 // Expected heights: 640/3=213, 750/3=250, etc. expect(img.srcset).toBe( `${IMG_BASE_URL}/img.png?w=640&h=213 640w, ${IMG_BASE_URL}/img.png?w=750&h=250 750w, ${IMG_BASE_URL}/img.png?w=828&h=276 828w, ${IMG_BASE_URL}/img.png?w=1080&h=360 1080w, ${IMG_BASE_URL}/img.png?w=1200&h=400 1200w, ${IMG_BASE_URL}/img.png?w=1920&h=640 1920w, ${IMG_BASE_URL}/img.png?w=2048&h=683 2048w, ${IMG_BASE_URL}/img.png?w=3840&h=1280 3840w`, ); }); it('should not pass height to custom image loader when height is not provided', async () => { const imageLoader = (config: ImageLoaderConfig) => { const widthStr = config.width ? `w=${config.width}` : ''; const heightStr = config.height ? `h=${config.height}` : ''; const params = [widthStr, heightStr].filter((p) => p).join('&'); return `${IMG_BASE_URL}/${config.src}${params ? '?' + params : ''}`; }; setupTestingModule({imageLoader}); const template = '<img ngSrc="img.png" fill>'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; // No height provided (fill mode), so aspect ratio cannot be calculated // In fill mode, a responsive srcset is generated but without height parameters expect(img.srcset).toBe( `${IMG_BASE_URL}/img.png?w=640 640w, ${IMG_BASE_URL}/img.png?w=750 750w, ${IMG_BASE_URL}/img.png?w=828 828w, ${IMG_BASE_URL}/img.png?w=1080 1080w, ${IMG_BASE_URL}/img.png?w=1200 1200w, ${IMG_BASE_URL}/img.png?w=1920 1920w, ${IMG_BASE_URL}/img.png?w=2048 2048w, ${IMG_BASE_URL}/img.png?w=3840 3840w`, ); }); it('should pass height to custom image loaders', async () => { @Component({ selector: 'test-cmp', imports: [NgOptimizedImage], template: `<img [ngSrc]="ngSrc" width="300" height="150" sizes="100vw" />`, }) class TestComponent { ngSrc = `img.png`; } const imageLoader = (config: ImageLoaderConfig) => { const params: string[] = []; if (config.width) { params.push(`w=${config.width}`); } if (config.height) { params.push(`h=${config.height}`); } const query = params.length ? `?${params.join('&')}` : ''; return `${IMG_BASE_URL}/${config.src}${query}`; }; setupTestingModule({imageLoader, component: TestComponent}); const fixture = TestBed.createComponent(TestComponent); await fixture.whenStable(); let nativeElement = fixture.nativeElement as HTMLElement; let imgs = nativeElement.querySelectorAll('img')!; expect(imgs[0].getAttribute('srcset')).toBe( `${IMG_BASE_URL}/img.png?w=640&h=320 640w, ${IMG_BASE_URL}/img.png?w=750&h=375 750w, ${IMG_BASE_URL}/img.png?w=828&h=414 828w, ${IMG_BASE_URL}/img.png?w=1080&h=540 1080w, ${IMG_BASE_URL}/img.png?w=1200&h=600 1200w, ${IMG_BASE_URL}/img.png?w=1920&h=960 1920w, ${IMG_BASE_URL}/img.png?w=2048&h=1024 2048w, ${IMG_BASE_URL}/img.png?w=3840&h=1920 3840w`, ); }); it('should set `src` to an image URL that does not include a default width parameter', async () => { const imageLoader = (config: ImageLoaderConfig) => { const widthStr = config.width ? `?w=${config.width}` : ``; return `${IMG_BASE_URL}/${config.src}${widthStr}`; }; setupTestingModule({imageLoader}); const template = '<img ngSrc="img.png" width="150" height="50">'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.src).toBe(`${IMG_BASE_URL}/img.png`); }); it( `should allow providing image loaders via Component providers`, withHead('', async () => { const createImgUrl = (path: string, config: ImageLoaderConfig) => `${path}/${config.src}`; const loaderWithPath = createImageLoader(createImgUrl); @Component({ selector: 'test-cmp', template: '<img ngSrc="a.png" width="100" height="50" priority>', providers: [loaderWithPath('https://component.io')], imports: [NgOptimizedImage], }) class TestComponent {} setupTestingModule({ component: TestComponent, extraProviders: [loaderWithPath('https://default.io')], }); const fixture = TestBed.createComponent(TestComponent); await fixture.whenStable(); const defaultLoader = TestBed.inject(IMAGE_LOADER); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(defaultLoader({src: 'a.png'})).toBe('https://default.io/a.png'); expect(img.src).toBe('https://component.io/a.png'); }), ); describe('`ngSrcset` values', () => { let imageLoader!: ImageLoader; beforeEach(() => { imageLoader = (config: ImageLoaderConfig) => { const width = config.width ? `?w=${config.width}` : ``; return `${IMG_BASE_URL}/${config.src}${width}`; }; }); it('should set the `srcset` using the `ngSrcset` value with width descriptors', async () => { setupTestingModule({imageLoader}); const template = ` <img ngSrc="img.png" ngSrcset="100w, 200w" width="100" height="50"> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.src).toBe(`${IMG_BASE_URL}/img.png`); expect(img.srcset).toBe( `${IMG_BASE_URL}/img.png?w=100 100w, ${IMG_BASE_URL}/img.png?w=200 200w`, ); }); it('should set the `srcset` using the `ngSrcset` value with density descriptors', async () => { setupTestingModule({imageLoader}); const template = ` <img ngSrc="img.png" ngSrcset="1x, 2x" width="100" height="50"> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.src).toBe(`${IMG_BASE_URL}/img.png`); expect(img.srcset).toBe( `${IMG_BASE_URL}/img.png?w=100 1x, ${IMG_BASE_URL}/img.png?w=200 2x`, ); }); it('should set the `srcset` if `ngSrcset` has only one src defined', async () => { setupTestingModule({imageLoader}); const template = ` <img ngSrc="img.png" ngSrcset="100w" width="100" height="50"> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.src.trim()).toBe(`${IMG_BASE_URL}/img.png`); expect(img.srcset.trim()).toBe(`${IMG_BASE_URL}/img.png?w=100 100w`); }); it('should set the `srcset` if `ngSrcSet` has extra spaces', async () => { setupTestingModule({imageLoader}); const template = ` <img ngSrc="img.png" ngSrcset=" 100w, 200w " width="100" height="50"> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.src).toBe(`${IMG_BASE_URL}/img.png`); expect(img.srcset).toBe( `${IMG_BASE_URL}/img.png?w=100 100w, ${IMG_BASE_URL}/img.png?w=200 200w`, ); }); it('should set the `srcset` if `ngSrcSet` has a trailing comma', async () => { setupTestingModule({imageLoader}); const template = ` <img ngSrc="img.png" ngSrcset="1x, 2x," width="100" height="50"> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.src).toBe(`${IMG_BASE_URL}/img.png`); expect(img.srcset).toBe( `${IMG_BASE_URL}/img.png?w=100 1x, ${IMG_BASE_URL}/img.png?w=200 2x`, ); }); it('should set the `srcset` if `ngSrcSet` has 3+ srcs', async () => { setupTestingModule({imageLoader}); const template = ` <img ngSrc="img.png" ngSrcset="100w, 200w, 300w" width="100" height="50"> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.src).toBe(`${IMG_BASE_URL}/img.png`); expect(img.srcset).toBe( `${IMG_BASE_URL}/img.png?w=100 100w, ` + `${IMG_BASE_URL}/img.png?w=200 200w, ` + `${IMG_BASE_URL}/img.png?w=300 300w`, ); }); it('should set the `srcset` if `ngSrcSet` has decimal density descriptors', async () => { setupTestingModule({imageLoader}); const template = ` <img ngSrc="img.png" ngSrcset="1.75x, 2.5x, 3x" width="100" height="50"> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.src).toBe(`${IMG_BASE_URL}/img.png`); expect(img.srcset).toBe( `${IMG_BASE_URL}/img.png?w=175 1.75x, ` + `${IMG_BASE_URL}/img.png?w=250 2.5x, ` + `${IMG_BASE_URL}/img.png?w=300 3x`, ); }); it('should automatically set a default sizes attribute when ngSrcset is used with a responsive srcset and is lazy', async () => { setupTestingModule({imageLoader}); const template = ` <img ngSrc="img.png" ngSrcset="100w, 200w, 300w" width="100" height="50"> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.src).toBe(`${IMG_BASE_URL}/img.png`); expect(img.sizes).toBe(`auto, 100vw`); }); it('should not automatically set a default sizes attribute when ngSrcset is used with a responsive srcset and is not lazy', async () => { setupTestingModule({imageLoader}); const template = ` <img ngSrc="img.png" ngSrcset="100w, 200w, 300w" width="100" height="50" priority> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.src).toBe(`${IMG_BASE_URL}/img.png`); expect(img.sizes).toBe(''); }); }); describe('sizes attribute', () => { it('should pass through the sizes attribute', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="150" height="50" ' + 'sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" priority>'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('sizes')).toBe( '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw', ); }); it('should prepend sizes="auto" to a lazy-loaded image', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="150" height="50" ' + 'sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw">'; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('sizes')).toBe( 'auto, (max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw', ); }); it('should throw if a complex `sizes` is used', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="100" height="50" sizes="(min-width: 768px) 500px, 100vw">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02952: The NgOptimizedImage directive has detected that `sizes` was set to a string including pixel values. ' + 'For automatic `srcset` generation, `sizes` must only include responsive values, such as `sizes="50vw"` or ' + '`sizes="(min-width: 768px) 50vw, 100vw"`. To fix this, modify the `sizes` attribute, or provide your own `ngSrcset` value directly.', ); }); it('should throw if a complex `sizes` is used with srcset', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="100" height="50" sizes="(min-width: 768px) 500px, 100vw" srcset="www.example.com/img.png?w=500 768w, www.example.com/img.png?w=2000" >'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeRejectedWithError( 'NG02952: The NgOptimizedImage directive has detected that `sizes` was set to a string including pixel values. ' + 'For automatic `srcset` generation, `sizes` must only include responsive values, such as `sizes="50vw"` or ' + '`sizes="(min-width: 768px) 50vw, 100vw"`. To fix this, modify the `sizes` attribute, or provide your own `ngSrcset` value directly.', ); }); it('should not throw if a complex `sizes` is used with ngSrcset', async () => { setupTestingModule(); const template = '<img ngSrc="path/img.png" width="100" height="50" sizes="(min-width: 768px) 500px, 100vw" ngSrcset="100w, 200w">'; const fixture = createTestComponent(template); await expectAsync(fixture.whenStable()).toBeResolved(); }); }); describe('automatic srcset generation', () => { const imageLoader = (config: ImageLoaderConfig) => { const width = config.width ? `?w=${config.width}` : ``; return `${IMG_BASE_URL}/${config.src}${width}`; }; it('should not generate a srcset if the default noop loader is used', async () => { setupTestingModule({noLoader: true}); const template = ` <img ngSrc="img" width="100" height="50" sizes="100vw"> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('srcset')).toBeNull(); }); it('should add a responsive srcset to the img element if sizes attribute exists', async () => { setupTestingModule({imageLoader}); const template = ` <img ngSrc="img" width="100" height="50" sizes="100vw"> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('srcset')).toBe( `${IMG_BASE_URL}/img?w=640 640w, ${IMG_BASE_URL}/img?w=750 750w, ${IMG_BASE_URL}/img?w=828 828w, ${IMG_BASE_URL}/img?w=1080 1080w, ${IMG_BASE_URL}/img?w=1200 1200w, ${IMG_BASE_URL}/img?w=1920 1920w, ${IMG_BASE_URL}/img?w=2048 2048w, ${IMG_BASE_URL}/img?w=3840 3840w`, ); }); it('should use the long responsive srcset if sizes attribute exists and is less than 100vw', async () => { setupTestingModule({imageLoader}); const template = ` <img ngSrc="img" width="100" height="50" sizes="2vw"> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('srcset')).toBe( `${IMG_BASE_URL}/img?w=16 16w, ${IMG_BASE_URL}/img?w=32 32w, ${IMG_BASE_URL}/img?w=48 48w, ${IMG_BASE_URL}/img?w=64 64w, ${IMG_BASE_URL}/img?w=96 96w, ${IMG_BASE_URL}/img?w=128 128w, ${IMG_BASE_URL}/img?w=256 256w, ${IMG_BASE_URL}/img?w=384 384w, ${IMG_BASE_URL}/img?w=640 640w, ${IMG_BASE_URL}/img?w=750 750w, ${IMG_BASE_URL}/img?w=828 828w, ${IMG_BASE_URL}/img?w=1080 1080w, ${IMG_BASE_URL}/img?w=1200 1200w, ${IMG_BASE_URL}/img?w=1920 1920w, ${IMG_BASE_URL}/img?w=2048 2048w, ${IMG_BASE_URL}/img?w=3840 3840w`, ); }); it('should add a fixed srcset to the img element if sizes attribute does not exist', async () => { setupTestingModule({imageLoader}); const template = ` <img ngSrc="img" width="100" height="50"> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('srcset')).toBe( `${IMG_BASE_URL}/img?w=100 1x, ${IMG_BASE_URL}/img?w=200 2x`, ); }); it('should not add a fixed srcset to the img element if height is too large', async () => { setupTestingModule({imageLoader}); const template = `<img ngSrc="img" width="1100" height="2400">`; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('srcset')).toBeNull(); }); it('should not add a fixed srcset to the img element if width is too large', async () => { setupTestingModule({imageLoader}); const template = `<img ngSrc="img" width="3000" height="400">`; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('srcset')).toBeNull(); }); it('should add a responsive srcset to the img element if height is too large', async () => { setupTestingModule({imageLoader}); const template = `<img ngSrc="img" width="1100" height="2400" sizes="100vw">`; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('srcset')).toBe( `${IMG_BASE_URL}/img?w=640 640w, ${IMG_BASE_URL}/img?w=750 750w, ${IMG_BASE_URL}/img?w=828 828w, ${IMG_BASE_URL}/img?w=1080 1080w, ${IMG_BASE_URL}/img?w=1200 1200w, ${IMG_BASE_URL}/img?w=1920 1920w, ${IMG_BASE_URL}/img?w=2048 2048w, ${IMG_BASE_URL}/img?w=3840 3840w`, ); }); it('should add a responsive srcset to the img element if width is too large', async () => { setupTestingModule({imageLoader}); const template = `<img ngSrc="img" width="3000" height="400" sizes="100vw">`; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('srcset')).toBe( `${IMG_BASE_URL}/img?w=640 640w, ${IMG_BASE_URL}/img?w=750 750w, ${IMG_BASE_URL}/img?w=828 828w, ${IMG_BASE_URL}/img?w=1080 1080w, ${IMG_BASE_URL}/img?w=1200 1200w, ${IMG_BASE_URL}/img?w=1920 1920w, ${IMG_BASE_URL}/img?w=2048 2048w, ${IMG_BASE_URL}/img?w=3840 3840w`, ); }); it('should use a custom breakpoint set if one is provided', async () => { const imageConfig = { breakpoints: [16, 32, 48, 64, 96, 128, 256, 384, 640, 1280, 3840], }; setupTestingModule({imageLoader, imageConfig}); const template = ` <img ngSrc="img" width="100" height="50" sizes="2vw"> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('srcset')).toBe( `${IMG_BASE_URL}/img?w=16 16w, ${IMG_BASE_URL}/img?w=32 32w, ${IMG_BASE_URL}/img?w=48 48w, ${IMG_BASE_URL}/img?w=64 64w, ${IMG_BASE_URL}/img?w=96 96w, ${IMG_BASE_URL}/img?w=128 128w, ${IMG_BASE_URL}/img?w=256 256w, ${IMG_BASE_URL}/img?w=384 384w, ${IMG_BASE_URL}/img?w=640 640w, ${IMG_BASE_URL}/img?w=1280 1280w, ${IMG_BASE_URL}/img?w=3840 3840w`, ); }); it('should sort custom breakpoint set', async () => { const imageConfig = { breakpoints: [48, 16, 3840, 640, 1280], }; setupTestingModule({imageLoader, imageConfig}); const template = ` <img ngSrc="img" width="100" height="50" sizes="2vw"> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('srcset')).toBe( `${IMG_BASE_URL}/img?w=16 16w, ${IMG_BASE_URL}/img?w=48 48w, ${IMG_BASE_URL}/img?w=640 640w, ${IMG_BASE_URL}/img?w=1280 1280w, ${IMG_BASE_URL}/img?w=3840 3840w`, ); }); it('should disable automatic srcset generation if "disableOptimizedSrcset" attribute is set', async () => { setupTestingModule({imageLoader}); const template = ` <img ngSrc="img" width="100" height="50" sizes="50vw" disableOptimizedSrcset> `; const fixture = createTestComponent(template); await fixture.whenStable(); const nativeElement = fixture.nativeElement as HTMLElement; const img = nativeElement.querySelector('img')!; expect(img.getAttribute('srcset')).toBeNull(); }); }); }); }); describe('escapeCssUrl', () => { it('strips characters that are invalid in URLs and break CSS quoted strings', () => { // \n, \r, \f and \0 all terminate a CSS string per the spec, and none of // them are valid URL characters either, so we just drop them. expect(escapeCssUrl('http://x.com/img\n.jpg')).toBe('http://x.com/img.jpg'); // newline expect(escapeCssUrl('http://x.com/img\r.jpg')).toBe('http://x.com/img.jpg'); // carriage return expect(escapeCssUrl('http://x.com/img\f.jpg')).toBe('http://x.com/img.jpg'); // form feed expect(escapeCssUrl('http://x.com/img\0.jpg')).toBe('http://x.com/img.jpg'); // null byte }); it('escapes characters that break the CSS url("...") wrapper', () => { // A bare " would end the url("...") string prematurely, so it becomes \". expect(escapeCssUrl('http://x.com/img".jpg')).toBe('http://x.com/img\\".jpg'); // A bare \ starts a CSS escape sequence, so it must be doubled to \\. expect(escapeCssUrl('http://x.com/img\\.jpg')).toBe('http://x.com/img\\\\.jpg'); }); it('does not double-escape backslashes', () => { // The \ → \\ replacement runs first, so backslashes introduced by later // replacements are never accidentally escaped a second time. expect(escapeCssUrl('a\\b')).toBe('a\\\\b'); }); }); // Helpers // Base URL that can be used in tests to construct absolute URLs. const IMG_BASE_URL = { // Use `toString` here to delay referencing the `window` until the tests // execution starts, otherwise the `window` might not be defined in Node env. toString: () => window.location.origin, }; const ANGULAR_LOGO_BASE64 = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTAgMjUwIj4KICAgIDxwYXRoIGZpbGw9IiNERDAwMzEiIGQ9Ik0xMjUgMzBMMzEuOSA2My4ybDE0LjIgMTIzLjFMMTI1IDIzMGw3OC45LTQzLjcgMTQuMi0xMjMuMXoiIC8+CiAgICA8cGF0aCBmaWxsPSIjQzMwMDJGIiBkPSJNMTI1IDMwdjIyLjItLjFWMjMwbDc4LjktNDMuNyAxNC4yLTEyMy4xTDEyNSAzMHoiIC8+CiAgICA8cGF0aCAgZmlsbD0iI0ZGRkZGRiIgZD0iTTEyNSA1Mi4xTDY2LjggMTgyLjZoMjEuN2wxMS43LTI5LjJoNDkuNGwxMS43IDI5LjJIMTgzTDEyNSA1Mi4xem0xNyA4My4zaC0zNGwxNy00MC45IDE3IDQwLjl6IiAvPgogIDwvc3ZnPg=='; @Component({ selector: 'test-cmp', template: '', imports: [NgOptimizedImage], }) class TestComponent { width = 100; height = 50; ngSrc = 'img.png'; priority = false; } function setupTestingModule(config?: { imageConfig?: ImageConfig; imageLoader?: ImageLoader; noLoader?: boolean; extraProviders?: Provider[]; component?: Type<unknown>; }) { const defaultLoader = (config: ImageLoaderConfig) => { const isAbsolute = /^https?:\/\//.test(config.src); return isAbsolute ? config.src : window.location.origin + '/' + config.src; }; const loader = config?.imageLoader || defaultLoader; const extraProviders = config?.extraProviders || []; const providers: Provider[] = [ {provide: DOCUMENT, useValue: window.document}, ...(config?.noLoader ? [] : [{provide: IMAGE_LOADER, useValue: loader}]), ...extraProviders, ]; if (config?.imageConfig) { providers.push({provide: IMAGE_CONFIG, useValue: config.imageConfig}); } TestBed.configureTestingModule({ imports: [config?.component ?? TestComponent], providers, }); } // Same as above but explicitly doesn't provide a custom loader, // so the noopImageLoader should be used. function setUpModuleNoLoader() { TestBed.configureTestingModule({ imports: [TestComponent], providers: [{provide: DOCUMENT, useValue: window.document}], }); } function createTestComponent( template: string, changeDetection = ChangeDetectionStrategy.OnPush, ): ComponentFixture<TestComponent> { return TestBed.overrideComponent(TestComponent, { set: {template, changeDetection}, }).createComponent(TestComponent); } function parseInlineStyles(img: HTMLImageElement): Map<string, string> { const styles = new Map(); const rawStyles = img.getAttribute('style')?.replace(/\s/g, ''); if (rawStyles) { rawStyles.split(';').forEach((style) => { const styleTuple = style.split(':'); styles.set(styleTuple[0], styleTuple.slice(1).join(':')); }); } return styles; }