rquery
Version:
jQuery-like functionality for React to facilitate testing.
831 lines (670 loc) • 23.1 kB
JavaScript
(function (rquery) {
// Module systems magic dance.
if (typeof require === "function" && typeof exports === "object" && typeof module === "object") {
// NodeJS
module.exports = rquery;
} else if (typeof define === "function" && define.amd) {
// AMD
define(['lodash', 'react', 'react-dom', 'react-addons-test-utils'], function (_, React, ReactDOM, TestUtils) {
return rquery(_, React, ReactDOM, TestUtils);
});
} else {
// Other environment (usually <script> tag): assume React is already loaded.
window.$R = rquery(window._, React, ReactDOM, React.addons.TestUtils);
}
}(function (_, React, ReactDOM, TestUtils) {
'use strict';
var showCompositeWarning = true;
function isShallow (component) {
return '$$typeof' in component;
}
function isArray (arr) {
return Object.prototype.toString.call(arr) === '[object Array]';
}
function descendantsFromNode (node) {
return _.chain(node.childNodes).filter(function (node) {
return node.nodeType === Node.ELEMENT_NODE;
}).map(function (node) {
var descendants = descendantsFromNode(node);
descendants.unshift(node);
return descendants;
}).flatten().value();
}
function isDOMComponent (component) {
return TestUtils.isDOMComponent(component);
}
function rquery_findAllInRenderedTree (component, predicate) {
var components;
if (isDOMComponent(component)) {
components = descendantsFromNode(component);
return _.filter(components, predicate);
} else {
return TestUtils.findAllInRenderedTree(component, predicate);
}
}
function findDescendantsInContext (context, onlyChildren) {
return getDescendants(context._origRootComponent, context.currentScope, onlyChildren);
}
function rquery_getDOMNode (component) {
if (isDOMComponent(component)) {
return component;
}
return ReactDOM.findDOMNode(component);
}
function getComponentProp (component, prop, shallow) {
if (shallow) {
if (prop === 'class') {
prop = 'className';
}
return component && component.props && component.props[prop];
}
if (isDOMComponent(component)) {
switch (prop) {
case 'checked':
case 'className':
return component[prop];
default:
return component.getAttribute(prop);
}
} else {
return component.props[prop];
}
}
function componentHasProp (component, prop) {
if (isDOMComponent(component)) {
switch (prop) {
case 'checked':
case 'className':
return prop in component;
default:
return component.hasAttribute(prop);
}
} else {
return component.props && prop in component.props;
}
}
function getShallowChildren (node, recursive) {
var children = [];
if (node && node.props && node.props.children) {
children = [].concat(node.props.children);
}
if (recursive) {
return _.chain(children)
.map(function (child) {
return getShallowChildren(child, true);
})
.flatten()
.concat(children)
.value();
}
return children;
}
function getShallowDescendants (components, onlyChildren, includeSelf, childTypes) {
childTypes = childTypes || 'object';
return _.chain(components)
.map(function (component) {
return getShallowChildren(component, !onlyChildren)
})
.flatten()
.concat(includeSelf ? components : [])
.uniq()
.filter(function (child) {
return typeof child === childTypes;
})
.value();
}
function injectCompositeComponents (root, nodes) {
rquery_findAllInRenderedTree(root, function (component) {
var node, index;
if (TestUtils.isCompositeComponent(component)) {
node = rquery_getDOMNode(component);
index = nodes.indexOf(node);
if (index !== -1) {
nodes.splice(index, 0, component);
}
}
});
}
/**
* Reduces the list of descendants to only DOM nodes. Then, it will splice in
* the React components for any composite components.
*/
function getComponentDescendants (root, component, onlyChildren, includeSelf) {
var node = rquery_getDOMNode(component),
descendants = [];
if (onlyChildren) {
descendants = node.children;
} else {
descendants = node.getElementsByTagName('*');
}
// convert to array
descendants = _.toArray(descendants);
if (includeSelf) {
descendants.unshift(rquery_getDOMNode(component));
}
injectCompositeComponents(root, descendants);
return descendants;
}
function getDescendants (root, components, onlyChildren, includeSelf) {
if (isShallow(root)) {
return getShallowDescendants(components, onlyChildren, includeSelf);
}
var descendants = _.map(components, function (component) {
return getComponentDescendants(root, component, onlyChildren, includeSelf);
});
return [].concat.apply([], descendants);
}
function includeCompositeComponents (component) {
if (component && component._renderedComponent) {
return [component, component._renderedComponent];
}
return [component];
}
var STEP_DEFINITIONS = [
// group modifiers
{
matcher: /^:not\(/,
pushStack: true,
runStep: function (context, match, steps) {
var notContext = new Context([], context._origRootComponent);
notContext.defaultScope = context.currentScope.slice();
notContext.resetScope();
runSteps(steps, notContext);
context.setScope(_.without.apply(_, [context.currentScope].concat(notContext.results)));
}
},
{
matcher: /^\)/,
popStack: true
},
// scope modifiers
{
matcher: /^\s*>\s*/,
runStep: function (context, match) {
var newScope = findDescendantsInContext(context, true);
context.setScope(newScope);
}
},
{
matcher: /^,\s*/,
runStep: function (context, match) {
context.saveResults();
context.resetScope();
}
},
{
matcher: /^\s+/,
runStep: function (context, match) {
var newScope = findDescendantsInContext(context);
context.setScope(newScope);
}
},
// selectors
{
matcher: /^([A-Z]\w*)/,
runStep: function (context, match) {
context.filterScope(function (component) {
if (TestUtils.isCompositeComponent(component)) {
if (component._reactInternalInstance
&& component._reactInternalInstance._currentElement
&& component._reactInternalInstance._currentElement.type) {
var type = component._reactInternalInstance._currentElement.type;
var displayName = (type.displayName || '').replace(/Connect\(([A-Z]\w*)\)/, '$1');
return (displayName === match[1] || type.name === match[1]);
}
}
return false;
});
},
runShallowStep: function (context, match) {
context.filterScope(function (component) {
if (typeof component.type === 'function') {
var displayName = (component.type.displayName || '').replace(/Connect\(([A-Z]\w*)\)/, '$1');
return (displayName === match[1] || component.type.name === match[1]);
}
return false;
});
}
},
{
matcher: /^([a-z]\w*)/,
runStep: function (context, match) {
context.filterScope(function (component) {
// composite components must be found by their displayName, not its
// root DOM node,
if (TestUtils.isCompositeComponent(component)) {
return false;
}
if (!component.tagName) {
console.log(component);
}
return component.tagName.toUpperCase() === match[1].toUpperCase();
});
},
runShallowStep: function (context, match) {
context.filterScope(function (component) {
if (typeof component.type === 'string') {
return component.type.toUpperCase() === match[1].toUpperCase();
}
return false;
});
}
},
{
matcher: /^\.([^\s:.)!\[\]]+)/,
matchClass: function (className, match) {
var classes;
if (window.SVGAnimatedString && className instanceof SVGAnimatedString) {
className = className.animVal;
}
classes = className.split(' ');
return classes.indexOf(match[1]) !== -1;
},
runStep: function (context, match) {
var self = this;
context.filterScope(function (component) {
if (isDOMComponent(component) && component.className) {
return self.matchClass(component.className, match);
}
return false;
});
},
runShallowStep: function (context, match) {
var self = this;
context.filterScope(function (component) {
if (component && component.props && component.props.className) {
return self.matchClass(component.props.className, match);
}
return false;
});
}
},
{
// `.find('div[1]')` is shorthand for `find('div').at(1)`
matcher: /^\[(\d+)\]/,
runStep: function (context, match) {
var index = parseInt(match[1], 10),
newScope = [];
if (context.currentScope[index]) {
newScope.push(context.currentScope[index]);
}
context.setScope(newScope);
}
},
{
matcher: /^\[([^\s~|^$*=]+)(?:([~|^$*]?=)"((?:\\"|.)*?)")?\]/,
runStep: function (context, match) {
context.filterScope(function (component) {
var propName = match[1],
hasProp = componentHasProp(component, propName);
if (match[2]) {
var value = match[3].replace('\\"', '"'),
prop = String(getComponentProp(component, propName, context.shallow));
switch (match[2]) {
case '=':
return prop === value;
case '~=':
return prop.split(/\s+/).indexOf(value) !== -1;
case '|=':
return prop === value || prop.indexOf(value + '-') === 0;
case '^=':
return prop.indexOf(value) === 0;
case '$=':
return prop.indexOf(value) === prop.length - value.length;
case '*=':
return prop.indexOf(value) !== -1;
default:
throw new Error('Unknown attribute operator: ' + operator);
break;
}
}
return hasProp;
});
}
},
{
matcher: /^:contains\(((?:\\\)|.)*)\)/,
runStep: function (context, match) {
context.filterScope(function (component) {
return new rquery(component, context._origRootComponent).text().indexOf(match[1]) !== -1;
});
}
}
];
function isEmptyString (str) {
return /^\s*$/.test(str);
}
function parseNextStep (selector) {
var match, step;
for (var i = 0; i < STEP_DEFINITIONS.length; i++) {
step = STEP_DEFINITIONS[i];
match = selector.match(step.matcher);
if (match) {
return {
match: match,
step: step
};
}
}
}
function buildSteps (selector) {
var parsedStep, step,
steps = [],
stack = [];
while (!isEmptyString(selector)) {
parsedStep = parseNextStep(selector);
if (parsedStep) {
step = parsedStep.step;
steps.push(parsedStep);
if (step.pushStack) {
stack.push(steps);
parsedStep.steps = steps = [];
} else if (step.popStack) {
if (stack.length < 1) {
throw new Error('Syntax error, unmatched )');
}
// pop ourselves from the steps array, as we don't actually do anything
steps.pop();
steps = stack.pop();
}
selector = selector.substr(parsedStep.match[0].length);
} else {
throw new Error('Failed to parse selector at: ' + selector);
}
}
if (stack.length !== 0) {
throw new Error('Syntax error, unclosed )');
}
return steps;
}
function runSteps (steps, context) {
steps.forEach(function (step) {
var stepRunner = step.step.runStep;
if (context.shallow) {
stepRunner = step.step.runShallowStep || step.step.runStep;
}
stepRunner.call(step.step, context, step.match, step.steps);
});
context.saveResults();
}
function Context (rootComponents, origRootComponent) {
this.shallow = isShallow(origRootComponent);
this.rootComponents = rootComponents;
this._origRootComponent = origRootComponent;
this.results = [];
this.defaultScope = getDescendants(origRootComponent, rootComponents, false, true);
this.resetScope();
}
Context.prototype.setScope = function (scope) {
this.currentScope = _(scope)
.map(includeCompositeComponents)
.flatten()
.value();
};
Context.prototype.resetScope = function () {
this.setScope(this.defaultScope);
};
Context.prototype.filterScope = function (predicate) {
this.currentScope = this.currentScope.filter(predicate);
};
Context.prototype.saveResults = function () {
this.results = _.union(this.results, this.currentScope);
};
function rquery (components, _rootComponent) {
if (!isArray(components)) {
if (components) {
components = [components];
} else {
components = [];
}
}
this.components = components;
this._rootComponent = _rootComponent;
this.shallow = isShallow(_rootComponent);
this.length = components.length;
for (var i = 0; i < components.length; i++) {
this[i] = components[i];
}
}
rquery.prototype.find = function (selector) {
var steps = buildSteps(selector),
context = new Context(this.components, this._rootComponent);
runSteps(steps, context);
return new rquery(context.results, this._rootComponent);
};
rquery.prototype.findComponent = function (type) {
if (this.shallow) {
return this._generate(function (component) {
return component.type === type;
});
}
return this._generate(function (component) {
return TestUtils.isCompositeComponentWithType(component, type);
});
};
rquery.prototype.get = function (index) {
return this.components[index];
};
rquery.prototype.at = function (index) {
return new rquery(this.components[index], this._rootComponent);
};
rquery.prototype._generate = function (predicate) {
var matches;
if (this.shallow) {
matches = _.filter(getShallowDescendants(this.components, false, true), predicate);
} else {
matches = [].concat.apply([], this.components.map(function (component) {
return TestUtils.findAllInRenderedTree(component, predicate);
}));
}
return new rquery(matches, this._rootComponent);
};
rquery.prototype.simulateEvent = function (eventName, eventData) {
this._notAllowedInShallowMode('simulateEvent');
for (var i = 0; i < this.components.length; i++) {
TestUtils.Simulate[eventName](rquery_getDOMNode(this.components[i]), eventData);
}
return this;
};
rquery.prototype.ensureSimulateEvent = function (eventName, eventData) {
this._notAllowedInShallowMode('ensureSimulateEvent');
var name = 'ensure' + eventName[0].toUpperCase() + eventName.substr(1);
if (this.length !== 1) {
throw new Error('Called ' + name + ', but current context has ' + this.length + ' components. ' + name + ' only works when 1 component is present.');
}
if (eventName === 'click' && this[0].disabled) {
throw new Error('Called ' + name + ', but the targeted element is disabled.');
}
return this.simulateEvent(eventName, eventData);
}
rquery.prototype.clickAndChange = function (clickData, changeData) {
this._notAllowedInShallowMode('clickAndChange');
this.click(clickData);
this.change(changeData);
return this;
};
rquery.prototype.ensureClickAndChange = function (clickData, changeData) {
this._notAllowedInShallowMode('ensureClickAndChange');
this.ensureSimulateEvent('click', clickData);
this.ensureSimulateEvent('change', changeData);
return this;
};
rquery.prototype._toggleCheckbox = function () {
var i, node;
for (i = 0; i < this.length; i++) {
node = this[i];
if (isDOMComponent(node)) {
if (node.tagName.toUpperCase() === 'INPUT' && node.type.toUpperCase() === 'CHECKBOX') {
node.checked = !node.checked;
}
}
}
}
rquery.prototype.toggleCheckbox = function (clickData) {
this._notAllowedInShallowMode('toggleCheckbox');
this._toggleCheckbox();
this.clickAndChange(clickData);
return this;
};
rquery.prototype.ensureToggleCheckbox = function (clickData) {
this._notAllowedInShallowMode('ensureToggleCheckbox');
if (this.length !== 1) {
throw new Error('Called ensureToggleCheckbox, but current context has ' + this.length + ' components. ensureToggleCheckbox only works when 1 component is present.');
}
this._toggleCheckbox();
this.ensureClickAndChange(clickData);
return this;
};
rquery.prototype.prop = function (name) {
if (this.length < 1) {
throw new Error('$R#prop requires at least one component. No components in current scope.');
}
if (componentHasProp(this[0], name)) {
return getComponentProp(this[0], name, this.shallow);
}
};
rquery.prototype.style = function (name) {
var style;
if (this.length < 1) {
throw new Error('$R#style requires at least one component. No components in current scope.');
}
if (this.shallow) {
style = getComponentProp(this[0], 'style', this.shallow);
} else {
style = rquery_getDOMNode(this[0]).style;
}
if (style) {
return style[name];
}
};
rquery.prototype.state = function (name) {
this._notAllowedInShallowMode('state');
if (this.length < 1) {
throw new Error('$R#state requires at least one component. No components in current scope.');
}
return (this[0].state || {})[name];
};
rquery.prototype.nodes = function () {
this._notAllowedInShallowMode('nodes');
return _.map(this.components, rquery_getDOMNode);
};
rquery.prototype.text = function () {
if (this.shallow) {
return getShallowDescendants(this.components, false, true, 'string').join('');
}
return _.map(this.nodes(), function(node) {
return node.innerText || node.textContent;
}).join('');
};
rquery.prototype.html = function () {
this._notAllowedInShallowMode('html');
return _.map(this.nodes(), function(node) {
return node.innerHTML || '';
}).join('');
};
rquery.prototype.val = function (value) {
this._notAllowedInShallowMode('val');
if (value !== undefined) {
_.each(this.components, function(component) {
var node = rquery_getDOMNode(component);
if ('value' in node) {
node.value = value;
$R(component).change();
}
});
return this;
} else {
if (this.components[0]) {
if ('value' in this.components[0]) {
return rquery_getDOMNode(this.components[0]).value;
} else {
return rquery_getDOMNode(this.components[0]).getAttribute('value');
}
}
}
};
rquery.prototype.disabled = function (name) {
if (this.length < 1) {
throw new Error('$R#disabled requires at least one component. No components in current scope.');
}
return rquery_getDOMNode(this[0]).disabled;
};
rquery.prototype.checked = function (value) {
this._notAllowedInShallowMode('checked');
if (value !== undefined) {
_.each(this.components, function (component) {
var node = rquery_getDOMNode(component);
if ('checked' in node) {
node.checked = value;
$R(component).change();
}
});
return this;
} else {
if (this.length < 1) {
throw new Error('$R#checked requires at least one component. No components in current scope.');
}
return rquery_getDOMNode(this[0]).checked;
}
};
rquery.prototype._notAllowedInShallowMode = function (methodName) {
if (this.shallow) {
throw new Error('The ' + methodName + '() method is not allowed in shallow rquery objects.');
}
};
var EVENT_NAMES = [
// clipboard events
'copy', 'cut', 'paste',
// keyboard events
'keyDown', 'keyPress', 'keyUp',
// focus events
'focus', 'blur',
// form events
'change', 'input', 'submit',
// mouse events
'click', 'mouseDown', 'mouseEnter', 'mouseLeave', 'mouseMove', 'mouseOut', 'mouseOver', 'mouseUp',
'doubleClick', 'drag', 'dragEnd', 'dragEnter', 'dragExit', 'dragLeave', 'dragOver', 'dragStart', 'drop',
// touch events
'touchCancel', 'touchEnd', 'touchMove', 'touchStart',
// UI events
'scroll',
// wheel events
'wheel'
];
EVENT_NAMES.forEach(function (eventName) {
rquery.prototype[eventName] = function (eventData) {
this._notAllowedInShallowMode(eventName);
return this.simulateEvent(eventName, eventData);
};
var name = 'ensure' + eventName[0].toUpperCase() + eventName.substr(1);
rquery.prototype[name] = function (eventData) {
this._notAllowedInShallowMode(name);
return this.ensureSimulateEvent(eventName, eventData);
};
});
var $R = function (component, selector) {
var $r;
if (isArray(component)) {
throw new Error('Cannot initialize an rquery object with an array of components. This prevents rquery from traversing the tree as necessary.');
} else if (typeof component !== 'object') {
throw new Error('Must initialize an rquery object with a React component.');
} else if (!TestUtils.isCompositeComponent(component) && showCompositeWarning) {
showCompositeWarning = false;
window.console && console.warn('Initializing an rquery object with a DOM component (really just a DOM node in React 0.14) prevents rquery from properly traversing the React tree. For best results, initialize your rquery object with a composite component.');
}
// pass in root component to constructor
$r = new rquery(component, component);
if (selector) {
return $r.find(selector);
}
return $r;
};
$R.rquery = rquery;
$R.isRQuery = function (obj) {
return obj instanceof rquery;
};
$R.extend = function (obj) {
_.defaults(rquery.prototype, obj);
};
return $R;
}));