devtools/testlib.py
brancholdstable
changeset 7074 e4580e5f0703
parent 7071 db7608cb32bc
child 7075 4751d77394b1
child 7078 bad26a22fe29
equal deleted inserted replaced
6749:48f468f33704 7074:e4580e5f0703
     1 # copyright 2003-2010 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
     1 # copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
     3 #
     3 #
     4 # This file is part of CubicWeb.
     4 # This file is part of CubicWeb.
     5 #
     5 #
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
    23 
    23 
    24 import os
    24 import os
    25 import sys
    25 import sys
    26 import re
    26 import re
    27 import urlparse
    27 import urlparse
    28 from os.path import dirname, join
    28 from os.path import dirname, join, abspath
    29 from urllib import unquote
    29 from urllib import unquote
    30 from math import log
    30 from math import log
    31 from contextlib import contextmanager
    31 from contextlib import contextmanager
    32 from warnings import warn
    32 from warnings import warn
    33 
    33 
    36 from logilab.common.testlib import TestCase, InnerTest, Tags
    36 from logilab.common.testlib import TestCase, InnerTest, Tags
    37 from logilab.common.pytest import nocoverage, pause_tracing, resume_tracing
    37 from logilab.common.pytest import nocoverage, pause_tracing, resume_tracing
    38 from logilab.common.debugger import Debugger
    38 from logilab.common.debugger import Debugger
    39 from logilab.common.umessage import message_from_string
    39 from logilab.common.umessage import message_from_string
    40 from logilab.common.decorators import cached, classproperty, clear_cache
    40 from logilab.common.decorators import cached, classproperty, clear_cache
    41 from logilab.common.deprecation import deprecated
    41 from logilab.common.deprecation import deprecated, class_deprecated
    42 from logilab.common.shellutils import getlogin
    42 from logilab.common.shellutils import getlogin
    43 
    43 
    44 from cubicweb import ValidationError, NoSelectableObject, AuthenticationError
    44 from cubicweb import ValidationError, NoSelectableObject, AuthenticationError
    45 from cubicweb import cwconfig, devtools, web, server
    45 from cubicweb import cwconfig, devtools, web, server
    46 from cubicweb.dbapi import ProgrammingError, DBAPISession, repo_connect
    46 from cubicweb.dbapi import ProgrammingError, DBAPISession, repo_connect
   183     * `session`, server side session associated to `cnx`
   183     * `session`, server side session associated to `cnx`
   184     * `app`, the cubicweb publisher (for web testing)
   184     * `app`, the cubicweb publisher (for web testing)
   185     * `repo`, the repository object
   185     * `repo`, the repository object
   186     * `admlogin`, login of the admin user
   186     * `admlogin`, login of the admin user
   187     * `admpassword`, password of the admin user
   187     * `admpassword`, password of the admin user
       
   188     * `shell`, create and use shell environment
   188     """
   189     """
   189     appid = 'data'
   190     appid = 'data'
   190     configcls = devtools.ApptestConfiguration
   191     configcls = devtools.ApptestConfiguration
   191     reset_schema = reset_vreg = False # reset schema / vreg between tests
   192     reset_schema = reset_vreg = False # reset schema / vreg between tests
   192     tags = TestCase.tags | Tags('cubicweb', 'cw_repo')
   193     tags = TestCase.tags | Tags('cubicweb', 'cw_repo')
   198         Configuration is cached on the test class.
   199         Configuration is cached on the test class.
   199         """
   200         """
   200         try:
   201         try:
   201             return cls.__dict__['_config']
   202             return cls.__dict__['_config']
   202         except KeyError:
   203         except KeyError:
   203             home = join(dirname(sys.modules[cls.__module__].__file__), cls.appid)
   204             home = abspath(join(dirname(sys.modules[cls.__module__].__file__), cls.appid))
   204             config = cls._config = cls.configcls(cls.appid, apphome=home)
   205             config = cls._config = cls.configcls(cls.appid, apphome=home)
   205             config.mode = 'test'
   206             config.mode = 'test'
   206             return config
   207             return config
   207 
   208 
   208     @classmethod
   209     @classmethod
   284     @property
   285     @property
   285     def adminsession(self):
   286     def adminsession(self):
   286         """return current server side session (using default manager account)"""
   287         """return current server side session (using default manager account)"""
   287         return self.repo._sessions[self._orig_cnx[0].sessionid]
   288         return self.repo._sessions[self._orig_cnx[0].sessionid]
   288 
   289 
       
   290     def shell(self):
       
   291         """return a shell session object"""
       
   292         from cubicweb.server.migractions import ServerMigrationHelper
       
   293         return ServerMigrationHelper(None, repo=self.repo, cnx=self.cnx,
       
   294                                      interactive=False,
       
   295                                      # hack so it don't try to load fs schema
       
   296                                      schema=1)
       
   297 
   289     def set_option(self, optname, value):
   298     def set_option(self, optname, value):
   290         self.config.global_set_option(optname, value)
   299         self.config.global_set_option(optname, value)
   291 
   300 
   292     def set_debug(self, debugmode):
   301     def set_debug(self, debugmode):
   293         server.set_debug(debugmode)
   302         server.set_debug(debugmode)
   294 
   303 
       
   304     def debugged(self, debugmode):
       
   305         return server.debugged(debugmode)
       
   306 
   295     # default test setup and teardown #########################################
   307     # default test setup and teardown #########################################
   296 
   308 
   297     def setUp(self):
   309     def setUp(self):
   298         # monkey patch send mail operation so emails are sent synchronously
   310         # monkey patch send mail operation so emails are sent synchronously
   299         self._old_mail_commit_event = SendMailOp.commit_event
   311         self._old_mail_postcommit_event = SendMailOp.postcommit_event
   300         SendMailOp.commit_event = SendMailOp.sendmails
   312         SendMailOp.postcommit_event = SendMailOp.sendmails
   301         pause_tracing()
   313         pause_tracing()
   302         previous_failure = self.__class__.__dict__.get('_repo_init_failed')
   314         previous_failure = self.__class__.__dict__.get('_repo_init_failed')
   303         if previous_failure is not None:
   315         if previous_failure is not None:
   304             self.skipTest('repository is not initialised: %r' % previous_failure)
   316             self.skipTest('repository is not initialised: %r' % previous_failure)
   305         try:
   317         try:
   317         if not self.cnx._closed:
   329         if not self.cnx._closed:
   318             self.cnx.rollback()
   330             self.cnx.rollback()
   319         for cnx in self._cnxs:
   331         for cnx in self._cnxs:
   320             if not cnx._closed:
   332             if not cnx._closed:
   321                 cnx.close()
   333                 cnx.close()
   322         SendMailOp.commit_event = self._old_mail_commit_event
   334         SendMailOp.postcommit_event = self._old_mail_postcommit_event
   323 
   335 
   324     def setup_database(self):
   336     def setup_database(self):
   325         """add your database setup code by overriding this method"""
   337         """add your database setup code by overriding this method"""
   326 
   338 
   327     # user / session management ###############################################
   339     # user / session management ###############################################
   342         if req is None:
   354         if req is None:
   343             req = self._orig_cnx[0].request()
   355             req = self._orig_cnx[0].request()
   344         user = req.create_entity('CWUser', login=unicode(login),
   356         user = req.create_entity('CWUser', login=unicode(login),
   345                                  upassword=password, **kwargs)
   357                                  upassword=password, **kwargs)
   346         req.execute('SET X in_group G WHERE X eid %%(x)s, G name IN(%s)'
   358         req.execute('SET X in_group G WHERE X eid %%(x)s, G name IN(%s)'
   347                     % ','.join(repr(g) for g in groups),
   359                     % ','.join(repr(str(g)) for g in groups),
   348                     {'x': user.eid})
   360                     {'x': user.eid})
   349         user.cw_clear_relation_cache('in_group', 'subject')
   361         user.cw_clear_relation_cache('in_group', 'subject')
   350         if commit:
   362         if commit:
   351             req.cnx.commit()
   363             req.cnx.commit()
   352         return user
   364         return user
   421         self.session.set_pool()
   433         self.session.set_pool()
   422         return self.session.execute(rql, args)
   434         return self.session.execute(rql, args)
   423 
   435 
   424     # other utilities #########################################################
   436     # other utilities #########################################################
   425 
   437 
       
   438     def grant_permission(self, entity, group, pname, plabel=None):
       
   439         """insert a permission on an entity. Will have to commit the main
       
   440         connection to be considered
       
   441         """
       
   442         pname = unicode(pname)
       
   443         plabel = plabel and unicode(plabel) or unicode(group)
       
   444         e = entity.eid
       
   445         with security_enabled(self.session, False, False):
       
   446             peid = self.execute(
       
   447             'INSERT CWPermission X: X name %(pname)s, X label %(plabel)s,'
       
   448             'X require_group G, E require_permission X '
       
   449             'WHERE G name %(group)s, E eid %(e)s',
       
   450             locals())[0][0]
       
   451         return peid
       
   452 
   426     @contextmanager
   453     @contextmanager
   427     def temporary_appobjects(self, *appobjects):
   454     def temporary_appobjects(self, *appobjects):
   428         self.vreg._loadedmods.setdefault(self.__module__, {})
   455         self.vreg._loadedmods.setdefault(self.__module__, {})
   429         for obj in appobjects:
   456         for obj in appobjects:
   430             self.vreg.register(obj)
   457             self.vreg.register(obj)
   432             yield
   459             yield
   433         finally:
   460         finally:
   434             for obj in appobjects:
   461             for obj in appobjects:
   435                 self.vreg.unregister(obj)
   462                 self.vreg.unregister(obj)
   436 
   463 
   437     # vregistry inspection utilities ###########################################
   464     def assertModificationDateGreater(self, entity, olddate):
       
   465         entity.cw_attr_cache.pop('modification_date', None)
       
   466         self.failUnless(entity.modification_date > olddate)
       
   467 
       
   468 
       
   469     # workflow utilities #######################################################
       
   470 
       
   471     def assertPossibleTransitions(self, entity, expected):
       
   472         transitions = entity.cw_adapt_to('IWorkflowable').possible_transitions()
       
   473         self.assertListEqual(sorted(tr.name for tr in transitions),
       
   474                              sorted(expected))
       
   475 
       
   476 
       
   477     # views and actions registries inspection ##################################
   438 
   478 
   439     def pviews(self, req, rset):
   479     def pviews(self, req, rset):
   440         return sorted((a.__regid__, a.__class__)
   480         return sorted((a.__regid__, a.__class__)
   441                       for a in self.vreg['views'].possible_views(req, rset=rset))
   481                       for a in self.vreg['views'].possible_views(req, rset=rset))
   442 
   482 
   466         class fake_menu(list):
   506         class fake_menu(list):
   467             @property
   507             @property
   468             def items(self):
   508             def items(self):
   469                 return self
   509                 return self
   470         class fake_box(object):
   510         class fake_box(object):
   471             def mk_action(self, label, url, **kwargs):
   511             def action_link(self, action, **kwargs):
   472                 return (label, url)
       
   473             def box_action(self, action, **kwargs):
       
   474                 return (action.title, action.url())
   512                 return (action.title, action.url())
   475         submenu = fake_menu()
   513         submenu = fake_menu()
   476         action.fill_menu(fake_box(), submenu)
   514         action.fill_menu(fake_box(), submenu)
   477         return submenu
   515         return submenu
   478 
   516 
   487                 continue
   525                 continue
   488             if rset.rowcount > 1 and vid in only_once_vids:
   526             if rset.rowcount > 1 and vid in only_once_vids:
   489                 continue
   527                 continue
   490             views = [view for view in views
   528             views = [view for view in views
   491                      if view.category != 'startupview'
   529                      if view.category != 'startupview'
   492                      and not issubclass(view, notification.NotificationView)]
   530                      and not issubclass(view, notification.NotificationView)
       
   531                      and not isinstance(view, class_deprecated)]
   493             if views:
   532             if views:
   494                 try:
   533                 try:
   495                     view = viewsvreg._select_best(views, req, rset=rset)
   534                     view = viewsvreg._select_best(views, req, rset=rset)
   496                     if view.linkable():
   535                     if view.linkable():
   497                         yield view
   536                         yield view
   509             yield action
   548             yield action
   510 
   549 
   511     def list_boxes_for(self, rset):
   550     def list_boxes_for(self, rset):
   512         """returns the list of boxes that can be applied on `rset`"""
   551         """returns the list of boxes that can be applied on `rset`"""
   513         req = rset.req
   552         req = rset.req
   514         for box in self.vreg['boxes'].possible_objects(req, rset=rset):
   553         for box in self.vreg['ctxcomponents'].possible_objects(req, rset=rset):
   515             yield box
   554             yield box
   516 
   555 
   517     def list_startup_views(self):
   556     def list_startup_views(self):
   518         """returns the list of startup views"""
   557         """returns the list of startup views"""
   519         req = self.request()
   558         req = self.request()
   618         return self.expect_redirect(lambda x: self.app_publish(x, path), req)
   657         return self.expect_redirect(lambda x: self.app_publish(x, path), req)
   619 
   658 
   620     def init_authentication(self, authmode, anonuser=None):
   659     def init_authentication(self, authmode, anonuser=None):
   621         self.set_option('auth-mode', authmode)
   660         self.set_option('auth-mode', authmode)
   622         self.set_option('anonymous-user', anonuser)
   661         self.set_option('anonymous-user', anonuser)
       
   662         if anonuser is None:
       
   663             self.config.anonymous_credential = None
       
   664         else:
       
   665             self.config.anonymous_credential = (anonuser, anonuser)
   623         req = self.request()
   666         req = self.request()
   624         origsession = req.session
   667         origsession = req.session
   625         req.session = req.cnx = None
   668         req.session = req.cnx = None
   626         del req.execute # get back to class implementation
   669         del req.execute # get back to class implementation
   627         sh = self.app.session_handler
   670         sh = self.app.session_handler
   719         and parsed.
   762         and parsed.
   720 
   763 
   721         :returns: an instance of `cubicweb.devtools.htmlparser.PageInfo`
   764         :returns: an instance of `cubicweb.devtools.htmlparser.PageInfo`
   722                   encapsulation the generated HTML
   765                   encapsulation the generated HTML
   723         """
   766         """
   724         output = None
       
   725         try:
   767         try:
   726             output = viewfunc(**kwargs)
   768             output = viewfunc(**kwargs)
   727             return self._check_html(output, view, template)
       
   728         except (SystemExit, KeyboardInterrupt):
   769         except (SystemExit, KeyboardInterrupt):
   729             raise
   770             raise
   730         except:
   771         except:
   731             # hijack exception: generative tests stop when the exception
   772             # hijack exception: generative tests stop when the exception
   732             # is not an AssertionError
   773             # is not an AssertionError
   733             klass, exc, tcbk = sys.exc_info()
   774             klass, exc, tcbk = sys.exc_info()
   734             try:
   775             try:
   735                 msg = '[%s in %s] %s' % (klass, view.__regid__, exc)
   776                 msg = '[%s in %s] %s' % (klass, view.__regid__, exc)
   736             except:
   777             except:
   737                 msg = '[%s in %s] undisplayable exception' % (klass, view.__regid__)
   778                 msg = '[%s in %s] undisplayable exception' % (klass, view.__regid__)
   738             if output is not None:
   779             raise AssertionError, msg, tcbk
       
   780         return self._check_html(output, view, template)
       
   781 
       
   782     def get_validator(self, view=None, content_type=None, output=None):
       
   783         if view is not None:
       
   784             try:
       
   785                 return self.vid_validators[view.__regid__]()
       
   786             except KeyError:
       
   787                 if content_type is None:
       
   788                     content_type = view.content_type
       
   789         if content_type is None:
       
   790             content_type = 'text/html'
       
   791         if content_type in ('text/html', 'application/xhtml+xml'):
       
   792             if output and output.startswith('<?xml'):
       
   793                 default_validator = htmlparser.DTDValidator
       
   794             else:
       
   795                 default_validator = htmlparser.HTMLValidator
       
   796         else:
       
   797             default_validator = None
       
   798         validatorclass = self.content_type_validators.get(content_type,
       
   799                                                           default_validator)
       
   800         if validatorclass is None:
       
   801             return
       
   802         return validatorclass()
       
   803 
       
   804     @nocoverage
       
   805     def _check_html(self, output, view, template='main-template'):
       
   806         """raises an exception if the HTML is invalid"""
       
   807         output = output.strip()
       
   808         validator = self.get_validator(view, output=output)
       
   809         if validator is None:
       
   810             return
       
   811         if isinstance(validator, htmlparser.DTDValidator):
       
   812             # XXX remove <canvas> used in progress widget, unknown in html dtd
       
   813             output = re.sub('<canvas.*?></canvas>', '', output)
       
   814         return self.assertWellFormed(validator, output.strip(), context= view.__regid__)
       
   815 
       
   816     def assertWellFormed(self, validator, content, context=None):
       
   817         try:
       
   818             return validator.parse_string(content)
       
   819         except (SystemExit, KeyboardInterrupt):
       
   820             raise
       
   821         except:
       
   822             # hijack exception: generative tests stop when the exception
       
   823             # is not an AssertionError
       
   824             klass, exc, tcbk = sys.exc_info()
       
   825             if context is None:
       
   826                 msg = u'[%s]' % (klass,)
       
   827             else:
       
   828                 msg = u'[%s in %s]' % (klass, context)
       
   829             msg = msg.encode(sys.getdefaultencoding(), 'replace')
       
   830 
       
   831             try:
       
   832                 str_exc = str(exc)
       
   833             except:
       
   834                 str_exc = 'undisplayable exception'
       
   835             msg += str_exc
       
   836             if content is not None:
   739                 position = getattr(exc, "position", (0,))[0]
   837                 position = getattr(exc, "position", (0,))[0]
   740                 if position:
   838                 if position:
   741                     # define filter
   839                     # define filter
   742                     output = output.splitlines()
   840                     if isinstance(content, str):
   743                     width = int(log(len(output), 10)) + 1
   841                         content = unicode(content, sys.getdefaultencoding(), 'replace')
       
   842                     content = content.splitlines()
       
   843                     width = int(log(len(content), 10)) + 1
   744                     line_template = " %" + ("%i" % width) + "i: %s"
   844                     line_template = " %" + ("%i" % width) + "i: %s"
   745                     # XXX no need to iterate the whole file except to get
   845                     # XXX no need to iterate the whole file except to get
   746                     # the line number
   846                     # the line number
   747                     output = '\n'.join(line_template % (idx + 1, line)
   847                     content = u'\n'.join(line_template % (idx + 1, line)
   748                                 for idx, line in enumerate(output)
   848                                          for idx, line in enumerate(content)
   749                                 if line_context_filter(idx+1, position))
   849                                          if line_context_filter(idx+1, position))
   750                     msg += '\nfor output:\n%s' % output
   850                     msg += u'\nfor content:\n%s' % content
   751             raise AssertionError, msg, tcbk
   851             raise AssertionError, msg, tcbk
   752 
   852 
   753 
   853     def assertDocTestFile(self, testfile):
   754     @nocoverage
   854         # doctest returns tuple (failure_count, test_count)
   755     def _check_html(self, output, view, template='main-template'):
   855         result = self.shell().process_script(testfile)
   756         """raises an exception if the HTML is invalid"""
   856         if result[0] and result[1]:
   757         try:
   857             raise self.failureException("doctest file '%s' failed"
   758             validatorclass = self.vid_validators[view.__regid__]
   858                                         % testfile)
   759         except KeyError:
   859 
   760             if view.content_type in ('text/html', 'application/xhtml+xml'):
   860     # notifications ############################################################
   761                 if template is None:
   861 
   762                     default_validator = htmlparser.HTMLValidator
   862     def assertSentEmail(self, subject, recipients=None, nb_msgs=None):
   763                 else:
   863         """test recipients in system mailbox for given email subject
   764                     default_validator = htmlparser.DTDValidator
   864 
   765             else:
   865         :param subject: email subject to find in mailbox
   766                 default_validator = None
   866         :param recipients: list of email recipients
   767             validatorclass = self.content_type_validators.get(view.content_type,
   867         :param nb_msgs: expected number of entries
   768                                                               default_validator)
   868         :returns: list of matched emails
   769         if validatorclass is None:
   869         """
   770             return output.strip()
   870         messages = [email for email in MAILBOX
   771         validator = validatorclass()
   871                     if email.message.get('Subject') == subject]
   772         if isinstance(validator, htmlparser.DTDValidator):
   872         if recipients is not None:
   773             # XXX remove <canvas> used in progress widget, unknown in html dtd
   873             sent_to = set()
   774             output = re.sub('<canvas.*?></canvas>', '', output)
   874             for msg in messages:
   775         return validator.parse_string(output.strip())
   875                 sent_to.update(msg.recipients)
       
   876             self.assertSetEqual(set(recipients), sent_to)
       
   877         if nb_msgs is not None:
       
   878             self.assertEqual(len(MAILBOX), nb_msgs)
       
   879         return messages
   776 
   880 
   777     # deprecated ###############################################################
   881     # deprecated ###############################################################
   778 
   882 
   779     @deprecated('[3.8] use self.execute(...).get_entity(0, 0)')
   883     @deprecated('[3.8] use self.execute(...).get_entity(0, 0)')
   780     def entity(self, rql, args=None, eidkey=None, req=None):
   884     def entity(self, rql, args=None, eidkey=None, req=None):
   964             # resultset's syntax tree
  1068             # resultset's syntax tree
   965             rset = backup_rset
  1069             rset = backup_rset
   966         for action in self.list_actions_for(rset):
  1070         for action in self.list_actions_for(rset):
   967             yield InnerTest(self._testname(rset, action.__regid__, 'action'), self._test_action, action)
  1071             yield InnerTest(self._testname(rset, action.__regid__, 'action'), self._test_action, action)
   968         for box in self.list_boxes_for(rset):
  1072         for box in self.list_boxes_for(rset):
   969             yield InnerTest(self._testname(rset, box.__regid__, 'box'), box.render)
  1073             w = [].append
       
  1074             yield InnerTest(self._testname(rset, box.__regid__, 'box'), box.render, w)
   970 
  1075 
   971     @staticmethod
  1076     @staticmethod
   972     def _testname(rset, objid, objtype):
  1077     def _testname(rset, objid, objtype):
   973         return '%s_%s_%s' % ('_'.join(rset.column_types(0)), objid, objtype)
  1078         return '%s_%s_%s' % ('_'.join(rset.column_types(0)), objid, objtype)
   974 
  1079