testXHTML.py 31.5 KB
Newer Older
1
# -*- coding: utf-8 -*-
2 3 4
##############################################################################
#
# Copyright (c) 2007 Nexedi SARL and Contributors. All Rights Reserved.
5 6
#               Fabien Morin <fabien@nexedi.com
#               Jacek Medrzycki <jacek@erp5.pl>
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
# WARNING: This program as such is intended to be used by professional
# programmers who take the whole responsability of assessing all potential
# consequences resulting from its eventual inadequacies and bugs
# End users who are looking for a ready-to-use solution with commercial
# garantees and support are strongly adviced to contract a Free Software
# Service Company
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
##############################################################################

30 31
import unittest
import os
32
import requests
33 34
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry
35

Lingnan Wu's avatar
Lingnan Wu committed
36
from subprocess import Popen, PIPE
37
from Testing import ZopeTestCase
38
from Products.ERP5Type.tests.ERP5TypeTestCase import ERP5TypeTestCase
39
from Products.ERP5Type.tests.utils import addUserToDeveloperRole
40
from Products.CMFCore.utils import getToolByName
41
from zLOG import LOG
42 43 44 45 46 47 48
# You can invoke same tests in your favourite collection of business templates
# by using TestXHTMLMixin like the following :
#
# from Products.ERP5.tests.testERP5XHTML import TestXHTMLMixin
# class TestMyXHTML(TestXHTMLMixin):
#   def getBusinessTemplateList(self):
#     return (...)
49

50
class TestXHTMLMixin(ERP5TypeTestCase):
51

52
  # some forms have intentionally empty listbox selections like RSS generators
53
  FORM_LISTBOX_EMPTY_SELECTION_PATH_LIST = ['erp5_web_widget_library/WebSection_viewContentListAsRSS',
54 55
                                            'erp5_discussion/DiscusionThread_viewContentListAsRSS',
                                            'erp5_discussion/WebSection_viewLatestDiscussionPostListAsRSS',
56
                                            'erp5_core/Base_viewHistoricalComparisonDiff',
57
                                            'erp5_diff/ERP5Site_viewDiffTwoObjectDialog',]
58
  JSL_IGNORE_FILE_LIST = (
59 60
        'diff2html.js',
        'diff2html-ui.js',
61 62 63 64 65 66 67 68 69
        'dream_graph_editor/lib/handlebars.min.js',
        'dream_graph_editor/lib/jquery-ui.js',
        'dream_graph_editor/lib/jquery.js',
        'dream_graph_editor/lib/jquery.jsplumb.js',
        'dream_graph_editor/lib/jquery.simulate.js',
        'dream_graph_editor/lib/qunit.js',
        'dream_graph_editor/lib/springy.js',
        'handlebars.js',
        'jio.js',
70
        'jslint.js',
71 72 73 74 75 76 77 78 79 80 81
        'pdf_js/build/pdf.js',
        'pdf_js/build/pdf.worker.js',
        'pdf_js/compatibility.js',
        'pdf_js/debugger.js',
        'pdf_js/l10n.js',
        'pdf_js/viewer.js',
        'renderjs.js',
        'require.js',
        'require.min.js',
        'rsvp.js',
        'wz_dragdrop.js',
82
        'gadget_vcs_status.js',  # XXX because jsl is buggy
83 84 85 86
        )
  JSL_IGNORE_SKIN_LIST = (
        'erp5_code_mirror',
        'erp5_fckeditor',
87
        'erp5_ckeditor',
88 89
        'erp5_jquery',
        'erp5_jquery_ui',
90 91 92
        'erp5_pivot_table',
        'erp5_sql_browser',
        'erp5_dhtmlx_scheduler',
93 94
        'erp5_svg_editor',
        )
95

96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111
  HTML_IGNORE_FILE_LIST = (
        'gadget_erp5_side_by_side_diff.html',
        )
  # NOTE: Here the difference between the JSL_IGNORE_SKIN_LIST is that we also
  # consider the folders inside the skin. In this way, we can include multiple
  # HTML files at once which are inside some folder in any skin folder.
  HTML_IGNORE_SKIN_FOLDER_LIST = (
        'erp5_jquery',
        'erp5_fckeditor',
        'erp5_ckeditor',
        'erp5_svg_editor',
        'erp5_jquery_ui',
        'erp5_dms/pdf_js',
        'erp5_test_result/test_result_js',
        )

112 113 114 115 116 117 118 119
  def changeSkin(self, skin_name):
    """
      Change current Skin
    """
    request = self.app.REQUEST
    self.getPortal().portal_skins.changeSkin(skin_name)
    request.set('portal_skin', skin_name)

120 121 122 123 124 125 126 127 128 129 130 131
  def getFieldList(self, form, form_path):
    try:
      for field in form.get_fields(include_disabled=1):
        if field.getTemplateField() is not None:
          try:
            if field.get_value('enabled'):
              yield field
          except Exception:
            yield field
    except AttributeError, e:
      ZopeTestCase._print("%s is broken: %s" % (form_path, e))

132 133 134 135
  def test_deadProxyFields(self):
    # check that all proxy fields defined in business templates have a valid
    # target
    skins_tool = self.portal.portal_skins
136
    error_list = []
137 138 139 140 141 142 143

    for skin_name, skin_folder_string in skins_tool.getSkinPaths():
      skin_folder_id_list = skin_folder_string.split(',')
      self.changeSkin(skin_name)

      for skin_folder_id in skin_folder_id_list:
        for field_path, field in skins_tool[skin_folder_id].ZopeFind(
144
                  skins_tool[skin_folder_id],
145 146 147
                  obj_metatypes=['ProxyField'], search_sub=1):
          template_field = field.getTemplateField(cache=False)
          if template_field is None:
Jérome Perrin's avatar
Jérome Perrin committed
148
            # Base_viewRelatedObjectList (used for proxy listbox ids on
149 150
            # relation fields) is an exception, the proxy field has no target
            # by default.
Jérome Perrin's avatar
Jérome Perrin committed
151
            if field_path != 'Base_viewRelatedObjectList/listbox':
152 153
              error_list.append((skin_name, field_path, field.get_value('form_id'),
                                 field.get_value('field_id')))
154 155

    if error_list:
156 157
      message = '\nDead proxy field list%s\n' \
                    % '\n\t'.join(str(e) for e in error_list)
158
      self.fail(message)
159

160 161
  def test_configurationOfFieldLibrary(self):
    error_list = []
162 163
    for business_template in self.portal.portal_templates.searchFolder(
          title=['erp5_trade']):
164 165 166
      # XXX Impossible to filter by installation state, as it is not catalogued
      business_template = business_template.getObject()
      for modifiable_field in business_template.BusinessTemplate_getModifiableFieldList():
167
        # Do not consider 'Check delegated values' as an error
168 169
        if modifiable_field.choice_item_list[0][1] not in \
            ("0_check_delegated_value", "0_keep_non_proxy_field"):
170 171
          error_list.append((modifiable_field.object_id,
                            modifiable_field.choice_item_list[0][0]))
172
    if error_list:
173
      message = '%s fields to modify' % len(error_list)
174 175 176
      message += '\n\t' + '\n\t'.join(fieldname + ": " + message
                                       for fieldname, message in error_list)
      self.fail(message)
177

178 179 180 181 182 183 184 185
  def test_portalTypesDomainTranslation(self):
    # according to bt5-Module.Creation.Guidelines document, module
    # portal_types should be translated using erp5_ui, and normal ones, using
    # erp5_content
    error_list = []
    portal_types_module = self.portal.portal_types
    for portal_type in portal_types_module.contentValues(portal_type=\
        'Base Type'):
186
      if portal_type.getId().endswith('Module'):
187
        for k, v in portal_type.getPropertyTranslationDomainDict().items():
188
          if k in ('title', 'short_title') and v.getDomainName() != 'erp5_ui':
189
            error_list.append('"%s" should use erp5_ui for %s' % \
190
                (portal_type.getId(), k))
191
    if error_list:
192 193
      message = '\nBad portal_type domain translation list%s\n' \
                    % '\n\t'.join(error_list)
194 195
      self.fail(message)

196 197 198 199 200 201
  def test_emptySelectionNameInListbox(self):
    # check all empty selection name in listboxes
    skins_tool = self.portal.portal_skins
    error_list = []
    for form_path, form in skins_tool.ZopeFind(
              skins_tool, obj_metatypes=['ERP5 Form'], search_sub=1):
