Files
notifications-admin/tests/javascripts/support/helpers.js
Tom Byers 9ef093cfda Add ScreenMock to helpers
Mocks DOM API calls for position and dimension of
elements and provides an API to allow access to
them.
2019-08-22 15:16:42 +01:00

489 lines
12 KiB
JavaScript

function getDescriptorForProperty (prop, obj) {
const descriptors = Object.getOwnPropertyDescriptors(obj);
const prototype = Object.getPrototypeOf(obj);
if ((descriptors !== {}) && (prop in descriptors)) {
return descriptors[prop];
}
// if not in this object's descriptors, check the prototype chain
if (prototype !== null) {
return getDescriptorForProperty(prop, prototype);
}
// no descriptor for this prop and no prototypes left in the chain
return null;
};
const triggerEvent = (el, evtType, options) => {
const eventInit = {
bubbles: true,
cancelable: true
};
let setPositionData = () => {
const browserUI = {
leftFrameBorder: 0,
topHeight: 100
};
const cursorOffset = {
x: 5,
y: 5
};
const elBoundingBox = el.getBoundingClientRect();
if (!eventInit.clientX) { eventInit.clientX = elBoundingBox.left + cursorOffset.x; }
if (!eventInit.clientY) { eventInit.clientY = elBoundingBox.top + cursorOffset.y; }
if (!eventInit.pageX) { eventInit.pageX = elBoundingBox.left + cursorOffset.x; }
if (!eventInit.pageY) { eventInit.pageY = elBoundingBox.top + cursorOffset.y; }
if (!eventInit.screenX) { eventInit.screenX = eventInit.clientX + browserUI.leftFrameBorder; }
if (!eventInit.screenY) { eventInit.screenY = eventInit.clientY + browserUI.topHeight; }
if (!eventInit.offsetX) { eventInit.offsetX = cursorOffset.x; }
if (!eventInit.offsetY) { eventInit.offsetY = cursorOffset.y; }
};
let Instance;
// mixin any specified event properties with the defaults
if (options && ('eventInit' in options)) {
Object.assign(eventInit, options.eventInit);
}
// use event interface if specified
if (options && ('interface' in options)) {
Instance = options.interface;
} else {
// otherwise, derive from the event type
switch (evtType) {
case 'click':
// click events are part of the MouseEvent interface
Instance = window.MouseEvent;
break;
case 'mousedown':
Instance = window.MouseEvent;
break;
case 'mouseup':
Instance = window.MouseEvent;
break;
case 'keydown':
Instance = window.KeyboardEvent;
break;
case 'keyup':
Instance = window.KeyboardEvent;
break;
default:
Instance = Event;
}
}
if (evtType === 'click') {
// hack for click events to simulate details of pointer interaction
setPositionData();
}
const evt = new Instance(evtType, eventInit);
el.dispatchEvent(evt);
};
function clickElementWithMouse (el) {
triggerEvent(el, 'mousedown');
triggerEvent(el, 'mouseup');
triggerEvent(el, 'click');
};
function moveSelectionToRadio (el, options) {
// movement within a radio group with arrow keys fires no keyboard events
// click event fired from option radio being activated
triggerEvent(el, 'click', {
eventInit: { pageX: 0 }
});
};
function activateRadioWithSpace (el) {
// simulate events for space key press to confirm selection
// event for space key press
triggerEvent(el, 'keydown', {
eventInit: { which: 32 }
});
// click event fired from option radio being activated
triggerEvent(el, 'click', {
eventInit: { pageX: 0 }
});
};
class ElementQuery {
constructor (el) {
this.el = el;
}
get nodeName () {
return this.el.nodeName.toLowerCase();
}
get firstTextNodeValue () {
const textNodes = Array.from(this.el.childNodes).filter(el => el.nodeType === 3);
return textNodes.length ? textNodes[0].nodeValue : undefined;
};
// returns the elements attributes as an object
hasAttributesSetTo (mappings) {
if (!this.el.hasAttributes()) { return false; }
const keys = Object.keys(mappings);
let matches = 0;
keys.forEach(key => {
if (this.el.hasAttribute(key) && (this.el.attributes[key].value === mappings[key])) {
matches++;
}
});
return matches === keys.length;
}
hasClass (classToken) {
return Array.from(this.el.classList).includes(classToken);
}
is (state) {
const test = `_is${state.charAt(0).toUpperCase()}${state.slice(1)}`;
if (ElementQuery.prototype.hasOwnProperty(test)) {
return this[test]();
}
}
// looks for a sibling before the el that matches the supplied test function
// the test function gets sent each sibling, wrapped in an Element instance
getPreviousSibling (test) {
let node = this.el.previousElementSibling;
let el;
while(node) {
el = element(node);
if (test(el)) {
return node;
}
node = node.previousElementSibling;
}
return null;
}
_isHidden () {
const display = window.getComputedStyle(this.el).getPropertyValue('display');
return display === 'none';
}
};
class WindowMock {
constructor (jest) {
this._defaults = {
height: window.innerHeight,
width: window.innerWidth
};
this.spies = {
document: {}
};
this._jest = jest;
this._setSpies();
}
get top () {
return window.scrollY;
}
get bottom () {
return window.scrollY + window.innerHeight;
}
get height () {
return window.innerHeight;
}
get width () {
return window.innerWidth
}
get scrollPosition () {
return window.scrollY;
}
_setSpies () {
// remove calls to document.documentElement.clientHeight when jQuery is gone. It's called to support older browsers like IE8
this.spies.document.clientHeight = this._jest.spyOn(document.documentElement, 'clientHeight', 'get').mockImplementation(() => window.innerHeight);
// remove calls to document.documentElement.clientWidth when jQuery is gone. It's called to support older browsers like IE8
this.spies.document.clientWidth = this._jest.spyOn(document.documentElement, 'clientWidth', 'get').mockImplementation(() => window.innerWidth);
}
setHeightTo (height) {
window.innerHeight = height;
}
setWidthTo (width) {
window.innerWidth = width;
}
resizeTo (dimensions) {
this.setHeightTo(dimensions.height);
this.setWidthTo(dimensions.width);
triggerEvent(window, 'resize');
}
scrollTo (scrollPosition) {
document.documentElement.scrollTop = scrollPosition;
window.scrollY = scrollPosition;
window.pageYOffset = scrollPosition;
triggerEvent(window, 'scroll');
}
reset () {
window.innerHeight = this._defaults.height;
window.innerWidth = this._defaults.width;
this.scrollTo(0);
// reset all spies
Object.keys(this.spies).forEach(key => {
const objectSpies = this.spies[key];
Object.keys(objectSpies).forEach(key => objectSpies[key].mockClear());
});
}
}
// Base class for mocking an DOM APi interfaces not in JSDOM
class DOMInterfaceMock {
constructor (jest, spec) {
// set up methods so their calls can be tracked
// leave implementation/return values to the test
spec.methods.forEach(method => this[method] = jest.fn(() => {}));
// set up props
// any spies should be relative to the test so not set here
spec.props.forEach(prop => {
Object.defineProperty(this, prop, {
get: () => this[prop],
set: value => this[prop] = value
});
});
}
}
// Very basic class for stubbing the Range interface
// Only contains methods required for current tests
class RangeMock extends DOMInterfaceMock {
constructor (jest) {
super(jest, { props: [], methods: ['selectNodeContents'] });
}
}
// Very basic class for stubbing the Selection interface
// Only contains methods required for current tests
class SelectionMock extends DOMInterfaceMock {
constructor (jest) {
super(jest, { props: [], methods: ['removeAllRanges', 'addRange'] });
}
}
class ScreenRenderItem {
constructor (jest, node) {
this._jest = jest;
this._node = node;
this._storeProps();
this._mockAPICalls();
}
setData (itemData) {
// check all the item data is present
const itemProps = Object.keys(itemData);
const missingKeys = ScreenRenderItem.REQUIRED_PROPS.filter(prop => !itemProps.includes(prop));
this._data = {};
if (missingKeys.length) {
throw Error(`${itemData.name ? itemData.name : itemProps.join(', ')} is missing these properties: ${missingKeys.join(', ')}`);
}
// default left if not set
if (!('offsetLeft' in itemData)) { itemData.offsetLeft = 0; }
// copy onto internal store
Object.assign(this._data, itemData);
}
_getBoundingClientRect () {
const {offsetHeight, offsetWidth, offsetTop, offsetLeft} = this._data;
const x = offsetLeft - window.scrollX;
const y = offsetTop - window.scrollY;
return {
'x': x,
'y': y,
'top': (offsetHeight < 0) ? y + offsetHeight : y,
'left': (offsetWidth < 0) ? x + offsetWidth : x,
'bottom': (offsetTop + offsetHeight) - window.scrollY,
'right': (offsetLeft + offsetWidth) - window.scrollX,
};
}
reset () {
// reset DOMRect mock
this._node.getBoundingClientRect.mockClear();
ScreenRenderItem.OFFSET_PROPS.forEach(prop => {
if (prop in this._propStore) {
// replace property implementation
Object.defineProperty(this._node, prop, this._propStore[prop]);
}
});
}
_storeProps () {
this._propStore = {};
ScreenRenderItem.OFFSET_PROPS.forEach(prop => {
const descriptor = getDescriptorForProperty(prop, this._node);
if (descriptor !== null) {
this._propStore[prop] = descriptor;
}
});
}
// mock any calls to the node's DOM API for position/dimension
_mockAPICalls () {
// proxy boundingClientRect property calls to item data
this._jest.spyOn(this._node, 'getBoundingClientRect').mockImplementation(() => this._getBoundingClientRect());
// handle calls to offset properties
ScreenRenderItem.OFFSET_PROPS.forEach(prop => {
this._jest.spyOn(this._node, prop, 'get').mockImplementation(() => this._data[prop]);
// proxy DOM API sets for offsetValues (not possible to mock directly)
Object.defineProperty(this._node, prop, {
configurable: true,
set: jest.fn(value => {
this._data[prop] = value;
return true;
})
});
});
}
}
ScreenRenderItem.OFFSET_PROPS = ['offsetHeight', 'offsetWidth', 'offsetTop', 'offsetLeft'];
ScreenRenderItem.REQUIRED_PROPS = ['name', 'offsetHeight', 'offsetHeight', 'offsetWidth', 'offsetTop'];
class ScreenMock {
constructor (jest) {
this._jest = jest
this._items = {};
}
mockPositionAndDimension (itemName, node, itemData) {
if (itemName in this._items) { throw new Error(`${itemName} already has its position and dimension mocked`); }
const data = Object.assign({ 'name': itemName }, itemData);
const item = new ScreenRenderItem(this._jest, node);
item.setData(data);
this._items[itemName] = item;
}
setWindow (windowData) {
this.window = new WindowMock(this._jest);
// check all the window data is present
const missingKeys = Object.keys(windowData).filter(key => !ScreenMock.REQUIRED_WINDOW_PROPS.includes(key));
if (missingKeys.length) {
throw Error(`Window definition is missing these properties: ${missingKeys.join(', ')}`);
}
this.window.setHeightTo(windowData.height);
this.window.setWidthTo(windowData.width);
this.window.scrollTo(windowData.scrollTop);
}
scrollTo (scrollTop) {
this.window.scrollTo(scrollTop);
}
reset () {
Object.keys(this._items).forEach(itemName => this._items[itemName].reset());
}
}
ScreenMock.REQUIRED_WINDOW_PROPS = ['height', 'width', 'scrollTop'];
// function to ask certain questions of a DOM Element
const element = function (el) {
return new ElementQuery(el);
};
exports.triggerEvent = triggerEvent;
exports.clickElementWithMouse = clickElementWithMouse;
exports.moveSelectionToRadio = moveSelectionToRadio;
exports.activateRadioWithSpace = activateRadioWithSpace;
exports.RangeMock = RangeMock;
exports.SelectionMock = SelectionMock;
exports.element = element;
exports.WindowMock = WindowMock;
exports.ScreenMock = ScreenMock;