gooderp18绿色标准版
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

2284 lines
91KB

  1. # -*- coding: utf-8 -*-
  2. """
  3. The module :mod:`odoo.tests.common` provides unittest test cases and a few
  4. helpers and classes to write tests.
  5. """
  6. from __future__ import annotations
  7. import base64
  8. import concurrent.futures
  9. import contextlib
  10. import difflib
  11. import importlib
  12. import inspect
  13. import itertools
  14. import json
  15. import logging
  16. import os
  17. import pathlib
  18. import platform
  19. import pprint
  20. import re
  21. import shutil
  22. import signal
  23. import subprocess
  24. import sys
  25. import tempfile
  26. import threading
  27. import time
  28. import traceback
  29. import unittest
  30. import warnings
  31. from collections import defaultdict, deque
  32. from concurrent.futures import Future, CancelledError, wait
  33. from contextlib import contextmanager, ExitStack
  34. from datetime import datetime
  35. from functools import lru_cache, partial
  36. from itertools import zip_longest as izip_longest
  37. from passlib.context import CryptContext
  38. from typing import Optional, Iterable
  39. from unittest.mock import patch, _patch, Mock
  40. from xmlrpc import client as xmlrpclib
  41. try:
  42. from concurrent.futures import InvalidStateError
  43. except ImportError:
  44. InvalidStateError = NotImplementedError
  45. import freezegun
  46. import requests
  47. import werkzeug.urls
  48. from lxml import etree, html
  49. from requests import PreparedRequest, Session
  50. from urllib3.util import Url, parse_url
  51. import odoo
  52. from odoo import api
  53. from odoo.exceptions import AccessError
  54. from odoo.fields import Command
  55. from odoo.modules.registry import Registry
  56. from odoo.service import security
  57. from odoo.sql_db import BaseCursor, Cursor
  58. from odoo.tools import config, float_compare, mute_logger, profiler, SQL, DotDict
  59. from odoo.tools.mail import single_email_re
  60. from odoo.tools.misc import find_in_path, lower_logging
  61. from odoo.tools.xml_utils import _validate_xml
  62. from . import case
  63. try:
  64. # the behaviour of decorator changed in 5.0.5 changing the structure of the traceback when
  65. # an error is raised inside a method using a decorator.
  66. # this is not a hudge problem for test execution but this makes error message
  67. # more difficult to read and breaks test_with_decorators
  68. # This also changes the error format making runbot error matching fail
  69. # This also breaks the first frame meaning that the module detection will also fail on runbot
  70. # In 5.1 decoratorx was introduced and it looks like it has the same behaviour of old decorator
  71. from decorator import decoratorx as decorator
  72. except ImportError:
  73. from decorator import decorator
  74. try:
  75. import websocket
  76. except ImportError:
  77. # chrome headless tests will be skipped
  78. websocket = None
  79. try:
  80. import freezegun
  81. except ImportError:
  82. freezegun = None
  83. _logger = logging.getLogger(__name__)
  84. if config['test_enable'] or config['test_file']:
  85. _logger.info("Importing test framework", stack_info=_logger.isEnabledFor(logging.DEBUG))
  86. else:
  87. _logger.error(
  88. "Importing test framework"
  89. ", avoid importing from business modules and when not running in test mode",
  90. stack_info=True,
  91. )
  92. # backward compatibility: Form was defined in this file
  93. def __getattr__(name):
  94. # pylint: disable=import-outside-toplevel
  95. if name != 'Form':
  96. raise AttributeError(name)
  97. from .form import Form
  98. warnings.warn(
  99. "Since 18.0: odoo.tests.common.Form is deprecated, use odoo.tests.Form",
  100. category=DeprecationWarning,
  101. stacklevel=2,
  102. )
  103. return Form
  104. # The odoo library is supposed already configured.
  105. ADDONS_PATH = odoo.tools.config['addons_path']
  106. HOST = '127.0.0.1'
  107. # Useless constant, tests are aware of the content of demo data
  108. ADMIN_USER_ID = odoo.SUPERUSER_ID
  109. CHECK_BROWSER_SLEEP = 0.1 # seconds
  110. CHECK_BROWSER_ITERATIONS = 100
  111. BROWSER_WAIT = CHECK_BROWSER_SLEEP * CHECK_BROWSER_ITERATIONS # seconds
  112. DEFAULT_SUCCESS_SIGNAL = 'test successful'
  113. def get_db_name():
  114. db = odoo.tools.config['db_name']
  115. # If the database name is not provided on the command-line,
  116. # use the one on the thread (which means if it is provided on
  117. # the command-line, this will break when installing another
  118. # database from XML-RPC).
  119. if not db and hasattr(threading.current_thread(), 'dbname'):
  120. return threading.current_thread().dbname
  121. return db
  122. standalone_tests = defaultdict(list)
  123. def standalone(*tags):
  124. """ Decorator for standalone test functions. This is somewhat dedicated to
  125. tests that install, upgrade or uninstall some modules, which is currently
  126. forbidden in regular test cases. The function is registered under the given
  127. ``tags`` and the corresponding Odoo module name.
  128. """
  129. def register(func):
  130. # register func by odoo module name
  131. if func.__module__.startswith('odoo.addons.'):
  132. module = func.__module__.split('.')[2]
  133. standalone_tests[module].append(func)
  134. # register func with aribitrary name, if any
  135. for tag in tags:
  136. standalone_tests[tag].append(func)
  137. standalone_tests['all'].append(func)
  138. return func
  139. return register
  140. def test_xsd(url=None, path=None, skip=False):
  141. def decorator(func):
  142. def wrapped_f(self, *args, **kwargs):
  143. if not skip:
  144. xmls = func(self, *args, **kwargs)
  145. _validate_xml(self.env, url, path, xmls)
  146. return wrapped_f
  147. return decorator
  148. # For backwards-compatibility - get_db_name() should be used instead
  149. DB = get_db_name()
  150. def new_test_user(env, login='', groups='base.group_user', context=None, **kwargs):
  151. """ Helper function to create a new test user. It allows to quickly create
  152. users given its login and groups (being a comma separated list of xml ids).
  153. Kwargs are directly propagated to the create to further customize the
  154. created user.
  155. User creation uses a potentially customized environment using the context
  156. parameter allowing to specify a custom context. It can be used to force a
  157. specific behavior and/or simplify record creation. An example is to use
  158. mail-related context keys in mail tests to speedup record creation.
  159. Some specific fields are automatically filled to avoid issues
  160. * groups_id: it is filled using groups function parameter;
  161. * name: "login (groups)" by default as it is required;
  162. * email: it is either the login (if it is a valid email) or a generated
  163. string 'x.x@example.com' (x being the first login letter). This is due
  164. to email being required for most odoo operations;
  165. """
  166. if not login:
  167. raise ValueError('New users require at least a login')
  168. if not groups:
  169. raise ValueError('New users require at least user groups')
  170. if context is None:
  171. context = {}
  172. groups_id = [Command.set(kwargs.pop('groups_id', False) or [env.ref(g.strip()).id for g in groups.split(',')])]
  173. create_values = dict(kwargs, login=login, groups_id=groups_id)
  174. # automatically generate a name as "Login (groups)" to ease user comprehension
  175. if not create_values.get('name'):
  176. create_values['name'] = '%s (%s)' % (login, groups)
  177. # automatically give a password equal to login
  178. if not create_values.get('password'):
  179. create_values['password'] = login + 'x' * (8 - len(login))
  180. # generate email if not given as most test require an email
  181. if 'email' not in create_values:
  182. if single_email_re.match(login):
  183. create_values['email'] = login
  184. else:
  185. create_values['email'] = '%s.%s@example.com' % (login[0], login[0])
  186. # ensure company_id + allowed company constraint works if not given at create
  187. if 'company_id' in create_values and 'company_ids' not in create_values:
  188. create_values['company_ids'] = [(4, create_values['company_id'])]
  189. return env['res.users'].with_context(**context).create(create_values)
  190. def loaded_demo_data(env):
  191. return bool(env.ref('base.user_demo', raise_if_not_found=False))
  192. class RecordCapturer:
  193. def __init__(self, model, domain):
  194. self._model = model
  195. self._domain = domain
  196. def __enter__(self):
  197. self._before = self._model.search(self._domain, order='id')
  198. self._after = None
  199. return self
  200. def __exit__(self, exc_type, exc_value, exc_traceback):
  201. if exc_type is None:
  202. self._after = self._model.search(self._domain, order='id') - self._before
  203. @property
  204. def records(self):
  205. if self._after is None:
  206. return self._model.search(self._domain, order='id') - self._before
  207. return self._after
  208. class MetaCase(type):
  209. """ Metaclass of test case classes to assign default 'test_tags':
  210. 'standard', 'at_install' and the name of the module.
  211. """
  212. def __init__(cls, name, bases, attrs):
  213. super(MetaCase, cls).__init__(name, bases, attrs)
  214. # assign default test tags
  215. if cls.__module__.startswith('odoo.addons.'):
  216. if getattr(cls, 'test_tags', None) is None:
  217. cls.test_tags = {'standard', 'at_install'}
  218. cls.test_module = cls.__module__.split('.')[2]
  219. cls.test_class = cls.__name__
  220. cls.test_sequence = 0
  221. def _normalize_arch_for_assert(arch_string, parser_method="xml"):
  222. """Takes some xml and normalize it to make it comparable to other xml
  223. in particular, blank text is removed, and the output is pretty-printed
  224. :param str arch_string: the string representing an XML arch
  225. :param str parser_method: an string representing which lxml.Parser class to use
  226. when normalizing both archs. Takes either "xml" or "html"
  227. :return: the normalized arch
  228. :rtype str:
  229. """
  230. Parser = None
  231. if parser_method == 'xml':
  232. Parser = etree.XMLParser
  233. elif parser_method == 'html':
  234. Parser = etree.HTMLParser
  235. parser = Parser(remove_blank_text=True)
  236. arch_string = etree.fromstring(arch_string, parser=parser)
  237. return etree.tostring(arch_string, pretty_print=True, encoding='unicode')
  238. class BlockedRequest(requests.exceptions.ConnectionError):
  239. pass
  240. _super_send = requests.Session.send
  241. class BaseCase(case.TestCase, metaclass=MetaCase):
  242. """ Subclass of TestCase for Odoo-specific code. This class is abstract and
  243. expects self.registry, self.cr and self.uid to be initialized by subclasses.
  244. """
  245. longMessage = True # more verbose error message by default: https://www.odoo.com/r/Vmh
  246. warm = True # False during warm-up phase (see :func:`warmup`)
  247. _python_version = sys.version_info
  248. _tests_run_count = int(os.environ.get('ODOO_TEST_FAILURE_RETRIES', 0)) + 1
  249. def __init__(self, methodName='runTest'):
  250. super().__init__(methodName)
  251. self.addTypeEqualityFunc(etree._Element, self.assertTreesEqual)
  252. self.addTypeEqualityFunc(html.HtmlElement, self.assertTreesEqual)
  253. @classmethod
  254. def _request_handler(cls, s: Session, r: PreparedRequest, /, **kw):
  255. # allow localhost requests
  256. # TODO: also check port?
  257. url = werkzeug.urls.url_parse(r.url)
  258. if url.host in (HOST, 'localhost'):
  259. return _super_send(s, r, **kw)
  260. if url.scheme == 'file':
  261. return _super_send(s, r, **kw)
  262. _logger.getChild('requests').info(
  263. "Blocking un-mocked external HTTP request %s %s", r.method, r.url)
  264. raise BlockedRequest(f"External requests verboten (was {r.method} {r.url})")
  265. def run(self, result):
  266. testMethod = getattr(self, self._testMethodName)
  267. if getattr(testMethod, '_retry', True) and getattr(self, '_retry', True):
  268. tests_run_count = self._tests_run_count
  269. else:
  270. tests_run_count = 1
  271. _logger.info('Auto retry disabled for %s', self)
  272. quiet_log = None
  273. for retry in range(tests_run_count):
  274. result.had_failure = False # reset in case of retry without soft_fail
  275. if retry:
  276. _logger.runbot(f'Retrying a failed test: {self}')
  277. if retry < tests_run_count-1:
  278. with warnings.catch_warnings(), \
  279. result.soft_fail(), \
  280. lower_logging(25, logging.INFO) as quiet_log:
  281. super().run(result)
  282. if not (result.had_failure or quiet_log.had_error_log):
  283. break
  284. else: # last try
  285. super().run(result)
  286. if not result.wasSuccessful() and BaseCase._tests_run_count != 1:
  287. _logger.runbot('Disabling auto-retry after a failed test')
  288. BaseCase._tests_run_count = 1
  289. @classmethod
  290. def setUpClass(cls):
  291. def check_remaining_patchers():
  292. for patcher in _patch._active_patches:
  293. _logger.warning("A patcher (targeting %s.%s) was remaining active at the end of %s, disabling it...", patcher.target, patcher.attribute, cls.__name__)
  294. patcher.stop()
  295. cls.addClassCleanup(check_remaining_patchers)
  296. super().setUpClass()
  297. if 'standard' in cls.test_tags:
  298. # if the method is passed directly `patch` discards the session
  299. # object which we need
  300. # pylint: disable=unnecessary-lambda
  301. patcher = patch.object(
  302. requests.sessions.Session,
  303. 'send',
  304. lambda s, r, **kwargs: cls._request_handler(s, r, **kwargs),
  305. )
  306. patcher.start()
  307. cls.addClassCleanup(patcher.stop)
  308. def cursor(self):
  309. return self.registry.cursor()
  310. @property
  311. def uid(self):
  312. """ Get the current uid. """
  313. return self.env.uid
  314. @uid.setter
  315. def uid(self, user):
  316. """ Set the uid by changing the test's environment. """
  317. self.env = self.env(user=user)
  318. def ref(self, xid):
  319. """ Returns database ID for the provided :term:`external identifier`,
  320. shortcut for ``_xmlid_lookup``
  321. :param xid: fully-qualified :term:`external identifier`, in the form
  322. :samp:`{module}.{identifier}`
  323. :raise: ValueError if not found
  324. :returns: registered id
  325. """
  326. return self.browse_ref(xid).id
  327. def browse_ref(self, xid):
  328. """ Returns a record object for the provided
  329. :term:`external identifier`
  330. :param xid: fully-qualified :term:`external identifier`, in the form
  331. :samp:`{module}.{identifier}`
  332. :raise: ValueError if not found
  333. :returns: :class:`~odoo.models.BaseModel`
  334. """
  335. assert "." in xid, "this method requires a fully qualified parameter, in the following form: 'module.identifier'"
  336. return self.env.ref(xid)
  337. def patch(self, obj, key, val):
  338. """ Do the patch ``setattr(obj, key, val)``, and prepare cleanup. """
  339. patcher = patch.object(obj, key, val) # this is unittest.mock.patch
  340. patcher.start()
  341. self.addCleanup(patcher.stop)
  342. @classmethod
  343. def classPatch(cls, obj, key, val):
  344. """ Do the patch ``setattr(obj, key, val)``, and prepare cleanup. """
  345. patcher = patch.object(obj, key, val) # this is unittest.mock.patch
  346. patcher.start()
  347. cls.addClassCleanup(patcher.stop)
  348. def startPatcher(self, patcher):
  349. mock = patcher.start()
  350. self.addCleanup(patcher.stop)
  351. return mock
  352. @classmethod
  353. def startClassPatcher(cls, patcher):
  354. mock = patcher.start()
  355. cls.addClassCleanup(patcher.stop)
  356. return mock
  357. @contextmanager
  358. def with_user(self, login):
  359. """ Change user for a given test, like with self.with_user() ... """
  360. old_uid = self.uid
  361. try:
  362. user = self.env['res.users'].sudo().search([('login', '=', login)])
  363. assert user, "Login %s not found" % login
  364. # switch user
  365. self.uid = user.id
  366. self.env = self.env(user=self.uid)
  367. yield
  368. finally:
  369. # back
  370. self.uid = old_uid
  371. self.env = self.env(user=self.uid)
  372. @contextmanager
  373. def debug_mode(self):
  374. """ Enable the effects of debug mode (in particular for group ``base.group_no_one``). """
  375. request = Mock(
  376. httprequest=Mock(host='localhost'),
  377. db=self.env.cr.dbname,
  378. env=self.env,
  379. session=DotDict(odoo.http.get_default_session(), debug='1'),
  380. )
  381. try:
  382. self.env.flush_all()
  383. self.env.invalidate_all()
  384. odoo.http._request_stack.push(request)
  385. yield
  386. self.env.flush_all()
  387. self.env.invalidate_all()
  388. finally:
  389. popped_request = odoo.http._request_stack.pop()
  390. if popped_request is not request:
  391. raise Exception('Wrong request stack cleanup.')
  392. @contextmanager
  393. def _assertRaises(self, exception, *, msg=None):
  394. """ Context manager that clears the environment upon failure. """
  395. with ExitStack() as init:
  396. if hasattr(self, 'env'):
  397. init.enter_context(self.env.cr.savepoint())
  398. if issubclass(exception, AccessError):
  399. # The savepoint() above calls flush(), which leaves the
  400. # record cache with lots of data. This can prevent
  401. # access errors to be detected. In order to avoid this
  402. # issue, we clear the cache before proceeding.
  403. self.env.cr.clear()
  404. with ExitStack() as inner:
  405. cm = inner.enter_context(super().assertRaises(exception, msg=msg))
  406. # *moves* the cleanups from init to inner, this ensures the
  407. # savepoint gets rolled back when `yield` raises `exception`,
  408. # but still allows the initialisation to be protected *and* not
  409. # interfered with by `assertRaises`.
  410. inner.push(init.pop_all())
  411. yield cm
  412. def assertRaises(self, exception, func=None, *args, **kwargs):
  413. if func:
  414. with self._assertRaises(exception):
  415. func(*args, **kwargs)
  416. else:
  417. return self._assertRaises(exception, **kwargs)
  418. def _patchExecute(self, actual_queries, flush=True):
  419. Cursor_execute = Cursor.execute
  420. def execute(self, query, params=None, log_exceptions=None):
  421. actual_queries.append(query.code if isinstance(query, SQL) else query)
  422. return Cursor_execute(self, query, params, log_exceptions)
  423. if flush:
  424. self.env.flush_all()
  425. self.env.cr.flush()
  426. with (
  427. patch('odoo.sql_db.Cursor.execute', execute),
  428. patch.object(self.env.registry, 'unaccent', lambda x: x),
  429. ):
  430. yield actual_queries
  431. if flush:
  432. self.env.flush_all()
  433. self.env.cr.flush()
  434. @contextmanager
  435. def assertQueries(self, expected, flush=True):
  436. """ Check the queries made by the current cursor. ``expected`` is a list
  437. of strings representing the expected queries being made. Query strings
  438. are matched against each other, ignoring case and whitespaces.
  439. """
  440. actual_queries = []
  441. yield from self._patchExecute(actual_queries, flush)
  442. if not self.warm:
  443. return
  444. self.assertEqual(
  445. len(actual_queries), len(expected),
  446. "\n---- actual queries:\n%s\n---- expected queries:\n%s" % (
  447. "\n".join(actual_queries), "\n".join(expected),
  448. )
  449. )
  450. for actual_query, expect_query in zip(actual_queries, expected):
  451. self.assertEqual(
  452. "".join(actual_query.lower().split()),
  453. "".join(expect_query.lower().split()),
  454. "\n---- actual query:\n%s\n---- not like:\n%s" % (actual_query, expect_query),
  455. )
  456. @contextmanager
  457. def assertQueriesContain(self, expected, flush=True):
  458. """ Check the queries made by the current cursor. ``expected`` is a list
  459. of strings representing the expected queries being made. Query strings
  460. are matched against each other, ignoring case and whitespaces.
  461. """
  462. actual_queries = []
  463. yield from self._patchExecute(actual_queries, flush)
  464. if not self.warm:
  465. return
  466. self.assertEqual(
  467. len(actual_queries), len(expected),
  468. "\n---- actual queries:\n%s\n---- expected queries:\n%s" % (
  469. "\n".join(actual_queries), "\n".join(expected),
  470. )
  471. )
  472. for actual_query, expect_query in zip(actual_queries, expected):
  473. self.assertIn(
  474. "".join(expect_query.lower().split()),
  475. "".join(actual_query.lower().split()),
  476. "\n---- actual query:\n%s\n---- doesn't contain:\n%s" % (actual_query, expect_query),
  477. )
  478. @contextmanager
  479. def assertQueryCount(self, default=0, flush=True, **counters):
  480. """ Context manager that counts queries. It may be invoked either with
  481. one value, or with a set of named arguments like ``login=value``::
  482. with self.assertQueryCount(42):
  483. ...
  484. with self.assertQueryCount(admin=3, demo=5):
  485. ...
  486. The second form is convenient when used with :func:`users`.
  487. """
  488. if self.warm:
  489. # mock random in order to avoid random bus gc
  490. with patch('random.random', lambda: 1):
  491. login = self.env.user.login
  492. expected = counters.get(login, default)
  493. if flush:
  494. self.env.flush_all()
  495. self.env.cr.flush()
  496. count0 = self.cr.sql_log_count
  497. yield
  498. if flush:
  499. self.env.flush_all()
  500. self.env.cr.flush()
  501. count = self.cr.sql_log_count - count0
  502. if count != expected:
  503. # add some info on caller to allow semi-automatic update of query count
  504. frame, filename, linenum, funcname, lines, index = inspect.stack()[2]
  505. filename = filename.replace('\\', '/')
  506. if "/odoo/addons/" in filename:
  507. filename = filename.rsplit("/odoo/addons/", 1)[1]
  508. if count > expected:
  509. msg = "Query count more than expected for user %s: %d > %d in %s at %s:%s"
  510. # add a subtest in order to continue the test_method in case of failures
  511. with self.subTest():
  512. self.fail(msg % (login, count, expected, funcname, filename, linenum))
  513. else:
  514. logger = logging.getLogger(type(self).__module__)
  515. msg = "Query count less than expected for user %s: %d < %d in %s at %s:%s"
  516. logger.info(msg, login, count, expected, funcname, filename, linenum)
  517. else:
  518. # flush before and after during warmup, in order to reproduce the
  519. # same operations, otherwise the caches might not be ready!
  520. if flush:
  521. self.env.flush_all()
  522. self.env.cr.flush()
  523. yield
  524. if flush:
  525. self.env.flush_all()
  526. self.env.cr.flush()
  527. def assertRecordValues(
  528. self,
  529. records: odoo.models.BaseModel,
  530. expected_values: list[dict],
  531. *,
  532. field_names: Optional[Iterable[str]] = None,
  533. ) -> None:
  534. ''' Compare a recordset with a list of dictionaries representing the expected results.
  535. This method performs a comparison element by element based on their index.
  536. Then, the order of the expected values is extremely important.
  537. .. note::
  538. - ``None`` expected values can be used for empty fields.
  539. - x2many fields are expected by ids (so the expected value should be
  540. a ``list[int]``
  541. - many2one fields are expected by id (so the expected value should
  542. be an ``int``
  543. :param records: The records to compare.
  544. :param expected_values: Items to check the ``records`` against.
  545. :param field_names: list of fields to check during comparison, if
  546. unspecified all expected_values must have the same
  547. keys and all are checked
  548. '''
  549. if not field_names:
  550. field_names = expected_values[0].keys()
  551. for i, v in enumerate(expected_values):
  552. self.assertEqual(
  553. v.keys(), field_names,
  554. f"All expected values must have the same keys, found differences between records 0 and {i}",
  555. )
  556. expected_reformatted = []
  557. for vs in expected_values:
  558. r = {}
  559. for f in field_names:
  560. t = records._fields[f].type
  561. if t in ('one2many', 'many2many'):
  562. r[f] = sorted(vs[f])
  563. elif t == 'float':
  564. r[f] = float(vs[f])
  565. elif t == 'integer':
  566. r[f] = int(vs[f])
  567. elif vs[f] is None:
  568. r[f] = False
  569. else:
  570. r[f] = vs[f]
  571. expected_reformatted.append(r)
  572. record_reformatted = []
  573. for record in records:
  574. r = {}
  575. for field_name in field_names:
  576. record_value = record[field_name]
  577. match (field := record._fields[field_name]).type:
  578. case 'many2one':
  579. record_value = record_value.id
  580. case 'one2many' | 'many2many':
  581. record_value = sorted(record_value.ids)
  582. case 'float' if digits := field.get_digits(record.env):
  583. record_value = Approx(record_value, digits[1], decorate=False)
  584. case 'monetary' if currency_field_name := field.get_currency_field(record):
  585. # don't round if there's no currency set
  586. if c := record[currency_field_name]:
  587. record_value = Approx(record_value, c, decorate=False)
  588. r[field_name] = record_value
  589. record_reformatted.append(r)
  590. try:
  591. self.assertSequenceEqual(expected_reformatted, record_reformatted, seq_type=list)
  592. return
  593. except AssertionError as e:
  594. standardMsg, _, diffMsg = str(e).rpartition('\n')
  595. if 'self.maxDiff' not in diffMsg:
  596. raise
  597. # move out of handler to avoid exception chaining
  598. diffMsg = "".join(difflib.unified_diff(
  599. pprint.pformat(expected_reformatted).splitlines(keepends=True),
  600. pprint.pformat(record_reformatted).splitlines(keepends=True),
  601. fromfile="expected", tofile="records",
  602. ))
  603. self.fail(self._formatMessage(None, standardMsg + '\n' + diffMsg))
  604. # turns out this thing may not be quite as useful as we thought...
  605. def assertItemsEqual(self, a, b, msg=None):
  606. self.assertCountEqual(a, b, msg=None)
  607. def assertTreesEqual(self, n1, n2, msg=None):
  608. self.assertIsNotNone(n1, msg)
  609. self.assertIsNotNone(n2, msg)
  610. self.assertEqual(n1.tag, n2.tag, msg)
  611. # Because lxml.attrib is an ordereddict for which order is important
  612. # to equality, even though *we* don't care
  613. self.assertEqual(dict(n1.attrib), dict(n2.attrib), msg)
  614. self.assertEqual((n1.text or u'').strip(), (n2.text or u'').strip(), msg)
  615. self.assertEqual((n1.tail or u'').strip(), (n2.tail or u'').strip(), msg)
  616. for c1, c2 in izip_longest(n1, n2):
  617. self.assertTreesEqual(c1, c2, msg)
  618. def _assertXMLEqual(self, original, expected, parser="xml"):
  619. """Asserts that two xmls archs are equal
  620. :param original: the xml arch to test
  621. :type original: str
  622. :param expected: the xml arch of reference
  623. :type expected: str
  624. :param parser: an string representing which lxml.Parser class to use
  625. when normalizing both archs. Takes either "xml" or "html"
  626. :type parser: str
  627. """
  628. self.maxDiff = 10000
  629. if original:
  630. original = _normalize_arch_for_assert(original, parser)
  631. if expected:
  632. expected = _normalize_arch_for_assert(expected, parser)
  633. self.assertEqual(original, expected)
  634. def assertXMLEqual(self, original, expected):
  635. return self._assertXMLEqual(original, expected)
  636. def assertHTMLEqual(self, original, expected):
  637. return self._assertXMLEqual(original, expected, 'html')
  638. def profile(self, description='', **kwargs):
  639. test_method = getattr(self, '_testMethodName', 'Unknown test method')
  640. if not hasattr(self, 'profile_session'):
  641. self.profile_session = profiler.make_session(test_method)
  642. if 'db' not in kwargs:
  643. kwargs['db'] = self.env.cr.dbname
  644. return profiler.Profiler(
  645. description='%s uid:%s %s %s' % (test_method, self.env.user.id, 'warm' if self.warm else 'cold', description),
  646. profile_session=self.profile_session,
  647. **kwargs)
  648. class Like:
  649. """
  650. A string-like object comparable to other strings but where the substring
  651. '...' can match anything in the other string.
  652. Example of usage:
  653. self.assertEqual("SELECT field1, field2, field3 FROM model", Like('SELECT ... FROM model'))
  654. self.assertIn(Like('Company ... (SF)'), ['TestPartner', 'Company 8 (SF)', 'SomeAdress'])
  655. self.assertEqual([
  656. 'TestPartner',
  657. 'Company 8 (SF)',
  658. 'Anything else'
  659. ], [
  660. 'TestPartner',
  661. Like('Company ... (SF)'),
  662. Like('...'),
  663. ])
  664. In case of mismatch, here is an example of error message
  665. AssertionError: Lists differ: ['TestPartner', 'Company 8 (LA)', 'Anything else'] != ['TestPartner', ~Company ... (SF), ~...]
  666. First differing element 1:
  667. 'Company 8 (LA)'
  668. ~Company ... (SF)~
  669. - ['TestPartner', 'Company 8 (LA)', 'Anything else']
  670. + ['TestPartner', ~Company ... (SF), ~...]
  671. """
  672. def __init__(self, pattern):
  673. self.pattern = pattern
  674. self.regex = '.*'.join([re.escape(part.strip()) for part in self.pattern.split('...')])
  675. def __eq__(self, other):
  676. return re.fullmatch(self.regex, other.strip(), re.DOTALL)
  677. def __repr__(self):
  678. return repr(self.pattern)
  679. class Approx: # noqa: PLW1641
  680. """A wrapper for approximate float comparisons. Uses float_compare under
  681. the hood.
  682. Most of the time, :meth:`TestCase.assertAlmostEqual` is more useful, but it
  683. doesn't work for all helpers.
  684. """
  685. def __init__(self, value: float, rounding: int | float | odoo.addons.base.models.res_currency.Currency, /, decorate: bool) -> None: # noqa: PYI041
  686. self.value = value
  687. self.decorate = decorate
  688. if isinstance(rounding, int):
  689. self.cmp = partial(float_compare, precision_digits=rounding)
  690. elif isinstance(rounding, float):
  691. self.cmp = partial(float_compare, precision_rounding=rounding)
  692. else:
  693. self.cmp = rounding.compare_amounts
  694. def __repr__(self) -> str:
  695. if self.decorate:
  696. return f"~{self.value!r}"
  697. return repr(self.value)
  698. def __eq__(self, other: object) -> bool | NotImplemented:
  699. if not isinstance(other, (float, int)):
  700. return NotImplemented
  701. return self.cmp(self.value, other) == 0
  702. savepoint_seq = itertools.count()
  703. class TransactionCase(BaseCase):
  704. """ Test class in which all test methods are run in a single transaction,
  705. but each test method is run in a sub-transaction managed by a savepoint.
  706. The transaction's cursor is always closed without committing.
  707. The data setup common to all methods should be done in the class method
  708. `setUpClass`, so that it is done once for all test methods. This is useful
  709. for test cases containing fast tests but with significant database setup
  710. common to all cases (complex in-db test data).
  711. After being run, each test method cleans up the record cache and the
  712. registry cache. However, there is no cleanup of the registry models and
  713. fields. If a test modifies the registry (custom models and/or fields), it
  714. should prepare the necessary cleanup (`self.registry.reset_changes()`).
  715. """
  716. registry: Registry = None
  717. env: api.Environment = None
  718. cr: Cursor = None
  719. muted_registry_logger = mute_logger(odoo.modules.registry._logger.name)
  720. freeze_time = None
  721. @classmethod
  722. def _gc_filestore(cls):
  723. # attachment can be created or unlink during the tests.
  724. # they can addup during test and take some disc space.
  725. # since cron are not running during tests, we need to gc manually
  726. # We need to check the status of the file system outside of the test cursor
  727. with Registry(get_db_name()).cursor() as cr:
  728. gc_env = api.Environment(cr, odoo.SUPERUSER_ID, {})
  729. gc_env['ir.attachment']._gc_file_store_unsafe()
  730. @classmethod
  731. def setUpClass(cls):
  732. super().setUpClass()
  733. cls.addClassCleanup(cls._gc_filestore)
  734. cls.registry = Registry(get_db_name())
  735. cls.registry_start_invalidated = cls.registry.registry_invalidated
  736. cls.registry_start_sequence = cls.registry.registry_sequence
  737. cls.registry_cache_sequences = dict(cls.registry.cache_sequences)
  738. def reset_changes():
  739. if (cls.registry_start_sequence != cls.registry.registry_sequence) or cls.registry.registry_invalidated:
  740. with cls.registry.cursor() as cr:
  741. cls.registry.setup_models(cr)
  742. cls.registry.registry_invalidated = cls.registry_start_invalidated
  743. cls.registry.registry_sequence = cls.registry_start_sequence
  744. with cls.muted_registry_logger:
  745. cls.registry.clear_all_caches()
  746. cls.registry.cache_invalidated.clear()
  747. cls.registry.cache_sequences = cls.registry_cache_sequences
  748. cls.addClassCleanup(reset_changes)
  749. def signal_changes():
  750. if not cls.registry.ready:
  751. _logger.info('Skipping signal changes during tests')
  752. return
  753. _logger.info('Simulating signal changes during tests')
  754. if cls.registry.registry_invalidated:
  755. cls.registry.registry_sequence += 1
  756. for cache_name in cls.registry.cache_invalidated or ():
  757. cls.registry.cache_sequences[cache_name] += 1
  758. cls.registry.registry_invalidated = False
  759. cls.registry.cache_invalidated.clear()
  760. cls._signal_changes_patcher = patch.object(cls.registry, 'signal_changes', signal_changes)
  761. cls.startClassPatcher(cls._signal_changes_patcher)
  762. cls.cr = cls.registry.cursor()
  763. cls.addClassCleanup(cls.cr.close)
  764. if cls.freeze_time:
  765. cls.startClassPatcher(freezegun.freeze_time(cls.freeze_time))
  766. def forbidden(*args, **kwars):
  767. traceback.print_stack()
  768. raise AssertionError('Cannot commit or rollback a cursor from inside a test, this will lead to a broken cursor when trying to rollback the test. Please rollback to a specific savepoint instead or open another cursor if really necessary')
  769. cls.commit_patcher = patch.object(cls.cr, 'commit', forbidden)
  770. cls.startClassPatcher(cls.commit_patcher)
  771. cls.rollback_patcher = patch.object(cls.cr, 'rollback', forbidden)
  772. cls.startClassPatcher(cls.rollback_patcher)
  773. cls.close_patcher = patch.object(cls.cr, 'close', forbidden)
  774. cls.startClassPatcher(cls.close_patcher)
  775. cls.env = api.Environment(cls.cr, odoo.SUPERUSER_ID, {})
  776. # speedup CryptContext. Many user an password are done during tests, avoid spending time hasing password with many rounds
  777. def _crypt_context(self): # noqa: ARG001
  778. return CryptContext(
  779. ['pbkdf2_sha512', 'plaintext'],
  780. pbkdf2_sha512__rounds=1,
  781. )
  782. cls._crypt_context_patcher = patch('odoo.addons.base.models.res_users.Users._crypt_context', _crypt_context)
  783. cls.startClassPatcher(cls._crypt_context_patcher)
  784. def setUp(self):
  785. super().setUp()
  786. # restore environments after the test to avoid invoking flush() with an
  787. # invalid environment (inexistent user id) from another test
  788. envs = self.env.transaction.envs
  789. for env in list(envs):
  790. self.addCleanup(env.clear)
  791. # restore the set of known environments as it was at setUp
  792. self.addCleanup(envs.update, list(envs))
  793. self.addCleanup(envs.clear)
  794. self.addCleanup(self.muted_registry_logger(self.registry.clear_all_caches))
  795. # This prevents precommit functions and data from piling up
  796. # until cr.flush is called in 'assertRaises' clauses
  797. # (these are not cleared in self.env.clear or envs.clear)
  798. cr = self.env.cr
  799. def _reset(cb, funcs, data):
  800. cb._funcs = funcs
  801. cb.data = data
  802. for callback in [cr.precommit, cr.postcommit, cr.prerollback, cr.postrollback]:
  803. self.addCleanup(_reset, callback, deque(callback._funcs), dict(callback.data))
  804. # flush everything in setUpClass before introducing a savepoint
  805. self.env.flush_all()
  806. self._savepoint_id = next(savepoint_seq)
  807. self.cr.execute('SAVEPOINT test_%d' % self._savepoint_id)
  808. self.addCleanup(self.cr.execute, 'ROLLBACK TO SAVEPOINT test_%d' % self._savepoint_id)
  809. class SingleTransactionCase(BaseCase):
  810. """ TestCase in which all test methods are run in the same transaction,
  811. the transaction is started with the first test method and rolled back at
  812. the end of the last.
  813. """
  814. @classmethod
  815. def __init_subclass__(cls):
  816. super().__init_subclass__()
  817. if issubclass(cls, TransactionCase):
  818. _logger.warning("%s inherits from both TransactionCase and SingleTransactionCase")
  819. @classmethod
  820. def setUpClass(cls):
  821. super().setUpClass()
  822. cls.registry = Registry(get_db_name())
  823. cls.addClassCleanup(cls.registry.reset_changes)
  824. cls.addClassCleanup(cls.registry.clear_all_caches)
  825. cls.cr = cls.registry.cursor()
  826. cls.addClassCleanup(cls.cr.close)
  827. cls.env = api.Environment(cls.cr, odoo.SUPERUSER_ID, {})
  828. def setUp(self):
  829. super(SingleTransactionCase, self).setUp()
  830. self.env.flush_all()
  831. class ChromeBrowserException(Exception):
  832. pass
  833. def run(gen_func):
  834. def done(f):
  835. try:
  836. try:
  837. r = f.result()
  838. except Exception as e:
  839. f = coro.throw(e)
  840. else:
  841. f = coro.send(r)
  842. except StopIteration:
  843. return
  844. assert isinstance(f, Future), f"coroutine must yield futures, got {f}"
  845. f.add_done_callback(done)
  846. coro = gen_func()
  847. try:
  848. next(coro).add_done_callback(done)
  849. except StopIteration:
  850. return
  851. def save_test_file(test_name, content, prefix, extension='png', logger=_logger, document_type='Screenshot', date_format="%Y%m%d_%H%M%S_%f"):
  852. assert re.fullmatch(r'\w*_', prefix)
  853. assert re.fullmatch(r'[a-z]+', extension)
  854. assert re.fullmatch(r'\w+', test_name)
  855. now = datetime.now().strftime(date_format)
  856. screenshots_dir = pathlib.Path(odoo.tools.config['screenshots']) / get_db_name() / 'screenshots'
  857. screenshots_dir.mkdir(parents=True, exist_ok=True)
  858. fname = f'{prefix}{now}_{test_name}.{extension}'
  859. full_path = screenshots_dir / fname
  860. with full_path.open('wb') as f:
  861. f.write(content)
  862. logger.runbot(f'{document_type} in: {full_path}')
  863. class ChromeBrowser:
  864. """ Helper object to control a Chrome headless process. """
  865. remote_debugging_port = 0 # 9222, change it in a non-git-tracked file
  866. def __init__(self, test_case: HttpCase, success_signal: str = DEFAULT_SUCCESS_SIGNAL, headless: bool = True, debug: bool = False):
  867. self._logger = test_case._logger
  868. self.test_case = test_case
  869. self.success_signal = success_signal
  870. if websocket is None:
  871. self._logger.warning("websocket-client module is not installed")
  872. raise unittest.SkipTest("websocket-client module is not installed")
  873. self.user_data_dir = tempfile.mkdtemp(suffix='_chrome_odoo')
  874. otc = odoo.tools.config
  875. self.screencasts_dir = None
  876. self.screencast_frames = []
  877. if otc['screencasts']:
  878. self.screencasts_dir = os.path.join(otc['screencasts'], get_db_name(), 'screencasts')
  879. os.makedirs(self.screencasts_frames_dir, exist_ok=True)
  880. if os.name == 'posix':
  881. self.sigxcpu_handler = signal.getsignal(signal.SIGXCPU)
  882. signal.signal(signal.SIGXCPU, self.signal_handler)
  883. else:
  884. self.sigxcpu_handler = None
  885. test_case.browser_size = test_case.browser_size.replace('x', ',')
  886. self.chrome, self.devtools_port = self._chrome_start(
  887. user_data_dir=self.user_data_dir,
  888. touch_enabled=test_case.touch_enabled,
  889. headless=headless,
  890. debug=debug,
  891. )
  892. self.ws = self._open_websocket()
  893. self._request_id = itertools.count()
  894. self._result = Future()
  895. self.error_checker = None
  896. self.had_failure = False
  897. # maps request_id to Futures
  898. self._responses = {}
  899. # maps frame ids to callbacks
  900. self._frames = {}
  901. self._handlers = {
  902. 'Runtime.consoleAPICalled': self._handle_console,
  903. 'Runtime.exceptionThrown': self._handle_exception,
  904. 'Page.frameStoppedLoading': self._handle_frame_stopped_loading,
  905. 'Page.screencastFrame': self._handle_screencast_frame,
  906. }
  907. self._receiver = threading.Thread(
  908. target=self._receive,
  909. name="WebSocket events consumer",
  910. args=(get_db_name(),)
  911. )
  912. self._receiver.start()
  913. self._logger.info('Enable chrome headless console log notification')
  914. self._websocket_send('Runtime.enable')
  915. self._logger.info('Chrome headless enable page notifications')
  916. self._websocket_send('Page.enable')
  917. self._websocket_send('Page.setDownloadBehavior', params={
  918. 'behavior': 'deny',
  919. 'eventsEnabled': False,
  920. })
  921. self._websocket_send('Emulation.setFocusEmulationEnabled', params={'enabled': True})
  922. emulated_device = {
  923. 'mobile': False,
  924. 'width': None,
  925. 'height': None,
  926. 'deviceScaleFactor': 1,
  927. }
  928. emulated_device['width'], emulated_device['height'] = [int(size) for size in test_case.browser_size.split(",")]
  929. self._websocket_request('Emulation.setDeviceMetricsOverride', params=emulated_device)
  930. @property
  931. def screencasts_frames_dir(self):
  932. if screencasts_dir := self.screencasts_dir:
  933. return os.path.join(screencasts_dir, 'frames')
  934. else:
  935. return None
  936. def signal_handler(self, sig, frame):
  937. if sig == signal.SIGXCPU:
  938. _logger.info('CPU time limit reached, stopping Chrome and shutting down')
  939. self.stop()
  940. os._exit(0)
  941. def stop(self):
  942. if hasattr(self, 'ws'):
  943. self._websocket_send('Page.stopScreencast')
  944. if screencasts_frames_dir := self.screencasts_frames_dir:
  945. self.screencasts_dir = None
  946. if os.path.isdir(screencasts_frames_dir):
  947. shutil.rmtree(screencasts_frames_dir, ignore_errors=True)
  948. self._websocket_request('Page.stopLoading')
  949. self._websocket_request('Runtime.evaluate', params={'expression': """
  950. ('serviceWorker' in navigator) &&
  951. navigator.serviceWorker.getRegistrations().then(
  952. registrations => Promise.all(registrations.map(r => r.unregister()))
  953. )
  954. """, 'awaitPromise': True})
  955. # wait for the screenshot or whatever
  956. wait(self._responses.values(), 10)
  957. self._result.cancel()
  958. self._logger.info("Closing chrome headless with pid %s", self.chrome.pid)
  959. self._websocket_send('Browser.close')
  960. self._logger.info("Closing websocket connection")
  961. self.ws.close()
  962. if self.chrome:
  963. self._logger.info("Terminating chrome headless with pid %s", self.chrome.pid)
  964. self.chrome.terminate()
  965. if self.user_data_dir and os.path.isdir(self.user_data_dir) and self.user_data_dir != '/':
  966. self._logger.info('Removing chrome user profile "%s"', self.user_data_dir)
  967. shutil.rmtree(self.user_data_dir, ignore_errors=True)
  968. # Restore previous signal handler
  969. if self.sigxcpu_handler and os.name == 'posix':
  970. signal.signal(signal.SIGXCPU, self.sigxcpu_handler)
  971. @property
  972. def executable(self):
  973. return _find_executable()
  974. def _chrome_without_limit(self, cmd):
  975. if os.name == 'posix' and platform.system() != 'Darwin':
  976. # since the introduction of pointer compression in Chrome 80 (v8 v8.0),
  977. # the memory reservation algorithm requires more than 8GiB of
  978. # virtual mem for alignment this exceeds our default memory limits.
  979. def preexec():
  980. import resource
  981. resource.setrlimit(resource.RLIMIT_AS, (resource.RLIM_INFINITY, resource.RLIM_INFINITY))
  982. else:
  983. preexec = None
  984. # pylint: disable=subprocess-popen-preexec-fn
  985. return subprocess.Popen(cmd, stderr=subprocess.DEVNULL, preexec_fn=preexec)
  986. def _spawn_chrome(self, cmd):
  987. proc = self._chrome_without_limit(cmd)
  988. port_file = pathlib.Path(self.user_data_dir, 'DevToolsActivePort')
  989. for _ in range(CHECK_BROWSER_ITERATIONS):
  990. time.sleep(CHECK_BROWSER_SLEEP)
  991. if port_file.is_file() and port_file.stat().st_size > 5:
  992. with port_file.open('r', encoding='utf-8') as f:
  993. return proc, int(f.readline())
  994. raise unittest.SkipTest(f'Failed to detect chrome devtools port after {BROWSER_WAIT :.1f}s.')
  995. def _chrome_start(
  996. self,
  997. user_data_dir: str,
  998. touch_enabled: bool,
  999. headless=True,
  1000. debug=False,
  1001. ):
  1002. headless_switches = {
  1003. '--headless': '',
  1004. '--disable-extensions': '',
  1005. '--disable-background-networking' : '',
  1006. '--disable-background-timer-throttling' : '',
  1007. '--disable-backgrounding-occluded-windows': '',
  1008. '--disable-renderer-backgrounding' : '',
  1009. '--disable-breakpad': '',
  1010. '--disable-client-side-phishing-detection': '',
  1011. '--disable-crash-reporter': '',
  1012. '--disable-dev-shm-usage': '',
  1013. '--disable-namespace-sandbox': '',
  1014. '--disable-translate': '',
  1015. '--no-sandbox': '',
  1016. '--disable-gpu': '',
  1017. }
  1018. switches = {
  1019. # required for tours that use Youtube autoplay conditions (namely website_slides' "course_tour")
  1020. '--autoplay-policy': 'no-user-gesture-required',
  1021. '--disable-default-apps': '',
  1022. '--disable-device-discovery-notifications': '',
  1023. '--no-default-browser-check': '',
  1024. '--remote-debugging-address': HOST,
  1025. '--remote-debugging-port': str(self.remote_debugging_port),
  1026. '--user-data-dir': user_data_dir,
  1027. '--no-first-run': '',
  1028. # FIXME: these next 2 flags are temporarily uncommented to allow client
  1029. # code to manually run garbage collection. This is done as currently
  1030. # the Chrome unit test process doesn't have access to its available
  1031. # memory, so it cannot run the GC efficiently and may run out of memory
  1032. # and crash. These should be re-commented when the process is correctly
  1033. # configured.
  1034. '--enable-precise-memory-info': '',
  1035. '--js-flags': '--expose-gc',
  1036. }
  1037. if headless:
  1038. switches.update(headless_switches)
  1039. if touch_enabled:
  1040. # enable Chrome's Touch mode, useful to detect touch capabilities using
  1041. # "'ontouchstart' in window"
  1042. switches['--touch-events'] = ''
  1043. if debug is not False:
  1044. switches['--auto-open-devtools-for-tabs'] = ''
  1045. switches['--start-fullscreen'] = ''
  1046. cmd = [self.executable]
  1047. cmd += ['%s=%s' % (k, v) if v else k for k, v in switches.items()]
  1048. url = 'about:blank'
  1049. cmd.append(url)
  1050. try:
  1051. proc, devtools_port = self._spawn_chrome(cmd)
  1052. except OSError:
  1053. raise unittest.SkipTest("%s not found" % cmd[0])
  1054. self._logger.info('Chrome pid: %s', proc.pid)
  1055. self._logger.info('Chrome headless temporary user profile dir: %s', self.user_data_dir)
  1056. return proc, devtools_port
  1057. def _json_command(self, command, timeout=3):
  1058. """Queries browser state using JSON
  1059. Available commands:
  1060. ``''``
  1061. return list of tabs with their id
  1062. ``list`` (or ``json/``)
  1063. list tabs
  1064. ``new``
  1065. open a new tab
  1066. :samp:`activate/{id}`
  1067. activate a tab
  1068. :samp:`close/{id}`
  1069. close a tab
  1070. ``version``
  1071. get chrome and dev tools version
  1072. ``protocol``
  1073. get the full protocol
  1074. """
  1075. command = '/'.join(['json', command]).strip('/')
  1076. url = werkzeug.urls.url_join('http://%s:%s/' % (HOST, self.devtools_port), command)
  1077. self._logger.info("Issuing json command %s", url)
  1078. delay = 0.1
  1079. tries = 0
  1080. failure_info = None
  1081. message = None
  1082. while timeout > 0:
  1083. try:
  1084. self.chrome.send_signal(0)
  1085. except ProcessLookupError:
  1086. message = 'Chrome crashed at startup'
  1087. break
  1088. try:
  1089. r = requests.get(url, timeout=3)
  1090. if r.ok:
  1091. return r.json()
  1092. except requests.ConnectionError as e:
  1093. failure_info = str(e)
  1094. message = 'Connection Error while trying to connect to Chrome debugger'
  1095. except requests.exceptions.ReadTimeout as e:
  1096. failure_info = str(e)
  1097. message = 'Connection Timeout while trying to connect to Chrome debugger'
  1098. break
  1099. time.sleep(delay)
  1100. timeout -= delay
  1101. delay = delay * 1.5
  1102. tries += 1
  1103. self._logger.error("%s after %s tries" % (message, tries))
  1104. if failure_info:
  1105. self._logger.info(failure_info)
  1106. self.stop()
  1107. raise unittest.SkipTest("Error during Chrome headless connection")
  1108. def _open_websocket(self):
  1109. version = self._json_command('version')
  1110. self._logger.info('Browser version: %s', version['Browser'])
  1111. start = time.time()
  1112. while (time.time() - start) < 5.0:
  1113. ws_url = next((
  1114. target['webSocketDebuggerUrl']
  1115. for target in self._json_command('')
  1116. if target['type'] == 'page'
  1117. if target['url'] == 'about:blank'
  1118. ), None)
  1119. if ws_url:
  1120. break
  1121. time.sleep(0.1)
  1122. else:
  1123. self.stop()
  1124. raise unittest.SkipTest("Error during Chrome connection: never found 'page' target")
  1125. self._logger.info('Websocket url found: %s', ws_url)
  1126. ws = websocket.create_connection(ws_url, enable_multithread=True, suppress_origin=True)
  1127. if ws.getstatus() != 101:
  1128. raise unittest.SkipTest("Cannot connect to chrome dev tools")
  1129. ws.settimeout(0.01)
  1130. return ws
  1131. def _receive(self, dbname):
  1132. threading.current_thread().dbname = dbname
  1133. # So CDT uses a streamed JSON-RPC structure, meaning a request is
  1134. # {id, method, params} and eventually a {id, result | error} should
  1135. # arrive the other way, however for events it uses "notifications"
  1136. # meaning request objects without an ``id``, but *coming from the server
  1137. while True: # or maybe until `self._result` is `done()`?
  1138. try:
  1139. msg = self.ws.recv()
  1140. if not msg:
  1141. continue
  1142. self._logger.debug('\n<- %s', msg)
  1143. except websocket.WebSocketTimeoutException:
  1144. continue
  1145. except Exception as e:
  1146. # if the socket is still connected something bad happened,
  1147. # otherwise the client was just shut down
  1148. if self.ws.connected:
  1149. self._result.set_exception(e)
  1150. raise
  1151. self._result.cancel()
  1152. return
  1153. res = json.loads(msg)
  1154. request_id = res.get('id')
  1155. try:
  1156. if request_id is None:
  1157. handler = self._handlers.get(res['method'])
  1158. if handler:
  1159. handler(**res['params'])
  1160. else:
  1161. f = self._responses.pop(request_id, None)
  1162. if f:
  1163. if 'result' in res:
  1164. f.set_result(res['result'])
  1165. else:
  1166. f.set_exception(ChromeBrowserException(res['error']['message']))
  1167. except Exception:
  1168. msg = str(msg)
  1169. if msg and len(msg) > 500:
  1170. msg = msg[:500] + '...'
  1171. _logger.exception("While processing message %s", msg)
  1172. def _websocket_request(self, method, *, params=None, timeout=10.0):
  1173. assert threading.get_ident() != self._receiver.ident,\
  1174. "_websocket_request must not be called from the consumer thread"
  1175. if self.ws is None:
  1176. return
  1177. f = self._websocket_send(method, params=params, with_future=True)
  1178. try:
  1179. return f.result(timeout=timeout)
  1180. except concurrent.futures.TimeoutError:
  1181. raise TimeoutError(f'{method}({params or ""})')
  1182. def _websocket_send(self, method, *, params=None, with_future=False):
  1183. """send chrome devtools protocol commands through websocket
  1184. If ``with_future`` is set, returns a ``Future`` for the operation.
  1185. """
  1186. if self.ws is None:
  1187. return
  1188. result = None
  1189. request_id = next(self._request_id)
  1190. if with_future:
  1191. result = self._responses[request_id] = Future()
  1192. payload = {'method': method, 'id': request_id}
  1193. if params:
  1194. payload['params'] = params
  1195. self._logger.debug('\n-> %s', payload)
  1196. self.ws.send(json.dumps(payload))
  1197. return result
  1198. def _handle_console(self, type, args=None, stackTrace=None, **kw): # pylint: disable=redefined-builtin
  1199. # console formatting differs somewhat from Python's, if args[0] has
  1200. # format modifiers that many of args[1:] get formatted in, missing
  1201. # args are replaced by empty strings and extra args are concatenated
  1202. # (space-separated)
  1203. #
  1204. # current version modifies the args in place which could and should
  1205. # probably be improved
  1206. if args:
  1207. arg0, args = str(self._from_remoteobject(args[0])), args[1:]
  1208. else:
  1209. arg0, args = '', []
  1210. formatted = [re.sub(r'%[%sdfoOc]', self.console_formatter(args), arg0)]
  1211. # formatter consumes args it uses, leaves unformatted args untouched
  1212. formatted.extend(str(self._from_remoteobject(arg)) for arg in args)
  1213. message = ' '.join(formatted)
  1214. stack = ''.join(self._format_stack({'type': type, 'stackTrace': stackTrace}))
  1215. if stack:
  1216. message += '\n' + stack
  1217. log_type = type
  1218. _logger = self._logger.getChild('browser')
  1219. _logger.log(
  1220. self._TO_LEVEL.get(log_type, logging.INFO),
  1221. "%s%s",
  1222. "Error received after termination: " if self._result.done() else "",
  1223. message # might still have %<x> characters
  1224. )
  1225. if log_type == 'error':
  1226. self.had_failure = True
  1227. if self._result.done():
  1228. return
  1229. if not self.error_checker or self.error_checker(message):
  1230. self.take_screenshot()
  1231. self._save_screencast()
  1232. try:
  1233. self._result.set_exception(ChromeBrowserException(message))
  1234. except CancelledError:
  1235. ...
  1236. except InvalidStateError:
  1237. self._logger.warning(
  1238. "Trying to set result to failed (%s) but found the future settled (%s)",
  1239. message, self._result
  1240. )
  1241. elif message == self.success_signal:
  1242. @run
  1243. def _get_heap():
  1244. yield self._websocket_send("HeapProfiler.collectGarbage", with_future=True)
  1245. r = yield self._websocket_send("Runtime.getHeapUsage", with_future=True)
  1246. _logger.info("heap %d (allocated %d)", r['usedSize'], r['totalSize'])
  1247. if self.test_case.allow_end_on_form:
  1248. self._result.set_result(True)
  1249. return
  1250. @run
  1251. def _check_form():
  1252. node_id = 0
  1253. with contextlib.suppress(Exception):
  1254. d = yield self._websocket_send('DOM.getDocument', params={'depth': 0}, with_future=True)
  1255. form = yield self._websocket_send("DOM.querySelector", params={
  1256. 'nodeId': d['root']['nodeId'],
  1257. 'selector': '.o_form_dirty',
  1258. }, with_future=True)
  1259. node_id = form['nodeId']
  1260. if node_id:
  1261. self.take_screenshot("unsaved_form_")
  1262. msg = """\
  1263. Tour finished with an open form view in edition mode.
  1264. Form views in edition mode are automatically saved when the page is closed, \
  1265. which leads to stray network requests and inconsistencies."""
  1266. if self._result.done():
  1267. _logger.error("%s", msg)
  1268. else:
  1269. self._result.set_exception(ChromeBrowserException(msg))
  1270. return
  1271. if not self._result.done():
  1272. self._result.set_result(True)
  1273. elif self._result.exception() is None:
  1274. # if the future was already failed, we're happy,
  1275. # otherwise swap for a new failed
  1276. _logger.error("Tried to make the tour successful twice.")
  1277. def _handle_exception(self, exceptionDetails, timestamp):
  1278. message = exceptionDetails['text']
  1279. exception = exceptionDetails.get('exception')
  1280. if exception:
  1281. message += str(self._from_remoteobject(exception))
  1282. exceptionDetails['type'] = 'trace' # fake this so _format_stack works
  1283. stack = ''.join(self._format_stack(exceptionDetails))
  1284. if stack:
  1285. message += '\n' + stack
  1286. if self._result.done():
  1287. self._logger.getChild('browser').error(
  1288. "Exception received after termination: %s", message)
  1289. return
  1290. self.take_screenshot()
  1291. self._save_screencast()
  1292. try:
  1293. self._result.set_exception(ChromeBrowserException(message))
  1294. except CancelledError:
  1295. ...
  1296. except InvalidStateError:
  1297. self._logger.warning(
  1298. "Trying to set result to failed (%s) but found the future settled (%s)",
  1299. message, self._result
  1300. )
  1301. def _handle_frame_stopped_loading(self, frameId):
  1302. wait = self._frames.pop(frameId, None)
  1303. if wait:
  1304. wait()
  1305. def _handle_screencast_frame(self, sessionId, data, metadata):
  1306. frames_dir = self.screencasts_frames_dir
  1307. if not frames_dir:
  1308. return
  1309. self._websocket_send('Page.screencastFrameAck', params={'sessionId': sessionId})
  1310. outfile = os.path.join(frames_dir, 'frame_%05d.b64' % len(self.screencast_frames))
  1311. try:
  1312. with open(outfile, 'w') as f:
  1313. f.write(data)
  1314. self.screencast_frames.append({
  1315. 'file_path': outfile,
  1316. 'timestamp': metadata.get('timestamp')
  1317. })
  1318. except FileNotFoundError:
  1319. self._logger.debug('Useless screencast frame skipped: %s', outfile)
  1320. _TO_LEVEL = {
  1321. 'debug': logging.DEBUG,
  1322. 'log': logging.INFO,
  1323. 'info': logging.INFO,
  1324. 'warning': logging.WARNING,
  1325. 'error': logging.ERROR,
  1326. 'dir': logging.RUNBOT,
  1327. # TODO: what do with
  1328. # dir, dirxml, table, trace, clear, startGroup, startGroupCollapsed,
  1329. # endGroup, assert, profile, profileEnd, count, timeEnd
  1330. }
  1331. def take_screenshot(self, prefix='sc_'):
  1332. def handler(f):
  1333. try:
  1334. base_png = f.result(timeout=0)['data']
  1335. except Exception as e:
  1336. self._logger.runbot("Couldn't capture screenshot: %s", e)
  1337. return
  1338. if not base_png:
  1339. self._logger.runbot("Couldn't capture screenshot: expected image data, got %r", base_png)
  1340. return
  1341. decoded = base64.b64decode(base_png, validate=True)
  1342. save_test_file(type(self.test_case).__name__, decoded, prefix, logger=self._logger)
  1343. self._logger.info('Asking for screenshot')
  1344. f = self._websocket_send('Page.captureScreenshot', with_future=True)
  1345. f.add_done_callback(handler)
  1346. return f
  1347. def _save_screencast(self, prefix='failed'):
  1348. # could be encododed with something like that
  1349. # ffmpeg -framerate 3 -i frame_%05d.png output.mp4
  1350. if not self.screencast_frames:
  1351. self._logger.debug('No screencast frames to encode')
  1352. return None
  1353. self.stop_screencast()
  1354. for f in self.screencast_frames:
  1355. with open(f['file_path'], 'rb') as b64_file:
  1356. frame = base64.decodebytes(b64_file.read())
  1357. os.unlink(f['file_path'])
  1358. f['file_path'] = f['file_path'].replace('.b64', '.png')
  1359. with open(f['file_path'], 'wb') as png_file:
  1360. png_file.write(frame)
  1361. timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_%f')
  1362. fname = '%s_screencast_%s.mp4' % (prefix, timestamp)
  1363. outfile = os.path.join(self.screencasts_dir, fname)
  1364. try:
  1365. ffmpeg_path = find_in_path('ffmpeg')
  1366. except IOError:
  1367. ffmpeg_path = None
  1368. if ffmpeg_path:
  1369. nb_frames = len(self.screencast_frames)
  1370. concat_script_path = os.path.join(self.screencasts_dir, fname.replace('.mp4', '.txt'))
  1371. with open(concat_script_path, 'w') as concat_file:
  1372. for i in range(nb_frames):
  1373. frame_file_path = os.path.join(self.screencasts_frames_dir, self.screencast_frames[i]['file_path'])
  1374. end_time = time.time() if i == nb_frames - 1 else self.screencast_frames[i+1]['timestamp']
  1375. duration = end_time - self.screencast_frames[i]['timestamp']
  1376. concat_file.write("file '%s'\nduration %s\n" % (frame_file_path, duration))
  1377. concat_file.write("file '%s'" % frame_file_path) # needed by the concat plugin
  1378. try:
  1379. subprocess.run([ffmpeg_path, '-f', 'concat', '-safe', '0', '-i', concat_script_path, '-pix_fmt', 'yuv420p', '-g', '0', outfile], check=True)
  1380. except subprocess.CalledProcessError:
  1381. self._logger.error('Failed to encode screencast.')
  1382. return
  1383. self._logger.log(25, 'Screencast in: %s', outfile)
  1384. else:
  1385. outfile = outfile.strip('.mp4')
  1386. shutil.move(self.screencasts_frames_dir, outfile)
  1387. self._logger.runbot('Screencast frames in: %s', outfile)
  1388. def start_screencast(self):
  1389. assert self.screencasts_dir
  1390. self._websocket_send('Page.startScreencast')
  1391. def stop_screencast(self):
  1392. self._websocket_send('Page.stopScreencast')
  1393. def set_cookie(self, name, value, path, domain):
  1394. params = {'name': name, 'value': value, 'path': path, 'domain': domain}
  1395. self._websocket_request('Network.setCookie', params=params)
  1396. return
  1397. def delete_cookie(self, name, **kwargs):
  1398. params = {k: v for k, v in kwargs.items() if k in ['url', 'domain', 'path']}
  1399. params['name'] = name
  1400. self._websocket_request('Network.deleteCookies', params=params)
  1401. return
  1402. def _wait_ready(self, ready_code=None, timeout=60):
  1403. ready_code = ready_code or "document.readyState === 'complete'"
  1404. self._logger.info('Evaluate ready code "%s"', ready_code)
  1405. start_time = time.time()
  1406. result = None
  1407. while True:
  1408. taken = time.time() - start_time
  1409. if taken > timeout:
  1410. break
  1411. result = self._websocket_request('Runtime.evaluate', params={
  1412. 'expression': "try { %s } catch {}" % ready_code,
  1413. 'awaitPromise': True,
  1414. }, timeout=timeout-taken)['result']
  1415. if result == {'type': 'boolean', 'value': True}:
  1416. time_to_ready = time.time() - start_time
  1417. if taken > 2:
  1418. self._logger.info('The ready code tooks too much time : %s', time_to_ready)
  1419. return True
  1420. self.take_screenshot(prefix='sc_failed_ready_')
  1421. self._logger.info('Ready code last try result: %s', result)
  1422. return False
  1423. def _wait_code_ok(self, code, timeout, error_checker=None):
  1424. self.error_checker = error_checker
  1425. self._logger.info('Evaluate test code "%s"', code)
  1426. start = time.time()
  1427. res = self._websocket_request('Runtime.evaluate', params={
  1428. 'expression': code,
  1429. 'awaitPromise': True,
  1430. }, timeout=timeout)['result']
  1431. if res.get('subtype') == 'error':
  1432. raise ChromeBrowserException("Running code returned an error: %s" % res)
  1433. err = ChromeBrowserException("failed")
  1434. try:
  1435. # if the runcode was a promise which took some time to execute,
  1436. # discount that from the timeout
  1437. if self._result.result(time.time() - start + timeout) and not self.had_failure:
  1438. return
  1439. except CancelledError:
  1440. # regular-ish shutdown
  1441. return
  1442. except Exception as e:
  1443. err = e
  1444. self.take_screenshot()
  1445. self._save_screencast()
  1446. if isinstance(err, ChromeBrowserException):
  1447. raise err
  1448. if isinstance(err, concurrent.futures.TimeoutError):
  1449. raise ChromeBrowserException('Script timeout exceeded') from err
  1450. raise ChromeBrowserException("Unknown error") from err
  1451. def navigate_to(self, url, wait_stop=False):
  1452. self._logger.info('Navigating to: "%s"', url)
  1453. nav_result = self._websocket_request('Page.navigate', params={'url': url}, timeout=20.0)
  1454. self._logger.info("Navigation result: %s", nav_result)
  1455. if wait_stop:
  1456. frame_id = nav_result['frameId']
  1457. e = threading.Event()
  1458. self._frames[frame_id] = e.set
  1459. self._logger.info('Waiting for frame %r to stop loading', frame_id)
  1460. e.wait(10)
  1461. def _from_remoteobject(self, arg):
  1462. """ attempts to make a CDT RemoteObject comprehensible
  1463. """
  1464. objtype = arg['type']
  1465. subtype = arg.get('subtype')
  1466. if objtype == 'undefined':
  1467. # the undefined remoteobject is literally just {type: undefined}...
  1468. return 'undefined'
  1469. elif objtype != 'object' or subtype not in (None, 'array'):
  1470. # value is the json representation for json object
  1471. # otherwise fallback on the description which is "a string
  1472. # representation of the object" e.g. the traceback for errors, the
  1473. # source for functions, ... finally fallback on the entire arg mess
  1474. return arg.get('value', arg.get('description', arg))
  1475. elif subtype == 'array':
  1476. # apparently value is *not* the JSON representation for arrays
  1477. # instead it's just Array(3) which is useless, however the preview
  1478. # properties are the same as object which is useful (just ignore the
  1479. # name which is the index)
  1480. return '[%s]' % ', '.join(
  1481. repr(p['value']) if p['type'] == 'string' else str(p['value'])
  1482. for p in arg.get('preview', {}).get('properties', [])
  1483. if re.match(r'\d+', p['name'])
  1484. )
  1485. # all that's left is type=object, subtype=None aka custom or
  1486. # non-standard objects, print as TypeName(param=val, ...), sadly because
  1487. # of the way Odoo widgets are created they all appear as Class(...)
  1488. # nb: preview properties are *not* recursive, the value is *all* we get
  1489. return '%s(%s)' % (
  1490. arg.get('className') or 'object',
  1491. ', '.join(
  1492. '%s=%s' % (p['name'], repr(p['value']) if p['type'] == 'string' else p['value'])
  1493. for p in arg.get('preview', {}).get('properties', [])
  1494. if p.get('value') is not None
  1495. )
  1496. )
  1497. LINE_PATTERN = '\tat %(functionName)s (%(url)s:%(lineNumber)d:%(columnNumber)d)\n'
  1498. def _format_stack(self, logrecord):
  1499. if logrecord['type'] not in ['trace']:
  1500. return
  1501. trace = logrecord.get('stackTrace')
  1502. while trace:
  1503. for f in trace['callFrames']:
  1504. yield self.LINE_PATTERN % f
  1505. trace = trace.get('parent')
  1506. def console_formatter(self, args):
  1507. """ Formats similarly to the console API:
  1508. * if there are no args, don't format (return string as-is)
  1509. * %% -> %
  1510. * %c -> replace by styling directives (ignore for us)
  1511. * other known formatters -> replace by corresponding argument
  1512. * leftover known formatters (args exhausted) -> replace by empty string
  1513. * unknown formatters -> return as-is
  1514. """
  1515. if not args:
  1516. return lambda m: m[0]
  1517. def replacer(m):
  1518. fmt = m[0][1]
  1519. if fmt == '%':
  1520. return '%'
  1521. if fmt in 'sdfoOc':
  1522. if not args:
  1523. return ''
  1524. repl = args.pop(0)
  1525. if fmt == 'c':
  1526. return ''
  1527. return str(self._from_remoteobject(repl))
  1528. return m[0]
  1529. return replacer
  1530. @lru_cache(1)
  1531. def _find_executable():
  1532. system = platform.system()
  1533. if system == 'Linux':
  1534. for bin_ in ['google-chrome', 'chromium', 'chromium-browser', 'google-chrome-stable']:
  1535. try:
  1536. return find_in_path(bin_)
  1537. except IOError:
  1538. continue
  1539. elif system == 'Darwin':
  1540. bins = [
  1541. '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
  1542. '/Applications/Chromium.app/Contents/MacOS/Chromium',
  1543. ]
  1544. for bin_ in bins:
  1545. if os.path.exists(bin_):
  1546. return bin_
  1547. elif system == 'Windows':
  1548. bins = [
  1549. '%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe',
  1550. '%ProgramFiles(x86)%\\Google\\Chrome\\Application\\chrome.exe',
  1551. '%LocalAppData%\\Google\\Chrome\\Application\\chrome.exe',
  1552. ]
  1553. for bin_ in bins:
  1554. bin_ = os.path.expandvars(bin_)
  1555. if os.path.exists(bin_):
  1556. return bin_
  1557. raise unittest.SkipTest("Chrome executable not found")
  1558. class Opener(requests.Session):
  1559. """
  1560. Flushes and clears the current transaction when starting a request.
  1561. This is likely necessary when we make a request to the server, as the
  1562. request is made with a test cursor, which uses a different cache than this
  1563. transaction.
  1564. """
  1565. def __init__(self, cr: BaseCursor):
  1566. super().__init__()
  1567. self.cr = cr
  1568. def request(self, *args, **kwargs):
  1569. self.cr.flush()
  1570. self.cr.clear()
  1571. return super().request(*args, **kwargs)
  1572. class Transport(xmlrpclib.Transport):
  1573. """ see :class:`Opener` """
  1574. def __init__(self, cr: BaseCursor):
  1575. self.cr = cr
  1576. super().__init__()
  1577. def request(self, *args, **kwargs):
  1578. self.cr.flush()
  1579. self.cr.clear()
  1580. return super().request(*args, **kwargs)
  1581. class JsonRpcException(Exception):
  1582. def __init__(self, code, message):
  1583. super().__init__(message)
  1584. self.code = code
  1585. class HttpCase(TransactionCase):
  1586. """ Transactional HTTP TestCase with url_open and Chrome headless helpers. """
  1587. registry_test_mode = True
  1588. browser = None
  1589. browser_size = '1366x768'
  1590. touch_enabled = False
  1591. allow_end_on_form = False
  1592. _logger: logging.Logger = None
  1593. @classmethod
  1594. def setUpClass(cls):
  1595. super().setUpClass()
  1596. if cls.registry_test_mode:
  1597. cls.registry.enter_test_mode(cls.cr, not hasattr(cls, 'readonly_enabled') or cls.readonly_enabled)
  1598. cls.addClassCleanup(cls.registry.leave_test_mode)
  1599. ICP = cls.env['ir.config_parameter']
  1600. ICP.set_param('web.base.url', cls.base_url())
  1601. ICP.env.flush_all()
  1602. # v8 api with correct xmlrpc exception handling.
  1603. cls.xmlrpc_url = f'{cls.base_url()}/xmlrpc/2/'
  1604. cls._logger = logging.getLogger('%s.%s' % (cls.__module__, cls.__name__))
  1605. @classmethod
  1606. def base_url(cls):
  1607. return f"http://{HOST}:{cls.http_port():d}"
  1608. @classmethod
  1609. def http_port(cls):
  1610. if odoo.service.server.server is None:
  1611. return None
  1612. return odoo.service.server.server.httpd.server_port
  1613. def setUp(self):
  1614. super().setUp()
  1615. self._logger = self._logger.getChild(self._testMethodName)
  1616. self.xmlrpc_common = xmlrpclib.ServerProxy(self.xmlrpc_url + 'common', transport=Transport(self.cr))
  1617. self.xmlrpc_db = xmlrpclib.ServerProxy(self.xmlrpc_url + 'db', transport=Transport(self.cr))
  1618. self.xmlrpc_object = xmlrpclib.ServerProxy(self.xmlrpc_url + 'object', transport=Transport(self.cr), use_datetime=True)
  1619. # setup an url opener helper
  1620. self.opener = Opener(self.cr)
  1621. def parse_http_location(self, location):
  1622. """ Parse a Location http header typically found in 201/3xx
  1623. responses, return the corresponding Url object. The scheme/host
  1624. are taken from ``base_url()`` in case they are missing from the
  1625. header.
  1626. https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html#urllib3.util.Url
  1627. """
  1628. if not location:
  1629. return Url()
  1630. base_url = parse_url(self.base_url())
  1631. url = parse_url(location)
  1632. return Url(
  1633. scheme=url.scheme or base_url.scheme,
  1634. auth=url.auth or base_url.auth,
  1635. host=url.host or base_url.host,
  1636. port=url.port or base_url.port,
  1637. path=url.path,
  1638. query=url.query,
  1639. fragment=url.fragment,
  1640. )
  1641. def assertURLEqual(self, test_url, truth_url, message=None):
  1642. """ Assert that two URLs are equivalent. If any URL is missing
  1643. a scheme and/or host, assume the same scheme/host as base_url()
  1644. """
  1645. self.assertEqual(
  1646. self.parse_http_location(test_url).url,
  1647. self.parse_http_location(truth_url).url,
  1648. message,
  1649. )
  1650. def url_open(self, url, data=None, files=None, timeout=12, headers=None, allow_redirects=True, head=False):
  1651. if url.startswith('/'):
  1652. url = self.base_url() + url
  1653. if head:
  1654. return self.opener.head(url, data=data, files=files, timeout=timeout, headers=headers, allow_redirects=False)
  1655. if data or files:
  1656. return self.opener.post(url, data=data, files=files, timeout=timeout, headers=headers, allow_redirects=allow_redirects)
  1657. return self.opener.get(url, timeout=timeout, headers=headers, allow_redirects=allow_redirects)
  1658. def _wait_remaining_requests(self, timeout=10):
  1659. def get_http_request_threads():
  1660. return [t for t in threading.enumerate() if t.name.startswith('odoo.service.http.request.')]
  1661. start_time = time.time()
  1662. request_threads = get_http_request_threads()
  1663. self._logger.info('waiting for threads: %s', request_threads)
  1664. for thread in request_threads:
  1665. thread.join(timeout - (time.time() - start_time))
  1666. request_threads = get_http_request_threads()
  1667. for thread in request_threads:
  1668. self._logger.info("Stop waiting for thread %s handling request for url %s",
  1669. thread.name, getattr(thread, 'url', '<UNKNOWN>'))
  1670. if request_threads:
  1671. self._logger.info('remaining requests')
  1672. odoo.tools.misc.dumpstacks()
  1673. def logout(self, keep_db=True):
  1674. self.session.logout(keep_db=keep_db)
  1675. odoo.http.root.session_store.save(self.session)
  1676. def authenticate(self, user, password, browser: ChromeBrowser = None):
  1677. if getattr(self, 'session', None):
  1678. odoo.http.root.session_store.delete(self.session)
  1679. self.session = session = odoo.http.root.session_store.new()
  1680. session.update(odoo.http.get_default_session(), db=get_db_name())
  1681. session.context['lang'] = odoo.http.DEFAULT_LANG
  1682. if user: # if authenticated
  1683. # Flush and clear the current transaction. This is useful, because
  1684. # the call below opens a test cursor, which uses a different cache
  1685. # than this transaction.
  1686. self.cr.flush()
  1687. self.cr.clear()
  1688. def patched_check_credentials(self, credential, env):
  1689. return {'uid': self.id, 'auth_method': 'password', 'mfa': 'default'}
  1690. # patching to speedup the check in case the password is hashed with many hashround + avoid to update the password
  1691. with patch('odoo.addons.base.models.res_users.Users._check_credentials', new=patched_check_credentials):
  1692. credential = {'login': user, 'password': password, 'type': 'password'}
  1693. auth_info = self.registry['res.users'].authenticate(session.db, credential, {'interactive': False})
  1694. uid = auth_info['uid']
  1695. env = api.Environment(self.cr, uid, {})
  1696. session.uid = uid
  1697. session.login = user
  1698. session.session_token = uid and security.compute_session_token(session, env)
  1699. session.context = dict(env['res.users'].context_get())
  1700. odoo.http.root.session_store.save(session)
  1701. # Reset the opener: turns out when we set cookies['foo'] we're really
  1702. # setting a cookie on domain='' path='/'.
  1703. #
  1704. # But then our friendly neighborhood server might set a cookie for
  1705. # domain='localhost' path='/' (with the same value) which is considered
  1706. # a *different* cookie following ours rather than the same.
  1707. #
  1708. # When we update our cookie, it's done in-place, so the server-set
  1709. # cookie is still present and (as it follows ours and is more precise)
  1710. # very likely to still be used, therefore our session change is ignored.
  1711. #
  1712. # An alternative would be to set the cookie to None (unsetting it
  1713. # completely) or clear-ing session.cookies.
  1714. self.opener = Opener(self.cr)
  1715. self.opener.cookies['session_id'] = session.sid
  1716. if browser:
  1717. self._logger.info('Setting session cookie in browser')
  1718. browser.set_cookie('session_id', session.sid, '/', HOST)
  1719. return session
  1720. def browser_js(self, url_path, code, ready='', login=None, timeout=60, cookies=None, error_checker=None, watch=False, success_signal=DEFAULT_SUCCESS_SIGNAL, debug=False, cpu_throttling=None, **kw):
  1721. """ Test JavaScript code running in the browser.
  1722. To signal success test do: `console.log()` with the expected `success_signal`. Default is "test successful"
  1723. To signal test failure raise an exception or call `console.error` with a message.
  1724. Test will stop when a failure occurs if `error_checker` is not defined or returns `True` for this message
  1725. :param string url_path: URL path to load the browser page on
  1726. :param string code: JavaScript code to be executed
  1727. :param string ready: JavaScript object to wait for before proceeding with the test
  1728. :param string login: logged in user which will execute the test. e.g. 'admin', 'demo'
  1729. :param int timeout: maximum time to wait for the test to complete (in seconds). Default is 60 seconds
  1730. :param dict cookies: dictionary of cookies to set before loading the page
  1731. :param error_checker: function to filter failures out.
  1732. If provided, the function is called with the error log message, and if it returns `False` the log is ignored and the test continue
  1733. If not provided, every error log triggers a failure
  1734. :param bool watch: open a new browser window to watch the test execution
  1735. :param string success_signal: string signal to wait for to consider the test successful
  1736. :param bool debug: automatically open a fullscreen Chrome window with opened devtools and a debugger breakpoint set at the start of the tour.
  1737. The tour is ran with the `debug=assets` query parameter. When an error is thrown, the debugger stops on the exception.
  1738. :param int cpu_throttling: CPU throttling rate as a slowdown factor (1 is no throttle, 2 is 2x slowdown, etc)
  1739. """
  1740. if not self.env.registry.loaded:
  1741. self._logger.warning('HttpCase test should be in post_install only')
  1742. # increase timeout if coverage is running
  1743. if any(f.filename.endswith('/coverage/execfile.py') for f in inspect.stack() if f.filename):
  1744. timeout = timeout * 1.5
  1745. if debug is not False:
  1746. watch = True
  1747. timeout = 1e6
  1748. if watch:
  1749. self._logger.warning('watch mode is only suitable for local testing')
  1750. browser = ChromeBrowser(self, headless=not watch, success_signal=success_signal, debug=debug)
  1751. try:
  1752. self.authenticate(login, login, browser=browser)
  1753. # Flush and clear the current transaction. This is useful in case
  1754. # we make requests to the server, as these requests are made with
  1755. # test cursors, which uses different caches than this transaction.
  1756. self.cr.flush()
  1757. self.cr.clear()
  1758. url = werkzeug.urls.url_join(self.base_url(), url_path)
  1759. if watch:
  1760. parsed = werkzeug.urls.url_parse(url)
  1761. qs = parsed.decode_query()
  1762. qs['watch'] = '1'
  1763. if debug is not False:
  1764. qs['debug'] = "assets"
  1765. url = parsed.replace(query=werkzeug.urls.url_encode(qs)).to_url()
  1766. self._logger.info('Open "%s" in browser', url)
  1767. if browser.screencasts_dir:
  1768. self._logger.info('Starting screencast')
  1769. browser.start_screencast()
  1770. if cookies:
  1771. for name, value in cookies.items():
  1772. browser.set_cookie(name, value, '/', HOST)
  1773. cpu_throttling_os = os.environ.get('ODOO_BROWSER_CPU_THROTTLING') # used by dedicated runbot builds
  1774. cpu_throttling = int(cpu_throttling_os) if cpu_throttling_os else cpu_throttling
  1775. if cpu_throttling:
  1776. assert 1 <= cpu_throttling <= 50 # arbitrary upper limit
  1777. timeout *= cpu_throttling # extend the timeout as test will be slower to execute
  1778. _logger.log(
  1779. logging.INFO if cpu_throttling_os else logging.WARNING,
  1780. 'CPU throttling mode is only suitable for local testing - ' \
  1781. 'Throttling browser CPU to %sx slowdown and extending timeout to %s sec', cpu_throttling, timeout)
  1782. browser._websocket_request('Emulation.setCPUThrottlingRate', params={'rate': cpu_throttling})
  1783. browser.navigate_to(url, wait_stop=not bool(ready))
  1784. # Needed because tests like test01.js (qunit tests) are passing a ready
  1785. # code = ""
  1786. self.assertTrue(browser._wait_ready(ready), 'The ready "%s" code was always falsy' % ready)
  1787. error = False
  1788. try:
  1789. browser._wait_code_ok(code, timeout, error_checker=error_checker)
  1790. except ChromeBrowserException as chrome_browser_exception:
  1791. error = chrome_browser_exception
  1792. if error: # dont keep initial traceback, keep that outside of except
  1793. if code:
  1794. message = 'The test code "%s" failed' % code
  1795. else:
  1796. message = "Some js test failed"
  1797. self.fail('%s\n\n%s' % (message, error))
  1798. finally:
  1799. browser.stop()
  1800. self._wait_remaining_requests()
  1801. def start_tour(self, url_path, tour_name, step_delay=None, **kwargs):
  1802. """Wrapper for `browser_js` to start the given `tour_name` with the
  1803. optional delay between steps `step_delay`. Other arguments from
  1804. `browser_js` can be passed as keyword arguments."""
  1805. options = {
  1806. 'stepDelay': step_delay or 0,
  1807. 'keepWatchBrowser': kwargs.get('watch', False),
  1808. 'debug': kwargs.get('debug', False),
  1809. 'startUrl': url_path,
  1810. 'delayToCheckUndeterminisms': kwargs.pop('delay_to_check_undeterminisms', int(os.getenv("ODOO_TOUR_DELAY_TO_CHECK_UNDETERMINISMS", "0")) or 0),
  1811. }
  1812. code = kwargs.pop('code', f"odoo.startTour({tour_name!r}, {json.dumps(options)})")
  1813. ready = kwargs.pop('ready', f"odoo.isTourReady({tour_name!r})")
  1814. timeout = kwargs.pop('timeout', 60)
  1815. if options["delayToCheckUndeterminisms"] > 0:
  1816. timeout = timeout + 1000 * options["delayToCheckUndeterminisms"]
  1817. _logger.runbot("Tour %s is launched with mode: check for undeterminisms.", tour_name)
  1818. return self.browser_js(url_path=url_path, code=code, ready=ready, timeout=timeout, success_signal="tour succeeded", **kwargs)
  1819. def profile(self, **kwargs):
  1820. """
  1821. for http_case, also patch _get_profiler_context_manager in order to profile all requests
  1822. """
  1823. sup = super()
  1824. _profiler = sup.profile(**kwargs)
  1825. def route_profiler(request):
  1826. _route_profiler = sup.profile(description=request.httprequest.full_path, db=_profiler.db)
  1827. _profiler.sub_profilers.append(_route_profiler)
  1828. return _route_profiler
  1829. return profiler.Nested(_profiler, patch('odoo.http.Request._get_profiler_context_manager', route_profiler))
  1830. def make_jsonrpc_request(self, route, params=None, headers=None):
  1831. """Make a JSON-RPC request to the server.
  1832. :param str route: the route to request
  1833. :param dict params: the parameters to send
  1834. :raises requests.HTTPError: if one occurred
  1835. :raises JsonRpcException: if the response contains an error
  1836. :return: The 'result' key from the response if any.
  1837. """
  1838. data = json.dumps({
  1839. 'id': 0,
  1840. 'jsonrpc': '2.0',
  1841. 'method': 'call',
  1842. 'params': params or {},
  1843. }).encode()
  1844. headers = headers or {}
  1845. headers['Content-Type'] = 'application/json'
  1846. response = self.url_open(route, data, headers=headers)
  1847. response.raise_for_status()
  1848. decoded_response = response.json()
  1849. if 'result' in decoded_response:
  1850. return decoded_response['result']
  1851. if 'error' in decoded_response:
  1852. raise JsonRpcException(
  1853. code=decoded_response['error']['code'],
  1854. message=decoded_response['error']['data']['name']
  1855. )
  1856. def no_retry(arg):
  1857. """Disable auto retry on decorated test method or test class"""
  1858. arg._retry = False
  1859. return arg
  1860. def users(*logins):
  1861. """ Decorate a method to execute it once for each given user. """
  1862. @decorator
  1863. def _users(func, *args, **kwargs):
  1864. self = args[0]
  1865. old_uid = self.uid
  1866. try:
  1867. # retrieve users
  1868. Users = self.env['res.users'].with_context(active_test=False)
  1869. user_id = {
  1870. user.login: user.id
  1871. for user in Users.search([('login', 'in', list(logins))])
  1872. }
  1873. for login in logins:
  1874. with self.subTest(login=login):
  1875. # switch user and execute func
  1876. self.uid = user_id[login]
  1877. func(*args, **kwargs)
  1878. # Invalidate the cache between subtests, in order to not reuse
  1879. # the former user's cache (`test_read_mail`, `test_write_mail`)
  1880. self.env.invalidate_all()
  1881. finally:
  1882. self.uid = old_uid
  1883. return _users
  1884. @decorator
  1885. def warmup(func, *args, **kwargs):
  1886. """
  1887. Stabilize assertQueries and assertQueryCount assertions.
  1888. Reset the cache to a stable state by flushing pending changes and
  1889. invalidating the cache.
  1890. Warmup the ormcaches by running the decorated function an extra time
  1891. before the actual test runs. The extra execution ignores
  1892. assertQueries and assertQueryCount assertions, it also discardes all
  1893. changes but the ormcaches ones.
  1894. """
  1895. self = args[0]
  1896. self.env.flush_all()
  1897. self.env.invalidate_all()
  1898. # run once to warm up the caches
  1899. self.warm = False
  1900. self.cr.execute('SAVEPOINT test_warmup')
  1901. func(*args, **kwargs)
  1902. self.env.flush_all()
  1903. # run once for real
  1904. self.cr.execute('ROLLBACK TO SAVEPOINT test_warmup')
  1905. self.env.invalidate_all()
  1906. self.warm = True
  1907. func(*args, **kwargs)
  1908. def can_import(module):
  1909. """ Checks if <module> can be imported, returns ``True`` if it can be,
  1910. ``False`` otherwise.
  1911. To use with ``unittest.skipUnless`` for tests conditional on *optional*
  1912. dependencies, which may or may be present but must still be tested if
  1913. possible.
  1914. """
  1915. try:
  1916. importlib.import_module(module)
  1917. except ImportError:
  1918. return False
  1919. else:
  1920. return True
  1921. def tagged(*tags):
  1922. """A decorator to tag BaseCase objects.
  1923. Tags are stored in a set that can be accessed from a 'test_tags' attribute.
  1924. A tag prefixed by '-' will remove the tag e.g. to remove the 'standard' tag.
  1925. By default, all Test classes from odoo.tests.common have a test_tags
  1926. attribute that defaults to 'standard' and 'at_install'.
  1927. When using class inheritance, the tags ARE inherited.
  1928. """
  1929. include = {t for t in tags if not t.startswith('-')}
  1930. exclude = {t[1:] for t in tags if t.startswith('-')}
  1931. def tags_decorator(obj):
  1932. obj.test_tags = (getattr(obj, 'test_tags', set()) | include) - exclude
  1933. at_install = 'at_install' in obj.test_tags
  1934. post_install = 'post_install' in obj.test_tags
  1935. if not (at_install ^ post_install):
  1936. _logger.warning('A tests should be either at_install or post_install, which is not the case of %r', obj)
  1937. return obj
  1938. return tags_decorator
  1939. class freeze_time:
  1940. """ Object to replace the freezegun in Odoo test suites
  1941. It properly handles the test classes decoration
  1942. Also, it can be used like the usual method decorator or context manager
  1943. """
  1944. def __init__(self, time_to_freeze):
  1945. self.freezer = None
  1946. self.time_to_freeze = time_to_freeze
  1947. def __call__(self, func):
  1948. if isinstance(func, MetaCase):
  1949. func.freeze_time = self.time_to_freeze
  1950. return func
  1951. else:
  1952. if freezegun:
  1953. return freezegun.freeze_time(self.time_to_freeze)(func)
  1954. else:
  1955. _logger.warning("freezegun package missing")
  1956. def __enter__(self):
  1957. if freezegun:
  1958. self.freezer = freezegun.freeze_time(self.time_to_freeze)
  1959. return self.freezer.start()
  1960. else:
  1961. _logger.warning("freezegun package missing")
  1962. def __exit__(self, *args):
  1963. if self.freezer:
  1964. self.freezer.stop()
上海开阖软件有限公司 沪ICP备12045867号-1