Commit a123813c authored by Paul Slaughter's avatar Paul Slaughter Committed by Nicolò Maria Mezzopera

Migrate lazy_loader_spec to Jest

- Introduces mock_dom_observer which can be used
  to manually trigger MutationObserver or
  IntersectionObserver
parent bb894c71
......@@ -15,7 +15,7 @@ export default class LazyLoader {
}
static supportsIntersectionObserver() {
return 'IntersectionObserver' in window;
return Boolean(window.IntersectionObserver);
}
searchLazyImages() {
......
/* eslint-disable class-methods-use-this, max-classes-per-file */
import { isMatch } from 'lodash';
/**
* This class gives us a JSDom friendly DOM observer which we can manually trigger in tests
*
* Use this in place of MutationObserver or IntersectionObserver
*/
class MockObserver {
constructor(cb) {
this.$_cb = cb;
this.$_observers = [];
}
observe(node, options = {}) {
this.$_observers.push([node, options]);
}
disconnect() {
this.$_observers = [];
}
takeRecords() {}
// eslint-disable-next-line babel/camelcase
$_triggerObserve(node, { entry = {}, options = {} } = {}) {
if (this.$_hasObserver(node, options)) {
this.$_cb([{ target: node, ...entry }]);
}
}
// eslint-disable-next-line babel/camelcase
$_hasObserver(node, options = {}) {
return this.$_observers.some(
([obvNode, obvOptions]) => node === obvNode && isMatch(options, obvOptions),
);
}
}
class MockIntersectionObserver extends MockObserver {
unobserve(node) {
this.$_observers = this.$_observers.filter(([obvNode]) => node === obvNode);
}
}
/**
* Use this function to setup a mock observer instance in place of the given DOM Observer
*
* Example:
* ```
* describe('', () => {
* const { trigger: triggerMutate } = useMockMutationObserver();
*
* it('test', () => {
* trigger(el, { options: { childList: true }, entry: { } });
* });
* })
* ```
*
* @param {String} key
*/
const useMockObserver = (key, createMock) => {
let mockObserver;
let origObserver;
beforeEach(() => {
origObserver = global[key];
global[key] = jest.fn().mockImplementation((...args) => {
mockObserver = createMock(...args);
return mockObserver;
});
});
afterEach(() => {
mockObserver = null;
global[key] = origObserver;
});
const trigger = (...args) => {
if (!mockObserver) {
return;
}
mockObserver.$_triggerObserve(...args);
};
return { trigger };
};
export const useMockIntersectionObserver = () =>
useMockObserver('IntersectionObserver', (...args) => new MockIntersectionObserver(...args));
export const useMockMutationObserver = () =>
useMockObserver('MutationObserver', (...args) => new MockObserver(...args));
export default function scrollIntoViewPromise(intersectionTarget, timeout = 100, maxTries = 5) {
return new Promise((resolve, reject) => {
let intersectionObserver;
let retry = 0;
const intervalId = setInterval(() => {
if (retry >= maxTries) {
intersectionObserver.disconnect();
clearInterval(intervalId);
reject(new Error(`Could not scroll target into viewPort within ${timeout * maxTries} ms`));
}
retry += 1;
intersectionTarget.scrollIntoView();
}, timeout);
intersectionObserver = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
intersectionObserver.disconnect();
clearInterval(intervalId);
resolve();
}
});
intersectionObserver.observe(intersectionTarget);
intersectionTarget.scrollIntoView();
});
}
export default (domElement, attributes, timeout = 1500) =>
new Promise((resolve, reject) => {
let observer;
const timeoutId = setTimeout(() => {
observer.disconnect();
reject(new Error(`Could not see an attribute update within ${timeout} ms`));
}, timeout);
observer = new MutationObserver(() => {
clearTimeout(timeoutId);
observer.disconnect();
resolve();
});
observer.observe(domElement, { attributes: true, attributeFilter: attributes });
});
import { noop } from 'lodash';
import LazyLoader from '~/lazy_loader';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from './helpers/wait_for_promises';
import { useMockMutationObserver, useMockIntersectionObserver } from 'helpers/mock_dom_observer';
const execImmediately = callback => {
callback();
};
const TEST_PATH = `${TEST_HOST}/img/testimg.png`;
describe('LazyLoader', () => {
let lazyLoader = null;
const { trigger: triggerMutation } = useMockMutationObserver();
const { trigger: triggerIntersection } = useMockIntersectionObserver();
const triggerChildMutation = () => {
triggerMutation(document.body, { options: { childList: true, subtree: true } });
};
const triggerIntersectionWithRatio = img => {
triggerIntersection(img, { entry: { intersectionRatio: 0.1 } });
};
const createLazyLoadImage = () => {
const newImg = document.createElement('img');
newImg.className = 'lazy';
newImg.setAttribute('data-src', TEST_PATH);
document.body.appendChild(newImg);
triggerChildMutation();
return newImg;
};
const createImage = () => {
const newImg = document.createElement('img');
newImg.setAttribute('src', TEST_PATH);
document.body.appendChild(newImg);
triggerChildMutation();
return newImg;
};
beforeEach(() => {
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(execImmediately);
jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately);
jest.spyOn(LazyLoader, 'loadImage');
});
afterEach(() => {
document.body.innerHTML = '';
});
describe.each`
hasIntersectionObserver | trigger
${true} | ${triggerIntersectionWithRatio}
${false} | ${noop}
`(
'with hasIntersectionObserver=$hasIntersectionObserver',
({ hasIntersectionObserver, trigger }) => {
let origIntersectionObserver;
beforeEach(() => {
origIntersectionObserver = global.IntersectionObserver;
global.IntersectionObserver = hasIntersectionObserver
? global.IntersectionObserver
: undefined;
lazyLoader = new LazyLoader({
observerNode: 'foobar',
});
});
afterEach(() => {
global.IntersectionObserver = origIntersectionObserver;
lazyLoader.unregister();
});
it('determines intersection observer support', () => {
expect(LazyLoader.supportsIntersectionObserver()).toBe(hasIntersectionObserver);
});
it('should copy value from data-src to src for img 1', () => {
const img = createLazyLoadImage();
// Doing everything that happens normally in onload
lazyLoader.register();
trigger(img);
expect(LazyLoader.loadImage).toHaveBeenCalledWith(img);
expect(img.getAttribute('src')).toBe(TEST_PATH);
expect(img.getAttribute('data-src')).toBe(null);
expect(img).toHaveClass('js-lazy-loaded');
});
it('should lazy load dynamically added data-src images', async () => {
lazyLoader.register();
const newImg = createLazyLoadImage();
trigger(newImg);
await waitForPromises();
expect(LazyLoader.loadImage).toHaveBeenCalledWith(newImg);
expect(newImg.getAttribute('src')).toBe(TEST_PATH);
expect(newImg).toHaveClass('js-lazy-loaded');
});
it('should not alter normal images', () => {
const newImg = createImage();
lazyLoader.register();
expect(LazyLoader.loadImage).not.toHaveBeenCalled();
expect(newImg).not.toHaveClass('js-lazy-loaded');
});
it('should not load dynamically added pictures if content observer is turned off', async () => {
lazyLoader.register();
lazyLoader.stopContentObserver();
const newImg = createLazyLoadImage();
await waitForPromises();
expect(LazyLoader.loadImage).not.toHaveBeenCalledWith(newImg);
expect(newImg).not.toHaveClass('js-lazy-loaded');
});
it('should load dynamically added pictures if content observer is turned off and on again', async () => {
lazyLoader.register();
lazyLoader.stopContentObserver();
lazyLoader.startContentObserver();
const newImg = createLazyLoadImage();
trigger(newImg);
await waitForPromises();
expect(LazyLoader.loadImage).toHaveBeenCalledWith(newImg);
expect(newImg.getAttribute('src')).toBe(TEST_PATH);
expect(newImg).toHaveClass('js-lazy-loaded');
});
},
);
});
export default function scrollIntoViewPromise(intersectionTarget, timeout = 100, maxTries = 5) {
return new Promise((resolve, reject) => {
let intersectionObserver;
let retry = 0;
const intervalId = setInterval(() => {
if (retry >= maxTries) {
intersectionObserver.disconnect();
clearInterval(intervalId);
reject(new Error(`Could not scroll target into viewPort within ${timeout * maxTries} ms`));
}
retry += 1;
intersectionTarget.scrollIntoView();
}, timeout);
intersectionObserver = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
intersectionObserver.disconnect();
clearInterval(intervalId);
resolve();
}
});
intersectionObserver.observe(intersectionTarget);
intersectionTarget.scrollIntoView();
});
}
export default (domElement, attributes, timeout = 1500) =>
new Promise((resolve, reject) => {
let observer;
const timeoutId = setTimeout(() => {
observer.disconnect();
reject(new Error(`Could not see an attribute update within ${timeout} ms`));
}, timeout);
observer = new MutationObserver(() => {
clearTimeout(timeoutId);
observer.disconnect();
resolve();
});
observer.observe(domElement, { attributes: true, attributeFilter: attributes });
});
import LazyLoader from '~/lazy_loader';
import { TEST_HOST } from './test_constants';
import scrollIntoViewPromise from './helpers/scroll_into_view_promise';
import waitForPromises from './helpers/wait_for_promises';
import waitForAttributeChange from './helpers/wait_for_attribute_change';
const execImmediately = callback => {
callback();
};
describe('LazyLoader', function() {
let lazyLoader = null;
preloadFixtures('issues/issue_with_comment.html');
describe('without IntersectionObserver', () => {
beforeEach(function() {
loadFixtures('issues/issue_with_comment.html');
lazyLoader = new LazyLoader({
observerNode: 'foobar',
});
spyOn(LazyLoader, 'supportsIntersectionObserver').and.callFake(() => false);
spyOn(LazyLoader, 'loadImage').and.callThrough();
spyOn(window, 'requestAnimationFrame').and.callFake(execImmediately);
spyOn(window, 'requestIdleCallback').and.callFake(execImmediately);
// Doing everything that happens normally in onload
lazyLoader.register();
});
afterEach(() => {
lazyLoader.unregister();
});
it('should copy value from data-src to src for img 1', function(done) {
const img = document.querySelectorAll('img[data-src]')[0];
const originalDataSrc = img.getAttribute('data-src');
Promise.all([scrollIntoViewPromise(img), waitForAttributeChange(img, ['data-src', 'src'])])
.then(() => {
expect(LazyLoader.loadImage).toHaveBeenCalled();
expect(img.getAttribute('src')).toBe(originalDataSrc);
expect(img).toHaveClass('js-lazy-loaded');
done();
})
.catch(done.fail);
});
it('should lazy load dynamically added data-src images', function(done) {
const newImg = document.createElement('img');
const testPath = `${TEST_HOST}/img/testimg.png`;
newImg.className = 'lazy';
newImg.setAttribute('data-src', testPath);
document.body.appendChild(newImg);
Promise.all([
scrollIntoViewPromise(newImg),
waitForAttributeChange(newImg, ['data-src', 'src']),
])
.then(() => {
expect(LazyLoader.loadImage).toHaveBeenCalledWith(newImg);
expect(newImg.getAttribute('src')).toBe(testPath);
expect(newImg).toHaveClass('js-lazy-loaded');
done();
})
.catch(done.fail);
});
it('should not alter normal images', function(done) {
const newImg = document.createElement('img');
const testPath = `${TEST_HOST}/img/testimg.png`;
newImg.setAttribute('src', testPath);
document.body.appendChild(newImg);
scrollIntoViewPromise(newImg)
.then(waitForPromises)
.then(() => {
expect(LazyLoader.loadImage).not.toHaveBeenCalledWith(newImg);
expect(newImg).not.toHaveClass('js-lazy-loaded');
done();
})
.catch(done.fail);
});
it('should not load dynamically added pictures if content observer is turned off', done => {
lazyLoader.stopContentObserver();
const newImg = document.createElement('img');
const testPath = `${TEST_HOST}/img/testimg.png`;
newImg.className = 'lazy';
newImg.setAttribute('data-src', testPath);
document.body.appendChild(newImg);
scrollIntoViewPromise(newImg)
.then(waitForPromises)
.then(() => {
expect(LazyLoader.loadImage).not.toHaveBeenCalledWith(newImg);
expect(newImg).not.toHaveClass('js-lazy-loaded');
done();
})
.catch(done.fail);
});
it('should load dynamically added pictures if content observer is turned off and on again', done => {
lazyLoader.stopContentObserver();
lazyLoader.startContentObserver();
const newImg = document.createElement('img');
const testPath = `${TEST_HOST}/img/testimg.png`;
newImg.className = 'lazy';
newImg.setAttribute('data-src', testPath);
document.body.appendChild(newImg);
Promise.all([
scrollIntoViewPromise(newImg),
waitForAttributeChange(newImg, ['data-src', 'src']),
])
.then(waitForPromises)
.then(() => {
expect(LazyLoader.loadImage).toHaveBeenCalledWith(newImg);
expect(newImg).toHaveClass('js-lazy-loaded');
done();
})
.catch(done.fail);
});
});
describe('with IntersectionObserver', () => {
beforeEach(function() {
loadFixtures('issues/issue_with_comment.html');
lazyLoader = new LazyLoader({
observerNode: 'foobar',
});
spyOn(LazyLoader, 'loadImage').and.callThrough();
spyOn(window, 'requestAnimationFrame').and.callFake(execImmediately);
spyOn(window, 'requestIdleCallback').and.callFake(execImmediately);
// Doing everything that happens normally in onload
lazyLoader.register();
});
afterEach(() => {
lazyLoader.unregister();
});
it('should copy value from data-src to src for img 1', function(done) {
const img = document.querySelectorAll('img[data-src]')[0];
const originalDataSrc = img.getAttribute('data-src');
Promise.all([scrollIntoViewPromise(img), waitForAttributeChange(img, ['data-src', 'src'])])
.then(() => {
expect(LazyLoader.loadImage).toHaveBeenCalledWith(img);
expect(img.getAttribute('src')).toBe(originalDataSrc);
expect(img).toHaveClass('js-lazy-loaded');
done();
})
.catch(done.fail);
});
it('should lazy load dynamically added data-src images', function(done) {
const newImg = document.createElement('img');
const testPath = `${TEST_HOST}/img/testimg.png`;
newImg.className = 'lazy';
newImg.setAttribute('data-src', testPath);
document.body.appendChild(newImg);
Promise.all([
scrollIntoViewPromise(newImg),
waitForAttributeChange(newImg, ['data-src', 'src']),
])
.then(() => {
expect(LazyLoader.loadImage).toHaveBeenCalledWith(newImg);
expect(newImg.getAttribute('src')).toBe(testPath);
expect(newImg).toHaveClass('js-lazy-loaded');
done();
})
.catch(done.fail);
});
it('should not alter normal images', function(done) {
const newImg = document.createElement('img');
const testPath = `${TEST_HOST}/img/testimg.png`;
newImg.setAttribute('src', testPath);
document.body.appendChild(newImg);
scrollIntoViewPromise(newImg)
.then(waitForPromises)
.then(() => {
expect(LazyLoader.loadImage).not.toHaveBeenCalledWith(newImg);
expect(newImg).not.toHaveClass('js-lazy-loaded');
done();
})
.catch(done.fail);
});
it('should not load dynamically added pictures if content observer is turned off', done => {
lazyLoader.stopContentObserver();
const newImg = document.createElement('img');
const testPath = `${TEST_HOST}/img/testimg.png`;
newImg.className = 'lazy';
newImg.setAttribute('data-src', testPath);
document.body.appendChild(newImg);
scrollIntoViewPromise(newImg)
.then(waitForPromises)
.then(() => {
expect(LazyLoader.loadImage).not.toHaveBeenCalledWith(newImg);
expect(newImg).not.toHaveClass('js-lazy-loaded');
done();
})
.catch(done.fail);
});
it('should load dynamically added pictures if content observer is turned off and on again', done => {
lazyLoader.stopContentObserver();
lazyLoader.startContentObserver();
const newImg = document.createElement('img');
const testPath = `${TEST_HOST}/img/testimg.png`;
newImg.className = 'lazy';
newImg.setAttribute('data-src', testPath);
document.body.appendChild(newImg);
Promise.all([
scrollIntoViewPromise(newImg),
waitForAttributeChange(newImg, ['data-src', 'src']),
])
.then(() => {
expect(LazyLoader.loadImage).toHaveBeenCalledWith(newImg);
expect(newImg).toHaveClass('js-lazy-loaded');
done();
})
.catch(done.fail);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment