vanilla 18.0

This commit is contained in:
Ernad Husremovic 2025-10-08 10:48:09 +02:00
parent 5454004ff9
commit d7f6d2725e
979 changed files with 428093 additions and 0 deletions

View file

@ -0,0 +1,347 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
import logging
import re
from collections import defaultdict
from datetime import date
from http import HTTPStatus
from urllib.parse import urlencode
import psycopg2.errors
from dateutil.relativedelta import relativedelta
from lxml import etree
from werkzeug.exceptions import BadRequest, NotFound
from odoo import http
from odoo.exceptions import AccessError
from odoo.http import request
from odoo.models import regex_object_name
from odoo.osv import expression
from odoo.tools.safe_eval import safe_eval
from .utils import get_action_triples
_logger = logging.getLogger(__name__)
class WebJsonController(http.Controller):
# for /json, the route should work in a browser, therefore type=http
@http.route('/json/<path:subpath>', auth='user', type='http', readonly=True)
def web_json(self, subpath, **kwargs):
self._check_json_route_active()
return request.redirect(
f'/json/1/{subpath}?{urlencode(kwargs)}',
HTTPStatus.TEMPORARY_REDIRECT
)
@http.route('/json/1/<path:subpath>', auth='bearer', type='http', readonly=True)
def web_json_1(self, subpath, **kwargs):
"""Simple JSON representation of the views.
Get the JSON representation of the action/view as it would be shown
in the web client for the same /odoo `subpath`.
Behaviour:
- When, the action resolves to a pair (Action, id), `form` view_type.
Otherwise when it resolves to (Action, None), use the given view_type
or the preferred one.
- View form uses `web_read`.
- If a groupby is given, use a read group.
Views pivot, graph redirect to a canonical URL with a groupby.
- Otherwise use a search read.
- If any parameter is missing, redirect to the canonical URL (one where
all parameters are set).
:param subpath: Path to the (window) action to execute
:param view_type: View type from which we generate the parameters
:param domain: The domain for searches
:param offset: Offset for search
:param limit: Limit for search
:param groupby: Comma-separated string; when set, executes a `web_read_group`
and groups by the given fields
:param fields: Comma-separates aggregates for the "group by" query
:param start_date: When applicable, minimum date (inclusive bound)
:param end_date: When applicable, maximum date (exclusive bound)
"""
self._check_json_route_active()
if not request.env.user.has_group('base.group_allow_export'):
raise AccessError(request.env._("You need export permissions to use the /json route"))
# redirect when the computed kwargs and the kwargs from the URL are different
param_list = set(kwargs)
def check_redirect():
# when parameters were added, redirect
if set(param_list) == set(kwargs):
return None
# for domains, make chars as safe
encoded_kwargs = urlencode(kwargs, safe="()[], '\"")
return request.redirect(
f'/json/1/{subpath}?{encoded_kwargs}',
HTTPStatus.TEMPORARY_REDIRECT
)
# Get the action
env = request.env
action, context, eval_context, record_id = self._get_action(subpath)
model = env[action.res_model].with_context(context)
# Get the view
view_type = kwargs.get('view_type')
if not view_type and record_id:
view_type = 'form'
view_id, view_type = get_view_id_and_type(action, view_type)
view = model.get_view(view_id, view_type)
spec = model._get_fields_spec(view)
# Simple case: form view with record
if view_type == 'form' or record_id:
if redirect := check_redirect():
return redirect
if not record_id:
raise BadRequest(env._("Missing record id"))
res = model.browse(int(record_id)).web_read(spec)[0]
return request.make_json_response(res)
# Find domain and limits
domains = [safe_eval(action.domain or '[]', eval_context)]
if 'domain' in kwargs:
# for the user-given domain, use only literal-eval instead of safe_eval
user_domain = ast.literal_eval(kwargs.get('domain') or '[]')
domains.append(user_domain)
else:
default_domain = get_default_domain(model, action, context, eval_context)
if default_domain and default_domain != expression.TRUE_DOMAIN:
kwargs['domain'] = repr(default_domain)
domains.append(default_domain)
try:
limit = int(kwargs.get('limit', 0)) or action.limit
offset = int(kwargs.get('offset', 0))
except ValueError as exc:
raise BadRequest(exc.args[0]) from exc
if 'offset' not in kwargs:
kwargs['offset'] = offset
if 'limit' not in kwargs:
kwargs['limit'] = limit
# Additional info from the view
view_tree = etree.fromstring(view['arch'])
# Add date domain for some view types
if view_type in ('calendar', 'gantt', 'cohort'):
try:
start_date = date.fromisoformat(kwargs['start_date'])
end_date = date.fromisoformat(kwargs['end_date'])
except ValueError as exc:
raise BadRequest(exc.args[0]) from exc
except KeyError:
start_date = end_date = None
date_domain = get_date_domain(start_date, end_date, view_tree)
domains.append(date_domain)
if 'start_date' not in kwargs or end_date not in kwargs:
kwargs.update({
'start_date': date_domain[0][2].isoformat(),
'end_date': date_domain[1][2].isoformat(),
})
# Add explicitly activity fields for an activity view
if view_type == 'activity':
domains.append([('activity_ids', '!=', False)])
# add activity fields
for field_name, field in model._fields.items():
if field_name.startswith('activity_') and field_name not in spec and field.is_accessible(env):
spec[field_name] = {}
# Group by
groupby, fields = get_groupby(view_tree, kwargs.get('groupby'), kwargs.get('fields'))
if groupby is not None and not kwargs.get('groupby'):
# add arguments to kwargs
kwargs['groupby'] = ','.join(groupby)
if 'fields' not in kwargs and fields:
kwargs['fields'] = ','.join(fields)
if groupby is None and fields:
# add fields to the spec
for field in fields:
spec.setdefault(field, {})
# Last checks before the query
if redirect := check_redirect():
return redirect
domain = expression.AND(domains)
# Reading a group or a list
if groupby:
res = model.web_read_group(
domain,
fields=fields or ['__count'],
groupby=groupby,
limit=limit,
lazy=False,
)
# pop '__domain' key
for value in res['groups']:
del value['__domain']
else:
res = model.web_search_read(
domain,
spec,
limit=limit,
offset=offset,
)
return request.make_json_response(res)
def _check_json_route_active(self):
# experimental route, only enabled in demo mode or when explicitly set
if not (request.env.ref('base.module_base').demo
or request.env['ir.config_parameter'].sudo().get_param('web.json.enabled')):
raise NotFound()
def _get_action(self, subpath):
def get_action_triples_():
try:
yield from get_action_triples(request.env, subpath, start_pos=1)
except ValueError as exc:
raise BadRequest(exc.args[0]) from exc
context = dict(request.env.context)
active_id, action, record_id = list(get_action_triples_())[-1]
action = action.sudo()
if action.usage == 'ir_actions_server' and action.path:
# force read-only evaluation of action_data
try:
with action.pool.cursor(readonly=True) as ro_cr:
if not ro_cr.readonly:
ro_cr.connection.set_session(readonly=True)
assert ro_cr.readonly
action_data = action.with_env(action.env(cr=ro_cr, su=False)).run()
except psycopg2.errors.ReadOnlySqlTransaction as e:
# never retry on RO connection, just leave
raise AccessError(action.env._("Unsupported server action")) from e
except ValueError as e:
# safe_eval wraps the error into a ValueError (as str)
if "ReadOnlySqlTransaction" not in e.args[0]:
raise
raise AccessError(action.env._("Unsupported server action")) from e
# transform data into a new record
action = action.env[action_data['type']]
action = action.new(action_data, origin=action.browse(action_data.pop('id')))
if action._name != 'ir.actions.act_window':
e = f"{action._name} are not supported server-side"
raise BadRequest(e)
eval_context = dict(
action._get_eval_context(action),
active_id=active_id,
context=context,
allowed_company_ids=request.env.user.company_ids.ids,
)
# update the context and return
context.update(safe_eval(action.context, eval_context))
return action, context, eval_context, record_id
def get_view_id_and_type(action, view_type: str | None) -> tuple[int | None, str]:
"""Extract the view id from the action"""
assert action._name == 'ir.actions.act_window'
view_modes = action.view_mode.split(',')
if not view_type:
view_type = view_modes[0]
for view_id, action_view_type in action.views:
if view_type == action_view_type:
break
else:
if view_type not in view_modes:
raise BadRequest(request.env._(
"Invalid view type '%(view_type)s' for action id=%(action)s",
view_type=view_type,
action=action.id,
))
view_id = False
return view_id, view_type
def get_default_domain(model, action, context, eval_context):
for ir_filter in model.env['ir.filters'].get_filters(model._name, action._origin.id):
if ir_filter['is_default']:
# user filters, static parsing only
domain_str = ir_filter['domain']
domain_str = re.sub(r'\buid\b', str(model.env.uid), domain_str)
default_domain = ast.literal_eval(domain_str)
break
else:
def filters_from_context():
view_tree = None
for key, value in context.items():
if key.startswith('search_default_') and value:
filter_name = key[15:]
if not regex_object_name.match(filter_name):
raise ValueError(model.env._("Invalid default search filter name for %s", key))
if view_tree is None:
view = model.get_view(action.search_view_id.id, 'search')
view_tree = etree.fromstring(view['arch'])
if (element := view_tree.find(Rf'.//filter[@name="{filter_name}"]')) is not None:
# parse the domain
if domain := element.attrib.get('domain'):
yield domain
# not parsing context['group_by']
default_domain = expression.AND(
safe_eval(domain, eval_context)
for domain in filters_from_context()
)
return default_domain
def get_date_domain(start_date, end_date, view_tree):
if not start_date or not end_date:
start_date = date.today() + relativedelta(day=1)
end_date = start_date + relativedelta(months=1)
date_field = view_tree.attrib.get('date_start')
if not date_field:
raise ValueError("Could not find the date field in the view")
return [(date_field, '>=', start_date), (date_field, '<', end_date)]
def get_groupby(view_tree, groupby=None, fields=None):
"""Parse the given groupby and fields and fallback to the view if not provided.
Return the groupby as a list when given.
Otherwise find groupby and fields from the view.
:param view_tree: The xml tree of the view
:param groupby: string or None
:param fields: string or None
"""
if groupby:
groupby = groupby.split(',')
if fields:
fields = fields.split(',')
else:
fields = None
if groupby is not None:
return groupby, fields
if view_tree.tag in ('pivot', 'graph'):
# extract groupby from the view if we don't have any
field_by_type = defaultdict(list)
for element in view_tree.findall(r'./field'):
field_name = element.attrib.get('name')
if element.attrib.get('invisible', '') in ('1', 'true'):
field_by_type['invisible'].append(field_name)
else:
field_by_type[element.attrib.get('type', 'normal')].append(field_name)
# not reading interval from the attribute
groupby = [
*field_by_type.get('row', ()),
*field_by_type.get('col', ()),
*field_by_type.get('normal', ()),
]
if fields is None:
fields = field_by_type.get('measure', [])
return groupby, fields
if view_tree.attrib.get('default_group_by'):
# in case the kanban view (or other) defines a default grouping
# return the field name so it is added to the spec
field = view_tree.attrib.get('default_group_by')
return (None, [field] if field else [])
return None, None

View file

@ -0,0 +1,14 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from json import loads, dumps
from odoo.http import Controller, request, route
class Model(Controller):
@route("/web/model/get_definitions", methods=["POST"], type="http", auth="user")
def get_model_definitions(self, model_names, **kwargs):
return request.make_response(
dumps(
request.env["ir.model"]._get_definitions(loads(model_names)),
)
)

View file

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import importlib.util
import io
import zipfile
import odoo.http as http
from odoo.exceptions import UserError
from odoo.http import request, content_disposition
class Partner(http.Controller):
@http.route(['/web_enterprise/partner/<model("res.partner"):partner>/vcard',
'/web/partner/vcard'], type='http', auth="user")
def download_vcard(self, partner_ids=None, partner=None, **kwargs):
if importlib.util.find_spec('vobject') is None:
raise UserError('vobject library is not installed')
if partner_ids:
partner_ids = list(filter(None, (int(pid) for pid in partner_ids.split(',') if pid.isdigit())))
partners = request.env['res.partner'].browse(partner_ids)
if len(partners) > 1:
with io.BytesIO() as buffer:
with zipfile.ZipFile(buffer, 'w') as zipf:
for partner in partners:
filename = f"{partner.name or partner.email}.vcf"
content = partner._get_vcard_file()
zipf.writestr(filename, content)
return request.make_response(buffer.getvalue(), [
('Content-Type', 'application/zip'),
('Content-Length', len(content)),
('Content-Disposition', content_disposition('Contacts.zip'))
])
if partner or partners:
partner = partner or partners
content = partner._get_vcard_file()
return request.make_response(content, [
('Content-Type', 'text/vcard'),
('Content-Length', len(content)),
('Content-Disposition', content_disposition(f"{partner.name or partner.email}.vcf")),
])
return request.not_found()

View file

@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import mimetypes
from urllib.parse import unquote, urlencode
from odoo import http, modules
from odoo.exceptions import AccessError
from odoo.http import request
from odoo.tools import file_open, file_path, image_process
class WebManifest(http.Controller):
def _get_shortcuts(self):
module_names = ['mail', 'crm', 'project', 'project_todo']
try:
module_ids = request.env['ir.module.module'].search([('state', '=', 'installed'), ('name', 'in', module_names)]) \
.sorted(key=lambda r: module_names.index(r["name"]))
except AccessError:
return []
menu_roots = request.env['ir.ui.menu'].get_user_roots()
datas = request.env['ir.model.data'].sudo().search([('model', '=', 'ir.ui.menu'),
('res_id', 'in', menu_roots.ids),
('module', 'in', module_names)])
shortcuts = []
for module in module_ids:
data = datas.filtered(lambda res: res.module == module.name)
if data:
shortcuts.append({
'name': module.display_name,
'url': '/odoo?menu_id=%s' % data.mapped('res_id')[0],
'description': module.summary,
'icons': [{
'sizes': '100x100',
'src': module.icon,
'type': mimetypes.guess_type(module.icon)[0] or 'image/png'
}]
})
return shortcuts
def _get_webmanifest(self):
web_app_name = request.env['ir.config_parameter'].sudo().get_param('web.web_app_name', 'Odoo')
manifest = {
'name': web_app_name,
'scope': '/odoo',
'start_url': '/odoo',
'display': 'standalone',
'background_color': '#714B67',
'theme_color': '#714B67',
'prefer_related_applications': False,
}
icon_sizes = ['192x192', '512x512']
manifest['icons'] = [{
'src': '/web/static/img/odoo-icon-%s.png' % size,
'sizes': size,
'type': 'image/png',
} for size in icon_sizes]
manifest['shortcuts'] = self._get_shortcuts()
return manifest
@http.route('/web/manifest.webmanifest', type='http', auth='public', methods=['GET'], readonly=True)
def webmanifest(self):
""" Returns a WebManifest describing the metadata associated with a web application.
Using this metadata, user agents can provide developers with means to create user
experiences that are more comparable to that of a native application.
"""
return request.make_json_response(self._get_webmanifest(), {
'Content-Type': 'application/manifest+json'
})
@http.route('/web/service-worker.js', type='http', auth='public', methods=['GET'], readonly=True)
def service_worker(self):
response = request.make_response(
self._get_service_worker_content(),
[
('Content-Type', 'text/javascript'),
('Service-Worker-Allowed', '/odoo'),
]
)
return response
def _get_service_worker_content(self):
""" Returns a ServiceWorker javascript file scoped for the backend (aka. '/odoo')
"""
with file_open('web/static/src/service_worker.js') as f:
body = f.read()
return body
def _icon_path(self):
return 'web/static/img/odoo-icon-192x192.png'
@http.route('/odoo/offline', type='http', auth='public', methods=['GET'], readonly=True)
def offline(self):
""" Returns the offline page delivered by the service worker """
return request.render('web.webclient_offline', {
'odoo_icon': base64.b64encode(file_open(self._icon_path(), 'rb').read())
})
@http.route('/scoped_app', type='http', auth='public', methods=['GET'])
def scoped_app(self, app_id, path='', app_name=''):
""" Returns the app shortcut page to install the app given in parameters """
app_name = unquote(app_name) if app_name else self._get_scoped_app_name(app_id)
path = f"/{unquote(path)}"
scoped_app_values = {
'app_id': app_id,
'apple_touch_icon': '/web/static/img/odoo-icon-ios.png',
'app_name': app_name,
'path': path,
'safe_manifest_url': "/web/manifest.scoped_app_manifest?" + urlencode({
'app_id': app_id,
'path': path,
'app_name': app_name
})
}
return request.render('web.webclient_scoped_app', scoped_app_values)
@http.route('/scoped_app_icon_png', type='http', auth='public', methods=['GET'])
def scoped_app_icon_png(self, app_id, add_padding=False):
""" Returns an app icon created with a fixed size in PNG. It is required for Safari PWAs """
# To begin, we take the first icon available for the app
app_icon = self._get_scoped_app_icons(app_id)[0]
if app_icon['type'] == "image/svg+xml":
# We don't handle SVG images here, let's look for the module icon if possible
manifest = modules.module.get_manifest(app_id)
add_padding = True
if len(manifest) > 0 and manifest['icon']:
icon_src = manifest['icon']
else:
icon_src = f"/{self._icon_path()}"
else:
icon_src = app_icon['src']
if not add_padding:
# A valid icon is explicitly provided, we can use it directly
return request.redirect(app_icon['src'])
# Now that we have the image source, we can generate a PNG image
with file_open(icon_src.removeprefix('/'), 'rb') as file:
image = image_process(file.read(), size=(180, 180), expand=True, colorize=(255, 255, 255), padding=16)
return request.make_response(image, headers=[('Content-Type', 'image/png')])
@http.route('/web/manifest.scoped_app_manifest', type='http', auth='public', methods=['GET'])
def scoped_app_manifest(self, app_id, path, app_name=''):
""" Returns a WebManifest dedicated to the scope of the given app. A custom scope and start
url are set to make sure no other installed PWA can overlap the scope (e.g. /odoo)
"""
path = unquote(path)
app_name = unquote(app_name) if app_name else self._get_scoped_app_name(app_id)
webmanifest = {
'icons': self._get_scoped_app_icons(app_id),
'name': app_name,
'scope': path,
'start_url': path,
'display': 'standalone',
'background_color': '#714B67',
'theme_color': '#714B67',
'prefer_related_applications': False,
'shortcuts': self._get_scoped_app_shortcuts(app_id)
}
return request.make_json_response(webmanifest, {
'Content-Type': 'application/manifest+json'
})
def _get_scoped_app_shortcuts(self, app_id):
return []
def _get_scoped_app_name(self, app_id):
manifest = modules.module.get_manifest(app_id)
if manifest:
return manifest['name']
return app_id
def _get_scoped_app_icons(self, app_id):
try:
file_path(f'{app_id}/static/description/icon.svg')
src = f'{app_id}/static/description/icon.svg'
except FileNotFoundError:
src = self._icon_path()
return [{
'src': f"/{src}",
'sizes': 'any',
'type': mimetypes.guess_type(src)[0] or 'image/png'
}]

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,31 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class View(models.Model):
_inherit = 'ir.ui.view'
def get_view_info(self):
_view_info = self._get_view_info()
return {
type_: {
'display_name': display_name,
'icon': _view_info[type_]['icon'],
'multi_record': _view_info[type_].get('multi_record', True),
}
for (type_, display_name)
in self.fields_get(['type'], ['selection'])['type']['selection']
if type_ != 'qweb' and type_ in _view_info
}
def _get_view_info(self):
return {
'list': {'icon': 'oi oi-view-list'},
'form': {'icon': 'fa fa-address-card', 'multi_record': False},
'graph': {'icon': 'fa fa-area-chart'},
'pivot': {'icon': 'oi oi-view-pivot'},
'kanban': {'icon': 'oi oi-view-kanban'},
'calendar': {'icon': 'fa fa-calendar'},
'search': {'icon': 'oi oi-search'},
}

View file

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
web_app_name = fields.Char('Web App Name', config_parameter='web.web_app_name')

View file

@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from base64 import b64decode
from odoo import models
from odoo.tools.facade import Proxy, ProxyAttr, ProxyFunc
_logger = logging.getLogger(__name__)
try:
import vobject
except ImportError:
_logger.warning("`vobject` Python module not found, vcard file generation disabled. Consider installing this module if you want to generate vcard files")
vobject = None
if vobject is not None:
class VBaseProxy(Proxy):
_wrapped__ = vobject.base.VBase
encoding_param = ProxyAttr()
type_param = ProxyAttr()
value = ProxyAttr(None)
class VCardContentsProxy(Proxy):
_wrapped__ = dict
__delitem__ = ProxyFunc()
__contains__ = ProxyFunc()
get = ProxyFunc(lambda lines: [VBaseProxy(line) for line in lines])
class VComponentProxy(Proxy):
_wrapped__ = vobject.base.Component
add = ProxyFunc(VBaseProxy)
contents = ProxyAttr(VCardContentsProxy)
serialize = ProxyFunc()
class ResPartner(models.Model):
_inherit = 'res.partner'
def _build_vcard(self):
""" Build the partner's vCard.
:returns a vobject.vCard object
"""
if not vobject:
return False
vcard = vobject.vCard()
# Name
n = vcard.add('n')
n.value = vobject.vcard.Name(family=self.name or self.complete_name or '')
if self.title:
n.value.prefix = self.title.name
# Formatted Name
fn = vcard.add('fn')
fn.value = self.name or self.complete_name or ''
# Address
adr = vcard.add('adr')
adr.value = vobject.vcard.Address(street=self.street or '', city=self.city or '', code=self.zip or '')
if self.state_id:
adr.value.region = self.state_id.name
if self.country_id:
adr.value.country = self.country_id.name
# Email
if self.email:
email = vcard.add('email')
email.value = self.email
email.type_param = 'INTERNET'
# Telephone numbers
if self.phone:
tel = vcard.add('tel')
tel.type_param = 'work'
tel.value = self.phone
if self.mobile:
tel = vcard.add('tel')
tel.type_param = 'cell'
tel.value = self.mobile
# URL
if self.website:
url = vcard.add('url')
url.value = self.website
# Organisation
if self.commercial_company_name:
org = vcard.add('org')
org.value = [self.commercial_company_name]
if self.function:
function = vcard.add('title')
function.value = self.function
# Photo
photo = vcard.add('photo')
photo.value = b64decode(self.avatar_512)
photo.encoding_param = 'B'
photo.type_param = 'JPG'
return VComponentProxy(vcard)
def _get_vcard_file(self):
vcard = self._build_vcard()
if vcard:
return vcard.serialize().encode()
return False

View file

@ -0,0 +1,27 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from odoo.osv import expression
class ResUsers(models.Model):
_inherit = "res.users"
@api.model
def name_search(self, name='', args=None, operator='ilike', limit=100):
# if we have a search with a limit, move current user as the first result
user_list = super().name_search(name, args, operator, limit)
uid = self._uid
# index 0 is correct not Falsy in this case, use None to avoid ignoring it
if (index := next((i for i, (user_id, _name) in enumerate(user_list) if user_id == uid), None)) is not None:
# move found user first
user_tuple = user_list.pop(index)
user_list.insert(0, user_tuple)
elif limit is not None and len(user_list) == limit:
# user not found and limit reached, try to find the user again
if user_tuple := super().name_search(name, expression.AND([args or [], [('id', '=', uid)]]), operator, limit=1):
user_list = [user_tuple[0], *user_list[:-1]]
return user_list
def _on_webclient_bootstrap(self):
self.ensure_one()

View file

@ -0,0 +1,93 @@
Copyright (c) 2012-2013, The Mozilla Corporation and Telefonica S.A.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,21 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M45.1117 3.28193C45.9195 2.81551 46.579 3.18918 46.5816 4.11958L46.6941 43.7874C46.6967 44.7178 46.0415 45.8526 45.2337 46.319L18.2492 61.8986C17.4395 62.366 16.78 61.9903 16.7774 61.0599L16.6649 21.392C16.6623 20.4616 17.3175 19.3289 18.1272 18.8615L45.1117 3.28193Z" fill="white"/>
<path d="M44.6428 6.35106L44.7489 43.8005L18.7122 58.7557L18.6144 21.392L44.6428 6.35106ZM44.6428 5.89392C44.5638 5.89392 44.4848 5.91435 44.414 5.95531L18.3856 20.9963C18.2439 21.0781 18.1568 21.2295 18.1572 21.3933L18.2551 58.757C18.2555 58.9199 18.3426 59.0703 18.4837 59.1517C18.5544 59.1925 18.6333 59.2129 18.7122 59.2129C18.7908 59.2129 18.8694 59.1927 18.9399 59.1522L44.9766 44.197C45.1189 44.1152 45.2065 43.9634 45.2061 43.7992L45.0999 6.34976C45.0994 6.1867 45.0121 6.03614 44.8707 5.95479C44.8001 5.91429 44.7214 5.89392 44.6428 5.89392Z" fill="#374874"/>
<path d="M17.2056 62.0004L15.1787 60.8226C14.9154 60.6695 14.752 60.3442 14.7506 59.882L16.7774 61.0598C16.7787 61.522 16.9421 61.8474 17.2056 62.0004Z" fill="#FBDBD0"/>
<path d="M44.1275 2.00229L46.1543 3.18014C45.8877 3.0252 45.5185 3.04707 45.1117 3.28193L43.0849 2.10408C43.4917 1.86922 43.8608 1.84737 44.1275 2.00229Z" fill="#FBDBD0"/>
<path d="M16.7774 61.0598L14.7506 59.882L14.6382 20.2141L16.665 21.392L16.7774 61.0598Z" fill="#FBDBD0"/>
<path d="M18.1272 18.8614L16.1004 17.6836L43.0849 2.10408L45.1117 3.28193L18.1272 18.8614Z" fill="#FBDBD0"/>
<path d="M16.665 21.392L14.6382 20.2141C14.6356 19.2837 15.2907 18.151 16.1004 17.6836L18.1272 18.8614C17.3175 19.3289 16.6624 20.4616 16.665 21.392Z" fill="#FBDBD0"/>
<path d="M27.4854 13.7748L25.4586 12.597L25.4529 10.5948L27.4797 11.7726L27.4854 13.7748Z" fill="#C1DBF6"/>
<path d="M29.0407 9.06877L27.0139 7.89092L32.1347 4.93446L34.1615 6.11232L29.0407 9.06877Z" fill="#C1DBF6"/>
<path d="M34.1615 6.11232L32.1347 4.93446C32.5699 4.68322 32.9642 4.65996 33.249 4.82546L35.2758 6.00332C34.991 5.83781 34.5967 5.86107 34.1615 6.11232Z" fill="#C1DBF6"/>
<path d="M27.4797 11.7726L25.4529 10.5948C25.4501 9.60107 26.1492 8.39019 27.0139 7.89092L29.0407 9.06877C28.176 9.56806 27.4769 10.7789 27.4797 11.7726Z" fill="#C1DBF6"/>
<path d="M34.1614 6.11233C35.0262 5.61304 35.7299 6.01405 35.7327 7.00774L35.7384 9.00991L36.7757 8.41098C37.6368 7.91382 38.3386 8.31375 38.3415 9.30328L38.3458 10.8277C38.3463 10.994 38.2577 11.1478 38.1137 11.231L25.5913 18.4608C25.2824 18.6391 24.8962 18.4169 24.8952 18.0602L24.8923 17.0681C24.8895 16.0786 25.5868 14.8709 26.4479 14.3737L27.4853 13.7748L27.4796 11.7727C27.4768 10.7789 28.1759 9.56808 29.0407 9.06881L34.1614 6.11233Z" fill="#C1DBF6"/>
<path d="M43.7493 1.90049C43.8835 1.90049 44.0023 1.93085 44.0994 1.98822C44.0994 1.98822 46.1513 3.17873 46.1514 3.17873C46.4158 3.33085 46.5804 3.65597 46.5817 4.11946L46.6417 25.2753L46.6942 24.8874L50.6933 19.0206C50.6933 19.0206 50.7846 19.0226 50.9267 19.036L50.9279 19.0343C50.9279 19.0343 51.0658 18.6205 51.8109 18.5238C51.8665 18.5167 51.9182 18.5133 51.9662 18.5133C52.1141 18.5133 52.2271 18.5448 52.314 18.5919L52.8456 17.7374C52.8456 17.7374 53.0342 17.6835 53.2814 17.6835C53.5021 17.6835 53.7695 17.7265 53.9907 17.8892C54.5555 18.3044 54.4598 18.7723 54.4598 18.7723L53.2789 20.3826C53.4278 20.659 53.4251 20.8417 53.4251 20.8417L46.6559 30.289L46.6942 43.7874C46.6968 44.7178 46.0416 45.8526 45.2338 46.319L18.2493 61.8985C18.0134 62.0348 17.7904 62.099 17.5927 62.099C17.447 62.099 17.315 62.0641 17.2016 61.9972C17.2013 61.9972 15.1788 60.8226 15.1788 60.8226C14.9154 60.6695 14.752 60.3441 14.7507 59.882L14.6382 20.214C14.6356 19.2836 15.2907 18.1511 16.1005 17.6835L25.4576 12.2812L25.4529 10.5948C25.45 9.60104 26.1491 8.39023 27.0139 7.89101L32.1347 4.93453C32.3871 4.78877 32.6257 4.71969 32.8372 4.71969C32.9904 4.71981 33.1294 4.75597 33.249 4.8255L35.2758 6.00328C35.2751 6.00294 35.2742 6.00262 35.2734 6.00216C35.4233 6.08854 35.5418 6.22838 35.6214 6.41311L43.085 2.10406C43.3261 1.96488 43.554 1.90038 43.7493 1.90049ZM43.7494 0.986206C43.3853 0.986092 42.9974 1.09894 42.6278 1.31222L35.8012 5.25352C35.7886 5.24547 35.7759 5.23745 35.7631 5.22963C35.7539 5.22394 35.7446 5.21825 35.7352 5.21279L33.7084 4.03501C33.45 3.8849 33.1488 3.80543 32.8372 3.80543C32.4552 3.80543 32.065 3.91894 31.6774 4.14282L26.5568 7.0993C25.4025 7.76559 24.5348 9.2695 24.5386 10.5974L24.5419 11.7542L15.6433 16.8917C14.5455 17.5256 13.7203 18.955 13.7239 20.2166L13.8364 59.8846C13.8385 60.6581 14.1603 61.2882 14.7192 61.613L15.7309 62.2005C16.2512 62.5027 16.5504 62.6764 16.7413 62.7763L16.7365 62.7844C16.9902 62.9342 17.2862 63.0134 17.5927 63.0134C17.9604 63.0134 18.3352 62.9047 18.7065 62.6903L45.6909 47.1108C46.7876 46.4777 47.612 45.0477 47.6084 43.7849L47.5709 30.5816L54.1682 21.3742C54.2767 21.2228 54.3364 21.0417 54.3392 20.8555C54.3401 20.7939 54.3356 20.6814 54.3035 20.5316L55.1971 19.313C55.275 19.2066 55.3291 19.0846 55.3555 18.9554C55.4537 18.4751 55.2969 17.7148 54.5322 17.1527C54.1911 16.9019 53.7586 16.7693 53.2813 16.7693C52.9243 16.7693 52.6465 16.8435 52.5945 16.8584C52.3763 16.9207 52.1891 17.0618 52.0692 17.2545L51.8528 17.6023C51.8004 17.6052 51.7471 17.6103 51.6935 17.6172C51.1122 17.6925 50.73 17.907 50.482 18.131C50.2629 18.1831 50.0677 18.3151 49.9378 18.5057L47.5466 22.0135L47.4959 4.11695C47.4937 3.34072 47.1698 2.70991 46.6072 2.38625L44.5582 1.19741C44.3265 1.06058 44.0448 0.986206 43.7494 0.986206Z" fill="#374874"/>
<path d="M52.8456 17.7375C52.8456 17.7375 53.5216 17.5443 53.9907 17.8893C54.5554 18.3045 54.4598 18.7723 54.4598 18.7723L53.0939 20.6348L51.6866 19.6001L52.8456 17.7375Z" fill="white"/>
<path d="M35.6272 41.123L50.6932 19.0206C50.6932 19.0206 51.1745 19.0273 51.7065 19.1813C51.9822 19.261 52.2714 19.3803 52.5144 19.5587C53.4375 20.2363 53.425 20.8418 53.425 20.8418L38.2762 42.7787L35.6272 44.2687L35.1719 43.9238L35.6272 41.123Z" fill="#C1DBF6"/>
<path d="M48.0581 25.2355L52.5972 19.0206C52.5972 19.0206 52.5558 18.4274 51.8108 18.5239C51.0658 18.6205 50.9278 19.0344 50.9278 19.0344L46.8716 24.76C46.8716 24.76 46.3335 25.5316 48.0581 25.2355Z" fill="white"/>
<path d="M35.2133 43.696L35.0342 44.4941C35.0166 44.5723 35.1033 44.632 35.1701 44.5876L35.8273 44.1513C35.8273 44.1513 35.6855 43.9109 35.6134 43.8547C35.5285 43.7886 35.2133 43.696 35.2133 43.696Z" fill="#ECECEC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.799 24.3406C36.7992 24.3407 36.7993 24.3408 36.7 24.5452L36.7993 24.3408C36.9128 24.3959 36.9602 24.5326 36.905 24.6462C36.85 24.7596 36.7135 24.807 36.6001 24.7522C36.5999 24.7521 36.5997 24.7521 36.5996 24.752L36.6994 24.5464C36.5996 24.752 36.5995 24.7519 36.5996 24.752L36.5979 24.7512C36.5956 24.7501 36.5915 24.7483 36.5858 24.7457C36.5744 24.7407 36.5562 24.7329 36.5317 24.7231C36.4825 24.7036 36.4077 24.6761 36.3096 24.6465C36.1133 24.5874 35.8243 24.52 35.4611 24.4908C34.7365 24.4324 33.7119 24.5251 32.5312 25.1449C31.3349 25.7729 29.8362 26.9478 28.8321 28.3057C27.8226 29.6709 27.3598 31.1501 28.0695 32.4434C28.4862 33.2027 29.0952 33.4915 29.8333 33.5503C30.5929 33.6108 31.4702 33.4258 32.3938 33.2304L32.3997 33.2292C33.3027 33.0382 34.2509 32.8377 35.0935 32.9094C35.959 32.983 36.7278 33.3452 37.2411 34.2724C37.8182 35.3146 37.797 36.4184 37.3715 37.4468C36.9485 38.4691 36.127 39.4181 35.0951 40.1851C33.037 41.715 30.0693 42.5718 27.556 41.7907C27.4355 41.7532 27.3681 41.6252 27.4056 41.5046C27.4431 41.3841 27.5712 41.3167 27.6917 41.3542C30.028 42.0803 32.8459 41.2874 34.8224 39.8182C35.8079 39.0857 36.566 38.1979 36.9491 37.272C37.3297 36.3523 37.341 35.3965 36.8412 34.4938C36.4147 33.7236 35.7973 33.428 35.0547 33.3649C34.2909 33.2999 33.4113 33.4825 32.4884 33.6777L32.4517 33.6855C31.5586 33.8744 30.6248 34.0719 29.797 34.006C28.9363 33.9374 28.1732 33.5826 27.6688 32.6633C26.8278 31.1309 27.4199 29.4466 28.4645 28.0339C29.5145 26.6139 31.069 25.3962 32.3187 24.7402C33.5841 24.0759 34.6967 23.9706 35.4978 24.0351C35.8975 24.0673 36.2185 24.1417 36.4415 24.2088C36.553 24.2425 36.6401 24.2743 36.7005 24.2983C36.7307 24.3103 36.7542 24.3204 36.7708 24.3277C36.7791 24.3313 36.7856 24.3343 36.7904 24.3365L36.7962 24.3393L36.7981 24.3402L36.7987 24.3405L36.799 24.3406Z" fill="#374874"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M32.3994 22.5471C32.5256 22.5471 32.628 22.6495 32.628 22.7757V43.5612C32.628 43.6874 32.5256 43.7897 32.3994 43.7897C32.2731 43.7897 32.1708 43.6874 32.1708 43.5612V22.7757C32.1708 22.6495 32.2731 22.5471 32.3994 22.5471Z" fill="#374874"/>
</svg>

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View file

