//****************************************************************************** // Globals, including constants var UI_GLOBAL = { UI_PREFIX: 'ui' , XHTML_DOCTYPE: '' , XHTML_XMLNS: 'http://www.w3.org/1999/xhtml' }; //***************************************************************************** // Exceptions function UIElementException(message) { this.message = message; this.name = 'UIElementException'; } function UIArgumentException(message) { this.message = message; this.name = 'UIArgumentException'; } function PagesetException(message) { this.message = message; this.name = 'PagesetException'; } function UISpecifierException(message) { this.message = message; this.name = 'UISpecifierException'; } function CommandMatcherException(message) { this.message = message; this.name = 'CommandMatcherException'; } //***************************************************************************** // UI-Element core /** * The UIElement object. This has been crafted along with UIMap to make * specifying UI elements using JSON as simple as possible. Object construction * will fail if 1) a proper name isn't provided, 2) a faulty args argument is * given, or 3) getLocator() returns undefined for a valid permutation of * default argument values. See ui-doc.html for the documentation on the * builder syntax. * * @param uiElementShorthand an object whose contents conform to the * UI-Element builder syntax. * * @return a new UIElement object * @throws UIElementException */ function UIElement(uiElementShorthand) { // a shorthand object might look like: // // { // name: 'topic' // , description: 'sidebar links to topic categories' // , args: [ // { // name: 'name' // , description: 'the name of the topic' // , defaultValues: topLevelTopics // } // ] // , getLocator: function(args) { // return this._listXPath + // "/a[text()=" + args.name.quoteForXPath() + "]"; // } // , getGenericLocator: function() { // return this._listXPath + '/a'; // } // // maintain testcases for getLocator() // , testcase1: { // // defaultValues used if args not specified // args: { name: 'foo' } // , xhtml: '
' // + '' // + '
' // } // // set a local element variable // , _listXPath: "//div[@id='topiclist']/ul/li" // } // // name cannot be null or an empty string. Enforce the same requirement for // the description. /** * Recursively returns all permutations of argument-value pairs, given * a list of argument definitions. Each argument definition will have * a set of default values to use in generating said pairs. If an argument * has no default values defined, it will not be included among the * permutations. * * @param args a list of UIArguments * @param opt_inDocument (optional) * @return a list of associative arrays containing key value pairs */ this.permuteArgs = function(args, opt_inDocument) { var permutations = []; for (var i = 0; i < args.length; ++i) { var arg = args[i]; var defaultValues = (arguments.length > 1) ? arg.getDefaultValues(opt_inDocument) : arg.getDefaultValues(); // skip arguments for which no default values are defined if (defaultValues.length == 0) { continue; } for (var j = 0; j < defaultValues.length; ++j) { var value = defaultValues[j]; var nextPermutations = this.permuteArgs(args.slice(i+1)); if (nextPermutations.length == 0) { var permutation = {}; permutation[arg.name] = value + ''; // make into string permutations.push(permutation); } else { for (var k = 0; k < nextPermutations.length; ++k) { nextPermutations[k][arg.name] = value + ''; permutations.push(nextPermutations[k]); } } } break; } return permutations; } /** * Returns a list of all testcases for this UIElement. */ this.getTestcases = function() { return this.testcases; } /** * Run all unit tests, stopping at the first failure, if any. Return true * if no failures encountered, false otherwise. See the following thread * regarding use of getElementById() on XML documents created by parsing * text via the DOMParser: * * http://groups.google.com/group/comp.lang.javascript/browse_thread/thread/2b1b82b3c53a1282/ */ this.test = function() { var parser = new DOMParser(); var testcases = this.getTestcases(); testcaseLoop: for (var i = 0; i < testcases.length; ++i) { var testcase = testcases[i]; var xhtml = UI_GLOBAL.XHTML_DOCTYPE + '' + testcase.xhtml + ''; var doc = parser.parseFromString(xhtml, "text/xml"); if (doc.firstChild.nodeName == 'parsererror') { safe_alert('Error parsing XHTML in testcase "' + testcase.name + '" for UI element "' + this.name + '": ' + "\n" + doc.firstChild.firstChild.nodeValue); } // we're no longer using the default locators when testing, because // args is now required var locator = parse_locator(this.getLocator(testcase.args)); var results; if (locator.type == 'xpath' || (locator.type == 'implicit' && locator.string.substring(0, 2) == '//')) { // try using the javascript xpath engine to avoid namespace // issues. The xpath does have to be lowercase however, it // seems. results = eval_xpath(locator.string, doc, { allowNativeXpath: false, returnOnFirstMatch: true }); } else { // piece the locator back together locator = (locator.type == 'implicit') ? locator.string : locator.type + '=' + locator.string; results = eval_locator(locator, doc); } if (results.length && results[0].hasAttribute('expected-result')) { continue testcaseLoop; } // testcase failed if (is_IDE()) { var msg = 'Testcase "' + testcase.name + '" failed for UI element "' + this.name + '":'; if (!results.length) { msg += '\n"' + locator + '" did not match any elements!'; } else { msg += '\n' + results[0] + ' was not the expected result!'; } safe_alert(msg); } return false; } return true; }; /** * Creates a set of locators using permutations of default values for * arguments used in the locator construction. The set is returned as an * object mapping locators to key-value arguments objects containing the * values passed to getLocator() to create the locator. * * @param opt_inDocument (optional) the document object of the "current" * page when this method is invoked. Some arguments * may have default value lists that are calculated * based on the contents of the page. * * @return a list of locator strings * @throws UIElementException */ this.getDefaultLocators = function(opt_inDocument) { var defaultLocators = {}; if (this.args.length == 0) { defaultLocators[this.getLocator({})] = {}; } else { var permutations = this.permuteArgs(this.args, opt_inDocument); if (permutations.length != 0) { for (var i = 0; i < permutations.length; ++i) { var args = permutations[i]; var locator = this.getLocator(args); if (!locator) { throw new UIElementException('Error in UIElement(): ' + 'no getLocator return value for element "' + name + '"'); } defaultLocators[locator] = args; } } else { // try using no arguments. If it doesn't work, fine. try { var locator = this.getLocator(); defaultLocators[locator] = {}; } catch (e) { safe_log('debug', e.message); } } } return defaultLocators; }; /** * Validate the structure of the shorthand notation this object is being * initialized with. Throws an exception if there's a validation error. * * @param uiElementShorthand * * @throws UIElementException */ this.validate = function(uiElementShorthand) { var msg = "UIElement validation error:\n" + print_r(uiElementShorthand); if (!uiElementShorthand.name) { throw new UIElementException(msg + 'no name specified!'); } if (!uiElementShorthand.description) { throw new UIElementException(msg + 'no description specified!'); } if (!uiElementShorthand.locator && !uiElementShorthand.getLocator && !uiElementShorthand.xpath && !uiElementShorthand.getXPath) { throw new UIElementException(msg + 'no locator specified!'); } }; this.init = function(uiElementShorthand) { this.validate(uiElementShorthand); this.name = uiElementShorthand.name; this.description = uiElementShorthand.description; // construct a new getLocator() method based on the locator property, // or use the provided function. We're deprecating the xpath property // and getXPath() function, but still allow for them for backwards // compatability. if (uiElementShorthand.locator) { this.getLocator = function(args) { return uiElementShorthand.locator; }; } else if (uiElementShorthand.getLocator) { this.getLocator = uiElementShorthand.getLocator; } else if (uiElementShorthand.xpath) { this.getLocator = function(args) { return uiElementShorthand.xpath; }; } else { this.getLocator = uiElementShorthand.getXPath; } if (uiElementShorthand.genericLocator) { this.getGenericLocator = function() { return uiElementShorthand.genericLocator; }; } else if (uiElementShorthand.getGenericLocator) { this.getGenericLocator = uiElementShorthand.getGenericLocator; } if (uiElementShorthand.getOffsetLocator) { this.getOffsetLocator = uiElementShorthand.getOffsetLocator; } // get the testcases and local variables this.testcases = []; var localVars = {}; for (var attr in uiElementShorthand) { if (attr.match(/^testcase/)) { var testcase = uiElementShorthand[attr]; if (uiElementShorthand.args && uiElementShorthand.args.length && !testcase.args) { safe_alert('No args defined in ' + attr + ' for UI element ' + this.name + '! Skipping testcase.'); continue; } testcase.name = attr; this.testcases.push(testcase); } else if (attr.match(/^_/)) { this[attr] = uiElementShorthand[attr]; localVars[attr] = uiElementShorthand[attr]; } } // create the arguments this.args = [] this.argsOrder = []; if (uiElementShorthand.args) { for (var i = 0; i < uiElementShorthand.args.length; ++i) { var arg = new UIArgument(uiElementShorthand.args[i], localVars); this.args.push(arg); this.argsOrder.push(arg.name); // if an exception is thrown when invoking getDefaultValues() // with no parameters passed in, assume the method requires an // inDocument parameter, and thus may only be invoked at run // time. Mark the UI element object accordingly. try { arg.getDefaultValues(); } catch (e) { this.isDefaultLocatorConstructionDeferred = true; } } } if (!this.isDefaultLocatorConstructionDeferred) { this.defaultLocators = this.getDefaultLocators(); } }; this.init(uiElementShorthand); } // hang this off the UIElement "namespace". This is a composite strategy. UIElement.defaultOffsetLocatorStrategy = function(locatedElement, pageElement) { var strategies = [ UIElement.linkXPathOffsetLocatorStrategy , UIElement.preferredAttributeXPathOffsetLocatorStrategy , UIElement.simpleXPathOffsetLocatorStrategy ]; for (var i = 0; i < strategies.length; ++i) { var strategy = strategies[i]; var offsetLocator = strategy(locatedElement, pageElement); if (offsetLocator) { return offsetLocator; } } return null; }; UIElement.simpleXPathOffsetLocatorStrategy = function(locatedElement, pageElement) { if (is_ancestor(locatedElement, pageElement)) { var xpath = ""; var recorder = Recorder.get(locatedElement.ownerDocument.defaultView); var locatorBuilders = recorder.locatorBuilders; var currentNode = pageElement; while (currentNode != null && currentNode != locatedElement) { xpath = locatorBuilders.relativeXPathFromParent(currentNode) + xpath; currentNode = currentNode.parentNode; } var results = eval_xpath(xpath, locatedElement.ownerDocument, { contextNode: locatedElement }); if (results.length > 0 && results[0] == pageElement) { return xpath; } } return null; }; UIElement.linkXPathOffsetLocatorStrategy = function(locatedElement, pageElement) { if (pageElement.nodeName == 'A' && is_ancestor(locatedElement, pageElement)) { var text = pageElement.textContent .replace(/^\s+/, "") .replace(/\s+$/, ""); if (text) { var xpath = '/descendant::a[normalize-space()=' + text.quoteForXPath() + ']'; var results = eval_xpath(xpath, locatedElement.ownerDocument, { contextNode: locatedElement }); if (results.length > 0 && results[0] == pageElement) { return xpath; } } } return null; }; // compare to the "xpath:attributes" locator strategy defined in the IDE source UIElement.preferredAttributeXPathOffsetLocatorStrategy = function(locatedElement, pageElement) { // this is an ordered listing of single attributes var preferredAttributes = [ 'name' , 'value' , 'type' , 'action' , 'alt' , 'title' , 'class' , 'src' , 'href' , 'onclick' ]; if (is_ancestor(locatedElement, pageElement)) { var xpathBase = '/descendant::' + pageElement.nodeName.toLowerCase(); for (var i = 0; i < preferredAttributes.length; ++i) { var name = preferredAttributes[i]; var value = pageElement.getAttribute(name); if (value) { var xpath = xpathBase + '[@' + name + '=' + value.quoteForXPath() + ']'; var results = eval_xpath(xpath, locatedElement.ownerDocument, { contextNode: locatedElement }); if (results.length > 0 && results[0] == pageElement) { return xpath; } } } } return null; }; /** * Constructs a UIArgument. This is mostly for checking that the values are * valid. * * @param uiArgumentShorthand * @param localVars * * @throws UIArgumentException */ function UIArgument(uiArgumentShorthand, localVars) { /** * @param uiArgumentShorthand * * @throws UIArgumentException */ this.validate = function(uiArgumentShorthand) { var msg = "UIArgument validation error:\n" + print_r(uiArgumentShorthand); // try really hard to throw an exception! if (!uiArgumentShorthand.name) { throw new UIArgumentException(msg + 'no name specified!'); } if (!uiArgumentShorthand.description) { throw new UIArgumentException(msg + 'no description specified!'); } if (!uiArgumentShorthand.defaultValues && !uiArgumentShorthand.getDefaultValues) { throw new UIArgumentException(msg + 'no default values specified!'); } }; /** * @param uiArgumentShorthand * @param localVars a list of local variables */ this.init = function(uiArgumentShorthand, localVars) { this.validate(uiArgumentShorthand); this.name = uiArgumentShorthand.name; this.description = uiArgumentShorthand.description; if (uiArgumentShorthand.defaultValues) { var defaultValues = uiArgumentShorthand.defaultValues; this.getDefaultValues = function() { return defaultValues; } } else { this.getDefaultValues = uiArgumentShorthand.getDefaultValues; } for (var name in localVars) { this[name] = localVars[name]; } } this.init(uiArgumentShorthand, localVars); } /** * The UISpecifier constructor is overloaded. If less than three arguments are * provided, the first argument will be considered a UI specifier string, and * will be split out accordingly. Otherwise, the first argument will be * considered the path. * * @param uiSpecifierStringOrPagesetName a UI specifier string, or the pageset * name of the UI specifier * @param elementName the name of the element * @param args an object associating keys to values * * @return new UISpecifier object */ function UISpecifier(uiSpecifierStringOrPagesetName, elementName, args) { /** * Initializes this object from a UI specifier string of the form: * * pagesetName::elementName(arg1=value1, arg2=value2, ...) * * into its component parts, and returns them as an object. * * @return an object containing the components of the UI specifier * @throws UISpecifierException */ this._initFromUISpecifierString = function(uiSpecifierString) { var matches = /^(.*)::([^\(]+)\((.*)\)$/.exec(uiSpecifierString); if (matches == null) { throw new UISpecifierException('Error in ' + 'UISpecifier._initFromUISpecifierString(): "' + this.string + '" is not a valid UI specifier string'); } this.pagesetName = matches[1]; this.elementName = matches[2]; this.args = (matches[3]) ? parse_kwargs(matches[3]) : {}; }; /** * Override the toString() method to return the UI specifier string when * evaluated in a string context. Combines the UI specifier components into * a canonical UI specifier string and returns it. * * @return a UI specifier string */ this.toString = function() { // empty string is acceptable for the path, but it must be defined if (this.pagesetName == undefined) { throw new UISpecifierException('Error in UISpecifier.toString(): "' + this.pagesetName + '" is not a valid UI specifier pageset ' + 'name'); } if (!this.elementName) { throw new UISpecifierException('Error in UISpecifier.unparse(): "' + this.elementName + '" is not a valid UI specifier element ' + 'name'); } if (!this.args) { throw new UISpecifierException('Error in UISpecifier.unparse(): "' + this.args + '" are not valid UI specifier args'); } uiElement = UIMap.getInstance() .getUIElement(this.pagesetName, this.elementName); if (uiElement != null) { var kwargs = to_kwargs(this.args, uiElement.argsOrder); } else { // probably under unit test var kwargs = to_kwargs(this.args); } return this.pagesetName + '::' + this.elementName + '(' + kwargs + ')'; }; // construct the object if (arguments.length < 2) { this._initFromUISpecifierString(uiSpecifierStringOrPagesetName); } else { this.pagesetName = uiSpecifierStringOrPagesetName; this.elementName = elementName; this.args = (args) ? clone(args) : {}; } } function Pageset(pagesetShorthand) { /** * Returns true if the page is included in this pageset, false otherwise. * The page is specified by a document object. * * @param inDocument the document object representing the page */ this.contains = function(inDocument) { var urlParts = parseUri(unescape(inDocument.location.href)); var path = urlParts.path .replace(/^\//, "") .replace(/\/$/, ""); if (!this.pathRegexp.test(path)) { return false; } for (var paramName in this.paramRegexps) { var paramRegexp = this.paramRegexps[paramName]; if (!paramRegexp.test(urlParts.queryKey[paramName])) { return false; } } if (!this.pageContent(inDocument)) { return false; } return true; } this.getUIElements = function() { var uiElements = []; for (var uiElementName in this.uiElements) { uiElements.push(this.uiElements[uiElementName]); } return uiElements; }; /** * Returns a list of UI specifier string stubs representing all UI elements * for this pageset. Stubs contain all required arguments, but leave * argument values blank. Each element stub is paired with the element's * description. * * @return a list of UI specifier string stubs */ this.getUISpecifierStringStubs = function() { var stubs = []; for (var name in this.uiElements) { var uiElement = this.uiElements[name]; var args = {}; for (var i = 0; i < uiElement.args.length; ++i) { args[uiElement.args[i].name] = ''; } var uiSpecifier = new UISpecifier(this.name, uiElement.name, args); stubs.push([ UI_GLOBAL.UI_PREFIX + '=' + uiSpecifier.toString() , uiElement.description ]); } return stubs; } /** * Throws an exception on validation failure. */ this._validate = function(pagesetShorthand) { var msg = "Pageset validation error:\n" + print_r(pagesetShorthand); if (!pagesetShorthand.name) { throw new PagesetException(msg + 'no name specified!'); } if (!pagesetShorthand.description) { throw new PagesetException(msg + 'no description specified!'); } if (!pagesetShorthand.paths && !pagesetShorthand.pathRegexp && !pagesetShorthand.pageContent) { throw new PagesetException(msg + 'no path, pathRegexp, or pageContent specified!'); } }; this.init = function(pagesetShorthand) { this._validate(pagesetShorthand); this.name = pagesetShorthand.name; this.description = pagesetShorthand.description; var pathPrefixRegexp = pagesetShorthand.pathPrefix ? RegExp.escape(pagesetShorthand.pathPrefix) : ""; var pathRegexp = '^' + pathPrefixRegexp; if (pagesetShorthand.paths != undefined) { pathRegexp += '(?:'; for (var i = 0; i < pagesetShorthand.paths.length; ++i) { if (i > 0) { pathRegexp += '|'; } pathRegexp += RegExp.escape(pagesetShorthand.paths[i]); } pathRegexp += ')$'; } else if (pagesetShorthand.pathRegexp) { pathRegexp += '(?:' + pagesetShorthand.pathRegexp + ')$'; } this.pathRegexp = new RegExp(pathRegexp); this.paramRegexps = {}; for (var paramName in pagesetShorthand.paramRegexps) { this.paramRegexps[paramName] = new RegExp(pagesetShorthand.paramRegexps[paramName]); } this.pageContent = pagesetShorthand.pageContent || function() { return true; }; this.uiElements = {}; }; this.init(pagesetShorthand); } /** * Construct the UI map object, and return it. Once the object is instantiated, * it binds to a global variable and will not leave scope. * * @return new UIMap object */ function UIMap() { // the singleton pattern, split into two parts so that "new" can still // be used, in addition to "getInstance()" UIMap.self = this; // need to attach variables directly to the Editor object in order for them // to be in scope for Editor methods if (is_IDE()) { Editor.uiMap = this; Editor.UI_PREFIX = UI_GLOBAL.UI_PREFIX; } this.pagesets = new Object(); /** * pageset[pagesetName] * regexp * elements[elementName] * UIElement */ this.addPageset = function(pagesetShorthand) { try { var pageset = new Pageset(pagesetShorthand); } catch (e) { safe_alert("Could not create pageset from shorthand:\n" + print_r(pagesetShorthand) + "\n" + e.message); return false; } if (this.pagesets[pageset.name]) { safe_alert('Could not add pageset "' + pageset.name + '": a pageset with that name already exists!'); return false; } this.pagesets[pageset.name] = pageset; return true; }; /** * @param pagesetName * @param uiElementShorthand a representation of a UIElement object in * shorthand JSON. */ this.addElement = function(pagesetName, uiElementShorthand) { try { var uiElement = new UIElement(uiElementShorthand); } catch (e) { safe_alert("Could not create UI element from shorthand:\n" + print_r(uiElementShorthand) + "\n" + e.message); return false; } // run the element's unit tests only for the IDE, and only when the // IDE is starting. Make a rough guess as to the latter condition. if (is_IDE() && !editor.selDebugger && !uiElement.test()) { safe_alert('Could not add UI element "' + uiElement.name + '": failed testcases!'); return false; } try { this.pagesets[pagesetName].uiElements[uiElement.name] = uiElement; } catch (e) { safe_alert("Could not add UI element '" + uiElement.name + "' to pageset '" + pagesetName + "':\n" + e.message); return false; } return true; }; /** * Returns the pageset for a given UI specifier string. * * @param uiSpecifierString * @return a pageset object */ this.getPageset = function(uiSpecifierString) { try { var uiSpecifier = new UISpecifier(uiSpecifierString); return this.pagesets[uiSpecifier.pagesetName]; } catch (e) { return null; } } /** * Returns the UIElement that a UISpecifierString or pageset and element * pair refer to. * * @param pagesetNameOrUISpecifierString * @return a UIElement, or null if none is found associated with * uiSpecifierString */ this.getUIElement = function(pagesetNameOrUISpecifierString, uiElementName) { var pagesetName = pagesetNameOrUISpecifierString; if (arguments.length == 1) { var uiSpecifierString = pagesetNameOrUISpecifierString; try { var uiSpecifier = new UISpecifier(uiSpecifierString); pagesetName = uiSpecifier.pagesetName; var uiElementName = uiSpecifier.elementName; } catch (e) { return null; } } try { return this.pagesets[pagesetName].uiElements[uiElementName]; } catch (e) { return null; } }; /** * Returns a list of pagesets that "contains" the provided page, * represented as a document object. Containership is defined by the * Pageset object's contain() method. * * @param inDocument the page to get pagesets for * @return a list of pagesets */ this.getPagesetsForPage = function(inDocument) { var pagesets = []; for (var pagesetName in this.pagesets) { var pageset = this.pagesets[pagesetName]; if (pageset.contains(inDocument)) { pagesets.push(pageset); } } return pagesets; }; /** * Returns a list of all pagesets. * * @return a list of pagesets */ this.getPagesets = function() { var pagesets = []; for (var pagesetName in this.pagesets) { pagesets.push(this.pagesets[pagesetName]); } return pagesets; }; /** * Returns a list of elements on a page that a given UI specifier string, * maps to. If no elements are mapped to, returns an empty list.. * * @param uiSpecifierString a String that specifies a UI element with * attendant argument values * @param inDocument the document object the specified UI element * appears in * @return a potentially-empty list of elements * specified by uiSpecifierString */ this.getPageElements = function(uiSpecifierString, inDocument) { var locator = this.getLocator(uiSpecifierString); var results = locator ? eval_locator(locator, inDocument) : []; return results; }; /** * Returns the locator string that a given UI specifier string maps to, or * null if it cannot be mapped. * * @param uiSpecifierString */ this.getLocator = function(uiSpecifierString) { try { var uiSpecifier = new UISpecifier(uiSpecifierString); } catch (e) { safe_alert('Could not create UISpecifier for string "' + uiSpecifierString + '": ' + e.message); return null; } var uiElement = this.getUIElement(uiSpecifier.pagesetName, uiSpecifier.elementName); try { return uiElement.getLocator(uiSpecifier.args); } catch (e) { return null; } } /** * Finds and returns a UI specifier string given an element and the page * that it appears on. * * @param pageElement the document element to map to a UI specifier * @param inDocument the document the element appears in * @return a UI specifier string, or false if one cannot be * constructed */ this.getUISpecifierString = function(pageElement, inDocument) { var is_fuzzy_match = BrowserBot.prototype.locateElementByUIElement.is_fuzzy_match; var pagesets = this.getPagesetsForPage(inDocument); for (var i = 0; i < pagesets.length; ++i) { var pageset = pagesets[i]; var uiElements = pageset.getUIElements(); for (var j = 0; j < uiElements.length; ++j) { var uiElement = uiElements[j]; // first test against the generic locator, if there is one. // This should net some performance benefit when recording on // more complicated pages. if (uiElement.getGenericLocator) { var passedTest = false; var results = eval_locator(uiElement.getGenericLocator(), inDocument); for (var i = 0; i < results.length; ++i) { if (results[i] == pageElement) { passedTest = true; break; } } if (!passedTest) { continue; } } var defaultLocators; if (uiElement.isDefaultLocatorConstructionDeferred) { defaultLocators = uiElement.getDefaultLocators(inDocument); } else { defaultLocators = uiElement.defaultLocators; } //safe_alert(print_r(uiElement.defaultLocators)); for (var locator in defaultLocators) { var locatedElements = eval_locator(locator, inDocument); if (locatedElements.length) { var locatedElement = locatedElements[0]; } else { continue; } // use a heuristic to determine whether the element // specified is the "same" as the element we're matching if (is_fuzzy_match) { if (is_fuzzy_match(locatedElement, pageElement)) { return UI_GLOBAL.UI_PREFIX + '=' + new UISpecifier(pageset.name, uiElement.name, defaultLocators[locator]); } } else { if (locatedElement == pageElement) { return UI_GLOBAL.UI_PREFIX + '=' + new UISpecifier(pageset.name, uiElement.name, defaultLocators[locator]); } } // ok, matching the element failed. See if an offset // locator can complete the match. if (uiElement.getOffsetLocator) { for (var k = 0; k < locatedElements.length; ++k) { var offsetLocator = uiElement .getOffsetLocator(locatedElements[k], pageElement); if (offsetLocator) { return UI_GLOBAL.UI_PREFIX + '=' + new UISpecifier(pageset.name, uiElement.name, defaultLocators[locator]) + '->' + offsetLocator; } } } } } } return false; }; /** * Returns a sorted list of UI specifier string stubs representing possible * UI elements for all pagesets, paired the their descriptions. Stubs * contain all required arguments, but leave argument values blank. * * @return a list of UI specifier string stubs */ this.getUISpecifierStringStubs = function() { var stubs = []; var pagesets = this.getPagesets(); for (var i = 0; i < pagesets.length; ++i) { stubs = stubs.concat(pagesets[i].getUISpecifierStringStubs()); } stubs.sort(function(a, b) { if (a[0] < b[0]) { return -1; } return a[0] == b[0] ? 0 : 1; }); return stubs; } } UIMap.getInstance = function() { return (UIMap.self == null) ? new UIMap() : UIMap.self; } //****************************************************************************** // Rollups /** * The Command object isn't available in the Selenium RC. We introduce an * object with the identical constructor. In the IDE, this will be redefined, * which is just fine. * * @param command * @param target * @param value */ if (typeof(Command) == 'undefined') { function Command(command, target, value) { this.command = command != null ? command : ''; this.target = target != null ? target : ''; this.value = value != null ? value : ''; } } /** * A CommandMatcher object matches commands during the application of a * RollupRule. It's specified with a shorthand format, for example: * * new CommandMatcher({ * command: 'click' * , target: 'ui=allPages::.+' * }) * * which is intended to match click commands whose target is an element in the * allPages PageSet. The matching expressions are given as regular expressions; * in the example above, the command must be "click"; "clickAndWait" would be * acceptable if 'click.*' were used. Here's a more complete example: * * new CommandMatcher({ * command: 'type' * , target: 'ui=loginPages::username()' * , value: '.+_test' * , updateArgs: function(command, args) { * args.username = command.value; * } * }) * * Here, the command and target are fixed, but there is variability in the * value of the command. When a command matches, the username is saved to the * arguments object. */ function CommandMatcher(commandMatcherShorthand) { /** * Ensure the shorthand notation used to initialize the CommandMatcher has * all required values. * * @param commandMatcherShorthand an object containing information about * the CommandMatcher */ this.validate = function(commandMatcherShorthand) { var msg = "CommandMatcher validation error:\n" + print_r(commandMatcherShorthand); if (!commandMatcherShorthand.command) { throw new CommandMatcherException(msg + 'no command specified!'); } if (!commandMatcherShorthand.target) { throw new CommandMatcherException(msg + 'no target specified!'); } if (commandMatcherShorthand.minMatches && commandMatcherShorthand.maxMatches && commandMatcherShorthand.minMatches > commandMatcherShorthand.maxMatches) { throw new CommandMatcherException(msg + 'minMatches > maxMatches!'); } }; /** * Initialize this object. * * @param commandMatcherShorthand an object containing information used to * initialize the CommandMatcher */ this.init = function(commandMatcherShorthand) { this.validate(commandMatcherShorthand); this.command = commandMatcherShorthand.command; this.target = commandMatcherShorthand.target; this.value = commandMatcherShorthand.value || null; this.minMatches = commandMatcherShorthand.minMatches || 1; this.maxMatches = commandMatcherShorthand.maxMatches || 1; this.updateArgs = commandMatcherShorthand.updateArgs || function(command, args) { return args; }; }; /** * Determines whether a given command matches. Updates args by "reference" * and returns true if it does; return false otherwise. * * @param command the command to attempt to match */ this.isMatch = function(command) { var re = new RegExp('^' + this.command + '$'); if (! re.test(command.command)) { return false; } re = new RegExp('^' + this.target + '$'); if (! re.test(command.target)) { return false; } if (this.value != null) { re = new RegExp('^' + this.value + '$'); if (! re.test(command.value)) { return false; } } // okay, the command matches return true; }; // initialization this.init(commandMatcherShorthand); } function RollupRuleException(message) { this.message = message; this.name = 'RollupRuleException'; } function RollupRule(rollupRuleShorthand) { /** * Ensure the shorthand notation used to initialize the RollupRule has all * required values. * * @param rollupRuleShorthand an object containing information about the * RollupRule */ this.validate = function(rollupRuleShorthand) { var msg = "RollupRule validation error:\n" + print_r(rollupRuleShorthand); if (!rollupRuleShorthand.name) { throw new RollupRuleException(msg + 'no name specified!'); } if (!rollupRuleShorthand.description) { throw new RollupRuleException(msg + 'no description specified!'); } // rollupRuleShorthand.args is optional if (!rollupRuleShorthand.commandMatchers && !rollupRuleShorthand.getRollup) { throw new RollupRuleException(msg + 'no command matchers specified!'); } if (!rollupRuleShorthand.expandedCommands && !rollupRuleShorthand.getExpandedCommands) { throw new RollupRuleException(msg + 'no expanded commands specified!'); } return true; }; /** * Initialize this object. * * @param rollupRuleShorthand an object containing information used to * initialize the RollupRule */ this.init = function(rollupRuleShorthand) { this.validate(rollupRuleShorthand); this.name = rollupRuleShorthand.name; this.description = rollupRuleShorthand.description; this.pre = rollupRuleShorthand.pre || ''; this.post = rollupRuleShorthand.post || ''; this.alternateCommand = rollupRuleShorthand.alternateCommand; this.args = rollupRuleShorthand.args || []; if (rollupRuleShorthand.commandMatchers) { // construct the rule from the list of CommandMatchers this.commandMatchers = []; var matchers = rollupRuleShorthand.commandMatchers; for (var i = 0; i < matchers.length; ++i) { if (matchers[i].updateArgs && this.args.length == 0) { // enforce metadata for arguments var msg = "RollupRule validation error:\n" + print_r(rollupRuleShorthand) + 'no argument metadata provided!'; throw new RollupRuleException(msg); } this.commandMatchers.push(new CommandMatcher(matchers[i])); } // returns false if the rollup doesn't match, or a rollup command // if it does. If returned, the command contains the // replacementIndexes property, which indicates which commands it // substitutes for. this.getRollup = function(commands) { // this is a greedy matching algorithm var replacementIndexes = []; var commandMatcherQueue = this.commandMatchers; var matchCount = 0; var args = {}; for (var i = 0, j = 0; i < commandMatcherQueue.length;) { var matcher = commandMatcherQueue[i]; if (j >= commands.length) { // we've run out of commands! If the remaining matchers // do not have minMatches requirements, this is a // match. Otherwise, it's not. if (matcher.minMatches > 0) { return false; } ++i; matchCount = 0; // unnecessary, but let's be consistent } else { if (matcher.isMatch(commands[j])) { ++matchCount; if (matchCount == matcher.maxMatches) { // exhausted this matcher's matches ... move on // to next matcher ++i; matchCount = 0; } args = matcher.updateArgs(commands[j], args); replacementIndexes.push(j); ++j; // move on to next command } else { //alert(matchCount + ', ' + matcher.minMatches); if (matchCount < matcher.minMatches) { return false; } // didn't match this time, but we've satisfied the // requirements already ... move on to next matcher ++i; matchCount = 0; // still gonna look at same command } } } var rollup; if (this.alternateCommand) { rollup = new Command(this.alternateCommand, commands[0].target, commands[0].value); } else { rollup = new Command('rollup', this.name); rollup.value = to_kwargs(args); } rollup.replacementIndexes = replacementIndexes; return rollup; }; } else { this.getRollup = function(commands) { var result = rollupRuleShorthand.getRollup(commands); if (result) { var rollup = new Command( result.command , result.target , result.value ); rollup.replacementIndexes = result.replacementIndexes; return rollup; } return false; }; } this.getExpandedCommands = function(kwargs) { var commands = []; var expandedCommands = (rollupRuleShorthand.expandedCommands ? rollupRuleShorthand.expandedCommands : rollupRuleShorthand.getExpandedCommands( parse_kwargs(kwargs))); for (var i = 0; i < expandedCommands.length; ++i) { var command = expandedCommands[i]; commands.push(new Command( command.command , command.target , command.value )); } return commands; }; }; this.init(rollupRuleShorthand); } /** * */ function RollupManager() { // singleton pattern RollupManager.self = this; this.init = function() { this.rollupRules = {}; if (is_IDE()) { Editor.rollupManager = this; } }; /** * Adds a new RollupRule to the repository. Returns true on success, or * false if the rule couldn't be added. * * @param rollupRuleShorthand shorthand JSON specification of the new * RollupRule, possibly including CommandMatcher * shorthand too. * @return true if the rule was added successfully, * false otherwise. */ this.addRollupRule = function(rollupRuleShorthand) { try { var rule = new RollupRule(rollupRuleShorthand); this.rollupRules[rule.name] = rule; } catch(e) { smart_alert("Could not create RollupRule from shorthand:\n\n" + e.message); return false; } return true; }; /** * Returns a RollupRule by name. * * @param rollupName the name of the rule to fetch * @return the RollupRule, or null if it isn't found. */ this.getRollupRule = function(rollupName) { return (this.rollupRules[rollupName] || null); }; /** * Returns a list of name-description pairs for use in populating the * auto-populated target dropdown in the IDE. Rules that have an alternate * command defined are not included in the list, as they are not bona-fide * rollups. * * @return a list of name-description pairs */ this.getRollupRulesForDropdown = function() { var targets = []; var names = keys(this.rollupRules).sort(); for (var i = 0; i < names.length; ++i) { var name = names[i]; if (this.rollupRules[name].alternateCommand) { continue; } targets.push([ name, this.rollupRules[name].description ]); } return targets; }; /** * Applies all rules to the current editor commands, asking the user in * each case if it's okay to perform the replacement. The rules are applied * repeatedly until there are no more matches. The algorithm should * remember when the user has declined a replacement, and not ask to do it * again. * * @return the list of commands with rollup replacements performed */ this.applyRollupRules = function() { var commands = editor.getTestCase().commands; var blacklistedRollups = {}; // so long as rollups were performed, we need to keep iterating through // the commands starting at the beginning, because further rollups may // potentially be applied on the newly created ones. while (true) { var performedRollup = false; for (var i = 0; i < commands.length; ++i) { // iterate through commands for (var rollupName in this.rollupRules) { var rule = this.rollupRules[rollupName]; var rollup = rule.getRollup(commands.slice(i)); if (rollup) { // since we passed in a sliced version of the commands // array to the getRollup() method, we need to re-add // the offset to the replacementIndexes var k = 0; for (; k < rollup.replacementIndexes.length; ++k) { rollup.replacementIndexes[k] += i; } // build the confirmation message var msg = "Perform the following command rollup?\n\n"; for (k = 0; k < rollup.replacementIndexes.length; ++k) { var replacementIndex = rollup.replacementIndexes[k]; var command = commands[replacementIndex]; msg += '[' + replacementIndex + ']: '; msg += command + "\n"; } msg += "\n"; msg += rollup; // check against blacklisted rollups if (blacklistedRollups[msg]) { continue; } // highlight the potentially replaced rows for (k = 0; k < commands.length; ++k) { var command = commands[k]; command.result = ''; if (rollup.replacementIndexes.indexOf(k) != -1) { command.selectedForReplacement = true; } editor.view.rowUpdated(replacementIndex); } // get confirmation from user if (confirm(msg)) { // perform rollup var deleteRanges = []; var replacementIndexes = rollup.replacementIndexes; for (k = 0; k < replacementIndexes.length; ++k) { // this is expected to be list of ranges. A // range has a start, and a list of commands. // The deletion only checks the length of the // command list. deleteRanges.push({ start: replacementIndexes[k] , commands: [ 1 ] }); } editor.view.executeAction(new TreeView .DeleteCommandAction(editor.view,deleteRanges)); editor.view.insertAt(i, rollup); performedRollup = true; } else { // cleverly remember not to try this rollup again blacklistedRollups[msg] = true; } // unhighlight for (k = 0; k < commands.length; ++k) { commands[k].selectedForReplacement = false; editor.view.rowUpdated(k); } } } } if (!performedRollup) { break; } } return commands; }; this.init(); } RollupManager.getInstance = function() { return (RollupManager.self == null) ? new RollupManager() : RollupManager.self; }