mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-02-06 11:23:48 -05:00
Mocks DOM API calls for position and dimension of elements and provides an API to allow access to them.
489 lines
12 KiB
JavaScript
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;
|