@ -0,0 +1,13 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M32.5125 11.4113L27.7457 8.71763C27.1893 8.28739 26.4241 8.24848 25.827 8.62007L5.13932 20.5438C4.48071 20.9576 4.19072 21.7682 4.43744 22.5058L18.4411 60.5643C18.5986 61.0023 19.1694 61.9233 19.6074 61.7658C19.6564 61.7482 19.7037 61.7261 19.7486 61.6997L59.5116 38.6814C60.1816 38.2923 60.4578 37.4658 60.1566 36.752L47.0757 5.88032C46.7147 5.02549 45.7291 4.62519 44.8742 4.9862C44.835 5.00275 44.7965 5.02078 44.7587 5.04025L34.3227 11.5712C33.7399 11.8763 33.0328 11.8138 32.5125 11.4113Z" fill="#FBDBD0"/>
<path d="M3.40499 22.1807L18.0382 60.8153L17.959 59.2562L4.43747 22.514C4.34574 22.2361 4.32707 21.9394 4.38327 21.6522L3.35349 21.2999C3.29203 21.5929 3.30981 21.8969 3.40499 22.1807Z" fill="#C1DBF6"/>
<path d="M26.9895 8.37342C26.4303 7.9467 25.6642 7.91425 25.0709 8.29212L4.10676 20.2159C3.71826 20.4593 3.44612 20.8509 3.35339 21.2998L4.38318 21.644C4.47273 21.1887 4.74631 20.7906 5.13924 20.5438L25.8269 8.62003C26.1857 8.3971 26.6154 8.31774 27.0302 8.39781L26.9895 8.37342Z" fill="white"/>
<path d="M43.7642 4.99963L33.5667 11.2325C33.2289 11.4099 32.8403 11.4654 32.4664 11.3897L32.5125 11.4168C33.0328 11.8193 33.7399 11.8818 34.3227 11.5767L44.7587 5.04029C45.1904 4.81487 45.6987 4.78818 46.1516 4.96713C46.1516 4.96713 44.7885 4.44409 43.7642 4.99963Z" fill="white"/>
<path d="M46.4362 10.7474L45.3902 16.7391C45.3128 17.0273 45.1287 17.2753 44.8753 17.4328L18.8599 32.5109C18.5202 32.7272 18.3155 33.1028 18.3179 33.5055L18.8599 61.3989C18.8647 61.6608 19.081 61.8691 19.3428 61.8643C19.4237 61.8628 19.5027 61.8406 19.5726 61.8L60.1404 38.3129C60.4772 38.1185 60.6839 37.7587 60.6824 37.3699V3.82893C60.6826 3.18089 60.1574 2.65536 59.5094 2.65515C59.2888 2.65508 59.0726 2.71721 58.8857 2.83437L46.962 10.0537C46.7037 10.2083 46.5153 10.4569 46.4362 10.7474Z" fill="white"/>
<path d="M43.9565 16.9044L17.9085 31.931C17.7906 32.0026 17.686 32.0944 17.5996 32.202L18.5725 32.7657L18.6375 32.6871C18.7061 32.6153 18.7835 32.5525 18.8679 32.5001L44.8833 17.422C45.0062 17.3459 45.1137 17.2476 45.2004 17.132L44.2736 16.6117C44.1867 16.7279 44.0793 16.8271 43.9565 16.9044Z" fill="white"/>
<path d="M45.5148 10.0672L44.4688 16.2106C44.4283 16.3597 44.3592 16.4996 44.2655 16.6225L45.1923 17.1428C45.2798 17.0236 45.345 16.8896 45.3847 16.7471L46.4307 10.7555C46.4505 10.6836 46.4768 10.6138 46.5093 10.5468C46.5643 10.435 46.6365 10.3326 46.7234 10.2433L45.7207 9.64709C45.6253 9.77233 45.5554 9.91505 45.5148 10.0672Z" fill="#C1DBF6"/>
<path d="M57.0781 2.38721C57.0613 2.39425 57.045 2.40239 57.0293 2.4116L46.0296 9.37615C45.9114 9.44737 45.8067 9.53914 45.7207 9.64714L46.7315 10.2244C46.7987 10.1558 46.8732 10.0949 46.9537 10.0428L54.9074 5.22993L58.8829 2.82351C59.4283 2.48309 60.1461 2.64504 60.4926 3.18664C59.975 2.40076 58.4222 1.76392 57.0781 2.38721Z" fill="white"/>
<path d="M17.3559 32.9283L18.0095 60.8153C18.0095 61.0132 19.1119 62.0576 19.5265 61.8163C19.5265 61.8163 19.5237 61.7906 19.4181 61.8054C19.0089 61.838 18.8409 61.3854 18.8328 60.9925L18.3152 33.4947C18.3161 33.2954 18.3674 33.0997 18.4642 32.9256C18.4953 32.869 18.5316 32.8155 18.5726 32.7657L17.5998 32.202C17.4401 32.4103 17.3542 32.6658 17.3559 32.9283Z" fill="#C1DBF6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.8461 19.7264C52.0683 19.8463 52.1512 20.1236 52.0312 20.3458L39.1085 44.282C39.0509 44.3887 38.9533 44.4682 38.8371 44.5029C38.7209 44.5376 38.5957 44.5247 38.489 44.4671L33.1549 41.5857C32.9327 41.4657 32.8499 41.1883 32.9699 40.9662C33.0899 40.7441 33.3673 40.6613 33.5894 40.7813L38.5212 43.4453L51.2267 19.9114C51.3466 19.6893 51.624 19.6064 51.8461 19.7264Z" fill="#374874"/>
<path d="M58.2392 2.13562C59.2166 2.13567 60.1207 2.6225 60.4923 3.18609C60.4896 3.18209 60.4862 3.17901 60.4834 3.17501C60.609 3.36189 60.6824 3.5868 60.6823 3.82888V37.3699C60.6839 37.7586 60.4771 38.1185 60.1404 38.3129C60.1404 38.3129 19.4236 61.8628 19.3428 61.8643H19.3423C19.2174 61.8643 18.0094 60.9114 18.0094 60.8153L3.40501 22.1806C3.30981 21.8969 3.29205 21.5929 3.35352 21.2999C3.44625 20.851 3.71839 20.4593 4.10689 20.2159L25.071 8.29219C25.3467 8.11661 25.6597 8.02961 25.972 8.02963C26.3316 8.02963 26.6903 8.14509 26.9896 8.3735L27.0285 8.39686C27.2842 8.44511 27.5307 8.55135 27.7457 8.71768L32.4761 11.3908C32.5824 11.4116 32.6899 11.422 32.797 11.422C33.0635 11.422 33.3278 11.3579 33.5667 11.2325L43.7642 4.99965C44.0909 4.82248 44.5835 4.68003 45.1017 4.68006C45.8459 4.68008 46.6428 4.97389 47.0757 5.88035L48.0224 8.11448C48.0224 8.11448 56.3587 2.84095 57.0781 2.38721C57.4579 2.21112 57.8542 2.13562 58.2392 2.13562ZM58.2392 1.22134C57.695 1.22131 57.175 1.3345 56.6935 1.55777L56.6401 1.5825L56.5904 1.61388C56.0181 1.97482 50.6262 5.38545 48.4439 6.76593L47.9176 5.52367L47.9096 5.50481L47.9007 5.48634C47.3785 4.39299 46.3584 3.76586 45.1018 3.76577C44.4788 3.76572 43.8324 3.92252 43.3283 4.19594L43.3075 4.20721L43.2873 4.21955L33.1204 10.4337C33.0203 10.4822 32.9089 10.5077 32.797 10.5077C32.7883 10.5077 32.7796 10.5076 32.7709 10.5073L28.2464 7.95046C27.9935 7.76636 27.7056 7.62854 27.4016 7.5454C26.9798 7.26705 26.4785 7.11535 25.972 7.11532C25.4865 7.11532 25.0131 7.25085 24.6013 7.50751L3.65487 19.4212L3.63793 19.4308L3.62143 19.4411C3.0246 19.8151 2.60058 20.4252 2.45813 21.115C2.36396 21.5639 2.39144 22.0339 2.5382 22.4714L2.54369 22.4877L2.54979 22.5039L17.1263 61.0648C17.2203 61.4152 17.528 61.6723 18.0632 62.094C18.8065 62.6796 19.0345 62.7785 19.3423 62.7785H19.3508L19.3598 62.7784C19.6004 62.7739 19.6254 62.7596 20.2671 62.3938C20.5704 62.2209 21.0128 61.9673 21.5744 61.6446C22.6958 61.0004 24.2934 60.0801 26.2082 58.9757C30.037 56.7676 35.1344 53.8236 40.2293 50.8797C50.4188 44.9919 60.5981 39.1044 60.5981 39.1044C61.2166 38.7473 61.5995 38.0811 61.5966 37.3662V3.82888C61.5967 3.42636 61.4821 3.03656 61.2647 2.69907L61.2657 2.69841L61.2567 2.68422L61.251 2.6759C60.6729 1.80561 59.4638 1.22143 58.2392 1.22134Z" fill="#374874"/>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="512px" height="512px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-734,0)">
<g id="Artboard3" transform="matrix(0.741178,0,0,0.73941,189.822,133.422)">
<rect x="734.208" y="-180.444" width="690.792" height="692.444" style="fill:none;"/>
<g id="g2221" transform="matrix(5.72329,0,0,5.73697,406.05,-691.239)">
<g id="Layer-2" serif:id="Layer 2">
<g id="g22211" serif:id="g2221">
<g id="path26" transform="matrix(1.7333,0,0,1.72464,-36.9275,-59.3636)">
<ellipse cx="89.202" cy="121.039" rx="32.302" ry="32.464" style="fill:white;"/>
</g>
<g id="path28" transform="matrix(1.00106,0,0,1.00171,-0.641439,-0.841358)">
<ellipse cx="118.203" cy="149.97" rx="49.218" ry="49.185" style="fill:rgb(113,75,103);"/>
</g>
<g id="path859" transform="matrix(1.00285,0,0,0.996901,-0.432423,-0.053773)">
<ellipse cx="118.239" cy="149.903" rx="29.619" ry="29.796" style="fill:white;"/>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,19 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M48.2304 3.28046C49.0383 2.81404 49.6978 3.18771 49.7004 4.11811L49.8128 43.786C49.8155 44.7164 49.1603 45.8512 48.3525 46.3176L21.368 61.8971C20.5583 62.3646 19.8988 61.9888 19.8962 61.0584L19.7837 21.3905C19.7811 20.4601 20.4362 19.3275 21.2459 18.86L48.2304 3.28046Z" fill="white"/>
<path d="M47.7615 6.3496L47.8677 43.7991L21.831 58.7543L21.7331 21.3906L47.7615 6.3496ZM47.7615 5.89246C47.6825 5.89246 47.6036 5.91289 47.5328 5.95385L21.5044 20.9948C21.3627 21.0766 21.2756 21.2281 21.276 21.3918L21.3739 58.7555C21.3743 58.9185 21.4614 59.0688 21.6025 59.1503C21.6732 59.191 21.7521 59.2114 21.831 59.2114C21.9096 59.2114 21.9882 59.1912 22.0587 59.1507L48.0954 44.1956C48.2377 44.1138 48.3253 43.962 48.3249 43.7978L48.2187 6.3483C48.2182 6.18523 48.1309 6.03467 47.9895 5.95332C47.9189 5.91282 47.8402 5.89246 47.7615 5.89246Z" fill="#374874"/>
<path d="M20.3243 61.999L18.2975 60.8211C18.0342 60.668 17.8707 60.3427 17.8694 59.8805L19.8962 61.0584C19.8975 61.5206 20.0609 61.8459 20.3243 61.999Z" fill="#FBDBD0"/>
<path d="M47.2462 2.00083L49.2731 3.17868C49.0065 3.02373 48.6373 3.0456 48.2305 3.28046L46.2037 2.10261C46.6105 1.86775 46.9796 1.8459 47.2462 2.00083Z" fill="#FBDBD0"/>
<path d="M19.8962 61.0584L17.8694 59.8805L17.757 20.2127L19.7838 21.3905L19.8962 61.0584Z" fill="#FBDBD0"/>
<path d="M21.246 18.86L19.2192 17.6821L46.2037 2.10261L48.2305 3.28046L21.246 18.86Z" fill="#FBDBD0"/>
<path d="M19.7838 21.3905L17.757 20.2127C17.7543 19.2823 18.4095 18.1496 19.2192 17.6821L21.246 18.86C20.4363 19.3274 19.7811 20.4601 19.7838 21.3905Z" fill="#FBDBD0"/>
<path d="M37.2801 6.11085L35.2533 4.933C35.6885 4.68176 36.0829 4.65849 36.3677 4.824L38.3945 6.00185C38.1097 5.83634 37.7153 5.85961 37.2801 6.11085Z" fill="#C1DBF6"/>
<path d="M30.604 13.7733L28.5772 12.5955L28.5715 10.5933L30.5983 11.7712L30.604 13.7733Z" fill="#C1DBF6"/>
<path d="M32.1594 9.06731L30.1326 7.88946L35.2533 4.933L37.2801 6.11085L32.1594 9.06731Z" fill="#C1DBF6"/>
<path d="M30.5983 11.7712L28.5715 10.5933C28.5687 9.59961 29.2678 8.38873 30.1326 7.88946L32.1594 9.06731C31.2946 9.5666 30.5955 10.7775 30.5983 11.7712Z" fill="#C1DBF6"/>
<path d="M37.2802 6.11087C38.145 5.61158 38.8486 6.01258 38.8515 7.00627L38.8571 9.00844L39.8945 8.40952C40.7556 7.91235 41.4574 8.31228 41.4602 9.30182L41.4646 10.8263C41.465 10.9925 41.3765 11.1464 41.2325 11.2295L28.71 18.4593C28.4012 18.6377 28.0149 18.4154 28.0139 18.0588L28.0111 17.0667C28.0083 16.0771 28.7056 14.8695 29.5667 14.3723L30.6041 13.7734L30.5984 11.7712C30.5956 10.7775 31.2947 9.56662 32.1594 9.06735L37.2802 6.11087Z" fill="#C1DBF6"/>
<path d="M46.8679 1.899C47.0022 1.899 47.121 1.92947 47.2182 1.98684C47.2182 1.98684 49.27 3.17736 49.2701 3.17736C49.5346 3.32947 49.6991 3.65459 49.7004 4.11809L49.8129 43.786C49.8156 44.7164 49.1604 45.8511 48.3525 46.3177L21.368 61.8971C21.1321 62.0333 20.9091 62.0977 20.7113 62.0977C20.5657 62.0977 20.4337 62.0628 20.3203 61.9958L18.2975 60.8212C18.0341 60.6681 17.8707 60.3428 17.8694 59.8806L17.7569 20.2126C17.7543 19.2823 18.4095 18.1497 19.2192 17.6822L28.5764 12.2797L28.5716 10.5935C28.5688 9.59969 29.2679 8.38876 30.1327 7.88954L35.2534 4.93306C35.5058 4.7873 35.7444 4.71832 35.956 4.71832C36.1092 4.71832 36.2481 4.75459 36.3678 4.82401L38.3943 6.00179C38.3936 6.00134 38.3928 6.00113 38.3921 6.00079C38.542 6.08705 38.6605 6.2268 38.7401 6.41174L46.2037 2.10259C46.4448 1.96342 46.6726 1.899 46.8679 1.899ZM38.3943 6.00179L38.3946 6.00193L38.3943 6.00179ZM46.868 0.984741C46.5039 0.984741 46.1161 1.09747 45.7466 1.31075L38.9199 5.25217C38.907 5.2438 38.8939 5.23564 38.8807 5.22762C38.8718 5.22204 38.8628 5.21658 38.8537 5.21132L36.8272 4.03354C36.5687 3.88344 36.2675 3.80408 35.9561 3.80408C35.5741 3.80408 35.1839 3.91747 34.7963 4.14124L29.6755 7.09784C28.5212 7.76412 27.6536 9.26803 27.6573 10.5961L27.6606 11.7527L18.762 16.8905C17.6642 17.5243 16.8391 18.9536 16.8427 20.2152L16.9551 59.8832C16.9573 60.6568 17.2791 61.2868 17.838 61.6117C17.8384 61.6119 19.7688 62.7328 19.8572 62.7841C20.1103 62.9332 20.4057 63.012 20.7113 63.012C21.0791 63.012 21.4537 62.9033 21.8251 62.689L48.8096 47.1095C49.9064 46.4762 50.7308 45.0462 50.7272 43.7834L50.6147 4.11555C50.6125 3.34033 50.2895 2.7102 49.7283 2.3862C49.7052 2.37281 47.677 1.19603 47.677 1.19603C47.4452 1.0591 47.1634 0.984741 46.868 0.984741Z" fill="#374874"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.9177 24.3392C39.9178 24.3392 39.9179 24.3393 39.8187 24.5437L39.9179 24.3393C40.0315 24.3944 40.0788 24.5312 40.0237 24.6447C39.9686 24.7581 39.8322 24.8055 39.7187 24.7507C39.7186 24.7506 39.7184 24.7506 39.7182 24.7505L39.8181 24.5449C39.7182 24.7505 39.7181 24.7504 39.7182 24.7505L39.7165 24.7497C39.7142 24.7486 39.7102 24.7468 39.7045 24.7442C39.693 24.7392 39.6749 24.7314 39.6503 24.7216C39.6011 24.7021 39.5263 24.6746 39.4282 24.6451C39.2319 24.5859 38.9429 24.5185 38.5798 24.4893C37.8552 24.4309 36.8305 24.5237 35.6499 25.1434C34.4535 25.7715 32.9548 26.9463 31.9507 28.3042C30.9413 29.6695 30.4785 31.1486 31.1882 32.4419C31.6049 33.2012 32.2139 33.49 32.9519 33.5488C33.7115 33.6093 34.5889 33.4243 35.5125 33.229L35.5183 33.2277C36.4214 33.0367 37.3696 32.8362 38.2121 32.9079C39.0777 32.9815 39.8464 33.3438 40.3598 34.2709C40.9369 35.3131 40.9157 36.4169 40.4902 37.4453C40.0672 38.4676 39.2457 39.4166 38.2138 40.1836C36.1556 41.7135 33.1879 42.5703 30.6747 41.7892C30.5541 41.7518 30.4868 41.6237 30.5242 41.5031C30.5617 41.3826 30.6898 41.3152 30.8104 41.3527C33.1467 42.0788 35.9645 41.2859 37.9411 39.8168C38.9265 39.0843 39.6847 38.1964 40.0678 37.2706C40.4483 36.3508 40.4597 35.3951 39.9599 34.4924C39.5333 33.7221 38.9159 33.4265 38.1734 33.3634C37.4096 33.2984 36.53 33.481 35.607 33.6762L35.5703 33.684C34.6772 33.8729 33.7435 34.0704 32.9156 34.0045C32.055 33.9359 31.2919 33.5811 30.7874 32.6619C29.9465 31.1294 30.5386 29.4452 31.5832 28.0324C32.6331 26.6124 34.1877 25.3947 35.4374 24.7387C36.7027 24.0744 37.8153 23.9691 38.6165 24.0336C39.0162 24.0658 39.3372 24.1402 39.5601 24.2074C39.6716 24.241 39.7588 24.2728 39.8192 24.2968C39.8494 24.3088 39.8729 24.3189 39.8894 24.3262C39.8977 24.3299 39.9042 24.3328 39.909 24.3351L39.9148 24.3378L39.9167 24.3387L39.9174 24.339L39.9177 24.3392Z" fill="#374874"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M35.5181 22.5457C35.6443 22.5457 35.7466 22.648 35.7466 22.7742V43.5597C35.7466 43.6859 35.6443 43.7883 35.5181 43.7883C35.3918 43.7883 35.2895 43.6859 35.2895 43.5597V22.7742C35.2895 22.648 35.3918 22.5457 35.5181 22.5457Z" fill="#374874"/>
<path d="M49.5169 33.14C53.8497 30.6384 57.3723 32.6475 57.3864 37.6246C57.4005 42.6017 53.9008 48.6651 49.568 51.1667C45.2366 53.6674 41.714 51.6599 41.6999 46.6812C41.6857 41.7025 45.1855 35.6407 49.5169 33.14Z" fill="#374874"/>
<path d="M54.0353 35.226L54.7045 35.6559L47.1187 48.2591L44.3662 46.6905L45.0513 45.5022L47.1348 46.6911L54.0353 35.226Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 6.8 KiB

View file

@ -0,0 +1,9 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.3095 17.3993L22.0328 51.6869C22.0328 51.6869 21.8805 60.315 29.4658 62.0791L30.178 60.0212L33.6656 59.5619L34.4012 57.47L37.9639 57.0515L38.6927 54.9867L42.2732 54.4366L42.912 52.4792L46.4268 51.9162L47.2035 49.905L50.3861 49.5137C50.3861 49.5137 44.5997 49.9724 43.8764 44.5353C43.1531 39.0982 43.8764 12.9918 43.8764 12.9918C43.8764 12.9918 43.684 2.78861 34.7389 1.92065L15.3412 11.8767C15.3412 11.8767 21.3095 11.6206 21.3095 17.3993Z" fill="white"/>
<path d="M13.6426 16.8207C13.6426 16.8207 13.064 11.3236 17.1144 11.4683C21.1649 11.6129 21.3096 17.3993 21.3096 17.3993L13.6179 21.4496L13.6426 16.8207Z" fill="#FBDBD0"/>
<path d="M34.739 1.92076C43.684 2.78873 43.8764 12.9918 43.8764 12.9918C43.8764 12.9918 43.1531 39.0983 43.8764 44.5354C44.4953 49.1877 48.8209 49.5233 50.0569 49.5233C50.2654 49.5233 50.3861 49.5137 50.3861 49.5137L47.2036 49.9051L46.4269 51.9162L42.912 52.4793L42.2733 54.4367L38.6928 54.9868L37.9639 57.0516L34.4012 57.4701L33.6657 59.562L30.1781 60.0213L29.4658 62.0792C21.8805 60.3151 22.0328 51.687 22.0328 51.687L21.3095 17.3993L13.6178 21.4497L13.6425 16.8207C13.6425 16.8207 13.2557 13.1286 15.3467 11.8767C15.3455 11.8767 15.3413 11.8768 15.3413 11.8768L15.4025 11.8454C15.4688 11.8078 15.537 11.7723 15.6083 11.7397L34.739 1.92076ZM50.3861 49.5137H50.3873H50.3861ZM34.739 1.00647C34.5942 1.00647 34.451 1.04085 34.3215 1.10736L15.2077 10.9176C15.1284 10.9546 15.0501 10.9947 14.9695 11.04L14.9244 11.0631C14.8808 11.0854 14.8397 11.1108 14.8011 11.139C12.4487 12.6286 12.6827 16.3406 12.728 16.8608L12.7035 21.4448C12.7018 21.766 12.8687 22.0645 13.1432 22.2312C13.2887 22.3195 13.4531 22.364 13.6178 22.364C13.7639 22.364 13.9102 22.3291 14.0438 22.2587L20.4266 18.8976L21.1184 51.6915C21.1162 51.9413 21.1256 54.131 22.0509 56.5484C23.3553 59.956 25.8477 62.1765 29.2587 62.9698C29.3281 62.9859 29.3977 62.9937 29.4663 62.9937C29.8481 62.9937 30.2 62.7532 30.3298 62.3783L30.8573 60.854L33.785 60.4685C34.1266 60.4235 34.4139 60.1903 34.5282 59.8653L35.0744 58.3116L38.0705 57.9597C38.4169 57.919 38.7099 57.6848 38.826 57.356L39.3727 55.8073L42.412 55.3404C42.7529 55.288 43.0354 55.0483 43.1424 54.7204L43.6079 53.2938L46.5714 52.8191C46.8927 52.7676 47.1625 52.5492 47.2797 52.2457L47.859 50.7457L50.4857 50.4227C50.9439 50.373 51.3009 49.985 51.3009 49.5138C51.3009 49.0293 50.9246 48.6328 50.4481 48.6015C50.4279 48.6001 50.4075 48.5994 50.387 48.5994C50.3755 48.5994 50.364 48.5997 50.3524 48.6001C50.3384 48.6006 50.3245 48.6014 50.3107 48.6025C50.2928 48.6037 50.2013 48.609 50.0568 48.609C48.6388 48.609 45.2863 48.2004 44.7827 44.4148C44.0772 39.1117 44.7831 13.2775 44.7903 13.0172C44.7907 13.003 44.7908 12.9888 44.7905 12.9746C44.7884 12.863 44.7249 10.2091 43.4994 7.41458C41.8326 3.61397 38.8338 1.39952 34.8272 1.01079C34.7978 1.00789 34.7684 1.00647 34.739 1.00647Z" fill="#374874"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.7046 18.6847C39.7654 18.7953 39.7249 18.9343 39.6143 18.995L26.333 26.2861C26.2223 26.3469 26.0834 26.3064 26.0226 26.1958C25.9619 26.0851 26.0023 25.9461 26.113 25.8854L39.3943 18.5943C39.5049 18.5335 39.6439 18.574 39.7046 18.6847Z" fill="#374874"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.7046 25.9873C39.7654 26.0979 39.7249 26.2369 39.6143 26.2976L26.333 33.5887C26.2223 33.6495 26.0834 33.609 26.0226 33.4984C25.9619 33.3877 26.0023 33.2488 26.113 33.188L39.3943 25.8969C39.5049 25.8362 39.6439 25.8766 39.7046 25.9873Z" fill="#374874"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.7046 33.2899C39.7654 33.4005 39.7249 33.5395 39.6143 33.6002L26.333 40.8913C26.2223 40.9521 26.0834 40.9116 26.0226 40.801C25.9619 40.6903 26.0023 40.5514 26.113 40.4906L39.3943 33.1995C39.5049 33.1388 39.6439 33.1792 39.7046 33.2899Z" fill="#374874"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.7046 40.5925C39.7654 40.7032 39.7249 40.8421 39.6143 40.9029L26.333 48.194C26.2223 48.2547 26.0834 48.2143 26.0226 48.1036C25.9619 47.9929 26.0023 47.854 26.113 47.7932L39.3943 40.5021C39.5049 40.4414 39.6439 40.4818 39.7046 40.5925Z" fill="#374874"/>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,139 @@
/*!
* Bootstrap backdrop.js v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('../dom/event-handler.js'), require('./config.js'), require('./index.js')) :
typeof define === 'function' && define.amd ? define(['../dom/event-handler', './config', './index'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Backdrop = factory(global.EventHandler, global.Config, global.Index));
})(this, (function (EventHandler, Config, index_js) { 'use strict';
/**
* --------------------------------------------------------------------------
* Bootstrap util/backdrop.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
/**
* Constants
*/
const NAME = 'backdrop';
const CLASS_NAME_FADE = 'fade';
const CLASS_NAME_SHOW = 'show';
const EVENT_MOUSEDOWN = `mousedown.bs.${NAME}`;
const Default = {
className: 'modal-backdrop',
clickCallback: null,
isAnimated: false,
isVisible: true,
// if false, we use the backdrop helper without adding any element to the dom
rootElement: 'body' // give the choice to place backdrop under different elements
};
const DefaultType = {
className: 'string',
clickCallback: '(function|null)',
isAnimated: 'boolean',
isVisible: 'boolean',
rootElement: '(element|string)'
};
/**
* Class definition
*/
class Backdrop extends Config {
constructor(config) {
super();
this._config = this._getConfig(config);
this._isAppended = false;
this._element = null;
}
// Getters
static get Default() {
return Default;
}
static get DefaultType() {
return DefaultType;
}
static get NAME() {
return NAME;
}
// Public
show(callback) {
if (!this._config.isVisible) {
index_js.execute(callback);
return;
}
this._append();
const element = this._getElement();
if (this._config.isAnimated) {
index_js.reflow(element);
}
element.classList.add(CLASS_NAME_SHOW);
this._emulateAnimation(() => {
index_js.execute(callback);
});
}
hide(callback) {
if (!this._config.isVisible) {
index_js.execute(callback);
return;
}
this._getElement().classList.remove(CLASS_NAME_SHOW);
this._emulateAnimation(() => {
this.dispose();
index_js.execute(callback);
});
}
dispose() {
if (!this._isAppended) {
return;
}
EventHandler.off(this._element, EVENT_MOUSEDOWN);
this._element.remove();
this._isAppended = false;
}
// Private
_getElement() {
if (!this._element) {
const backdrop = document.createElement('div');
backdrop.className = this._config.className;
if (this._config.isAnimated) {
backdrop.classList.add(CLASS_NAME_FADE);
}
this._element = backdrop;
}
return this._element;
}
_configAfterMerge(config) {
// use getElement() with the default "body" to get a fresh Element on each instantiation
config.rootElement = index_js.getElement(config.rootElement);
return config;
}
_append() {
if (this._isAppended) {
return;
}
const element = this._getElement();
this._config.rootElement.append(element);
EventHandler.on(element, EVENT_MOUSEDOWN, () => {
index_js.execute(this._config.clickCallback);
});
this._isAppended = true;
}
_emulateAnimation(callback) {
index_js.executeAfterTransition(callback, this._getElement(), this._config.isAnimated);
}
}
return Backdrop;
}));
//# sourceMappingURL=backdrop.js.map

View file

@ -0,0 +1,42 @@
/*!
* Bootstrap component-functions.js v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('../dom/event-handler.js'), require('../dom/selector-engine.js'), require('./index.js')) :
typeof define === 'function' && define.amd ? define(['exports', '../dom/event-handler', '../dom/selector-engine', './index'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ComponentFunctions = {}, global.EventHandler, global.SelectorEngine, global.Index));
})(this, (function (exports, EventHandler, SelectorEngine, index_js) { 'use strict';
/**
* --------------------------------------------------------------------------
* Bootstrap util/component-functions.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
const enableDismissTrigger = (component, method = 'hide') => {
const clickEvent = `click.dismiss${component.EVENT_KEY}`;
const name = component.NAME;
EventHandler.on(document, clickEvent, `[data-bs-dismiss="${name}"]`, function (event) {
if (['A', 'AREA'].includes(this.tagName)) {
event.preventDefault();
}
if (index_js.isDisabled(this)) {
return;
}
const target = SelectorEngine.getElementFromSelector(this) || this.closest(`.${name}`);
const instance = component.getOrCreateInstance(target);
// Method argument is left, for Alert and only, as it doesn't implement the 'hide' method
instance[method]();
});
};
exports.enableDismissTrigger = enableDismissTrigger;
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
}));
//# sourceMappingURL=component-functions.js.map

View file

@ -0,0 +1,68 @@
/*!
* Bootstrap config.js v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('../dom/manipulator.js'), require('./index.js')) :
typeof define === 'function' && define.amd ? define(['../dom/manipulator', './index'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Config = factory(global.Manipulator, global.Index));
})(this, (function (Manipulator, index_js) { 'use strict';
/**
* --------------------------------------------------------------------------
* Bootstrap util/config.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
/**
* Class definition
*/
class Config {
// Getters
static get Default() {
return {};
}
static get DefaultType() {
return {};
}
static get NAME() {
throw new Error('You have to implement the static method "NAME", for each component!');
}
_getConfig(config) {
config = this._mergeConfigObj(config);
config = this._configAfterMerge(config);
this._typeCheckConfig(config);
return config;
}
_configAfterMerge(config) {
return config;
}
_mergeConfigObj(config, element) {
const jsonConfig = index_js.isElement(element) ? Manipulator.getDataAttribute(element, 'config') : {}; // try to parse
return {
...this.constructor.Default,
...(typeof jsonConfig === 'object' ? jsonConfig : {}),
...(index_js.isElement(element) ? Manipulator.getDataAttributes(element) : {}),
...(typeof config === 'object' ? config : {})
};
}
_typeCheckConfig(config, configTypes = this.constructor.DefaultType) {
for (const [property, expectedTypes] of Object.entries(configTypes)) {
const value = config[property];
const valueType = index_js.isElement(value) ? 'element' : index_js.toType(value);
if (!new RegExp(expectedTypes).test(valueType)) {
throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${property}" provided type "${valueType}" but expected type "${expectedTypes}".`);
}
}
}
}
return Config;
}));
//# sourceMappingURL=config.js.map

View file

@ -0,0 +1,113 @@
/*!
* Bootstrap focustrap.js v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('../dom/event-handler.js'), require('../dom/selector-engine.js'), require('./config.js')) :
typeof define === 'function' && define.amd ? define(['../dom/event-handler', '../dom/selector-engine', './config'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Focustrap = factory(global.EventHandler, global.SelectorEngine, global.Config));
})(this, (function (EventHandler, SelectorEngine, Config) { 'use strict';
/**
* --------------------------------------------------------------------------
* Bootstrap util/focustrap.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
/**
* Constants
*/
const NAME = 'focustrap';
const DATA_KEY = 'bs.focustrap';
const EVENT_KEY = `.${DATA_KEY}`;
const EVENT_FOCUSIN = `focusin${EVENT_KEY}`;
const EVENT_KEYDOWN_TAB = `keydown.tab${EVENT_KEY}`;
const TAB_KEY = 'Tab';
const TAB_NAV_FORWARD = 'forward';
const TAB_NAV_BACKWARD = 'backward';
const Default = {
autofocus: true,
trapElement: null // The element to trap focus inside of
};
const DefaultType = {
autofocus: 'boolean',
trapElement: 'element'
};
/**
* Class definition
*/
class FocusTrap extends Config {
constructor(config) {
super();
this._config = this._getConfig(config);
this._isActive = false;
this._lastTabNavDirection = null;
}
// Getters
static get Default() {
return Default;
}
static get DefaultType() {
return DefaultType;
}
static get NAME() {
return NAME;
}
// Public
activate() {
if (this._isActive) {
return;
}
if (this._config.autofocus) {
this._config.trapElement.focus();
}
EventHandler.off(document, EVENT_KEY); // guard against infinite focus loop
EventHandler.on(document, EVENT_FOCUSIN, event => this._handleFocusin(event));
EventHandler.on(document, EVENT_KEYDOWN_TAB, event => this._handleKeydown(event));
this._isActive = true;
}
deactivate() {
if (!this._isActive) {
return;
}
this._isActive = false;
EventHandler.off(document, EVENT_KEY);
}
// Private
_handleFocusin(event) {
const {
trapElement
} = this._config;
if (event.target === document || event.target === trapElement || trapElement.contains(event.target)) {
return;
}
const elements = SelectorEngine.focusableChildren(trapElement);
if (elements.length === 0) {
trapElement.focus();
} else if (this._lastTabNavDirection === TAB_NAV_BACKWARD) {
elements[elements.length - 1].focus();
} else {
elements[0].focus();
}
}
_handleKeydown(event) {
if (event.key !== TAB_KEY) {
return;
}
this._lastTabNavDirection = event.shiftKey ? TAB_NAV_BACKWARD : TAB_NAV_FORWARD;
}
}
return FocusTrap;
}));
//# sourceMappingURL=focustrap.js.map

View file

@ -0,0 +1,281 @@
/*!
* Bootstrap index.js v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Index = {}));
})(this, (function (exports) { 'use strict';
/**
* --------------------------------------------------------------------------
* Bootstrap util/index.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
const MAX_UID = 1000000;
const MILLISECONDS_MULTIPLIER = 1000;
const TRANSITION_END = 'transitionend';
/**
* Properly escape IDs selectors to handle weird IDs
* @param {string} selector
* @returns {string}
*/
const parseSelector = selector => {
if (selector && window.CSS && window.CSS.escape) {
// document.querySelector needs escaping to handle IDs (html5+) containing for instance /
selector = selector.replace(/#([^\s"#']+)/g, (match, id) => `#${CSS.escape(id)}`);
}
return selector;
};
// Shout-out Angus Croll (https://goo.gl/pxwQGp)
const toType = object => {
if (object === null || object === undefined) {
return `${object}`;
}
return Object.prototype.toString.call(object).match(/\s([a-z]+)/i)[1].toLowerCase();
};
/**
* Public Util API
*/
const getUID = prefix => {
do {
prefix += Math.floor(Math.random() * MAX_UID);
} while (document.getElementById(prefix));
return prefix;
};
const getTransitionDurationFromElement = element => {
if (!element) {
return 0;
}
// Get transition-duration of the element
let {
transitionDuration,
transitionDelay
} = window.getComputedStyle(element);
const floatTransitionDuration = Number.parseFloat(transitionDuration);
const floatTransitionDelay = Number.parseFloat(transitionDelay);
// Return 0 if element or transition duration is not found
if (!floatTransitionDuration && !floatTransitionDelay) {
return 0;
}
// If multiple durations are defined, take the first
transitionDuration = transitionDuration.split(',')[0];
transitionDelay = transitionDelay.split(',')[0];
return (Number.parseFloat(transitionDuration) + Number.parseFloat(transitionDelay)) * MILLISECONDS_MULTIPLIER;
};
const triggerTransitionEnd = element => {
element.dispatchEvent(new Event(TRANSITION_END));
};
const isElement = object => {
if (!object || typeof object !== 'object') {
return false;
}
if (typeof object.jquery !== 'undefined') {
object = object[0];
}
return typeof object.nodeType !== 'undefined';
};
const getElement = object => {
// it's a jQuery object or a node element
if (isElement(object)) {
return object.jquery ? object[0] : object;
}
if (typeof object === 'string' && object.length > 0) {
return document.querySelector(parseSelector(object));
}
return null;
};
const isVisible = element => {
if (!isElement(element) || element.getClientRects().length === 0) {
return false;
}
const elementIsVisible = getComputedStyle(element).getPropertyValue('visibility') === 'visible';
// Handle `details` element as its content may falsie appear visible when it is closed
const closedDetails = element.closest('details:not([open])');
if (!closedDetails) {
return elementIsVisible;
}
if (closedDetails !== element) {
const summary = element.closest('summary');
if (summary && summary.parentNode !== closedDetails) {
return false;
}
if (summary === null) {
return false;
}
}
return elementIsVisible;
};
const isDisabled = element => {
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
return true;
}
if (element.classList.contains('disabled')) {
return true;
}
if (typeof element.disabled !== 'undefined') {
return element.disabled;
}
return element.hasAttribute('disabled') && element.getAttribute('disabled') !== 'false';
};
const findShadowRoot = element => {
if (!document.documentElement.attachShadow) {
return null;
}
// Can find the shadow root otherwise it'll return the document
if (typeof element.getRootNode === 'function') {
const root = element.getRootNode();
return root instanceof ShadowRoot ? root : null;
}
if (element instanceof ShadowRoot) {
return element;
}
// when we don't find a shadow root
if (!element.parentNode) {
return null;
}
return findShadowRoot(element.parentNode);
};
const noop = () => {};
/**
* Trick to restart an element's animation
*
* @param {HTMLElement} element
* @return void
*
* @see https://www.charistheo.io/blog/2021/02/restart-a-css-animation-with-javascript/#restarting-a-css-animation
*/
const reflow = element => {
element.offsetHeight; // eslint-disable-line no-unused-expressions
};
const getjQuery = () => {
if (window.jQuery && !document.body.hasAttribute('data-bs-no-jquery')) {
return window.jQuery;
}
return null;
};
const DOMContentLoadedCallbacks = [];
const onDOMContentLoaded = callback => {
if (document.readyState === 'loading') {
// add listener on the first call when the document is in loading state
if (!DOMContentLoadedCallbacks.length) {
document.addEventListener('DOMContentLoaded', () => {
for (const callback of DOMContentLoadedCallbacks) {
callback();
}
});
}
DOMContentLoadedCallbacks.push(callback);
} else {
callback();
}
};
const isRTL = () => document.documentElement.dir === 'rtl';
const defineJQueryPlugin = plugin => {
onDOMContentLoaded(() => {
const $ = getjQuery();
/* istanbul ignore if */
if ($) {
const name = plugin.NAME;
const JQUERY_NO_CONFLICT = $.fn[name];
$.fn[name] = plugin.jQueryInterface;
$.fn[name].Constructor = plugin;
$.fn[name].noConflict = () => {
$.fn[name] = JQUERY_NO_CONFLICT;
return plugin.jQueryInterface;
};
}
});
};
const execute = (possibleCallback, args = [], defaultValue = possibleCallback) => {
return typeof possibleCallback === 'function' ? possibleCallback(...args) : defaultValue;
};
const executeAfterTransition = (callback, transitionElement, waitForTransition = true) => {
if (!waitForTransition) {
execute(callback);
return;
}
const durationPadding = 5;
const emulatedDuration = getTransitionDurationFromElement(transitionElement) + durationPadding;
let called = false;
const handler = ({
target
}) => {
if (target !== transitionElement) {
return;
}
called = true;
transitionElement.removeEventListener(TRANSITION_END, handler);
execute(callback);
};
transitionElement.addEventListener(TRANSITION_END, handler);
setTimeout(() => {
if (!called) {
triggerTransitionEnd(transitionElement);
}
}, emulatedDuration);
};
/**
* Return the previous/next element of a list.
*
* @param {array} list The list of elements
* @param activeElement The active element
* @param shouldGetNext Choose to get next or previous element
* @param isCycleAllowed
* @return {Element|elem} The proper element
*/
const getNextActiveElement = (list, activeElement, shouldGetNext, isCycleAllowed) => {
const listLength = list.length;
let index = list.indexOf(activeElement);
// if the element does not exist in the list return an element
// depending on the direction and if cycle is allowed
if (index === -1) {
return !shouldGetNext && isCycleAllowed ? list[listLength - 1] : list[0];
}
index += shouldGetNext ? 1 : -1;
if (isCycleAllowed) {
index = (index + listLength) % listLength;
}
return list[Math.max(0, Math.min(index, listLength - 1))];
};
exports.defineJQueryPlugin = defineJQueryPlugin;
exports.execute = execute;
exports.executeAfterTransition = executeAfterTransition;
exports.findShadowRoot = findShadowRoot;
exports.getElement = getElement;
exports.getNextActiveElement = getNextActiveElement;
exports.getTransitionDurationFromElement = getTransitionDurationFromElement;
exports.getUID = getUID;
exports.getjQuery = getjQuery;
exports.isDisabled = isDisabled;
exports.isElement = isElement;
exports.isRTL = isRTL;
exports.isVisible = isVisible;
exports.noop = noop;
exports.onDOMContentLoaded = onDOMContentLoaded;
exports.parseSelector = parseSelector;
exports.reflow = reflow;
exports.toType = toType;
exports.triggerTransitionEnd = triggerTransitionEnd;
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
}));
//# sourceMappingURL=index.js.map

View file