202
      for field in self.getFieldList(form, form_path):
Fabien Morin's avatar
Fabien Morin committed
203
        if field.getRecursiveTemplateField().meta_type == 'ListBox':
204
          selection_name = field.get_value("selection_name")
205
          if selection_name in ("",None) and \
206
            form_path not in self.FORM_LISTBOX_EMPTY_SELECTION_PATH_LIST:
207
            error_list.append(form_path)
208
    self.assertEqual(error_list, [])
209

210
  def test_duplicatingSelectionNameInListbox(self):
211
    """
212
    Check for duplicating selection name in listboxes.
213
    Usually we should not have duplicates except in some rare cases
214 215
    described in SkinsTool_getDuplicateSelectionNameDict
    """
216 217 218 219 220
    portal_skins = self.portal.portal_skins
    duplicating_selection_name_dict = portal_skins.SkinsTool_getDuplicateSelectionNameDict()
    self.assertFalse(duplicating_selection_name_dict,
                     "Repeated listbox selection names:\n" +
                     portal_skins.SkinsTool_checkDuplicateSelectionName())
221

222 223 224 225
  def test_SkinsTool_checkFieldExternalValidator(self):
    self.assertFalse(
      self.portal.portal_skins.SkinsTool_checkFieldExternalValidator())

Lingnan Wu's avatar
Lingnan Wu committed
226 227 228
  def test_javascript_lint(self):
    skins_tool = self.portal.portal_skins
    path_list = []
229 230
    for script_path, script in skins_tool.ZopeFind(skins_tool,
        obj_metatypes=('File','DTML Method','DTML Document'), search_sub=1):
Lingnan Wu's avatar
Lingnan Wu committed
231
      if script_path.endswith('.js'):
232 233 234
        x = script_path.split('/', 1)
        if not (x[0] in self.JSL_IGNORE_SKIN_LIST or
                x[1] in self.JSL_IGNORE_FILE_LIST):
Lingnan Wu's avatar
Lingnan Wu committed
235
          path_list.append(script_path)
236 237 238
    portal_skins_path = self.portal.getId() + '/portal_skins/'
    args = ('jsl', '-stdin', '-nologo', '-nosummary', '-conf',
            os.path.join(os.path.dirname(__file__), 'jsl.conf'))
239
    error_list = []
240 241
    for path in path_list:
      check_path = portal_skins_path + path
Lingnan Wu's avatar
Lingnan Wu committed
242 243
      body = self.publish(check_path).getBody()
      try:
244 245
        stdout, stderr = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE,
                               close_fds=True).communicate(body)
Lingnan Wu's avatar
Lingnan Wu committed
246
      except OSError, e:
247 248
        e.strerror += '\n%r' % os.environ
        raise
249 250 251 252 253
      if stdout:
        error_list.append((check_path, stdout))
    if error_list:
      message = '\n'.join(["%s\n%s\n" % error for error in error_list])
      self.fail(message)
Xiaowu Zhang's avatar
Xiaowu Zhang committed
254 255

  def test_html_file(self):
256 257 258 259 260
    skins_tool = self.portal.portal_skins
    path_list = []
    for script_path, script in skins_tool.ZopeFind(
              skins_tool, obj_metatypes=['File'], search_sub=1):
      if script_path.endswith('.html'):
261 262 263 264 265
        x = script_path.split('/', 1)
        if not x[1] in self.HTML_IGNORE_FILE_LIST:
          is_required_check_path = False
        for ignore_folder_name in self.HTML_IGNORE_SKIN_FOLDER_LIST:
          if  script_path.startswith(ignore_folder_name):
266 267 268 269
            is_required_check_path = False
            break;
        if is_required_check_path:
          path_list.append(script_path)
Xiaowu Zhang's avatar
Xiaowu Zhang committed
270

271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288
    def validate_html_file(source_path):
      message = ['Using %s validator to parse the file "%s"'
                 ' with warnings%sdisplayed :'
                % (validator.name, source_path,
                   validator.show_warnings and ' ' or ' NOT ')]
      source = self.publish(source_path).getBody()
      result_list_list = validator.getErrorAndWarningList(source)
      severity_list = ['Error']
      if validator.show_warnings:
        severity_list.append('Warning')
      for i, severity in enumerate(severity_list):
        for line, column, msg in result_list_list[i]:
          if line is None and column is None:
            message.append('%s: %s' % (severity, msg))
          else:
            message.append('%s: line %s column %s : %s' %
                           (severity, line, column, msg))
      return len(message) == 1, '\n'.join(message)
