1# -*- coding: utf-8 -*- 2# 3# Copyright (C) 2008-2021 Edgewall Software 4# All rights reserved. 5# 6# This software is licensed as described in the file COPYING, which 7# you should have received as part of this distribution. The terms 8# are also available at https://trac.edgewall.org/wiki/TracLicense. 9# 10# This software consists of voluntary contributions made by many 11# individuals. For the exact contribution history, see the revision 12# history and logs, available at https://trac.edgewall.org/log/. 13 14"""The :class:`FunctionalTester` object provides a higher-level interface to 15working with a Trac environment to make test cases more succinct. 16""" 17 18import io 19import re 20 21from trac.tests.functional import internal_error 22from trac.tests.functional.better_twill import tc, b 23from trac.tests.contentgen import random_page, random_sentence, random_word, \ 24 random_unique_camel 25from trac.util.html import tag 26from trac.util.text import to_utf8, unicode_quote 27 28 29class FunctionalTester(object): 30 """Provides a library of higher-level operations for interacting with a 31 test environment. 32 33 It makes assumptions such as knowing what ticket number is next, so 34 avoid doing things manually in a :class:`FunctionalTestCase` when you can. 35 """ 36 37 def __init__(self, url): 38 """Create a :class:`FunctionalTester` for the given Trac URL and 39 Subversion URL""" 40 self.url = url 41 self.ticketcount = 0 42 43 # Connect, and login so we can run tests. 44 self.login('admin') 45 46 def login(self, username): 47 """Login as the given user""" 48 tc.add_auth('', self.url + '/login', username, username) 49 self.go_to_front() 50 tc.find("Login") 51 url = self.url.replace('://', 52 '://{0}:{0}@'.format(unicode_quote(username))) 53 url = '%s/login?referer=%s' % (url, unicode_quote(self.url)) 54 tc.go(url) 55 tc.notfind(internal_error) 56 tc.url(self.url, regexp=False) 57 # We've provided authentication info earlier, so this should 58 # redirect back to the base url. 59 tc.find('logged in as[ \t\n]+<span class="trac-author-user">%s</span>' 60 % username) 61 tc.find("Logout") 62 tc.url(self.url, regexp=False) 63 tc.notfind(internal_error) 64 65 def logout(self): 66 """Logout""" 67 tc.submit('logout', 'logout') 68 tc.notfind(internal_error) 69 tc.notfind('logged in as') 70 71 def create_ticket(self, summary=None, info=None): 72 """Create a new (random) ticket in the test environment. Returns 73 the new ticket number. 74 75 :param summary: 76 may optionally be set to the desired summary 77 :param info: 78 may optionally be set to a dictionary of field value pairs for 79 populating the ticket. ``info['summary']`` overrides summary. 80 81 `summary` and `description` default to randomly-generated values. 82 """ 83 info = info or {} 84 self.go_to_front() 85 tc.follow(r"\bNew Ticket\b") 86 tc.notfind(internal_error) 87 if summary is None: 88 summary = random_sentence(5) 89 tc.formvalue('propertyform', 'field_summary', summary) 90 tc.formvalue('propertyform', 'field_description', random_page()) 91 if 'owner' in info: 92 tc.formvalue('propertyform', 'action', 'create_and_assign') 93 tc.formvalue('propertyform', 94 'action_create_and_assign_reassign_owner', 95 info.pop('owner')) 96 for field, value in info.items(): 97 tc.formvalue('propertyform', 'field_%s' % field, value) 98 tc.submit('submit') 99 tc.notfind(internal_error) 100 # we should be looking at the newly created ticket 101 tc.url('%s/ticket/%s#ticket' % (self.url, self.ticketcount + 1), 102 regexp=False) 103 # Increment self.ticketcount /after/ we've verified that the ticket 104 # was created so a failure does not trigger spurious later 105 # failures. 106 self.ticketcount += 1 107 108 return self.ticketcount 109 110 def quickjump(self, search): 111 """Do a quick search to jump to a page.""" 112 tc.formvalue('search', 'q', search) 113 tc.submit() 114 tc.notfind(internal_error) 115 116 def go_to_url(self, url): 117 if url.startswith('/'): 118 url = self.url + url 119 tc.go(url) 120 tc.url(url, regexp=False) 121 tc.notfind(internal_error) 122 123 def go_to_front(self): 124 """Go to the Trac front page""" 125 self.go_to_url(self.url) 126 127 def go_to_ticket(self, ticketid=None): 128 """Surf to the page for the given ticket ID, or to the NewTicket page 129 if `ticketid` is not specified or is `None`. If `ticketid` is 130 specified, it assumes the ticket exists.""" 131 if ticketid is not None: 132 ticket_url = self.url + '/ticket/%s' % ticketid 133 else: 134 ticket_url = self.url + '/newticket' 135 self.go_to_url(ticket_url) 136 tc.url(ticket_url, regexp=False) 137 138 def go_to_wiki(self, name, version=None): 139 """Surf to the wiki page. By default this will be the latest version 140 of the page. 141 142 :param name: name of the wiki page. 143 :param version: version of the wiki page. 144 """ 145 # Used to go based on a quickjump, but if the wiki pagename isn't 146 # camel case, that won't work. 147 wiki_url = self.url + '/wiki/%s' % name 148 if version: 149 wiki_url += '?version=%s' % version 150 self.go_to_url(wiki_url) 151 152 def go_to_timeline(self): 153 """Surf to the timeline page.""" 154 tc.go(self.url + '/timeline') 155 156 def go_to_view_tickets(self, href='report'): 157 """Surf to the View Tickets page. By default this will be the Reports 158 page, but 'query' can be specified for the `href` argument to support 159 non-default configurations.""" 160 self.go_to_front() 161 tc.follow(r"\bView Tickets\b") 162 tc.url(self.url + '/' + href.lstrip('/'), regexp=False) 163 164 def go_to_query(self): 165 """Surf to the custom query page.""" 166 self.go_to_front() 167 tc.follow(r"\bView Tickets\b") 168 tc.follow(r"\bNew Custom Query\b") 169 tc.url(self.url + '/query', regexp=False) 170 171 def go_to_admin(self, panel_label=None): 172 """Surf to the webadmin page. Continue surfing to a specific 173 admin page if `panel_label` is specified.""" 174 self.go_to_front() 175 tc.follow(r"\bAdmin\b") 176 tc.url(self.url + '/admin', regexp=False) 177 if panel_label is not None: 178 tc.follow(r"\b%s\b" % panel_label) 179 180 def go_to_roadmap(self): 181 """Surf to the roadmap page.""" 182 self.go_to_front() 183 tc.follow(r"\bRoadmap\b") 184 tc.url(self.url + '/roadmap', regexp=False) 185 186 def go_to_milestone(self, name): 187 """Surf to the specified milestone page. Assumes milestone exists.""" 188 self.go_to_roadmap() 189 tc.follow(r"\bMilestone:\s+%s\b" % name) 190 tc.url(self.url + '/milestone/%s' % name, regexp=False) 191 192 def go_to_report(self, id, args=None): 193 """Surf to the specified report. 194 195 Assumes the report exists. Report variables will be appended if 196 specified. 197 198 :param id: id of the report 199 :param args: may optionally specify a dictionary of arguments to 200 be encoded as a query string 201 """ 202 report_url = self.url + "/report/%s" % id 203 if args: 204 arglist = [] 205 for param, value in args.items(): 206 arglist.append('%s=%s' % (param.upper(), unicode_quote(value))) 207 report_url += '?' + '&'.join(arglist) 208 self.go_to_url(report_url) 209 210 def go_to_preferences(self, panel_label=None): 211 """Surf to the preferences page. Continue surfing to a specific 212 preferences panel if `panel_label` is specified.""" 213 self.go_to_front() 214 tc.follow(r"\bPreferences\b") 215 tc.url(self.url + '/prefs', regexp=False) 216 if panel_label is not None: 217 tc.follow(r"\b%s\b" % panel_label) 218 219 def add_comment(self, ticketid, comment=None): 220 """Adds a comment to the given ticket ID, assumes ticket exists.""" 221 self.go_to_ticket(ticketid) 222 if comment is None: 223 comment = random_sentence() 224 tc.formvalue('propertyform', 'comment', comment) 225 tc.submit("submit") 226 # Verify we're where we're supposed to be. 227 # The fragment is stripped since Python 2.7.1, see: 228 # https://trac.edgewall.org/ticket/9990#comment:18 229 tc.url(self.url + '/ticket/%s(?:#comment:.*)?$' % ticketid) 230 return comment 231 232 def attach_file_to_ticket(self, ticketid, data=None, filename=None, 233 description=None, replace=False, 234 content_type=None): 235 """Attaches a file to the given ticket id, with random data if none is 236 provided. Assumes the ticket exists. 237 """ 238 self.go_to_ticket(ticketid) 239 tc.click('#attachments .foldable a') 240 return self._attach_file_to_resource('ticket', ticketid, data, 241 filename, description, 242 replace, content_type) 243 244 def clone_ticket(self, ticketid): 245 """Create a clone of the given ticket id using the clone button.""" 246 ticket_url = self.url + '/ticket/%s' % ticketid 247 self.go_to_url(ticket_url) 248 tc.formvalue('clone', 'clone', 'Clone') 249 tc.submit() 250 # we should be looking at the newly created ticket 251 self.ticketcount += 1 252 tc.url('%s/ticket/%s' % (self.url, self.ticketcount), regexp=False) 253 return self.ticketcount 254 255 def create_wiki_page(self, name=None, content=None, comment=None): 256 """Creates a wiki page, with a random unique CamelCase name if none 257 is provided, random content if none is provided and a random comment 258 if none is provided. Returns the name of the wiki page. 259 """ 260 if name is None: 261 name = random_unique_camel() 262 if content is None: 263 content = random_page() 264 self.go_to_wiki(name) 265 tc.find("The page[ \n]+%s[ \n]+does not exist." % tag.strong(name)) 266 267 self.edit_wiki_page(name, content, comment) 268 269 # verify the event shows up in the timeline 270 self.go_to_timeline() 271 tc.formvalue('prefs', 'wiki', True) 272 tc.submit(formname='prefs') 273 tc.find(name + ".*created") 274 275 self.go_to_wiki(name) 276 277 return name 278 279 def edit_wiki_page(self, name, content=None, comment=None): 280 """Edits a wiki page, with random content is none is provided. 281 and a random comment if none is provided. Returns the content. 282 """ 283 if content is None: 284 content = random_page() 285 if comment is None: 286 comment = random_sentence() 287 self.go_to_wiki(name) 288 tc.submit(formname='modifypage') 289 tc.formvalue('edit', 'text', content) 290 tc.formvalue('edit', 'comment', comment) 291 tc.submit('save') 292 tc.url('%s/wiki/%s' % (self.url, name), regexp=False) 293 294 return content 295 296 def attach_file_to_wiki(self, name, data=None, filename=None, 297 description=None, replace=False, 298 content_type=None): 299 """Attaches a file to the given wiki page, with random content if none 300 is provided. Assumes the wiki page exists. 301 """ 302 303 self.go_to_wiki(name) 304 return self._attach_file_to_resource('wiki', name, data, 305 filename, description, 306 replace, content_type) 307 308 def create_milestone(self, name=None, due=None): 309 """Creates the specified milestone, with a random name if none is 310 provided. Returns the name of the milestone. 311 """ 312 if name is None: 313 name = random_unique_camel() 314 milestone_url = self.url + "/admin/ticket/milestones" 315 self.go_to_url(milestone_url) 316 tc.formvalue('addmilestone', 'name', name) 317 if due: 318 # TODO: How should we deal with differences in date formats? 319 tc.formvalue('addmilestone', 'duedate', due) 320 tc.submit() 321 tc.notfind(internal_error) 322 tc.notfind('Milestone .* already exists') 323 tc.url(milestone_url, regexp=False) 324 tc.find(name) 325 326 return name 327 328 def attach_file_to_milestone(self, name, data=None, filename=None, 329 description=None, replace=False, 330 content_type=None): 331 """Attaches a file to the given milestone, with random content if none 332 is provided. Assumes the milestone exists. 333 """ 334 335 self.go_to_milestone(name) 336 return self._attach_file_to_resource('milestone', name, data, 337 filename, description, 338 replace, content_type) 339 340 def create_component(self, name=None, owner=None, description=None): 341 """Creates the specified component, with a random camel-cased name if 342 none is provided. Returns the name.""" 343 if name is None: 344 name = random_unique_camel() 345 component_url = self.url + "/admin/ticket/components" 346 self.go_to_url(component_url) 347 tc.formvalue('addcomponent', 'name', name) 348 if owner is not None: 349 tc.formvalue('addcomponent', 'owner', owner) 350 tc.submit() 351 # Verify the component appears in the component list 352 tc.url(re.escape(component_url) + '#?$') 353 tc.find(name) 354 tc.notfind(internal_error) 355 if description is not None: 356 tc.follow(r"\b%s\b" % name) 357 tc.formvalue('edit', 'description', description) 358 tc.submit('save') 359 tc.url(re.escape(component_url) + '#?$') 360 tc.find("Your changes have been saved.") 361 tc.notfind(internal_error) 362 # TODO: verify the component shows up in the newticket page 363 return name 364 365 def create_enum(self, kind, name=None): 366 """Helper to create the specified enum (used for ``priority``, 367 ``severity``, etc). If no name is given, a unique random word is used. 368 The name is returned. 369 """ 370 if name is None: 371 name = random_unique_camel() 372 enum_url = self.url + "/admin/ticket/" + kind 373 self.go_to_url(enum_url) 374 tc.formvalue('addenum', 'name', name) 375 tc.submit() 376 tc.url(re.escape(enum_url) + '#?$') 377 tc.find(name) 378 tc.notfind(internal_error) 379 return name 380 381 def create_priority(self, name=None): 382 """Create a new priority enum""" 383 return self.create_enum('priority', name) 384 385 def create_resolution(self, name=None): 386 """Create a new resolution enum""" 387 return self.create_enum('resolution', name) 388 389 def create_severity(self, name=None): 390 """Create a new severity enum""" 391 return self.create_enum('severity', name) 392 393 def create_type(self, name=None): 394 """Create a new ticket type enum""" 395 return self.create_enum('type', name) 396 397 def create_version(self, name=None, releasetime=None): 398 """Create a new version. The name defaults to a random camel-cased 399 word if not provided.""" 400 version_admin = self.url + "/admin/ticket/versions" 401 if name is None: 402 name = random_unique_camel() 403 self.go_to_url(version_admin) 404 tc.formvalue('addversion', 'name', name) 405 if releasetime is not None: 406 tc.formvalue('addversion', 'time', releasetime) 407 tc.submit() 408 tc.url(re.escape(version_admin) + '#?$') 409 tc.find(name) 410 tc.notfind(internal_error) 411 return name 412 # TODO: verify releasetime 413 414 def create_report(self, title, query, description): 415 """Create a new report with the given title, query, and description""" 416 self.go_to_front() 417 tc.follow(r"\bView Tickets\b") 418 tc.submit(formname='create_report') 419 tc.find('New Report') 420 tc.notfind(internal_error) 421 tc.formvalue('edit_report', 'title', title) 422 tc.formvalue('edit_report', 'description', description) 423 tc.formvalue('edit_report', 'query', query) 424 tc.submit() 425 reportnum = b.get_url().split('/')[-1] 426 # TODO: verify the url is correct 427 # TODO: verify the report number is correct 428 # TODO: verify the report does not cause an internal error 429 # TODO: verify the title appears on the report list 430 return reportnum 431 432 def ticket_set_milestone(self, ticketid, milestone): 433 """Set the milestone on a given ticket.""" 434 self.go_to_ticket(ticketid) 435 tc.formvalue('propertyform', 'milestone', milestone) 436 tc.submit('submit') 437 # TODO: verify the change occurred. 438 439 def _attach_file_to_resource(self, realm, name, data=None, 440 filename=None, description=None, 441 replace=False, content_type=None): 442 """Attaches a file to a resource. Assumes the resource exists and 443 has already been navigated to.""" 444 445 if data is None: 446 data = random_page() 447 if description is None: 448 description = random_sentence() 449 if filename is None: 450 filename = random_word() 451 452 tc.submit('attachfilebutton', 'attachfile') 453 tc.url('%s/attachment/%s/%s/?action=new' % (self.url, realm, name), 454 regexp=False) 455 fp = io.BytesIO(data.encode('utf-8')) 456 tc.formfile('attachment', 'attachment', filename, 457 content_type=content_type, fp=fp) 458 tc.formvalue('attachment', 'description', description) 459 if replace: 460 tc.formvalue('attachment', 'replace', True) 461 tc.submit(formname='attachment') 462 tc.url('%s/attachment/%s/%s/' % (self.url, realm, name), regexp=False) 463 464 return filename 465