@ -0,0 +1,114 @@
/*!
* Bootstrap sanitizer.js v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Sanitizer = {}));
})(this, (function (exports) { 'use strict';
/**
* --------------------------------------------------------------------------
* Bootstrap util/sanitizer.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
// js-docs-start allow-list
const ARIA_ATTRIBUTE_PATTERN = /^aria-[\w-]*$/i;
const DefaultAllowlist = {
// Global attributes allowed on any supplied element below.
'*': ['class', 'dir', 'id', 'lang', 'role', ARIA_ATTRIBUTE_PATTERN],
a: ['target', 'href', 'title', 'rel'],
area: [],
b: [],
br: [],
col: [],
code: [],
dd: [],
div: [],
dl: [],
dt: [],
em: [],
hr: [],
h1: [],
h2: [],
h3: [],
h4: [],
h5: [],
h6: [],
i: [],
img: ['src', 'srcset', 'alt', 'title', 'width', 'height'],
li: [],
ol: [],
p: [],
pre: [],
s: [],
small: [],
span: [],
sub: [],
sup: [],
strong: [],
u: [],
ul: []
};
// js-docs-end allow-list
const uriAttributes = new Set(['background', 'cite', 'href', 'itemtype', 'longdesc', 'poster', 'src', 'xlink:href']);
/**
* A pattern that recognizes URLs that are safe wrt. XSS in URL navigation
* contexts.
*
* Shout-out to Angular https://github.com/angular/angular/blob/15.2.8/packages/core/src/sanitization/url_sanitizer.ts#L38
*/
// eslint-disable-next-line unicorn/better-regex
const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i;
const allowedAttribute = (attribute, allowedAttributeList) => {
const attributeName = attribute.nodeName.toLowerCase();
if (allowedAttributeList.includes(attributeName)) {
if (uriAttributes.has(attributeName)) {
return Boolean(SAFE_URL_PATTERN.test(attribute.nodeValue));
}
return true;
}
// Check if a regular expression validates the attribute.
return allowedAttributeList.filter(attributeRegex => attributeRegex instanceof RegExp).some(regex => regex.test(attributeName));
};
function sanitizeHtml(unsafeHtml, allowList, sanitizeFunction) {
if (!unsafeHtml.length) {
return unsafeHtml;
}
if (sanitizeFunction && typeof sanitizeFunction === 'function') {
return sanitizeFunction(unsafeHtml);
}
const domParser = new window.DOMParser();
const createdDocument = domParser.parseFromString(unsafeHtml, 'text/html');
const elements = [].concat(...createdDocument.body.querySelectorAll('*'));
for (const element of elements) {
const elementName = element.nodeName.toLowerCase();
if (!Object.keys(allowList).includes(elementName)) {
element.remove();
continue;
}
const attributeList = [].concat(...element.attributes);
const allowedAttributes = [].concat(allowList['*'] || [], allowList[elementName] || []);
for (const attribute of attributeList) {
if (!allowedAttribute(attribute, allowedAttributes)) {
element.removeAttribute(attribute.nodeName);
}
}
}
return createdDocument.body.innerHTML;
}
exports.DefaultAllowlist = DefaultAllowlist;
exports.sanitizeHtml = sanitizeHtml;
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
}));
//# sourceMappingURL=sanitizer.js.map

View file

@ -0,0 +1,113 @@
/*!
* Bootstrap scrollbar.js v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('../dom/manipulator.js'), require('../dom/selector-engine.js'), require('./index.js')) :
typeof define === 'function' && define.amd ? define(['../dom/manipulator', '../dom/selector-engine', './index'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Scrollbar = factory(global.Manipulator, global.SelectorEngine, global.Index));
})(this, (function (Manipulator, SelectorEngine, index_js) { 'use strict';
/**
* --------------------------------------------------------------------------
* Bootstrap util/scrollBar.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
/**
* Constants
*/
const SELECTOR_FIXED_CONTENT = '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top';
const SELECTOR_STICKY_CONTENT = '.sticky-top';
const PROPERTY_PADDING = 'padding-right';
const PROPERTY_MARGIN = 'margin-right';
/**
* Class definition
*/
class ScrollBarHelper {
constructor() {
this._element = document.body;
}
// Public
getWidth() {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth#usage_notes
const documentWidth = document.documentElement.clientWidth;
return Math.abs(window.innerWidth - documentWidth);
}
hide() {
const width = this.getWidth();
this._disableOverFlow();
// give padding to element to balance the hidden scrollbar width
this._setElementAttributes(this._element, PROPERTY_PADDING, calculatedValue => calculatedValue + width);
// trick: We adjust positive paddingRight and negative marginRight to sticky-top elements to keep showing fullwidth
this._setElementAttributes(SELECTOR_FIXED_CONTENT, PROPERTY_PADDING, calculatedValue => calculatedValue + width);
this._setElementAttributes(SELECTOR_STICKY_CONTENT, PROPERTY_MARGIN, calculatedValue => calculatedValue - width);
}
reset() {
this._resetElementAttributes(this._element, 'overflow');
this._resetElementAttributes(this._element, PROPERTY_PADDING);
this._resetElementAttributes(SELECTOR_FIXED_CONTENT, PROPERTY_PADDING);
this._resetElementAttributes(SELECTOR_STICKY_CONTENT, PROPERTY_MARGIN);
}
isOverflowing() {
return this.getWidth() > 0;
}
// Private
_disableOverFlow() {
this._saveInitialAttribute(this._element, 'overflow');
this._element.style.overflow = 'hidden';
}
_setElementAttributes(selector, styleProperty, callback) {
const scrollbarWidth = this.getWidth();
const manipulationCallBack = element => {
if (element !== this._element && window.innerWidth > element.clientWidth + scrollbarWidth) {
return;
}
this._saveInitialAttribute(element, styleProperty);
const calculatedValue = window.getComputedStyle(element).getPropertyValue(styleProperty);
element.style.setProperty(styleProperty, `${callback(Number.parseFloat(calculatedValue))}px`);
};
this._applyManipulationCallback(selector, manipulationCallBack);
}
_saveInitialAttribute(element, styleProperty) {
const actualValue = element.style.getPropertyValue(styleProperty);
if (actualValue) {
Manipulator.setDataAttribute(element, styleProperty, actualValue);
}
}
_resetElementAttributes(selector, styleProperty) {
const manipulationCallBack = element => {
const value = Manipulator.getDataAttribute(element, styleProperty);
// We only want to remove the property if the value is `null`; the value can also be zero
if (value === null) {
element.style.removeProperty(styleProperty);
return;
}
Manipulator.removeDataAttribute(element, styleProperty);
element.style.setProperty(styleProperty, value);
};
this._applyManipulationCallback(selector, manipulationCallBack);
}
_applyManipulationCallback(selector, callBack) {
if (index_js.isElement(selector)) {
callBack(selector);
return;
}
for (const sel of SelectorEngine.find(selector, this._element)) {
callBack(sel);
}
}
}
return ScrollBarHelper;
}));
//# sourceMappingURL=scrollbar.js.map

View file

@ -0,0 +1,135 @@
/*!
* Bootstrap swipe.js v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('../dom/event-handler.js'), require('./config.js'), require('./index.js')) :
typeof define === 'function' && define.amd ? define(['../dom/event-handler', './config', './index'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Swipe = factory(global.EventHandler, global.Config, global.Index));
})(this, (function (EventHandler, Config, index_js) { 'use strict';
/**
* --------------------------------------------------------------------------
* Bootstrap util/swipe.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
/**
* Constants
*/
const NAME = 'swipe';
const EVENT_KEY = '.bs.swipe';
const EVENT_TOUCHSTART = `touchstart${EVENT_KEY}`;
const EVENT_TOUCHMOVE = `touchmove${EVENT_KEY}`;
const EVENT_TOUCHEND = `touchend${EVENT_KEY}`;
const EVENT_POINTERDOWN = `pointerdown${EVENT_KEY}`;
const EVENT_POINTERUP = `pointerup${EVENT_KEY}`;
const POINTER_TYPE_TOUCH = 'touch';
const POINTER_TYPE_PEN = 'pen';
const CLASS_NAME_POINTER_EVENT = 'pointer-event';
const SWIPE_THRESHOLD = 40;
const Default = {
endCallback: null,
leftCallback: null,
rightCallback: null
};
const DefaultType = {
endCallback: '(function|null)',
leftCallback: '(function|null)',
rightCallback: '(function|null)'
};
/**
* Class definition
*/
class Swipe extends Config {
constructor(element, config) {
super();
this._element = element;
if (!element || !Swipe.isSupported()) {
return;
}
this._config = this._getConfig(config);
this._deltaX = 0;
this._supportPointerEvents = Boolean(window.PointerEvent);
this._initEvents();
}
// Getters
static get Default() {
return Default;
}
static get DefaultType() {
return DefaultType;
}
static get NAME() {
return NAME;
}
// Public
dispose() {
EventHandler.off(this._element, EVENT_KEY);
}
// Private
_start(event) {
if (!this._supportPointerEvents) {
this._deltaX = event.touches[0].clientX;
return;
}
if (this._eventIsPointerPenTouch(event)) {
this._deltaX = event.clientX;
}
}
_end(event) {
if (this._eventIsPointerPenTouch(event)) {
this._deltaX = event.clientX - this._deltaX;
}
this._handleSwipe();
index_js.execute(this._config.endCallback);
}
_move(event) {
this._deltaX = event.touches && event.touches.length > 1 ? 0 : event.touches[0].clientX - this._deltaX;
}
_handleSwipe() {
const absDeltaX = Math.abs(this._deltaX);
if (absDeltaX <= SWIPE_THRESHOLD) {
return;
}
const direction = absDeltaX / this._deltaX;
this._deltaX = 0;
if (!direction) {
return;
}
index_js.execute(direction > 0 ? this._config.rightCallback : this._config.leftCallback);
}
_initEvents() {
if (this._supportPointerEvents) {
EventHandler.on(this._element, EVENT_POINTERDOWN, event => this._start(event));
EventHandler.on(this._element, EVENT_POINTERUP, event => this._end(event));
this._element.classList.add(CLASS_NAME_POINTER_EVENT);
} else {
EventHandler.on(this._element, EVENT_TOUCHSTART, event => this._start(event));
EventHandler.on(this._element, EVENT_TOUCHMOVE, event => this._move(event));
EventHandler.on(this._element, EVENT_TOUCHEND, event => this._end(event));
}
}
_eventIsPointerPenTouch(event) {
return this._supportPointerEvents && (event.pointerType === POINTER_TYPE_PEN || event.pointerType === POINTER_TYPE_TOUCH);
}
// Static
static isSupported() {
return 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0;
}
}
return Swipe;
}));
//# sourceMappingURL=swipe.js.map

View file

@ -0,0 +1,151 @@
/*!
* Bootstrap template-factory.js v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('../dom/selector-engine.js'), require('./config.js'), require('./sanitizer.js'), require('./index.js')) :
typeof define === 'function' && define.amd ? define(['../dom/selector-engine', './config', './sanitizer', './index'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.TemplateFactory = factory(global.SelectorEngine, global.Config, global.Sanitizer, global.Index));
})(this, (function (SelectorEngine, Config, sanitizer_js, index_js) { 'use strict';
/**
* --------------------------------------------------------------------------
* Bootstrap util/template-factory.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
/**
* Constants
*/
const NAME = 'TemplateFactory';
const Default = {
allowList: sanitizer_js.DefaultAllowlist,
content: {},
// { selector : text , selector2 : text2 , }
extraClass: '',
html: false,
sanitize: true,
sanitizeFn: null,
template: '<div></div>'
};
const DefaultType = {
allowList: 'object',
content: 'object',
extraClass: '(string|function)',
html: 'boolean',
sanitize: 'boolean',
sanitizeFn: '(null|function)',
template: 'string'
};
const DefaultContentType = {
entry: '(string|element|function|null)',
selector: '(string|element)'
};
/**
* Class definition
*/
class TemplateFactory extends Config {
constructor(config) {
super();
this._config = this._getConfig(config);
}
// Getters
static get Default() {
return Default;
}
static get DefaultType() {
return DefaultType;
}
static get NAME() {
return NAME;
}
// Public
getContent() {
return Object.values(this._config.content).map(config => this._resolvePossibleFunction(config)).filter(Boolean);
}
hasContent() {
return this.getContent().length > 0;
}
changeContent(content) {
this._checkContent(content);
this._config.content = {
...this._config.content,
...content
};
return this;
}
toHtml() {
const templateWrapper = document.createElement('div');
templateWrapper.innerHTML = this._maybeSanitize(this._config.template);
for (const [selector, text] of Object.entries(this._config.content)) {
this._setContent(templateWrapper, text, selector);
}
const template = templateWrapper.children[0];
const extraClass = this._resolvePossibleFunction(this._config.extraClass);
if (extraClass) {
template.classList.add(...extraClass.split(' '));
}
return template;
}
// Private
_typeCheckConfig(config) {
super._typeCheckConfig(config);
this._checkContent(config.content);
}
_checkContent(arg) {
for (const [selector, content] of Object.entries(arg)) {
super._typeCheckConfig({
selector,
entry: content
}, DefaultContentType);
}
}
_setContent(template, content, selector) {
const templateElement = SelectorEngine.findOne(selector, template);
if (!templateElement) {
return;
}
content = this._resolvePossibleFunction(content);
if (!content) {
templateElement.remove();
return;
}
if (index_js.isElement(content)) {
this._putElementInTemplate(index_js.getElement(content), templateElement);
return;
}
if (this._config.html) {
templateElement.innerHTML = this._maybeSanitize(content);
return;
}
templateElement.textContent = content;
}
_maybeSanitize(arg) {
return this._config.sanitize ? sanitizer_js.sanitizeHtml(arg, this._config.allowList, this._config.sanitizeFn) : arg;
}
_resolvePossibleFunction(arg) {
return index_js.execute(arg, [this]);
}
_putElementInTemplate(element, templateElement) {
if (this._config.html) {
templateElement.innerHTML = '';
templateElement.append(element);
return;
}
templateElement.textContent = element.textContent;
}
}
return TemplateFactory;
}));
//# sourceMappingURL=template-factory.js.map

View file

