diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2023-01-24 12:33:51 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2023-01-24 12:33:51 +0000 |
commit | 3ea39841c8049525e31e9f4d6300f0c60cdb42de (patch) | |
tree | 855de60a8872eafb5911acd303aedcdbfe713a73 /js/tests/unit/util | |
parent | Inital commit. (diff) | |
download | bootstrap-html-3ea39841c8049525e31e9f4d6300f0c60cdb42de.tar.xz bootstrap-html-3ea39841c8049525e31e9f4d6300f0c60cdb42de.zip |
Adding upstream version 5.2.3+dfsg.upstream/5.2.3+dfsg
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | js/tests/unit/util/backdrop.spec.js | 321 | ||||
-rw-r--r-- | js/tests/unit/util/component-functions.spec.js | 108 | ||||
-rw-r--r-- | js/tests/unit/util/config.spec.js | 166 | ||||
-rw-r--r-- | js/tests/unit/util/focustrap.spec.js | 218 | ||||
-rw-r--r-- | js/tests/unit/util/index.spec.js | 814 | ||||
-rw-r--r-- | js/tests/unit/util/sanitizer.spec.js | 105 | ||||
-rw-r--r-- | js/tests/unit/util/scrollbar.spec.js | 363 | ||||
-rw-r--r-- | js/tests/unit/util/swipe.spec.js | 291 | ||||
-rw-r--r-- | js/tests/unit/util/template-factory.spec.js | 306 |
9 files changed, 2692 insertions, 0 deletions
diff --git a/js/tests/unit/util/backdrop.spec.js b/js/tests/unit/util/backdrop.spec.js new file mode 100644 index 0000000..73384fc --- /dev/null +++ b/js/tests/unit/util/backdrop.spec.js @@ -0,0 +1,321 @@ +import Backdrop from '../../../src/util/backdrop' +import { getTransitionDurationFromElement } from '../../../src/util/index' +import { clearFixture, getFixture } from '../../helpers/fixture' + +const CLASS_BACKDROP = '.modal-backdrop' +const CLASS_NAME_FADE = 'fade' +const CLASS_NAME_SHOW = 'show' + +describe('Backdrop', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + const list = document.querySelectorAll(CLASS_BACKDROP) + + for (const el of list) { + el.remove() + } + }) + + describe('show', () => { + it('should append the backdrop html once on show and include the "show" class if it is "shown"', () => { + return new Promise(resolve => { + const instance = new Backdrop({ + isVisible: true, + isAnimated: false + }) + const getElements = () => document.querySelectorAll(CLASS_BACKDROP) + + expect(getElements()).toHaveSize(0) + + instance.show() + instance.show(() => { + expect(getElements()).toHaveSize(1) + for (const el of getElements()) { + expect(el).toHaveClass(CLASS_NAME_SHOW) + } + + resolve() + }) + }) + }) + + it('should not append the backdrop html if it is not "shown"', () => { + return new Promise(resolve => { + const instance = new Backdrop({ + isVisible: false, + isAnimated: true + }) + const getElements = () => document.querySelectorAll(CLASS_BACKDROP) + + expect(getElements()).toHaveSize(0) + instance.show(() => { + expect(getElements()).toHaveSize(0) + resolve() + }) + }) + }) + + it('should append the backdrop html once and include the "fade" class if it is "shown" and "animated"', () => { + return new Promise(resolve => { + const instance = new Backdrop({ + isVisible: true, + isAnimated: true + }) + const getElements = () => document.querySelectorAll(CLASS_BACKDROP) + + expect(getElements()).toHaveSize(0) + + instance.show(() => { + expect(getElements()).toHaveSize(1) + for (const el of getElements()) { + expect(el).toHaveClass(CLASS_NAME_FADE) + } + + resolve() + }) + }) + }) + }) + + describe('hide', () => { + it('should remove the backdrop html', () => { + return new Promise(resolve => { + const instance = new Backdrop({ + isVisible: true, + isAnimated: true + }) + + const getElements = () => document.body.querySelectorAll(CLASS_BACKDROP) + + expect(getElements()).toHaveSize(0) + instance.show(() => { + expect(getElements()).toHaveSize(1) + instance.hide(() => { + expect(getElements()).toHaveSize(0) + resolve() + }) + }) + }) + }) + + it('should remove the "show" class', () => { + return new Promise(resolve => { + const instance = new Backdrop({ + isVisible: true, + isAnimated: true + }) + const elem = instance._getElement() + + instance.show() + instance.hide(() => { + expect(elem).not.toHaveClass(CLASS_NAME_SHOW) + resolve() + }) + }) + }) + + it('should not try to remove Node on remove method if it is not "shown"', () => { + return new Promise(resolve => { + const instance = new Backdrop({ + isVisible: false, + isAnimated: true + }) + const getElements = () => document.querySelectorAll(CLASS_BACKDROP) + const spy = spyOn(instance, 'dispose').and.callThrough() + + expect(getElements()).toHaveSize(0) + expect(instance._isAppended).toBeFalse() + instance.show(() => { + instance.hide(() => { + expect(getElements()).toHaveSize(0) + expect(spy).not.toHaveBeenCalled() + expect(instance._isAppended).toBeFalse() + resolve() + }) + }) + }) + }) + + it('should not error if the backdrop no longer has a parent', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '<div id="wrapper"></div>' + + const wrapper = fixtureEl.querySelector('#wrapper') + const instance = new Backdrop({ + isVisible: true, + isAnimated: true, + rootElement: wrapper + }) + + const getElements = () => document.querySelectorAll(CLASS_BACKDROP) + + instance.show(() => { + wrapper.remove() + instance.hide(() => { + expect(getElements()).toHaveSize(0) + resolve() + }) + }) + }) + }) + }) + + describe('click callback', () => { + it('should execute callback on click', () => { + return new Promise(resolve => { + const spy = jasmine.createSpy('spy') + + const instance = new Backdrop({ + isVisible: true, + isAnimated: false, + clickCallback: () => spy() + }) + const endTest = () => { + setTimeout(() => { + expect(spy).toHaveBeenCalled() + resolve() + }, 10) + } + + instance.show(() => { + const clickEvent = new Event('mousedown', { bubbles: true, cancelable: true }) + document.querySelector(CLASS_BACKDROP).dispatchEvent(clickEvent) + endTest() + }) + }) + }) + + describe('animation callbacks', () => { + it('should show and hide backdrop after counting transition duration if it is animated', () => { + return new Promise(resolve => { + const instance = new Backdrop({ + isVisible: true, + isAnimated: true + }) + const spy2 = jasmine.createSpy('spy2') + + const execDone = () => { + setTimeout(() => { + expect(spy2).toHaveBeenCalledTimes(2) + resolve() + }, 10) + } + + instance.show(spy2) + instance.hide(() => { + spy2() + execDone() + }) + expect(spy2).not.toHaveBeenCalled() + }) + }) + + it('should show and hide backdrop without a delay if it is not animated', () => { + return new Promise(resolve => { + const spy = jasmine.createSpy('spy', getTransitionDurationFromElement) + const instance = new Backdrop({ + isVisible: true, + isAnimated: false + }) + const spy2 = jasmine.createSpy('spy2') + + instance.show(spy2) + instance.hide(spy2) + + setTimeout(() => { + expect(spy2).toHaveBeenCalled() + expect(spy).not.toHaveBeenCalled() + resolve() + }, 10) + }) + }) + + it('should not call delay callbacks if it is not "shown"', () => { + return new Promise(resolve => { + const instance = new Backdrop({ + isVisible: false, + isAnimated: true + }) + const spy = jasmine.createSpy('spy', getTransitionDurationFromElement) + + instance.show() + instance.hide(() => { + expect(spy).not.toHaveBeenCalled() + resolve() + }) + }) + }) + }) + + describe('Config', () => { + describe('rootElement initialization', () => { + it('should be appended on "document.body" by default', () => { + return new Promise(resolve => { + const instance = new Backdrop({ + isVisible: true + }) + const getElement = () => document.querySelector(CLASS_BACKDROP) + instance.show(() => { + expect(getElement().parentElement).toEqual(document.body) + resolve() + }) + }) + }) + + it('should find the rootElement if passed as a string', () => { + return new Promise(resolve => { + const instance = new Backdrop({ + isVisible: true, + rootElement: 'body' + }) + const getElement = () => document.querySelector(CLASS_BACKDROP) + instance.show(() => { + expect(getElement().parentElement).toEqual(document.body) + resolve() + }) + }) + }) + + it('should be appended on any element given by the proper config', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '<div id="wrapper"></div>' + + const wrapper = fixtureEl.querySelector('#wrapper') + const instance = new Backdrop({ + isVisible: true, + rootElement: wrapper + }) + const getElement = () => document.querySelector(CLASS_BACKDROP) + instance.show(() => { + expect(getElement().parentElement).toEqual(wrapper) + resolve() + }) + }) + }) + }) + + describe('ClassName', () => { + it('should allow configuring className', () => { + return new Promise(resolve => { + const instance = new Backdrop({ + isVisible: true, + className: 'foo' + }) + const getElement = () => document.querySelector('.foo') + instance.show(() => { + expect(getElement()).toEqual(instance._getElement()) + instance.dispose() + resolve() + }) + }) + }) + }) + }) + }) +}) diff --git a/js/tests/unit/util/component-functions.spec.js b/js/tests/unit/util/component-functions.spec.js new file mode 100644 index 0000000..ec36672 --- /dev/null +++ b/js/tests/unit/util/component-functions.spec.js @@ -0,0 +1,108 @@ +/* Test helpers */ + +import { clearFixture, createEvent, getFixture } from '../../helpers/fixture' +import { enableDismissTrigger } from '../../../src/util/component-functions' +import BaseComponent from '../../../src/base-component' + +class DummyClass2 extends BaseComponent { + static get NAME() { + return 'test' + } + + hide() { + return true + } + + testMethod() { + return true + } +} + +describe('Plugin functions', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + describe('data-bs-dismiss functionality', () => { + it('should get Plugin and execute the given method, when a click occurred on data-bs-dismiss="PluginName"', () => { + fixtureEl.innerHTML = [ + '<div id="foo" class="test">', + ' <button type="button" data-bs-dismiss="test" data-bs-target="#foo"></button>', + '</div>' + ].join('') + + const spyGet = spyOn(DummyClass2, 'getOrCreateInstance').and.callThrough() + const spyTest = spyOn(DummyClass2.prototype, 'testMethod') + const componentWrapper = fixtureEl.querySelector('#foo') + const btnClose = fixtureEl.querySelector('[data-bs-dismiss="test"]') + const event = createEvent('click') + + enableDismissTrigger(DummyClass2, 'testMethod') + btnClose.dispatchEvent(event) + + expect(spyGet).toHaveBeenCalledWith(componentWrapper) + expect(spyTest).toHaveBeenCalled() + }) + + it('if data-bs-dismiss="PluginName" hasn\'t got "data-bs-target", "getOrCreateInstance" has to be initialized by closest "plugin.Name" class', () => { + fixtureEl.innerHTML = [ + '<div id="foo" class="test">', + ' <button type="button" data-bs-dismiss="test"></button>', + '</div>' + ].join('') + + const spyGet = spyOn(DummyClass2, 'getOrCreateInstance').and.callThrough() + const spyHide = spyOn(DummyClass2.prototype, 'hide') + const componentWrapper = fixtureEl.querySelector('#foo') + const btnClose = fixtureEl.querySelector('[data-bs-dismiss="test"]') + const event = createEvent('click') + + enableDismissTrigger(DummyClass2) + btnClose.dispatchEvent(event) + + expect(spyGet).toHaveBeenCalledWith(componentWrapper) + expect(spyHide).toHaveBeenCalled() + }) + + it('if data-bs-dismiss="PluginName" is disabled, must not trigger function', () => { + fixtureEl.innerHTML = [ + '<div id="foo" class="test">', + ' <button type="button" disabled data-bs-dismiss="test"></button>', + '</div>' + ].join('') + + const spy = spyOn(DummyClass2, 'getOrCreateInstance').and.callThrough() + const btnClose = fixtureEl.querySelector('[data-bs-dismiss="test"]') + const event = createEvent('click') + + enableDismissTrigger(DummyClass2) + btnClose.dispatchEvent(event) + + expect(spy).not.toHaveBeenCalled() + }) + + it('should prevent default when the trigger is <a> or <area>', () => { + fixtureEl.innerHTML = [ + '<div id="foo" class="test">', + ' <a type="button" data-bs-dismiss="test"></a>', + '</div>' + ].join('') + + const btnClose = fixtureEl.querySelector('[data-bs-dismiss="test"]') + const event = createEvent('click') + + enableDismissTrigger(DummyClass2) + const spy = spyOn(Event.prototype, 'preventDefault').and.callThrough() + + btnClose.dispatchEvent(event) + + expect(spy).toHaveBeenCalled() + }) + }) +}) diff --git a/js/tests/unit/util/config.spec.js b/js/tests/unit/util/config.spec.js new file mode 100644 index 0000000..e1693c0 --- /dev/null +++ b/js/tests/unit/util/config.spec.js @@ -0,0 +1,166 @@ +import Config from '../../../src/util/config' +import { clearFixture, getFixture } from '../../helpers/fixture' + +class DummyConfigClass extends Config { + static get NAME() { + return 'dummy' + } +} + +describe('Config', () => { + let fixtureEl + const name = 'dummy' + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + describe('NAME', () => { + it('should return plugin NAME', () => { + expect(DummyConfigClass.NAME).toEqual(name) + }) + }) + + describe('DefaultType', () => { + it('should return plugin default type', () => { + expect(DummyConfigClass.DefaultType).toEqual(jasmine.any(Object)) + }) + }) + + describe('Default', () => { + it('should return plugin defaults', () => { + expect(DummyConfigClass.Default).toEqual(jasmine.any(Object)) + }) + }) + + describe('mergeConfigObj', () => { + it('should parse element\'s data attributes and merge it with default config. Element\'s data attributes must excel Defaults', () => { + fixtureEl.innerHTML = '<div id="test" data-bs-test-bool="false" data-bs-test-int="8" data-bs-test-string1="bar"></div>' + + spyOnProperty(DummyConfigClass, 'Default', 'get').and.returnValue({ + testBool: true, + testString: 'foo', + testString1: 'foo', + testInt: 7 + }) + const instance = new DummyConfigClass() + const configResult = instance._mergeConfigObj({}, fixtureEl.querySelector('#test')) + + expect(configResult.testBool).toEqual(false) + expect(configResult.testString).toEqual('foo') + expect(configResult.testString1).toEqual('bar') + expect(configResult.testInt).toEqual(8) + }) + + it('should parse element\'s data attributes and merge it with default config, plug these given during method call. The programmatically given should excel all', () => { + fixtureEl.innerHTML = '<div id="test" data-bs-test-bool="false" data-bs-test-int="8" data-bs-test-string-1="bar"></div>' + + spyOnProperty(DummyConfigClass, 'Default', 'get').and.returnValue({ + testBool: true, + testString: 'foo', + testString1: 'foo', + testInt: 7 + }) + const instance = new DummyConfigClass() + const configResult = instance._mergeConfigObj({ + testString1: 'test', + testInt: 3 + }, fixtureEl.querySelector('#test')) + + expect(configResult.testBool).toEqual(false) + expect(configResult.testString).toEqual('foo') + expect(configResult.testString1).toEqual('test') + expect(configResult.testInt).toEqual(3) + }) + + it('should parse element\'s data attribute `config` and any rest attributes. The programmatically given should excel all. Data attribute `config` should excel only Defaults', () => { + fixtureEl.innerHTML = '<div id="test" data-bs-config=\'{"testBool":false,"testInt":50,"testInt2":100}\' data-bs-test-int="8" data-bs-test-string-1="bar"></div>' + + spyOnProperty(DummyConfigClass, 'Default', 'get').and.returnValue({ + testBool: true, + testString: 'foo', + testString1: 'foo', + testInt: 7, + testInt2: 600 + }) + const instance = new DummyConfigClass() + const configResult = instance._mergeConfigObj({ + testString1: 'test' + }, fixtureEl.querySelector('#test')) + + expect(configResult.testBool).toEqual(false) + expect(configResult.testString).toEqual('foo') + expect(configResult.testString1).toEqual('test') + expect(configResult.testInt).toEqual(8) + expect(configResult.testInt2).toEqual(100) + }) + + it('should omit element\'s data attribute `config` if is not an object', () => { + fixtureEl.innerHTML = '<div id="test" data-bs-config="foo" data-bs-test-int="8"></div>' + + spyOnProperty(DummyConfigClass, 'Default', 'get').and.returnValue({ + testInt: 7, + testInt2: 79 + }) + const instance = new DummyConfigClass() + const configResult = instance._mergeConfigObj({}, fixtureEl.querySelector('#test')) + + expect(configResult.testInt).toEqual(8) + expect(configResult.testInt2).toEqual(79) + }) + }) + + describe('typeCheckConfig', () => { + it('should check type of the config object', () => { + spyOnProperty(DummyConfigClass, 'DefaultType', 'get').and.returnValue({ + toggle: 'boolean', + parent: '(string|element)' + }) + const config = { + toggle: true, + parent: 777 + } + + const obj = new DummyConfigClass() + expect(() => { + obj._typeCheckConfig(config) + }).toThrowError(TypeError, obj.constructor.NAME.toUpperCase() + ': Option "parent" provided type "number" but expected type "(string|element)".') + }) + + it('should return null stringified when null is passed', () => { + spyOnProperty(DummyConfigClass, 'DefaultType', 'get').and.returnValue({ + toggle: 'boolean', + parent: '(null|element)' + }) + + const obj = new DummyConfigClass() + const config = { + toggle: true, + parent: null + } + + obj._typeCheckConfig(config) + expect().nothing() + }) + + it('should return undefined stringified when undefined is passed', () => { + spyOnProperty(DummyConfigClass, 'DefaultType', 'get').and.returnValue({ + toggle: 'boolean', + parent: '(undefined|element)' + }) + + const obj = new DummyConfigClass() + const config = { + toggle: true, + parent: undefined + } + + obj._typeCheckConfig(config) + expect().nothing() + }) + }) +}) diff --git a/js/tests/unit/util/focustrap.spec.js b/js/tests/unit/util/focustrap.spec.js new file mode 100644 index 0000000..bedd124 --- /dev/null +++ b/js/tests/unit/util/focustrap.spec.js @@ -0,0 +1,218 @@ +import FocusTrap from '../../../src/util/focustrap' +import EventHandler from '../../../src/dom/event-handler' +import SelectorEngine from '../../../src/dom/selector-engine' +import { clearFixture, createEvent, getFixture } from '../../helpers/fixture' + +describe('FocusTrap', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + describe('activate', () => { + it('should autofocus itself by default', () => { + fixtureEl.innerHTML = '<div id="focustrap" tabindex="-1"></div>' + + const trapElement = fixtureEl.querySelector('div') + + const spy = spyOn(trapElement, 'focus') + + const focustrap = new FocusTrap({ trapElement }) + focustrap.activate() + + expect(spy).toHaveBeenCalled() + }) + + it('if configured not to autofocus, should not autofocus itself', () => { + fixtureEl.innerHTML = '<div id="focustrap" tabindex="-1"></div>' + + const trapElement = fixtureEl.querySelector('div') + + const spy = spyOn(trapElement, 'focus') + + const focustrap = new FocusTrap({ trapElement, autofocus: false }) + focustrap.activate() + + expect(spy).not.toHaveBeenCalled() + }) + + it('should force focus inside focus trap if it can', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '<a href="#" id="outside">outside</a>', + '<div id="focustrap" tabindex="-1">', + ' <a href="#" id="inside">inside</a>', + '</div>' + ].join('') + + const trapElement = fixtureEl.querySelector('div') + const focustrap = new FocusTrap({ trapElement }) + focustrap.activate() + + const inside = document.getElementById('inside') + + const focusInListener = () => { + expect(spy).toHaveBeenCalled() + document.removeEventListener('focusin', focusInListener) + resolve() + } + + const spy = spyOn(inside, 'focus') + spyOn(SelectorEngine, 'focusableChildren').and.callFake(() => [inside]) + + document.addEventListener('focusin', focusInListener) + + const focusInEvent = createEvent('focusin', { bubbles: true }) + Object.defineProperty(focusInEvent, 'target', { + value: document.getElementById('outside') + }) + + document.dispatchEvent(focusInEvent) + }) + }) + + it('should wrap focus around forward on tab', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '<a href="#" id="outside">outside</a>', + '<div id="focustrap" tabindex="-1">', + ' <a href="#" id="first">first</a>', + ' <a href="#" id="inside">inside</a>', + ' <a href="#" id="last">last</a>', + '</div>' + ].join('') + + const trapElement = fixtureEl.querySelector('div') + const focustrap = new FocusTrap({ trapElement }) + focustrap.activate() + + const first = document.getElementById('first') + const inside = document.getElementById('inside') + const last = document.getElementById('last') + const outside = document.getElementById('outside') + + spyOn(SelectorEngine, 'focusableChildren').and.callFake(() => [first, inside, last]) + const spy = spyOn(first, 'focus').and.callThrough() + + const focusInListener = () => { + expect(spy).toHaveBeenCalled() + first.removeEventListener('focusin', focusInListener) + resolve() + } + + first.addEventListener('focusin', focusInListener) + + const keydown = createEvent('keydown') + keydown.key = 'Tab' + + document.dispatchEvent(keydown) + outside.focus() + }) + }) + + it('should wrap focus around backwards on shift-tab', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '<a href="#" id="outside">outside</a>', + '<div id="focustrap" tabindex="-1">', + ' <a href="#" id="first">first</a>', + ' <a href="#" id="inside">inside</a>', + ' <a href="#" id="last">last</a>', + '</div>' + ].join('') + + const trapElement = fixtureEl.querySelector('div') + const focustrap = new FocusTrap({ trapElement }) + focustrap.activate() + + const first = document.getElementById('first') + const inside = document.getElementById('inside') + const last = document.getElementById('last') + const outside = document.getElementById('outside') + + spyOn(SelectorEngine, 'focusableChildren').and.callFake(() => [first, inside, last]) + const spy = spyOn(last, 'focus').and.callThrough() + + const focusInListener = () => { + expect(spy).toHaveBeenCalled() + last.removeEventListener('focusin', focusInListener) + resolve() + } + + last.addEventListener('focusin', focusInListener) + + const keydown = createEvent('keydown') + keydown.key = 'Tab' + keydown.shiftKey = true + + document.dispatchEvent(keydown) + outside.focus() + }) + }) + + it('should force focus on itself if there is no focusable content', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '<a href="#" id="outside">outside</a>', + '<div id="focustrap" tabindex="-1"></div>' + ].join('') + + const trapElement = fixtureEl.querySelector('div') + const focustrap = new FocusTrap({ trapElement }) + focustrap.activate() + + const focusInListener = () => { + expect(spy).toHaveBeenCalled() + document.removeEventListener('focusin', focusInListener) + resolve() + } + + const spy = spyOn(focustrap._config.trapElement, 'focus') + + document.addEventListener('focusin', focusInListener) + + const focusInEvent = createEvent('focusin', { bubbles: true }) + Object.defineProperty(focusInEvent, 'target', { + value: document.getElementById('outside') + }) + + document.dispatchEvent(focusInEvent) + }) + }) + }) + + describe('deactivate', () => { + it('should flag itself as no longer active', () => { + const focustrap = new FocusTrap({ trapElement: fixtureEl }) + focustrap.activate() + expect(focustrap._isActive).toBeTrue() + + focustrap.deactivate() + expect(focustrap._isActive).toBeFalse() + }) + + it('should remove all event listeners', () => { + const focustrap = new FocusTrap({ trapElement: fixtureEl }) + focustrap.activate() + + const spy = spyOn(EventHandler, 'off') + focustrap.deactivate() + + expect(spy).toHaveBeenCalled() + }) + + it('doesn\'t try removing event listeners unless it needs to (in case it hasn\'t been activated)', () => { + const focustrap = new FocusTrap({ trapElement: fixtureEl }) + + const spy = spyOn(EventHandler, 'off') + focustrap.deactivate() + + expect(spy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/js/tests/unit/util/index.spec.js b/js/tests/unit/util/index.spec.js new file mode 100644 index 0000000..9f28ce0 --- /dev/null +++ b/js/tests/unit/util/index.spec.js @@ -0,0 +1,814 @@ +import * as Util from '../../../src/util/index' +import { clearFixture, getFixture } from '../../helpers/fixture' +import { noop } from '../../../src/util/index' + +describe('Util', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + describe('getUID', () => { + it('should generate uid', () => { + const uid = Util.getUID('bs') + const uid2 = Util.getUID('bs') + + expect(uid).not.toEqual(uid2) + }) + }) + + describe('getSelectorFromElement', () => { + it('should get selector from data-bs-target', () => { + fixtureEl.innerHTML = [ + '<div id="test" data-bs-target=".target"></div>', + '<div class="target"></div>' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(Util.getSelectorFromElement(testEl)).toEqual('.target') + }) + + it('should get selector from href if no data-bs-target set', () => { + fixtureEl.innerHTML = [ + '<a id="test" href=".target"></a>', + '<div class="target"></div>' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(Util.getSelectorFromElement(testEl)).toEqual('.target') + }) + + it('should get selector from href if data-bs-target equal to #', () => { + fixtureEl.innerHTML = [ + '<a id="test" data-bs-target="#" href=".target"></a>', + '<div class="target"></div>' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(Util.getSelectorFromElement(testEl)).toEqual('.target') + }) + + it('should return null if a selector from a href is a url without an anchor', () => { + fixtureEl.innerHTML = [ + '<a id="test" data-bs-target="#" href="foo/bar.html"></a>', + '<div class="target"></div>' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(Util.getSelectorFromElement(testEl)).toBeNull() + }) + + it('should return the anchor if a selector from a href is a url', () => { + fixtureEl.innerHTML = [ + '<a id="test" data-bs-target="#" href="foo/bar.html#target"></a>', + '<div id="target"></div>' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(Util.getSelectorFromElement(testEl)).toEqual('#target') + }) + + it('should return null if selector not found', () => { + fixtureEl.innerHTML = '<a id="test" href=".target"></a>' + + const testEl = fixtureEl.querySelector('#test') + + expect(Util.getSelectorFromElement(testEl)).toBeNull() + }) + + it('should return null if no selector', () => { + fixtureEl.innerHTML = '<div></div>' + + const testEl = fixtureEl.querySelector('div') + + expect(Util.getSelectorFromElement(testEl)).toBeNull() + }) + }) + + describe('getElementFromSelector', () => { + it('should get element from data-bs-target', () => { + fixtureEl.innerHTML = [ + '<div id="test" data-bs-target=".target"></div>', + '<div class="target"></div>' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(Util.getElementFromSelector(testEl)).toEqual(fixtureEl.querySelector('.target')) + }) + + it('should get element from href if no data-bs-target set', () => { + fixtureEl.innerHTML = [ + '<a id="test" href=".target"></a>', + '<div class="target"></div>' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(Util.getElementFromSelector(testEl)).toEqual(fixtureEl.querySelector('.target')) + }) + + it('should return null if element not found', () => { + fixtureEl.innerHTML = '<a id="test" href=".target"></a>' + + const testEl = fixtureEl.querySelector('#test') + + expect(Util.getElementFromSelector(testEl)).toBeNull() + }) + + it('should return null if no selector', () => { + fixtureEl.innerHTML = '<div></div>' + + const testEl = fixtureEl.querySelector('div') + + expect(Util.getElementFromSelector(testEl)).toBeNull() + }) + }) + + describe('getTransitionDurationFromElement', () => { + it('should get transition from element', () => { + fixtureEl.innerHTML = '<div style="transition: all 300ms ease-out;"></div>' + + expect(Util.getTransitionDurationFromElement(fixtureEl.querySelector('div'))).toEqual(300) + }) + + it('should return 0 if the element is undefined or null', () => { + expect(Util.getTransitionDurationFromElement(null)).toEqual(0) + expect(Util.getTransitionDurationFromElement(undefined)).toEqual(0) + }) + + it('should return 0 if the element do not possess transition', () => { + fixtureEl.innerHTML = '<div></div>' + + expect(Util.getTransitionDurationFromElement(fixtureEl.querySelector('div'))).toEqual(0) + }) + }) + + describe('triggerTransitionEnd', () => { + it('should trigger transitionend event', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '<div></div>' + + const el = fixtureEl.querySelector('div') + const spy = spyOn(el, 'dispatchEvent').and.callThrough() + + el.addEventListener('transitionend', () => { + expect(spy).toHaveBeenCalled() + resolve() + }) + + Util.triggerTransitionEnd(el) + }) + }) + }) + + describe('isElement', () => { + it('should detect if the parameter is an element or not and return Boolean', () => { + fixtureEl.innerHTML = [ + '<div id="foo" class="test"></div>', + '<div id="bar" class="test"></div>' + ].join('') + + const el = fixtureEl.querySelector('#foo') + + expect(Util.isElement(el)).toBeTrue() + expect(Util.isElement({})).toBeFalse() + expect(Util.isElement(fixtureEl.querySelectorAll('.test'))).toBeFalse() + }) + + it('should detect jQuery element', () => { + fixtureEl.innerHTML = '<div></div>' + + const el = fixtureEl.querySelector('div') + const fakejQuery = { + 0: el, + jquery: 'foo' + } + + expect(Util.isElement(fakejQuery)).toBeTrue() + }) + }) + + describe('getElement', () => { + it('should try to parse element', () => { + fixtureEl.innerHTML = [ + '<div id="foo" class="test"></div>', + '<div id="bar" class="test"></div>' + ].join('') + + const el = fixtureEl.querySelector('div') + + expect(Util.getElement(el)).toEqual(el) + expect(Util.getElement('#foo')).toEqual(el) + expect(Util.getElement('#fail')).toBeNull() + expect(Util.getElement({})).toBeNull() + expect(Util.getElement([])).toBeNull() + expect(Util.getElement()).toBeNull() + expect(Util.getElement(null)).toBeNull() + expect(Util.getElement(fixtureEl.querySelectorAll('.test'))).toBeNull() + + const fakejQueryObject = { + 0: el, + jquery: 'foo' + } + + expect(Util.getElement(fakejQueryObject)).toEqual(el) + }) + }) + + describe('isVisible', () => { + it('should return false if the element is not defined', () => { + expect(Util.isVisible(null)).toBeFalse() + expect(Util.isVisible(undefined)).toBeFalse() + }) + + it('should return false if the element provided is not a dom element', () => { + expect(Util.isVisible({})).toBeFalse() + }) + + it('should return false if the element is not visible with display none', () => { + fixtureEl.innerHTML = '<div style="display: none;"></div>' + + const div = fixtureEl.querySelector('div') + + expect(Util.isVisible(div)).toBeFalse() + }) + + it('should return false if the element is not visible with visibility hidden', () => { + fixtureEl.innerHTML = '<div style="visibility: hidden;"></div>' + + const div = fixtureEl.querySelector('div') + + expect(Util.isVisible(div)).toBeFalse() + }) + + it('should return false if an ancestor element is display none', () => { + fixtureEl.innerHTML = [ + '<div style="display: none;">', + ' <div>', + ' <div>', + ' <div class="content"></div>', + ' </div>', + ' </div>', + '</div>' + ].join('') + + const div = fixtureEl.querySelector('.content') + + expect(Util.isVisible(div)).toBeFalse() + }) + + it('should return false if an ancestor element is visibility hidden', () => { + fixtureEl.innerHTML = [ + '<div style="visibility: hidden;">', + ' <div>', + ' <div>', + ' <div class="content"></div>', + ' </div>', + ' </div>', + '</div>' + ].join('') + + const div = fixtureEl.querySelector('.content') + + expect(Util.isVisible(div)).toBeFalse() + }) + + it('should return true if an ancestor element is visibility hidden, but reverted', () => { + fixtureEl.innerHTML = [ + '<div style="visibility: hidden;">', + ' <div style="visibility: visible;">', + ' <div>', + ' <div class="content"></div>', + ' </div>', + ' </div>', + '</div>' + ].join('') + + const div = fixtureEl.querySelector('.content') + + expect(Util.isVisible(div)).toBeTrue() + }) + + it('should return true if the element is visible', () => { + fixtureEl.innerHTML = [ + '<div>', + ' <div id="element"></div>', + '</div>' + ].join('') + + const div = fixtureEl.querySelector('#element') + + expect(Util.isVisible(div)).toBeTrue() + }) + + it('should return false if the element is hidden, but not via display or visibility', () => { + fixtureEl.innerHTML = [ + '<details>', + ' <div id="element"></div>', + '</details>' + ].join('') + + const div = fixtureEl.querySelector('#element') + + expect(Util.isVisible(div)).toBeFalse() + }) + + it('should return true if its a closed details element', () => { + fixtureEl.innerHTML = '<details id="element"></details>' + + const div = fixtureEl.querySelector('#element') + + expect(Util.isVisible(div)).toBeTrue() + }) + + it('should return true if the element is visible inside an open details element', () => { + fixtureEl.innerHTML = [ + '<details open>', + ' <div id="element"></div>', + '</details>' + ].join('') + + const div = fixtureEl.querySelector('#element') + + expect(Util.isVisible(div)).toBeTrue() + }) + + it('should return true if the element is a visible summary in a closed details element', () => { + fixtureEl.innerHTML = [ + '<details>', + ' <summary id="element-1">', + ' <span id="element-2"></span>', + ' </summary>', + '</details>' + ].join('') + + const element1 = fixtureEl.querySelector('#element-1') + const element2 = fixtureEl.querySelector('#element-2') + + expect(Util.isVisible(element1)).toBeTrue() + expect(Util.isVisible(element2)).toBeTrue() + }) + }) + + describe('isDisabled', () => { + it('should return true if the element is not defined', () => { + expect(Util.isDisabled(null)).toBeTrue() + expect(Util.isDisabled(undefined)).toBeTrue() + expect(Util.isDisabled()).toBeTrue() + }) + + it('should return true if the element provided is not a dom element', () => { + expect(Util.isDisabled({})).toBeTrue() + expect(Util.isDisabled('test')).toBeTrue() + }) + + it('should return true if the element has disabled attribute', () => { + fixtureEl.innerHTML = [ + '<div>', + ' <div id="element" disabled="disabled"></div>', + ' <div id="element1" disabled="true"></div>', + ' <div id="element2" disabled></div>', + '</div>' + ].join('') + + const div = fixtureEl.querySelector('#element') + const div1 = fixtureEl.querySelector('#element1') + const div2 = fixtureEl.querySelector('#element2') + + expect(Util.isDisabled(div)).toBeTrue() + expect(Util.isDisabled(div1)).toBeTrue() + expect(Util.isDisabled(div2)).toBeTrue() + }) + + it('should return false if the element has disabled attribute with "false" value, or doesn\'t have attribute', () => { + fixtureEl.innerHTML = [ + '<div>', + ' <div id="element" disabled="false"></div>', + ' <div id="element1" ></div>', + '</div>' + ].join('') + + const div = fixtureEl.querySelector('#element') + const div1 = fixtureEl.querySelector('#element1') + + expect(Util.isDisabled(div)).toBeFalse() + expect(Util.isDisabled(div1)).toBeFalse() + }) + + it('should return false if the element is not disabled ', () => { + fixtureEl.innerHTML = [ + '<div>', + ' <button id="button"></button>', + ' <select id="select"></select>', + ' <select id="input"></select>', + '</div>' + ].join('') + + const el = selector => fixtureEl.querySelector(selector) + + expect(Util.isDisabled(el('#button'))).toBeFalse() + expect(Util.isDisabled(el('#select'))).toBeFalse() + expect(Util.isDisabled(el('#input'))).toBeFalse() + }) + + it('should return true if the element has disabled attribute', () => { + fixtureEl.innerHTML = [ + '<div>', + ' <input id="input" disabled="disabled">', + ' <input id="input1" disabled="disabled">', + ' <button id="button" disabled="true"></button>', + ' <button id="button1" disabled="disabled"></button>', + ' <button id="button2" disabled></button>', + ' <select id="select" disabled></select>', + ' <select id="input" disabled></select>', + '</div>' + ].join('') + + const el = selector => fixtureEl.querySelector(selector) + + expect(Util.isDisabled(el('#input'))).toBeTrue() + expect(Util.isDisabled(el('#input1'))).toBeTrue() + expect(Util.isDisabled(el('#button'))).toBeTrue() + expect(Util.isDisabled(el('#button1'))).toBeTrue() + expect(Util.isDisabled(el('#button2'))).toBeTrue() + expect(Util.isDisabled(el('#input'))).toBeTrue() + }) + + it('should return true if the element has class "disabled"', () => { + fixtureEl.innerHTML = [ + '<div>', + ' <div id="element" class="disabled"></div>', + '</div>' + ].join('') + + const div = fixtureEl.querySelector('#element') + + expect(Util.isDisabled(div)).toBeTrue() + }) + + it('should return true if the element has class "disabled" but disabled attribute is false', () => { + fixtureEl.innerHTML = [ + '<div>', + ' <input id="input" class="disabled" disabled="false">', + '</div>' + ].join('') + + const div = fixtureEl.querySelector('#input') + + expect(Util.isDisabled(div)).toBeTrue() + }) + }) + + describe('findShadowRoot', () => { + it('should return null if shadow dom is not available', () => { + // Only for newer browsers + if (!document.documentElement.attachShadow) { + expect().nothing() + return + } + + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + spyOn(document.documentElement, 'attachShadow').and.returnValue(null) + + expect(Util.findShadowRoot(div)).toBeNull() + }) + + it('should return null when we do not find a shadow root', () => { + // Only for newer browsers + if (!document.documentElement.attachShadow) { + expect().nothing() + return + } + + spyOn(document, 'getRootNode').and.returnValue(undefined) + + expect(Util.findShadowRoot(document)).toBeNull() + }) + + it('should return the shadow root when found', () => { + // Only for newer browsers + if (!document.documentElement.attachShadow) { + expect().nothing() + return + } + + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const shadowRoot = div.attachShadow({ + mode: 'open' + }) + + expect(Util.findShadowRoot(shadowRoot)).toEqual(shadowRoot) + + shadowRoot.innerHTML = '<button>Shadow Button</button>' + + expect(Util.findShadowRoot(shadowRoot.firstChild)).toEqual(shadowRoot) + }) + }) + + describe('noop', () => { + it('should be a function', () => { + expect(Util.noop).toEqual(jasmine.any(Function)) + }) + }) + + describe('reflow', () => { + it('should return element offset height to force the reflow', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const spy = spyOnProperty(div, 'offsetHeight') + Util.reflow(div) + expect(spy).toHaveBeenCalled() + }) + }) + + describe('getjQuery', () => { + const fakejQuery = { trigger() {} } + + beforeEach(() => { + Object.defineProperty(window, 'jQuery', { + value: fakejQuery, + writable: true + }) + }) + + afterEach(() => { + window.jQuery = undefined + }) + + it('should return jQuery object when present', () => { + expect(Util.getjQuery()).toEqual(fakejQuery) + }) + + it('should not return jQuery object when present if data-bs-no-jquery', () => { + document.body.setAttribute('data-bs-no-jquery', '') + + expect(window.jQuery).toEqual(fakejQuery) + expect(Util.getjQuery()).toBeNull() + + document.body.removeAttribute('data-bs-no-jquery') + }) + + it('should not return jQuery if not present', () => { + window.jQuery = undefined + expect(Util.getjQuery()).toBeNull() + }) + }) + + describe('onDOMContentLoaded', () => { + it('should execute callbacks when DOMContentLoaded is fired and should not add more than one listener', () => { + const spy = jasmine.createSpy() + const spy2 = jasmine.createSpy() + + const spyAdd = spyOn(document, 'addEventListener').and.callThrough() + spyOnProperty(document, 'readyState').and.returnValue('loading') + + Util.onDOMContentLoaded(spy) + Util.onDOMContentLoaded(spy2) + + document.dispatchEvent(new Event('DOMContentLoaded', { + bubbles: true, + cancelable: true + })) + + expect(spy).toHaveBeenCalled() + expect(spy2).toHaveBeenCalled() + expect(spyAdd).toHaveBeenCalledTimes(1) + }) + + it('should execute callback if readyState is not "loading"', () => { + const spy = jasmine.createSpy() + Util.onDOMContentLoaded(spy) + expect(spy).toHaveBeenCalled() + }) + }) + + describe('defineJQueryPlugin', () => { + const fakejQuery = { fn: {} } + + beforeEach(() => { + Object.defineProperty(window, 'jQuery', { + value: fakejQuery, + writable: true + }) + }) + + afterEach(() => { + window.jQuery = undefined + }) + + it('should define a plugin on the jQuery instance', () => { + const pluginMock = Util.noop + pluginMock.NAME = 'test' + pluginMock.jQueryInterface = Util.noop + + Util.defineJQueryPlugin(pluginMock) + expect(fakejQuery.fn.test).toEqual(pluginMock.jQueryInterface) + expect(fakejQuery.fn.test.Constructor).toEqual(pluginMock) + expect(fakejQuery.fn.test.noConflict).toEqual(jasmine.any(Function)) + }) + }) + + describe('execute', () => { + it('should execute if arg is function', () => { + const spy = jasmine.createSpy('spy') + Util.execute(spy) + expect(spy).toHaveBeenCalled() + }) + }) + + describe('executeAfterTransition', () => { + it('should immediately execute a function when waitForTransition parameter is false', () => { + const el = document.createElement('div') + const callbackSpy = jasmine.createSpy('callback spy') + const eventListenerSpy = spyOn(el, 'addEventListener') + + Util.executeAfterTransition(callbackSpy, el, false) + + expect(callbackSpy).toHaveBeenCalled() + expect(eventListenerSpy).not.toHaveBeenCalled() + }) + + it('should execute a function when a transitionend event is dispatched', () => { + const el = document.createElement('div') + const callbackSpy = jasmine.createSpy('callback spy') + + spyOn(window, 'getComputedStyle').and.returnValue({ + transitionDuration: '0.05s', + transitionDelay: '0s' + }) + + Util.executeAfterTransition(callbackSpy, el) + + el.dispatchEvent(new TransitionEvent('transitionend')) + + expect(callbackSpy).toHaveBeenCalled() + }) + + it('should execute a function after a computed CSS transition duration and there was no transitionend event dispatched', () => { + return new Promise(resolve => { + const el = document.createElement('div') + const callbackSpy = jasmine.createSpy('callback spy') + + spyOn(window, 'getComputedStyle').and.returnValue({ + transitionDuration: '0.05s', + transitionDelay: '0s' + }) + + Util.executeAfterTransition(callbackSpy, el) + + setTimeout(() => { + expect(callbackSpy).toHaveBeenCalled() + resolve() + }, 70) + }) + }) + + it('should not execute a function a second time after a computed CSS transition duration and if a transitionend event has already been dispatched', () => { + return new Promise(resolve => { + const el = document.createElement('div') + const callbackSpy = jasmine.createSpy('callback spy') + + spyOn(window, 'getComputedStyle').and.returnValue({ + transitionDuration: '0.05s', + transitionDelay: '0s' + }) + + Util.executeAfterTransition(callbackSpy, el) + + setTimeout(() => { + el.dispatchEvent(new TransitionEvent('transitionend')) + }, 50) + + setTimeout(() => { + expect(callbackSpy).toHaveBeenCalledTimes(1) + resolve() + }, 70) + }) + }) + + it('should not trigger a transitionend event if another transitionend event had already happened', () => { + return new Promise(resolve => { + const el = document.createElement('div') + + spyOn(window, 'getComputedStyle').and.returnValue({ + transitionDuration: '0.05s', + transitionDelay: '0s' + }) + + Util.executeAfterTransition(noop, el) + + // simulate a event dispatched by the browser + el.dispatchEvent(new TransitionEvent('transitionend')) + + const dispatchSpy = spyOn(el, 'dispatchEvent').and.callThrough() + + setTimeout(() => { + // setTimeout should not have triggered another transitionend event. + expect(dispatchSpy).not.toHaveBeenCalled() + resolve() + }, 70) + }) + }) + + it('should ignore transitionend events from nested elements', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '<div class="outer">', + ' <div class="nested"></div>', + '</div>' + ].join('') + + const outer = fixtureEl.querySelector('.outer') + const nested = fixtureEl.querySelector('.nested') + const callbackSpy = jasmine.createSpy('callback spy') + + spyOn(window, 'getComputedStyle').and.returnValue({ + transitionDuration: '0.05s', + transitionDelay: '0s' + }) + + Util.executeAfterTransition(callbackSpy, outer) + + nested.dispatchEvent(new TransitionEvent('transitionend', { + bubbles: true + })) + + setTimeout(() => { + expect(callbackSpy).not.toHaveBeenCalled() + }, 20) + + setTimeout(() => { + expect(callbackSpy).toHaveBeenCalled() + resolve() + }, 70) + }) + }) + }) + + describe('getNextActiveElement', () => { + it('should return first element if active not exists or not given and shouldGetNext is either true, or false with cycling being disabled', () => { + const array = ['a', 'b', 'c', 'd'] + + expect(Util.getNextActiveElement(array, '', true, true)).toEqual('a') + expect(Util.getNextActiveElement(array, 'g', true, true)).toEqual('a') + expect(Util.getNextActiveElement(array, '', true, false)).toEqual('a') + expect(Util.getNextActiveElement(array, 'g', true, false)).toEqual('a') + expect(Util.getNextActiveElement(array, '', false, false)).toEqual('a') + expect(Util.getNextActiveElement(array, 'g', false, false)).toEqual('a') + }) + + it('should return last element if active not exists or not given and shouldGetNext is false but cycling is enabled', () => { + const array = ['a', 'b', 'c', 'd'] + + expect(Util.getNextActiveElement(array, '', false, true)).toEqual('d') + expect(Util.getNextActiveElement(array, 'g', false, true)).toEqual('d') + }) + + it('should return next element or same if is last', () => { + const array = ['a', 'b', 'c', 'd'] + + expect(Util.getNextActiveElement(array, 'a', true, true)).toEqual('b') + expect(Util.getNextActiveElement(array, 'b', true, true)).toEqual('c') + expect(Util.getNextActiveElement(array, 'd', true, false)).toEqual('d') + }) + + it('should return next element or first, if is last and "isCycleAllowed = true"', () => { + const array = ['a', 'b', 'c', 'd'] + + expect(Util.getNextActiveElement(array, 'c', true, true)).toEqual('d') + expect(Util.getNextActiveElement(array, 'd', true, true)).toEqual('a') + }) + + it('should return previous element or same if is first', () => { + const array = ['a', 'b', 'c', 'd'] + + expect(Util.getNextActiveElement(array, 'b', false, true)).toEqual('a') + expect(Util.getNextActiveElement(array, 'd', false, true)).toEqual('c') + expect(Util.getNextActiveElement(array, 'a', false, false)).toEqual('a') + }) + + it('should return next element or first, if is last and "isCycleAllowed = true"', () => { + const array = ['a', 'b', 'c', 'd'] + + expect(Util.getNextActiveElement(array, 'd', false, true)).toEqual('c') + expect(Util.getNextActiveElement(array, 'a', false, true)).toEqual('d') + }) + }) +}) diff --git a/js/tests/unit/util/sanitizer.spec.js b/js/tests/unit/util/sanitizer.spec.js new file mode 100644 index 0000000..c656aed --- /dev/null +++ b/js/tests/unit/util/sanitizer.spec.js @@ -0,0 +1,105 @@ +import { DefaultAllowlist, sanitizeHtml } from '../../../src/util/sanitizer' + +describe('Sanitizer', () => { + describe('sanitizeHtml', () => { + it('should return the same on empty string', () => { + const empty = '' + + const result = sanitizeHtml(empty, DefaultAllowlist, null) + + expect(result).toEqual(empty) + }) + + it('should sanitize template by removing tags with XSS', () => { + const template = [ + '<div>', + ' <a href="javascript:alert(7)">Click me</a>', + ' <span>Some content</span>', + '</div>' + ].join('') + + const result = sanitizeHtml(template, DefaultAllowlist, null) + + expect(result).not.toContain('href="javascript:alert(7)') + }) + + it('should sanitize template and work with multiple regex', () => { + const template = [ + '<div>', + ' <a href="javascript:alert(7)" aria-label="This is a link" data-foo="bar">Click me</a>', + ' <span>Some content</span>', + '</div>' + ].join('') + + const myDefaultAllowList = DefaultAllowlist + // With the default allow list + let result = sanitizeHtml(template, myDefaultAllowList, null) + + // `data-foo` won't be present + expect(result).not.toContain('data-foo="bar"') + + // Add the following regex too + myDefaultAllowList['*'].push(/^data-foo/) + + result = sanitizeHtml(template, myDefaultAllowList, null) + + expect(result).not.toContain('href="javascript:alert(7)') // This is in the default list + expect(result).toContain('aria-label="This is a link"') // This is in the default list + expect(result).toContain('data-foo="bar"') // We explicitly allow this + }) + + it('should allow aria attributes and safe attributes', () => { + const template = [ + '<div aria-pressed="true">', + ' <span class="test">Some content</span>', + '</div>' + ].join('') + + const result = sanitizeHtml(template, DefaultAllowlist, null) + + expect(result).toContain('aria-pressed') + expect(result).toContain('class="test"') + }) + + it('should remove tags not in allowlist', () => { + const template = [ + '<div>', + ' <script>alert(7)</script>', + '</div>' + ].join('') + + const result = sanitizeHtml(template, DefaultAllowlist, null) + + expect(result).not.toContain('<script>') + }) + + it('should not use native api to sanitize if a custom function passed', () => { + const template = [ + '<div>', + ' <span>Some content</span>', + '</div>' + ].join('') + + function mySanitize(htmlUnsafe) { + return htmlUnsafe + } + + const spy = spyOn(DOMParser.prototype, 'parseFromString') + + const result = sanitizeHtml(template, DefaultAllowlist, mySanitize) + + expect(result).toEqual(template) + expect(spy).not.toHaveBeenCalled() + }) + + it('should allow multiple sanitation passes of the same template', () => { + const template = '<img src="test.jpg">' + + const firstResult = sanitizeHtml(template, DefaultAllowlist, null) + const secondResult = sanitizeHtml(template, DefaultAllowlist, null) + + expect(firstResult).toContain('src') + expect(secondResult).toContain('src') + }) + }) +}) diff --git a/js/tests/unit/util/scrollbar.spec.js b/js/tests/unit/util/scrollbar.spec.js new file mode 100644 index 0000000..6fcf571 --- /dev/null +++ b/js/tests/unit/util/scrollbar.spec.js @@ -0,0 +1,363 @@ +import { clearBodyAndDocument, clearFixture, getFixture } from '../../helpers/fixture' +import Manipulator from '../../../src/dom/manipulator' +import ScrollBarHelper from '../../../src/util/scrollbar' + +describe('ScrollBar', () => { + let fixtureEl + const doc = document.documentElement + const parseIntDecimal = arg => Number.parseInt(arg, 10) + const getPaddingX = el => parseIntDecimal(window.getComputedStyle(el).paddingRight) + const getMarginX = el => parseIntDecimal(window.getComputedStyle(el).marginRight) + const getOverFlow = el => el.style.overflow + const getPaddingAttr = el => Manipulator.getDataAttribute(el, 'padding-right') + const getMarginAttr = el => Manipulator.getDataAttribute(el, 'margin-right') + const getOverFlowAttr = el => Manipulator.getDataAttribute(el, 'overflow') + const windowCalculations = () => { + return { + htmlClient: document.documentElement.clientWidth, + htmlOffset: document.documentElement.offsetWidth, + docClient: document.body.clientWidth, + htmlBound: document.documentElement.getBoundingClientRect().width, + bodyBound: document.body.getBoundingClientRect().width, + window: window.innerWidth, + width: Math.abs(window.innerWidth - document.documentElement.clientWidth) + } + } + + // iOS, Android devices and macOS browsers hide scrollbar by default and show it only while scrolling. + // So the tests for scrollbar would fail + const isScrollBarHidden = () => { + const calc = windowCalculations() + return calc.htmlClient === calc.htmlOffset && calc.htmlClient === calc.window + } + + beforeAll(() => { + fixtureEl = getFixture() + // custom fixture to avoid extreme style values + fixtureEl.removeAttribute('style') + }) + + afterAll(() => { + fixtureEl.remove() + }) + + afterEach(() => { + clearFixture() + clearBodyAndDocument() + }) + + beforeEach(() => { + clearBodyAndDocument() + }) + + describe('isBodyOverflowing', () => { + it('should return true if body is overflowing', () => { + document.documentElement.style.overflowY = 'scroll' + document.body.style.overflowY = 'scroll' + fixtureEl.innerHTML = '<div style="height: 110vh; width: 100%"></div>' + const result = new ScrollBarHelper().isOverflowing() + + if (isScrollBarHidden()) { + expect(result).toBeFalse() + } else { + expect(result).toBeTrue() + } + }) + + it('should return false if body is not overflowing', () => { + doc.style.overflowY = 'hidden' + document.body.style.overflowY = 'hidden' + fixtureEl.innerHTML = '<div style="height: 110vh; width: 100%"></div>' + const scrollBar = new ScrollBarHelper() + const result = scrollBar.isOverflowing() + + expect(result).toBeFalse() + }) + }) + + describe('getWidth', () => { + it('should return an integer greater than zero, if body is overflowing', () => { + doc.style.overflowY = 'scroll' + document.body.style.overflowY = 'scroll' + fixtureEl.innerHTML = '<div style="height: 110vh; width: 100%"></div>' + const result = new ScrollBarHelper().getWidth() + + if (isScrollBarHidden()) { + expect(result).toEqual(0) + } else { + expect(result).toBeGreaterThan(1) + } + }) + + it('should return 0 if body is not overflowing', () => { + document.documentElement.style.overflowY = 'hidden' + document.body.style.overflowY = 'hidden' + fixtureEl.innerHTML = '<div style="height: 110vh; width: 100%"></div>' + + const result = new ScrollBarHelper().getWidth() + + expect(result).toEqual(0) + }) + }) + + describe('hide - reset', () => { + it('should adjust the inline padding of fixed elements which are full-width', () => { + fixtureEl.innerHTML = [ + '<div style="height: 110vh; width: 100%">', + ' <div class="fixed-top" id="fixed1" style="padding-right: 0px; width: 100vw"></div>', + ' <div class="fixed-top" id="fixed2" style="padding-right: 5px; width: 100vw"></div>', + '</div>' + ].join('') + doc.style.overflowY = 'scroll' + + const fixedEl = fixtureEl.querySelector('#fixed1') + const fixedEl2 = fixtureEl.querySelector('#fixed2') + const originalPadding = getPaddingX(fixedEl) + const originalPadding2 = getPaddingX(fixedEl2) + const scrollBar = new ScrollBarHelper() + const expectedPadding = originalPadding + scrollBar.getWidth() + const expectedPadding2 = originalPadding2 + scrollBar.getWidth() + + scrollBar.hide() + + let currentPadding = getPaddingX(fixedEl) + let currentPadding2 = getPaddingX(fixedEl2) + expect(getPaddingAttr(fixedEl)).toEqual(`${originalPadding}px`) + expect(getPaddingAttr(fixedEl2)).toEqual(`${originalPadding2}px`) + expect(currentPadding).toEqual(expectedPadding) + expect(currentPadding2).toEqual(expectedPadding2) + + scrollBar.reset() + currentPadding = getPaddingX(fixedEl) + currentPadding2 = getPaddingX(fixedEl2) + expect(getPaddingAttr(fixedEl)).toBeNull() + expect(getPaddingAttr(fixedEl2)).toBeNull() + expect(currentPadding).toEqual(originalPadding) + expect(currentPadding2).toEqual(originalPadding2) + }) + + it('should remove padding & margin if not existed before adjustment', () => { + fixtureEl.innerHTML = [ + '<div style="height: 110vh; width: 100%">', + ' <div class="fixed" id="fixed" style="width: 100vw;"></div>', + ' <div class="sticky-top" id="sticky" style=" width: 100vw;"></div>', + '</div>' + ].join('') + doc.style.overflowY = 'scroll' + + const fixedEl = fixtureEl.querySelector('#fixed') + const stickyEl = fixtureEl.querySelector('#sticky') + const scrollBar = new ScrollBarHelper() + + scrollBar.hide() + scrollBar.reset() + + expect(fixedEl.getAttribute('style').includes('padding-right')).toBeFalse() + expect(stickyEl.getAttribute('style').includes('margin-right')).toBeFalse() + }) + + it('should adjust the inline margin and padding of sticky elements', () => { + fixtureEl.innerHTML = [ + '<div style="height: 110vh">', + ' <div class="sticky-top" style="margin-right: 10px; padding-right: 20px; width: 100vw; height: 10px"></div>', + '</div>' + ].join('') + doc.style.overflowY = 'scroll' + + const stickyTopEl = fixtureEl.querySelector('.sticky-top') + const originalMargin = getMarginX(stickyTopEl) + const originalPadding = getPaddingX(stickyTopEl) + const scrollBar = new ScrollBarHelper() + const expectedMargin = originalMargin - scrollBar.getWidth() + const expectedPadding = originalPadding + scrollBar.getWidth() + scrollBar.hide() + + expect(getMarginAttr(stickyTopEl)).toEqual(`${originalMargin}px`) + expect(getMarginX(stickyTopEl)).toEqual(expectedMargin) + expect(getPaddingAttr(stickyTopEl)).toEqual(`${originalPadding}px`) + expect(getPaddingX(stickyTopEl)).toEqual(expectedPadding) + + scrollBar.reset() + expect(getMarginAttr(stickyTopEl)).toBeNull() + expect(getMarginX(stickyTopEl)).toEqual(originalMargin) + expect(getPaddingAttr(stickyTopEl)).toBeNull() + expect(getPaddingX(stickyTopEl)).toEqual(originalPadding) + }) + + it('should not adjust the inline margin and padding of sticky and fixed elements when element do not have full width', () => { + fixtureEl.innerHTML = '<div class="sticky-top" style="margin-right: 0px; padding-right: 0px; width: 50vw"></div>' + + const stickyTopEl = fixtureEl.querySelector('.sticky-top') + const originalMargin = getMarginX(stickyTopEl) + const originalPadding = getPaddingX(stickyTopEl) + + const scrollBar = new ScrollBarHelper() + scrollBar.hide() + + const currentMargin = getMarginX(stickyTopEl) + const currentPadding = getPaddingX(stickyTopEl) + + expect(currentMargin).toEqual(originalMargin) + expect(currentPadding).toEqual(originalPadding) + + scrollBar.reset() + }) + + it('should not put data-attribute if element doesn\'t have the proper style property, should just remove style property if element didn\'t had one', () => { + fixtureEl.innerHTML = [ + '<div style="height: 110vh; width: 100%">', + ' <div class="sticky-top" id="sticky" style="width: 100vw"></div>', + '</div>' + ].join('') + + document.body.style.overflowY = 'scroll' + const scrollBar = new ScrollBarHelper() + + const hasPaddingAttr = el => el.hasAttribute('data-bs-padding-right') + const hasMarginAttr = el => el.hasAttribute('data-bs-margin-right') + const stickyEl = fixtureEl.querySelector('#sticky') + const originalPadding = getPaddingX(stickyEl) + const originalMargin = getMarginX(stickyEl) + const scrollBarWidth = scrollBar.getWidth() + + scrollBar.hide() + + expect(getPaddingX(stickyEl)).toEqual(scrollBarWidth + originalPadding) + const expectedMargin = scrollBarWidth + originalMargin + expect(getMarginX(stickyEl)).toEqual(expectedMargin === 0 ? expectedMargin : -expectedMargin) + expect(hasMarginAttr(stickyEl)).toBeFalse() // We do not have to keep css margin + expect(hasPaddingAttr(stickyEl)).toBeFalse() // We do not have to keep css padding + + scrollBar.reset() + + expect(getPaddingX(stickyEl)).toEqual(originalPadding) + expect(getPaddingX(stickyEl)).toEqual(originalPadding) + }) + + describe('Body Handling', () => { + it('should ignore other inline styles when trying to restore body defaults ', () => { + document.body.style.color = 'red' + + const scrollBar = new ScrollBarHelper() + const scrollBarWidth = scrollBar.getWidth() + scrollBar.hide() + + expect(getPaddingX(document.body)).toEqual(scrollBarWidth) + expect(document.body.style.color).toEqual('red') + + scrollBar.reset() + }) + + it('should hide scrollbar and reset it to its initial value', () => { + const styleSheetPadding = '7px' + fixtureEl.innerHTML = [ + '<style>', + ' body {', + ` padding-right: ${styleSheetPadding}`, + ' }', + '</style>' + ].join('') + + const el = document.body + const inlineStylePadding = '10px' + el.style.paddingRight = inlineStylePadding + + const originalPadding = getPaddingX(el) + expect(originalPadding).toEqual(parseIntDecimal(inlineStylePadding)) // Respect only the inline style as it has prevails this of css + const originalOverFlow = 'auto' + el.style.overflow = originalOverFlow + const scrollBar = new ScrollBarHelper() + const scrollBarWidth = scrollBar.getWidth() + + scrollBar.hide() + + const currentPadding = getPaddingX(el) + + expect(currentPadding).toEqual(scrollBarWidth + originalPadding) + expect(currentPadding).toEqual(scrollBarWidth + parseIntDecimal(inlineStylePadding)) + expect(getPaddingAttr(el)).toEqual(inlineStylePadding) + expect(getOverFlow(el)).toEqual('hidden') + expect(getOverFlowAttr(el)).toEqual(originalOverFlow) + + scrollBar.reset() + + const currentPadding1 = getPaddingX(el) + expect(currentPadding1).toEqual(originalPadding) + expect(getPaddingAttr(el)).toBeNull() + expect(getOverFlow(el)).toEqual(originalOverFlow) + expect(getOverFlowAttr(el)).toBeNull() + }) + + it('should hide scrollbar and reset it to its initial value - respecting css rules', () => { + const styleSheetPadding = '7px' + fixtureEl.innerHTML = [ + '<style>', + ' body {', + ` padding-right: ${styleSheetPadding}`, + ' }', + '</style>' + ].join('') + const el = document.body + const originalPadding = getPaddingX(el) + const originalOverFlow = 'scroll' + el.style.overflow = originalOverFlow + const scrollBar = new ScrollBarHelper() + const scrollBarWidth = scrollBar.getWidth() + + scrollBar.hide() + + const currentPadding = getPaddingX(el) + + expect(currentPadding).toEqual(scrollBarWidth + originalPadding) + expect(currentPadding).toEqual(scrollBarWidth + parseIntDecimal(styleSheetPadding)) + expect(getPaddingAttr(el)).toBeNull() // We do not have to keep css padding + expect(getOverFlow(el)).toEqual('hidden') + expect(getOverFlowAttr(el)).toEqual(originalOverFlow) + + scrollBar.reset() + + const currentPadding1 = getPaddingX(el) + expect(currentPadding1).toEqual(originalPadding) + expect(getPaddingAttr(el)).toBeNull() + expect(getOverFlow(el)).toEqual(originalOverFlow) + expect(getOverFlowAttr(el)).toBeNull() + }) + + it('should not adjust the inline body padding when it does not overflow', () => { + const originalPadding = getPaddingX(document.body) + const scrollBar = new ScrollBarHelper() + + // Hide scrollbars to prevent the body overflowing + doc.style.overflowY = 'hidden' + doc.style.paddingRight = '0px' + + scrollBar.hide() + const currentPadding = getPaddingX(document.body) + + expect(currentPadding).toEqual(originalPadding) + scrollBar.reset() + }) + + it('should not adjust the inline body padding when it does not overflow, even on a scaled display', () => { + const originalPadding = getPaddingX(document.body) + const scrollBar = new ScrollBarHelper() + // Remove body margins as would be done by Bootstrap css + document.body.style.margin = '0' + + // Hide scrollbars to prevent the body overflowing + doc.style.overflowY = 'hidden' + + // Simulate a discrepancy between exact, i.e. floating point body width, and rounded body width + // as it can occur when zooming or scaling the display to something else than 100% + doc.style.paddingRight = '.48px' + scrollBar.hide() + + const currentPadding = getPaddingX(document.body) + + expect(currentPadding).toEqual(originalPadding) + + scrollBar.reset() + }) + }) + }) +}) diff --git a/js/tests/unit/util/swipe.spec.js b/js/tests/unit/util/swipe.spec.js new file mode 100644 index 0000000..f92bb5d --- /dev/null +++ b/js/tests/unit/util/swipe.spec.js @@ -0,0 +1,291 @@ +import { clearFixture, getFixture } from '../../helpers/fixture' +import EventHandler from '../../../src/dom/event-handler' +import Swipe from '../../../src/util/swipe' +import { noop } from '../../../src/util' + +describe('Swipe', () => { + const { Simulator, PointerEvent } = window + const originWinPointerEvent = PointerEvent + const supportPointerEvent = Boolean(PointerEvent) + + let fixtureEl + let swipeEl + const clearPointerEvents = () => { + window.PointerEvent = null + } + + const restorePointerEvents = () => { + window.PointerEvent = originWinPointerEvent + } + + // The headless browser does not support touch events, so we need to fake it + // in order to test that touch events are added properly + const defineDocumentElementOntouchstart = () => { + document.documentElement.ontouchstart = noop + } + + const deleteDocumentElementOntouchstart = () => { + delete document.documentElement.ontouchstart + } + + const mockSwipeGesture = (element, options = {}, type = 'touch') => { + Simulator.setType(type) + const _options = { deltaX: 0, deltaY: 0, ...options } + + Simulator.gestures.swipe(element, _options) + } + + beforeEach(() => { + fixtureEl = getFixture() + const cssStyle = [ + '<style>', + ' #fixture .pointer-event {', + ' touch-action: pan-y;', + ' }', + ' #fixture div {', + ' width: 300px;', + ' height: 300px;', + ' }', + '</style>' + ].join('') + + fixtureEl.innerHTML = `<div id="swipeEl"></div>${cssStyle}` + swipeEl = fixtureEl.querySelector('div') + }) + + afterEach(() => { + clearFixture() + deleteDocumentElementOntouchstart() + }) + + describe('constructor', () => { + it('should add touch event listeners by default', () => { + defineDocumentElementOntouchstart() + + spyOn(Swipe.prototype, '_initEvents').and.callThrough() + const swipe = new Swipe(swipeEl) + expect(swipe._initEvents).toHaveBeenCalled() + }) + + it('should not add touch event listeners if touch is not supported', () => { + spyOn(Swipe, 'isSupported').and.returnValue(false) + + spyOn(Swipe.prototype, '_initEvents').and.callThrough() + const swipe = new Swipe(swipeEl) + + expect(swipe._initEvents).not.toHaveBeenCalled() + }) + }) + + describe('Config', () => { + it('Test leftCallback', () => { + return new Promise(resolve => { + const spyRight = jasmine.createSpy('spy') + clearPointerEvents() + defineDocumentElementOntouchstart() + // eslint-disable-next-line no-new + new Swipe(swipeEl, { + leftCallback() { + expect(spyRight).not.toHaveBeenCalled() + restorePointerEvents() + resolve() + }, + rightCallback: spyRight + }) + + mockSwipeGesture(swipeEl, { + pos: [300, 10], + deltaX: -300 + }) + }) + }) + + it('Test rightCallback', () => { + return new Promise(resolve => { + const spyLeft = jasmine.createSpy('spy') + clearPointerEvents() + defineDocumentElementOntouchstart() + // eslint-disable-next-line no-new + new Swipe(swipeEl, { + rightCallback() { + expect(spyLeft).not.toHaveBeenCalled() + restorePointerEvents() + resolve() + }, + leftCallback: spyLeft + }) + + mockSwipeGesture(swipeEl, { + pos: [10, 10], + deltaX: 300 + }) + }) + }) + + it('Test endCallback', () => { + return new Promise(resolve => { + clearPointerEvents() + defineDocumentElementOntouchstart() + let isFirstTime = true + + const callback = () => { + if (isFirstTime) { + isFirstTime = false + return + } + + expect().nothing() + restorePointerEvents() + resolve() + } + + // eslint-disable-next-line no-new + new Swipe(swipeEl, { + endCallback: callback + }) + mockSwipeGesture(swipeEl, { + pos: [10, 10], + deltaX: 300 + }) + + mockSwipeGesture(swipeEl, { + pos: [300, 10], + deltaX: -300 + }) + }) + }) + }) + + describe('Functionality on PointerEvents', () => { + it('should not allow pinch with touch events', () => { + Simulator.setType('touch') + clearPointerEvents() + deleteDocumentElementOntouchstart() + + const swipe = new Swipe(swipeEl) + const spy = spyOn(swipe, '_handleSwipe') + + mockSwipeGesture(swipeEl, { + pos: [300, 10], + deltaX: -300, + deltaY: 0, + touches: 2 + }) + + restorePointerEvents() + expect(spy).not.toHaveBeenCalled() + }) + + it('should allow swipeRight and call "rightCallback" with pointer events', () => { + return new Promise(resolve => { + if (!supportPointerEvent) { + expect().nothing() + resolve() + return + } + + const style = '#fixture .pointer-event { touch-action: none !important; }' + fixtureEl.innerHTML += style + + defineDocumentElementOntouchstart() + // eslint-disable-next-line no-new + new Swipe(swipeEl, { + rightCallback() { + deleteDocumentElementOntouchstart() + expect().nothing() + resolve() + } + }) + + mockSwipeGesture(swipeEl, { deltaX: 300 }, 'pointer') + }) + }) + + it('should allow swipeLeft and call "leftCallback" with pointer events', () => { + return new Promise(resolve => { + if (!supportPointerEvent) { + expect().nothing() + resolve() + return + } + + const style = '#fixture .pointer-event { touch-action: none !important; }' + fixtureEl.innerHTML += style + + defineDocumentElementOntouchstart() + // eslint-disable-next-line no-new + new Swipe(swipeEl, { + leftCallback() { + expect().nothing() + deleteDocumentElementOntouchstart() + resolve() + } + }) + + mockSwipeGesture(swipeEl, { + pos: [300, 10], + deltaX: -300 + }, 'pointer') + }) + }) + }) + + describe('Dispose', () => { + it('should call EventHandler.off', () => { + defineDocumentElementOntouchstart() + spyOn(EventHandler, 'off').and.callThrough() + const swipe = new Swipe(swipeEl) + + swipe.dispose() + expect(EventHandler.off).toHaveBeenCalledWith(swipeEl, '.bs.swipe') + }) + + it('should destroy', () => { + const addEventSpy = spyOn(fixtureEl, 'addEventListener').and.callThrough() + const removeEventSpy = spyOn(EventHandler, 'off').and.callThrough() + defineDocumentElementOntouchstart() + + const swipe = new Swipe(fixtureEl) + + const expectedArgs = + swipe._supportPointerEvents ? + [ + ['pointerdown', jasmine.any(Function), jasmine.any(Boolean)], + ['pointerup', jasmine.any(Function), jasmine.any(Boolean)] + ] : + [ + ['touchstart', jasmine.any(Function), jasmine.any(Boolean)], + ['touchmove', jasmine.any(Function), jasmine.any(Boolean)], + ['touchend', jasmine.any(Function), jasmine.any(Boolean)] + ] + + expect(addEventSpy.calls.allArgs()).toEqual(expectedArgs) + + swipe.dispose() + + expect(removeEventSpy).toHaveBeenCalledWith(fixtureEl, '.bs.swipe') + deleteDocumentElementOntouchstart() + }) + }) + + describe('"isSupported" static', () => { + it('should return "true" if "touchstart" exists in document element)', () => { + Object.defineProperty(window.navigator, 'maxTouchPoints', () => 0) + defineDocumentElementOntouchstart() + + expect(Swipe.isSupported()).toBeTrue() + }) + + it('should return "false" if "touchstart" not exists in document element and "navigator.maxTouchPoints" are zero (0)', () => { + Object.defineProperty(window.navigator, 'maxTouchPoints', () => 0) + deleteDocumentElementOntouchstart() + + if ('ontouchstart' in document.documentElement) { + expect().nothing() + return + } + + expect(Swipe.isSupported()).toBeFalse() + }) + }) +}) diff --git a/js/tests/unit/util/template-factory.spec.js b/js/tests/unit/util/template-factory.spec.js new file mode 100644 index 0000000..5e5724c --- /dev/null +++ b/js/tests/unit/util/template-factory.spec.js @@ -0,0 +1,306 @@ +import { clearFixture, getFixture } from '../../helpers/fixture' +import TemplateFactory from '../../../src/util/template-factory' + +describe('TemplateFactory', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + describe('NAME', () => { + it('should return plugin NAME', () => { + expect(TemplateFactory.NAME).toEqual('TemplateFactory') + }) + }) + + describe('Default', () => { + it('should return plugin default config', () => { + expect(TemplateFactory.Default).toEqual(jasmine.any(Object)) + }) + }) + + describe('toHtml', () => { + describe('Sanitization', () => { + it('should use "sanitizeHtml" to sanitize template', () => { + const factory = new TemplateFactory({ + sanitize: true, + template: '<div><a href="javascript:alert(7)">Click me</a></div>' + }) + const spy = spyOn(factory, '_maybeSanitize').and.callThrough() + + expect(factory.toHtml().innerHTML).not.toContain('href="javascript:alert(7)') + expect(spy).toHaveBeenCalled() + }) + + it('should not sanitize template', () => { + const factory = new TemplateFactory({ + sanitize: false, + template: '<div><a href="javascript:alert(7)">Click me</a></div>' + }) + const spy = spyOn(factory, '_maybeSanitize').and.callThrough() + + expect(factory.toHtml().innerHTML).toContain('href="javascript:alert(7)') + expect(spy).toHaveBeenCalled() + }) + + it('should use "sanitizeHtml" to sanitize content', () => { + const factory = new TemplateFactory({ + sanitize: true, + html: true, + template: '<div id="foo"></div>', + content: { '#foo': '<a href="javascript:alert(7)">Click me</a>' } + }) + expect(factory.toHtml().innerHTML).not.toContain('href="javascript:alert(7)') + }) + + it('should not sanitize content', () => { + const factory = new TemplateFactory({ + sanitize: false, + html: true, + template: '<div id="foo"></div>', + content: { '#foo': '<a href="javascript:alert(7)">Click me</a>' } + }) + expect(factory.toHtml().innerHTML).toContain('href="javascript:alert(7)') + }) + + it('should sanitize content only if "config.html" is enabled', () => { + const factory = new TemplateFactory({ + sanitize: true, + html: false, + template: '<div id="foo"></div>', + content: { '#foo': '<a href="javascript:alert(7)">Click me</a>' } + }) + const spy = spyOn(factory, '_maybeSanitize').and.callThrough() + + expect(spy).not.toHaveBeenCalled() + }) + }) + + describe('Extra Class', () => { + it('should add extra class', () => { + const factory = new TemplateFactory({ + extraClass: 'testClass' + }) + expect(factory.toHtml()).toHaveClass('testClass') + }) + + it('should add extra classes', () => { + const factory = new TemplateFactory({ + extraClass: 'testClass testClass2' + }) + expect(factory.toHtml()).toHaveClass('testClass') + expect(factory.toHtml()).toHaveClass('testClass2') + }) + + it('should resolve class if function is given', () => { + const factory = new TemplateFactory({ + extraClass(arg) { + expect(arg).toEqual(factory) + return 'testClass' + } + }) + + expect(factory.toHtml()).toHaveClass('testClass') + }) + }) + }) + + describe('Content', () => { + it('add simple text content', () => { + const template = [ + '<div>', + ' <div class="foo"></div>', + ' <div class="foo2"></div>', + '</div>' + ].join('') + + const factory = new TemplateFactory({ + template, + content: { + '.foo': 'bar', + '.foo2': 'bar2' + } + }) + + const html = factory.toHtml() + expect(html.querySelector('.foo').textContent).toEqual('bar') + expect(html.querySelector('.foo2').textContent).toEqual('bar2') + }) + + it('should not fill template if selector not exists', () => { + const factory = new TemplateFactory({ + sanitize: true, + html: true, + template: '<div id="foo"></div>', + content: { '#bar': 'test' } + }) + + expect(factory.toHtml().outerHTML).toEqual('<div id="foo"></div>') + }) + + it('should remove template selector, if content is null', () => { + const factory = new TemplateFactory({ + sanitize: true, + html: true, + template: '<div><div id="foo"></div></div>', + content: { '#foo': null } + }) + + expect(factory.toHtml().outerHTML).toEqual('<div></div>') + }) + + it('should resolve content if is function', () => { + const factory = new TemplateFactory({ + sanitize: true, + html: true, + template: '<div><div id="foo"></div></div>', + content: { '#foo': () => null } + }) + + expect(factory.toHtml().outerHTML).toEqual('<div></div>') + }) + + it('if content is element and "config.html=false", should put content\'s textContent', () => { + fixtureEl.innerHTML = '<div>foo<span>bar</span></div>' + const contentElement = fixtureEl.querySelector('div') + + const factory = new TemplateFactory({ + html: false, + template: '<div><div id="foo"></div></div>', + content: { '#foo': contentElement } + }) + + const fooEl = factory.toHtml().querySelector('#foo') + expect(fooEl.innerHTML).not.toEqual(contentElement.innerHTML) + expect(fooEl.textContent).toEqual(contentElement.textContent) + expect(fooEl.textContent).toEqual('foobar') + }) + + it('if content is element and "config.html=true", should put content\'s outerHtml as child', () => { + fixtureEl.innerHTML = '<div>foo<span>bar</span></div>' + const contentElement = fixtureEl.querySelector('div') + + const factory = new TemplateFactory({ + html: true, + template: '<div><div id="foo"></div></div>', + content: { '#foo': contentElement } + }) + + const fooEl = factory.toHtml().querySelector('#foo') + expect(fooEl.innerHTML).toEqual(contentElement.outerHTML) + expect(fooEl.textContent).toEqual(contentElement.textContent) + }) + }) + + describe('getContent', () => { + it('should get content as array', () => { + const factory = new TemplateFactory({ + content: { + '.foo': 'bar', + '.foo2': 'bar2' + } + }) + expect(factory.getContent()).toEqual(['bar', 'bar2']) + }) + + it('should filter empties', () => { + const factory = new TemplateFactory({ + content: { + '.foo': 'bar', + '.foo2': '', + '.foo3': null, + '.foo4': () => 2, + '.foo5': () => null + } + }) + expect(factory.getContent()).toEqual(['bar', 2]) + }) + }) + + describe('hasContent', () => { + it('should return true, if it has', () => { + const factory = new TemplateFactory({ + content: { + '.foo': 'bar', + '.foo2': 'bar2', + '.foo3': '' + } + }) + expect(factory.hasContent()).toBeTrue() + }) + + it('should return false, if filtered content is empty', () => { + const factory = new TemplateFactory({ + content: { + '.foo2': '', + '.foo3': null, + '.foo4': () => null + } + }) + expect(factory.hasContent()).toBeFalse() + }) + }) + + describe('changeContent', () => { + it('should change Content', () => { + const template = [ + '<div>', + ' <div class="foo"></div>', + ' <div class="foo2"></div>', + '</div>' + ].join('') + + const factory = new TemplateFactory({ + template, + content: { + '.foo': 'bar', + '.foo2': 'bar2' + } + }) + + const html = selector => factory.toHtml().querySelector(selector).textContent + expect(html('.foo')).toEqual('bar') + expect(html('.foo2')).toEqual('bar2') + factory.changeContent({ + '.foo': 'test', + '.foo2': 'test2' + }) + + expect(html('.foo')).toEqual('test') + expect(html('.foo2')).toEqual('test2') + }) + + it('should change only the given, content', () => { + const template = [ + '<div>', + ' <div class="foo"></div>', + ' <div class="foo2"></div>', + '</div>' + ].join('') + + const factory = new TemplateFactory({ + template, + content: { + '.foo': 'bar', + '.foo2': 'bar2' + } + }) + + const html = selector => factory.toHtml().querySelector(selector).textContent + expect(html('.foo')).toEqual('bar') + expect(html('.foo2')).toEqual('bar2') + factory.changeContent({ + '.foo': 'test', + '.wrong': 'wrong' + }) + + expect(html('.foo')).toEqual('test') + expect(html('.foo2')).toEqual('bar2') + }) + }) +}) |