Xiaowu Zhang's avatar
Xiaowu Zhang committed
289

290 291
    def html_file(check_path):
      self.assert_(*validate_html_file(source_path=check_path))
Xiaowu Zhang's avatar
Xiaowu Zhang committed
292

293 294 295 296
    portal_skins_path = '%s/portal_skins' % self.portal.getId()
    for path in path_list:
      check_path = '%s/%s' % (portal_skins_path, path)
      html_file(check_path)
Xiaowu Zhang's avatar
Xiaowu Zhang committed
297

Ivan Tyagov's avatar
Ivan Tyagov committed
298
  def test_PythonScriptSyntax(self):
299
    """
Ivan Tyagov's avatar
Ivan Tyagov committed
300 301
    Check that Python Scripts syntax is correct.
    """
302 303 304 305 306
    for tool in (self.portal.portal_skins, self.portal.portal_workflow):
      for script_path, script in tool.ZopeFind(
                tool, obj_metatypes=['Script (Python)'], search_sub=1):
        if script.errors!=():
          # we need to add script id as well in test failure
307
          self.assertEqual('%s : %s' %(script_path, script.errors), ())
Ivan Tyagov's avatar
Ivan Tyagov committed
308 309

  def test_SkinItemId(self):
310
    """
Ivan Tyagov's avatar
Ivan Tyagov committed
311 312 313 314 315 316 317 318
    Check that skin item id is acquiring is correct.
    """
    skins_tool = self.portal.portal_skins
    for skin_folder in skins_tool.objectValues('Folder'):
      for skin_item in skin_folder.objectValues():
        if skin_item.meta_type not in ('File', 'Image', 'DTML Document', 'DTML Method',):
          skin_item_id = skin_item.id
          self.assertEqual(skin_item_id, skin_folder[skin_item_id].id)
319

Nicolas Delaby's avatar
Nicolas Delaby committed
320
  def test_callableListMethodInListbox(self):
321 322 323 324 325
    # check all list_method in listboxes
    skins_tool = self.portal.portal_skins
    error_list = []
    for form_path, form in skins_tool.ZopeFind(
              skins_tool, obj_metatypes=['ERP5 Form'], search_sub=1):
326
      for field in self.getFieldList(form, form_path):
Fabien Morin's avatar
Fabien Morin committed
327
        if field.getRecursiveTemplateField().meta_type == 'ListBox':
328 329 330
          list_method = field.get_value("list_method")
          if list_method:
            if isinstance(list_method, str):
331
              method = getattr(self.portal, list_method, None)
332 333 334
            else:
              method = list_method
            if not callable(method):
335
              error_list.append((form_path, list_method))
336
    self.assertEqual(error_list, [])
337

338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355
  def test_callableCountMethodInListbox(self):
    # check all count_method in listboxes
    skins_tool = self.portal.portal_skins
    error_list = []
    for form_path, form in skins_tool.ZopeFind(
              skins_tool, obj_metatypes=['ERP5 Form'], search_sub=1):
      for field in self.getFieldList(form, form_path):
        if field.getRecursiveTemplateField().meta_type == 'ListBox':
          count_method = field.get_value("count_method")
          if count_method:
            if isinstance(count_method, str):
              method = getattr(self.portal, count_method, None)
            else:
              method = count_method
            if not callable(method):
              error_list.append((form_path, count_method))
    self.assertEqual(error_list, [])

356 357 358 359 360 361 362
  def test_listActionInListbox(self):
    # check all list_action in listboxes
    skins_tool = self.portal.portal_skins
    error_list = []
    for form_path, form in skins_tool.ZopeFind(
              skins_tool, obj_metatypes=['ERP5 Form'], search_sub=1):
      for field in self.getFieldList(form, form_path):
Fabien Morin's avatar
Fabien Morin committed
363
        if field.getRecursiveTemplateField().meta_type == 'ListBox':
364 365 366 367
          list_action = field.get_value("list_action")
          if list_action and list_action != 'list': # We assume that 'list'
                                                    # list_action exists
            if isinstance(list_action, str):