@ -0,0 +1,174 @@
// Re-assigned maps
//
// Placed here so that others can override the default Sass maps and see automatic updates to utilities and more.
// scss-docs-start theme-colors-rgb
$theme-colors-rgb: map-loop($theme-colors, to-rgb, "$value") !default;
// scss-docs-end theme-colors-rgb
// scss-docs-start theme-text-map
$theme-colors-text: (
"primary": $primary-text-emphasis,
"secondary": $secondary-text-emphasis,
"success": $success-text-emphasis,
"info": $info-text-emphasis,
"warning": $warning-text-emphasis,
"danger": $danger-text-emphasis,
"light": $light-text-emphasis,
"dark": $dark-text-emphasis,
) !default;
// scss-docs-end theme-text-map
// scss-docs-start theme-bg-subtle-map
$theme-colors-bg-subtle: (
"primary": $primary-bg-subtle,
"secondary": $secondary-bg-subtle,
"success": $success-bg-subtle,
"info": $info-bg-subtle,
"warning": $warning-bg-subtle,
"danger": $danger-bg-subtle,
"light": $light-bg-subtle,
"dark": $dark-bg-subtle,
) !default;
// scss-docs-end theme-bg-subtle-map
// scss-docs-start theme-border-subtle-map
$theme-colors-border-subtle: (
"primary": $primary-border-subtle,
"secondary": $secondary-border-subtle,
"success": $success-border-subtle,
"info": $info-border-subtle,
"warning": $warning-border-subtle,
"danger": $danger-border-subtle,
"light": $light-border-subtle,
"dark": $dark-border-subtle,
) !default;
// scss-docs-end theme-border-subtle-map
$theme-colors-text-dark: null !default;
$theme-colors-bg-subtle-dark: null !default;
$theme-colors-border-subtle-dark: null !default;
@if $enable-dark-mode {
// scss-docs-start theme-text-dark-map
$theme-colors-text-dark: (
"primary": $primary-text-emphasis-dark,
"secondary": $secondary-text-emphasis-dark,
"success": $success-text-emphasis-dark,
"info": $info-text-emphasis-dark,
"warning": $warning-text-emphasis-dark,
"danger": $danger-text-emphasis-dark,
"light": $light-text-emphasis-dark,
"dark": $dark-text-emphasis-dark,
) !default;
// scss-docs-end theme-text-dark-map
// scss-docs-start theme-bg-subtle-dark-map
$theme-colors-bg-subtle-dark: (
"primary": $primary-bg-subtle-dark,
"secondary": $secondary-bg-subtle-dark,
"success": $success-bg-subtle-dark,
"info": $info-bg-subtle-dark,
"warning": $warning-bg-subtle-dark,
"danger": $danger-bg-subtle-dark,
"light": $light-bg-subtle-dark,
"dark": $dark-bg-subtle-dark,
) !default;
// scss-docs-end theme-bg-subtle-dark-map
// scss-docs-start theme-border-subtle-dark-map
$theme-colors-border-subtle-dark: (
"primary": $primary-border-subtle-dark,
"secondary": $secondary-border-subtle-dark,
"success": $success-border-subtle-dark,
"info": $info-border-subtle-dark,
"warning": $warning-border-subtle-dark,
"danger": $danger-border-subtle-dark,
"light": $light-border-subtle-dark,
"dark": $dark-border-subtle-dark,
) !default;
// scss-docs-end theme-border-subtle-dark-map
}
// Utilities maps
//
// Extends the default `$theme-colors` maps to help create our utilities.
// Come v6, we'll de-dupe these variables. Until then, for backward compatibility, we keep them to reassign.
// scss-docs-start utilities-colors
$utilities-colors: $theme-colors-rgb !default;
// scss-docs-end utilities-colors
// scss-docs-start utilities-text-colors
$utilities-text: map-merge(
$utilities-colors,
(
"black": to-rgb($black),
"white": to-rgb($white),
"body": to-rgb($body-color)
)
) !default;
$utilities-text-colors: map-loop($utilities-text, rgba-css-var, "$key", "text") !default;
$utilities-text-emphasis-colors: (
"primary-emphasis": var(--#{$prefix}primary-text-emphasis),
"secondary-emphasis": var(--#{$prefix}secondary-text-emphasis),
"success-emphasis": var(--#{$prefix}success-text-emphasis),
"info-emphasis": var(--#{$prefix}info-text-emphasis),
"warning-emphasis": var(--#{$prefix}warning-text-emphasis),
"danger-emphasis": var(--#{$prefix}danger-text-emphasis),
"light-emphasis": var(--#{$prefix}light-text-emphasis),
"dark-emphasis": var(--#{$prefix}dark-text-emphasis)
) !default;
// scss-docs-end utilities-text-colors
// scss-docs-start utilities-bg-colors
$utilities-bg: map-merge(
$utilities-colors,
(
"black": to-rgb($black),
"white": to-rgb($white),
"body": to-rgb($body-bg)
)
) !default;
$utilities-bg-colors: map-loop($utilities-bg, rgba-css-var, "$key", "bg") !default;
$utilities-bg-subtle: (
"primary-subtle": var(--#{$prefix}primary-bg-subtle),
"secondary-subtle": var(--#{$prefix}secondary-bg-subtle),
"success-subtle": var(--#{$prefix}success-bg-subtle),
"info-subtle": var(--#{$prefix}info-bg-subtle),
"warning-subtle": var(--#{$prefix}warning-bg-subtle),
"danger-subtle": var(--#{$prefix}danger-bg-subtle),
"light-subtle": var(--#{$prefix}light-bg-subtle),
"dark-subtle": var(--#{$prefix}dark-bg-subtle)
) !default;
// scss-docs-end utilities-bg-colors
// scss-docs-start utilities-border-colors
$utilities-border: map-merge(
$utilities-colors,
(
"black": to-rgb($black),
"white": to-rgb($white)
)
) !default;
$utilities-border-colors: map-loop($utilities-border, rgba-css-var, "$key", "border") !default;
$utilities-border-subtle: (
"primary-subtle": var(--#{$prefix}primary-border-subtle),
"secondary-subtle": var(--#{$prefix}secondary-border-subtle),
"success-subtle": var(--#{$prefix}success-border-subtle),
"info-subtle": var(--#{$prefix}info-border-subtle),
"warning-subtle": var(--#{$prefix}warning-border-subtle),
"danger-subtle": var(--#{$prefix}danger-border-subtle),
"light-subtle": var(--#{$prefix}light-border-subtle),
"dark-subtle": var(--#{$prefix}dark-border-subtle)
) !default;
// scss-docs-end utilities-border-colors
$utilities-links-underline: map-loop($utilities-colors, rgba-css-var, "$key", "link-underline") !default;
$negative-spacers: if($enable-negative-margins, negativify-map($spacers), null) !default;
$gutters: $spacers !default;

View file

@ -0,0 +1,87 @@
// Dark color mode variables
//
// Custom variables for the `[data-bs-theme="dark"]` theme. Use this as a starting point for your own custom color modes by creating a new theme-specific file like `_variables-dark.scss` and adding the variables you need.
//
// Global colors
//
// scss-docs-start sass-dark-mode-vars
// scss-docs-start theme-text-dark-variables
$primary-text-emphasis-dark: tint-color($primary, 40%) !default;
$secondary-text-emphasis-dark: tint-color($secondary, 40%) !default;
$success-text-emphasis-dark: tint-color($success, 40%) !default;
$info-text-emphasis-dark: tint-color($info, 40%) !default;
$warning-text-emphasis-dark: tint-color($warning, 40%) !default;
$danger-text-emphasis-dark: tint-color($danger, 40%) !default;
$light-text-emphasis-dark: $gray-100 !default;
$dark-text-emphasis-dark: $gray-300 !default;
// scss-docs-end theme-text-dark-variables
// scss-docs-start theme-bg-subtle-dark-variables
$primary-bg-subtle-dark: shade-color($primary, 80%) !default;
$secondary-bg-subtle-dark: shade-color($secondary, 80%) !default;
$success-bg-subtle-dark: shade-color($success, 80%) !default;
$info-bg-subtle-dark: shade-color($info, 80%) !default;
$warning-bg-subtle-dark: shade-color($warning, 80%) !default;
$danger-bg-subtle-dark: shade-color($danger, 80%) !default;
$light-bg-subtle-dark: $gray-800 !default;
$dark-bg-subtle-dark: mix($gray-800, $black) !default;
// scss-docs-end theme-bg-subtle-dark-variables
// scss-docs-start theme-border-subtle-dark-variables
$primary-border-subtle-dark: shade-color($primary, 40%) !default;
$secondary-border-subtle-dark: shade-color($secondary, 40%) !default;
$success-border-subtle-dark: shade-color($success, 40%) !default;
$info-border-subtle-dark: shade-color($info, 40%) !default;
$warning-border-subtle-dark: shade-color($warning, 40%) !default;
$danger-border-subtle-dark: shade-color($danger, 40%) !default;
$light-border-subtle-dark: $gray-700 !default;
$dark-border-subtle-dark: $gray-800 !default;
// scss-docs-end theme-border-subtle-dark-variables
$body-color-dark: $gray-300 !default;
$body-bg-dark: $gray-900 !default;
$body-secondary-color-dark: rgba($body-color-dark, .75) !default;
$body-secondary-bg-dark: $gray-800 !default;
$body-tertiary-color-dark: rgba($body-color-dark, .5) !default;
$body-tertiary-bg-dark: mix($gray-800, $gray-900, 50%) !default;
$body-emphasis-color-dark: $white !default;
$border-color-dark: $gray-700 !default;
$border-color-translucent-dark: rgba($white, .15) !default;
$headings-color-dark: inherit !default;
$link-color-dark: tint-color($primary, 40%) !default;
$link-hover-color-dark: shift-color($link-color-dark, -$link-shade-percentage) !default;
$code-color-dark: tint-color($code-color, 40%) !default;
$mark-color-dark: $body-color-dark !default;
$mark-bg-dark: $yellow-800 !default;
//
// Forms
//
$form-select-indicator-color-dark: $body-color-dark !default;
$form-select-indicator-dark: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path fill='none' stroke='#{$form-select-indicator-color-dark}' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/></svg>") !default;
$form-switch-color-dark: rgba($white, .25) !default;
$form-switch-bg-image-dark: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='3' fill='#{$form-switch-color-dark}'/></svg>") !default;
// scss-docs-start form-validation-colors-dark
$form-valid-color-dark: $green-300 !default;
$form-valid-border-color-dark: $green-300 !default;
$form-invalid-color-dark: $red-300 !default;
$form-invalid-border-color-dark: $red-300 !default;
// scss-docs-end form-validation-colors-dark
//
// Accordion
//
$accordion-icon-color-dark: $primary-text-emphasis-dark !default;
$accordion-icon-active-color-dark: $primary-text-emphasis-dark !default;
$accordion-button-icon-dark: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$accordion-icon-color-dark}'><path fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/></svg>") !default;
$accordion-button-active-icon-dark: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$accordion-icon-active-color-dark}'><path fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/></svg>") !default;
// scss-docs-end sass-dark-mode-vars

View file

@ -0,0 +1,7 @@
// All-caps `RGBA()` function used because of this Sass bug: https://github.com/sass/node-sass/issues/2251
@each $color, $value in $theme-colors {
.text-bg-#{$color} {
color: color-contrast($value) if($enable-important-utilities, !important, null);
background-color: RGBA(var(--#{$prefix}#{$color}-rgb), var(--#{$prefix}bg-opacity, 1)) if($enable-important-utilities, !important, null);
}
}

View file

@ -0,0 +1,5 @@
.focus-ring:focus {
outline: 0;
// By default, there is no `--bs-focus-ring-x`, `--bs-focus-ring-y`, or `--bs-focus-ring-blur`, but we provide CSS variables with fallbacks to initial `0` values
box-shadow: var(--#{$prefix}focus-ring-x, 0) var(--#{$prefix}focus-ring-y, 0) var(--#{$prefix}focus-ring-blur, 0) var(--#{$prefix}focus-ring-width) var(--#{$prefix}focus-ring-color);
}

View file

@ -0,0 +1,25 @@
.icon-link {
display: inline-flex;
gap: $icon-link-gap;
align-items: center;
text-decoration-color: rgba(var(--#{$prefix}link-color-rgb), var(--#{$prefix}link-opacity, .5));
text-underline-offset: $icon-link-underline-offset;
backface-visibility: hidden;
> .bi {
flex-shrink: 0;
width: $icon-link-icon-size;
height: $icon-link-icon-size;
fill: currentcolor;
@include transition($icon-link-icon-transition);
}
}
.icon-link-hover {
&:hover,
&:focus-visible {
> .bi {
transform: var(--#{$prefix}icon-link-transform, $icon-link-icon-transform);
}
}
}

View file

@ -0,0 +1,7 @@
@mixin bsBanner($file) {
/*!
* Bootstrap #{$file} v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
}

View file

@ -0,0 +1,21 @@
// scss-docs-start color-mode-mixin
@mixin color-mode($mode: light, $root: false) {
@if $color-mode-type == "media-query" {
@if $root == true {
@media (prefers-color-scheme: $mode) {
:root {
@content;
}
}
} @else {
@media (prefers-color-scheme: $mode) {
@content;
}
}
} @else {
[data-bs-theme="#{$mode}"] {
@content;
}
}
}
// scss-docs-end color-mode-mixin

View file

@ -0,0 +1,109 @@
/*!
* chartjs-adapter-luxon v1.3.1
* https://www.chartjs.org
* (c) 2023 chartjs-adapter-luxon Contributors
* Released under the MIT license
*/
(function (global, factory) {
typeof exports === "object" && typeof module !== "undefined"
? factory(require("chart.js"), require("luxon"))
: typeof define === "function" && define.amd
? define(["chart.js", "luxon"], factory)
: ((global =
typeof globalThis !== "undefined" ? globalThis : global || self),
factory(global.Chart, global.luxon));
})(this, function (chart_js, luxon) {
"use strict";
const FORMATS = {
datetime: luxon.DateTime.DATETIME_MED_WITH_SECONDS,
millisecond: "h:mm:ss.SSS a",
second: luxon.DateTime.TIME_WITH_SECONDS,
minute: luxon.DateTime.TIME_SIMPLE,
hour: { hour: "numeric" },
day: { day: "numeric", month: "short" },
week: "DD",
month: { month: "short", year: "numeric" },
quarter: "'Q'q - yyyy",
year: { year: "numeric" },
};
chart_js._adapters._date.override({
_id: "luxon", // DEBUG
/**
* @private
*/
_create: function (time) {
return luxon.DateTime.fromMillis(time, this.options);
},
init(chartOptions) {
if (!this.options.locale) {
this.options.locale = chartOptions.locale;
}
},
formats: function () {
return FORMATS;
},
parse: function (value, format) {
const options = this.options;
const type = typeof value;
if (value === null || type === "undefined") {
return null;
}
if (type === "number") {
value = this._create(value);
} else if (type === "string") {
if (typeof format === "string") {
value = luxon.DateTime.fromFormat(value, format, options);
} else {
value = luxon.DateTime.fromISO(value, options);
}
} else if (value instanceof Date) {
value = luxon.DateTime.fromJSDate(value, options);
} else if (type === "object" && !(value instanceof luxon.DateTime)) {
value = luxon.DateTime.fromObject(value, options);
}
return value.isValid ? value.valueOf() : null;
},
format: function (time, format) {
const datetime = this._create(time);
return typeof format === "string"
? datetime.toFormat(format)
: datetime.toLocaleString(format);
},
add: function (time, amount, unit) {
const args = {};
args[unit] = amount;
return this._create(time).plus(args).valueOf();
},
diff: function (max, min, unit) {
return this._create(max).diff(this._create(min)).as(unit).valueOf();
},
startOf: function (time, unit, weekday) {
if (unit === "isoWeek") {
weekday = Math.trunc(Math.min(Math.max(0, weekday), 6));
const dateTime = this._create(time);
return dateTime
.minus({ days: (dateTime.weekday - weekday + 7) % 7 })
.startOf("day")
.valueOf();
}
return unit ? this._create(time).startOf(unit).valueOf() : time;
},
endOf: function (time, unit) {
return this._create(time).endOf(unit).valueOf();
},
});
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
MIT License
Copyright (c) 2021 Adam Shaw
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,332 @@
/*!
FullCalendar List View Plugin v6.1.11
Docs & License: https://fullcalendar.io/docs/list-view
(c) 2023 Adam Shaw
*/
FullCalendar.List = (function (exports, core, internal$1, preact) {
'use strict';
class ListViewHeaderRow extends internal$1.BaseComponent {
constructor() {
super(...arguments);
this.state = {
textId: internal$1.getUniqueDomId(),
};
}
render() {
let { theme, dateEnv, options, viewApi } = this.context;
let { cellId, dayDate, todayRange } = this.props;
let { textId } = this.state;
let dayMeta = internal$1.getDateMeta(dayDate, todayRange);
// will ever be falsy?
let text = options.listDayFormat ? dateEnv.format(dayDate, options.listDayFormat) : '';
// will ever be falsy? also, BAD NAME "alt"
let sideText = options.listDaySideFormat ? dateEnv.format(dayDate, options.listDaySideFormat) : '';
let renderProps = Object.assign({ date: dateEnv.toDate(dayDate), view: viewApi, textId,
text,
sideText, navLinkAttrs: internal$1.buildNavLinkAttrs(this.context, dayDate), sideNavLinkAttrs: internal$1.buildNavLinkAttrs(this.context, dayDate, 'day', false) }, dayMeta);
// TODO: make a reusable HOC for dayHeader (used in daygrid/timegrid too)
return (preact.createElement(internal$1.ContentContainer, { elTag: "tr", elClasses: [
'fc-list-day',
...internal$1.getDayClassNames(dayMeta, theme),
], elAttrs: {
'data-date': internal$1.formatDayString(dayDate),
}, renderProps: renderProps, generatorName: "dayHeaderContent", customGenerator: options.dayHeaderContent, defaultGenerator: renderInnerContent, classNameGenerator: options.dayHeaderClassNames, didMount: options.dayHeaderDidMount, willUnmount: options.dayHeaderWillUnmount }, (InnerContent) => ( // TODO: force-hide top border based on :first-child
preact.createElement("th", { scope: "colgroup", colSpan: 3, id: cellId, "aria-labelledby": textId },
preact.createElement(InnerContent, { elTag: "div", elClasses: [
'fc-list-day-cushion',
theme.getClass('tableCellShaded'),
] })))));
}
}
function renderInnerContent(props) {
return (preact.createElement(preact.Fragment, null,
props.text && (preact.createElement("a", Object.assign({ id: props.textId, className: "fc-list-day-text" }, props.navLinkAttrs), props.text)),
props.sideText && ( /* not keyboard tabbable */preact.createElement("a", Object.assign({ "aria-hidden": true, className: "fc-list-day-side-text" }, props.sideNavLinkAttrs), props.sideText))));
}
const DEFAULT_TIME_FORMAT = internal$1.createFormatter({
hour: 'numeric',
minute: '2-digit',
meridiem: 'short',
});
class ListViewEventRow extends internal$1.BaseComponent {
render() {
let { props, context } = this;
let { options } = context;
let { seg, timeHeaderId, eventHeaderId, dateHeaderId } = props;
let timeFormat = options.eventTimeFormat || DEFAULT_TIME_FORMAT;
return (preact.createElement(internal$1.EventContainer, Object.assign({}, props, { elTag: "tr", elClasses: [
'fc-list-event',
seg.eventRange.def.url && 'fc-event-forced-url',
], defaultGenerator: () => renderEventInnerContent(seg, context) /* weird */, seg: seg, timeText: "", disableDragging: true, disableResizing: true }), (InnerContent, eventContentArg) => (preact.createElement(preact.Fragment, null,
buildTimeContent(seg, timeFormat, context, timeHeaderId, dateHeaderId),
preact.createElement("td", { "aria-hidden": true, className: "fc-list-event-graphic" },
preact.createElement("span", { className: "fc-list-event-dot", style: {
borderColor: eventContentArg.borderColor || eventContentArg.backgroundColor,
} })),
preact.createElement(InnerContent, { elTag: "td", elClasses: ['fc-list-event-title'], elAttrs: { headers: `${eventHeaderId} ${dateHeaderId}` } })))));
}
}
function renderEventInnerContent(seg, context) {
let interactiveAttrs = internal$1.getSegAnchorAttrs(seg, context);
return (preact.createElement("a", Object.assign({}, interactiveAttrs), seg.eventRange.def.title));
}
function buildTimeContent(seg, timeFormat, context, timeHeaderId, dateHeaderId) {
let { options } = context;
if (options.displayEventTime !== false) {
let eventDef = seg.eventRange.def;
let eventInstance = seg.eventRange.instance;
let doAllDay = false;
let timeText;
if (eventDef.allDay) {
doAllDay = true;
}
else if (internal$1.isMultiDayRange(seg.eventRange.range)) { // TODO: use (!isStart || !isEnd) instead?
if (seg.isStart) {
timeText = internal$1.buildSegTimeText(seg, timeFormat, context, null, null, eventInstance.range.start, seg.end);
}
else if (seg.isEnd) {
timeText = internal$1.buildSegTimeText(seg, timeFormat, context, null, null, seg.start, eventInstance.range.end);
}
else {
doAllDay = true;
}
}
else {
timeText = internal$1.buildSegTimeText(seg, timeFormat, context);
}
if (doAllDay) {
let renderProps = {
text: context.options.allDayText,
view: context.viewApi,
};
return (preact.createElement(internal$1.ContentContainer, { elTag: "td", elClasses: ['fc-list-event-time'], elAttrs: {
headers: `${timeHeaderId} ${dateHeaderId}`,
}, renderProps: renderProps, generatorName: "allDayContent", customGenerator: options.allDayContent, defaultGenerator: renderAllDayInner, classNameGenerator: options.allDayClassNames, didMount: options.allDayDidMount, willUnmount: options.allDayWillUnmount }));
}
return (preact.createElement("td", { className: "fc-list-event-time" }, timeText));
}
return null;
}
function renderAllDayInner(renderProps) {
return renderProps.text;
}
/*
Responsible for the scroller, and forwarding event-related actions into the "grid".
*/
class ListView extends internal$1.DateComponent {
constructor() {
super(...arguments);
this.computeDateVars = internal$1.memoize(computeDateVars);
this.eventStoreToSegs = internal$1.memoize(this._eventStoreToSegs);
this.state = {
timeHeaderId: internal$1.getUniqueDomId(),
eventHeaderId: internal$1.getUniqueDomId(),
dateHeaderIdRoot: internal$1.getUniqueDomId(),
};
this.setRootEl = (rootEl) => {
if (rootEl) {
this.context.registerInteractiveComponent(this, {
el: rootEl,
});
}
else {
this.context.unregisterInteractiveComponent(this);
}
};
}
render() {
let { props, context } = this;
let { dayDates, dayRanges } = this.computeDateVars(props.dateProfile);
let eventSegs = this.eventStoreToSegs(props.eventStore, props.eventUiBases, dayRanges);
return (preact.createElement(internal$1.ViewContainer, { elRef: this.setRootEl, elClasses: [
'fc-list',
context.theme.getClass('table'),
context.options.stickyHeaderDates !== false ?
'fc-list-sticky' :
'',
], viewSpec: context.viewSpec },
preact.createElement(internal$1.Scroller, { liquid: !props.isHeightAuto, overflowX: props.isHeightAuto ? 'visible' : 'hidden', overflowY: props.isHeightAuto ? 'visible' : 'auto' }, eventSegs.length > 0 ?
this.renderSegList(eventSegs, dayDates) :
this.renderEmptyMessage())));
}
renderEmptyMessage() {
let { options, viewApi } = this.context;
let renderProps = {
text: options.noEventsText,
view: viewApi,
};
return (preact.createElement(internal$1.ContentContainer, { elTag: "div", elClasses: ['fc-list-empty'], renderProps: renderProps, generatorName: "noEventsContent", customGenerator: options.noEventsContent, defaultGenerator: renderNoEventsInner, classNameGenerator: options.noEventsClassNames, didMount: options.noEventsDidMount, willUnmount: options.noEventsWillUnmount }, (InnerContent) => (preact.createElement(InnerContent, { elTag: "div", elClasses: ['fc-list-empty-cushion'] }))));
}
renderSegList(allSegs, dayDates) {
let { theme, options } = this.context;
let { timeHeaderId, eventHeaderId, dateHeaderIdRoot } = this.state;
let segsByDay = groupSegsByDay(allSegs); // sparse array
return (preact.createElement(internal$1.NowTimer, { unit: "day" }, (nowDate, todayRange) => {
let innerNodes = [];
for (let dayIndex = 0; dayIndex < segsByDay.length; dayIndex += 1) {
let daySegs = segsByDay[dayIndex];
if (daySegs) { // sparse array, so might be undefined
let dayStr = internal$1.formatDayString(dayDates[dayIndex]);
let dateHeaderId = dateHeaderIdRoot + '-' + dayStr;
// append a day header
innerNodes.push(preact.createElement(ListViewHeaderRow, { key: dayStr, cellId: dateHeaderId, dayDate: dayDates[dayIndex], todayRange: todayRange }));
daySegs = internal$1.sortEventSegs(daySegs, options.eventOrder);
for (let seg of daySegs) {
innerNodes.push(preact.createElement(ListViewEventRow, Object.assign({ key: dayStr + ':' + seg.eventRange.instance.instanceId /* are multiple segs for an instanceId */, seg: seg, isDragging: false, isResizing: false, isDateSelecting: false, isSelected: false, timeHeaderId: timeHeaderId, eventHeaderId: eventHeaderId, dateHeaderId: dateHeaderId }, internal$1.getSegMeta(seg, todayRange, nowDate))));
}
}
}
return (preact.createElement("table", { className: 'fc-list-table ' + theme.getClass('table') },
preact.createElement("thead", null,
preact.createElement("tr", null,
preact.createElement("th", { scope: "col", id: timeHeaderId }, options.timeHint),
preact.createElement("th", { scope: "col", "aria-hidden": true }),
preact.createElement("th", { scope: "col", id: eventHeaderId }, options.eventHint))),
preact.createElement("tbody", null, innerNodes)));
}));
}
_eventStoreToSegs(eventStore, eventUiBases, dayRanges) {
return this.eventRangesToSegs(internal$1.sliceEventStore(eventStore, eventUiBases, this.props.dateProfile.activeRange, this.context.options.nextDayThreshold).fg, dayRanges);
}
eventRangesToSegs(eventRanges, dayRanges) {
let segs = [];
for (let eventRange of eventRanges) {
segs.push(...this.eventRangeToSegs(eventRange, dayRanges));
}
return segs;
}
eventRangeToSegs(eventRange, dayRanges) {
let { dateEnv } = this.context;
let { nextDayThreshold } = this.context.options;
let range = eventRange.range;
let allDay = eventRange.def.allDay;
let dayIndex;
let segRange;
let seg;
let segs = [];
for (dayIndex = 0; dayIndex < dayRanges.length; dayIndex += 1) {
segRange = internal$1.intersectRanges(range, dayRanges[dayIndex]);
if (segRange) {
seg = {
component: this,
eventRange,
start: segRange.start,
end: segRange.end,
isStart: eventRange.isStart && segRange.start.valueOf() === range.start.valueOf(),
isEnd: eventRange.isEnd && segRange.end.valueOf() === range.end.valueOf(),
dayIndex,
};
segs.push(seg);
// detect when range won't go fully into the next day,
// and mutate the latest seg to the be the end.
if (!seg.isEnd && !allDay &&
dayIndex + 1 < dayRanges.length &&
range.end <
dateEnv.add(dayRanges[dayIndex + 1].start, nextDayThreshold)) {
seg.end = range.end;
seg.isEnd = true;
break;
}
}
}
return segs;
}
}
function renderNoEventsInner(renderProps) {
return renderProps.text;
}
function computeDateVars(dateProfile) {
let dayStart = internal$1.startOfDay(dateProfile.renderRange.start);
let viewEnd = dateProfile.renderRange.end;
let dayDates = [];
let dayRanges = [];
while (dayStart < viewEnd) {
dayDates.push(dayStart);
dayRanges.push({
start: dayStart,
end: internal$1.addDays(dayStart, 1),
});
dayStart = internal$1.addDays(dayStart, 1);
}
return { dayDates, dayRanges };
}
// Returns a sparse array of arrays, segs grouped by their dayIndex
function groupSegsByDay(segs) {
let segsByDay = []; // sparse array
let i;
let seg;
for (i = 0; i < segs.length; i += 1) {
seg = segs[i];
(segsByDay[seg.dayIndex] || (segsByDay[seg.dayIndex] = []))
.push(seg);
}
return segsByDay;
}
const OPTION_REFINERS = {
listDayFormat: createFalsableFormatter,
listDaySideFormat: createFalsableFormatter,
noEventsClassNames: internal$1.identity,
noEventsContent: internal$1.identity,
noEventsDidMount: internal$1.identity,
noEventsWillUnmount: internal$1.identity,
// noEventsText is defined in base options
};
function createFalsableFormatter(input) {
return input === false ? null : internal$1.createFormatter(input);
}
var css_248z = ":root{--fc-list-event-dot-width:10px;--fc-list-event-hover-bg-color:#f5f5f5}.fc-theme-standard .fc-list{border:1px solid var(--fc-border-color)}.fc .fc-list-empty{align-items:center;background-color:var(--fc-neutral-bg-color);display:flex;height:100%;justify-content:center}.fc .fc-list-empty-cushion{margin:5em 0}.fc .fc-list-table{border-style:hidden;width:100%}.fc .fc-list-table tr>*{border-left:0;border-right:0}.fc .fc-list-sticky .fc-list-day>*{background:var(--fc-page-bg-color);position:sticky;top:0}.fc .fc-list-table thead{left:-10000px;position:absolute}.fc .fc-list-table tbody>tr:first-child th{border-top:0}.fc .fc-list-table th{padding:0}.fc .fc-list-day-cushion,.fc .fc-list-table td{padding:8px 14px}.fc .fc-list-day-cushion:after{clear:both;content:\"\";display:table}.fc-theme-standard .fc-list-day-cushion{background-color:var(--fc-neutral-bg-color)}.fc-direction-ltr .fc-list-day-text,.fc-direction-rtl .fc-list-day-side-text{float:left}.fc-direction-ltr .fc-list-day-side-text,.fc-direction-rtl .fc-list-day-text{float:right}.fc-direction-ltr .fc-list-table .fc-list-event-graphic{padding-right:0}.fc-direction-rtl .fc-list-table .fc-list-event-graphic{padding-left:0}.fc .fc-list-event.fc-event-forced-url{cursor:pointer}.fc .fc-list-event:hover td{background-color:var(--fc-list-event-hover-bg-color)}.fc .fc-list-event-graphic,.fc .fc-list-event-time{white-space:nowrap;width:1px}.fc .fc-list-event-dot{border:calc(var(--fc-list-event-dot-width)/2) solid var(--fc-event-border-color);border-radius:calc(var(--fc-list-event-dot-width)/2);box-sizing:content-box;display:inline-block;height:0;width:0}.fc .fc-list-event-title a{color:inherit;text-decoration:none}.fc .fc-list-event.fc-event-forced-url:hover a{text-decoration:underline}";
internal$1.injectStyles(css_248z);
var plugin = core.createPlugin({
name: '@fullcalendar/list',
optionRefiners: OPTION_REFINERS,
views: {
list: {
component: ListView,
buttonTextKey: 'list',
listDayFormat: { month: 'long', day: 'numeric', year: 'numeric' }, // like "January 1, 2016"
},
listDay: {
type: 'list',
duration: { days: 1 },
listDayFormat: { weekday: 'long' }, // day-of-week is all we need. full date is probably in headerToolbar
},
listWeek: {
type: 'list',
duration: { weeks: 1 },
listDayFormat: { weekday: 'long' },
listDaySideFormat: { month: 'long', day: 'numeric', year: 'numeric' },
},
listMonth: {
type: 'list',
duration: { month: 1 },
listDaySideFormat: { weekday: 'long' }, // day-of-week is nice-to-have
},
listYear: {
type: 'list',
duration: { year: 1 },
listDaySideFormat: { weekday: 'long' }, // day-of-week is nice-to-have
},
},
});
var internal = {
__proto__: null,
ListView: ListView
};
core.globalPlugins.push(plugin);
exports.Internal = internal;
exports["default"] = plugin;
Object.defineProperty(exports, '__esModule', { value: true });
return exports;
})({}, FullCalendar, FullCalendar.Internal, FullCalendar.Preact);

View file

@ -0,0 +1,131 @@
/*!
FullCalendar Luxon 3 Plugin v6.1.11
Docs & License: https://fullcalendar.io/docs/luxon
(c) 2023 Adam Shaw
*/
FullCalendar.Luxon3 = (function (exports, core, luxon, internal) {
'use strict';
function toLuxonDateTime(date, calendar) {
if (!(calendar instanceof internal.CalendarImpl)) {
throw new Error('must supply a CalendarApi instance');
}
let { dateEnv } = calendar.getCurrentData();
return luxon.DateTime.fromJSDate(date, {
zone: dateEnv.timeZone,
locale: dateEnv.locale.codes[0],
});
}
function toLuxonDuration(duration, calendar) {
if (!(calendar instanceof internal.CalendarImpl)) {
throw new Error('must supply a CalendarApi instance');
}
let { dateEnv } = calendar.getCurrentData();
return luxon.Duration.fromObject(duration, {
locale: dateEnv.locale.codes[0],
});
}
// Internal Utils
function luxonToArray(datetime) {
return [
datetime.year,
datetime.month - 1,
datetime.day,
datetime.hour,
datetime.minute,
datetime.second,
datetime.millisecond,
];
}
function arrayToLuxon(arr, timeZone, locale) {
return luxon.DateTime.fromObject({
year: arr[0],
month: arr[1] + 1,
day: arr[2],
hour: arr[3],
minute: arr[4],
second: arr[5],
millisecond: arr[6],
}, {
locale,
zone: timeZone,
});
}
class LuxonNamedTimeZone extends internal.NamedTimeZoneImpl {
offsetForArray(a) {
return arrayToLuxon(a, this.timeZoneName).offset;
}
timestampToArray(ms) {
return luxonToArray(luxon.DateTime.fromMillis(ms, {
zone: this.timeZoneName,
}));
}
}
function formatWithCmdStr(cmdStr, arg) {
let cmd = parseCmdStr(cmdStr);
if (arg.end) {
let start = arrayToLuxon(arg.start.array, arg.timeZone, arg.localeCodes[0]);
let end = arrayToLuxon(arg.end.array, arg.timeZone, arg.localeCodes[0]);
return formatRange(cmd, start.toFormat.bind(start), end.toFormat.bind(end), arg.defaultSeparator);
}
return arrayToLuxon(arg.date.array, arg.timeZone, arg.localeCodes[0]).toFormat(cmd.whole);
}
function parseCmdStr(cmdStr) {
let parts = cmdStr.match(/^(.*?)\{(.*)\}(.*)$/); // TODO: lookbehinds for escape characters
if (parts) {
let middle = parseCmdStr(parts[2]);
return {
head: parts[1],
middle,
tail: parts[3],
whole: parts[1] + middle.whole + parts[3],
};
}
return {
head: null,
middle: null,
tail: null,
whole: cmdStr,
};
}
function formatRange(cmd, formatStart, formatEnd, separator) {
if (cmd.middle) {
let startHead = formatStart(cmd.head);
let startMiddle = formatRange(cmd.middle, formatStart, formatEnd, separator);
let startTail = formatStart(cmd.tail);
let endHead = formatEnd(cmd.head);
let endMiddle = formatRange(cmd.middle, formatStart, formatEnd, separator);
let endTail = formatEnd(cmd.tail);
if (startHead === endHead && startTail === endTail) {
return startHead +
(startMiddle === endMiddle ? startMiddle : startMiddle + separator + endMiddle) +
startTail;
}
}
let startWhole = formatStart(cmd.whole);
let endWhole = formatEnd(cmd.whole);
if (startWhole === endWhole) {
return startWhole;
}
return startWhole + separator + endWhole;
}
var plugin = core.createPlugin({
name: '@fullcalendar/luxon3',
cmdFormatter: formatWithCmdStr,
namedTimeZonedImpl: LuxonNamedTimeZone,
});
core.globalPlugins.push(plugin);
exports["default"] = plugin;
exports.toLuxonDateTime = toLuxonDateTime;
exports.toLuxonDuration = toLuxonDuration;
Object.defineProperty(exports, '__esModule', { value: true });
return exports;
})({}, FullCalendar, luxon, FullCalendar.Internal);

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,495 @@
/** @odoo-module */
import { isInstanceOf } from "../hoot_dom_utils";
/**
* @typedef {{
* animationFrame?: boolean;
* blockTimers?: boolean;
* }} AdvanceTimeOptions
*
* @typedef {{
* message?: string | () => string;
* timeout?: number;
* }} WaitOptions
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
cancelAnimationFrame,
clearInterval,
clearTimeout,
Error,
Math: { ceil: $ceil, floor: $floor, max: $max, min: $min },
Number,
performance,
Promise,
requestAnimationFrame,
setInterval,
setTimeout,
} = globalThis;
/** @type {Performance["now"]} */
const $performanceNow = performance.now.bind(performance);
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {number} id
*/
function animationToId(id) {
return ID_PREFIX.animation + String(id);
}
function getNextTimerValues() {
/** @type {[number, () => any, string] | null} */
let timerValues = null;
for (const [internalId, [callback, init, delay]] of timers.entries()) {
const timeout = init + delay;
if (!timerValues || timeout < timerValues[0]) {
timerValues = [timeout, callback, internalId];
}
}
return timerValues;
}
/**
* @param {string} id
*/
function idToAnimation(id) {
return Number(id.slice(ID_PREFIX.animation.length));
}
/**
* @param {string} id
*/
function idToInterval(id) {
return Number(id.slice(ID_PREFIX.interval.length));
}
/**
* @param {string} id
*/
function idToTimeout(id) {
return Number(id.slice(ID_PREFIX.timeout.length));
}
/**
* @param {number} id
*/
function intervalToId(id) {
return ID_PREFIX.interval + String(id);
}
/**
* Converts a given value to a **natural number** (or 0 if failing to do so).
*
* @param {unknown} value
*/
function parseNat(value) {
return $max($floor(Number(value)), 0) || 0;
}
function now() {
return (frozen ? 0 : $performanceNow()) + timeOffset;
}
/**
* @param {number} id
*/
function timeoutToId(id) {
return ID_PREFIX.timeout + String(id);
}
class HootTimingError extends Error {
name = "HootTimingError";
}
const ID_PREFIX = {
animation: "a_",
interval: "i_",
timeout: "t_",
};
/** @type {Map<string, [() => any, number, number]>} */
const timers = new Map();
let allowTimers = false;
let frozen = false;
let frameDelay = 1000 / 60;
let nextDummyId = 1;
let timeOffset = 0;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {number} [frameCount]
* @param {AdvanceTimeOptions} [options]
*/
export function advanceFrame(frameCount, options) {
return advanceTime(frameDelay * parseNat(frameCount), options);
}
/**
* Advances the current time by the given amount of milliseconds. This will
* affect all timeouts, intervals, animations and date objects.
*
* It returns a promise resolved after all related callbacks have been executed.
*
* @param {number} ms
* @param {AdvanceTimeOptions} [options]
* @returns {Promise<number>} time consumed by timers (in ms).
*/
export async function advanceTime(ms, options) {
ms = parseNat(ms);
if (options?.blockTimers) {
allowTimers = false;
}
const targetTime = now() + ms;
let remaining = ms;
/** @type {ReturnType<typeof getNextTimerValues>} */
let timerValues;
while ((timerValues = getNextTimerValues()) && timerValues[0] <= targetTime) {
const [timeout, handler, id] = timerValues;
const diff = timeout - now();
if (diff > 0) {
timeOffset += $min(remaining, diff);
remaining = $max(remaining - diff, 0);
}
if (timers.has(id)) {
handler(timeout);
}
}
if (remaining > 0) {
timeOffset += remaining;
}
if (options?.animationFrame ?? true) {
await animationFrame();
}
allowTimers = true;
return ms;
}
/**
* Returns a promise resolved after the next animation frame, typically allowing
* Owl components to render.
*
* @returns {Promise<void>}
*/
export function animationFrame() {
return new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve)));
}
/**
* Cancels all current timeouts, intervals and animations.
*/
export function cancelAllTimers() {
for (const id of timers.keys()) {
if (id.startsWith(ID_PREFIX.animation)) {
globalThis.cancelAnimationFrame(idToAnimation(id));
} else if (id.startsWith(ID_PREFIX.interval)) {
globalThis.clearInterval(idToInterval(id));
} else if (id.startsWith(ID_PREFIX.timeout)) {
globalThis.clearTimeout(idToTimeout(id));
}
}
}
export function cleanupTime() {
allowTimers = false;
frozen = false;
cancelAllTimers();
// Wait for remaining async code to run
return delay();
}
/**
* Returns a promise resolved after a given amount of milliseconds (default to 0).
*
* @param {number} [duration]
* @returns {Promise<void>}
* @example
* await delay(1000); // waits for 1 second
*/
export function delay(duration) {
return new Promise((resolve) => setTimeout(resolve, duration));
}
export function freezeTime() {
frozen = true;
}
export function unfreezeTime() {
frozen = false;
}
export function getTimeOffset() {
return timeOffset;
}
export function isTimeFrozen() {
return frozen;
}
/**
* Returns a promise resolved after the next microtask tick.
*
* @returns {Promise<void>}
*/
export function microTick() {
return new Promise(queueMicrotask);
}
/** @type {typeof cancelAnimationFrame} */
export function mockedCancelAnimationFrame(handle) {
if (!frozen) {
cancelAnimationFrame(handle);
}
timers.delete(animationToId(handle));
}
/** @type {typeof clearInterval} */
export function mockedClearInterval(intervalId) {
if (!frozen) {
clearInterval(intervalId);
}
timers.delete(intervalToId(intervalId));
}
/** @type {typeof clearTimeout} */
export function mockedClearTimeout(timeoutId) {
if (!frozen) {
clearTimeout(timeoutId);
}
timers.delete(timeoutToId(timeoutId));
}
/** @type {typeof requestAnimationFrame} */
export function mockedRequestAnimationFrame(callback) {
if (!allowTimers) {
return 0;
}
function handler() {
mockedCancelAnimationFrame(handle);
return callback(now());
}
const animationValues = [handler, now(), frameDelay];
const handle = frozen ? nextDummyId++ : requestAnimationFrame(handler);
const internalId = animationToId(handle);
timers.set(internalId, animationValues);
return handle;
}
/** @type {typeof setInterval} */
export function mockedSetInterval(callback, ms, ...args) {
if (!allowTimers) {
return 0;
}
ms = parseNat(ms);
function handler() {
if (allowTimers) {
intervalValues[1] = $max(now(), intervalValues[1] + ms);
} else {
mockedClearInterval(intervalId);
}
return callback(...args);
}
const intervalValues = [handler, now(), ms];
const intervalId = frozen ? nextDummyId++ : setInterval(handler, ms);
const internalId = intervalToId(intervalId);
timers.set(internalId, intervalValues);
return intervalId;
}
/** @type {typeof setTimeout} */
export function mockedSetTimeout(callback, ms, ...args) {
if (!allowTimers) {
return 0;
}
ms = parseNat(ms);
function handler() {
mockedClearTimeout(timeoutId);
return callback(...args);
}
const timeoutValues = [handler, now(), ms];
const timeoutId = frozen ? nextDummyId++ : setTimeout(handler, ms);
const internalId = timeoutToId(timeoutId);
timers.set(internalId, timeoutValues);
return timeoutId;
}
export function resetTimeOffset() {
timeOffset = 0;
}
/**
* Calculates the amount of time needed to run all current timeouts, intervals and
* animations, and then advances the current time by that amount.
*
* @see {@link advanceTime}
* @param {AdvanceTimeOptions} [options]
* @returns {Promise<number>} time consumed by timers (in ms).
*/
export function runAllTimers(options) {
if (!timers.size) {
return 0;
}
const endts = $max(...[...timers.values()].map(([, init, delay]) => init + delay));
return advanceTime($ceil(endts - now()), options);
}
/**
* Sets the current frame rate (in fps) used by animation frames (default to 60fps).
*
* @param {number} frameRate
*/
export function setFrameRate(frameRate) {
frameRate = parseNat(frameRate);
if (frameRate < 1 || frameRate > 1000) {
throw new HootTimingError("frame rate must be an number between 1 and 1000");
}
frameDelay = 1000 / frameRate;
}
export function setupTime() {
allowTimers = true;
}
/**
* Returns a promise resolved after the next task tick.
*
* @returns {Promise<void>}
*/
export function tick() {
return delay();
}
/**
* Returns a promise fulfilled when the given `predicate` returns a truthy value,
* with the value of the promise being the return value of the `predicate`.
*
* The `predicate` is run once initially, and then on each animation frame until
* it succeeds or fail.
*
* The promise automatically rejects after a given `timeout` (defaults to 5 seconds).
*
* @template T
* @param {(last: boolean) => T} predicate
* @param {WaitOptions} [options]
* @returns {Promise<T>}
* @example
* await waitUntil(() => []); // -> []
* @example
* const button = await waitUntil(() => queryOne("button:visible"));
* button.click();
*/
export async function waitUntil(predicate, options) {
await Promise.resolve();
// Early check before running the loop
const result = predicate(false);
if (result) {
return result;
}
const timeout = $floor(options?.timeout ?? 200);
const maxFrameCount = $ceil(timeout / frameDelay);
let frameCount = 0;
let handle;
return new Promise((resolve, reject) => {
function runCheck() {
const isLast = ++frameCount >= maxFrameCount;
const result = predicate(isLast);
if (result) {
resolve(result);
} else if (!isLast) {
handle = requestAnimationFrame(runCheck);
} else {
let message =
options?.message || `'waitUntil' timed out after %timeout% milliseconds`;
if (typeof message === "function") {
message = message();
}
if (isInstanceOf(message, Error)) {
reject(message);
} else {
reject(new HootTimingError(message.replace("%timeout%", String(timeout))));
}
}
}
handle = requestAnimationFrame(runCheck);
}).finally(() => {
cancelAnimationFrame(handle);
});
}
/**
* Manually resolvable and rejectable promise. It introduces 2 new methods:
* - {@link reject} rejects the deferred with the given reason;
* - {@link resolve} resolves the deferred with the given value.
*
* @template [T=unknown]
*/
export class Deferred extends Promise {
/** @type {typeof Promise.resolve<T>} */
_resolve;
/** @type {typeof Promise.reject<T>} */
_reject;
/**
* @param {(resolve: (value?: T) => any, reject: (reason?: any) => any) => any} [executor]
*/
constructor(executor) {
let _resolve, _reject;
super(function deferredResolver(resolve, reject) {
_resolve = resolve;
_reject = reject;
executor?.(_resolve, _reject);
});
this._resolve = _resolve;
this._reject = _reject;
}
/**
* @param {any} [reason]
*/
async reject(reason) {
return this._reject(reason);
}
/**
* @param {T} [value]
*/
async resolve(value) {
return this._resolve(value);
}
}

View file

@ -0,0 +1,110 @@
/** @odoo-module alias=@odoo/hoot-dom default=false */
import * as dom from "./helpers/dom";
import * as events from "./helpers/events";
import * as time from "./helpers/time";
import { interactor } from "./hoot_dom_utils";
/**
* @typedef {import("./helpers/dom").Dimensions} Dimensions
* @typedef {import("./helpers/dom").FormatXmlOptions} FormatXmlOptions
* @typedef {import("./helpers/dom").Position} Position
* @typedef {import("./helpers/dom").QueryOptions} QueryOptions
* @typedef {import("./helpers/dom").QueryRectOptions} QueryRectOptions
* @typedef {import("./helpers/dom").QueryTextOptions} QueryTextOptions
* @typedef {import("./helpers/dom").Target} Target
*
* @typedef {import("./helpers/events").DragHelpers} DragHelpers
* @typedef {import("./helpers/events").DragOptions} DragOptions
* @typedef {import("./helpers/events").EventType} EventType
* @typedef {import("./helpers/events").FillOptions} FillOptions
* @typedef {import("./helpers/events").InputValue} InputValue
* @typedef {import("./helpers/events").KeyStrokes} KeyStrokes
* @typedef {import("./helpers/events").PointerOptions} PointerOptions
*/
export {
formatXml,
getActiveElement,
getFocusableElements,
getNextFocusableElement,
getParentFrame,
getPreviousFocusableElement,
isDisplayed,
isEditable,
isFocusable,
isInDOM,
isInViewPort,
isScrollable,
isVisible,
matches,
queryAll,
queryAllAttributes,
queryAllProperties,
queryAllRects,
queryAllTexts,
queryAllValues,
queryAny,
queryAttribute,
queryFirst,
queryOne,
queryRect,
queryText,
queryValue,
} from "./helpers/dom";
export { on } from "./helpers/events";
export {
animationFrame,
cancelAllTimers,
Deferred,
delay,
freezeTime,
unfreezeTime,
microTick,
setFrameRate,
tick,
waitUntil,
} from "./helpers/time";
//-----------------------------------------------------------------------------
// Interactors
//-----------------------------------------------------------------------------
// DOM
export const observe = interactor("query", dom.observe);
export const waitFor = interactor("query", dom.waitFor);
export const waitForNone = interactor("query", dom.waitForNone);
// Events
export const check = interactor("interaction", events.check);
export const clear = interactor("interaction", events.clear);
export const click = interactor("interaction", events.click);
export const dblclick = interactor("interaction", events.dblclick);
export const drag = interactor("interaction", events.drag);
export const edit = interactor("interaction", events.edit);
export const fill = interactor("interaction", events.fill);
export const hover = interactor("interaction", events.hover);
export const keyDown = interactor("interaction", events.keyDown);
export const keyUp = interactor("interaction", events.keyUp);
export const leave = interactor("interaction", events.leave);
export const manuallyDispatchProgrammaticEvent = interactor("interaction", events.dispatch);
export const middleClick = interactor("interaction", events.middleClick);
export const pointerDown = interactor("interaction", events.pointerDown);
export const pointerUp = interactor("interaction", events.pointerUp);
export const press = interactor("interaction", events.press);
export const resize = interactor("interaction", events.resize);
export const rightClick = interactor("interaction", events.rightClick);
export const scroll = interactor("interaction", events.scroll);
export const select = interactor("interaction", events.select);
export const setInputFiles = interactor("interaction", events.setInputFiles);
export const setInputRange = interactor("interaction", events.setInputRange);
export const uncheck = interactor("interaction", events.uncheck);
export const unload = interactor("interaction", events.unload);
// Time
export const advanceFrame = interactor("time", time.advanceFrame);
export const advanceTime = interactor("time", time.advanceTime);
export const runAllTimers = interactor("time", time.runAllTimers);
// Debug
export { exposeHelpers } from "./hoot_dom_utils";

View file

@ -0,0 +1,426 @@
/** @odoo-module */
/**
* @typedef {ArgumentPrimitive | `${ArgumentPrimitive}[]` | null} ArgumentType
*
* @typedef {"any"
* | "bigint"
* | "boolean"
* | "error"
* | "function"
* | "integer"
* | "node"
* | "number"
* | "object"
* | "regex"
* | "string"
* | "symbol"
* | "undefined"} ArgumentPrimitive
*
* @typedef {[string, any[], any]} InteractionDetails
*
* @typedef {"interaction" | "query" | "server" | "time"} InteractionType
*/
/**
* @template T
* @typedef {T | Iterable<T>} MaybeIterable
*/
/**
* @template T
* @typedef {T | PromiseLike<T>} MaybePromise
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Array: { isArray: $isArray },
matchMedia,
navigator: { userAgent: $userAgent },
Object: { assign: $assign, getPrototypeOf: $getPrototypeOf },
RegExp,
SyntaxError,
} = globalThis;
const $toString = Object.prototype.toString;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @template {(...args: any[]) => any} T
* @param {InteractionType} type
* @param {T} fn
* @param {string} name
* @returns {T}
*/
function makeInteractorFn(type, fn, name) {
return {
[name](...args) {
const result = fn(...args);
if (isInstanceOf(result, Promise)) {
for (let i = 0; i < args.length; i++) {
if (isInstanceOf(args[i], Promise)) {
// Get promise result for async arguments if possible
args[i].then((result) => (args[i] = result));
}
}
return result.then((promiseResult) =>
dispatchInteraction(type, name, args, promiseResult)
);
} else {
return dispatchInteraction(type, name, args, result);
}
},
}[name];
}
function polyfillIsError(value) {
return $toString.call(value) === "[object Error]";
}
const GRAYS = {
100: "#f1f5f9",
200: "#e2e8f0",
300: "#cbd5e1",
400: "#94a3b8",
500: "#64748b",
600: "#475569",
700: "#334155",
800: "#1e293b",
900: "#0f172a",
};
const COLORS = {
default: {
// Generic colors
black: "#000000",
white: "#ffffff",
// Grays
"gray-100": GRAYS[100],
"gray-200": GRAYS[200],
"gray-300": GRAYS[300],
"gray-400": GRAYS[400],
"gray-500": GRAYS[500],
"gray-600": GRAYS[600],
"gray-700": GRAYS[700],
"gray-800": GRAYS[800],
"gray-900": GRAYS[900],
},
light: {
// Generic colors
primary: "#714b67",
secondary: "#74b4b9",
amber: "#f59e0b",
"amber-900": "#fef3c7",
blue: "#3b82f6",
"blue-900": "#dbeafe",
cyan: "#0891b2",
"cyan-900": "#e0f2fe",
emerald: "#047857",
"emerald-900": "#ecfdf5",
gray: GRAYS[400],
lime: "#84cc16",
"lime-900": "#f7fee7",
orange: "#ea580c",
"orange-900": "#ffedd5",
purple: "#581c87",
"purple-900": "#f3e8ff",
rose: "#9f1239",
"rose-900": "#fecdd3",
// App colors
bg: GRAYS[100],
text: GRAYS[900],
"status-bg": GRAYS[300],
"link-text-hover": "var(--primary)",
"btn-bg": "#714b67",
"btn-bg-hover": "#624159",
"btn-text": "#ffffff",
"bg-result": "rgba(255, 255, 255, 0.6)",
"border-result": GRAYS[300],
"border-search": "#d8dadd",
"shadow-opacity": 0.1,
// HootReporting colors
"bg-report": "#ffffff",
"text-report": "#202124",
"border-report": "#f0f0f0",
"bg-report-error": "#fff0f0",
"text-report-error": "#ff0000",
"border-report-error": "#ffd6d6",
"text-report-number": "#1a1aa6",
"text-report-string": "#c80000",
"text-report-key": "#881280",
"text-report-html-tag": "#881280",
"text-report-html-id": "#1a1aa8",
"text-report-html-class": "#994500",
},
dark: {
// Generic colors
primary: "#14b8a6",
amber: "#fbbf24",
"amber-900": "#422006",
blue: "#60a5fa",
"blue-900": "#172554",
cyan: "#22d3ee",
"cyan-900": "#083344",
emerald: "#34d399",
"emerald-900": "#064e3b",
gray: GRAYS[500],
lime: "#bef264",
"lime-900": "#365314",
orange: "#fb923c",
"orange-900": "#431407",
purple: "#a855f7",
"purple-900": "#3b0764",
rose: "#fb7185",
"rose-900": "#4c0519",
// App colors
bg: GRAYS[900],
text: GRAYS[100],
"status-bg": GRAYS[700],
"btn-bg": "#00dac5",
"btn-bg-hover": "#00c1ae",
"btn-text": "#000000",
"bg-result": "rgba(0, 0, 0, 0.5)",
"border-result": GRAYS[600],
"border-search": "#3c3f4c",
"shadow-opacity": 0.4,
// HootReporting colors
"bg-report": "#202124",
"text-report": "#e8eaed",
"border-report": "#3a3a3a",
"bg-report-error": "#290000",
"text-report-error": "#ff8080",
"border-report-error": "#5c0000",
"text-report-number": "#9980ff",
"text-report-string": "#f28b54",
"text-report-key": "#5db0d7",
"text-report-html-tag": "#5db0d7",
"text-report-html-id": "#f29364",
"text-report-html-class": "#9bbbdc",
},
};
const DEBUG_NAMESPACE = "hoot";
const isError = typeof Error.isError === "function" ? Error.isError : polyfillIsError;
const interactionBus = new EventTarget();
const preferredColorScheme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {Iterable<InteractionType>} types
* @param {(event: CustomEvent<InteractionDetails>) => any} callback
*/
export function addInteractionListener(types, callback) {
for (const type of types) {
interactionBus.addEventListener(type, callback);
}
return function removeInteractionListener() {
for (const type of types) {
interactionBus.removeEventListener(type, callback);
}
};
}
/**
* @param {InteractionType} type
* @param {string} name
* @param {any[]} args
* @param {any} returnValue
*/
export function dispatchInteraction(type, name, args, returnValue) {
interactionBus.dispatchEvent(
new CustomEvent(type, {
detail: [name, args, returnValue],
})
);
return returnValue;
}
/**
* @param {...any} helpers
*/
export function exposeHelpers(...helpers) {
let nameSpaceIndex = 1;
let nameSpace = DEBUG_NAMESPACE;
while (nameSpace in globalThis) {
nameSpace = `${DEBUG_NAMESPACE}${nameSpaceIndex++}`;
}
globalThis[nameSpace] = new HootDebugHelpers(...helpers);
return nameSpace;
}
/**
* @param {keyof typeof COLORS} [scheme]
*/
export function getAllColors(scheme) {
return scheme ? COLORS[scheme] : COLORS;
}
/**
* @param {keyof typeof COLORS["light"]} varName
*/
export function getColorHex(varName) {
return COLORS[preferredColorScheme][varName];
}
export function getPreferredColorScheme() {
return preferredColorScheme;
}
/**
* @param {Node} node
*/
export function getTag(node) {
return node?.nodeName?.toLowerCase() || "";
}
/**
* @template {(...args: any[]) => any} T
* @param {InteractionType} type
* @param {T} fn
* @returns {T & {
* as: (name: string) => T;
* readonly silent: T;
* }}
*/
export function interactor(type, fn) {
return $assign(makeInteractorFn(type, fn, fn.name), {
as(alias) {
return makeInteractorFn(type, fn, alias);
},
get silent() {
return fn;
},
});
}
/**
* @returns {boolean}
*/
export function isFirefox() {
return /firefox/i.test($userAgent);
}
/**
* Cross-realm equivalent to 'instanceof'.
* Can be called with multiple constructors, and will return true if the given object
* is an instance of any of them.
*
* @param {unknown} instance
* @param {...{ name: string }} classes
*/
export function isInstanceOf(instance, ...classes) {
if (!classes.length) {
return instance instanceof classes[0];
}
if (!instance || Object(instance) !== instance) {
// Object is falsy or a primitive (null, undefined and primitives cannot be the instance of anything)
return false;
}
for (const cls of classes) {
if (instance instanceof cls) {
return true;
}
const targetName = cls.name;
if (!targetName) {
return false;
}
if (targetName === "Array") {
return $isArray(instance);
}
if (targetName === "Error") {
return isError(instance);
}
if ($toString.call(instance) === `[object ${targetName}]`) {
return true;
}
let { constructor } = instance;
while (constructor) {
if (constructor.name === targetName) {
return true;
}
constructor = $getPrototypeOf(constructor);
}
}
return false;
}
/**
* Returns whether the given object is iterable (*excluding strings*).
*
* @template T
* @template {T | Iterable<T>} V
* @param {V} object
* @returns {V extends Iterable<T> ? true : false}
*/
export function isIterable(object) {
return !!(object && typeof object === "object" && object[Symbol.iterator]);
}
/**
* @param {string} value
* @param {{ safe?: boolean }} [options]
* @returns {string | RegExp}
*/
export function parseRegExp(value, options) {
const regexParams = value.match(R_REGEX);
if (regexParams) {
const unified = regexParams[1].replace(R_WHITE_SPACE, "\\s+");
const flag = regexParams[2];
try {
return new RegExp(unified, flag);
} catch (error) {
if (isInstanceOf(error, SyntaxError) && options?.safe) {
return value;
} else {
throw error;
}
}
}
return value;
}
/**
* @param {Node} node
* @param {{ raw?: boolean }} [options]
*/
export function toSelector(node, options) {
const tagName = getTag(node);
const id = node.id ? `#${node.id}` : "";
const classNames = node.classList
? [...node.classList].map((className) => `.${className}`)
: [];
if (options?.raw) {
return { tagName, id, classNames };
} else {
return [tagName, id, ...classNames].join("");
}
}
export class HootDebugHelpers {
/**
* @param {...any} helpers
*/
constructor(...helpers) {
$assign(this, ...helpers);
}
}
export const REGEX_MARKER = "/";
// Common regular expressions
export const R_REGEX = new RegExp(`^${REGEX_MARKER}(.*)${REGEX_MARKER}([dgimsuvy]+)?$`);
export const R_WHITE_SPACE = /\s+/g;

View file

@ -0,0 +1,255 @@
/** @odoo-module */
import { DEFAULT_EVENT_TYPES } from "../hoot_utils";
import { generateSeed } from "../mock/math";
/**
* @typedef {keyof typeof FILTER_SCHEMA} SearchFilter
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Number: { parseFloat: $parseFloat },
Object: { entries: $entries, fromEntries: $fromEntries, keys: $keys },
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @template {Record<string, any>} T
* @param {T} schema
* @returns {{ [key in keyof T]: ReturnType<T[key]["parse"]> }}
*/
function getSchemaDefaults(schema) {
return $fromEntries($entries(schema).map(([key, value]) => [key, value.default]));
}
/**
* @template {Record<string, any>} T
* @param {T} schema
* @returns {(keyof T)[]}
*/
function getSchemaKeys(schema) {
return $keys(schema);
}
/**
* @template T
* @param {(values: string[]) => T} parse
* @returns {(valueIfEmpty: T) => (values: string[]) => T}
*/
function makeParser(parse) {
return (valueIfEmpty) => (values) => values.length ? parse(values) : valueIfEmpty;
}
const parseBoolean = makeParser(([value]) => value === "true");
const parseNumber = makeParser(([value]) => $parseFloat(value) || 0);
/** @type {ReturnType<typeof makeParser<"first-fail" | "failed" | false>>} */
const parseShowDetail = makeParser(([value]) => (value === "false" ? false : value));
const parseString = makeParser(([value]) => value);
const parseStringArray = makeParser((values) => values);
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export const CONFIG_SCHEMA = {
/**
* Amount of failed tests after which the test runner will be stopped.
* A falsy value (including 0) means that the runner should never be aborted.
* @default false
*/
bail: {
default: 0,
parse: parseNumber(1),
},
/**
* Debug parameter used in Odoo.
* It has no direct effect on the test runner, but is taken into account since
* all URL parameters not explicitly defined in the schema are ignored.
* @default ""
*/
debug: {
default: "",
parse: parseString("assets"),
},
/**
* Same as the {@link FILTER_SCHEMA.test} filter, while also putting the test
* runner in "debug" mode. See {@link Runner.debug} for more info.
* @default false
*/
debugTest: {
default: false,
parse: parseBoolean(true),
},
/**
* Determines the event types shown in test results.
* @default assertion|error
*/
events: {
default: DEFAULT_EVENT_TYPES,
parse: parseNumber(0),
},
/**
* Amount of frames rendered per second, used when mocking animation frames.
* @default 60
*/
fps: {
default: 60,
parse: parseNumber(60),
},
/**
* Lights up the mood.
* @default false
*/
fun: {
default: false,
parse: parseBoolean(true),
},
/**
* Whether to render the test runner user interface.
* Note: this cannot be changed at runtime: the UI will not be un-rendered or
* rendered if this parameter changes.
* @default false
*/
headless: {
default: false,
parse: parseBoolean(true),
},
/**
* Log level used by the test runner. The higher the level, the more logs will
* be displayed.
*/
loglevel: {
default: 0,
parse: parseNumber(0),
},
/**
* Whether the test runner must be manually started after page load (defaults
* to starting automatically).
* @default false
*/
manual: {
default: false,
parse: parseBoolean(true),
},
/**
* Artifical delay introduced for each network call. It can be a fixed integer,
* or an integer range (in the form "min-max") to generate a random delay between
* "min" and "max".
* @default 0
*/
networkDelay: {
default: "0",
parse: parseString("0"),
},
/**
* Removes the safety of 'try .. catch' statements around each test's run function
* to let errors bubble to the browser.
* @default false
*/
notrycatch: {
default: false,
parse: parseBoolean(true),
},
/**
* Determines the order of the tests execution.
* - `"fifo"`: tests will be run sequentially as declared in the file system.
* - `"lifo"`: tests will be run sequentially in the reverse order.
* - `"random"`: shuffles tests and suites within their parent suite.
* @default "fifo"
*/
order: {
default: "fifo",
parse: parseString(""),
},
/**
* Environment in which the test runner is running. This parameter is used to
* determine the default value of other parameters, namely:
* - the user agent;
* - touch support;
* - size of the viewport.
* @default "" no specific parameters are set
*/
preset: {
default: "",
parse: parseString(""),
},
/**
* Determines the seed from which random numbers will be generated.
* @default 0
*/
random: {
default: 0,
parse: parseString(generateSeed()),
},
/**
* Determines how the failed tests must be unfolded in the UI:
* - "first-fail": only the first failed test will be unfolded
* - "failed": all failed tests will be unfolded
* - false: all tests will remain folded
* @default "first-fail"
*/
showdetail: {
default: "first-fail",
parse: parseShowDetail("failed"),
},
/**
* Duration (in milliseconds) at the end of which a test will automatically fail.
* @default 5_000
*/
timeout: {
default: 5_000,
parse: parseNumber(5_000),
},
};
export const FILTER_SCHEMA = {
/**
* Search string that will filter matching tests/suites, based on:
* - their full name (including their parent suite(s))
* - their tags
* @default ""
*/
filter: {
aliases: ["name"],
default: "",
parse: parseString(""),
},
/**
* IDs of the suites OR tests to run exclusively. The ID of a job is generated
* deterministically based on its full name.
* @default []
*/
id: {
aliases: ["ids"],
default: [],
parse: parseStringArray([]),
},
/**
* Tag names of tests and suites to run exclusively (case insensitive).
* @default []
*/
tag: {
aliases: ["tags"],
default: [],
parse: parseStringArray([]),
},
};
/** @see {@link CONFIG_SCHEMA} */
export const DEFAULT_CONFIG = getSchemaDefaults(CONFIG_SCHEMA);
export const CONFIG_KEYS = getSchemaKeys(CONFIG_SCHEMA);
/** @see {@link FILTER_SCHEMA} */
export const DEFAULT_FILTERS = getSchemaDefaults(FILTER_SCHEMA);
export const FILTER_KEYS = getSchemaKeys(FILTER_SCHEMA);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,223 @@
/** @odoo-module */
import { App } from "@odoo/owl";
import { getActiveElement, getCurrentDimensions } from "@web/../lib/hoot-dom/helpers/dom";
import { setupEventActions } from "@web/../lib/hoot-dom/helpers/events";
import { isInstanceOf } from "@web/../lib/hoot-dom/hoot_dom_utils";
import { HootError } from "../hoot_utils";
import { subscribeToTransitionChange } from "../mock/animation";
import { getViewPortHeight, getViewPortWidth } from "../mock/window";
/**
* @typedef {Parameters<typeof import("@odoo/owl").mount>[2] & {
* className: string | string[];
* target?: import("@odoo/hoot-dom").Target;
* }} MountOnFixtureOptions
*
* @typedef {{
* component: import("@odoo/owl").ComponentConstructor;
* props: unknown;
* }} TestRootProps
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const { customElements, document, getSelection, HTMLElement, Promise, WeakSet } = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {HTMLIFrameElement} iframe
*/
function waitForIframe(iframe) {
return new Promise((resolve) => iframe.addEventListener("load", resolve));
}
const destroyed = new WeakSet();
let allowFixture = false;
/** @type {HootFixtureElement | null} */
let currentFixture = null;
let shouldPrepareNextFixture = true; // Prepare setup for first test
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {App | import("@odoo/owl").Component} target
*/
export function destroy(target) {
const app = isInstanceOf(target, App) ? target : target.__owl__.app;
if (destroyed.has(app)) {
return;
}
destroyed.add(app);
app.destroy();
}
/**
* @param {import("./runner").Runner} runner
*/
export function makeFixtureManager(runner) {
function cleanup() {
allowFixture = false;
if (currentFixture) {
shouldPrepareNextFixture = true;
currentFixture.remove();
currentFixture = null;
}
}
function getFixture() {
if (!allowFixture) {
throw new HootError(`cannot access fixture outside of a test.`);
}
if (!currentFixture) {
// Prepare fixture once to not force layouts/reflows
currentFixture = document.createElement(HootFixtureElement.TAG_NAME);
if (runner.debug || runner.headless) {
currentFixture.show();
}
const { width, height } = getCurrentDimensions();
if (width !== getViewPortWidth()) {
currentFixture.style.width = `${width}px`;
}
if (height !== getViewPortHeight()) {
currentFixture.style.height = `${height}px`;
}
document.body.appendChild(currentFixture);
}
return currentFixture;
}
function setup() {
allowFixture = true;
if (shouldPrepareNextFixture) {
shouldPrepareNextFixture = false;
// Reset focus & selection
getActiveElement().blur();
getSelection().removeAllRanges();
}
}
return {
cleanup,
setup,
get: getFixture,
};
}
export class HootFixtureElement extends HTMLElement {
static CLASSES = {
transitions: "allow-transitions",
show: "show-fixture",
};
static TAG_NAME = "hoot-fixture";
static styleElement = document.createElement("style");
static {
customElements.define(this.TAG_NAME, this);
this.styleElement.id = "hoot-fixture-style";
this.styleElement.textContent = /* css */ `
${this.TAG_NAME} {
position: fixed !important;
height: 100vh;
width: 100vw;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
opacity: 0;
z-index: -1;
}
${this.TAG_NAME}.${this.CLASSES.show} {
background-color: inherit;
color: inherit;
opacity: 1;
z-index: 3;
}
${this.TAG_NAME}:not(.${this.CLASSES.transitions}) * {
animation: none !important;
transition: none !important;
}
`;
}
get hasIframes() {
return this._iframes.size > 0;
}
/** @private */
_observer = new MutationObserver(this._onFixtureMutation.bind(this));
/**
* @private
* @type {Map<HTMLIFrameElement, Promise<void>>}
*/
_iframes = new Map();
connectedCallback() {
setupEventActions(this);
subscribeToTransitionChange((allowTransitions) =>
this.classList.toggle(this.constructor.CLASSES.transitions, allowTransitions)
);
this._observer.observe(this, { childList: true, subtree: true });
this._lookForIframes();
}
disconnectedCallback() {
this._iframes.clear();
this._observer.disconnect();
}
hide() {
this.classList.remove(this.constructor.CLASSES.show);
}
async waitForIframes() {
await Promise.all(this._iframes.values());
}
show() {
this.classList.add(this.constructor.CLASSES.show);
}
/**
* @private
*/
_lookForIframes() {
const toRemove = new Set(this._iframes.keys());
for (const iframe of this.getElementsByTagName("iframe")) {
if (toRemove.delete(iframe)) {
continue;
}
this._iframes.set(iframe, waitForIframe(iframe));
setupEventActions(iframe.contentWindow);
}
for (const iframe of toRemove) {
this._iframes.delete(iframe);
}
}
/**
* @private
* @type {MutationCallback}
*/
_onFixtureMutation(mutations) {
if (mutations.some((mutation) => mutation.addedNodes)) {
this._lookForIframes();
}
}
}

View file

@ -0,0 +1,135 @@
/** @odoo-module */
import { generateHash, HootError, isOfType, normalize } from "../hoot_utils";
import { applyTags } from "./tag";
/**
* @typedef {{
* debug?: boolean;
* multi?: number;
* only?: boolean;
* skip?: boolean;
* timeout?: number;
* todo?: boolean;
* }} JobConfig
*
* @typedef {import("./tag").Tag} Tag
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Object: { assign: $assign, entries: $entries },
Symbol,
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {JobConfig} config
*/
function validateConfig(config) {
for (const [key, value] of $entries(config)) {
if (!isOfType(value, CONFIG_TAG_SCHEMA[key])) {
throw new HootError(`invalid config tag: parameter "${key}" does not exist`, {
level: "critical",
});
}
}
}
/** @type {Record<keyof JobConfig, import("../hoot_utils").ArgumentType>} */
const CONFIG_TAG_SCHEMA = {
debug: "boolean",
multi: "number",
only: "boolean",
skip: "boolean",
timeout: "number",
todo: "boolean",
};
const S_MINIMIZED = Symbol("minimized");
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export class Job {
/** @type {JobConfig} */
config = {};
/** @type {Job[]} */
path = [this];
runCount = 0;
/** @type {Tag[]} */
tags = [];
get isMinimized() {
return S_MINIMIZED in this;
}
/**
* @param {import("./suite").Suite | null} parent
* @param {string} name
* @param {JobConfig & { tags?: Iterable<Tag> }} config
*/
constructor(parent, name, config) {
this.parent = parent || null;
this.name = name;
if (this.parent) {
// Assigns parent path and config (ignoring multi)
const parentConfig = {
...this.parent.config,
tags: this.parent.tags,
};
delete parentConfig.multi;
this.configure(parentConfig);
this.path.unshift(...this.parent.path);
}
this.fullName = this.path.map((job) => job.name).join("/");
this.id = generateHash(this.fullName);
this.key = normalize(this.fullName);
this.configure(config);
}
after() {
for (const tag of this.tags) {
tag.after?.(this);
}
}
before() {
for (const tag of this.tags) {
tag.before?.(this);
}
}
/**
* @param {JobConfig & { tags?: Iterable<Tag> }} config
*/
configure({ tags, ...config }) {
// Assigns and validates job config
$assign(this.config, config);
validateConfig(this.config);
// Add tags
applyTags(this, tags);
}
minimize() {
this[S_MINIMIZED] = true;
}
/**
* @returns {boolean}
*/
willRunAgain() {
return this.runCount < (this.config.multi || 0) || this.parent?.willRunAgain();
}
}

View file

@ -0,0 +1,377 @@
/** @odoo-module */
import { getColorHex } from "../../hoot-dom/hoot_dom_utils";
import { stringify } from "../hoot_utils";
import { urlParams } from "./url";
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
console: {
debug: $debug,
dir: $dir,
error: $error,
groupCollapsed: $groupCollapsed,
groupEnd: $groupEnd,
log: $log,
table: $table,
trace: $trace,
warn: $warn,
},
Object: { entries: $entries, getOwnPropertyDescriptors: $getOwnPropertyDescriptors },
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {any[]} args
* @param {string} [prefix]
* @param {string} [prefixColor]
*/
function styledArguments(args, prefix, prefixColor) {
const fullPrefix = `%c[${prefix || DEFAULT_PREFIX[0]}]%c`;
const styles = [`color:${prefixColor || DEFAULT_PREFIX[1]};font-weight:bold`, ""];
const firstArg = args.shift() ?? "";
if (typeof firstArg === "string") {
args.unshift(`${fullPrefix} ${firstArg}`, ...styles);
} else {
args.unshift(fullPrefix, ...styles, firstArg);
}
return args;
}
/**
* @param {any[]} args
*/
function unstyledArguments(args) {
const prefix = `[${DEFAULT_PREFIX[0]}]`;
const firstArg = args.shift() ?? "";
if (typeof firstArg === "string") {
args.unshift(`${prefix} ${firstArg}`);
} else {
args.unshift(prefix, firstArg);
}
return [args.join(" ")];
}
class Logger {
/** @private */
issueLevel;
/** @private */
logLevel;
constructor(logLevel, issueLevel) {
this.logLevel = logLevel;
this.issueLevel = issueLevel;
// Pre-bind all methods for ease of use
for (const [key, desc] of $entries($getOwnPropertyDescriptors(Logger.prototype))) {
if (key !== "constructor" && typeof desc.value === "function") {
this[key] = this[key].bind(this);
}
}
}
get global() {
return new Logger(this.logLevel, ISSUE_LEVELS.global);
}
// Standard console methods
/**
* @param {...any} args
*/
debug(...args) {
$debug(...styledArguments(args));
}
/**
* @param {...any} args
*/
error(...args) {
switch (this.issueLevel) {
case ISSUE_LEVELS.suppressed: {
$groupCollapsed(...styledArguments(["suppressed"], ...ERROR_PREFIX));
$trace(...args);
$groupEnd();
break;
}
case ISSUE_LEVELS.trace: {
$trace(...styledArguments(args, ...ERROR_PREFIX));
break;
}
case ISSUE_LEVELS.global: {
$error(...styledArguments(args));
break;
}
default: {
$error(...args);
break;
}
}
}
/**
* @param {any} arg
* @param {() => any} callback
*/
group(title, callback) {
$groupCollapsed(...styledArguments([title]));
callback();
$groupEnd();
}
/**
* @param {...any} args
*/
table(...args) {
$table(...args);
}
/**
* @param {...any} args
*/
trace(...args) {
$trace(...args);
}
/**
* @param {...any} args
*/
warn(...args) {
switch (this.issueLevel) {
case ISSUE_LEVELS.suppressed: {
$groupCollapsed(...styledArguments(["suppressed"], ...WARNING_PREFIX));
$trace(...args);
$groupEnd();
break;
}
case ISSUE_LEVELS.global: {
$warn(...styledArguments(args));
break;
}
default: {
$warn(...args);
break;
}
}
}
// Level-specific methods
/**
* @param {...any} args
*/
logDebug(...args) {
if (!this.canLog("debug")) {
return;
}
$debug(...styledArguments(args, ...DEBUG_PREFIX));
}
/**
* @param {import("./suite").Suite} suite
*/
logSuite(suite) {
if (!this.canLog("suites")) {
return;
}
const args = [`${stringify(suite.fullName)} ended`];
const withArgs = [];
if (suite.reporting.passed) {
withArgs.push("passed:", suite.reporting.passed, "/");
}
if (suite.reporting.failed) {
withArgs.push("failed:", suite.reporting.failed, "/");
}
if (suite.reporting.skipped) {
withArgs.push("skipped:", suite.reporting.skipped, "/");
}
if (withArgs.length) {
args.push(
`(${withArgs.shift()}`,
...withArgs,
"time:",
suite.jobs.reduce((acc, job) => acc + (job.duration || 0), 0),
"ms)"
);
}
$log(...styledArguments(args));
}
/**
* @param {import("./test").Test} test
*/
logTest(test) {
if (!this.canLog("tests")) {
return;
}
const { fullName, lastResults } = test;
$log(
...styledArguments([
`Test ${stringify(fullName)} passed (assertions:`,
lastResults.counts.assertion || 0,
`/ time:`,
lastResults.duration,
`ms)`,
])
);
}
/**
* @param {[label: string, color: string]} prefix
* @param {...any} args
*/
logTestEvent(prefix, ...args) {
$log(...styledArguments(args, ...prefix));
}
/**
* @param {...any} args
*/
logRun(...args) {
if (!this.canLog("runner")) {
return;
}
$log(...styledArguments(args));
}
/**
* @param {...any} args
*/
logGlobal(...args) {
$dir(...unstyledArguments(args));
}
// Other methods
/**
* @param {keyof typeof LOG_LEVELS} level
*/
canLog(level) {
return this.logLevel >= LOG_LEVELS[level];
}
/**
* @param {keyof typeof ISSUE_LEVELS} level
*/
setIssueLevel(level) {
const restoreIssueLevel = () => {
this.issueLevel = previous;
};
const previous = this.issueLevel;
this.issueLevel = ISSUE_LEVELS[level];
return restoreIssueLevel;
}
/**
* @param {keyof typeof LOG_LEVELS} level
*/
setLogLevel(level) {
const restoreLogLevel = () => {
this.logLevel = previous;
};
const previous = this.logLevel;
this.logLevel = LOG_LEVELS[level];
return restoreLogLevel;
}
}
const DEBUG_PREFIX = ["DEBUG", getColorHex("purple")];
const DEFAULT_PREFIX = ["HOOT", getColorHex("primary")];
const ERROR_PREFIX = ["ERROR", getColorHex("rose")];
const WARNING_PREFIX = ["WARNING", getColorHex("amber")];
let nextNetworkLogId = 1;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {string} prefix
* @param {string} title
*/
export function makeNetworkLogger(prefix, title) {
const id = nextNetworkLogId++;
return {
/**
* Request logger: blue-ish.
* @param {() => any} getData
*/
async logRequest(getData) {
if (!logger.canLog("debug")) {
return;
}
const color = `color: #66e`;
const styles = [`${color}; font-weight: bold;`, color];
$groupCollapsed(`-> %c${prefix}#${id}%c<${title}>`, ...styles, await getData());
$trace("request trace");
$groupEnd();
},
/**
* Response logger: orange.
* @param {() => any} getData
*/
async logResponse(getData) {
if (!logger.canLog("debug")) {
return;
}
const color = `color: #f80`;
const styles = [`${color}; font-weight: bold;`, color];
$log(`<- %c${prefix}#${id}%c<${title}>`, ...styles, await getData());
},
};
}
export const ISSUE_LEVELS = {
/**
* Suppressed:
*
* Condition:
* - typically: in "todo" tests where issues should be ignored
*
* Effect:
* - all errors and warnings are replaced by 'trace' calls
*/
suppressed: 0,
/**
* Trace:
*
* Condition:
* - default level within a test run
*
* Effect:
* - warnings are left as-is;
* - errors are replaced by 'trace' calls, so that the actual console error
* comes from the test runner with a summary of all failed reasons.
*/
trace: 1,
/**
* Global:
*
* Condition:
* - errors which should be reported globally but not interrupt the run
*
* Effect:
* - warnings are left as-is;
* - errors are wrapped with a "HOOT" prefix, as to not stop the current test
* run. Can typically be used to log test failed reasons.
*/
global: 2,
/**
* Critical:
*
* Condition:
* - any error compromising the whole test run and should cancel or interrupt it
* - default level outside of a test run (import errors, module root errors, etc.)
*
* Effect:
* - warnings are left as-is;
* - errors are left as-is, as to tell the server test to stop the current
* (Python) test.
*/
critical: 3,
};
export const LOG_LEVELS = {
runner: 0,
suites: 1,
tests: 2,
debug: 3,
};
export const logger = new Logger(
urlParams.loglevel ?? LOG_LEVELS.runner,
ISSUE_LEVELS.critical // by default, all errors are "critical", i.e. should abort the whole run
);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,127 @@
/** @odoo-module */
import { Callbacks, HootError, createReporting, stringify } from "../hoot_utils";
import { Job } from "./job";
/**
* @typedef {import("./tag").Tag} Tag
*
* @typedef {import("./test").Test} Test
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Object: { freeze: $freeze },
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
class MinimalCallbacks extends Callbacks {
add() {}
call() {}
callSync() {}
clear() {}
}
const SHARED_CALLBACKS = new MinimalCallbacks();
const SHARED_CURRENT_JOBS = $freeze([]);
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {Pick<Suite, "name" | "parent">} suite
* @param {Error | string} message
* @returns {HootError}
*/
export function suiteError({ name, parent }, message) {
const parentString = parent ? ` (in parent suite ${stringify(parent.name)})` : "";
const errorOptions = { level: "critical" };
let errorMessage = `error while registering suite ${stringify(name)}${parentString}`;
if (message instanceof Error) {
errorOptions.cause = message;
} else {
errorMessage += `: ${message}`;
}
return new HootError(errorMessage, errorOptions);
}
export class Suite extends Job {
callbacks = new Callbacks();
currentJobIndex = 0;
/** @type {(Suite | Test)[]} */
currentJobs = [];
/** @type {(Suite | Test)[]} */
jobs = [];
reporting = createReporting();
totalSuiteCount = 0;
totalTestCount = 0;
get weight() {
return this.totalTestCount;
}
addJob(job) {
this.jobs.push(job);
if (job instanceof Suite) {
this.increaseSuiteCount();
} else {
this.increaseTestCount();
}
}
cleanup() {
this.parent?.reporting.add({ suites: +1 });
this.minimize();
}
minimize() {
super.minimize();
this.callbacks.clear();
this.callbacks = SHARED_CALLBACKS;
this.currentJobs = SHARED_CURRENT_JOBS;
}
increaseSuiteCount() {
this.totalSuiteCount++;
this.parent?.increaseSuiteCount();
}
increaseTestCount() {
this.totalTestCount++;
this.parent?.increaseTestCount();
}
reset() {
this.currentJobIndex = 0;
for (const job of this.jobs) {
job.runCount = 0;
if (job instanceof Suite) {
job.reset();
}
}
}
/**
* @param {Job[]} jobs
*/
setCurrentJobs(jobs) {
if (this.isMinimized) {
return;
}
this.currentJobs = jobs;
this.currentJobIndex = 0;
}
}

View file

@ -0,0 +1,202 @@
/** @odoo-module */
import { HootError, levenshtein, normalize, stringify, stringToNumber } from "../hoot_utils";
/**
* @typedef {import("./job").Job} Job
* @typedef {import("./suite").Suite} Suite
* @typedef {import("./suite").Test} Test
*
* @typedef {{
* name: string;
* exclude?: string[];
* before?: (test: Test) => any;
* after?: (test: Test) => any;
* }} TagDefinition
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Math: { ceil: $ceil, max: $max },
Object: { create: $create, keys: $keys },
Set,
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* Checks for similarity with other existing tag names.
*
* A tag name is considered similar to another if the following conditions are met:
* - it doesn't include numbers (the number is likely meaningful enough to dissociate
* it from other similar tags);
* - the edit distance between the 2 is <= 10% of the length of the largest string
*
* @param {string} tagKey
* @param {string} tagName
*/
function checkTagSimilarity(tagKey, tagName) {
if (R_UNIQUE_TAG.test(tagKey)) {
return;
}
for (const key of $keys(existingTags)) {
if (R_UNIQUE_TAG.test(key)) {
continue;
}
const maxLength = $max(tagKey.length, key.length);
const threshold = $ceil(SIMILARITY_PERCENTAGE * maxLength);
const editDistance = levenshtein(key, tagKey);
if (editDistance <= threshold) {
similarities.push([existingTags[key], tagName]);
}
}
}
const R_UNIQUE_TAG = /\d/;
const SIMILARITY_PERCENTAGE = 0.1;
const TAG_COLORS = [
["#f97316", "#ffedd5"], // orange
["#eab308", "#fef9c3"], // yellow
["#84cc16", "#ecfccb"], // lime
["#10b981", "#d1fae5"], // emerald
["#06b6d4", "#cffafe"], // cyan
["#3b82f6", "#dbeafe"], // blue
["#6366f1", "#e0e7ff"], // indigo
["#d946ef", "#fae8ff"], // fuschia
["#f43f5e", "#ffe4e6"], // rose
];
/** @type {Record<string, Tag>} */
const existingTags = $create(null);
/** @type {[string, string][]} */
const similarities = [];
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {Job} job
* @param {Iterable<Tag>} [tags]
*/
export function applyTags(job, tags) {
if (!tags?.length) {
return;
}
const existingKeys = new Set(job.tags.map((t) => t.key));
for (const tag of tags) {
if (existingKeys.has(tag.key)) {
continue;
}
const excluded = tag.exclude?.filter((key) => existingKeys.has(key));
if (excluded?.length) {
throw new HootError(
`cannot apply tag ${stringify(tag.name)} on test/suite ${stringify(
job.name
)} as it explicitly excludes tags ${excluded.map(stringify).join(" & ")}`,
{ level: "global" }
);
}
job.tags.push(tag);
existingKeys.add(tag.key);
tag.weight++;
}
}
/**
* Globally defines specifications for a list of tags.
* This is useful to add metadata or side-effects to a given tag, like an exclusion
* to prevent specific tags to be added at the same time.
*
* @param {...TagDefinition} definitions
* @example
* defineTags({
* name: "desktop",
* exclude: ["mobile"],
* });
*/
export function defineTags(...definitions) {
return definitions.map((def) => {
const tagKey = def.key || normalize(def.name.toLowerCase());
if (existingTags[tagKey]) {
throw new HootError(`duplicate definition for tag "${def.name}"`, {
level: "global",
});
}
checkTagSimilarity(tagKey, def.name);
existingTags[tagKey] = new Tag(tagKey, def);
return existingTags[tagKey];
});
}
/**
* @param {string[]} tagNames
*/
export function getTags(tagNames) {
return tagNames.map((tagKey, i) => {
const nKey = normalize(tagKey.toLowerCase());
const tag = existingTags[nKey] || defineTags({ key: nKey, name: tagNames[i] })[0];
return tag;
});
}
export function getTagSimilarities() {
return similarities;
}
/**
* ! SHOULD NOT BE EXPORTED OUTSIDE OF HOOT
*
* Used in Hoot internal tests to remove tags introduced within a test.
*
* @private
* @param {Iterable<string>} tagKeys
*/
export function undefineTags(tagKeys) {
for (const tagKey of tagKeys) {
delete existingTags[tagKey];
}
}
/**
* Should **not** be instantiated outside of {@link defineTags}.
* @see {@link defineTags}
*/
export class Tag {
static DEBUG = "debug";
static ONLY = "only";
static SKIP = "skip";
static TODO = "todo";
weight = 0;
get id() {
return this.key;
}
/**
* @param {string} key normalized tag name
* @param {TagDefinition} definition
*/
constructor(key, { name, exclude, before, after }) {
this.key = key;
this.name = name;
this.color = TAG_COLORS[stringToNumber(this.key) % TAG_COLORS.length];
if (exclude) {
this.exclude = exclude.map((id) => normalize(id.toLowerCase()));
}
if (before) {
this.before = before;
}
if (after) {
this.after = after;
}
}
}

View file

@ -0,0 +1,161 @@
/** @odoo-module */
import { markup, reactive } from "@odoo/owl";
import { HootError, stringify } from "../hoot_utils";
import { Job } from "./job";
import { Tag } from "./tag";
/**
* @template T
* @typedef {T | PromiseLike<T>} MaybePromise
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Object: { freeze: $freeze },
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
const SHARED_LOGS = $freeze({});
const SHARED_RESULTS = $freeze([]);
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {Pick<Test, "name" | "parent">} test
* @returns {HootError}
*/
export function testError({ name, parent }, ...message) {
const parentString = parent ? ` (in suite ${stringify(parent.name)})` : "";
return new HootError(
`error while registering test ${stringify(name)}${parentString}: ${message.join("\n")}`,
{ level: "critical" }
);
}
export class Test extends Job {
static SKIPPED = 0;
static PASSED = 1;
static FAILED = 2;
static ABORTED = 3;
formatted = false;
logs = reactive({
error: 0,
warn: 0,
});
/** @type {import("./expect").CaseResult[]} */
results = reactive([]);
/** @type {() => MaybePromise<void> | null} */
run = null;
runFnString = "";
status = Test.SKIPPED;
get code() {
if (!this.formatted) {
this.formatted = true;
this.runFnString = this.formatFunctionSource(this.runFnString);
if (window.Prism) {
const highlighted = window.Prism.highlight(
this.runFnString,
Prism.languages.javascript,
"javascript"
);
this.runFnString = markup(highlighted);
}
}
return this.runFnString;
}
get duration() {
return this.results.reduce((acc, result) => acc + result.duration, 0);
}
/** @returns {import("./expect").CaseResult | null} */
get lastResults() {
return this.results.at(-1);
}
cleanup() {
this.run = null;
}
/**
* @param {string} stringFn
*/
formatFunctionSource(stringFn) {
let modifiers = "";
let startingLine = 0;
if (this.name) {
for (const tag of this.tags) {
if (this.parent.tags.includes(tag)) {
continue;
}
switch (tag.key) {
case Tag.TODO:
case Tag.DEBUG:
case Tag.SKIP:
case Tag.ONLY: {
modifiers += `.${tag.key}`;
break;
}
}
}
startingLine++;
stringFn = `test${modifiers}(${stringify(this.name)}, ${stringFn});`;
}
const lines = stringFn.split("\n");
let toTrim = null;
for (let i = startingLine; i < lines.length; i++) {
if (!lines[i].trim()) {
continue;
}
const [, whiteSpaces] = lines[i].match(/^(\s*)/);
if (toTrim === null || whiteSpaces.length < toTrim) {
toTrim = whiteSpaces.length;
}
}
if (toTrim) {
for (let i = startingLine; i < lines.length; i++) {
lines[i] = lines[i].slice(toTrim);
}
}
return lines.join("\n");
}
minimize() {
super.minimize();
this.setRunFn(null);
this.runFnString = "";
this.logs = SHARED_LOGS;
this.results = SHARED_RESULTS;
}
reset() {
this.run = this.run.bind(this);
}
/**
* @param {() => MaybePromise<void>} fn
*/
setRunFn(fn) {
this.run = fn ? async () => fn() : null;
if (fn) {
this.formatted = false;
this.runFnString = fn.toString();
}
}
}

View file

@ -0,0 +1,202 @@
/** @odoo-module */
import { onWillRender, reactive, useState } from "@odoo/owl";
import { isIterable } from "@web/../lib/hoot-dom/hoot_dom_utils";
import { debounce, ensureArray, isNil } from "../hoot_utils";
import { CONFIG_KEYS, CONFIG_SCHEMA, FILTER_KEYS, FILTER_SCHEMA } from "./config";
/**
* @typedef {{
* debug?: boolean;
* ignore?: boolean;
* }} CreateUrlFromIdOptions
*
* @typedef {typeof import("./config").DEFAULT_CONFIG} DEFAULT_CONFIG
*
* @typedef {typeof import("./config").DEFAULT_FILTERS} DEFAULT_FILTERS
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
history,
location,
Object: { entries: $entries },
Set,
URIError,
URL,
URLSearchParams,
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
const debouncedUpdateUrl = debounce(function updateUrl() {
const url = createUrl({});
url.search = "";
for (const [key, value] of $entries(urlParams)) {
if (isIterable(value)) {
for (const val of value) {
if (val) {
url.searchParams.append(key, val);
}
}
} else if (value) {
url.searchParams.set(key, String(value));
}
}
const path = url.toString();
history.replaceState({ path }, "", path);
}, 20);
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {Partial<DEFAULT_CONFIG & DEFAULT_FILTERS>} params
*/
export function createUrl(params) {
const url = new URL(location.href);
for (const key in params) {
url.searchParams.delete(key);
if (!CONFIG_KEYS.includes(key) && !FILTER_KEYS.includes(key)) {
throw new URIError(`unknown URL param key: "${key}"`);
}
if (isIterable(params[key])) {
for (const value of params[key]) {
url.searchParams.append(key, value);
}
} else if (!isNil(params[key])) {
url.searchParams.set(key, params[key]);
}
}
return url;
}
/**
* @param {Record<keyof DEFAULT_FILTERS, string | Iterable<string>>} specs
* @param {CreateUrlFromIdOptions} [options]
*/
export function createUrlFromId(specs, options) {
const nextParams = {};
for (const key of FILTER_KEYS) {
nextParams[key] = new Set(ensureArray((options?.ignore && urlParams[key]) || []));
}
for (const [type, id] of $entries(specs)) {
const ids = ensureArray(id);
switch (type) {
case "id": {
if (options?.ignore) {
for (const id of ids) {
const exludedId = EXCLUDE_PREFIX + id;
if (nextParams.id.has(exludedId) || urlParams.id?.includes(exludedId)) {
nextParams.id.delete(exludedId);
} else {
nextParams.id.add(exludedId);
}
}
} else {
for (const id of ids) {
nextParams.id.add(id);
}
}
break;
}
case "tag": {
if (options?.ignore) {
for (const id of ids) {
const exludedId = EXCLUDE_PREFIX + id;
if (urlParams.tag?.includes(exludedId)) {
nextParams.tag.delete(exludedId);
} else {
nextParams.tag.add(exludedId);
}
}
} else {
for (const id of ids) {
nextParams.tag.add(id);
}
}
break;
}
}
}
for (const key in nextParams) {
if (!nextParams[key].size) {
nextParams[key] = null;
}
}
nextParams.debugTest = options?.debug ? true : null;
return createUrl(nextParams);
}
export function refresh() {
history.go();
}
/**
* @param {Partial<DEFAULT_CONFIG & DEFAULT_FILTERS>} params
*/
export function setParams(params) {
for (const [key, value] of $entries(params)) {
if (!CONFIG_KEYS.includes(key) && !FILTER_KEYS.includes(key)) {
throw new URIError(`unknown URL param key: "${key}"`);
}
if (value) {
urlParams[key] = isIterable(value) ? [...value] : value;
} else {
delete urlParams[key];
}
}
debouncedUpdateUrl();
}
/**
* @param {...(keyof DEFAULT_CONFIG | keyof DEFAULT_FILTERS | "*")} keys
*/
export function subscribeToURLParams(...keys) {
const state = useState(urlParams);
if (keys.length) {
const observedKeys = keys.includes("*") ? [...CONFIG_KEYS, ...FILTER_KEYS] : keys;
onWillRender(() => observedKeys.forEach((key) => state[key]));
}
return state;
}
export const EXCLUDE_PREFIX = "-";
/** @type {Partial<DEFAULT_CONFIG & DEFAULT_FILTERS>} */
export const urlParams = reactive({});
// Update URL params immediatly
const searchParams = new URLSearchParams(location.search);
const searchKeys = new Set(searchParams.keys());
for (const [configKey, { aliases, parse }] of $entries({
...CONFIG_SCHEMA,
...FILTER_SCHEMA,
})) {
const configKeys = [configKey, ...(aliases || [])];
/** @type {string[]} */
const values = [];
let hasKey = false;
for (const key of configKeys) {
if (searchKeys.has(key)) {
hasKey = true;
values.push(...searchParams.getAll(key).filter(Boolean));
}
}
if (hasKey) {
urlParams[configKey] = parse(values);
} else {
delete urlParams[configKey];
}
}

View file

@ -0,0 +1,33 @@
/** @odoo-module alias=@odoo/hoot-mock default=false */
/**
* @typedef {import("./mock/network").ServerWebSocket} ServerWebSocket
*/
export {
advanceFrame,
advanceTime,
animationFrame,
cancelAllTimers,
Deferred,
delay,
freezeTime,
microTick,
runAllTimers,
setFrameRate,
tick,
unfreezeTime,
} from "@odoo/hoot-dom";
export { disableAnimations, enableTransitions } from "./mock/animation";
export { mockDate, mockLocale, mockTimeZone, onTimeZoneChange } from "./mock/date";
export { makeSeededRandom } from "./mock/math";
export { mockPermission, mockSendBeacon, mockUserAgent, mockVibrate } from "./mock/navigator";
export { mockFetch, mockLocation, mockWebSocket, mockWorker } from "./mock/network";
export { flushNotifications } from "./mock/notification";
export {
mockMatchMedia,
mockTouch,
watchAddedNodes,
watchKeys,
watchListeners,
} from "./mock/window";

View file

@ -0,0 +1,119 @@
/** @odoo-module alias=@odoo/hoot default=false */
import { logger } from "./core/logger";
import { Runner } from "./core/runner";
import { urlParams } from "./core/url";
import { makeRuntimeHook } from "./hoot_utils";
import { setRunner } from "./main_runner";
import { setupHootUI } from "./ui/setup_hoot_ui";
/**
* @typedef {{
* runner: Runner;
* ui: import("./ui/setup_hoot_ui").UiState
* }} Environment
*/
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
const runner = new Runner(urlParams);
setRunner(runner);
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {...unknown} values
*/
export function registerDebugInfo(...values) {
logger.logDebug(...values);
}
// Main test API
export const describe = runner.describe;
export const expect = runner.expect;
export const test = runner.test;
// Hooks
export const after = makeRuntimeHook("after");
export const afterEach = makeRuntimeHook("afterEach");
export const before = makeRuntimeHook("before");
export const beforeEach = makeRuntimeHook("beforeEach");
export const onError = makeRuntimeHook("onError");
// Fixture
export const getFixture = runner.fixture.get;
// Other functions
export const definePreset = runner.exportFn(runner.definePreset);
export const dryRun = runner.exportFn(runner.dryRun);
export const getCurrent = runner.exportFn(runner.getCurrent);
export const start = runner.exportFn(runner.start);
export const stop = runner.exportFn(runner.stop);
export { makeExpect } from "./core/expect";
export { destroy } from "./core/fixture";
export { defineTags } from "./core/tag";
export { createJobScopedGetter } from "./hoot_utils";
// Constants
export const globals = {
AbortController: globalThis.AbortController,
Array: globalThis.Array,
Boolean: globalThis.Boolean,
DataTransfer: globalThis.DataTransfer,
Date: globalThis.Date,
Document: globalThis.Document,
Element: globalThis.Element,
Error: globalThis.Error,
ErrorEvent: globalThis.ErrorEvent,
EventTarget: globalThis.EventTarget,
Map: globalThis.Map,
MutationObserver: globalThis.MutationObserver,
Number: globalThis.Number,
Object: globalThis.Object,
ProgressEvent: globalThis.ProgressEvent,
Promise: globalThis.Promise,
PromiseRejectionEvent: globalThis.PromiseRejectionEvent,
Proxy: globalThis.Proxy,
RegExp: globalThis.RegExp,
Request: globalThis.Request,
Response: globalThis.Response,
Set: globalThis.Set,
SharedWorker: globalThis.SharedWorker,
String: globalThis.String,
TypeError: globalThis.TypeError,
URIError: globalThis.URIError,
URL: globalThis.URL,
URLSearchParams: globalThis.URLSearchParams,
WebSocket: globalThis.WebSocket,
Window: globalThis.Window,
Worker: globalThis.Worker,
XMLHttpRequest: globalThis.XMLHttpRequest,
cancelAnimationFrame: globalThis.cancelAnimationFrame,
clearInterval: globalThis.clearInterval,
clearTimeout: globalThis.clearTimeout,
console: globalThis.console,
document: globalThis.document,
fetch: globalThis.fetch,
history: globalThis.history,
JSON: globalThis.JSON,
localStorage: globalThis.localStorage,
location: globalThis.location,
matchMedia: globalThis.matchMedia,
Math: globalThis.Math,
navigator: globalThis.navigator,
ontouchstart: globalThis.ontouchstart,
performance: globalThis.performance,
requestAnimationFrame: globalThis.requestAnimationFrame,
sessionStorage: globalThis.sessionStorage,
setInterval: globalThis.setInterval,
setTimeout: globalThis.setTimeout,
};
export const __debug__ = runner;
export const isHootReady = setupHootUI();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
/** @odoo-module */
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/** @type {import("./core/runner").Runner} */
let runner;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export function getRunner() {
return runner;
}
export function setRunner(mainRunner) {
runner = mainRunner;
}

View file

@ -0,0 +1,175 @@
/** @odoo-module */
import { on } from "@odoo/hoot-dom";
import { MockEventTarget } from "../hoot_utils";
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Array: { isArray: $isArray },
Element,
Object: { assign: $assign, entries: $entries },
scroll: windowScroll,
scrollBy: windowScrollBy,
scrollTo: windowScrollTo,
} = globalThis;
const { animate, scroll, scrollBy, scrollIntoView, scrollTo } = Element.prototype;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
function forceInstantScroll(args) {
return !allowAnimations && args[0] && typeof args[0] === "object"
? [{ ...args[0], behavior: "instant" }, ...args.slice(1)]
: args;
}
const animationChangeBus = new MockEventTarget();
const animationChangeCleanups = [];
let allowAnimations = true;
let allowTransitions = false;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export class MockAnimation extends MockEventTarget {
static publicListeners = ["cancel", "finish", "remove"];
currentTime = null;
effect = null;
finished = Promise.resolve(this);
id = "";
pending = false;
playState = "idle";
playbackRate = 1;
ready = Promise.resolve(this);
replaceState = "active";
startTime = null;
timeline = {
currentTime: this.currentTime,
duration: null,
};
cancel() {
this.dispatchEvent(new AnimationPlaybackEvent("cancel"));
}
commitStyles() {}
finish() {
this.dispatchEvent(new AnimationPlaybackEvent("finish"));
}
pause() {}
persist() {}
play() {
this.dispatchEvent(new AnimationPlaybackEvent("finish"));
}
reverse() {}
updatePlaybackRate() {}
}
export function cleanupAnimations() {
allowAnimations = true;
allowTransitions = false;
while (animationChangeCleanups.length) {
animationChangeCleanups.pop()();
}
}
/**
* Turns off all animations triggered programmatically (such as with `animate`),
* as well as smooth scrolls.
*
* @param {boolean} [enable=false]
*/
export function disableAnimations(enable = false) {
allowAnimations = enable;
}
/**
* Restores all suppressed "animation" and "transition" properties for the current
* test, as they are turned off by default.
*
* @param {boolean} [enable=true]
*/
export function enableTransitions(enable = true) {
allowTransitions = enable;
animationChangeBus.dispatchEvent(new CustomEvent("toggle-transitions"));
}
/** @type {Element["animate"]} */
export function mockedAnimate(...args) {
if (allowAnimations) {
return animate.call(this, ...args);
}
// Apply style properties immediatly
const keyframesList = $isArray(args[0]) ? args[0] : [args[0]];
const style = {};
for (const kf of keyframesList) {
for (const [key, value] of $entries(kf)) {
style[key] = $isArray(value) ? value.at(-1) : value;
}
}
$assign(this.style, style);
// Return mock animation
return new MockAnimation();
}
/** @type {Element["scroll"]} */
export function mockedScroll(...args) {
return scroll.call(this, ...forceInstantScroll(args));
}
/** @type {Element["scrollBy"]} */
export function mockedScrollBy(...args) {
return scrollBy.call(this, ...forceInstantScroll(args));
}
/** @type {Element["scrollIntoView"]} */
export function mockedScrollIntoView(...args) {
return scrollIntoView.call(this, ...forceInstantScroll(args));
}
/** @type {Element["scrollTo"]} */
export function mockedScrollTo(...args) {
return scrollTo.call(this, ...forceInstantScroll(args));
}
/** @type {typeof window["scroll"]} */
export function mockedWindowScroll(...args) {
return windowScroll.call(this, ...forceInstantScroll(args));
}
/** @type {typeof window["scrollBy"]} */
export function mockedWindowScrollBy(...args) {
return windowScrollBy.call(this, ...forceInstantScroll(args));
}
/** @type {typeof window["scrollTo"]} */
export function mockedWindowScrollTo(...args) {
return windowScrollTo.call(this, ...forceInstantScroll(args));
}
/**
* @param {(allowTransitions: boolean) => any} onChange
*/
export function subscribeToTransitionChange(onChange) {
onChange(allowTransitions);
animationChangeCleanups.push(
on(animationChangeBus, "toggle-transitions", () => onChange(allowTransitions))
);
}

View file

@ -0,0 +1,39 @@
/** @odoo-module */
import { MockEventTarget } from "../hoot_utils";
import { logger } from "../core/logger";
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
console,
Object: { keys: $keys },
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
const DISPATCHING_METHODS = ["error", "trace", "warn"];
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export class MockConsole extends MockEventTarget {
static {
for (const fnName of $keys(console)) {
if (DISPATCHING_METHODS.includes(fnName)) {
const fn = logger[fnName];
this.prototype[fnName] = function (...args) {
this.dispatchEvent(new CustomEvent(fnName, { detail: args }));
return fn.apply(this, arguments);
};
} else {
this.prototype[fnName] = console[fnName];
}
}
}
}

View file

@ -0,0 +1,253 @@
/** @odoo-module */
import { getTimeOffset, isTimeFrozen, resetTimeOffset } from "@web/../lib/hoot-dom/helpers/time";
import { createMock, HootError, isNil } from "../hoot_utils";
/**
* @typedef DateSpecs
* @property {number} [year]
* @property {number} [month] // 1-12
* @property {number} [day] // 1-31
* @property {number} [hour] // 0-23
* @property {number} [minute] // 0-59
* @property {number} [second] // 0-59
* @property {number} [millisecond] // 0-999
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const { Date, Intl } = globalThis;
const { now: $now, UTC: $UTC } = Date;
const { DateTimeFormat, Locale } = Intl;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {Date} baseDate
*/
function computeTimeZoneOffset(baseDate) {
const utcDate = new Date(baseDate.toLocaleString(DEFAULT_LOCALE, { timeZone: "UTC" }));
const tzDate = new Date(baseDate.toLocaleString(DEFAULT_LOCALE, { timeZone: timeZoneName }));
return (utcDate - tzDate) / 60000; // in minutes
}
/**
* @param {number} id
*/
function getDateParams() {
return [...dateParams.slice(0, -1), dateParams.at(-1) + getTimeStampDiff() + getTimeOffset()];
}
function getTimeStampDiff() {
return isTimeFrozen() ? 0 : $now() - dateTimeStamp;
}
/**
* @param {string | DateSpecs} dateSpecs
*/
function parseDateParams(dateSpecs) {
/** @type {DateSpecs} */
const specs =
(typeof dateSpecs === "string" ? dateSpecs.match(DATE_REGEX)?.groups : dateSpecs) || {};
return [
specs.year ?? DEFAULT_DATE[0],
(specs.month ?? DEFAULT_DATE[1]) - 1,
specs.day ?? DEFAULT_DATE[2],
specs.hour ?? DEFAULT_DATE[3],
specs.minute ?? DEFAULT_DATE[4],
specs.second ?? DEFAULT_DATE[5],
specs.millisecond ?? DEFAULT_DATE[6],
].map(Number);
}
/**
* @param {typeof dateParams} newDateParams
*/
function setDateParams(newDateParams) {
dateParams = newDateParams;
dateTimeStamp = $now();
resetTimeOffset();
}
/**
* @param {string | number | null | undefined} tz
*/
function setTimeZone(tz) {
if (typeof tz === "string") {
if (!tz.includes("/")) {
throw new HootError(`invalid time zone: must be in the format <Country/...Location>`);
}
// Set TZ name
timeZoneName = tz;
// Set TZ offset based on name (must be computed for each date)
timeZoneOffset = computeTimeZoneOffset;
} else if (typeof tz === "number") {
// Only set TZ offset
timeZoneOffset = tz * -60;
} else {
// Reset both TZ name & offset
timeZoneName = null;
timeZoneOffset = null;
}
for (const callback of timeZoneChangeCallbacks) {
callback(tz ?? DEFAULT_TIMEZONE_NAME);
}
}
class MockDateTimeFormat extends DateTimeFormat {
constructor(locales, options) {
super(locales, {
...options,
timeZone: options?.timeZone ?? timeZoneName ?? DEFAULT_TIMEZONE_NAME,
});
}
/** @type {Intl.DateTimeFormat["format"]} */
format(date) {
return super.format(date || new MockDate());
}
resolvedOptions() {
return {
...super.resolvedOptions(),
timeZone: timeZoneName ?? DEFAULT_TIMEZONE_NAME,
locale: locale ?? DEFAULT_LOCALE,
};
}
}
const DATE_REGEX =
/(?<year>\d{4})[/-](?<month>\d{2})[/-](?<day>\d{2})([\sT]+(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(\.(?<millisecond>\d{3}))?)?/;
const DEFAULT_DATE = [2019, 2, 11, 9, 30, 0, 0];
const DEFAULT_LOCALE = "en-US";
const DEFAULT_TIMEZONE_NAME = "Europe/Brussels";
const DEFAULT_TIMEZONE_OFFSET = -60;
/** @type {((tz: string | number) => any)[]} */
const timeZoneChangeCallbacks = [];
let dateParams = DEFAULT_DATE;
let dateTimeStamp = $now();
/** @type {string | null} */
let locale = null;
/** @type {string | null} */
let timeZoneName = null;
/** @type {number | ((date: Date) => number) | null} */
let timeZoneOffset = null;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export function cleanupDate() {
setDateParams(DEFAULT_DATE);
locale = null;
timeZoneName = null;
timeZoneOffset = null;
}
/**
* Mocks the current date and time, and also the time zone if any.
*
* Date can either be an object describing the date and time to mock, or a
* string in SQL or ISO format (time and millisecond values can be omitted).
* @see {@link mockTimeZone} for the time zone params.
*
* @param {string | DateSpecs} [date]
* @param {string | number | null} [tz]
* @example
* mockDate("2023-12-25T20:45:00"); // 2023-12-25 20:45:00 UTC
* @example
* mockDate({ year: 2023, month: 12, day: 25, hour: 20, minute: 45 }); // same as above
* @example
* mockDate("2019-02-11 09:30:00.001", +2);
*/
export function mockDate(date, tz) {
setDateParams(date ? parseDateParams(date) : DEFAULT_DATE);
if (!isNil(tz)) {
setTimeZone(tz);
}
}
/**
* Mocks the current locale.
*
* If the time zone hasn't been mocked already, it will be assigned to the first
* time zone available in the given locale (if any).
*
* @param {string} newLocale
* @example
* mockTimeZone("ja-JP"); // UTC + 9
*/
export function mockLocale(newLocale) {
locale = newLocale;
if (!isNil(locale) && isNil(timeZoneName)) {
// Set TZ from locale (if not mocked already)
const firstAvailableTZ = new Locale(locale).timeZones?.[0];
if (!isNil(firstAvailableTZ)) {
setTimeZone(firstAvailableTZ);
}
}
}
/**
* Mocks the current time zone.
*
* Time zone can either be a time zone or an offset. Number offsets are expressed
* in hours.
*
* @param {string | number | null} [tz]
* @example
* mockTimeZone(+10); // UTC + 10
* @example
* mockTimeZone("Europe/Brussels"); // UTC + 1 (or UTC + 2 in summer)
* @example
* mockTimeZone(null) // Resets to test default (+1)
*/
export function mockTimeZone(tz) {
setTimeZone(tz);
}
/**
* Subscribe to changes made on the time zone (mocked) value.
*
* @param {(tz: string | number) => any} callback
*/
export function onTimeZoneChange(callback) {
timeZoneChangeCallbacks.push(callback);
}
export class MockDate extends Date {
constructor(...args) {
if (args.length === 1) {
super(args[0]);
} else {
const params = getDateParams();
for (let i = 0; i < params.length; i++) {
args[i] ??= params[i];
}
super($UTC(...args));
}
}
getTimezoneOffset() {
const offset = timeZoneOffset ?? DEFAULT_TIMEZONE_OFFSET;
return typeof offset === "function" ? offset(this) : offset;
}
static now() {
return new MockDate().getTime();
}
}
export const MockIntl = createMock(Intl, {
DateTimeFormat: { value: MockDateTimeFormat },
});

View file

@ -0,0 +1,91 @@
/** @odoo-module */
import { isNil, stringToNumber } from "../hoot_utils";
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Math,
Number: { isNaN: $isNaN, parseFloat: $parseFloat },
Object: { defineProperties: $defineProperties },
} = globalThis;
const { floor: $floor, random: $random } = Math;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {unknown} [seed]
*/
function toValidSeed(seed) {
if (isNil(seed)) {
return generateSeed();
}
const nSeed = $parseFloat(seed);
return $isNaN(nSeed) ? stringToNumber(nSeed) : nSeed;
}
const DEFAULT_SEED = 1e16;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* Generates a random 16-digit number.
* This function uses the native (unpatched) {@link Math.random} method.
*/
export function generateSeed() {
return $floor($random() * 1e16);
}
/**
* Returns a seeded random number generator equivalent to the native
* {@link Math.random} method.
*
* It exposes a `seed` property that can be changed at any time to reset the
* generator.
*
* @param {number} seed
* @example
* const randA = makeSeededRandom(1e16);
* const randB = makeSeededRandom(1e16);
* randA() === randB(); // true
* @example
* const random = makeSeededRandom(1e16);
* random() === random(); // false
*/
export function makeSeededRandom(seed) {
function random() {
state ^= (state << 13) >>> 0;
state ^= (state >>> 17) >>> 0;
state ^= (state << 5) >>> 0;
return ((state >>> 0) & 0x7fffffff) / 0x7fffffff; // Normalize to [0, 1)
}
let state = seed;
$defineProperties(random, {
seed: {
get() {
return seed;
},
set(value) {
seed = toValidSeed(value);
state = seed;
},
},
});
return random;
}
/**
* `random` function used internally to not generate unwanted calls on global
* `Math.random` function (and possibly having a different seed).
*/
export const internalRandom = makeSeededRandom(DEFAULT_SEED);

View file

@ -0,0 +1,328 @@
/** @odoo-module */
import { isInstanceOf } from "../../hoot-dom/hoot_dom_utils";
import { createMock, HootError, MIME_TYPE, MockEventTarget } from "../hoot_utils";
import { getSyncValue, setSyncValue } from "./sync_values";
/**
* @typedef {"android" | "ios" | "linux" | "mac" | "windows"} Platform
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Blob,
ClipboardItem = class NonSecureClipboardItem {},
navigator,
Object: { assign: $assign },
Set,
TypeError,
} = globalThis;
const { userAgent: $userAgent } = navigator;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
function getBlobValue(value) {
return isInstanceOf(value, Blob) ? value.text() : value;
}
/**
* Returns the final synchronous value of several item types.
*
* @param {unknown} value
* @param {string} type
*/
function getClipboardValue(value, type) {
return getBlobValue(isInstanceOf(value, ClipboardItem) ? value.getType(type) : value);
}
function getMockValues() {
return {
sendBeacon: throwNotImplemented("sendBeacon"),
userAgent: makeUserAgent("linux"),
/** @type {Navigator["vibrate"]} */
vibrate: throwNotImplemented("vibrate"),
};
}
/**
* @returns {Record<PermissionName, { name: string; state: PermissionState }>}
*/
function getPermissions() {
return {
"background-sync": {
state: "granted", // should always be granted
name: "background_sync",
},
"local-fonts": {
state: "denied",
name: "local_fonts",
},
"payment-handler": {
state: "denied",
name: "payment_handler",
},
"persistent-storage": {
state: "denied",
name: "durable_storage",
},
"screen-wake-lock": {
state: "denied",
name: "screen_wake_lock",
},
"storage-access": {
state: "denied",
name: "storage-access",
},
"window-management": {
state: "denied",
name: "window_placement",
},
accelerometer: {
state: "denied",
name: "sensors",
},
camera: {
state: "denied",
name: "video_capture",
},
geolocation: {
state: "denied",
name: "geolocation",
},
gyroscope: {
state: "denied",
name: "sensors",
},
magnetometer: {
state: "denied",
name: "sensors",
},
microphone: {
state: "denied",
name: "audio_capture",
},
midi: {
state: "denied",
name: "midi",
},
notifications: {
state: "denied",
name: "notifications",
},
push: {
state: "denied",
name: "push",
},
};
}
function getUserAgentBrowser() {
if (/Firefox/i.test($userAgent)) {
return "Gecko/20100101 Firefox/1000.0"; // Firefox
}
if (/Chrome/i.test($userAgent)) {
return "AppleWebKit/1000.00 (KHTML, like Gecko) Chrome/1000.00 Safari/1000.00"; // Chrome
}
if (/Safari/i.test($userAgent)) {
return "AppleWebKit/1000.00 (KHTML, like Gecko) Version/1000.00 Safari/1000.00"; // Safari
}
}
/**
* @param {Platform} platform
*/
function makeUserAgent(platform) {
const userAgent = ["Mozilla/5.0"];
switch (platform.toLowerCase()) {
case "android": {
userAgent.push("(Linux; Android 1000)");
break;
}
case "ios": {
userAgent.push("(iPhone; CPU iPhone OS 1000_0 like Mac OS X)");
break;
}
case "linux": {
userAgent.push("(X11; Linux x86_64)");
break;
}
case "mac":
case "macintosh": {
userAgent.push("(Macintosh; Intel Mac OS X 10_15_7)");
break;
}
case "win":
case "windows": {
userAgent.push("(Windows NT 10.0; Win64; x64)");
break;
}
default: {
userAgent.push(platform);
}
}
if (userAgentBrowser) {
userAgent.push(userAgentBrowser);
}
return userAgent.join(" ");
}
/**
* @param {string} fnName
*/
function throwNotImplemented(fnName) {
return function notImplemented() {
throw new HootError(`unmocked navigator method: ${fnName}`);
};
}
/** @type {Set<MockPermissionStatus>} */
const permissionStatuses = new Set();
const userAgentBrowser = getUserAgentBrowser();
const mockValues = getMockValues();
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export class MockClipboard {
/** @type {unknown} */
_value = null;
async read() {
return this._value;
}
async readText() {
return String(getClipboardValue(this._value, MIME_TYPE.text) ?? "");
}
async write(value) {
this._value = value;
}
async writeText(value) {
this._value = String(getClipboardValue(value, MIME_TYPE.text) ?? "");
}
}
export class MockClipboardItem extends ClipboardItem {
constructor(items) {
super(items);
setSyncValue(this, items);
}
// Added synchronous methods to enhance speed in tests
async getType(type) {
return getSyncValue(this)[type];
}
}
export class MockPermissions {
/**
* @param {PermissionDescriptor} permissionDesc
*/
async query({ name }) {
if (!(name in currentPermissions)) {
throw new TypeError(
`The provided value '${name}' is not a valid enum value of type PermissionName`
);
}
return new MockPermissionStatus(name);
}
}
export class MockPermissionStatus extends MockEventTarget {
static publicListeners = ["change"];
/** @type {typeof currentPermissions[PermissionName]} */
_permission;
/**
* @param {PermissionName} name
* @param {PermissionState} value
*/
constructor(name) {
super(...arguments);
this._permission = currentPermissions[name];
permissionStatuses.add(this);
}
get name() {
return this._permission.name;
}
get state() {
return this._permission.state;
}
}
export const currentPermissions = getPermissions();
export const mockClipboard = new MockClipboard();
export const mockPermissions = new MockPermissions();
export const mockNavigator = createMock(navigator, {
clipboard: { value: mockClipboard },
maxTouchPoints: { get: () => (globalThis.ontouchstart === undefined ? 0 : 1) },
permissions: { value: mockPermissions },
sendBeacon: { get: () => mockValues.sendBeacon },
serviceWorker: { get: () => undefined },
userAgent: { get: () => mockValues.userAgent },
vibrate: { get: () => mockValues.vibrate },
});
export function cleanupNavigator() {
permissionStatuses.clear();
$assign(currentPermissions, getPermissions());
$assign(mockValues, getMockValues());
}
/**
* @param {PermissionName} name
* @param {PermissionState} [value]
*/
export function mockPermission(name, value) {
if (!(name in currentPermissions)) {
throw new TypeError(
`The provided value '${name}' is not a valid enum value of type PermissionName`
);
}
currentPermissions[name].state = value;
for (const permissionStatus of permissionStatuses) {
if (permissionStatus.name === name) {
permissionStatus.dispatchEvent(new Event("change"));
}
}
}
/**
* @param {Navigator["sendBeacon"]} callback
*/
export function mockSendBeacon(callback) {
mockValues.sendBeacon = callback;
}
/**
* @param {Platform} platform
*/
export function mockUserAgent(platform = "linux") {
mockValues.userAgent = makeUserAgent(platform);
}
/**
* @param {Navigator["vibrate"]} callback
*/
export function mockVibrate(callback) {
mockValues.vibrate = callback;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,79 @@
/** @odoo-module */
import { MockEventTarget } from "../hoot_utils";
import { currentPermissions } from "./navigator";
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const { Event, Promise, Set } = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/** @type {Set<MockNotification>} */
const notifications = new Set();
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* Returns the list of notifications that have been created since the last call
* to this function, consuming it in the process.
*
* @returns {MockNotification[]}
*/
export function flushNotifications() {
const result = [...notifications];
notifications.clear();
return result;
}
export class MockNotification extends MockEventTarget {
static publicListeners = ["click", "close", "error", "show"];
/** @type {NotificationPermission} */
static get permission() {
return currentPermissions.notifications.state;
}
/** @type {NotificationPermission} */
get permission() {
return this.constructor.permission;
}
/**
* @param {string} title
* @param {NotificationOptions} [options]
*/
constructor(title, options) {
super(...arguments);
this.title = title;
this.options = options;
if (this.permission === "granted") {
notifications.push(this);
}
}
static requestPermission() {
return Promise.resolve(this.permission);
}
click() {
this.dispatchEvent(new Event("click"));
}
close() {
notifications.delete(this);
this.dispatchEvent(new Event("close"));
}
show() {
this.dispatchEvent(new Event("show"));
}
}

View file

@ -0,0 +1,54 @@
/** @odoo-module */
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Object: { keys: $keys },
StorageEvent,
String,
} = globalThis;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export class MockStorage {
get length() {
return $keys(this).length;
}
/** @type {typeof Storage.prototype.clear} */
clear() {
for (const key in this) {
delete this[key];
}
}
/** @type {typeof Storage.prototype.getItem} */
getItem(key) {
key = String(key);
return this[key] ?? null;
}
/** @type {typeof Storage.prototype.key} */
key(index) {
return $keys(this).at(index);
}
/** @type {typeof Storage.prototype.removeItem} */
removeItem(key) {
key = String(key);
delete this[key];
window.dispatchEvent(new StorageEvent("storage", { key, newValue: null }));
}
/** @type {typeof Storage.prototype.setItem} */
setItem(key, value) {
key = String(key);
value = String(value);
this[key] = value;
window.dispatchEvent(new StorageEvent("storage", { key, newValue: value }));
}
}

View file

@ -0,0 +1,48 @@
/** @odoo-module */
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const { Blob, TextEncoder } = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
const syncValues = new WeakMap();
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {any} object
*/
export function getSyncValue(object) {
return syncValues.get(object);
}
/**
* @param {any} object
* @param {any} value
*/
export function setSyncValue(object, value) {
syncValues.set(object, value);
}
export class MockBlob extends Blob {
constructor(blobParts, options) {
super(blobParts, options);
setSyncValue(this, blobParts);
}
async arrayBuffer() {
return new TextEncoder().encode(getSyncValue(this));
}
async text() {
return getSyncValue(this).join("");
}
}

View file

@ -0,0 +1,710 @@
/** @odoo-module */
import { EventBus } from "@odoo/owl";
import { getCurrentDimensions, getDocument, getWindow } from "@web/../lib/hoot-dom/helpers/dom";
import {
mockedCancelAnimationFrame,
mockedClearInterval,
mockedClearTimeout,
mockedRequestAnimationFrame,
mockedSetInterval,
mockedSetTimeout,
} from "@web/../lib/hoot-dom/helpers/time";
import { interactor } from "../../hoot-dom/hoot_dom_utils";
import { MockEventTarget, strictEqual, waitForDocument } from "../hoot_utils";
import { getRunner } from "../main_runner";
import {
MockAnimation,
mockedAnimate,
mockedScroll,
mockedScrollBy,
mockedScrollIntoView,
mockedScrollTo,
mockedWindowScroll,
mockedWindowScrollBy,
mockedWindowScrollTo,
} from "./animation";
import { MockConsole } from "./console";
import { MockDate, MockIntl } from "./date";
import { MockClipboardItem, mockNavigator } from "./navigator";
import {
MockBroadcastChannel,
MockMessageChannel,
MockMessagePort,
MockRequest,
MockResponse,
MockSharedWorker,
MockURL,
MockWebSocket,
MockWorker,
MockXMLHttpRequest,
MockXMLHttpRequestUpload,
mockCookie,
mockHistory,
mockLocation,
mockedFetch,
} from "./network";
import { MockNotification } from "./notification";
import { MockStorage } from "./storage";
import { MockBlob } from "./sync_values";
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
EventTarget,
HTMLAnchorElement,
MutationObserver,
Number: { isNaN: $isNaN, parseFloat: $parseFloat },
Object: {
assign: $assign,
defineProperties: $defineProperties,
entries: $entries,
getOwnPropertyDescriptor: $getOwnPropertyDescriptor,
getPrototypeOf: $getPrototypeOf,
keys: $keys,
hasOwn: $hasOwn,
},
Reflect: { ownKeys: $ownKeys },
Set,
WeakMap,
} = globalThis;
const { addEventListener, removeEventListener } = EventTarget.prototype;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {unknown} target
* @param {Record<string, PropertyDescriptor>} descriptors
*/
function applyPropertyDescriptors(target, descriptors) {
if (!originalDescriptors.has(target)) {
originalDescriptors.set(target, {});
}
const targetDescriptors = originalDescriptors.get(target);
const ownerDecriptors = new Map();
for (const [property, rawDescriptor] of $entries(descriptors)) {
const owner = findPropertyOwner(target, property);
targetDescriptors[property] = $getOwnPropertyDescriptor(owner, property);
const descriptor = { ...rawDescriptor };
if ("value" in descriptor) {
descriptor.writable = false;
}
if (!ownerDecriptors.has(owner)) {
ownerDecriptors.set(owner, {});
}
const nextDescriptors = ownerDecriptors.get(owner);
nextDescriptors[property] = descriptor;
}
for (const [owner, nextDescriptors] of ownerDecriptors) {
$defineProperties(owner, nextDescriptors);
}
}
/**
* @param {string[]} [changedKeys]
*/
function callMediaQueryChanges(changedKeys) {
for (const mediaQueryList of mediaQueryLists) {
if (!changedKeys || changedKeys.some((key) => mediaQueryList.media.includes(key))) {
const event = new MediaQueryListEvent("change", {
matches: mediaQueryList.matches,
media: mediaQueryList.media,
});
mediaQueryList.dispatchEvent(event);
}
}
}
/**
* @template T
* @param {T} target
* @param {keyof T} property
*/
function findOriginalDescriptor(target, property) {
if (originalDescriptors.has(target)) {
const descriptors = originalDescriptors.get(target);
if (descriptors && property in descriptors) {
return descriptors[property];
}
}
return null;
}
/**
* @param {unknown} object
* @param {string} property
* @returns {unknown}
*/
function findPropertyOwner(object, property) {
if ($hasOwn(object, property)) {
return object;
}
const prototype = $getPrototypeOf(object);
if (prototype) {
return findPropertyOwner(prototype, property);
}
return object;
}
/**
* @param {unknown} object
*/
function getTouchDescriptors(object) {
const descriptors = {};
const toDelete = [];
for (const eventName of TOUCH_EVENTS) {
const fnName = `on${eventName}`;
if (fnName in object) {
const owner = findPropertyOwner(object, fnName);
descriptors[fnName] = $getOwnPropertyDescriptor(owner, fnName);
} else {
toDelete.push(fnName);
}
}
/** @type {({ descriptors?: Record<string, PropertyDescriptor>; toDelete?: string[]})} */
const result = {};
if ($keys(descriptors).length) {
result.descriptors = descriptors;
}
if (toDelete.length) {
result.toDelete = toDelete;
}
return result;
}
/**
* @param {typeof globalThis} view
*/
function getTouchTargets(view) {
return [view, view.Document.prototype];
}
/**
* @param {typeof globalThis} view
*/
function getWatchedEventTargets(view) {
return [
view,
view.document,
// Permanent DOM elements
view.HTMLDocument.prototype,
view.HTMLBodyElement.prototype,
view.HTMLHeadElement.prototype,
view.HTMLHtmlElement.prototype,
// Other event targets
EventBus.prototype,
MockEventTarget.prototype,
];
}
/**
* @param {string} type
* @returns {PropertyDescriptor}
*/
function makeEventDescriptor(type) {
let callback = null;
return {
enumerable: true,
configurable: true,
get() {
return callback;
},
set(value) {
if (callback === value) {
return;
}
if (typeof callback === "function") {
this.removeEventListener(type, callback);
}
callback = value;
if (typeof callback === "function") {
this.addEventListener(type, callback);
}
},
};
}
/**
* @param {string} mediaQueryString
*/
function matchesQueryPart(mediaQueryString) {
const [, key, value] = mediaQueryString.match(R_MEDIA_QUERY_PROPERTY) || [];
let match = false;
if (mockMediaValues[key]) {
match = strictEqual(value, mockMediaValues[key]);
} else if (key) {
switch (key) {
case "max-height": {
match = getCurrentDimensions().height <= $parseFloat(value);
break;
}
case "max-width": {
match = getCurrentDimensions().width <= $parseFloat(value);
break;
}
case "min-height": {
match = getCurrentDimensions().height >= $parseFloat(value);
break;
}
case "min-width": {
match = getCurrentDimensions().width >= $parseFloat(value);
break;
}
case "orientation": {
const { width, height } = getCurrentDimensions();
match = value === "landscape" ? width > height : width < height;
break;
}
}
}
return mediaQueryString.startsWith("not") ? !match : match;
}
/** @type {addEventListener} */
function mockedAddEventListener(...args) {
const runner = getRunner();
if (runner.dry || !runner.suiteStack.length) {
// Ignore listeners during dry run or outside of a test suite
return;
}
if (!R_OWL_SYNTHETIC_LISTENER.test(String(args[1]))) {
// Ignore cleanup for Owl synthetic listeners
runner.after(removeEventListener.bind(this, ...args));
}
return addEventListener.call(this, ...args);
}
/** @type {Document["elementFromPoint"]} */
function mockedElementFromPoint(...args) {
return mockedElementsFromPoint.call(this, ...args)[0];
}
/**
* Mocked version of {@link document.elementsFromPoint} to:
* - remove "HOOT-..." elements from the result
* - put the <body> & <html> elements at the end of the list, as they may be ordered
* incorrectly due to the fixture being behind the body.
* @type {Document["elementsFromPoint"]}
*/
function mockedElementsFromPoint(...args) {
const { value: elementsFromPoint } = findOriginalDescriptor(this, "elementsFromPoint");
const result = [];
let hasDocumentElement = false;
let hasBody = false;
for (const element of elementsFromPoint.call(this, ...args)) {
if (element.tagName.startsWith("HOOT")) {
continue;
}
if (element === this.body) {
hasBody = true;
} else if (element === this.documentElement) {
hasDocumentElement = true;
} else {
result.push(element);
}
}
if (hasBody) {
result.push(this.body);
}
if (hasDocumentElement) {
result.push(this.documentElement);
}
return result;
}
function mockedHref() {
return this.hasAttribute("href") ? new MockURL(this.getAttribute("href")).href : "";
}
/** @type {typeof matchMedia} */
function mockedMatchMedia(mediaQueryString) {
return new MockMediaQueryList(mediaQueryString);
}
/** @type {typeof removeEventListener} */
function mockedRemoveEventListener(...args) {
if (getRunner().dry) {
// Ignore listeners during dry run
return;
}
return removeEventListener.call(this, ...args);
}
/**
* @param {MutationRecord[]} mutations
*/
function observeAddedNodes(mutations) {
const runner = getRunner();
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (runner.dry) {
node.remove();
} else {
runner.after(node.remove.bind(node));
}
}
}
}
/**
* @param {PointerEvent} ev
*/
function onAnchorHrefClick(ev) {
if (ev.defaultPrevented) {
return;
}
const href = ev.target.closest("a[href]")?.href;
if (!href) {
return;
}
ev.preventDefault();
// Assign href to mock location instead of actual location
mockLocation.href = href;
const [, hash] = href.split("#");
if (hash) {
// Scroll to the target element if the href is/has a hash
getDocument().getElementById(hash)?.scrollIntoView();
}
}
function onWindowResize() {
callMediaQueryChanges();
}
/**
* @param {typeof globalThis} view
*/
function restoreTouch(view) {
const touchObjects = getTouchTargets(view);
for (let i = 0; i < touchObjects.length; i++) {
const object = touchObjects[i];
const { descriptors, toDelete } = originalTouchFunctions[i];
if (descriptors) {
$defineProperties(object, descriptors);
}
if (toDelete) {
for (const fnName of toDelete) {
delete object[fnName];
}
}
}
}
class MockMediaQueryList extends MockEventTarget {
static publicListeners = ["change"];
get matches() {
return this.media
.split(R_COMMA)
.some((orPart) => orPart.split(R_AND).every(matchesQueryPart));
}
/**
* @param {string} mediaQueryString
*/
constructor(mediaQueryString) {
super(...arguments);
this.media = mediaQueryString.trim().toLowerCase();
mediaQueryLists.add(this);
}
}
const DEFAULT_MEDIA_VALUES = {
"display-mode": "browser",
pointer: "fine",
"prefers-color-scheme": "light",
"prefers-reduced-motion": "reduce",
};
const TOUCH_EVENTS = ["touchcancel", "touchend", "touchmove", "touchstart"];
const R_AND = /\s*\band\b\s*/;
const R_COMMA = /\s*,\s*/;
const R_MEDIA_QUERY_PROPERTY = /\(\s*([\w-]+)\s*:\s*(.+)\s*\)/;
const R_OWL_SYNTHETIC_LISTENER = /\bnativeToSyntheticEvent\b/;
/** @type {WeakMap<unknown, Record<string, PropertyDescriptor>>} */
const originalDescriptors = new WeakMap();
const originalTouchFunctions = getTouchTargets(globalThis).map(getTouchDescriptors);
/** @type {Set<MockMediaQueryList>} */
const mediaQueryLists = new Set();
const mockConsole = new MockConsole();
const mockLocalStorage = new MockStorage();
const mockMediaValues = { ...DEFAULT_MEDIA_VALUES };
const mockSessionStorage = new MockStorage();
let mockTitle = "";
// Mock descriptors
const ANCHOR_MOCK_DESCRIPTORS = {
href: {
...$getOwnPropertyDescriptor(HTMLAnchorElement.prototype, "href"),
get: mockedHref,
},
};
const DOCUMENT_MOCK_DESCRIPTORS = {
cookie: {
get: () => mockCookie.get(),
set: (value) => mockCookie.set(value),
},
elementFromPoint: { value: mockedElementFromPoint },
elementsFromPoint: { value: mockedElementsFromPoint },
title: {
get: () => mockTitle,
set: (value) => (mockTitle = value),
},
};
const ELEMENT_MOCK_DESCRIPTORS = {
animate: { value: mockedAnimate },
scroll: { value: mockedScroll },
scrollBy: { value: mockedScrollBy },
scrollIntoView: { value: mockedScrollIntoView },
scrollTo: { value: mockedScrollTo },
};
const WINDOW_MOCK_DESCRIPTORS = {
Animation: { value: MockAnimation },
Blob: { value: MockBlob },
BroadcastChannel: { value: MockBroadcastChannel },
cancelAnimationFrame: { value: mockedCancelAnimationFrame, writable: false },
clearInterval: { value: mockedClearInterval, writable: false },
clearTimeout: { value: mockedClearTimeout, writable: false },
ClipboardItem: { value: MockClipboardItem },
console: { value: mockConsole, writable: false },
Date: { value: MockDate, writable: false },
fetch: { value: interactor("server", mockedFetch).as("fetch"), writable: false },
history: { value: mockHistory },
innerHeight: { get: () => getCurrentDimensions().height },
innerWidth: { get: () => getCurrentDimensions().width },
Intl: { value: MockIntl },
localStorage: { value: mockLocalStorage, writable: false },
matchMedia: { value: mockedMatchMedia },
MessageChannel: { value: MockMessageChannel },
MessagePort: { value: MockMessagePort },
navigator: { value: mockNavigator },
Notification: { value: MockNotification },
outerHeight: { get: () => getCurrentDimensions().height },
outerWidth: { get: () => getCurrentDimensions().width },
Request: { value: MockRequest, writable: false },
requestAnimationFrame: { value: mockedRequestAnimationFrame, writable: false },
Response: { value: MockResponse, writable: false },
scroll: { value: mockedWindowScroll },
scrollBy: { value: mockedWindowScrollBy },
scrollTo: { value: mockedWindowScrollTo },
sessionStorage: { value: mockSessionStorage, writable: false },
setInterval: { value: mockedSetInterval, writable: false },
setTimeout: { value: mockedSetTimeout, writable: false },
SharedWorker: { value: MockSharedWorker },
URL: { value: MockURL },
WebSocket: { value: MockWebSocket },
Worker: { value: MockWorker },
XMLHttpRequest: { value: MockXMLHttpRequest },
XMLHttpRequestUpload: { value: MockXMLHttpRequestUpload },
};
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export function cleanupWindow() {
const view = getWindow();
// Storages
mockLocalStorage.clear();
mockSessionStorage.clear();
// Media
mediaQueryLists.clear();
$assign(mockMediaValues, DEFAULT_MEDIA_VALUES);
// Title
mockTitle = "";
// Listeners
view.removeEventListener("click", onAnchorHrefClick);
view.removeEventListener("resize", onWindowResize);
// Head & body attributes
const { head, body } = view.document;
for (const { name } of head.attributes) {
head.removeAttribute(name);
}
for (const { name } of body.attributes) {
body.removeAttribute(name);
}
// Touch
restoreTouch(view);
}
export function getTitle() {
const doc = getDocument();
const titleDescriptor = findOriginalDescriptor(doc, "title");
if (titleDescriptor) {
return titleDescriptor.get.call(doc);
} else {
return doc.title;
}
}
export function getViewPortHeight() {
const view = getWindow();
const heightDescriptor = findOriginalDescriptor(view, "innerHeight");
if (heightDescriptor) {
return heightDescriptor.get.call(view);
} else {
return view.innerHeight;
}
}
export function getViewPortWidth() {
const view = getWindow();
const titleDescriptor = findOriginalDescriptor(view, "innerWidth");
if (titleDescriptor) {
return titleDescriptor.get.call(view);
} else {
return view.innerWidth;
}
}
/**
* @param {Record<string, string>} name
*/
export function mockMatchMedia(values) {
$assign(mockMediaValues, values);
callMediaQueryChanges($keys(values));
}
/**
* @param {boolean} setTouch
*/
export function mockTouch(setTouch) {
const objects = getTouchTargets(getWindow());
if (setTouch) {
for (const object of objects) {
const descriptors = {};
for (const eventName of TOUCH_EVENTS) {
const fnName = `on${eventName}`;
if (!$hasOwn(object, fnName)) {
descriptors[fnName] = makeEventDescriptor(eventName);
}
}
$defineProperties(object, descriptors);
}
mockMatchMedia({ pointer: "coarse" });
} else {
for (const object of objects) {
for (const eventName of TOUCH_EVENTS) {
delete object[`on${eventName}`];
}
}
mockMatchMedia({ pointer: "fine" });
}
}
/**
* @param {typeof globalThis} [view=getWindow()]
*/
export function patchWindow(view = getWindow()) {
// Window (doesn't need to be ready)
applyPropertyDescriptors(view, WINDOW_MOCK_DESCRIPTORS);
waitForDocument(view.document).then(() => {
// Document
applyPropertyDescriptors(view.document, DOCUMENT_MOCK_DESCRIPTORS);
// Element prototypes
applyPropertyDescriptors(view.Element.prototype, ELEMENT_MOCK_DESCRIPTORS);
applyPropertyDescriptors(view.HTMLAnchorElement.prototype, ANCHOR_MOCK_DESCRIPTORS);
});
}
/**
* @param {string} value
*/
export function setTitle(value) {
const doc = getDocument();
const titleDescriptor = findOriginalDescriptor(doc, "title");
if (titleDescriptor) {
titleDescriptor.set.call(doc, value);
} else {
doc.title = value;
}
}
export function setupWindow() {
const view = getWindow();
// Listeners
view.addEventListener("click", onAnchorHrefClick);
view.addEventListener("resize", onWindowResize);
}
/**
* @param {typeof globalThis} [view=getWindow()]
*/
export function watchAddedNodes(view = getWindow()) {
const observer = new MutationObserver(observeAddedNodes);
observer.observe(view.document.head, { childList: true });
return function unwatchAddedNodes() {
observer.disconnect();
};
}
/**
* @param {typeof globalThis} [view=getWindow()]
*/
export function watchListeners(view = getWindow()) {
const targets = getWatchedEventTargets(view);
for (const target of targets) {
target.addEventListener = mockedAddEventListener;
target.removeEventListener = mockedRemoveEventListener;
}
return function unwatchAllListeners() {
for (const target of targets) {
target.addEventListener = addEventListener;
target.removeEventListener = removeEventListener;
}
};
}
/**
* Returns a function checking that the given target does not contain any unexpected
* key. The list of accepted keys is the initial list of keys of the target, along
* with an optional `whiteList` argument.
*
* @template T
* @param {T} target
* @param {string[]} [whiteList]
* @example
* afterEach(watchKeys(window, ["odoo"]));
*/
export function watchKeys(target, whiteList) {
const acceptedKeys = new Set([...$ownKeys(target), ...(whiteList || [])]);
return function checkKeys() {
const keysDiff = $ownKeys(target).filter(
(key) => $isNaN($parseFloat(key)) && !acceptedKeys.has(key)
);
for (const key of keysDiff) {
const descriptor = $getOwnPropertyDescriptor(target, key);
if (descriptor.configurable) {
delete target[key];
} else if (descriptor.writable) {
target[key] = undefined;
}
}
};
}

View file

@ -0,0 +1,624 @@
/** @odoo-module */
import { describe, expect, makeExpect, test } from "@odoo/hoot";
import { check, manuallyDispatchProgrammaticEvent, tick, waitFor } from "@odoo/hoot-dom";
import { Component, xml } from "@odoo/owl";
import { mountForTest, parseUrl } from "../local_helpers";
import { Test } from "../../core/test";
import { makeLabel } from "../../hoot_utils";
describe(parseUrl(import.meta.url), () => {
test("makeExpect passing, without a test", () => {
const [customExpect, hooks] = makeExpect({ headless: true });
expect(() => customExpect(true).toBe(true)).toThrow(
"cannot call `expect()` outside of a test"
);
hooks.before();
customExpect({ key: true }).toEqual({ key: true });
customExpect("oui").toBe("oui");
const results = hooks.after();
expect(results.pass).toBe(true);
expect(results.events).toHaveLength(2);
});
test("makeExpect failing, without a test", () => {
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
customExpect({ key: true }).toEqual({ key: true });
customExpect("oui").toBe("non");
const results = hooks.after();
expect(results.pass).toBe(false);
expect(results.events).toHaveLength(2);
});
test("makeExpect with a test", async () => {
const [customExpect, hooks] = makeExpect({ headless: true });
const customTest = new Test(null, "test", {});
customTest.setRunFn(() => {
customExpect({ key: true }).toEqual({ key: true });
customExpect("oui").toBe("non");
});
hooks.before(customTest);
await customTest.run();
const results = hooks.after();
expect(customTest.lastResults).toBe(results);
// Result is expected to have the same shape, no need for other assertions
});
test("makeExpect with a test flagged with TODO", async () => {
const [customExpect, hooks] = makeExpect({ headless: true });
const customTest = new Test(null, "test", { todo: true });
customTest.setRunFn(() => {
customExpect(1).toBe(1);
});
hooks.before(customTest);
await customTest.run();
const results = hooks.after();
expect(results.pass).toBe(false);
expect(results.events[0].pass).toBe(true);
});
test("makeExpect with no assertions & query events", async () => {
await mountForTest(/* xml */ `<div>ABC</div>`);
const [, hooks] = makeExpect({ headless: true });
hooks.before();
await waitFor("div:contains(ABC)");
const results = hooks.after();
expect(results.pass).toBe(true);
expect(results.events).toHaveLength(1);
expect(results.events[0].label).toBe("waitFor");
});
test("makeExpect with no assertions & no query events", () => {
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
expect(() => customExpect.assertions(0)).toThrow(
"expected assertions count should be more than 1"
);
const results = hooks.after();
expect(results.pass).toBe(false);
expect(results.events).toHaveLength(1);
expect(results.events[0].message).toEqual([
"expected at least",
["1", "integer"],
"assertion or query event, but none were run",
]);
});
test("makeExpect with unconsumed matchers", () => {
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
expect(() => customExpect(true, true)).toThrow("`expect()` only accepts a single argument");
customExpect(1).toBe(1);
customExpect(true);
const results = hooks.after();
expect(results.pass).toBe(false);
expect(results.events).toHaveLength(2);
expect(results.events[1].message.join(" ")).toBe(
"called once without calling any matchers"
);
});
test("makeExpect with unverified steps", () => {
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
customExpect.step("oui");
customExpect.verifySteps(["oui"]);
customExpect.step("non");
const results = hooks.after();
expect(results.pass).toBe(false);
expect(results.events).toHaveLength(2); // 1 'verifySteps' + 1 'unverified steps'
expect(results.events.at(-1).message).toEqual(["unverified steps"]);
});
test("makeExpect retains current values", () => {
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
const object = { a: 1 };
customExpect(object).toEqual({ b: 2 });
object.b = 2;
const testResult = hooks.after();
const [assertion] = testResult.events;
expect(assertion.pass).toBe(false);
expect(assertion.failedDetails[1][1]).toEqual({ a: 1 });
expect(object).toEqual({ a: 1, b: 2 });
});
test("'expect' results contain the correct informations", async () => {
await mountForTest(/* xml */ `
<label style="color: #f00">
Checkbox
<input class="cb" type="checkbox" />
</label>
<input type="text" value="abc" />
`);
await check("input[type=checkbox]");
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
const matchers = [
// Standard
["toBe", 1, 1],
["toBeCloseTo", 1, 1],
["toBeEmpty", []],
["toBeGreaterThan", 1, 0],
["toBeInstanceOf", {}, Object],
["toBeLessThan", 0, 1],
["toBeOfType", 1, "integer"],
["toBeWithin", 1, 0, 2],
["toEqual", [], []],
["toHaveLength", [], 0],
["toInclude", [1], 1],
["toMatch", "a", "a"],
["toMatchObject", { a: 1, b: { l: [1, 2] } }, { b: { l: [1, 2] } }],
[
"toThrow",
() => {
throw new Error("");
},
],
// DOM
["toBeChecked", ".cb"],
["toBeDisplayed", ".cb"],
["toBeEnabled", ".cb"],
["toBeFocused", ".cb"],
["toBeVisible", ".cb"],
["toHaveAttribute", ".cb", "type", "checkbox"],
["toHaveClass", ".cb", "cb"],
["toHaveCount", ".cb", 1],
["toHaveInnerHTML", ".cb", ""],
["toHaveOuterHTML", ".cb", `<input class="cb" type="checkbox" />`],
["toHaveProperty", ".cb", "checked", true],
["toHaveRect", "label", { x: 0 }],
["toHaveStyle", "label", { color: "rgb(255, 0, 0)" }],
["toHaveText", "label", "Checkbox"],
["toHaveValue", "input[type=text]", "abc"],
];
for (const [name, ...args] of matchers) {
customExpect(args.shift())[name](...args);
}
const testResult = hooks.after();
expect(testResult.pass).toBe(true);
expect(testResult.events).toHaveLength(matchers.length);
expect(testResult.events.map(({ label }) => label)).toEqual(matchers.map(([name]) => name));
});
test("assertions are prevented after an error", async () => {
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
await customExpect(Promise.resolve(1)).resolves.toBe(1);
hooks.error(new Error("boom"));
customExpect(2).toBe(2);
customExpect(Promise.resolve(3)).resolves.toBe(3);
await tick();
const results = hooks.after();
expect(results.pass).toBe(false);
expect(results.events).toHaveLength(3); // toBe + error + unverified errors
});
describe("standard matchers", () => {
test("toBe", () => {
// Boolean
expect(true).toBe(true);
expect(true).not.toBe(false);
// Floats
expect(1.1).toBe(1.1);
expect(0.1 + 0.2).not.toBe(0.3); // floating point errors
// Integers
expect(+0).toBe(-0);
expect(1 + 2).toBe(3);
expect(1).not.toBe(-1);
expect(NaN).toBe(NaN);
// Strings
expect("abc").toBe("abc");
expect(new String("abc")).not.toBe(new String("abc"));
// Other primitives
expect(undefined).toBe(undefined);
expect(undefined).not.toBe(null);
// Symbols
const symbol = Symbol("symbol");
expect(symbol).toBe(symbol);
expect(symbol).not.toBe(Symbol("symbol"));
expect(Symbol.for("symbol")).toBe(Symbol.for("symbol"));
// Objects
const object = { x: 1 };
expect(object).toBe(object);
expect([]).not.toBe([]);
expect(object).not.toBe({ x: 1 });
// Dates
const date = new Date(0);
expect(date).toBe(date);
expect(new Date(0)).not.toBe(new Date(0));
// Nodes
expect(new Image()).not.toBe(new Image());
expect(document.createElement("div")).not.toBe(document.createElement("div"));
});
test("toBeCloseTo", () => {
expect(0.2 + 0.1).toBeCloseTo(0.3);
expect(0.2 + 0.1).toBeCloseTo(0.3, { margin: Number.EPSILON });
expect(0.2 + 0.1).not.toBeCloseTo(0.3, { margin: 1e-17 });
expect(3.51).toBeCloseTo(3);
expect(3.51).toBeCloseTo(3.52, { margin: 2e-2 });
expect(3.502).not.toBeCloseTo(3.503, { margin: 1e-3 });
expect(3).toBeCloseTo(4 - 1e-15);
expect(3 + 1e-15).toBeCloseTo(4);
expect(3).not.toBeCloseTo(4);
});
test("toEqual", () => {
// Boolean
expect(true).toEqual(true);
expect(true).not.toEqual(false);
// Floats
expect(1.1).toEqual(1.1);
expect(0.1 + 0.2).not.toEqual(0.3); // floating point errors
// Integers
expect(+0).toEqual(-0);
expect(1 + 2).toEqual(3);
expect(1).not.toEqual(-1);
expect(NaN).toEqual(NaN);
// Strings
expect("abc").toEqual("abc");
expect(new String("abc")).toEqual(new String("abc"));
// Other primitives
expect(undefined).toEqual(undefined);
expect(undefined).not.toEqual(null);
// Symbols
const symbol = Symbol("symbol");
expect(symbol).toEqual(symbol);
expect(symbol).not.toEqual(Symbol("symbol"));
expect(Symbol.for("symbol")).toEqual(Symbol.for("symbol"));
// Objects
const object = { x: 1 };
expect(object).toEqual(object);
expect([]).toEqual([]);
expect(object).toEqual({ x: 1 });
// Iterables
expect(new Set([1, 4, 6])).toEqual(new Set([1, 4, 6]));
expect(new Set([1, 4, 6])).not.toEqual([1, 4, 6]);
expect(new Map([[{}, "abc"]])).toEqual(new Map([[{}, "abc"]]));
// Dates
const date = new Date(0);
expect(date).toEqual(date);
expect(new Date(0)).toEqual(new Date(0));
// Nodes
expect(new Image()).toEqual(new Image());
expect(document.createElement("div")).toEqual(document.createElement("div"));
expect(document.createElement("div")).not.toEqual(document.createElement("span"));
});
test("toMatch", () => {
class Exception extends Error {}
expect("aaaa").toMatch(/^a{4}$/);
expect("aaaa").toMatch("aa");
expect("aaaa").not.toMatch("aaaaa");
// Matcher from a class
expect(new Exception("oui")).toMatch(Error);
expect(new Exception("oui")).toMatch(Exception);
expect(new Exception("oui")).toMatch(new Error("oui"));
});
test("toMatchObject", () => {
expect({
bath: true,
bedrooms: 4,
kitchen: {
amenities: ["oven", "stove", "washer"],
area: 20,
wallColor: "white",
},
}).toMatchObject({
bath: true,
kitchen: {
amenities: ["oven", "stove", "washer"],
wallColor: "white",
},
});
expect([{ tralalero: "tralala" }, { foo: 1 }]).toMatchObject([
{ tralalero: "tralala" },
{ foo: 1 },
]);
expect([{ tralalero: "tralala" }, { foo: 1, lirili: "larila" }]).toMatchObject([
{ tralalero: "tralala" },
{ foo: 1 },
]);
});
test("toThrow", async () => {
const asyncBoom = async () => {
throw new Error("rejection");
};
const boom = () => {
throw new Error("error");
};
expect(boom).toThrow();
expect(boom).toThrow("error");
expect(boom).toThrow(new Error("error"));
await expect(asyncBoom()).rejects.toThrow();
await expect(asyncBoom()).rejects.toThrow("rejection");
await expect(asyncBoom()).rejects.toThrow(new Error("rejection"));
});
test("verifyErrors", async () => {
expect.assertions(1);
expect.errors(3);
const boom = (msg) => {
throw new Error(msg);
};
// Timeout
setTimeout(() => boom("timeout"));
// Promise
queueMicrotask(() => boom("promise"));
// Event
manuallyDispatchProgrammaticEvent(window, "error", { message: "event" });
await tick();
expect.verifyErrors(["event", "promise", "timeout"]);
});
test("verifySteps", () => {
expect.assertions(4);
expect.verifySteps([]);
expect.step("abc");
expect.step("def");
expect.verifySteps(["abc", "def"]);
expect.step({ property: "foo" });
expect.step("ghi");
expect.verifySteps([{ property: "foo" }, "ghi"]);
expect.verifySteps([]);
});
});
describe("DOM matchers", () => {
test("toBeChecked", async () => {
await mountForTest(/* xml */ `
<input type="checkbox" />
<input type="checkbox" checked="" />
`);
expect("input:first").not.toBeChecked();
expect("input:last").toBeChecked();
});
test("toHaveAttribute", async () => {
await mountForTest(/* xml */ `
<input type="number" disabled="" />
`);
expect("input").toHaveAttribute("disabled");
expect("input").not.toHaveAttribute("readonly");
expect("input").toHaveAttribute("type", "number");
});
test("toHaveCount", async () => {
await mountForTest(/* xml */ `
<ul>
<li>milk</li>
<li>eggs</li>
<li>milk</li>
</ul>
`);
expect("iframe").toHaveCount(0);
expect("iframe").not.toHaveCount();
expect("ul").toHaveCount(1);
expect("ul").toHaveCount();
expect("li").toHaveCount(3);
expect("li").toHaveCount();
expect("li:contains(milk)").toHaveCount(2);
});
test("toHaveProperty", async () => {
await mountForTest(/* xml */ `
<input type="search" readonly="" />
`);
expect("input").toHaveProperty("type", "search");
expect("input").not.toHaveProperty("readonly");
expect("input").toHaveProperty("readOnly", true);
});
test("toHaveText", async () => {
class TextComponent extends Component {
static props = {};
static template = xml`
<div class="with">With<t t-esc="nbsp" />nbsp</div>
<div class="without">Without nbsp</div>
`;
nbsp = "\u00a0";
}
await mountForTest(TextComponent);
expect(".with").toHaveText("With nbsp");
expect(".with").toHaveText("With\u00a0nbsp", { raw: true });
expect(".with").not.toHaveText("With\u00a0nbsp");
expect(".without").toHaveText("Without nbsp");
expect(".without").not.toHaveText("Without\u00a0nbsp");
expect(".without").not.toHaveText("Without\u00a0nbsp", { raw: true });
});
test("toHaveInnerHTML", async () => {
await mountForTest(/* xml */ `
<div class="parent">
<p>
abc<strong>def</strong>ghi
<br />
<input type="text" />
</p>
</div>
`);
expect(".parent").toHaveInnerHTML(/* xml */ `
<p>abc<strong>def</strong>ghi<br><input type="text"></p>
`);
});
test("toHaveOuterHTML", async () => {
await mountForTest(/* xml */ `
<div class="parent">
<p>
abc<strong>def</strong>ghi
<br />
<input type="text" />
</p>
</div>
`);
expect(".parent").toHaveOuterHTML(/* xml */ `
<div class="parent">
<p>abc<strong>def</strong>ghi<br><input type="text"></p>
</div>
`);
});
test("toHaveStyle", async () => {
const documentFontSize = parseFloat(
getComputedStyle(document.documentElement).fontSize
);
await mountForTest(/* xml */ `
<div class="div" style="width: 3rem; height: 26px" />
`);
expect(".div").toHaveStyle({ width: `${3 * documentFontSize}px`, height: 26 });
expect(".div").toHaveStyle({ display: "block" });
expect(".div").toHaveStyle("border-top");
expect(".div").not.toHaveStyle({ height: 50 });
expect(".div").toHaveStyle("height: 26px ; width : 3rem", { inline: true });
expect(".div").not.toHaveStyle({ display: "block" }, { inline: true });
expect(".div").not.toHaveStyle("border-top", { inline: true });
});
test("no elements found messages", async () => {
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
await mountForTest(/* xml */ `
<div />
`);
const SELECTOR = "#brrbrrpatapim";
const DOM_MATCHERS = [
["toBeChecked"],
["toBeDisplayed"],
["toBeEnabled"],
["toBeFocused"],
["toBeVisible"],
["toHaveAttribute", "attr"],
["toHaveClass", "cls"],
["toHaveInnerHTML", "<html></html>"],
["toHaveOuterHTML", "<html></html>"],
["toHaveProperty", "prop"],
["toHaveRect", {}],
["toHaveStyle", {}],
["toHaveText", "abc"],
["toHaveValue", "value"],
];
for (const [matcher, arg] of DOM_MATCHERS) {
customExpect(SELECTOR)[matcher](arg);
}
const results = hooks.after();
const assertions = results.getEvents("assertion");
for (let i = 0; i < DOM_MATCHERS.length; i++) {
const { label, message } = assertions[i];
expect.step(label);
expect(message).toEqual([
"expected at least",
makeLabel(1),
"element and got",
makeLabel(0),
"elements matching",
makeLabel(SELECTOR),
]);
}
expect.verifySteps(DOM_MATCHERS.map(([matcher]) => matcher));
});
});
});

View file

@ -0,0 +1,118 @@
/** @odoo-module */
import { after, defineTags, describe, expect, test } from "@odoo/hoot";
import { parseUrl } from "../local_helpers";
import { Runner } from "../../core/runner";
import { Suite } from "../../core/suite";
import { undefineTags } from "../../core/tag";
const makeTestRunner = () => {
const runner = new Runner();
after(() => undefineTags(runner.tags.keys()));
return runner;
};
describe(parseUrl(import.meta.url), () => {
test("can register suites", () => {
const runner = makeTestRunner();
runner.describe("a suite", () => {});
runner.describe("another suite", () => {});
expect(runner.suites).toHaveLength(2);
expect(runner.tests).toHaveLength(0);
for (const suite of runner.suites.values()) {
expect(suite).toMatch(Suite);
}
});
test("can register nested suites", () => {
const runner = makeTestRunner();
runner.describe(["a", "b", "c"], () => {});
expect([...runner.suites.values()].map((s) => s.name)).toEqual(["a", "b", "c"]);
});
test("can register tests", () => {
const runner = makeTestRunner();
runner.describe("suite 1", () => {
runner.test("test 1", () => {});
});
runner.describe("suite 2", () => {
runner.test("test 2", () => {});
runner.test("test 3", () => {});
});
expect(runner.suites).toHaveLength(2);
expect(runner.tests).toHaveLength(3);
});
test("should not have duplicate suites", () => {
const runner = makeTestRunner();
runner.describe(["parent", "child a"], () => {});
runner.describe(["parent", "child b"], () => {});
expect([...runner.suites.values()].map((suite) => suite.name)).toEqual([
"parent",
"child a",
"child b",
]);
});
test("can refuse standalone tests", async () => {
const runner = makeTestRunner();
expect(() =>
runner.test([], "standalone test", () => {
expect(true).toBe(false);
})
).toThrow();
});
test("can register test tags", async () => {
const runner = makeTestRunner();
runner.describe("suite", () => {
for (let i = 1; i <= 10; i++) {
// 10
runner.test.tags(`Tag-${i}`);
}
runner.test("tagged test", () => {});
});
expect(runner.tags).toHaveLength(10);
expect(runner.tests.values().next().value.tags).toHaveLength(10);
});
test("can define exclusive test tags", async () => {
expect.assertions(3);
defineTags(
{
name: "a",
exclude: ["b"],
},
{
name: "b",
exclude: ["a"],
}
);
const runner = makeTestRunner();
runner.describe("suite", () => {
runner.test.tags("a");
runner.test("first test", () => {});
runner.test.tags("b");
runner.test("second test", () => {});
runner.test.tags("a", "b");
expect(() => runner.test("third test", () => {})).toThrow(`cannot apply tag "b"`);
runner.test.tags("a", "c");
runner.test("fourth test", () => {});
});
expect(runner.tests).toHaveLength(3);
expect(runner.tags).toHaveLength(3);
});
});

View file

@ -0,0 +1,29 @@
/** @odoo-module */
import { describe, expect, test } from "@odoo/hoot";
import { parseUrl } from "../local_helpers";
import { Suite } from "../../core/suite";
describe(parseUrl(import.meta.url), () => {
test("should have a hashed id", () => {
expect(new Suite(null, "a suite", []).id).toMatch(/^\w{8}$/);
});
test("should describe its path in its name", () => {
const a = new Suite(null, "a", []);
const b = new Suite(a, "b", []);
const c = new Suite(a, "c", []);
const d = new Suite(b, "d", []);
expect(a.parent).toBe(null);
expect(b.parent).toBe(a);
expect(c.parent).toBe(a);
expect(d.parent.parent).toBe(a);
expect(a.fullName).toBe("a");
expect(b.fullName).toBe("a/b");
expect(c.fullName).toBe("a/c");
expect(d.fullName).toBe("a/b/d");
});
});

View file

@ -0,0 +1,70 @@
/** @odoo-module */
import { describe, expect, test } from "@odoo/hoot";
import { parseUrl } from "../local_helpers";
import { Suite } from "../../core/suite";
import { Test } from "../../core/test";
function disableHighlighting() {
if (!window.Prism) {
return () => {};
}
const { highlight } = window.Prism;
window.Prism.highlight = (text) => text;
return function restoreHighlighting() {
window.Prism.highlight = highlight;
};
}
describe(parseUrl(import.meta.url), () => {
test("should have a hashed id", () => {
expect(new Test(null, "a test", {}).id).toMatch(/^\w{8}$/);
});
test("should describe its path in its name", () => {
const a = new Suite(null, "a", {});
const b = new Suite(a, "b", {});
const t1 = new Test(null, "t1", {});
const t2 = new Test(a, "t2", {});
const t3 = new Test(b, "t3", {});
expect(t1.fullName).toBe("t1");
expect(t2.fullName).toBe("a/t2");
expect(t3.fullName).toBe("a/b/t3");
});
test("run is async and lazily formatted", () => {
const restoreHighlighting = disableHighlighting();
const testName = "some test";
const t = new Test(null, testName, {});
const runFn = () => {
// Synchronous
expect(1).toBe(1);
};
expect(t.run).toBe(null);
expect(t.runFnString).toBe("");
expect(t.formatted).toBe(false);
t.setRunFn(runFn);
expect(t.run()).toBeInstanceOf(Promise);
expect(t.runFnString).toBe(runFn.toString());
expect(t.formatted).toBe(false);
expect(String(t.code)).toBe(
`
test("${testName}", () => {
// Synchronous
expect(1).toBe(1);
});
`.trim()
);
expect(t.formatted).toBe(true);
restoreHighlighting();
});
});

View file

@ -0,0 +1,922 @@
/** @odoo-module */
import { describe, expect, getFixture, test } from "@odoo/hoot";
import {
animationFrame,
click,
formatXml,
getActiveElement,
getFocusableElements,
getNextFocusableElement,
getPreviousFocusableElement,
isDisplayed,
isEditable,
isFocusable,
isInDOM,
isVisible,
queryAll,
queryAllRects,
queryAllTexts,
queryFirst,
queryOne,
queryRect,
waitFor,
waitForNone,
} from "@odoo/hoot-dom";
import { mockTouch } from "@odoo/hoot-mock";
import { getParentFrame } from "@web/../lib/hoot-dom/helpers/dom";
import { mountForTest, parseUrl } from "../local_helpers";
const $ = queryFirst;
const $1 = queryOne;
const $$ = queryAll;
/**
* @param {...string} queryAllSelectors
*/
const expectSelector = (...queryAllSelectors) => {
/**
* @param {string} nativeSelector
*/
const toEqualNodes = (nativeSelector, options) => {
if (typeof nativeSelector !== "string") {
throw new Error(`Invalid selector: ${nativeSelector}`);
}
let root = options?.root || getFixture();
if (typeof root === "string") {
root = getFixture().querySelector(root);
if (root.tagName === "IFRAME") {
root = root.contentDocument;
}
}
let nodes = nativeSelector ? [...root.querySelectorAll(nativeSelector)] : [];
if (Number.isInteger(options?.index)) {
nodes = [nodes.at(options.index)];
}
const selector = queryAllSelectors.join(", ");
const fnNodes = $$(selector);
expect(fnNodes).toEqual($$`${selector}`, {
message: `should return the same result from a tagged template literal`,
});
expect(fnNodes).toEqual(nodes, {
message: `should match ${nodes.length} nodes`,
});
};
return { toEqualNodes };
};
/**
* @param {Document} document
* @param {HTMLElement} [root]
* @returns {Promise<HTMLIFrameElement>}
*/
const makeIframe = (document, root) =>
new Promise((resolve) => {
const iframe = document.createElement("iframe");
iframe.addEventListener("load", () => resolve(iframe));
iframe.srcdoc = "<body></body>";
(root || document.body).appendChild(iframe);
});
const FULL_HTML_TEMPLATE = /* html */ `
<header>
<h1 class="title">Title</h1>
</header>
<main id="custom-html">
<h5 class="title">List header</h5>
<ul colspan="1" class="overflow-auto" style="max-height: 80px">
<li class="text highlighted">First item</li>
<li class="text">Second item</li>
<li class="text">Last item</li>
</ul>
<p colspan="2" class="text">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur justo
velit, tristique vitae neque a, faucibus mollis dui. Aliquam iaculis
sodales mi id posuere. Proin malesuada bibendum pellentesque. Phasellus
mattis at massa quis gravida. Morbi luctus interdum mi, quis dapibus
augue. Vivamus condimentum nunc mi, vitae suscipit turpis dictum nec.
Sed varius diam dui, eget ultricies ante dictum ac.
</p>
<div class="hidden" style="display: none;">Invisible section</div>
<svg></svg>
<form class="overflow-auto" style="max-width: 100px">
<h5 class="title">Form title</h5>
<input name="name" type="text" value="John Doe (JOD)" />
<input name="email" type="email" value="johndoe@sample.com" />
<select name="title" value="mr">
<option>Select an option</option>
<option value="mr" selected="selected">Mr.</option>
<option value="mrs">Mrs.</option>
</select>
<select name="job">
<option selected="selected">Select an option</option>
<option value="employer">Employer</option>
<option value="employee">Employee</option>
</select>
<button type="submit">Submit</button>
<button type="submit" disabled="disabled">Cancel</button>
</form>
<iframe srcdoc="&lt;p&gt;Iframe text content&lt;/p&gt;"></iframe>
</main>
<footer>
<em>Footer</em>
<button type="button">Back to top</button>
</footer>
`;
customElements.define(
"hoot-test-shadow-root",
class ShadowRoot extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
const p = document.createElement("p");
p.textContent = "Shadow content";
const input = document.createElement("input");
shadow.append(p, input);
}
}
);
describe.tags("ui");
describe(parseUrl(import.meta.url), () => {
test("formatXml", () => {
expect(formatXml("")).toBe("");
expect(formatXml("<input />")).toBe("<input/>");
expect(
formatXml(/* xml */ `
<div>
A
</div>
`)
).toBe(`<div>\n A\n</div>`);
expect(formatXml(/* xml */ `<div>A</div>`)).toBe(`<div>\n A\n</div>`);
// Inline
expect(
formatXml(
/* xml */ `
<div>
A
</div>
`,
{ keepInlineTextNodes: true }
)
).toBe(`<div>\n A\n</div>`);
expect(formatXml(/* xml */ `<div>A</div>`, { keepInlineTextNodes: true })).toBe(
`<div>A</div>`
);
});
test("getActiveElement", async () => {
await mountForTest(/* xml */ `<iframe srcdoc="&lt;input &gt;"></iframe>`);
expect(":iframe input").not.toBeFocused();
const input = $1(":iframe input");
await click(input);
expect(":iframe input").toBeFocused();
expect(getActiveElement()).toBe(input);
});
test("getActiveElement: shadow dom", async () => {
await mountForTest(/* xml */ `<hoot-test-shadow-root />`);
expect("hoot-test-shadow-root:shadow input").not.toBeFocused();
const input = $1("hoot-test-shadow-root:shadow input");
await click(input);
expect("hoot-test-shadow-root:shadow input").toBeFocused();
expect(getActiveElement()).toBe(input);
});
test("getFocusableElements", async () => {
await mountForTest(/* xml */ `
<input class="input" />
<div class="div" tabindex="0">aaa</div>
<span class="span" tabindex="-1">aaa</span>
<button class="disabled-button" disabled="disabled">Disabled button</button>
<button class="button" tabindex="1">Button</button>
`);
expect(getFocusableElements().map((el) => el.className)).toEqual([
"button",
"span",
"input",
"div",
]);
expect(getFocusableElements({ tabbable: true }).map((el) => el.className)).toEqual([
"button",
"input",
"div",
]);
});
test("getNextFocusableElement", async () => {
await mountForTest(/* xml */ `
<input class="input" />
<div class="div" tabindex="0">aaa</div>
<button class="disabled-button" disabled="disabled">Disabled button</button>
<button class="button" tabindex="1">Button</button>
`);
await click(".input");
expect(getNextFocusableElement()).toHaveClass("div");
});
test("getParentFrame", async () => {
await mountForTest(/* xml */ `
<div class="root"></div>
`);
const parent = await makeIframe(document, $1(".root"));
const child = await makeIframe(parent.contentDocument);
const content = child.contentDocument.createElement("div");
child.contentDocument.body.appendChild(content);
expect(getParentFrame(content)).toBe(child);
expect(getParentFrame(child)).toBe(parent);
expect(getParentFrame(parent)).toBe(null);
});
test("getPreviousFocusableElement", async () => {
await mountForTest(/* xml */ `
<input class="input" />
<div class="div" tabindex="0">aaa</div>
<button class="disabled-button" disabled="disabled">Disabled button</button>
<button class="button" tabindex="1">Button</button>
`);
await click(".input");
expect(getPreviousFocusableElement()).toHaveClass("button");
});
test("isEditable", async () => {
expect(isEditable(document.createElement("input"))).toBe(true);
expect(isEditable(document.createElement("textarea"))).toBe(true);
expect(isEditable(document.createElement("select"))).toBe(false);
const editableDiv = document.createElement("div");
expect(isEditable(editableDiv)).toBe(false);
editableDiv.setAttribute("contenteditable", "true");
expect(isEditable(editableDiv)).toBe(false); // not supported
});
test("isFocusable", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
expect(isFocusable("input:first")).toBe(true);
expect(isFocusable("li:first")).toBe(false);
});
test("isInDom", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
expect(isInDOM(document)).toBe(true);
expect(isInDOM(document.body)).toBe(true);
expect(isInDOM(document.head)).toBe(true);
expect(isInDOM(document.documentElement)).toBe(true);
const form = $1`form`;
expect(isInDOM(form)).toBe(true);
form.remove();
expect(isInDOM(form)).toBe(false);
const paragraph = $1`:iframe p`;
expect(isInDOM(paragraph)).toBe(true);
paragraph.remove();
expect(isInDOM(paragraph)).toBe(false);
});
test("isDisplayed", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
expect(isDisplayed(document)).toBe(true);
expect(isDisplayed(document.body)).toBe(true);
expect(isDisplayed(document.head)).toBe(true);
expect(isDisplayed(document.documentElement)).toBe(true);
expect(isDisplayed("form")).toBe(true);
expect(isDisplayed(".hidden")).toBe(false);
expect(isDisplayed("body")).toBe(false); // not available from fixture
});
test("isVisible", async () => {
await mountForTest(FULL_HTML_TEMPLATE + "<hoot-test-shadow-root />");
expect(isVisible(document)).toBe(true);
expect(isVisible(document.body)).toBe(true);
expect(isVisible(document.head)).toBe(false);
expect(isVisible(document.documentElement)).toBe(true);
expect(isVisible("form")).toBe(true);
expect(isVisible("hoot-test-shadow-root:shadow input")).toBe(true);
expect(isVisible(".hidden")).toBe(false);
expect(isVisible("body")).toBe(false); // not available from fixture
});
test("matchMedia", async () => {
// Invalid syntax
expect(matchMedia("aaaa").matches).toBe(false);
expect(matchMedia("display-mode: browser").matches).toBe(false);
// Does not exist
expect(matchMedia("(a)").matches).toBe(false);
expect(matchMedia("(a: b)").matches).toBe(false);
// Defaults
expect(matchMedia("(display-mode:browser)").matches).toBe(true);
expect(matchMedia("(display-mode: standalone)").matches).toBe(false);
expect(matchMedia("not (display-mode: standalone)").matches).toBe(true);
expect(matchMedia("(prefers-color-scheme :light)").matches).toBe(true);
expect(matchMedia("(prefers-color-scheme : dark)").matches).toBe(false);
expect(matchMedia("not (prefers-color-scheme: dark)").matches).toBe(true);
expect(matchMedia("(prefers-reduced-motion: reduce)").matches).toBe(true);
expect(matchMedia("(prefers-reduced-motion: no-preference)").matches).toBe(false);
// Touch feature
expect(window.matchMedia("(pointer: coarse)").matches).toBe(false);
expect(window.ontouchstart).toBe(undefined);
mockTouch(true);
expect(window.matchMedia("(pointer: coarse)").matches).toBe(true);
expect(window.ontouchstart).not.toBe(undefined);
});
test("waitFor: already in fixture", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
waitFor(".title").then((el) => {
expect.step(el.className);
return el;
});
expect.verifySteps([]);
await animationFrame();
expect.verifySteps(["title"]);
});
test("waitFor: rejects", async () => {
await expect(waitFor("never", { timeout: 1 })).rejects.toThrow(
`expected at least 1 element after 1ms and found 0 elements: 0 matching "never"`
);
});
test("waitFor: add new element", async () => {
const el1 = document.createElement("div");
el1.className = "new-element";
const el2 = document.createElement("div");
el2.className = "new-element";
const promise = waitFor(".new-element").then((el) => {
expect.step(el.className);
return el;
});
await animationFrame();
expect.verifySteps([]);
getFixture().append(el1, el2);
await expect(promise).resolves.toBe(el1);
expect.verifySteps(["new-element"]);
});
test("waitForNone: DOM empty", async () => {
waitForNone(".title").then(() => expect.step("none"));
expect.verifySteps([]);
await animationFrame();
expect.verifySteps(["none"]);
});
test("waitForNone: rejects", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
await expect(waitForNone(".title", { timeout: 1 })).rejects.toThrow();
});
test("waitForNone: delete elements", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
waitForNone(".title").then(() => expect.step("none"));
expect(".title").toHaveCount(3);
for (const title of $$(".title")) {
expect.verifySteps([]);
title.remove();
await animationFrame();
}
expect.verifySteps(["none"]);
});
describe("query", () => {
test("native selectors", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
expect($$()).toEqual([]);
for (const selector of [
"main",
`.${"title"}`,
`${"ul"}${" "}${`${"li"}`}`,
".title",
"ul > li",
"form:has(.title:not(.haha)):not(.huhu) input[name='email']:enabled",
"[colspan='1']",
]) {
expectSelector(selector).toEqualNodes(selector);
}
});
test("custom pseudo-classes", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
// :first, :last, :only & :eq
expectSelector(".title:first").toEqualNodes(".title", { index: 0 });
expectSelector(".title:last").toEqualNodes(".title", { index: -1 });
expectSelector(".title:eq(-1)").toEqualNodes(".title", { index: -1 });
expectSelector("main:only").toEqualNodes("main");
expectSelector(".title:only").toEqualNodes("");
expectSelector(".title:eq(1)").toEqualNodes(".title", { index: 1 });
expectSelector(".title:eq('1')").toEqualNodes(".title", { index: 1 });
expectSelector('.title:eq("1")').toEqualNodes(".title", { index: 1 });
// :contains (text)
expectSelector("main > .text:contains(ipsum)").toEqualNodes("p");
expectSelector(".text:contains(/\\bL\\w+\\b\\sipsum/)").toEqualNodes("p");
expectSelector(".text:contains(item)").toEqualNodes("li");
// :contains (value)
expectSelector("input:value(john)").toEqualNodes("[name=name],[name=email]");
expectSelector("input:value(john doe)").toEqualNodes("[name=name]");
expectSelector("input:value('John Doe (JOD)')").toEqualNodes("[name=name]");
expectSelector(`input:value("(JOD)")`).toEqualNodes("[name=name]");
expectSelector("input:value(johndoe)").toEqualNodes("[name=email]");
expectSelector("select:value(mr)").toEqualNodes("[name=title]");
expectSelector("select:value(unknown value)").toEqualNodes("");
// :selected
expectSelector("option:selected").toEqualNodes(
"select[name=title] option[value=mr],select[name=job] option:first-child"
);
// :iframe
expectSelector("iframe p:contains(iframe text content)").toEqualNodes("");
expectSelector("div:iframe p").toEqualNodes("");
expectSelector(":iframe p:contains(iframe text content)").toEqualNodes("p", {
root: "iframe",
});
});
test("advanced use cases", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
// Comma-separated selectors
expectSelector(":has(form:contains('Form title')),p:contains(ipsum)").toEqualNodes(
"p,main"
);
// :has & :not combinations with custom pseudo-classes
expectSelector(`select:has(:contains(Employer))`).toEqualNodes("select[name=job]");
expectSelector(`select:not(:has(:contains(Employer)))`).toEqualNodes(
"select[name=title]"
);
expectSelector(
`main:first-of-type:not(:has(:contains(This text does not exist))):contains('List header') > form:has([name="name"]):contains("Form title"):nth-child(6).overflow-auto:visible select[name=job] option:selected`
).toEqualNodes("select[name=job] option:first-child");
// :contains & commas
expectSelector(`p:contains(velit,)`).toEqualNodes("p");
expectSelector(`p:contains('velit,')`).toEqualNodes("p");
expectSelector(`p:contains(", tristique")`).toEqualNodes("p");
expectSelector(`p:contains(/\\bvelit,/)`).toEqualNodes("p");
});
// Whatever, at this point I'm just copying failing selectors and creating
// fake contexts accordingly as I'm fixing them.
test("comma-separated long selector: no match", async () => {
await mountForTest(/* xml */ `
<div class="o_we_customize_panel">
<we-customizeblock-option class="snippet-option-ImageTools">
<div class="o_we_so_color_palette o_we_widget_opened">
idk
</div>
<we-select data-name="shape_img_opt">
<we-toggler></we-toggler>
</we-select>
</we-customizeblock-option>
</div>
`);
expectSelector(
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] we-select[data-name="shape_img_opt"] we-toggler`,
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] [title='we-select[data-name="shape_img_opt"] we-toggler']`
).toEqualNodes("");
});
test("comma-separated long selector: match first", async () => {
await mountForTest(/* xml */ `
<div class="o_we_customize_panel">
<we-customizeblock-option class="snippet-option-ImageTools">
<we-select data-name="shape_img_opt">
<we-toggler></we-toggler>
</we-select>
</we-customizeblock-option>
</div>
`);
expectSelector(
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] we-select[data-name="shape_img_opt"] we-toggler`,
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] [title='we-select[data-name="shape_img_opt"] we-toggler']`
).toEqualNodes("we-toggler");
});
test("comma-separated long selector: match second", async () => {
await mountForTest(/* xml */ `
<div class="o_we_customize_panel">
<we-customizeblock-option class="snippet-option-ImageTools">
<div title='we-select[data-name="shape_img_opt"] we-toggler'>
idk
</div>
</we-customizeblock-option>
</div>
`);
expectSelector(
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] we-select[data-name="shape_img_opt"] we-toggler`,
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] [title='we-select[data-name="shape_img_opt"] we-toggler']`
).toEqualNodes("div[title]");
});
test("comma-separated :contains", async () => {
await mountForTest(/* xml */ `
<div class="o_menu_sections">
<a class="dropdown-item">Products</a>
</div>
<nav class="o_burger_menu_content">
<ul>
<li data-menu-xmlid="sale.menu_product_template_action">
Products
</li>
</ul>
</nav>
`);
expectSelector(
`.o_menu_sections .dropdown-item:contains('Products'), nav.o_burger_menu_content li[data-menu-xmlid='sale.menu_product_template_action']`
).toEqualNodes(".dropdown-item,li");
});
test(":contains with line return", async () => {
await mountForTest(/* xml */ `
<span>
<div>Matrix (PAV11, PAV22, PAV31)</div>
<div>PA4: PAV41</div>
</span>
`);
expectSelector(
`span:contains("Matrix (PAV11, PAV22, PAV31)\nPA4: PAV41")`
).toEqualNodes("span");
});
test(":has(...):first", async () => {
await mountForTest(/* xml */ `
<a href="/web/event/1"></a>
<a target="" href="/web/event/2">
<span>Conference for Architects TEST</span>
</a>
`);
expectSelector(
`a[href*="/event"]:contains("Conference for Architects TEST")`
).toEqualNodes("[target]");
expectSelector(
`a[href*="/event"]:contains("Conference for Architects TEST"):first`
).toEqualNodes("[target]");
});
test(":eq", async () => {
await mountForTest(/* xml */ `
<ul>
<li>a</li>
<li>b</li>
<li>c</li>
</ul>
`);
expectSelector(`li:first:contains(a)`).toEqualNodes("li:nth-child(1)");
expectSelector(`li:contains(a):first`).toEqualNodes("li:nth-child(1)");
expectSelector(`li:first:contains(b)`).toEqualNodes("");
expectSelector(`li:contains(b):first`).toEqualNodes("li:nth-child(2)");
});
test(":empty", async () => {
await mountForTest(/* xml */ `
<input class="empty" />
<input class="value" value="value" />
`);
expectSelector(`input:empty`).toEqualNodes(".empty");
expectSelector(`input:not(:empty)`).toEqualNodes(".value");
});
test("regular :contains", async () => {
await mountForTest(/* xml */ `
<div class="website_links_click_chart">
<div class="title">
0 clicks
</div>
<div class="title">
1 clicks
</div>
<div class="title">
2 clicks
</div>
</div>
`);
expectSelector(`.website_links_click_chart .title:contains("1 clicks")`).toEqualNodes(
".title:nth-child(2)"
);
});
test("other regular :contains", async () => {
await mountForTest(/* xml */ `
<ul
class="o-autocomplete--dropdown-menu ui-widget show dropdown-menu ui-autocomplete"
style="position: fixed; top: 283.75px; left: 168.938px"
>
<li class="o-autocomplete--dropdown-item ui-menu-item block">
<a
href="#"
class="dropdown-item ui-menu-item-wrapper truncate ui-state-active"
>Account Tax Group Partner</a
>
</li>
<li
class="o-autocomplete--dropdown-item ui-menu-item block o_m2o_dropdown_option o_m2o_dropdown_option_search_more"
>
<a href="#" class="dropdown-item ui-menu-item-wrapper truncate"
>Search More...</a
>
</li>
<li
class="o-autocomplete--dropdown-item ui-menu-item block o_m2o_dropdown_option o_m2o_dropdown_option_create_edit"
>
<a href="#" class="dropdown-item ui-menu-item-wrapper truncate"
>Create and edit...</a
>
</li>
</ul>
`);
expectSelector(`.ui-menu-item a:contains("Account Tax Group Partner")`).toEqualNodes(
"ul li:first-child a"
);
});
test(":iframe", async () => {
await mountForTest(/* xml */ `
<iframe srcdoc="&lt;p&gt;Iframe text content&lt;/p&gt;"></iframe>
`);
expectSelector(`:iframe html`).toEqualNodes("html", { root: "iframe" });
expectSelector(`:iframe body`).toEqualNodes("body", { root: "iframe" });
expectSelector(`:iframe head`).toEqualNodes("head", { root: "iframe" });
});
test(":contains with brackets", async () => {
await mountForTest(/* xml */ `
<div class="o_content">
<div class="o_field_widget" name="messages">
<table class="o_list_view table table-sm table-hover table-striped o_list_view_ungrouped">
<tbody>
<tr class="o_data_row">
<td class="o_list_record_selector">
bbb
</td>
<td class="o_data_cell o_required_modifier">
<span>
[test_trigger] Mitchell Admin
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
`);
expectSelector(
`.o_content:has(.o_field_widget[name=messages]):has(td:contains(/^bbb$/)):has(td:contains(/^\\[test_trigger\\] Mitchell Admin$/))`
).toEqualNodes(".o_content");
});
test(":eq in the middle of a selector", async () => {
await mountForTest(/* xml */ `
<ul>
<li class="oe_overlay o_draggable"></li>
<li class="oe_overlay o_draggable"></li>
<li class="oe_overlay o_draggable oe_active"></li>
<li class="oe_overlay o_draggable"></li>
</ul>
`);
expectSelector(`.oe_overlay.o_draggable:eq(2).oe_active`).toEqualNodes(
"li:nth-child(3)"
);
});
test("combinator +", async () => {
await mountForTest(/* xml */ `
<form class="js_attributes">
<input type="checkbox" />
<label>Steel - Test</label>
</form>
`);
expectSelector(
`form.js_attributes input:not(:checked) + label:contains(Steel - Test)`
).toEqualNodes("label");
});
test("multiple + combinators", async () => {
await mountForTest(/* xml */ `
<div class="s_cover">
<span class="o_text_highlight">
<span class="o_text_highlight_item">
<span class="o_text_highlight_path_underline" />
</span>
<br />
<span class="o_text_highlight_item">
<span class="o_text_highlight_path_underline" />
</span>
</span>
</div>
`);
expectSelector(`
.s_cover span.o_text_highlight:has(
.o_text_highlight_item
+ br
+ .o_text_highlight_item
)
`).toEqualNodes(".o_text_highlight");
});
test(":last", async () => {
await mountForTest(/* xml */ `
<div class="o_field_widget" name="messages">
<table class="o_list_view table table-sm table-hover table-striped o_list_view_ungrouped">
<tbody>
<tr class="o_data_row">
<td class="o_list_record_remove">
<button class="btn">Remove</button>
</td>
</tr>
<tr class="o_data_row">
<td class="o_list_record_remove">
<button class="btn">Remove</button>
</td>
</tr>
</tbody>
</table>
</div>
`);
expectSelector(
`.o_field_widget[name=messages] .o_data_row td.o_list_record_remove button:visible:last`
).toEqualNodes(".o_data_row:last-child button");
});
test("select :contains & :value", async () => {
await mountForTest(/* xml */ `
<select class="configurator_select form-select form-select-lg">
<option value="217" selected="">Metal</option>
<option value="218">Wood</option>
</select>
`);
expectSelector(`.configurator_select:has(option:contains(Metal))`).toEqualNodes(
"select"
);
expectSelector(`.configurator_select:has(option:value(217))`).toEqualNodes("select");
expectSelector(`.configurator_select:has(option:value(218))`).toEqualNodes("select");
expectSelector(`.configurator_select:value(217)`).toEqualNodes("select");
expectSelector(`.configurator_select:value(218)`).toEqualNodes("");
expectSelector(`.configurator_select:value(Metal)`).toEqualNodes("");
});
test("invalid selectors", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
expect(() => $$`[colspan=1]`).toThrow(); // missing quotes
expect(() => $$`[href=/]`).toThrow(); // missing quotes
expect(
() =>
$$`_o_wblog_posts_loop:has(span:has(i.fa-calendar-o):has(a[href="/blog?search=a"])):has(span:has(i.fa-search):has(a[href^="/blog?date_begin"]))`
).toThrow(); // nested :has statements
});
test("queryAllRects", async () => {
await mountForTest(/* xml */ `
<div style="width: 40px; height: 60px;" />
<div style="width: 20px; height: 10px;" />
`);
expect(queryAllRects("div")).toEqual($$("div").map((el) => el.getBoundingClientRect()));
expect(queryAllRects("div:first")).toEqual([new DOMRect({ width: 40, height: 60 })]);
expect(queryAllRects("div:last")).toEqual([new DOMRect({ width: 20, height: 10 })]);
});
test("queryAllTexts", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
expect(queryAllTexts(".title")).toEqual(["Title", "List header", "Form title"]);
expect(queryAllTexts("footer")).toEqual(["FooterBack to top"]);
});
test("queryOne", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
expect($1(".title:first")).toBe(getFixture().querySelector("header .title"));
expect(() => $1(".title")).toThrow();
expect(() => $1(".title", { exact: 2 })).toThrow();
});
test("queryRect", async () => {
await mountForTest(/* xml */ `
<div class="container">
<div class="rect" style="width: 40px; height: 60px;" />
</div>
`);
expect(".rect").toHaveRect(".container"); // same rect as parent
expect(".rect").toHaveRect({ width: 40, height: 60 });
expect(queryRect(".rect")).toEqual($1(".rect").getBoundingClientRect());
expect(queryRect(".rect")).toEqual(new DOMRect({ width: 40, height: 60 }));
});
test("queryRect with trimPadding", async () => {
await mountForTest(/* xml */ `
<div style="width: 50px; height: 70px; padding: 5px; margin: 6px" />
`);
expect("div").toHaveRect({ width: 50, height: 70 }); // with padding
expect("div").toHaveRect({ width: 40, height: 60 }, { trimPadding: true });
});
test("not found messages", async () => {
await mountForTest(/* xml */ `
<div class="tralalero">
Tralala
</div>
`);
expect(() => $("invalid:pseudo-selector")).toThrow();
// Perform in-between valid query with custom pseudo selectors
expect($`.modal:visible:contains('Tung Tung Tung Sahur')`).toBe(null);
// queryOne error messages
expect(() => $1()).toThrow(`found 0 elements instead of 1`);
expect(() => $$([], { exact: 18 })).toThrow(`found 0 elements instead of 18`);
expect(() => $1("")).toThrow(`found 0 elements instead of 1: 0 matching ""`);
expect(() => $$(".tralalero", { exact: -20 })).toThrow(
`found 1 element instead of -20: 1 matching ".tralalero"`
);
expect(() => $1`.tralalero:contains(Tralala):visible:scrollable:first`).toThrow(
`found 0 elements instead of 1: 0 matching ".tralalero:contains(Tralala):visible:scrollable:first" (1 element with text "Tralala" > 1 visible element > 0 scrollable elements)`
);
expect(() =>
$1(".tralalero", {
contains: "Tralala",
visible: true,
scrollable: true,
first: true,
})
).toThrow(
`found 0 elements instead of 1: 1 matching ".tralalero", including 1 element with text "Tralala", including 1 visible element, including 0 scrollable elements`
);
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,132 @@
/** @odoo-module */
import { describe, expect, test } from "@odoo/hoot";
import {
Deferred,
advanceTime,
animationFrame,
microTick,
runAllTimers,
tick,
waitUntil,
} from "@odoo/hoot-dom";
import { parseUrl } from "../local_helpers";
// timeout of 1 second to ensure all timeouts are actually mocked
describe.timeout(1_000);
describe(parseUrl(import.meta.url), () => {
test("advanceTime", async () => {
expect.assertions(8);
await advanceTime(5_000);
const timeoutId = window.setTimeout(() => expect.step("timeout"), "2000");
const intervalId = window.setInterval(() => expect.step("interval"), 3_000);
const animationHandle = window.requestAnimationFrame((delta) => {
expect(delta).toBeGreaterThan(5_000);
expect.step("animation");
});
expect(timeoutId).toBeGreaterThan(0);
expect(intervalId).toBeGreaterThan(0);
expect(animationHandle).toBeGreaterThan(0);
expect.verifySteps([]);
await advanceTime(10_000); // 10 seconds
expect.verifySteps(["animation", "timeout", "interval", "interval", "interval"]);
await advanceTime(10_000);
expect.verifySteps(["interval", "interval", "interval"]);
window.clearInterval(intervalId);
await advanceTime(10_000);
expect.verifySteps([]);
});
test("Deferred", async () => {
const def = new Deferred();
def.then(() => expect.step("resolved"));
expect.step("before");
def.resolve(14);
expect.step("after");
await expect(def).resolves.toBe(14);
expect.verifySteps(["before", "after", "resolved"]);
});
test("tick", async () => {
let count = 0;
const nextTickPromise = tick().then(() => ++count);
expect(count).toBe(0);
await expect(nextTickPromise).resolves.toBe(1);
expect(count).toBe(1);
});
test("runAllTimers", async () => {
expect.assertions(4);
window.setTimeout(() => expect.step("timeout"), 1e6);
window.requestAnimationFrame((delta) => {
expect(delta).toBeGreaterThan(1);
expect.step("animation");
});
expect.verifySteps([]);
const ms = await runAllTimers();
expect(ms).toBeCloseTo(1e6, { margin: 10 });
expect.verifySteps(["animation", "timeout"]);
});
test("waitUntil: already true", async () => {
const promise = waitUntil(() => "some value").then((value) => {
expect.step("resolved");
return value;
});
expect.verifySteps([]);
expect(promise).toBeInstanceOf(Promise);
await microTick();
expect.verifySteps(["resolved"]);
await expect(promise).resolves.toBe("some value");
});
test("waitUntil: rejects", async () => {
await expect(waitUntil(() => false, { timeout: 0 })).rejects.toThrow();
});
test("waitUntil: lazy", async () => {
let returnValue = "";
const promise = waitUntil(() => returnValue).then((v) => expect.step(v));
expect.verifySteps([]);
expect(promise).toBeInstanceOf(Promise);
await animationFrame();
await animationFrame();
expect.verifySteps([]);
returnValue = "test";
await animationFrame();
expect.verifySteps(["test"]);
});
});

View file

@ -0,0 +1,38 @@
const _owl = window.owl;
delete window.owl;
export const App = _owl.App;
export const Component = _owl.Component;
export const EventBus = _owl.EventBus;
export const OwlError = _owl.OwlError;
export const __info__ = _owl.__info__;
export const blockDom = _owl.blockDom;
export const loadFile = _owl.loadFile;
export const markRaw = _owl.markRaw;
export const markup = _owl.markup;
export const mount = _owl.mount;
export const onError = _owl.onError;
export const onMounted = _owl.onMounted;
export const onPatched = _owl.onPatched;
export const onRendered = _owl.onRendered;
export const onWillDestroy = _owl.onWillDestroy;
export const onWillPatch = _owl.onWillPatch;
export const onWillRender = _owl.onWillRender;
export const onWillStart = _owl.onWillStart;
export const onWillUnmount = _owl.onWillUnmount;
export const onWillUpdateProps = _owl.onWillUpdateProps;
export const reactive = _owl.reactive;
export const status = _owl.status;
export const toRaw = _owl.toRaw;
export const useChildSubEnv = _owl.useChildSubEnv;
export const useComponent = _owl.useComponent;
export const useEffect = _owl.useEffect;
export const useEnv = _owl.useEnv;
export const useExternalListener = _owl.useExternalListener;
export const useRef = _owl.useRef;
export const useState = _owl.useState;
export const useSubEnv = _owl.useSubEnv;
export const validate = _owl.validate;
export const validateType = _owl.validateType;
export const whenReady = _owl.whenReady;
export const xml = _owl.xml;

View file

@ -0,0 +1,354 @@
/** @odoo-module */
import { describe, expect, test } from "@odoo/hoot";
import { queryOne } from "@odoo/hoot-dom";
import { isInstanceOf, isIterable } from "@web/../lib/hoot-dom/hoot_dom_utils";
import {
deepEqual,
formatHumanReadable,
formatTechnical,
generateHash,
levenshtein,
lookup,
match,
parseQuery,
title,
toExplicitString,
} from "../hoot_utils";
import { mountForTest, parseUrl } from "./local_helpers";
describe(parseUrl(import.meta.url), () => {
test("deepEqual", () => {
const recursive = {};
recursive.self = recursive;
const TRUTHY_CASES = [
[true, true],
[false, false],
[null, null],
[recursive, recursive],
[new Date(0), new Date(0)],
[
{ b: 2, a: 1 },
{ a: 1, b: 2 },
],
[{ o: { a: [{ b: 1 }] } }, { o: { a: [{ b: 1 }] } }],
[Symbol.for("a"), Symbol.for("a")],
[document.createElement("div"), document.createElement("div")],
[
[1, 2, 3],
[1, 2, 3],
],
];
const FALSY_CASES = [
[true, false],
[null, undefined],
[recursive, { ...recursive, a: 1 }],
[
[1, 2, 3],
[3, 1, 2],
],
[new Date(0), new Date(1_000)],
[{ a: new Date(0) }, { a: 0 }],
[document.createElement("a"), document.createElement("div")],
[{ [Symbol("a")]: 1 }, { [Symbol("a")]: 1 }],
];
const TRUTHY_IF_UNORDERED_CASES = [
[
[1, "2", 3],
["2", 3, 1],
],
[
[1, { a: [4, 2] }, "3"],
[{ a: [2, 4] }, "3", 1],
],
[
new Set([
"abc",
new Map([
["b", 2],
["a", 1],
]),
]),
new Set([
new Map([
["a", 1],
["b", 2],
]),
"abc",
]),
],
];
expect.assertions(
TRUTHY_CASES.length + FALSY_CASES.length + TRUTHY_IF_UNORDERED_CASES.length * 2
);
for (const [a, b] of TRUTHY_CASES) {
expect(deepEqual(a, b)).toBe(true, {
message: [a, `==`, b],
});
}
for (const [a, b] of FALSY_CASES) {
expect(deepEqual(a, b)).toBe(false, {
message: [a, `!=`, b],
});
}
for (const [a, b] of TRUTHY_IF_UNORDERED_CASES) {
expect(deepEqual(a, b)).toBe(false, {
message: [a, `!=`, b],
});
expect(deepEqual(a, b, { ignoreOrder: true })).toBe(true, {
message: [a, `==`, b, `(unordered))`],
});
}
});
test("formatHumanReadable", () => {
// Strings
expect(formatHumanReadable("abc")).toBe(`"abc"`);
expect(formatHumanReadable("a".repeat(300))).toBe(`"${"a".repeat(80)}…"`);
expect(formatHumanReadable(`with "double quotes"`)).toBe(`'with "double quotes"'`);
expect(formatHumanReadable(`with "double quotes" and 'single quote'`)).toBe(
`\`with "double quotes" and 'single quote'\``
);
// Numbers
expect(formatHumanReadable(1)).toBe(`1`);
// Other primitives
expect(formatHumanReadable(true)).toBe(`true`);
expect(formatHumanReadable(null)).toBe(`null`);
// Functions & classes
expect(formatHumanReadable(async function oui() {})).toBe(`async function oui() { … }`);
expect(formatHumanReadable(class Oui {})).toBe(`class Oui { … }`);
// Iterators
expect(formatHumanReadable([1, 2, 3])).toBe(`[1, 2, 3]`);
expect(formatHumanReadable(new Set([1, 2, 3]))).toBe(`Set [1, 2, 3]`);
expect(
formatHumanReadable(
new Map([
["a", 1],
["b", 2],
])
)
).toBe(`Map [["a", 1], ["b", 2]]`);
// Objects
expect(formatHumanReadable(/ab(c)d/gi)).toBe(`/ab(c)d/gi`);
expect(formatHumanReadable(new Date("1997-01-09T12:30:00.000Z"))).toBe(
`1997-01-09T12:30:00.000Z`
);
expect(formatHumanReadable({})).toBe(`{ }`);
expect(formatHumanReadable({ a: { b: 1 } })).toBe(`{ a: { b: 1 } }`);
expect(
formatHumanReadable(
new Proxy(
{
allowed: true,
get forbidden() {
throw new Error("Cannot access!");
},
},
{}
)
)
).toBe(`{ allowed: true }`);
expect(formatHumanReadable(window)).toBe(`Window { }`);
// Nodes
expect(formatHumanReadable(document.createElement("div"))).toBe("<div>");
expect(formatHumanReadable(document.createTextNode("some text"))).toBe("#text");
expect(formatHumanReadable(document)).toBe("#document");
});
test("formatTechnical", () => {
expect(
formatTechnical({
b: 2,
[Symbol("s")]: "value",
a: true,
})
).toBe(
`{
a: true,
b: 2,
Symbol(s): "value",
}`.trim()
);
expect(formatTechnical(["a", "b"])).toBe(
`[
"a",
"b",
]`.trim()
);
class List extends Array {}
expect(formatTechnical(new List("a", "b"))).toBe(
`List [
"a",
"b",
]`.trim()
);
function toArguments() {
return arguments;
}
expect(formatTechnical(toArguments("a", "b"))).toBe(
`Arguments [
"a",
"b",
]`.trim()
);
});
test("generateHash", () => {
expect(generateHash("abc")).toHaveLength(8);
expect(generateHash("abcdef")).toHaveLength(8);
expect(generateHash("abc")).toBe(generateHash("abc"));
expect(generateHash("abc")).not.toBe(generateHash("def"));
});
test("isInstanceOf", async () => {
await mountForTest(/* xml */ `
<iframe srcdoc="" />
`);
expect(() => isInstanceOf()).toThrow(TypeError);
expect(() => isInstanceOf("a")).toThrow(TypeError);
expect(isInstanceOf(null, null)).toBe(false);
expect(isInstanceOf(undefined, undefined)).toBe(false);
expect(isInstanceOf("", String)).toBe(false);
expect(isInstanceOf(24, Number)).toBe(false);
expect(isInstanceOf(true, Boolean)).toBe(false);
class List extends Array {}
class A {}
class B extends A {}
expect(isInstanceOf([], Array)).toBe(true);
expect(isInstanceOf(new List(), Array)).toBe(true);
expect(isInstanceOf(new B(), B)).toBe(true);
expect(isInstanceOf(new B(), A)).toBe(true);
expect(isInstanceOf(new Error("error"), Error)).toBe(true);
expect(isInstanceOf(/a/, RegExp, Date)).toBe(true);
expect(isInstanceOf(new Date(), RegExp, Date)).toBe(true);
const { contentDocument, contentWindow } = queryOne("iframe");
expect(isInstanceOf(queryOne("iframe"), HTMLIFrameElement)).toBe(true);
expect(contentWindow instanceof Window).toBe(false);
expect(isInstanceOf(contentWindow, Window)).toBe(true);
expect(contentDocument.body instanceof HTMLBodyElement).toBe(false);
expect(isInstanceOf(contentDocument.body, HTMLBodyElement)).toBe(true);
});
test("isIterable", () => {
expect(isIterable([1, 2, 3])).toBe(true);
expect(isIterable(new Set([1, 2, 3]))).toBe(true);
expect(isIterable(null)).toBe(false);
expect(isIterable("abc")).toBe(false);
expect(isIterable({})).toBe(false);
});
test("levenshtein", () => {
expect(levenshtein("abc", "abc")).toBe(0);
expect(levenshtein("abc", "àbc ")).toBe(2);
expect(levenshtein("abc", "def")).toBe(3);
expect(levenshtein("abc", "adc")).toBe(1);
});
test("parseQuery & lookup", () => {
/**
* @param {string} query
* @param {string[]} itemsList
* @param {string} [property]
*/
const expectQuery = (query, itemsList, property = "key") => {
const keyedItems = itemsList.map((item) => ({ [property]: item }));
const result = lookup(parseQuery(query), keyedItems);
return {
/**
* @param {string[]} expected
*/
toEqual: (expected) =>
expect(result).toEqual(
expected.map((item) => ({ [property]: item })),
{ message: `query ${query} should match ${expected}` }
),
};
};
const list = [
"Frodo",
"Sam",
"Merry",
"Pippin",
"Frodo Sam",
"Merry Pippin",
"Frodo Sam Merry Pippin",
];
// Error handling
expect(() => parseQuery()).toThrow();
expect(() => lookup()).toThrow();
expect(() => lookup("a", [{ key: "a" }])).toThrow();
expect(() => lookup(parseQuery("a"))).toThrow();
// Empty query and/or empty lists
expectQuery("", []).toEqual([]);
expectQuery("", ["bababa", "baaab", "cccbccb"]).toEqual(["bababa", "baaab", "cccbccb"]);
expectQuery("aaa", []).toEqual([]);
// Regex
expectQuery(`/.b$/`, ["bababa", "baaab", "cccbccB"]).toEqual(["baaab"]);
expectQuery(`/.b$/i`, ["bababa", "baaab", "cccbccB"]).toEqual(["baaab", "cccbccB"]);
// Exact match
expectQuery(`"aaa"`, ["bababa", "baaab", "cccbccb"]).toEqual(["baaab"]);
expectQuery(`"sam"`, list).toEqual([]);
expectQuery(`"Sam"`, list).toEqual(["Sam", "Frodo Sam", "Frodo Sam Merry Pippin"]);
expectQuery(`"Sam" "Frodo"`, list).toEqual(["Frodo Sam", "Frodo Sam Merry Pippin"]);
expectQuery(`"Frodo Sam"`, list).toEqual(["Frodo Sam", "Frodo Sam Merry Pippin"]);
expectQuery(`"FrodoSam"`, list).toEqual([]);
expectQuery(`"Frodo Sam"`, list).toEqual([]);
expectQuery(`"Sam" -"Frodo"`, list).toEqual(["Sam"]);
// Partial (fuzzy) match
expectQuery(`aaa`, ["bababa", "baaab", "cccbccb"]).toEqual(["baaab", "bababa"]);
expectQuery(`aaa -bbb`, ["bababa", "baaab", "cccbccb"]).toEqual(["baaab"]);
expectQuery(`-aaa`, ["bababa", "baaab", "cccbccb"]).toEqual(["cccbccb"]);
expectQuery(`frosapip`, list).toEqual(["Frodo Sam Merry Pippin"]);
expectQuery(`-s fro`, list).toEqual(["Frodo"]);
expectQuery(` FR SAPI `, list).toEqual(["Frodo Sam Merry Pippin"]);
// Mixed queries
expectQuery(`"Sam" fro pip`, list).toEqual(["Frodo Sam Merry Pippin"]);
expectQuery(`fro"Sam"pip`, list).toEqual(["Frodo Sam Merry Pippin"]);
expectQuery(`-"Frodo" s`, list).toEqual(["Sam"]);
expectQuery(`"Merry" -p`, list).toEqual(["Merry"]);
expectQuery(`"rry" -s`, list).toEqual(["Merry", "Merry Pippin"]);
});
test("match", () => {
expect(match("abc", /^abcd?/)).toBe(true);
expect(match(new Error("error message"), "message")).toBe(true);
});
test("title", () => {
expect(title("abcDef")).toBe("AbcDef");
});
test("toExplicitString", () => {
expect(toExplicitString("\n")).toBe(`\\n`);
expect(toExplicitString("\t")).toBe(`\\t`);
expect(toExplicitString(" \n")).toBe(` \n`);
expect(toExplicitString("\t ")).toBe(`\t `);
expect(toExplicitString("Abc\u200BDef")).toBe(`Abc\\u200bDef`);
});
});

View file

@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HOOT internal tests</title>
<!-- Source map -->
<script type="importmap">
{
"imports": {
"@odoo/hoot-dom": "/web/static/lib/hoot-dom/hoot-dom.js",
"@odoo/hoot-mock": "/web/static/lib/hoot/hoot-mock.js",
"@odoo/hoot": "/web/static/lib/hoot/hoot.js",
"@odoo/owl": "/web/static/lib/hoot/tests/hoot-owl-module.js",
"@web/../lib/hoot-dom/helpers/dom": "/web/static/lib/hoot-dom/helpers/dom.js",
"@web/../lib/hoot-dom/helpers/events": "/web/static/lib/hoot-dom/helpers/events.js",
"@web/../lib/hoot-dom/helpers/time": "/web/static/lib/hoot-dom/helpers/time.js",
"@web/../lib/hoot-dom/hoot_dom_utils": "/web/static/lib/hoot-dom/hoot_dom_utils.js",
"/web/static/lib/hoot-dom/helpers/dom": "/web/static/lib/hoot-dom/helpers/dom.js",
"/web/static/lib/hoot-dom/helpers/events": "/web/static/lib/hoot-dom/helpers/events.js",
"/web/static/lib/hoot-dom/helpers/time": "/web/static/lib/hoot-dom/helpers/time.js",
"/web/static/lib/hoot-dom/hoot_dom_utils": "/web/static/lib/hoot-dom/hoot_dom_utils.js",
"/web/static/lib/hoot-dom/hoot-dom": "/web/static/lib/hoot-dom/hoot-dom.js",
"/web/static/lib/hoot/core/cleanup": "/web/static/lib/hoot/core/cleanup.js",
"/web/static/lib/hoot/core/config": "/web/static/lib/hoot/core/config.js",
"/web/static/lib/hoot/core/expect": "/web/static/lib/hoot/core/expect.js",
"/web/static/lib/hoot/core/fixture": "/web/static/lib/hoot/core/fixture.js",
"/web/static/lib/hoot/core/job": "/web/static/lib/hoot/core/job.js",
"/web/static/lib/hoot/core/logger": "/web/static/lib/hoot/core/logger.js",
"/web/static/lib/hoot/core/runner": "/web/static/lib/hoot/core/runner.js",
"/web/static/lib/hoot/core/suite": "/web/static/lib/hoot/core/suite.js",
"/web/static/lib/hoot/core/tag": "/web/static/lib/hoot/core/tag.js",
"/web/static/lib/hoot/core/test": "/web/static/lib/hoot/core/test.js",
"/web/static/lib/hoot/core/url": "/web/static/lib/hoot/core/url.js",
"/web/static/lib/hoot/hoot_utils": "/web/static/lib/hoot/hoot_utils.js",
"/web/static/lib/hoot/hoot-mock": "/web/static/lib/hoot/hoot-mock.js",
"/web/static/lib/hoot/hoot": "/web/static/lib/hoot/hoot.js",
"/web/static/lib/hoot/lib/diff_match_patch": "/web/static/lib/hoot/lib/diff_match_patch.js",
"/web/static/lib/hoot/main_runner": "/web/static/lib/hoot/main_runner.js",
"/web/static/lib/hoot/mock/animation": "/web/static/lib/hoot/mock/animation.js",
"/web/static/lib/hoot/mock/console": "/web/static/lib/hoot/mock/console.js",
"/web/static/lib/hoot/mock/date": "/web/static/lib/hoot/mock/date.js",
"/web/static/lib/hoot/mock/math": "/web/static/lib/hoot/mock/math.js",
"/web/static/lib/hoot/mock/navigator": "/web/static/lib/hoot/mock/navigator.js",
"/web/static/lib/hoot/mock/network": "/web/static/lib/hoot/mock/network.js",
"/web/static/lib/hoot/mock/notification": "/web/static/lib/hoot/mock/notification.js",
"/web/static/lib/hoot/mock/storage": "/web/static/lib/hoot/mock/storage.js",
"/web/static/lib/hoot/mock/sync_values": "/web/static/lib/hoot/mock/sync_values.js",
"/web/static/lib/hoot/mock/window": "/web/static/lib/hoot/mock/window.js",
"/web/static/lib/hoot/tests/local_helpers": "/web/static/lib/hoot/tests/local_helpers.js",
"/web/static/lib/hoot/ui/hoot_buttons": "/web/static/lib/hoot/ui/hoot_buttons.js",
"/web/static/lib/hoot/ui/hoot_colors": "/web/static/lib/hoot/ui/hoot_colors.js",
"/web/static/lib/hoot/ui/hoot_config_menu": "/web/static/lib/hoot/ui/hoot_config_menu.js",
"/web/static/lib/hoot/ui/hoot_copy_button": "/web/static/lib/hoot/ui/hoot_copy_button.js",
"/web/static/lib/hoot/ui/hoot_debug_toolbar": "/web/static/lib/hoot/ui/hoot_debug_toolbar.js",
"/web/static/lib/hoot/ui/hoot_dropdown": "/web/static/lib/hoot/ui/hoot_dropdown.js",
"/web/static/lib/hoot/ui/hoot_job_buttons": "/web/static/lib/hoot/ui/hoot_job_buttons.js",
"/web/static/lib/hoot/ui/hoot_link": "/web/static/lib/hoot/ui/hoot_link.js",
"/web/static/lib/hoot/ui/hoot_log_counters": "/web/static/lib/hoot/ui/hoot_log_counters.js",
"/web/static/lib/hoot/ui/hoot_main": "/web/static/lib/hoot/ui/hoot_main.js",
"/web/static/lib/hoot/ui/hoot_reporting": "/web/static/lib/hoot/ui/hoot_reporting.js",
"/web/static/lib/hoot/ui/hoot_search": "/web/static/lib/hoot/ui/hoot_search.js",
"/web/static/lib/hoot/ui/hoot_side_bar": "/web/static/lib/hoot/ui/hoot_side_bar.js",
"/web/static/lib/hoot/ui/hoot_status_panel": "/web/static/lib/hoot/ui/hoot_status_panel.js",
"/web/static/lib/hoot/ui/hoot_tag_button": "/web/static/lib/hoot/ui/hoot_tag_button.js",
"/web/static/lib/hoot/ui/hoot_technical_value": "/web/static/lib/hoot/ui/hoot_technical_value.js",
"/web/static/lib/hoot/ui/hoot_test_path": "/web/static/lib/hoot/ui/hoot_test_path.js",
"/web/static/lib/hoot/ui/hoot_test_result": "/web/static/lib/hoot/ui/hoot_test_result.js",
"/web/static/lib/hoot/ui/setup_hoot_ui": "/web/static/lib/hoot/ui/setup_hoot_ui.js"
}
}
</script>
<style>
html,
body {
height: 100%;
margin: 0;
position: relative;
width: 100%;
}
</style>
<!-- Test assets -->
<script src="/web/static/lib/owl/owl.js"></script>
<script src="../hoot.js" type="module" defer></script>
<link rel="stylesheet" href="/web/static/lib/hoot/ui/hoot_style.css" />
<link rel="stylesheet" href="/web/static/src/libs/fontawesome/css/font-awesome.css" />
<!-- Test suites -->
<script src="./index.js" type="module" defer></script>
</head>
<body></body>
</html>

View file

@ -0,0 +1,17 @@
import { isHootReady, start } from "@odoo/hoot";
import "./core/expect.test.js";
import "./core/runner.test.js";
import "./core/suite.test.js";
import "./core/test.test.js";
import "./hoot-dom/dom.test.js";
import "./hoot-dom/events.test.js";
import "./hoot-dom/time.test.js";
import "./hoot_utils.test.js";
import "./mock/navigator.test.js";
import "./mock/network.test.js";
import "./mock/window.test.js";
import "./ui/hoot_technical_value.test.js";
import "./ui/hoot_test_result.test.js";
isHootReady.then(start);

View file

@ -0,0 +1,45 @@
/** @odoo-module */
import { after, destroy, getFixture } from "@odoo/hoot";
import { App, Component, xml } from "@odoo/owl";
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {import("@odoo/owl").ComponentConstructor} ComponentClass
* @param {ConstructorParameters<typeof App>[1]} [config]
*/
export async function mountForTest(ComponentClass, config) {
if (typeof ComponentClass === "string") {
ComponentClass = class extends Component {
static name = "anonymous component";
static props = {};
static template = xml`${ComponentClass}`;
};
}
const app = new App(ComponentClass, {
name: "TEST",
test: true,
warnIfNoStaticProps: true,
...config,
});
const fixture = getFixture();
after(() => destroy(app));
fixture.style.backgroundColor = "#fff";
await app.mount(fixture);
if (fixture.hasIframes) {
await fixture.waitForIframes();
}
}
/**
* @param {string} url
*/
export function parseUrl(url) {
return url.replace(/^.*hoot\/tests/, "@hoot").replace(/(\.test)?\.js$/, "");
}

View file

@ -0,0 +1,74 @@
/** @odoo-module */
import { describe, expect, test } from "@odoo/hoot";
import { mockSendBeacon, mockTouch, mockVibrate } from "@odoo/hoot-mock";
import { parseUrl } from "../local_helpers";
/**
* @param {Promise<any>} promise
*/
const ensureResolvesImmediatly = (promise) =>
Promise.race([
promise,
new Promise((resolve, reject) => reject("failed to resolve in a single micro tick")),
]);
describe(parseUrl(import.meta.url), () => {
describe("clipboard", () => {
test.tags("secure");
test("read/write calls are resolved immediatly", async () => {
navigator.clipboard.write([
new ClipboardItem({
"text/plain": new Blob(["some text"], { type: "text/plain" }),
}),
]);
const items = await ensureResolvesImmediatly(navigator.clipboard.read());
expect(items).toHaveLength(1);
expect(items[0]).toBeInstanceOf(ClipboardItem);
const blob = await ensureResolvesImmediatly(items[0].getType("text/plain"));
expect(blob).toBeInstanceOf(Blob);
const value = await ensureResolvesImmediatly(blob.text());
expect(value).toBe("some text");
});
});
test("maxTouchPoints", () => {
mockTouch(false);
expect(navigator.maxTouchPoints).toBe(0);
mockTouch(true);
expect(navigator.maxTouchPoints).toBe(1);
});
test("sendBeacon", () => {
expect(() => navigator.sendBeacon("/route", new Blob([]))).toThrow(/sendBeacon/);
mockSendBeacon(expect.step);
expect.verifySteps([]);
navigator.sendBeacon("/route", new Blob([]));
expect.verifySteps(["/route"]);
});
test("vibrate", () => {
expect(() => navigator.vibrate(100)).toThrow(/vibrate/);
mockVibrate(expect.step);
expect.verifySteps([]);
navigator.vibrate(100);
expect.verifySteps([100]);
});
});

View file

@ -0,0 +1,32 @@
/** @odoo-module */
import { describe, expect, test } from "@odoo/hoot";
import { mockFetch } from "@odoo/hoot-mock";
import { parseUrl } from "../local_helpers";
describe(parseUrl(import.meta.url), () => {
test("setup network values", async () => {
expect(document.cookie).toBe("");
document.cookie = "cids=4";
document.title = "kek";
expect(document.cookie).toBe("cids=4");
expect(document.title).toBe("kek");
});
test("values are reset between test", async () => {
expect(document.cookie).toBe("");
expect(document.title).toBe("");
});
test("fetch should not mock internal URLs", async () => {
mockFetch(expect.step);
await fetch("http://some.url");
await fetch("/odoo");
await fetch(URL.createObjectURL(new Blob([""])));
expect.verifySteps(["http://some.url", "/odoo"]);
});
});

View file

@ -0,0 +1,70 @@
/** @odoo-module */
import { after, describe, expect, test } from "@odoo/hoot";
import { queryOne } from "@odoo/hoot-dom";
import { EventBus } from "@odoo/owl";
import { mountForTest, parseUrl } from "../local_helpers";
import { watchListeners } from "@odoo/hoot-mock";
describe(parseUrl(import.meta.url), () => {
class TestBus extends EventBus {
addEventListener(type) {
expect.step(`addEventListener:${type}`);
return super.addEventListener(...arguments);
}
removeEventListener() {
throw new Error("Cannot remove event listeners");
}
}
let testBus;
test("elementFromPoint and elementsFromPoint should be mocked", async () => {
await mountForTest(/* xml */ `
<div class="oui" style="position: absolute; left: 10px; top: 10px; width: 250px; height: 250px;">
Oui
</div>
`);
expect(".oui").toHaveRect({
x: 10,
y: 10,
width: 250,
height: 250,
});
const div = queryOne(".oui");
expect(document.elementFromPoint(11, 11)).toBe(div);
expect(document.elementsFromPoint(11, 11)).toEqual([
div,
document.body,
document.documentElement,
]);
expect(document.elementFromPoint(9, 9)).toBe(document.body);
expect(document.elementsFromPoint(9, 9)).toEqual([document.body, document.documentElement]);
});
// ! WARNING: the following 2 tests need to be run sequentially to work, as they
// ! attempt to test the in-between-tests event listeners cleanup.
test("event listeners are properly removed: setup", async () => {
const callback = () => expect.step("callback");
testBus = new TestBus();
expect.verifySteps([]);
after(watchListeners());
testBus.addEventListener("some-event", callback);
testBus.trigger("some-event");
expect.verifySteps(["addEventListener:some-event", "callback"]);
});
test("event listeners are properly removed: check", async () => {
testBus.trigger("some-event");
expect.verifySteps([]);
});
});

Some files were not shown because too many files have changed in this diff Show more