//******************************************************************************
// 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;
}