368 369
              # list_action can be a fully qualified URL, we care for last part of it
              list_action = list_action.split('/')[-1].split('?')[0]
370 371 372 373
              try:
                method = self.portal.restrictedTraverse(list_action)
              except KeyError:
                method = None
374 375 376 377 378 379
              if method is None:
                # list_action can actually exists but not in current skin, check if it can be found in portal_skins
                found_list_action_list = skins_tool.ZopeFind(skins_tool, obj_ids=[list_action], search_sub=1)
                if found_list_action_list:
                  method = found_list_action_list[0][1]
                  ZopeTestCase._print("List action %s for %s is not part of current skin but do exists in another skin folder.\n" % (list_action, form_path))
380 381 382
            else:
              method = list_action
            if not callable(method):
383 384 385
              error_list.append('Form %s/%s : list_action "%s" is not callable.'\
                  % (form_path, field.id, list_action))
    self.assert_(not len(error_list), '\n'.join(error_list))
386

387 388
  def test_moduleListMethod(self):
    """Make sure that module's list method works."""
389
    error_list = []
390 391
    for document in self.portal.contentValues():
      if document.portal_type.endswith(' Module'):
392
        if document.getTranslatedTitle() not in document.list(reset=1):
393 394
          error_list.append(document.id)
    self.assertEqual([], error_list)
395

396 397 398 399
  def test_preferenceViewDuplication(self):
    """Make sure that preference view is not duplicated."""
    preference_view_id_dict = {}
    def addPreferenceView(folder_id, view_id):
Jérome Perrin's avatar
Jérome Perrin committed
400
      preference_view_id_dict.setdefault(view_id, []).append('%s.%s' % (folder_id, view_id))
401
    error_list = []
Jérome Perrin's avatar
Jérome Perrin committed
402 403
    for skin_folder in self.portal.portal_skins.objectValues():
      if skin_folder.isPrincipiaFolderish:
404
        for id_ in skin_folder.objectIds():
405
          if id_.startswith('Preference_view'):
Jérome Perrin's avatar
Jérome Perrin committed
406
            addPreferenceView(skin_folder.id, id_)
407
    for view_id, location_list in preference_view_id_dict.items():
Jérome Perrin's avatar
Jérome Perrin committed
408
      if len(location_list) > 1:
409 410 411
        error_list.extend(location_list)
    self.assertEqual(error_list, [])

412 413 414 415 416 417 418 419 420 421 422 423 424 425
class TestXHTML(TestXHTMLMixin):

  run_all_test = 1

  def getTitle(self):
    return "XHTML Test"

  @staticmethod
  def getBusinessTemplateList():
    """  """
    return ( # dependency order
      'erp5_core_proxy_field_legacy',
      'erp5_base',
      'erp5_simulation',
426
      'erp5_pdm',
427 428 429 430 431 432 433 434 435 436
      'erp5_trade',

      'erp5_accounting',
      'erp5_invoicing',

      'erp5_apparel',

      'erp5_budget',
      'erp5_public_accounting_budget',

437 438
      'erp5_project',

439 440 441
      'erp5_consulting',

      'erp5_ingestion_mysql_innodb_catalog',
442
      'erp5_ingestion',
443
      'erp5_crm',
444
      'erp5_interface_post',
445 446 447 448 449 450 451 452

      'erp5_jquery',
      'erp5_jquery_ui',
      'erp5_web',
      'erp5_dms',
      'erp5_email_reader',
      'erp5_commerce',
      'erp5_credential',
453
      'erp5_web_service',
454
      'erp5_test_result',
455 456 457 458 459 460 461 462 463

      'erp5_forge',

      'erp5_immobilisation',

      'erp5_item',

      'erp5_mrp',

464
      'erp5_open_trade',
465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484
      'erp5_payroll',

      'erp5_calendar',

      'erp5_advanced_invoicing',

      'erp5_odt_style',

      'erp5_administration',

      'erp5_knowledge_pad',
      'erp5_knowledge_pad_ui_test',
      'erp5_km',
      'erp5_ui_test',
      'erp5_dms_ui_test',

      'erp5_trade_proxy_field_legacy', # it is necessary until all bt are well
                                       # reviewed. Many bt like erp5_project are
                                       # using obsolete field library of trade
      'erp5_xhtml_style',
485
      'erp5_dhtml_style',
486 487 488 489 490 491 492 493 494 495 496 497 498 499
      'erp5_jquery_plugin_svg_editor',
      'erp5_jquery_plugin_spinbtn',
      'erp5_jquery_plugin_jquerybbq',
      'erp5_jquery_plugin_svgicon',
      'erp5_jquery_plugin_jgraduate',
      'erp5_jquery_plugin_hotkey',
      'erp5_jquery_plugin_elastic',
      'erp5_jquery_plugin_colorpicker',
      'erp5_jquery_plugin_jqchart',
      'erp5_jquery_plugin_sheet',
      'erp5_jquery_plugin_mbmenu',
      'erp5_jquery_plugin_wdcalendar',
      'erp5_svg_editor',
      'erp5_jquery_sheet_editor',
500
      'erp5_graph_editor',
501 502 503 504 505 506 507 508 509 510 511 512
      'erp5_ui_test',
      'erp5_l10n_fr', # install at least one localization business template
                      # because some language switching widgets are only
                      # present if there is more than one available language.
    )

  def afterSetUp(self):
    self.portal = self.getPortal()

    uf = self.getPortal().acl_users
    uf._doAddUser('seb', '', ['Manager'], [])

