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