//****************************************************************************** | |
// Globals, including constants | |
var UI_GLOBAL = { | |
UI_PREFIX: 'ui' | |
, XHTML_DOCTYPE: '<!DOCTYPE html PUBLIC ' | |
+ '"-//W3C//DTD XHTML 1.0 Strict//EN" ' | |
+ '"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">' | |
, 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: '<div id="topiclist">' | |
// + '<ul><li><a expected-result="1">foo</a></li></ul>' | |
// + '</div>' | |
// } | |
// // 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 inDocument the document object to pass to the getDefaultValues() | |
* method of each argument. | |
* | |
* @return a list of associative arrays containing key value pairs | |
*/ | |
this.permuteArgs = function(args, inDocument) { | |
if (args.length == 0) { | |
return []; | |
} | |
var permutations = []; | |
var arg = args[0]; | |
var remainingArgs = args.slice(1); | |
var subsequentPermutations = this.permuteArgs(remainingArgs, | |
inDocument); | |
var defaultValues = arg.getDefaultValues(inDocument); | |
// skip arguments for which no default values are defined. If the | |
// argument is a required one, then no permutations are possible. | |
if (defaultValues.length == 0) { | |
if (arg.required) { | |
return []; | |
} | |
else { | |
return subsequentPermutations; | |
} | |
} | |
for (var i = 0; i < defaultValues.length; ++i) { | |
var value = defaultValues[i]; | |
var permutation; | |
if (subsequentPermutations.length == 0) { | |
permutation = {}; | |
permutation[arg.name] = value + ""; | |
permutations.push(permutation); | |
} | |
else { | |
for (var j = 0; j < subsequentPermutations.length; ++j) { | |
permutation = clone(subsequentPermutations[j]); | |
permutation[arg.name] = value + ""; | |
permutations.push(permutation); | |
} | |
} | |
} | |
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 + '<html xmlns="' | |
+ UI_GLOBAL.XHTML_XMLNS + '">' + testcase.xhtml + '</html>'; | |
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.string || 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. Parse the locator to make sure it's | |
// really good. If it doesn't work, fine. | |
try { | |
var locator = this.getLocator(); | |
parse_locator(locator); | |
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; | |
this.required = uiArgumentShorthand.required || false; | |
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; | |
} | |