513
    self.loginByUserName('seb')
514
    addUserToDeveloperRole('seb') # required to create content in portal_components
515 516 517 518 519 520 521 522
    self.enableDefaultSitePreference()

  def enableDefaultSitePreference(self):
    portal_preferences = getToolByName(self.portal, 'portal_preferences')
    default_site_preference = portal_preferences.default_site_preference
    if self.portal.portal_workflow.isTransitionPossible(default_site_preference, 'enable'):
      default_site_preference.enable()

523 524 525

class NuValidator(object):

526
  def __init__(self, show_warnings, validator_url='https://validator.erp5.net/'):
527 528
    self.show_warnings = show_warnings
    self.name = 'nu'
529 530 531 532 533 534 535 536 537 538 539 540 541 542
    self.validator_url = validator_url
    self.validator_session = requests.Session()
    # retries HTTP 502 errors which sometimes happen with validator.erp5.net
    self.validator_session.mount(
        self.validator_url,
        requests.adapters.HTTPAdapter(
            max_retries=Retry(
                total=3,
                read=3,
                connect=3,
                backoff_factor=.5,
                status_forcelist=(502, ))))

  def _parse_validation_results(self, response):
543 544 545 546 547 548 549 550
    """
    parses the validation results, returns a list of tuples:
    line_number, col_number, error description
    """
    if response.status_code != 200:
      return [
        [(None, None,
          'Contacting the external validator %s failed with status: %i' %
551
            (self.validator_url, response.status_code))],
552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578
        []
      ]

    content_type = response.headers.get('Content-Type', None)
    if content_type != 'application/json;charset=utf-8':
      return [[(None, None, 'Unsupported validator response content type %s' %
                            content_type)], []]

    result = response.json()

    error_list = []
    warning_list = []
    for message in result['messages']:
      if message['type'] == 'info':
        severity_list = warning_list
      else:
        severity_list = error_list
      txt = message['message'].encode('UTF-8')
      if 'extract' in message:
        txt += ': %s' % message['extract'].encode('UTF-8')
      severity_list.append([message['lastLine'], message['lastColumn'], txt])
    return [error_list, warning_list]

  def getErrorAndWarningList(self, page_source):
    '''
      retrun two list : a list of errors and an other for warnings
    '''
579
    response = self.validator_session.post(self.validator_url,
580 581 582 583 584
                             data=page_source.encode('UTF-8'),
                             params={'out': 'json'},
                             headers={
                               'Content-Type': 'text/html; charset=UTF-8'
                             })
585
    return self._parse_validation_results(response)
586 587


588 589
class TidyValidator(object):

Jérome Perrin's avatar
Jérome Perrin committed
590
  def __init__(self, validator_path, show_warnings):
