We’ve started creating a test for sao. We need it to cover some of the customizations we have but also to test patches that sometimes we backport.
While working on that I thought that in fact most of the test should be part of core.
The idea is to create a test that covers all the workflows: login, menu navigation, creating, reading, updating and deleting records, using wizards, relate buttons, etc, etc.
It will be a very extensive test but it should prevent regressions that appear with relative frequency.
My proposal is to use Playwright for the automation. There are other tools but testing several of those over the years Playwright has proved to be the most stable one.
Never had an issue installing it or the different browsers it supports (the experience with other tools was quite different) and we use it flawlessly as part of some functionalities we have in production servers inside docker containers after several years.
Playwright also allows to store screenshots, videos as well as traces. It has a tool to reproduce those traces and a tool to simplify the creation of tests (although it may or may not be appropriate for the sao tests).
Thoughts?
Here’s a snippet of a basic proof of concept of using plain unittest with playwright. We’re already using it + some more tests in our CI system, as one more test in “tests” directory of an internal module we have.
import os
import re
import threading
import unittest
from functools import wraps
from playwright.sync_api import Page, expect, sync_playwright, Locator
from trytond import wsgi
from trytond.pool import Pool
from trytond.tests.test_tryton import drop_create, drop_db
from trytond.transaction import Transaction
from werkzeug.serving import make_server
from trytond.config import config
expect.set_options(timeout=2500)
# CAUTION: If you want to execute this test locally or on a new server, please define the 'root' variable with the value 'sao', on TRYTOND.conf
# HINT: You can temporarily define it with the command 'export TRYTOND_WEB__ROOT=sao'
class ServerThread(threading.Thread):
def __init__(self, app):
threading.Thread.__init__(self)
# When the system gets a '0' on the socket, it automatically finds a not-used port.
# SEE: https://stackoverflow.com/questions/1365265/on-localhost-how-do-i-pick-a-free-port-number
self.port = 0
self.host = 'localhost'
# Threaded is put to TRUE to ensure that the server is stopped.
# Werkzeug says: shutdown() must be called while serve_forever() is running in another thread, or it will deadlock.
self.server = make_server(self.host, self.port, app, threaded=True)
# Retrieve the port selected by the system
self.port = self.server.socket.getsockname()[1]
def run(self):/
self.server.serve_forever()
def stop(self):
if self.is_alive():
print('Stopping server...')
self.server.shutdown()
def browser():
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
with sync_playwright() as playwright:
try:
browser = playwright.firefox.launch(headless='DISPLAY' not in os.environ)
context = browser.new_context(locale='en-US')
context.set_default_timeout(2500)
page = context.new_page()
return func(*args, page=page, **kwargs)
except:
args[0].fail('An exception ocurred!')
args[0].server.stop()
finally:
context.clear_cookies()
context.clear_permissions()
context.close()
browser.close()
return wrapper
return decorator
user, password = None, None
class SaoTest(unittest.TestCase):
@classmethod
def setUpClass(self):
global user, password
self.database = 'sao_database'
# CAUTION: When creating a database in SQLITE, by default it will create a 'in-memory' database,
# making tryton unable to find a database to connect, because it only search databases in a directory, by default, /home/your_user/db.
drop_create(name=self.database)
with Transaction().start(self.database, 0, close=True, autocommit=True, readonly=False):
pool = Pool()
Module = pool.get('ir.module')
User = pool.get('res.user')
ActivateUpgrade = pool.get('ir.module.activate_upgrade', type='wizard')
users = User.search([('login','=','admin')])
assert users
user, = users
user.reset_password() # Create a reset password
self.password = user.password_reset
self.user = user.login
password = self.password
user = self.user
records = Module.search([
('name', '=', 'res'),
('state', '!=', 'activated')
])
assert len(records) == 1
Module.activate(records) # Code from 'activate_module' of test_tryton.py, line 111
instance_id, _, _ = ActivateUpgrade.create()
ActivateUpgrade(instance_id).transition_upgrade()
ActivateUpgrade.delete(instance_id)
def setUp(self):
global user, password
self.database = 'sao_database'
self.user = user
self.password = password
self.server = ServerThread(wsgi.app)
self.server.start()
@classmethod
def tearDownClass(self):
drop_db(name=self.database)
def tearDown(self):
self.server.stop()
def login(self, page: Page):
page.goto(f'http://{self.server.host}:{self.server.port}')
page.wait_for_load_state('load')
login_popup = page.locator('div.login-dialog')
login_popup.wait_for(state='visible')
database = page.locator('select[name="database"]')
database_unique = page.locator('input[name="database"]')
if database.is_hidden():
if (database_unique.is_editable()):
database_unique.fill(self.database)
else:
database.select_option(value=self.database)
# Putting the username
username = page.locator('input[name="login"]')
username.wait_for(state='visible')
username.fill(self.user)
# Accept the username and go to the password popup
access = page.get_by_text(re.compile('login$', re.IGNORECASE), exact=True)
access.click()
# Putting the password
password = page.locator('input[type="password"]')
password.wait_for(state='visible')
password.fill(self.password)
# Accept and try login
accept = page.get_by_text(re.compile('^ok$', re.IGNORECASE))
accept.click()
def login_test(self, page: Page):
login_popup = page.locator('div.login-dialog')
database_list = page.locator('select[name="database"]')
database_input = page.locator('input[name="database"]')
# Putting the username
username = page.locator('input[name="login"]')
expect(username).to_have_count(1)
expect(username).to_be_visible()
expect(username).to_be_editable()
expect(username).not_to_be_disabled()
username.fill(self.user)
expect(username).to_have_value(self.user)
# Accept the username and go to the password popup
access = page.get_by_text(re.compile('login$', re.IGNORECASE), exact=True)
expect(access).to_have_count(1)
expect(access).to_be_visible()
access.click()
# Check the ask popup
ask_popup = page.locator("div.ask-dialog")
expect(ask_popup).to_have_count(1)
expect(ask_popup).to_be_visible()
# Some verifications
expect(database_input).to_be_disabled()
expect(database_list).to_be_disabled()
expect(access).to_be_disabled()
# Putting the password
password = page.locator('input[type="password"]')
password.wait_for(state='visible')
expect(password).to_have_count(1)
expect(password).to_be_editable()
password.fill(self.password)
# Accept and try login
accept = page.get_by_text(re.compile('^ok$', re.IGNORECASE))
expect(accept).to_have_count(1)
expect(accept).to_be_visible()
accept.click()
# Wait for the popups to be hidden
expect(ask_popup).to_be_hidden()
expect(login_popup).to_be_hidden()
def button_fail(self, button: Locator):
with self.assertRaises(Exception):
button.click(trial=True)
@browser()
def test_01(self, page: Page):
"""
Checking the login without database list
"""
config.set('database', 'list', 'False')
page.goto(f'http://{self.server.host}:{self.server.port}')
page.wait_for_load_state('load')
login_popup = page.locator('div.login-dialog')
expect(login_popup).to_be_visible()
database_list = page.locator('select[name="database"]')
database_input = page.locator('input[name="database"]')
expect(database_list).to_have_count(1)
expect(database_list).to_be_hidden()
expect(database_input).to_have_count(1)
expect(database_input).to_be_visible()
if (database_input.is_editable()):
expect(database_input).to_be_editable()
database_input.fill(self.database)
else:
expect(database_input).not_to_be_editable()
expect(database_input).to_have_value(self.database)
self.login_test(page)