mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-08-23 15:57:23 -04:00
Merge pull request #3103 from alphagov/split-up-js-test-helpers
Split JS test helpers into separate files
This commit is contained in:
@@ -1,548 +1,17 @@
|
|||||||
function getDescriptorForProperty (prop, obj) {
|
const events = require('./helpers/events.js');
|
||||||
const descriptors = Object.getOwnPropertyDescriptors(obj);
|
const domInterfaces = require('./helpers/dom_interfaces.js');
|
||||||
const prototype = Object.getPrototypeOf(obj);
|
const html = require('./helpers/html.js');
|
||||||
|
const elements = require('./helpers/elements.js');
|
||||||
if ((descriptors !== {}) && (prop in descriptors)) {
|
const rendering = require('./helpers/rendering.js');
|
||||||
return descriptors[prop];
|
|
||||||
}
|
exports.triggerEvent = events.triggerEvent;
|
||||||
|
exports.clickElementWithMouse = events.clickElementWithMouse;
|
||||||
// if not in this object's descriptors, check the prototype chain
|
exports.moveSelectionToRadio = events.moveSelectionToRadio;
|
||||||
if (prototype !== null) {
|
exports.activateRadioWithSpace = events.activateRadioWithSpace;
|
||||||
return getDescriptorForProperty(prop, prototype);
|
exports.RangeMock = domInterfaces.RangeMock;
|
||||||
}
|
exports.SelectionMock = domInterfaces.SelectionMock;
|
||||||
|
exports.getRadioGroup = html.getRadioGroup;
|
||||||
// no descriptor for this prop and no prototypes left in the chain
|
exports.getRadios = html.getRadios;
|
||||||
return null;
|
exports.element = elements.element;
|
||||||
};
|
exports.WindowMock = rendering.WindowMock;
|
||||||
|
exports.ScreenMock = rendering.ScreenMock;
|
||||||
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 getRadios (fields) {
|
|
||||||
const result = '';
|
|
||||||
|
|
||||||
return fields.map((field, idx) => {
|
|
||||||
const count = idx + 1;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="multiple-choice">
|
|
||||||
<input id="choose-${field.name}-1" name="choose-${field.name}-1" type="radio" value="${field.value}" ${field.checked ? 'checked' : ''}>
|
|
||||||
<label class="block-label" for="choose-${field.name}-1">
|
|
||||||
${field.label}
|
|
||||||
</label>
|
|
||||||
</div>`;
|
|
||||||
}).join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
function getRadioGroup (data) {
|
|
||||||
let radioGroup = document.createElement('div');
|
|
||||||
|
|
||||||
data.cssClasses.forEach(cssClass => radioGroup.classList.add(cssClass));
|
|
||||||
radioGroup.innerHTML = `
|
|
||||||
<div class="form-group ">
|
|
||||||
<fieldset id="choose-${data.name}">
|
|
||||||
<legend class="form-label">
|
|
||||||
Choose ${data.label}
|
|
||||||
</legend>
|
|
||||||
${getRadios(data.fields)}
|
|
||||||
</fieldset>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
return radioGroup;
|
|
||||||
};
|
|
||||||
|
|
||||||
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: {},
|
|
||||||
window: {}
|
|
||||||
};
|
|
||||||
this._jest = jest;
|
|
||||||
this._setSpies();
|
|
||||||
this._plugJSDOM();
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
_plugJSDOM () {
|
|
||||||
|
|
||||||
const self = this;
|
|
||||||
|
|
||||||
// JSDOM doesn't support .scrollTo
|
|
||||||
this.spies.window.scrollTo = this._jest.fn(function () {
|
|
||||||
let y;
|
|
||||||
|
|
||||||
// data sent as props in an object
|
|
||||||
if (arguments.length === 1) {
|
|
||||||
y = arguments[0].y;
|
|
||||||
} else {
|
|
||||||
y = arguments[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
self.scrollTo(y);
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
window.scrollTo = this.spies.window.scrollTo;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
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.getRadioGroup = getRadioGroup;
|
|
||||||
exports.getRadios = getRadios;
|
|
||||||
exports.element = element;
|
|
||||||
exports.WindowMock = WindowMock;
|
|
||||||
exports.ScreenMock = ScreenMock;
|
|
||||||
|
|||||||
49
tests/javascripts/support/helpers/dom_interfaces.js
Normal file
49
tests/javascripts/support/helpers/dom_interfaces.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// helpers for mocking DOM interfaces
|
||||||
|
// see https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model#DOM_interfaces
|
||||||
|
|
||||||
|
// 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'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.RangeMock = RangeMock;
|
||||||
|
exports.SelectionMock = SelectionMock;
|
||||||
76
tests/javascripts/support/helpers/elements.js
Normal file
76
tests/javascripts/support/helpers/elements.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// helpers for getting information about DOM nodes
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// function to ask certain questions of a DOM Element
|
||||||
|
function element (el) {
|
||||||
|
return new ElementQuery(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.element = element;
|
||||||
111
tests/javascripts/support/helpers/events.js
Normal file
111
tests/javascripts/support/helpers/events.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// helper to simulate events fired by interactions
|
||||||
|
// adds positional data for 'click' events to match behaviour of browsers
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// helpers for simulating events fired by certain interactions
|
||||||
|
// simulates those fired in Chrome, other browsers vary the events fired
|
||||||
|
|
||||||
|
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 }
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.triggerEvent = triggerEvent;
|
||||||
|
exports.clickElementWithMouse = clickElementWithMouse;
|
||||||
|
exports.moveSelectionToRadio = moveSelectionToRadio;
|
||||||
|
exports.activateRadioWithSpace = activateRadioWithSpace;
|
||||||
37
tests/javascripts/support/helpers/html.js
Normal file
37
tests/javascripts/support/helpers/html.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// helpers for generating patterns of HTML
|
||||||
|
|
||||||
|
function getRadios (fields) {
|
||||||
|
const result = '';
|
||||||
|
|
||||||
|
return fields.map((field, idx) => {
|
||||||
|
const count = idx + 1;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="multiple-choice">
|
||||||
|
<input id="choose-${field.name}-1" name="choose-${field.name}-1" type="radio" value="${field.value}" ${field.checked ? 'checked' : ''}>
|
||||||
|
<label class="block-label" for="choose-${field.name}-1">
|
||||||
|
${field.label}
|
||||||
|
</label>
|
||||||
|
</div>`;
|
||||||
|
}).join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRadioGroup (data) {
|
||||||
|
let radioGroup = document.createElement('div');
|
||||||
|
|
||||||
|
data.cssClasses.forEach(cssClass => radioGroup.classList.add(cssClass));
|
||||||
|
radioGroup.innerHTML = `
|
||||||
|
<div class="form-group ">
|
||||||
|
<fieldset id="choose-${data.name}">
|
||||||
|
<legend class="form-label">
|
||||||
|
Choose ${data.label}
|
||||||
|
</legend>
|
||||||
|
${getRadios(data.fields)}
|
||||||
|
</fieldset>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return radioGroup;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getRadios = getRadios;
|
||||||
|
exports.getRadioGroup = getRadioGroup;
|
||||||
296
tests/javascripts/support/helpers/rendering.js
Normal file
296
tests/javascripts/support/helpers/rendering.js
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
// helpers for mocking the getting and setting of: position, dimension and scroll position for:
|
||||||
|
// - elements on the page
|
||||||
|
// - the page
|
||||||
|
// - the window
|
||||||
|
|
||||||
|
const triggerEvent = require('./events.js').triggerEvent;
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
class WindowMock {
|
||||||
|
constructor (jest) {
|
||||||
|
this._defaults = {
|
||||||
|
height: window.innerHeight,
|
||||||
|
width: window.innerWidth
|
||||||
|
};
|
||||||
|
this.spies = {
|
||||||
|
document: {},
|
||||||
|
window: {}
|
||||||
|
};
|
||||||
|
this._jest = jest;
|
||||||
|
this._setSpies();
|
||||||
|
this._plugJSDOM();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
_plugJSDOM () {
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
// JSDOM doesn't support .scrollTo
|
||||||
|
this.spies.window.scrollTo = this._jest.fn(function () {
|
||||||
|
let y;
|
||||||
|
|
||||||
|
// data sent as props in an object
|
||||||
|
if (arguments.length === 1) {
|
||||||
|
y = arguments[0].y;
|
||||||
|
} else {
|
||||||
|
y = arguments[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
self.scrollTo(y);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
window.scrollTo = this.spies.window.scrollTo;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'];
|
||||||
|
|
||||||
|
exports.WindowMock = WindowMock;
|
||||||
|
exports.ScreenMock = ScreenMock;
|
||||||
Reference in New Issue
Block a user