591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624
    self.validator_path = validator_path
    self.show_warnings = show_warnings
    self.name = 'tidy'

  def _parse_validation_results(self, result):
    """
    parses the validation results, returns a list of tuples:
    line_number, col_number, error description
    """
    error_list=[]
    warning_list=[]

    for i in result:
      data = i.split(' - ')
      if len(data) >= 2:
        data[1] = data[1].replace('\n','')
        if data[1].startswith('Error: '):
          location_list = data[0].split(' ')
          line = location_list[1]
          column = location_list[3]
          message = data[1].split(': ')[1]
          error_list.append((line, column, message))
        elif data[1].startswith('Warning: '):
          location_list = data[0].split(' ')
          line = location_list[1]
          column = location_list[3]
          message = data[1].split(': ')[1]
          warning_list.append((line, column, message))
    return (error_list, warning_list)

  def getErrorAndWarningList(self, page_source):
    '''
      retrun two list : a list of errors and an other for warnings
    '''
625 626 627
    stdout, stderr = Popen('%s -e -q -utf8' % self.validator_path,
            stdin=PIPE, stdout=PIPE, stderr=PIPE,
            close_fds=True).communicate(page_source)
628 629 630 631 632 633 634
    return self._parse_validation_results(stderr)


def validate_xhtml(validator, source, view_name, bt_name):
  '''
    validate_xhtml return True if there is no error on the page, False else.
    Now it's possible to show warnings, so, if the option is set to True on the
635
    validator object, and there is some warning on the page, the function
636 637 638
    return False, even if there is no error.
  '''
  # display some information when test faild to facilitate debugging
639
  message = ['Using %s validator to parse the view "%s" (from %s bt)'
Julien Muchembled's avatar
typo  
Julien Muchembled committed
640
             ' with warnings%sdisplayed :'
641
             % (validator.name, view_name, bt_name,
Julien Muchembled's avatar
typo  
Julien Muchembled committed
642
                validator.show_warnings and ' ' or ' NOT ')]
643

644
  result_list_list = validator.getErrorAndWarningList(source)
645

646 647 648
  severity_list = ['Error']
  if validator.show_warnings:
    severity_list.append('Warning')
649

650 651 652 653 654 655 656
  for i, severity in enumerate(severity_list):
    for line, column, msg in result_list_list[i]:
      if line is None and column is None:
        message.append('%s: %s' % (severity, msg))
      else:
        message.append('%s: line %s column %s : %s' %
                       (severity, line, column, msg))
657

658
  return len(message) == 1, '\n'.join(message)
659 660


661
def makeTestMethod(validator, portal_type, view_name, bt_name):
662 663

  def createSubContent(content, portal_type_list):
Jérome Perrin's avatar
Jérome Perrin committed
664
    if not portal_type_list:
665
      return content
Jérome Perrin's avatar
Jérome Perrin committed
666 667 668 669 670
    if portal_type_list == [content.getPortalType()]:
      return content
    return createSubContent(
               content.newContent(portal_type=portal_type_list[0]),
               portal_type_list[1:])
671

672

673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729
  def findContentChain(portal, target_portal_type):
    # type: (erp5.portal_type.ERP5Site,str) -> Tuple[erp5.portal_type.Folder, Tuple[str, ...]]
    """Returns the module and the chain of portal types to create a document of target_portal_type.

    This tries all allowed content types up to three levels and if not found, use portal_trash,
    which allows anything.
    """
    # These types have a special `newContent` which does not really follow the interface, we
    # cannot not use them as container.
    invalid_container_type_set = {
        'Session Tool',
        'Contribution Tool',
    }
    # first look modules and their content to find a real container chain.
    for module in portal.contentValues():
      module_type = module.getTypeInfo()
      if module_type is not None:
        if module_type.getId() == target_portal_type:
          return module, ()
        if module_type.isTypeFilterContentType() \
              and module_type.getId() not in invalid_container_type_set:
          for allowed_type in module.allowedContentTypes():
            # Actions on portal_actions are global actions which can be rendered on any context.
            # We don't test them on all portal types, only on the first type "top level document"
            if target_portal_type in ('portal_actions', allowed_type.getId()):
              return module, (allowed_type.getId(),)
            for sub_allowed_type in allowed_type.getTypeAllowedContentTypeList():
              if target_portal_type == sub_allowed_type:
                return module, (allowed_type.getId(), target_portal_type)
              if sub_allowed_type in portal.portal_types:
                for sub_sub_allowed_type in portal.portal_types[
                    sub_allowed_type].getTypeAllowedContentTypeList():
                  if target_portal_type == sub_sub_allowed_type:
                    return module, (
                        allowed_type.getId(),
                        sub_allowed_type,
                        target_portal_type,
                    )
    # we did not find a valid chain of containers, so we'll fallback to creating
    # in portal_trash, which allow anything.
    # We still make one attempt at finding a valid container.
    for ti in portal.portal_types.contentValues():
      if ti.getId() not in invalid_container_type_set\
          and target_portal_type in ti.getTypeAllowedContentTypeList():
        return portal.portal_trash, (ti.getId(), target_portal_type,)
    # no suitable container found, use directly portal_trash.
    ZopeTestCase._print(
        'Could not find container for %s. Using portal_trash as a container\n'
        % target_portal_type)
    return portal.portal_trash, (target_portal_type,)

  def testMethod(self):
    module, portal_type_list = findContentChain(
        self.portal,
        portal_type)
    document = createSubContent(module, portal_type_list)
    view = getattr(document, view_name)
