// sample UI element mapping definition. This is for http://alistapart.com/, // a particularly well structured site on web design principles. // in general, the map should capture structural aspects of the system, instead // of "content". In other words, interactive elements / assertible elements // that can be counted on to always exist should be defined here. Content - // for example text or a link that appears in a blog entry - is always liable // to change, and will not be fun to represent in this way. You probably don't // want to be testing specific content anyway. // create the UI mapping object. THIS IS THE MOST IMPORTANT PART - DON'T FORGET // TO DO THIS! In order for it to come into play, a user extension must // construct the map in this way. var myMap = new UIMap(); // any values which may appear multiple times can be defined as variables here. // For example, here we're enumerating a list of top level topics that will be // used as default argument values for several UI elements. Check out how // this variable is referenced further down. var topics = [ 'Code', 'Content', 'Culture', 'Design', 'Process', 'User Science' ]; // map subtopics to their parent topics var subtopics = { 'Browsers': 'Code' , 'CSS': 'Code' , 'Flash': 'Code' , 'HTML and XHTML': 'Code' , 'Scripting': 'Code' , 'Server Side': 'Code' , 'XML': 'Code' , 'Brand Arts': 'Content' , 'Community': 'Content' , 'Writing': 'Content' , 'Industry': 'Culture' , 'Politics and Money': 'Culture' , 'State of the Web': 'Culture' , 'Graphic Design': 'Design' , 'User Interface Design': 'Design' , 'Typography': 'Design' , 'Layout': 'Design' , 'Business': 'Process' , 'Creativity': 'Process' , 'Project Management and Workflow': 'Process' , 'Accessibility': 'User Science' , 'Information Architecture': 'User Science' , 'Usability': 'User Science' }; // define UI elements common for all pages. This regular expression does the // trick. '^' is automatically prepended, and '$' is automatically postpended. // Please note that because the regular expression is being represented as a // string, all backslashes must be escaped with an additional backslash. Also // note that the URL being matched will always have any trailing forward slash // stripped. myMap.addPageset({ name: 'allPages' , description: 'all alistapart.com pages' , pathRegexp: '.*' }); myMap.addElement('allPages', { name: 'masthead' // the description should be short and to the point, usually no longer than // a single line , description: 'top level image link to site homepage' // make sure the function returns the XPath ... it's easy to leave out the // "return" statement by accident! , locator: "xpath=//*[@id='masthead']/a/img" , testcase1: { xhtml: '<h1 id="masthead"><a><img expected-result="1" /></a></h1>' } }); myMap.addElement('allPages', { // be VERY CAREFUL to include commas in the correct place. Missing commas // and extra commas can cause lots of headaches when debugging map // definition files!!! name: 'current_issue' , description: 'top level link to issue currently being browsed' , locator: "//div[@id='ish']/a" , testcase1: { xhtml: '<div id="ish"><a expected-result="1"></a></div>' } }); myMap.addElement('allPages', { name: 'section' , description: 'top level link to articles section' , args: [ { name: 'section' , description: 'the name of the section' , defaultValues: [ 'articles' , 'topics' , 'about' , 'contact' , 'contribute' , 'feed' ] } ] // getXPath has been deprecated by getLocator, but verify backward // compatability here , getXPath: function(args) { return "//li[@id=" + args.section.quoteForXPath() + "]/a"; } , testcase1: { args: { section: 'feed' } , xhtml: '<ul><li id="feed"><a expected-result="1" /></li></ul>' } }); myMap.addElement('allPages', { name: 'search_box' , description: 'site search input field' // xpath has been deprecated by locator, but verify backward compatability , xpath: "//input[@id='search']" , testcase1: { xhtml: '<input id="search" expected-result="1" />' } }); myMap.addElement('allPages', { name: 'search_discussions' , description: 'site search include discussions checkbox' , locator: 'incdisc' , testcase1: { xhtml: '<input id="incdisc" expected-result="1" />' } }); myMap.addElement('allPages', { name: 'search_submit' , description: 'site search submission button' , locator: 'submit' , testcase1: { xhtml: '<input id="submit" expected-result="1" />' } }); myMap.addElement('allPages', { name: 'topics' , description: 'sidebar links to topic categories' , args: [ { name: 'topic' , description: 'the name of the topic' , defaultValues: topics } ] , getLocator: function(args) { return "//div[@id='topiclist']/ul/li" + "/a[text()=" + args.topic.quoteForXPath() + "]"; } , testcase1: { args: { topic: 'foo' } , xhtml: '<div id="topiclist"><ul><li>' + '<a expected-result="1">foo</a>' + '</li></ul></div>' } }); myMap.addElement('allPages', { name: 'copyright' , description: 'footer link to copyright page' , getLocator: function(args) { return "//span[@class='copyright']/a"; } , testcase1: { xhtml: '<span class="copyright"><a expected-result="1" /></span>' } }); // define UI elements for the homepage, i.e. "http://alistapart.com/", and // magazine issue pages, i.e. "http://alistapart.com/issues/234". myMap.addPageset({ name: 'issuePages' , description: 'pages including magazine issues' , pathRegexp: '(issues/.+)?' }); myMap.addElement('issuePages', { name: 'article' , description: 'front or issue page link to article' , args: [ { name: 'index' , description: 'the index of the article' // an array of default values for the argument. A default // value is one that is passed to the getXPath() method of // the container UIElement object when trying to build an // element locator. // // range() may be used to count easily. Remember though that // the ending value does not include the right extreme; for // example range(1, 5) counts from 1 to 4 only. , defaultValues: range(1, 5) } ] , getLocator: function(args) { return "//div[@class='item'][" + args.index + "]/h4/a"; } }); myMap.addElement('issuePages', { name: 'author' , description: 'article author link' , args: [ { name: 'index' , description: 'the index of the author, by article' , defaultValues: range(1, 5) } ] , getLocator: function(args) { return "//div[@class='item'][" + args.index + "]/h5/a"; } }); myMap.addElement('issuePages', { name: 'store' , description: 'alistapart.com store link' , locator: "//ul[@id='banners']/li/a[@title='ALA Store']/img" }); myMap.addElement('issuePages', { name: 'special_article' , description: "editor's choice article link" , locator: "//div[@id='choice']/h4/a" }); myMap.addElement('issuePages', { name: 'special_author' , description: "author link of editor's choice article" , locator: "//div[@id='choice']/h5/a" }); // define UI elements for the articles page, i.e. // "http://alistapart.com/articles" myMap.addPageset({ name: 'articleListPages' , description: 'page with article listings' , paths: [ 'articles' ] }); myMap.addElement('articleListPages', { name: 'issue' , description: 'link to issue' , args: [ { name: 'index' , description: 'the index of the issue on the page' , defaultValues: range(1, 10) } ] , getLocator: function(args) { return "//h2[@class='ishinfo'][" + args.index + ']/a'; } , genericLocator: "//h2[@class='ishinfo']/a" }); myMap.addElement('articleListPages', { name: 'article' , description: 'link to article, by issue and article number' , args: [ { name: 'issue_index' , description: "the index of the article's issue on the page; " + 'typically five per page' , defaultValues: range(1, 6) } , { name: 'article_index' , description: 'the index of the article within the issue; ' + 'typically two per issue' , defaultValues: range(1, 5) } ] , getLocator: function(args) { var xpath = "//h2[@class='ishinfo'][" + (args.issue_index || 1) + ']' + "/following-sibling::div[@class='item']" + '[' + (args.article_index || 1) + "]/h3[@class='title']/a"; return xpath; } , genericLocator: "//h2[@class='ishinfo']" + "/following-sibling::div[@class='item']/h3[@class='title']/a" }); myMap.addElement('articleListPages', { name: 'author' , description: 'article author link, by issue and article' , args: [ { name: 'issue_index' , description: "the index of the article's issue on the page; \ typically five per page" , defaultValues: range(1, 6) } , { name: 'article_index' , description: "the index of the article within the issue; \ typically two articles per issue" , defaultValues: range(1, 3) } ] // this XPath uses the "following-sibling" axis. The div elements for // the articles in an issue are not children, but siblings of the h2 // element identifying the article. , getLocator: function(args) { var xpath = "//h2[@class='ishinfo'][" + (args.issue_index || 1) + ']' + "/following-sibling::div[@class='item']" + '[' + (args.article_index || 1) + "]/h4[@class='byline']/a"; return xpath; } , genericLocator: "//h2[@class='ishinfo']" + "/following-sibling::div[@class='item']/h4[@class='byline']/a" }); myMap.addElement('articleListPages', { name: 'next_page' , description: 'link to next page of articles (older)' , locator: "//a[contains(text(),'Next page')]" }); myMap.addElement('articleListPages', { name: 'previous_page' , description: 'link to previous page of articles (newer)' , locator: "//a[contains(text(),'Previous page')]" }); // define UI elements for specific article pages, i.e. // "http://alistapart.com/articles/culturalprobe" myMap.addPageset({ name: 'articlePages' , description: 'pages for actual articles' , pathRegexp: 'articles/.+' }); myMap.addElement('articlePages', { name: 'title' , description: 'article title loop-link' , locator: "//div[@id='content']/h1[@class='title']/a" }); myMap.addElement('articlePages', { name: 'author' , description: 'article author link' , locator: "//div[@id='content']/h3[@class='byline']/a" }); myMap.addElement('articlePages', { name: 'article_topics' , description: 'links to topics under which article is published, before \ article content' , args: [ { name: 'topic' , description: 'the name of the topic' , defaultValues: keys(subtopics) } ] , getLocator: function(args) { return "//ul[@id='metastuff']/li/a" + "[@title=" + args.topic.quoteForXPath() + "]"; } }); myMap.addElement('articlePages', { name: 'discuss' , description: 'link to article discussion area, before article content' , locator: "//ul[@id='metastuff']/li[@class='discuss']/p/a" }); myMap.addElement('articlePages', { name: 'related_topics' , description: 'links to topics under which article is published, after \ article content' , args: [ { name: 'topic' , description: 'the name of the topic' , defaultValues: keys(subtopics) } ] , getLocator: function(args) { return "//div[@id='learnmore']/p/a" + "[@title=" + args.topic.quoteForXPath() + "]"; } }); myMap.addElement('articlePages', { name: 'join_discussion' , description: 'link to article discussion area, after article content' , locator: "//div[@class='discuss']/p/a" }); myMap.addPageset({ name: 'topicListingPages' , description: 'top level listing of topics' , paths: [ 'topics' ] }); myMap.addElement('topicListingPages', { name: 'topic' , description: 'link to topic category' , args: [ { name: 'topic' , description: 'the name of the topic' , defaultValues: topics } ] , getLocator: function(args) { return "//div[@id='content']/h2/a" + "[text()=" + args.topic.quoteForXPath() + "]"; } }); myMap.addElement('topicListingPages', { name: 'subtopic' , description: 'link to subtopic category' , args: [ { name: 'subtopic' , description: 'the name of the subtopic' , defaultValues: keys(subtopics) } ] , getLocator: function(args) { return "//div[@id='content']" + "/descendant::a[text()=" + args.subtopic.quoteForXPath() + "]"; } }); // the following few subtopic page UI elements are very similar. Define UI // elements for the code page, which is a subpage under topics, i.e. // "http://alistapart.com/topics/code/" myMap.addPageset({ name: 'subtopicListingPages' , description: 'pages listing subtopics' , pathPrefix: 'topics/' , paths: [ 'code' , 'content' , 'culture' , 'design' , 'process' , 'userscience' ] }); myMap.addElement('subtopicListingPages', { name: 'subtopic' , description: 'link to a subtopic category' , args: [ { name: 'subtopic' , description: 'the name of the subtopic' , defaultValues: keys(subtopics) } ] , getLocator: function(args) { return "//div[@id='content']/h2" + "/a[text()=" + args.subtopic.quoteForXPath() + "]"; } }); // subtopic articles page myMap.addPageset({ name: 'subtopicArticleListingPages' , description: 'pages listing the articles for a given subtopic' , pathRegexp: 'topics/[^/]+/.+' }); myMap.addElement('subtopicArticleListingPages', { name: 'article' , description: 'link to a subtopic article' , args: [ { name: 'index' , description: 'the index of the article' , defaultValues: range(1, 51) // the range seems unlimited ... } ] , getLocator: function(args) { return "//div[@id='content']/div[@class='item']" + "[" + args.index + "]/h3/a"; } , testcase1: { args: { index: 2 } , xhtml: '<div id="content"><div class="item" /><div class="item">' + '<h3><a expected-result="1" /></h3></div></div>' } }); myMap.addElement('subtopicArticleListingPages', { name: 'author' , description: "link to a subtopic article author's page" , args: [ { name: 'article_index' , description: 'the index of the authored article' , defaultValues: range(1, 51) } , { name: 'author_index' , description: 'the index of the author when there are multiple' , defaultValues: range(1, 4) } ] , getLocator: function(args) { return "//div[@id='content']/div[@class='item'][" + args.article_index + "]/h4/a[" + (args.author_index ? args.author_index : '1') + ']'; } }); myMap.addElement('subtopicArticleListingPages', { name: 'issue' , description: 'link to issue a subtopic article appears in' , args: [ { name: 'index' , description: 'the index of the subtopic article' , defaultValues: range(1, 51) } ] , getLocator: function(args) { return "//div[@id='content']/div[@class='item']" + "[" + args.index + "]/h5/a"; } }); myMap.addPageset({ name: 'aboutPages' , description: 'the website about page' , paths: [ 'about' ] }); myMap.addElement('aboutPages', { name: 'crew' , description: 'link to site crew member bio or personal website' , args: [ { name: 'role' , description: 'the role of the crew member' , defaultValues: [ 'ALA Crew' , 'Support' , 'Emeritus' ] } , { name: 'role_index' , description: 'the index of the member within the role' , defaultValues: range(1, 20) } , { name: 'member_index' , description: 'the index of the member within the role title' , defaultValues: range(1, 5) } ] , getLocator: function(args) { // the first role is kind of funky, and requires a conditional to // build the XPath correctly. Its header looks like this: // // <h3> // <span class="caps">ALA 4</span>.0 <span class="caps">CREW</span> // </h3> // // This kind of complexity is a little daunting, but you can see // how the format can handle it relatively easily and concisely. if (args.role == 'ALA Crew') { var selector = "descendant::text()='CREW'"; } else { var selector = "text()=" + args.role.quoteForXPath(); } var xpath = "//div[@id='secondary']/h3[" + selector + ']' + "/following-sibling::dl/dt[" + (args.role_index || 1) + ']' + '/a[' + (args.member_index || '1') + ']'; return xpath; } }); myMap.addPageset({ name: 'searchResultsPages' , description: 'pages listing search results' , paths: [ 'search' ] }); myMap.addElement('searchResultsPages', { name: 'result_link' , description: 'search result link' , args: [ { name: 'index' , description: 'the index of the search result' , defaultValues: range(1, 11) } ] , getLocator: function(args) { return "//div[@id='content']/ul[" + args.index + ']/li/h3/a'; } }); myMap.addElement('searchResultsPages', { name: 'more_results_link' , description: 'next or previous results link at top or bottom of page' , args: [ { name: 'direction' , description: 'next or previous results page' // demonstrate a method which acquires default values from the // document object. Such default values may contain EITHER commas // OR equals signs, but NOT BOTH. , getDefaultValues: function(inDocument) { var defaultValues = []; var divs = inDocument.getElementsByTagName('div'); for (var i = 0; i < divs.length; ++i) { if (divs[i].className == 'pages') { break; } } var links = divs[i].getElementsByTagName('a'); for (i = 0; i < links.length; ++i) { defaultValues.push(links[i].innerHTML .replace(/^\xab\s*/, "") .replace(/\s*\bb$/, "") .replace(/\s*\d+$/, "")); } return defaultValues; } } , { name: 'position' , description: 'position of the link' , defaultValues: ['top', 'bottom'] } ] , getLocator: function(args) { return "//div[@id='content']/div[@class='pages'][" + (args.position == 'top' ? '1' : '2') + ']' + "/a[contains(text(), " + (args.direction ? args.direction.quoteForXPath() : undefined) + ")]"; } }); myMap.addPageset({ name: 'commentsPages' , description: 'pages listing comments made to an article' , pathRegexp: 'comments/.+' }); myMap.addElement('commentsPages', { name: 'article_link' , description: 'link back to the original article' , locator: "//div[@id='content']/h1[@class='title']/a" }); myMap.addElement('commentsPages', { name: 'comment_link' , description: 'same-page link to comment' , args: [ { name: 'index' , description: 'the index of the comment' , defaultValues: range(1, 11) } ] , getLocator: function(args) { return "//div[@class='content']/div[contains(@class, 'comment')]" + '[' + args.index + ']/h4/a[2]'; } }); myMap.addElement('commentsPages', { name: 'paging_link' , description: 'links to more pages of comments' , args: [ { name: 'dest' , description: 'the destination page' , defaultValues: ['next', 'prev'].concat(range(1, 16)) } , { name: 'position' , description: 'position of the link' , defaultValues: ['top', 'bottom'] } ] , getLocator: function(args) { var dest = args.dest; var xpath = "//div[@id='content']/div[@class='pages']" + '[' + (args.position == 'top' ? '1' : '2') + ']/p'; if (dest == 'next' || dest == 'prev') { xpath += "/a[contains(text(), " + dest.quoteForXPath() + ")]"; } else { xpath += "/a[text()=" + dest.quoteForXPath() + "]"; } return xpath; } }); myMap.addPageset({ name: 'authorPages' , description: 'personal pages for each author' , pathRegexp: 'authors/[a-z]/.+' }); myMap.addElement('authorPages', { name: 'article' , description: "link to article written by this author.\n" + 'This description has a line break.' , args: [ { name: 'index' , description: 'index of the article on the page' , defaultValues: range(1, 11) } ] , getLocator: function(args) { var index = args.index; // try out the CSS locator! //return "//h4[@class='title'][" + index + "]/a"; return 'css=h4.title:nth-child(' + index + ') > a'; } , testcase1: { args: { index: '2' } , xhtml: '<h4 class="title" /><h4 class="title">' + '<a expected-result="1" /></h4>' } }); // test the offset locator. Something like the following can be recorded: // ui=qaPages::content()//a[contains(text(),'May I quote from your articles?')] myMap.addPageset({ name: 'qaPages' , description: 'question and answer pages' , pathRegexp: 'qa' }); myMap.addElement('qaPages', { name: 'content' , description: 'the content pane containing the q&a entries' , locator: "//div[@id='content' and " + "child::h1[text()='Questions and Answers']]" , getOffsetLocator: UIElement.defaultOffsetLocatorStrategy }); myMap.addElement('qaPages', { name: 'last_updated' , description: 'displays the last update date' // demonstrate calling getLocator() for another UI element within a // getLocator(). The former must have already been added to the map. And // obviously, you can't randomly combine different locator types! , locator: myMap.getUIElement('qaPages', 'content').getLocator() + '/p/em' }); //****************************************************************************** var myRollupManager = new RollupManager(); // though the description element is required, its content is free form. You // might want to create a documentation policy as given below, where the pre- // and post-conditions of the rollup are spelled out. // // To take advantage of a "heredoc" like syntax for longer descriptions, // add a backslash to the end of the current line and continue the string on // the next line. myRollupManager.addRollupRule({ name: 'navigate_to_subtopic_article_listing' , description: 'drill down to the listing of articles for a given subtopic \ from the section menu, then the topic itself.' , pre: 'current page contains the section menu (most pages should)' , post: 'navigated to the page listing all articles for a given subtopic' , args: [ { name: 'subtopic' , description: 'the subtopic whose article listing to navigate to' , exampleValues: keys(subtopics) } ] , commandMatchers: [ { command: 'clickAndWait' , target: 'ui=allPages::section\\(section=topics\\)' // must escape parentheses in the the above target, since the // string is being used as a regular expression. Again, backslashes // in strings must be escaped too. } , { command: 'clickAndWait' , target: 'ui=topicListingPages::topic\\(.+' } , { command: 'clickAndWait' , target: 'ui=subtopicListingPages::subtopic\\(.+' , updateArgs: function(command, args) { // don't bother stripping the "ui=" prefix from the locator // here; we're just using UISpecifier to parse the args out var uiSpecifier = new UISpecifier(command.target); args.subtopic = uiSpecifier.args.subtopic; return args; } } ] , getExpandedCommands: function(args) { var commands = []; var topic = subtopics[args.subtopic]; var subtopic = args.subtopic; commands.push({ command: 'clickAndWait' , target: 'ui=allPages::section(section=topics)' }); commands.push({ command: 'clickAndWait' , target: 'ui=topicListingPages::topic(topic=' + topic + ')' }); commands.push({ command: 'clickAndWait' , target: 'ui=subtopicListingPages::subtopic(subtopic=' + subtopic + ')' }); commands.push({ command: 'verifyLocation' , target: 'regexp:.+/topics/.+/.+' }); return commands; } }); myRollupManager.addRollupRule({ name: 'replace_click_with_clickAndWait' , description: 'replaces commands where a click was detected with \ clickAndWait instead' , alternateCommand: 'clickAndWait' , commandMatchers: [ { command: 'click' , target: 'ui=subtopicArticleListingPages::article\\(.+' } ] , expandedCommands: [] }); myRollupManager.addRollupRule({ name: 'navigate_to_subtopic_article' , description: 'navigate to an article listed under a subtopic.' , pre: 'current page contains the section menu (most pages should)' , post: 'navigated to an article page' , args: [ { name: 'subtopic' , description: 'the subtopic whose article listing to navigate to' , exampleValues: keys(subtopics) } , { name: 'index' , description: 'the index of the article in the listing' , exampleValues: range(1, 11) } ] , commandMatchers: [ { command: 'rollup' , target: 'navigate_to_subtopic_article_listing' , value: 'subtopic\\s*=.+' , updateArgs: function(command, args) { var args1 = parse_kwargs(command.value); args.subtopic = args1.subtopic; return args; } } , { command: 'clickAndWait' , target: 'ui=subtopicArticleListingPages::article\\(.+' , updateArgs: function(command, args) { var uiSpecifier = new UISpecifier(command.target); args.index = uiSpecifier.args.index; return args; } } ] /* // this is pretty much equivalent to the commandMatchers immediately above. // Seems more verbose and less expressive, doesn't it? But sometimes you // might prefer the flexibility of a function. , getRollup: function(commands) { if (commands.length >= 2) { command1 = commands[0]; command2 = commands[1]; var args1 = parse_kwargs(command1.value); try { var uiSpecifier = new UISpecifier(command2.target .replace(/^ui=/, '')); } catch (e) { return false; } if (command1.command == 'rollup' && command1.target == 'navigate_to_subtopic_article_listing' && args1.subtopic && command2.command == 'clickAndWait' && uiSpecifier.pagesetName == 'subtopicArticleListingPages' && uiSpecifier.elementName == 'article') { var args = { subtopic: args1.subtopic , index: uiSpecifier.args.index }; return { command: 'rollup' , target: this.name , value: to_kwargs(args) , replacementIndexes: [ 0, 1 ] }; } } return false; } */ , getExpandedCommands: function(args) { var commands = []; commands.push({ command: 'rollup' , target: 'navigate_to_subtopic_article_listing' , value: to_kwargs({ subtopic: args.subtopic }) }); var uiSpecifier = new UISpecifier( 'subtopicArticleListingPages' , 'article' , { index: args.index }); commands.push({ command: 'clickAndWait' , target: 'ui=' + uiSpecifier.toString() }); commands.push({ command: 'verifyLocation' , target: 'regexp:.+/articles/.+' }); return commands; } });