/** * @class ST.Locator * @singleton * * Sencha Test provides multiple ways to locate an element from a text string. The best * and most reliable way to locate elements will be application-specific, so Sencha Test * generalizes the tools needed in what are called "locators". * * A locator solves the same probelm as a CSS selector but is a super-set of CSS selector * syntax. The locator syntax is more expressive than selectors to provide more options * for testing real-world applications. * * When testing applications, ideally the application developers provide a reliable way * for testers to locate application components and elements. * * Locators appear in the {@link ST.playable.Playable#target target} property of records * passed to {@link ST#play}. Locators can be passed to {@link ST#find} to find an * {@link ST.Element element}. Locators are also passed to {@link ST#element} to create * {@link ST.future.Element future elements} and {@link ST#component} to create * {@link ST.future.Component future components}. * * ## Locating Elements * * ### At-Path * Locators that start with the "@" character are called "at-paths". The first token of * an at-path is an element ID. Following the first token is a slash-delimited sequence * of tag names and offsets, similar to XPath. For example: * * @some-div/span[2] * * This identifies the 2nd "span" element that is an immediate child of the element with * the id "some-div". The equivalent XPath expression would be: * * //[@id="some-div"]/span[2] * * The primary advantages of at-paths over XPath are compactness and speed. This is because * an at-path uses `getElementById` followed by a simple path based on tag names. Because * at-paths are inherently based on ID's, they will be most useful in applications that * assign meaningful ID's to their components. * * ### XPath * XPath is probably the most powerful supported locator syntax. Sencha Test uses the * [document.evaluate](http://www.w3.org/TR/DOM-Level-3-XPath/xpath.html#XPathEvaluator-evaluate) * method of the browser, but also a [polyfill](https://github.com/google/wicked-good-xpath) * when this method is not present. * * In addition to attribute matching, XPath can also navigate upwards, unlike CSS * selectors. For example: * * //[id="some-div"]/.. * * The above XPath selects the parent node of the node having ID of "some-div". * * **IMPORTANT** Sencha Test requires that all XPath locators start with a slash character. * Typically XPath locators will begin with "//" (as shown above) so that matches do not * start at the document root. * * Some useful resources on XPath: * * * [DOM XPath Specification](http://www.w3.org/TR/DOM-Level-3-XPath/xpath.html) * * [XPath and CSS Selectors](http://ejohn.org/blog/xpath-css-selectors/) * * ### DOM Query * The DOM Query, or CSS Selector, is perhaps the most familiar locator syntax supported * by Sencha Test. To differentiate DOM Query locators from the Component and Composite * Queries (discussed below), a DOM Query starts with ">>" or "=>". * * The above paths would be approximated by the following DOM Query: * * >> #some-div > span:nth-child(2) * * This is only approximately the same because `nth-child()` does not require the first * child to also be a `span`. * * ## Locating Components * When testing applications built using Sencha frameworks (Ext JS and Sencha Touch), the * majority of logic operates at a layer above elements: Components. It is therefore more * desirable to locate and operate on components than raw DOM elements. * * ### Component Query * "[Component Query](http://docs.sencha.com/extjs/6.0/6.0.1-classic/#!/api/Ext.ComponentQuery)" * is a feature provided by Sencha frameworks that can locate components of the application. * Component Query syntax is essentially the same as DOM Query. * * Consider: * * #some-component * * The above will locate a Component with an `id` or `itemId` property of "some-component". * * ### Composite Query * Finally, you can combine Component Query and DOM Query in a "Composite Query" by using * the "=>" to separate the two pieces. * * For example: * * #some-component => div.foo * * This locates the child "div" with class "foo" inside the component with `id` (or `itemId`) * of "some-component". */ST.Locator = (function (Locator) { return ST.apply(Locator, { tagPathRegEx: /([\w-:]+)(?:\[(\d+)\])?/, attrRegEx: /\[(.*?)\]/g, atPathRe: /^@/, xpathRe: /^\/|\.\//, // need to allow for /, //, and .// (current context) root: window, /** * Given the `target` locator string, return the matching element or `null` if one * is not found. See {@link ST.Locator} for a description of valid locator * strings. * * See also the short-hand equivalent {@link ST#find}. * * @param {String/Function} target The target locator string or a function that * returns the DOM node. * @param {Boolean} [wrap] Pass `true` to return a wrapped {@link ST.Element} * instead of the raw DOM node. * @param {HTMLElement/ST.Element/Ext.Component} [root] * @param {"down"/"up"/"child"/"sibling?"} [direction="down"] * @param {Boolean} [allowMultiples=undefined] Pass `true` to return multiple match results if available. * currently only used for composite queries * @param {Boolean} [returnComponents=undefined] Pass `true` to return results as Components instead of * ST.Element (if possible). Only applicable if the target is a standard Component Query. * @return {HTMLElement/HTMLElement[]/ST.Element/ST.Element[]} */ find: function (target, wrap, root, direction, allowMultiples, returnComponents) { var opts = { $isOpts: true }, result = [], el, scope; if (root) { opts.root = root; } if (direction) { opts.direction = direction; } if (allowMultiples) { opts.allowMultiples = allowMultiples; } if (returnComponents) { opts.returnComponents = returnComponents; } if (target == null && root == null) { return null; } if (typeof target === 'number') { el = ST.cache.get(target); } else if (typeof target === 'object' && root == null) { el = target; } else if (typeof target === 'function') { scope = target.$scope || window; el = target.apply(scope); } else if (Locator.atPathRe.test(target)) { el = Locator.atPath(target, opts); } else if (Locator.xpathRe.test(target)) { el = Locator.xpath(target, opts); } else { el = Locator.composite(target, opts); } if (el && wrap) { if (ST.isArray(el)) { for (var i in el) { if (el.hasOwnProperty(i)) { if (returnComponents && (el[i].isComponent || el[i].isWidget)) { result.push(el[i]); } else { result.push(new ST.Element(el[i])); } } } return result; } else { // TODO: Unless returnComponents=true, el should *always* be a dom node... if (!el.isComponent && !el.isWidget) { el = new ST.Element(el); } } } return el; }, /** * @return {ST.Element[]/Ext.Component[]} */ findAll: function (target, wrap, root, direction, returnComponents) { var results = this.find(target, wrap, root, direction, true, returnComponents); if (results === null) { results = []; } else if (!ST.isArray(results)) { results = [results]; } return results; }, atPath: function (path, opts) { var parts = path.split('/'), regex = Locator.tagPathRegEx, attrRe = Locator.attrRegEx, i, n, m, x, count, tag, child, matchedAttr, attrs, attr, key, value, hasInvalidAttr, el = Locator.root.document; el = (parts[0] == '@') ? el.body : el.getElementById(parts[0].substring(1)); // remove '@' for (i = 1, n = parts.length; el && i < n; ++i) { m = regex.exec(parts[i]); attrs = []; count = m[2] ? parseInt(m[2], 10) : 1; tag = m[1].toUpperCase(); while ((matchedAttr = attrRe.exec(parts[i])) !== null) { attrs.push(matchedAttr[1]); } for (child = el.firstChild; child; child = child.nextSibling) { if (child.tagName == tag) { if (count == 1) { // if we have attributes... if (attrs && attrs.length) { // start with a blank slate hasInvalidAttr = false; for (x = 0; x < attrs.length; x++) { attr = attrs[x]; // if it's a index, skip if (!isNaN(parseInt(attr))) { continue; } // if it's a key/value pair else if (attr.search(/=/) !== -1) { attr = attr.split('='); key = attr[0]; value = attr[1].replace(/^["|'](.*)["|']$/g, '$1'); if (child.getAttribute(key) != value) { hasInvalidAttr = true; } } // if it's a contains() declaration else if (attr.search(/contains\(/)) { } } // TODO: selector is broken; default to original element? if (hasInvalidAttr) { child = el; } } break; } --count; } } el = child; } if (opts && opts.returnComponents) { var fly = ST.fly(el), cmp = fly && fly.getComponent(); return cmp || el; } else { return el; } }, composite: function (target, queryRoot, direction, allowMultiples, returnComponents) { var me = this, opts = queryRoot, isOpts = typeof opts === 'object' && opts.$isOpts, queryRoot = isOpts ? opts.root : queryRoot, direction = isOpts ? opts.direction : direction, allowMultiples = isOpts ? opts.allowMultiples : allowMultiples, returnComponents = isOpts ? opts.returnComponents : returnComponents, context = me._parseRoot(queryRoot), compContext = context.comp, root = context.root, parsedTarget = me._parseSelector(target, context.type), domQuery = parsedTarget.domQuery, compQuery = parsedTarget.compQuery, isComposite = parsedTarget.isComposite, direction = direction || 'down', results = [], comp; // if this is a composite query of the "up" or "child" variety if (isComposite && compContext && direction !== 'down') { return me._doCompositeQuery(compQuery, domQuery, compContext, direction, null, allowMultiples); } else { // if we failed to resolve the requested root, we can skip the rest if (context.failedRoot) { return null; } // if a component query was detected, run first to determine root if (compQuery) { root = compContext || null; comp = me._doComponentQuery(compQuery, root, direction, allowMultiples); if (ST.isArray(comp)) { if (returnComponents) { root = comp; } else { root = []; for (var i in comp) { if (comp.hasOwnProperty(i)) { root[i] = comp[i] && (comp[i].el || comp[i].element); root[i] = root[i] && root[i].dom; } } } } else { if (returnComponents) { root = comp; } else { root = comp && (comp.el || comp.element); root = root && root.dom; } } } else if (!domQuery) { root = null; } if (domQuery && root) { if (allowMultiples && ST.isArray(root)) { for (var i in root) { if (!root.hasOwnProperty(i)) continue // TODO test _doDomQuery() returning mulitple results results.push(me._doDomQuery(domQuery, root[i], direction, allowMultiples)); } return results; } else { return me._doDomQuery(domQuery, root, direction, allowMultiples); } } } return root; }, xpath: function (target, opts) { var opts = opts || {}, // allow context switch for query (useful for iframes and different windows) doc = opts.context || Locator.root.document, // allow root switch based on configuration (nice for subqueries of existing, located elements) root = doc, //root = opts.root || doc, // opts.root was an invalid Node type - needs to be a dom node, not an ST locator object // root could be an ST.Element instance root = root && root.dom ? root.dom : root, // do the query!!! res = doc.evaluate(target, root, null, 5, null), // ORDERED_NODE_ITERATOR_TYPE el = res ? res.iterateNext() : null, allowMultiples = opts.allowMultiples, results, i, fly, cmp; if (allowMultiples) { results = el ? [el] : []; } else { results = el || null; } if (el) { if (allowMultiples) { while (el = res.iterateNext()) { results.push(el); } } else if (ST.options.failOnMultipleMatches && res.iterateNext()) { e = new Error('XPath locator matches multiple items: "' + target + '"'); e.multipleMatches = true; throw e; } } if (opts && opts.returnComponents) { for (i in results) { fly = ST.fly(results[i]); cmp = fly && fly.getComponent(); if (cmp) { results[i] = cmp; } } } return results; }, /** * @private * Executes composite query for hierarchy requests of the 'up' and 'child' varieties * @param {String} compQuery The Component query * @param {String} domQuery The DOM query * @param {Ext.Component} root The root component * @param {String} direction The direction of the hierarchical query. * @param {String} start The id of the original starting context * @return {HTMLElement} */ _doCompositeQuery: function (compQuery, domQuery, root, direction, start, allowMultiples) { var me = this, start = start || root, originalRoot, el, matches, match, comp, i, node, verified, rootEl; if (direction === 'up') { // first, run component query side of composite comp = root.up(compQuery); if (comp) { // we have a match, so run dom query portion el = comp.el || comp.element; match = ST.fly(el).down(domQuery, true /* asDom */); verified = false; if (match) { // Here's where it gets interesting; we found a match, but it could be *anywhere* in the hierarchy // of the matched component; so, we need to work our way from the bottom (original node) to the top (matched component) // If we find the matched node along the way, this is a valid ancestor element // If we don't find the matched node, we need to continue up() the component tree, as a higher level might have // what we're after originalRoot = start; // the rootEl is where the query started rootEl = originalRoot.el || originalRoot.element; // ensure that the match isn't our starting location! if (rootEl.dom !== match) { node = rootEl.dom; } // verify that this match is *above* the original root in the dom hierarchy while (node) { if (node === match) { // matching node has been located node = null; verified = true; } else if (node !== el.dom) { // node not found yet, keep interating up the hierarchy node = node.parentNode; } else { // we're reached the top-most node where a match is allowed without finding anything; abort node = null; } } } // if we couldn't verify the match, we need to continue the search upward, recursively if (!verified) { match = me._doCompositeQuery(compQuery, domQuery, comp, direction, start); } } } else if (direction === 'child' && root.query) { // for first, last, and child, we can query for matches on the component level rootEl = root.el || root.element; matches = root.query(compQuery); // now we'll loop over the matches, and run the appropriate dom query method on the component's root element for (i = 0; i < matches.length; i++) { comp = matches[i]; el = comp.el || comp.element; match = ST.get(el).down(domQuery, true); // if we have a match, verify that the element is actually a child of the original root if (match && (match.parentNode === rootEl.parentNode) && (rootEl !== match)) { break; } else { match = null; } } } return match || null; }, _doDomQuery: function (selector, root, direction, allowMultiples) { root = ST.get(root); // run the appropriate method against the passed context element return root[direction](selector, true, (typeof allowMultiples === 'undefined') ? null : !allowMultiples /* single */); }, _doComponentQuery: function (selector, root, direction, allowMultiples) { var comp; // if we have a root, it's a contextual query so we can use direction if (root) { switch (direction) { case 'up': comp = root.up(selector); break; case 'down': comp = root.down && root.down(selector, false /* asDom */, !allowMultiples /* single */); break; case 'child': comp = root.child && root.child(selector, false /* asDom */, !allowMultiples /* single */); break; } } // otherwise, fall back to component query else { comp = Ext.ComponentQuery.query(selector); if (comp && comp.length) { if (comp.length > 1 && ST.options.failOnMultipleMatches && !allowMultiples) { throw new Error('Component Query locator matches multiple items: "' + selector + '"'); } if (!allowMultiples) { comp = comp[0]; } } else { comp = null; } } return comp || null; }, _parseRoot: function (root) { var defaultRoot = Locator.root.document, type = typeof root, result = { comp: null, el: null, type: 'element', failedRoot: false, root: defaultRoot }, contextCmp = null, contextEl = null, context; if (!root || root === defaultRoot) { result.type = null; } else { if (root && type === 'object') { // is an Ext JS / Sencha Touch component? if (root.isComponent) { result.comp = root; result.el = root.el || root.element; } // this is either an Ext JS element or an ST.Element else if (root.dom) { result.el = root; } // this could be an html element else if (root.nodeType && root.nodeType === 1) { result.el = ST.get(root); } // this may be a future else if (root.$ST) { context = root; if (context) { contextCmp = context.cmp || null; contextEl = context.el || null; } result.comp = contextCmp; result.el = contextEl; } // this is a locator chain else if (ST.isArray(root)) { // _parseChain will ultimately produce the "result" that _parseRoot produces for other scenarios context = this._parseChain(root); return context; } } // if it's a string, we'll try to produce an ST.Element from it else if (type === 'string') { result.el = ST.get(root); } // determine the correct type if (result.comp) { result.type = 'component'; } else { result.type = result.el ? 'element' : null; } // if element was located, use it for the root if (result.el) { result.root = result.el.dom || result.el; } else { result.root = null; result.failedRoot = true; } } return result; }, _parseChain: function (chain) { var len = chain.length - 1, i, item, root, future; // The goal here is to loop over the "locator chain" that was provided on the originating future; // we'll use this descriptor to walk the identified hierarchy so that we can recreate it in order // to locate the desired element/component, starting from the correct root. // This allows the locator to be remoted since we don't have to have any extant futures (or their corresponding) // components/elements; instead, we can simply re-walk the hierarchy on demand for (i = 0; i < len; i++) { item = chain[i]; if (item.locator && item.locator === '$$SKIP$$') { root = root; if (root && root.isComponent && item.type === 'element') { root = root.element || root.el; } } else if (ST.playable.Playable.hasLocatorFn(item)) { // call locator fn of future future = ST.clsFromString(item.futureClsName + '.prototype.playables.' + ST.capitalize(item.type)); // pass current root and args to the locatorFn root = future.prototype.locatorFn(root, item.args); } else if (item.el) { root = item.el; } else { // the root will change on each iteration, so we'll re-find() based on the new root root = this.find(item.locator, false, root, item.direction); } // if the type is component, we'll try to resolve the component // so we can use it as the root if (root && item.futureClsName && ST.future.Element.isFutureType(item.futureClsName, 'component')) { root = ST.fly(root).getComponent(); } } // Here we're at the end of the line. Our root could be an element or a component. // Since _parseRoot() already deals in these scenarios, we'll just run through it once more // This will produce the correct result that our original find() is needing in order to continue return this._parseRoot(root); }, _parseSelector: function (selector, contextType) { var Ext = ST.Ext, hasCQ = Ext && Ext.ComponentQuery, selector = selector || (contextType ? '*' : ''), compPos = selector.indexOf('=>'), domPos = selector.indexOf('>>'), parts, result; result = { domQuery: null, compQuery: null, isComposite: false, selector: selector } // context is a component if (hasCQ) { if (contextType !== 'element') { // no fat arrow (>> .my-class) if (domPos === 0) { result.domQuery = ST.String.trim(selector.replace('>>', '')); } // fat arrow at beginning (=> .my-class) else if (compPos === 0) { result.domQuery = ST.String.trim(selector.replace('=>', '')); } // fat arrow between selectors (container => .my-class) else if (compPos > 0) { parts = selector.split('=>'); result.domQuery = ST.String.trim(parts[1]); result.compQuery = ST.String.trim(parts[0]); result.isComposite = true; } // pure component query (container) else { result.compQuery = ST.String.trim(selector) } } else if (domPos === -1 || domPos === 0) { if (compPos !== -1) { throw new Error('The specified composite query ("' + selector + '") cannot be used in the current context'); } result.domQuery = ST.String.trim(selector.replace('>>', '')); } } // if component query isn't an option, everything has to funnel through dom query else { result.domQuery = ST.String.trim(selector.replace('>>', '')); } return result; } });})({}); wgxpath.install(); // polyfill win.document.evaluate for old browsers /** * Given the `target` locator string, return the matching element or `null` if one is not * found. See {@link ST.Locator} for a description of valid locator strings. * * Alias for {@link ST.Locator#find}. * * @param {String} target The target locator string. * @param {Boolean} [wrap=false] Pass `true` to return a wrapped {@link ST.Element} * instead of the raw DOM node. * @param {HTMLElement/ST.Element/Ext.Component} [root] * @param {"down"/"up"/"child"} [direction="down"] * @method find * @member ST * @return {ST.Element} */ST.find = ST.Locator.find;