730 731 732
    self.assert_(*validate_xhtml( validator=validator,
                                  source=view(),
                                  view_name=view_name,
733
                                  bt_name=bt_name))
734 735
  return testMethod

736 737 738 739 740 741

def addTestMethodDynamically(
    test_class,
    validator,
    target_business_templates,
    expected_failure_list=()):
742 743
  from Products.ERP5.tests.utils import BusinessTemplateInfoTar
  from Products.ERP5.tests.utils import BusinessTemplateInfoDir
744 745
  business_template_info_list = []

746 747 748
  for url, _ in ERP5TypeTestCase._getBTPathAndIdList(target_business_templates):
    if os.path.isdir(url):
      business_template_info = BusinessTemplateInfoDir(url)
749
    else:
750 751
      business_template_info = BusinessTemplateInfoTar(url)
    business_template_info_list.append(business_template_info)
752

753
  for business_template_info in business_template_info_list:
754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773
    for portal_type, action_information_list in business_template_info.actions.items():
      for action_information in action_information_list:
        if (action_information['category'] in ('object_view', 'object_list') and
            action_information['visible']==1 and
            action_information['action'].startswith('string:${object_url}/') and
            len(action_information['action'].split('/'))==2):
          view_name = action_information['action'].split('/')[-1].split('?')[0]
          method = makeTestMethod(
              validator,
              portal_type,
              view_name,
              business_template_info.title)
          method_name = ('test_%s_%s_%s' %
                        (business_template_info.title,
                        str(portal_type).replace(' ','_'), # can be unicode
                        view_name))
          method.__name__ = method_name
          if method_name in expected_failure_list:
            method = unittest.expectedFailure(method)
          setattr(test_class, method_name, method)
774 775


776
# Two validators are available : nu and tidy
777 778
# It's hightly recommanded to use the nu validator which validates html5
validator_to_use = 'nu'
779 780 781 782
show_warnings = True

validator = None

783
# tidy may not be installed in livecd. Then we will skip xhtml validation tests.
784
# create the validator object
785
if validator_to_use == 'tidy':
786 787 788 789 790 791 792 793
  error = False
  warning = False
  validator_path = '/usr/bin/tidy'
  if not os.path.exists(validator_path):
    print 'tidy is not installed at %s' % validator_path
  else:
    validator = TidyValidator(validator_path, show_warnings)

794 795 796
elif validator_to_use == 'nu':
  validator = NuValidator(show_warnings)

797
def test_suite():
798
  # add the tests
799 800 801
  if validator is not None:
    # add erp5_core to the list here to not return it
    # on getBusinessTemplateList call
802
    addTestMethodDynamically(TestXHTML, validator,
803 804 805 806 807 808 809 810 811 812
      ('erp5_core',) + TestXHTML.getBusinessTemplateList(),
      expected_failure_list=(
          # this view needs VCS preference set (this test suite does not support
          # setting preferences, but this might be a way to fix this).
          'test_erp5_forge_Business_Template_BusinessTemplate_viewVcsStatus',
          # this view only works when solver decision has a relation to a solver.
          # One way to fix this would be to allow a custom "init script" to be called
          # on a portal type.
          'test_erp5_simulation_Solver_Decision_SolverDecision_viewConfiguration',
      ))
813 814 815
  suite = unittest.TestSuite()
  suite.addTest(unittest.makeSuite(TestXHTML))
  return suite