mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 14:31:59 +02:00
vanilla 18.0
This commit is contained in:
parent
5454004ff9
commit
d7f6d2725e
979 changed files with 428093 additions and 0 deletions
1173
odoo-bringout-oca-ocb-web/web/static/lib/ace/mode-javascript.js
Normal file
1173
odoo-bringout-oca-ocb-web/web/static/lib/ace/mode-javascript.js
Normal file
File diff suppressed because it is too large
Load diff
139
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/backdrop.js
vendored
Normal file
139
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/backdrop.js
vendored
Normal 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
|
||||
42
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/component-functions.js
vendored
Normal file
42
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/component-functions.js
vendored
Normal 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
|
||||
68
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/config.js
vendored
Normal file
68
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/config.js
vendored
Normal 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
|
||||
113
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/focustrap.js
vendored
Normal file
113
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/focustrap.js
vendored
Normal 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
|
||||
281
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/index.js
vendored
Normal file
281
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/index.js
vendored
Normal 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
|
||||
114
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/sanitizer.js
vendored
Normal file
114
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/sanitizer.js
vendored
Normal 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
|
||||
113
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/scrollbar.js
vendored
Normal file
113
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/scrollbar.js
vendored
Normal 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
|
||||
135
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/swipe.js
vendored
Normal file
135
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/swipe.js
vendored
Normal 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
|
||||
151
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/template-factory.js
vendored
Normal file
151
odoo-bringout-oca-ocb-web/web/static/lib/bootstrap/js/dist/util/template-factory.js
vendored
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
*/
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
1563
odoo-bringout-oca-ocb-web/web/static/lib/dompurify/DOMpurify.js
Normal file
1563
odoo-bringout-oca-ocb-web/web/static/lib/dompurify/DOMpurify.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
|
@ -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);
|
||||
|
|
@ -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
2144
odoo-bringout-oca-ocb-web/web/static/lib/hoot-dom/helpers/dom.js
Normal file
2144
odoo-bringout-oca-ocb-web/web/static/lib/hoot-dom/helpers/dom.js
Normal file
File diff suppressed because it is too large
Load diff
2937
odoo-bringout-oca-ocb-web/web/static/lib/hoot-dom/helpers/events.js
Normal file
2937
odoo-bringout-oca-ocb-web/web/static/lib/hoot-dom/helpers/events.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
110
odoo-bringout-oca-ocb-web/web/static/lib/hoot-dom/hoot-dom.js
Normal file
110
odoo-bringout-oca-ocb-web/web/static/lib/hoot-dom/hoot-dom.js
Normal 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";
|
||||
|
|
@ -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;
|
||||
255
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/config.js
Normal file
255
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/config.js
Normal 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);
|
||||
2435
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/expect.js
Normal file
2435
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/expect.js
Normal file
File diff suppressed because it is too large
Load diff
223
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/fixture.js
Normal file
223
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/fixture.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
135
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/job.js
Normal file
135
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/job.js
Normal 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();
|
||||
}
|
||||
}
|
||||
377
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/logger.js
Normal file
377
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/logger.js
Normal 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
|
||||
);
|
||||
1996
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/runner.js
Normal file
1996
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/runner.js
Normal file
File diff suppressed because it is too large
Load diff
127
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/suite.js
Normal file
127
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/suite.js
Normal 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;
|
||||
}
|
||||
}
|
||||
202
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/tag.js
Normal file
202
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/tag.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
161
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/test.js
Normal file
161
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/test.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
202
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/url.js
Normal file
202
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/url.js
Normal 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];
|
||||
}
|
||||
}
|
||||
33
odoo-bringout-oca-ocb-web/web/static/lib/hoot/hoot-mock.js
Normal file
33
odoo-bringout-oca-ocb-web/web/static/lib/hoot/hoot-mock.js
Normal 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";
|
||||
119
odoo-bringout-oca-ocb-web/web/static/lib/hoot/hoot.js
Normal file
119
odoo-bringout-oca-ocb-web/web/static/lib/hoot/hoot.js
Normal 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();
|
||||
2014
odoo-bringout-oca-ocb-web/web/static/lib/hoot/hoot_utils.js
Normal file
2014
odoo-bringout-oca-ocb-web/web/static/lib/hoot/hoot_utils.js
Normal file
File diff suppressed because it is too large
Load diff
20
odoo-bringout-oca-ocb-web/web/static/lib/hoot/main_runner.js
Normal file
20
odoo-bringout-oca-ocb-web/web/static/lib/hoot/main_runner.js
Normal 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;
|
||||
}
|
||||
175
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/animation.js
Normal file
175
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/animation.js
Normal 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))
|
||||
);
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
253
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/date.js
Normal file
253
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/date.js
Normal 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 },
|
||||
});
|
||||
91
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/math.js
Normal file
91
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/math.js
Normal 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);
|
||||
328
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/navigator.js
Normal file
328
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/navigator.js
Normal 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;
|
||||
}
|
||||
1021
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/network.js
Normal file
1021
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/network.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }));
|
||||
}
|
||||
}
|
||||
|
|
@ -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("");
|
||||
}
|
||||
}
|
||||
710
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/window.js
Normal file
710
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/window.js
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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="<p>Iframe text content</p>"></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="<input >"></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="<p>Iframe text content</p>"></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
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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`);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
17
odoo-bringout-oca-ocb-web/web/static/lib/hoot/tests/index.js
Normal file
17
odoo-bringout-oca-ocb-web/web/static/lib/hoot/tests/index.js
Normal 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);
|
||||
|
|
@ -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$/, "");
|
||||
}
|
||||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { after, describe, expect, test } from "@odoo/hoot";
|
||||
import { animationFrame, click, Deferred } from "@odoo/hoot-dom";
|
||||
import { Component, reactive, useState, xml } from "@odoo/owl";
|
||||
import { mountForTest, parseUrl } from "../local_helpers";
|
||||
|
||||
import { logger } from "../../core/logger";
|
||||
import { HootTechnicalValue } from "../../ui/hoot_technical_value";
|
||||
|
||||
const mountTechnicalValue = async (defaultValue) => {
|
||||
const updateValue = async (value) => {
|
||||
state.value = value;
|
||||
await animationFrame();
|
||||
};
|
||||
|
||||
const state = reactive({ value: defaultValue });
|
||||
|
||||
class TechnicalValueParent extends Component {
|
||||
static components = { HootTechnicalValue };
|
||||
static props = {};
|
||||
static template = xml`<HootTechnicalValue value="state.value" />`;
|
||||
|
||||
setup() {
|
||||
this.state = useState(state);
|
||||
}
|
||||
}
|
||||
|
||||
await mountForTest(TechnicalValueParent);
|
||||
|
||||
return updateValue;
|
||||
};
|
||||
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
test("technical value with primitive values", async () => {
|
||||
const updateValue = await mountTechnicalValue("oui");
|
||||
expect(".hoot-string").toHaveText(`"oui"`);
|
||||
|
||||
await updateValue(`"stringified"`);
|
||||
expect(".hoot-string").toHaveText(`'"stringified"'`);
|
||||
|
||||
await updateValue(3);
|
||||
expect(".hoot-integer").toHaveText(`3`);
|
||||
|
||||
await updateValue(undefined);
|
||||
expect(".hoot-undefined").toHaveText(`undefined`);
|
||||
|
||||
await updateValue(null);
|
||||
expect(".hoot-null").toHaveText(`null`);
|
||||
});
|
||||
|
||||
test("technical value with objects", async () => {
|
||||
const logDebug = logger.debug;
|
||||
logger.debug = expect.step;
|
||||
after(() => (logger.debug = logDebug));
|
||||
|
||||
const updateValue = await mountTechnicalValue({});
|
||||
expect(".hoot-technical").toHaveText(`Object(0)`);
|
||||
|
||||
await updateValue([1, 2, "3"]);
|
||||
|
||||
expect(".hoot-technical").toHaveText(`Array(3)`);
|
||||
expect.verifySteps([]);
|
||||
|
||||
await click(".hoot-object");
|
||||
await animationFrame();
|
||||
|
||||
expect(".hoot-technical").toHaveText(`Array(3)[\n1\n,\n2\n,\n"3"\n,\n]`);
|
||||
expect.verifySteps([[1, 2, "3"]]);
|
||||
|
||||
await updateValue({ a: true });
|
||||
expect(".hoot-technical").toHaveText(`Object(1)`);
|
||||
|
||||
await click(".hoot-object");
|
||||
await animationFrame();
|
||||
|
||||
expect(".hoot-technical").toHaveText(`Object(1){\na\n:\ntrue\n,\n}`);
|
||||
|
||||
await updateValue({
|
||||
a: true,
|
||||
sub: {
|
||||
key: "oui",
|
||||
},
|
||||
});
|
||||
expect(".hoot-technical").toHaveText(`Object(2)`);
|
||||
|
||||
await click(".hoot-object:first");
|
||||
await animationFrame();
|
||||
|
||||
expect(".hoot-technical:first").toHaveText(
|
||||
`Object(2){\na\n:\ntrue\n,\nsub\n:\nObject(1)\n}`
|
||||
);
|
||||
expect.verifySteps([]);
|
||||
|
||||
await click(".hoot-object:last");
|
||||
await animationFrame();
|
||||
|
||||
expect(".hoot-technical:first").toHaveText(
|
||||
`Object(2){\na\n:\ntrue\n,\nsub\n:\nObject(1){\nkey\n:\n"oui"\n,\n}\n}`
|
||||
);
|
||||
expect.verifySteps([{ key: "oui" }]);
|
||||
});
|
||||
|
||||
test("technical value with special cases", async () => {
|
||||
const updateValue = await mountTechnicalValue(new Date(0));
|
||||
expect(".hoot-technical").toHaveText(`1970-01-01T00:00:00.000Z`);
|
||||
|
||||
await updateValue(/ab[c]/gi);
|
||||
expect(".hoot-technical").toHaveText(`/ab[c]/gi`);
|
||||
|
||||
const def = new Deferred(() => {});
|
||||
await updateValue(def);
|
||||
expect(".hoot-technical").toHaveText(`Deferred<\npending\n>`);
|
||||
|
||||
def.resolve("oui");
|
||||
await animationFrame();
|
||||
expect(".hoot-technical").toHaveText(`Deferred<\nfulfilled\n:\n"oui"\n>`);
|
||||
});
|
||||
|
||||
test("evaluation of unsafe value does not crash", async () => {
|
||||
const logDebug = logger.debug;
|
||||
logger.debug = () => expect.step("debug");
|
||||
after(() => (logger.debug = logDebug));
|
||||
|
||||
class UnsafeString extends String {
|
||||
toString() {
|
||||
return this.valueOf();
|
||||
}
|
||||
valueOf() {
|
||||
throw new Error("UNSAFE");
|
||||
}
|
||||
}
|
||||
|
||||
await mountTechnicalValue(new UnsafeString("some value"));
|
||||
await click(".hoot-object");
|
||||
|
||||
expect(".hoot-object").toHaveText("UnsafeString(0)", {
|
||||
message: "size is 0 because it couldn't be evaluated",
|
||||
});
|
||||
|
||||
expect.verifySteps(["debug"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { describe, expect, makeExpect, test } from "@odoo/hoot";
|
||||
import { mountForTest, parseUrl } from "../local_helpers";
|
||||
|
||||
import { animationFrame, click } from "@odoo/hoot-dom";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { Runner } from "../../core/runner";
|
||||
import { Test } from "../../core/test";
|
||||
import { HootTestResult } from "../../ui/hoot_test_result";
|
||||
import { makeUiState } from "../../ui/setup_hoot_ui";
|
||||
|
||||
/**
|
||||
* @param {(mockExpect: typeof expect) => any} callback
|
||||
*/
|
||||
const mountTestResults = async (testFn, props) => {
|
||||
const runner = new Runner();
|
||||
const ui = makeUiState();
|
||||
const mockTest = new Test(null, "test", {});
|
||||
const [mockExpect, { after, before }] = makeExpect({});
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { HootTestResult };
|
||||
static props = { test: Test, open: [Boolean, { value: "always" }] };
|
||||
static template = xml`
|
||||
<HootTestResult test="props.test" open="props.open">
|
||||
Toggle
|
||||
</HootTestResult>
|
||||
`;
|
||||
|
||||
mockTest = mockTest;
|
||||
}
|
||||
|
||||
before(mockTest);
|
||||
testFn(mockExpect);
|
||||
after(runner);
|
||||
|
||||
await mountForTest(Parent, {
|
||||
env: { runner, ui },
|
||||
props: {
|
||||
test: mockTest,
|
||||
open: "always",
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
||||
return mockTest;
|
||||
};
|
||||
|
||||
const CLS_PASS = "text-emerald";
|
||||
const CLS_FAIL = "text-rose";
|
||||
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
test("test results: toBe and basic interactions", async () => {
|
||||
const mockTest = await mountTestResults(
|
||||
(mockExpect) => {
|
||||
mockExpect(true).toBe(true);
|
||||
mockExpect(true).toBe(false);
|
||||
},
|
||||
{ open: false }
|
||||
);
|
||||
|
||||
expect(".HootTestResult button:only").toHaveText("Toggle");
|
||||
expect(".hoot-result-detail").not.toHaveCount();
|
||||
expect(mockTest.lastResults.pass).toBe(false);
|
||||
|
||||
await click(".HootTestResult button");
|
||||
await animationFrame();
|
||||
|
||||
expect(".hoot-result-detail").toHaveCount(1);
|
||||
|
||||
// First assertion: pass
|
||||
expect(`.hoot-result-detail > .${CLS_PASS}`).toHaveText(
|
||||
/received value is strictly equal to true/,
|
||||
{ inline: true }
|
||||
);
|
||||
|
||||
// Second assertion: fail
|
||||
expect(`.hoot-result-detail > .${CLS_FAIL}`).toHaveText(
|
||||
/expected values to be strictly equal/,
|
||||
{ inline: true }
|
||||
);
|
||||
expect(`.hoot-info .${CLS_PASS}:contains(Expected)`).toHaveCount(1);
|
||||
expect(`.hoot-info .${CLS_FAIL}:contains(Received)`).toHaveCount(1);
|
||||
});
|
||||
test("test results: toEqual", async () => {
|
||||
await mountTestResults((mockExpect) => {
|
||||
mockExpect([1, 2, { a: true }]).toEqual([1, 2, { a: true }]);
|
||||
mockExpect([1, { a: false }, 3]).toEqual([1, { a: true }, 3]);
|
||||
});
|
||||
|
||||
expect(".hoot-result-detail").toHaveCount(1);
|
||||
|
||||
// First assertion: pass
|
||||
expect(`.hoot-result-detail > .${CLS_PASS}`).toHaveText(
|
||||
/received value is deeply equal to \[1, 2, { a: true }\]/,
|
||||
{ inline: true }
|
||||
);
|
||||
|
||||
// Second assertion: fail
|
||||
expect(`.hoot-result-detail > .${CLS_FAIL}`).toHaveText(
|
||||
/expected values to be deeply equal/,
|
||||
{ inline: true }
|
||||
);
|
||||
expect(`.hoot-info .${CLS_PASS}:contains(Expected)`).toHaveCount(1);
|
||||
expect(`.hoot-info .${CLS_FAIL}:contains(Received)`).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("test results: toHaveCount", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<span class="text" >abc</span>
|
||||
<span class="text" >bcd</span>
|
||||
`);
|
||||
await mountTestResults((mockExpect) => {
|
||||
mockExpect(".text").toHaveCount(2);
|
||||
mockExpect(".text").toHaveCount(1);
|
||||
});
|
||||
|
||||
expect(".hoot-result-detail").toHaveCount(1);
|
||||
|
||||
// First assertion: pass
|
||||
expect(`.hoot-result-detail > .${CLS_PASS}`).toHaveText(
|
||||
/found 2 elements matching ".text"/,
|
||||
{ inline: true }
|
||||
);
|
||||
|
||||
// Second assertion: fail
|
||||
expect(`.hoot-result-detail > .${CLS_FAIL}`).toHaveText(
|
||||
/found 2 elements matching ".text"/,
|
||||
{ inline: true }
|
||||
);
|
||||
expect(`.hoot-info .${CLS_PASS}:contains(Expected)`).toHaveCount(1);
|
||||
expect(`.hoot-info .${CLS_FAIL}:contains(Received)`).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("multiple test results: toHaveText", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<span class="text" >abc</span>
|
||||
<span class="text" >bcd</span>
|
||||
`);
|
||||
await mountTestResults((mockExpect) => {
|
||||
mockExpect(".text:first").toHaveText("abc");
|
||||
mockExpect(".text").toHaveText("abc");
|
||||
mockExpect(".text").not.toHaveText("abc");
|
||||
});
|
||||
|
||||
expect(".hoot-result-detail").toHaveCount(1);
|
||||
|
||||
// First assertion: pass
|
||||
expect(`.hoot-result-detail > .${CLS_PASS}`).toHaveText(
|
||||
/1 element matching ".text:first" has text "abc"/,
|
||||
{ inline: true }
|
||||
);
|
||||
|
||||
// Second assertion: fail
|
||||
expect(`.hoot-result-detail > .${CLS_FAIL}:eq(0)`).toHaveText(
|
||||
/expected 2 elements matching ".text" to have the given text/,
|
||||
{ inline: true }
|
||||
);
|
||||
expect(".hoot-info:eq(0) .hoot-html").toHaveCount(2);
|
||||
expect(".hoot-info:eq(0) .hoot-html").toHaveText("<span.text/>");
|
||||
expect(`.hoot-info:eq(0) .${CLS_PASS}:contains(Received)`).toHaveCount(1);
|
||||
expect(`.hoot-info:eq(0) .${CLS_PASS}:contains(Expected)`).toHaveCount(1);
|
||||
expect(`.hoot-info:eq(0) .${CLS_FAIL}:contains(Received)`).toHaveCount(1);
|
||||
|
||||
// Third assertion: fail
|
||||
expect(`.hoot-result-detail > .${CLS_FAIL}:eq(1)`).toHaveText(
|
||||
/expected 2 elements matching ".text" not to have the given text/,
|
||||
{ inline: true }
|
||||
);
|
||||
expect(".hoot-info:eq(1) .hoot-html").toHaveCount(2);
|
||||
expect(".hoot-info:eq(1) .hoot-html").toHaveText("<span.text/>");
|
||||
expect(`.hoot-info:eq(1) .${CLS_PASS}:contains(Received)`).toHaveCount(1);
|
||||
expect(`.hoot-info:eq(1) .${CLS_PASS}:contains(Expected)`).toHaveCount(1);
|
||||
expect(`.hoot-info:eq(1) .${CLS_FAIL}:contains(Received)`).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
185
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_buttons.js
Normal file
185
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_buttons.js
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
import { refresh, subscribeToURLParams } from "../core/url";
|
||||
import { STORAGE, storageSet } from "../hoot_utils";
|
||||
import { HootLink } from "./hoot_link";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* }} HootButtonsProps
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
clearTimeout,
|
||||
Object: { keys: $keys },
|
||||
setTimeout,
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const DISABLE_TIMEOUT = 500;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/** @extends {Component<HootButtonsProps, import("../hoot").Environment>} */
|
||||
export class HootButtons extends Component {
|
||||
static components = { HootLink };
|
||||
|
||||
static props = {};
|
||||
|
||||
static template = xml`
|
||||
<t t-set="isRunning" t-value="runnerState.status === 'running'" />
|
||||
<t t-set="showAll" t-value="env.runner.hasRemovableFilter" />
|
||||
<t t-set="showFailed" t-value="runnerState.failedIds.size" />
|
||||
<t t-set="failedSuites" t-value="getFailedSuiteIds()" />
|
||||
<div
|
||||
class="${HootButtons.name} relative"
|
||||
t-on-pointerenter="onPointerEnter"
|
||||
t-on-pointerleave="onPointerLeave"
|
||||
>
|
||||
<div class="flex rounded gap-px overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center bg-btn gap-2 px-2 py-1 transition-colors"
|
||||
t-on-click.stop="onRunClick"
|
||||
t-att-title="isRunning ? 'Stop (Esc)' : 'Run'"
|
||||
t-att-disabled="state.disable"
|
||||
>
|
||||
<i t-attf-class="fa fa-{{ isRunning ? 'stop' : 'play' }}" />
|
||||
<span t-esc="isRunning ? 'Stop' : 'Run'" />
|
||||
</button>
|
||||
<t t-if="showAll or showFailed">
|
||||
<button
|
||||
type="button"
|
||||
class="bg-btn px-2 py-1 transition-colors animate-slide-left"
|
||||
t-on-click.stop="onToggleClick"
|
||||
>
|
||||
<i class="fa fa-caret-down transition" t-att-class="{ 'rotate-180': state.open }" />
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
<t t-if="state.open">
|
||||
<div
|
||||
class="animate-slide-down w-fit absolute flex flex-col end-0 shadow rounded overflow-hidden shadow z-2"
|
||||
>
|
||||
<t t-if="showAll">
|
||||
<HootLink class="'bg-btn p-2 whitespace-nowrap transition-colors'">
|
||||
Run <strong>all</strong> tests
|
||||
</HootLink>
|
||||
</t>
|
||||
<t t-if="showFailed">
|
||||
<HootLink
|
||||
ids="{ id: runnerState.failedIds }"
|
||||
class="'bg-btn p-2 whitespace-nowrap transition-colors'"
|
||||
title="'Run failed tests'"
|
||||
onClick="onRunFailedClick"
|
||||
>
|
||||
Run failed <strong>tests</strong>
|
||||
</HootLink>
|
||||
<HootLink
|
||||
ids="{ id: failedSuites }"
|
||||
class="'bg-btn p-2 whitespace-nowrap transition-colors'"
|
||||
title="'Run failed suites'"
|
||||
onClick="onRunFailedClick"
|
||||
>
|
||||
Run failed <strong>suites</strong>
|
||||
</HootLink>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setup() {
|
||||
const { runner } = this.env;
|
||||
this.state = useState({
|
||||
disable: false,
|
||||
open: false,
|
||||
});
|
||||
this.runnerState = useState(runner.state);
|
||||
this.disableTimeout = 0;
|
||||
|
||||
subscribeToURLParams(...$keys(runner.config));
|
||||
}
|
||||
|
||||
getFailedSuiteIds() {
|
||||
const { tests } = this.env.runner;
|
||||
const suiteIds = [];
|
||||
for (const id of this.runnerState.failedIds) {
|
||||
const test = tests.get(id);
|
||||
if (test && !suiteIds.includes(test.parent.id)) {
|
||||
suiteIds.push(test.parent.id);
|
||||
}
|
||||
}
|
||||
return suiteIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} ev
|
||||
*/
|
||||
onPointerLeave(ev) {
|
||||
if (ev.pointerType !== "mouse") {
|
||||
return;
|
||||
}
|
||||
this.state.open = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} ev
|
||||
*/
|
||||
onPointerEnter(ev) {
|
||||
if (ev.pointerType !== "mouse") {
|
||||
return;
|
||||
}
|
||||
if (!this.isRunning) {
|
||||
this.state.open = true;
|
||||
}
|
||||
}
|
||||
|
||||
onRunClick() {
|
||||
const { runner } = this.env;
|
||||
switch (runner.state.status) {
|
||||
case "done": {
|
||||
refresh();
|
||||
break;
|
||||
}
|
||||
case "ready": {
|
||||
if (runner.config.manual) {
|
||||
runner.manualStart();
|
||||
} else {
|
||||
refresh();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "running": {
|
||||
runner.stop();
|
||||
if (this.disableTimeout) {
|
||||
clearTimeout(this.disableTimeout);
|
||||
}
|
||||
this.state.disable = true;
|
||||
this.disableTimeout = setTimeout(
|
||||
() => (this.state.disable = false),
|
||||
DISABLE_TIMEOUT
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onRunFailedClick() {
|
||||
storageSet(STORAGE.failed, []);
|
||||
}
|
||||
|
||||
onToggleClick() {
|
||||
this.state.open = !this.state.open;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { reactive, useState } from "@odoo/owl";
|
||||
import { getAllColors, getPreferredColorScheme } from "../../hoot-dom/hoot_dom_utils";
|
||||
import { STORAGE, storageGet, storageSet } from "../hoot_utils";
|
||||
|
||||
/**
|
||||
* @typedef {"dark" | "light"} ColorScheme
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Object: { entries: $entries, keys: $keys },
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/** @type {ColorScheme[]} */
|
||||
const COLOR_SCHEMES = $keys(getAllColors()).filter((key) => key !== "default");
|
||||
|
||||
/** @type {ColorScheme} */
|
||||
let defaultScheme = storageGet(STORAGE.scheme);
|
||||
if (!COLOR_SCHEMES.includes(defaultScheme)) {
|
||||
defaultScheme = getPreferredColorScheme();
|
||||
storageSet(STORAGE.scheme, defaultScheme);
|
||||
}
|
||||
|
||||
const colorChangedCallbacks = [
|
||||
() => {
|
||||
const { classList } = current.root;
|
||||
classList.remove(...COLOR_SCHEMES);
|
||||
classList.add(current.scheme);
|
||||
},
|
||||
];
|
||||
const current = reactive(
|
||||
{
|
||||
/** @type {HTMLElement | null} */
|
||||
root: null,
|
||||
scheme: defaultScheme,
|
||||
},
|
||||
() => {
|
||||
if (!current.root) {
|
||||
return;
|
||||
}
|
||||
for (const callback of colorChangedCallbacks) {
|
||||
callback(current.scheme);
|
||||
}
|
||||
}
|
||||
);
|
||||
current.root;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export function generateStyleSheets() {
|
||||
/** @type {Record<string, string>} */
|
||||
const styles = {};
|
||||
for (const [scheme, values] of $entries(getAllColors())) {
|
||||
const content = [];
|
||||
for (const [key, value] of $entries(values)) {
|
||||
content.push(`--${key}:${value};`);
|
||||
}
|
||||
styles[scheme] = content.join("");
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
export function getColorScheme() {
|
||||
return current.scheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(scheme: ColorScheme) => any} callback
|
||||
*/
|
||||
export function onColorSchemeChange(callback) {
|
||||
colorChangedCallbacks.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement | null} element
|
||||
*/
|
||||
export function setColorRoot(element) {
|
||||
current.root = element;
|
||||
}
|
||||
|
||||
export function toggleColorScheme() {
|
||||
current.scheme = COLOR_SCHEMES.at(COLOR_SCHEMES.indexOf(current.scheme) - 1);
|
||||
storageSet(STORAGE.scheme, current.scheme);
|
||||
}
|
||||
|
||||
export function useColorScheme() {
|
||||
return useState(current);
|
||||
}
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
import { CONFIG_KEYS } from "../core/config";
|
||||
import { LOG_LEVELS } from "../core/logger";
|
||||
import { refresh } from "../core/url";
|
||||
import { CASE_EVENT_TYPES, strictEqual } from "../hoot_utils";
|
||||
import { generateSeed, internalRandom } from "../mock/math";
|
||||
import { toggleColorScheme, useColorScheme } from "./hoot_colors";
|
||||
import { HootCopyButton } from "./hoot_copy_button";
|
||||
|
||||
/**
|
||||
* @typedef {"dark" | "light"} ColorScheme
|
||||
*
|
||||
* @typedef {{
|
||||
* }} HootConfigMenuProps
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Object: { entries: $entries, keys: $keys, values: $values },
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/** @extends {Component<HootConfigMenuProps, import("../hoot").Environment>} */
|
||||
export class HootConfigMenu extends Component {
|
||||
static components = { HootCopyButton };
|
||||
static props = {};
|
||||
static template = xml`
|
||||
<form class="contents" t-on-submit.prevent="refresh">
|
||||
<h3 class="pb-1 border-b text-gray border-gray">Behavior</h3>
|
||||
<t t-if="hasPresets()">
|
||||
<div class="flex items-center gap-1">
|
||||
<t t-set="hasCorrectViewPort" t-value="env.runner.checkPresetForViewPort()" />
|
||||
<t t-set="highlightClass" t-value="hasCorrectViewPort ? 'text-primary' : 'text-amber'" />
|
||||
<span class="me-auto">Preset</span>
|
||||
<t t-foreach="env.runner.presets" t-as="presetKey" t-key="presetKey">
|
||||
<t t-set="preset" t-value="env.runner.presets[presetKey]" />
|
||||
<button
|
||||
type="button"
|
||||
class="border rounded transition-colors hover:bg-gray-300 dark:hover:bg-gray-700"
|
||||
t-att-class="{ ['border-primary ' + highlightClass]: config.preset === presetKey }"
|
||||
t-att-title="presetKey ? preset.label : 'No preset'"
|
||||
t-on-click.stop="() => this.onPresetChange(presetKey)"
|
||||
>
|
||||
<i t-attf-class="fa w-5 h-5 {{ preset.icon or 'fa-ban' }}" />
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
title="Determines the order of the tests execution"
|
||||
>
|
||||
<span class="me-auto">Execution order</span>
|
||||
<t t-foreach="executionOrders" t-as="order" t-key="order.value">
|
||||
<button
|
||||
type="button"
|
||||
class="border rounded transition-colors hover:bg-gray-300 dark:hover:bg-gray-700"
|
||||
t-att-class="{ 'text-primary border-primary': config.order === order.value }"
|
||||
t-att-title="order.title"
|
||||
t-on-click.stop="() => this.setExecutionOrder(order.value)"
|
||||
>
|
||||
<i class="fa w-5 h-5" t-att-class="{ [order.icon]: true }"/>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
<t t-if="config.order === 'random'">
|
||||
<small class="flex items-center p-1 pt-0 gap-1">
|
||||
<span class="text-gray whitespace-nowrap ms-1">Seed:</span>
|
||||
<input
|
||||
type="text"
|
||||
autofocus=""
|
||||
class="w-full outline-none border-b border-primary px-1"
|
||||
t-model.number="config.random"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title="Generate new random seed"
|
||||
t-on-click.stop="resetSeed"
|
||||
>
|
||||
<i class="fa fa-repeat" />
|
||||
</button>
|
||||
<HootCopyButton text="config.random.toString()" />
|
||||
</small>
|
||||
</t>
|
||||
<label
|
||||
class="flex items-center gap-3"
|
||||
title="Sets test timeout value (in milliseconds)"
|
||||
>
|
||||
<span class="shrink-0">Test timeout</span>
|
||||
<input
|
||||
type="text"
|
||||
class="outline-none border-b border-primary px-1 w-full"
|
||||
t-model.number="config.timeout"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
class="flex items-center gap-3"
|
||||
title="Sets network delay (in milliseconds)"
|
||||
>
|
||||
<span class="shrink-0">Network delay</span>
|
||||
<input
|
||||
type="text"
|
||||
class="outline-none border-b border-primary px-1 w-full"
|
||||
t-model="config.networkDelay"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
class="cursor-pointer flex items-center gap-1 hover:bg-gray-300 dark:hover:bg-gray-700"
|
||||
title="Awaits user input before running the tests"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="appearance-none border border-primary rounded-sm w-4 h-4"
|
||||
t-model="config.manual"
|
||||
/>
|
||||
<span>Run tests manually</span>
|
||||
</label>
|
||||
<label
|
||||
class="cursor-pointer flex items-center gap-1 hover:bg-gray-300 dark:hover:bg-gray-700"
|
||||
title="Re-run current tests and abort after a given amount of failed tests"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="appearance-none border border-primary rounded-sm w-4 h-4"
|
||||
t-att-checked="config.bail"
|
||||
t-on-change="onBailChange"
|
||||
/>
|
||||
<span>Bail</span>
|
||||
</label>
|
||||
<t t-if="config.bail">
|
||||
<small class="flex items-center p-1 pt-0 gap-1">
|
||||
<span class="text-gray whitespace-nowrap ms-1">Failed tests:</span>
|
||||
<input
|
||||
type="text"
|
||||
autofocus=""
|
||||
class="outline-none w-full border-b border-primary px-1"
|
||||
t-model.number="config.bail"
|
||||
/>
|
||||
</small>
|
||||
</t>
|
||||
<label
|
||||
class="cursor-pointer flex items-center gap-1 hover:bg-gray-300 dark:hover:bg-gray-700"
|
||||
title="Controls the verbosity of the logs"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="appearance-none border border-primary rounded-sm w-4 h-4"
|
||||
t-att-checked="config.loglevel"
|
||||
t-on-change="onLogLevelChange"
|
||||
/>
|
||||
<span>Log level</span>
|
||||
</label>
|
||||
<t t-if="config.loglevel">
|
||||
<small class="flex items-center p-1 pt-0 gap-1">
|
||||
<span class="text-gray whitespace-nowrap ms-1">Level:</span>
|
||||
<select
|
||||
autofocus=""
|
||||
class="outline-none w-full bg-base text-base border-b border-primary px-1"
|
||||
t-model.number="config.loglevel"
|
||||
>
|
||||
<t t-foreach="LOG_LEVELS" t-as="level" t-key="level.value">
|
||||
<option
|
||||
t-att-value="level.value"
|
||||
t-esc="level.label"
|
||||
/>
|
||||
</t>
|
||||
</select>
|
||||
</small>
|
||||
</t>
|
||||
<label
|
||||
class="cursor-pointer flex items-center gap-1 hover:bg-gray-300 dark:hover:bg-gray-700"
|
||||
title="Re-run current tests without catching any errors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="appearance-none border border-primary rounded-sm w-4 h-4"
|
||||
t-model="config.notrycatch"
|
||||
/>
|
||||
<span>No try/catch</span>
|
||||
</label>
|
||||
|
||||
<!-- Display -->
|
||||
<h3 class="mt-2 pb-1 border-b text-gray border-gray">Display</h3>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="me-auto">Events</span>
|
||||
<t t-foreach="CASE_EVENT_TYPES" t-as="sType" t-key="sType">
|
||||
<t t-set="isDisplayed" t-value="isEventDisplayed(sType)" />
|
||||
<t t-set="eventColor" t-value="isDisplayed ? CASE_EVENT_TYPES[sType].color : 'gray'" />
|
||||
<button
|
||||
type="button"
|
||||
t-attf-class="p-1 border-b-2 transition-color text-{{ eventColor }} border-{{ eventColor }}"
|
||||
t-attf-title="{{ isDisplayed ? 'Hide' : 'Show' }} {{ sType }} events"
|
||||
t-on-click.stop="(ev) => this.toggleEventType(ev, sType)"
|
||||
>
|
||||
<i class="fa" t-att-class="CASE_EVENT_TYPES[sType].icon" />
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1"
|
||||
t-on-click.stop="toggleSortResults"
|
||||
>
|
||||
<span class="me-auto">Sort by duration</span>
|
||||
<span
|
||||
class="flex items-center gap-1 transition-colors"
|
||||
t-att-class="{ 'text-primary': uiState.sortResults }"
|
||||
>
|
||||
<t t-if="uiState.sortResults === 'asc'">
|
||||
ascending
|
||||
</t>
|
||||
<t t-elif="uiState.sortResults === 'desc'">
|
||||
descending
|
||||
</t>
|
||||
<t t-else="">
|
||||
none
|
||||
</t>
|
||||
<i t-attf-class="fa fa-sort-numeric-{{ uiState.sortResults or 'desc' }}" />
|
||||
</span>
|
||||
</button>
|
||||
<label
|
||||
class="cursor-pointer flex items-center gap-1 hover:bg-gray-300 dark:hover:bg-gray-700"
|
||||
title="Re-run current tests in headless mode (no UI)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="appearance-none border border-primary rounded-sm w-4 h-4"
|
||||
t-model="config.headless"
|
||||
/>
|
||||
<span>Headless</span>
|
||||
</label>
|
||||
<label
|
||||
class="cursor-pointer flex items-center gap-1 hover:bg-gray-300 dark:hover:bg-gray-700"
|
||||
title='Activates "incentives" to help you stay motivated'
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="appearance-none border border-primary rounded-sm w-4 h-4"
|
||||
t-model="config.fun"
|
||||
/>
|
||||
<span>Enable incentives</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 hover:bg-gray-300 dark:hover:bg-gray-700"
|
||||
title="Toggle the color scheme of the UI"
|
||||
t-on-click.stop="toggleColorScheme"
|
||||
>
|
||||
<i t-attf-class="fa fa-{{ color.scheme === 'light' ? 'moon' : 'sun' }}-o w-4 h-4" />
|
||||
Color scheme
|
||||
</button>
|
||||
|
||||
<!-- Refresh button -->
|
||||
<button
|
||||
class="flex bg-btn justify-center rounded mt-1 p-1 transition-colors"
|
||||
t-att-disabled="doesNotNeedRefresh()"
|
||||
>
|
||||
Apply and refresh
|
||||
</button>
|
||||
</form>
|
||||
`;
|
||||
|
||||
CASE_EVENT_TYPES = CASE_EVENT_TYPES;
|
||||
|
||||
executionOrders = [
|
||||
{ value: "fifo", title: "First in, first out", icon: "fa-sort-numeric-asc" },
|
||||
{ value: "lifo", title: "Last in, first out", icon: "fa-sort-numeric-desc" },
|
||||
{ value: "random", title: "Random", icon: "fa-random" },
|
||||
];
|
||||
LOG_LEVELS = $entries(LOG_LEVELS)
|
||||
.filter(([, value]) => value)
|
||||
.map(([label, value]) => ({ label, value }));
|
||||
|
||||
refresh = refresh;
|
||||
toggleColorScheme = toggleColorScheme;
|
||||
|
||||
setup() {
|
||||
const { runner, ui } = this.env;
|
||||
this.color = useColorScheme();
|
||||
this.config = useState(runner.config);
|
||||
this.uiState = useState(ui);
|
||||
}
|
||||
|
||||
doesNotNeedRefresh() {
|
||||
return CONFIG_KEYS.every((key) =>
|
||||
strictEqual(this.config[key], this.env.runner.initialConfig[key])
|
||||
);
|
||||
}
|
||||
|
||||
hasPresets() {
|
||||
return $keys(this.env.runner.presets).filter(Boolean).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {keyof CASE_EVENT_TYPES} sType
|
||||
*/
|
||||
isEventDisplayed(sType) {
|
||||
return this.config.events & CASE_EVENT_TYPES[sType].value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event & { currentTarget: HTMLInputElement }} ev
|
||||
*/
|
||||
onBailChange(ev) {
|
||||
this.config.bail = ev.currentTarget.checked ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event & { currentTarget: HTMLInputElement }} ev
|
||||
*/
|
||||
onLogLevelChange(ev) {
|
||||
this.config.loglevel = ev.currentTarget.checked ? LOG_LEVELS.suites : LOG_LEVELS.runner;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} presetId
|
||||
*/
|
||||
onPresetChange(presetId) {
|
||||
this.config.preset = this.config.preset === presetId ? "" : presetId;
|
||||
}
|
||||
|
||||
resetSeed() {
|
||||
const newSeed = generateSeed();
|
||||
this.config.random = newSeed;
|
||||
internalRandom.seed = newSeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"fifo" | "lifo" | "random"} order
|
||||
*/
|
||||
setExecutionOrder(order) {
|
||||
this.config.order = order;
|
||||
|
||||
if (order === "random" && !this.config.random) {
|
||||
this.resetSeed();
|
||||
} else if (this.config.random) {
|
||||
this.config.random = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} ev
|
||||
* @param {import("../core/expect").CaseEventType} sType
|
||||
*/
|
||||
toggleEventType(ev, sType) {
|
||||
const nType = CASE_EVENT_TYPES[sType].value;
|
||||
if (this.config.events & nType) {
|
||||
if (ev.altKey) {
|
||||
this.config.events = 0;
|
||||
} else {
|
||||
this.config.events &= ~nType;
|
||||
}
|
||||
} else {
|
||||
if (ev.altKey) {
|
||||
// Aggregate all event types
|
||||
this.config.events = $values(CASE_EVENT_TYPES).reduce((acc, t) => acc + t.value, 0);
|
||||
} else {
|
||||
this.config.events |= nType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleSortResults() {
|
||||
this.uiState.resultsPage = 0;
|
||||
if (!this.uiState.sortResults) {
|
||||
this.uiState.sortResults = "desc";
|
||||
} else if (this.uiState.sortResults === "desc") {
|
||||
this.uiState.sortResults = "asc";
|
||||
} else {
|
||||
this.uiState.sortResults = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
import { copy, hasClipboard } from "../hoot_utils";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* altText?: string;
|
||||
* text: string;
|
||||
* }} HootCopyButtonProps
|
||||
*/
|
||||
|
||||
/** @extends {Component<HootCopyButtonProps, import("../hoot").Environment>} */
|
||||
export class HootCopyButton extends Component {
|
||||
static props = {
|
||||
altText: { type: String, optional: true },
|
||||
text: String,
|
||||
};
|
||||
|
||||
static template = xml`
|
||||
<t t-if="hasClipboard()">
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-500"
|
||||
t-att-class="{ 'text-emerald': state.copied }"
|
||||
title="copy to clipboard"
|
||||
t-on-click.stop="onClick"
|
||||
>
|
||||
<i class="fa fa-clipboard" />
|
||||
</button>
|
||||
</t>
|
||||
`;
|
||||
|
||||
hasClipboard = hasClipboard;
|
||||
|
||||
setup() {
|
||||
this.state = useState({ copied: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} ev
|
||||
*/
|
||||
async onClick(ev) {
|
||||
const text = ev.altKey && this.props.altText ? this.props.altText : this.props.text;
|
||||
await copy(text);
|
||||
this.state.copied = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component, onWillRender, useEffect, useRef, useState, xml } from "@odoo/owl";
|
||||
import { Test } from "../core/test";
|
||||
import { refresh } from "../core/url";
|
||||
import { formatTime, throttle } from "../hoot_utils";
|
||||
import { HootConfigMenu } from "./hoot_config_menu";
|
||||
import { HootTestPath } from "./hoot_test_path";
|
||||
import { HootTestResult } from "./hoot_test_result";
|
||||
|
||||
const {
|
||||
HTMLElement,
|
||||
innerHeight,
|
||||
innerWidth,
|
||||
Math: { max: $max, min: $min },
|
||||
Object: { assign: $assign },
|
||||
} = globalThis;
|
||||
const addWindowListener = window.addEventListener.bind(window);
|
||||
const removeWindowListener = window.removeEventListener.bind(window);
|
||||
const { addEventListener, removeEventListener } = HTMLElement.prototype;
|
||||
|
||||
/**
|
||||
* @param {string} containerRefName
|
||||
* @param {string} handleRefName
|
||||
* @param {() => any} allowDrag
|
||||
*/
|
||||
function useMovable(containerRefName, handleRefName, allowDrag) {
|
||||
function computeEffectDependencies() {
|
||||
return [(currentContainer = containerRef.el), (currentHandle = handleRef.el)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} ev
|
||||
*/
|
||||
function drag(ev) {
|
||||
if (!currentContainer || !isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
|
||||
const x = $max($min(maxX, ev.clientX - offsetX), 0);
|
||||
const y = $max($min(maxY, ev.clientY - offsetY), 0);
|
||||
$assign(currentContainer.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} [ev]
|
||||
*/
|
||||
function dragEnd(ev) {
|
||||
if (!currentContainer || !isDragging) {
|
||||
return;
|
||||
}
|
||||
isDragging = false;
|
||||
|
||||
ev?.preventDefault();
|
||||
|
||||
removeWindowListener("pointermove", throttledDrag);
|
||||
removeWindowListener("pointerup", dragEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} ev
|
||||
*/
|
||||
function dragStart(ev) {
|
||||
if (!currentContainer || !allowDrag()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
dragEnd(ev);
|
||||
} else {
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
isDragging = true;
|
||||
|
||||
addWindowListener("pointermove", throttledDrag);
|
||||
addWindowListener("pointerup", dragEnd);
|
||||
addWindowListener("keydown", dragEnd);
|
||||
|
||||
const { x, y, width, height } = currentContainer.getBoundingClientRect();
|
||||
|
||||
$assign(currentContainer.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
});
|
||||
|
||||
offsetX = ev.clientX - x;
|
||||
offsetY = ev.clientY - y;
|
||||
maxX = innerWidth - width;
|
||||
maxY = innerHeight - height;
|
||||
}
|
||||
|
||||
function effectCleanup() {
|
||||
if (currentHandle) {
|
||||
removeEventListener.call(currentHandle, "pointerdown", dragStart);
|
||||
}
|
||||
}
|
||||
|
||||
function onEffect() {
|
||||
if (currentHandle) {
|
||||
addEventListener.call(currentHandle, "pointerdown", dragStart);
|
||||
}
|
||||
return effectCleanup;
|
||||
}
|
||||
|
||||
function resetPosition() {
|
||||
currentContainer?.removeAttribute("style");
|
||||
dragEnd();
|
||||
}
|
||||
|
||||
const throttledDrag = throttle(drag);
|
||||
|
||||
const containerRef = useRef(containerRefName);
|
||||
const handleRef = useRef(handleRefName);
|
||||
/** @type {HTMLElement | null} */
|
||||
let currentContainer = null;
|
||||
/** @type {HTMLElement | null} */
|
||||
let currentHandle = null;
|
||||
let isDragging = false;
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
useEffect(onEffect, computeEffectDependencies);
|
||||
|
||||
return {
|
||||
resetPosition,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {import("../core/expect").Assertion} Assertion
|
||||
*
|
||||
* @typedef {{
|
||||
* test: Test;
|
||||
* }} HootDebugToolBarProps
|
||||
*
|
||||
* @typedef {import("../core/expect").CaseResult} CaseResult
|
||||
*/
|
||||
|
||||
/** @extends {Component<HootDebugToolBarProps, import("../hoot").Environment>} */
|
||||
export class HootDebugToolBar extends Component {
|
||||
static components = { HootConfigMenu, HootTestPath, HootTestResult };
|
||||
|
||||
static props = {
|
||||
test: Test,
|
||||
};
|
||||
|
||||
static template = xml`
|
||||
<div
|
||||
class="${HootDebugToolBar.name} absolute start-0 bottom-0 max-w-full max-h-full flex p-4 z-4"
|
||||
t-att-class="{ 'w-full': state.open }"
|
||||
t-ref="root"
|
||||
>
|
||||
<div class="flex flex-col w-full overflow-hidden rounded shadow bg-gray-200 dark:bg-gray-800">
|
||||
<div class="flex items-center gap-2 px-2">
|
||||
<i
|
||||
class="fa fa-bug text-cyan p-2"
|
||||
t-att-class="{ 'cursor-move': !state.open }"
|
||||
t-ref="handle"
|
||||
/>
|
||||
<div class="flex gap-px rounded my-1 overflow-hidden min-w-fit">
|
||||
<button
|
||||
class="bg-btn px-2 py-1"
|
||||
title="Exit debug mode (Ctrl + Esc)"
|
||||
t-on-click.stop="exitDebugMode"
|
||||
>
|
||||
<i class="fa fa-sign-out" />
|
||||
</button>
|
||||
<t t-if="done">
|
||||
<button
|
||||
class="bg-btn px-2 py-1 animate-slide-left"
|
||||
title="Restart test (F5)"
|
||||
t-on-click.stop="refresh"
|
||||
>
|
||||
<i class="fa fa-refresh" />
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
<button
|
||||
class="flex flex-1 items-center gap-1 truncate"
|
||||
t-on-click.stop="toggleOpen"
|
||||
title="Click to toggle details"
|
||||
>
|
||||
status:
|
||||
<strong
|
||||
t-attf-class="text-{{ info.className }}"
|
||||
t-esc="info.status"
|
||||
/>
|
||||
<span class="hidden sm:flex items-center gap-1">
|
||||
<span class="text-gray">-</span>
|
||||
assertions:
|
||||
<span class="contents text-emerald">
|
||||
<strong t-esc="info.passed" />
|
||||
passed
|
||||
</span>
|
||||
<t t-if="info.failed">
|
||||
<span class="text-gray">/</span>
|
||||
<span class="contents text-rose">
|
||||
<strong t-esc="info.failed" />
|
||||
failed
|
||||
</span>
|
||||
</t>
|
||||
</span>
|
||||
<span class="text-gray">-</span>
|
||||
time:
|
||||
<span
|
||||
class="text-primary"
|
||||
t-esc="formatTime(props.test.lastResults?.duration, 'ms')"
|
||||
/>
|
||||
</button>
|
||||
<button class="p-2" t-on-click="toggleConfig">
|
||||
<i class="fa fa-cog" />
|
||||
</button>
|
||||
</div>
|
||||
<t t-if="state.open">
|
||||
<div class="flex flex-col w-full sm:flex-row overflow-auto">
|
||||
<HootTestResult open="'always'" test="props.test" t-key="done">
|
||||
<HootTestPath canCopy="true" full="true" test="props.test" />
|
||||
</HootTestResult>
|
||||
<t t-if="state.configOpen">
|
||||
<div class="flex flex-col gap-1 p-3 overflow-y-auto">
|
||||
<HootConfigMenu />
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
formatTime = formatTime;
|
||||
refresh = refresh;
|
||||
|
||||
get done() {
|
||||
return Boolean(this.runnerState.done.size); // subscribe to test being added as done
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.runnerState = useState(this.env.runner.state);
|
||||
this.state = useState({
|
||||
configOpen: false,
|
||||
open: false,
|
||||
});
|
||||
|
||||
onWillRender(this.onWillRender.bind(this));
|
||||
|
||||
this.movable = useMovable("root", "handle", this.allowDrag.bind(this));
|
||||
}
|
||||
|
||||
allowDrag() {
|
||||
return !this.state.open;
|
||||
}
|
||||
|
||||
exitDebugMode() {
|
||||
const { runner } = this.env;
|
||||
runner.config.debugTest = false;
|
||||
runner.stop();
|
||||
}
|
||||
|
||||
getInfo() {
|
||||
const [status, className] = this.getStatus();
|
||||
const [assertPassed, assertFailed] = this.groupAssertions(
|
||||
this.props.test.lastResults?.getEvents("assertion")
|
||||
);
|
||||
return {
|
||||
className,
|
||||
status,
|
||||
passed: assertPassed,
|
||||
failed: assertFailed,
|
||||
};
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
if (this.props.test.lastResults) {
|
||||
switch (this.props.test.status) {
|
||||
case Test.PASSED:
|
||||
return ["passed", "emerald"];
|
||||
case Test.FAILED:
|
||||
return ["failed", "rose"];
|
||||
case Test.ABORTED:
|
||||
return ["aborted", "amber"];
|
||||
}
|
||||
}
|
||||
return ["running", "cyan"];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Assertion[]} [assertions]
|
||||
*/
|
||||
groupAssertions(assertions) {
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
for (const assertion of assertions || []) {
|
||||
if (assertion.pass) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return [passed, failed];
|
||||
}
|
||||
|
||||
onWillRender() {
|
||||
this.info = this.getInfo();
|
||||
}
|
||||
|
||||
toggleConfig() {
|
||||
this.state.configOpen = !this.state.open || !this.state.configOpen;
|
||||
if (this.state.configOpen && !this.state.open) {
|
||||
this.state.open = true;
|
||||
this.movable.resetPosition();
|
||||
}
|
||||
}
|
||||
|
||||
toggleOpen() {
|
||||
this.state.open = !this.state.open;
|
||||
if (this.state.open) {
|
||||
this.movable.resetPosition();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component, useRef, useState, xml } from "@odoo/owl";
|
||||
import { useAutofocus, useHootKey, useWindowListener } from "../hoot_utils";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* buttonClassName?: string:
|
||||
* className?: string:
|
||||
* slots: Record<string, any>;
|
||||
* }} HootDropdownProps
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/** @extends {Component<HootDropdownProps, import("../hoot").Environment>} */
|
||||
export class HootDropdown extends Component {
|
||||
static template = xml`
|
||||
<div class="${HootDropdown.name} relative" t-att-class="props.className" t-ref="root">
|
||||
<button
|
||||
t-ref="toggler"
|
||||
class="flex rounded p-2 transition-colors"
|
||||
t-att-class="props.buttonClassName"
|
||||
>
|
||||
<t t-slot="toggler" open="state.open" />
|
||||
</button>
|
||||
<t t-if="state.open">
|
||||
<div
|
||||
class="
|
||||
hoot-dropdown absolute animate-slide-down
|
||||
flex flex-col end-0 p-3 gap-2
|
||||
bg-base text-base mt-1 shadow rounded z-2"
|
||||
>
|
||||
<button class="fixed end-2 top-2 p-1 text-rose sm:hidden" t-on-click="() => state.open = false">
|
||||
<i class="fa fa-times w-5 h-5" />
|
||||
</button>
|
||||
<t t-slot="menu" open="state.open" />
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
`;
|
||||
static props = {
|
||||
buttonClassName: { type: String, optional: true },
|
||||
className: { type: String, optional: true },
|
||||
slots: {
|
||||
type: Object,
|
||||
shape: {
|
||||
toggler: Object,
|
||||
menu: Object,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.rootRef = useRef("root");
|
||||
this.togglerRef = useRef("toggler");
|
||||
this.state = useState({
|
||||
open: false,
|
||||
});
|
||||
|
||||
useAutofocus(this.rootRef);
|
||||
useHootKey(["Escape"], this.close);
|
||||
useWindowListener(
|
||||
"click",
|
||||
(ev) => {
|
||||
const path = ev.composedPath();
|
||||
if (!path.includes(this.rootRef.el)) {
|
||||
this.state.open = false;
|
||||
} else if (path.includes(this.togglerRef.el)) {
|
||||
this.state.open = !this.state.open;
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
close(ev) {
|
||||
if (this.state.open) {
|
||||
ev.preventDefault();
|
||||
this.state.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { Job } from "../core/job";
|
||||
import { Test } from "../core/test";
|
||||
import { HootLink } from "./hoot_link";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* hidden?: boolean;
|
||||
* job: Job;
|
||||
* }} HootJobButtonsProps
|
||||
*/
|
||||
|
||||
/** @extends {Component<HootJobButtonsProps, import("../hoot").Environment>} */
|
||||
export class HootJobButtons extends Component {
|
||||
static components = { HootLink };
|
||||
|
||||
static props = {
|
||||
hidden: { type: Boolean, optional: true },
|
||||
job: Job,
|
||||
};
|
||||
|
||||
static template = xml`
|
||||
<t t-set="type" t-value="getType()" />
|
||||
<div class="${HootJobButtons.name} items-center gap-1" t-att-class="props.hidden ? 'hidden' : 'flex'">
|
||||
<HootLink
|
||||
ids="{ id: props.job.id }"
|
||||
class="'hoot-btn-link border border-primary text-emerald rounded transition-colors'"
|
||||
title="'Run this ' + type + ' only'"
|
||||
>
|
||||
<i class="fa fa-play w-5 h-5" />
|
||||
</HootLink>
|
||||
<t t-if="type === 'test'">
|
||||
<HootLink
|
||||
ids="{ id: props.job.id }"
|
||||
options="{ debug: true }"
|
||||
class="'hoot-btn-link border border-primary text-emerald rounded transition-colors'"
|
||||
title="'Run this ' + type + ' only in debug mode'"
|
||||
>
|
||||
<i class="fa fa-bug w-5 h-5" />
|
||||
</HootLink>
|
||||
</t>
|
||||
<HootLink
|
||||
ids="{ id: props.job.id }"
|
||||
options="{ ignore: true }"
|
||||
class="'hoot-btn-link border border-primary text-rose rounded transition-colors'"
|
||||
title="'Ignore ' + type"
|
||||
>
|
||||
<i class="fa fa-ban w-5 h-5" />
|
||||
</HootLink>
|
||||
</div>
|
||||
`;
|
||||
|
||||
getType() {
|
||||
return this.props.job instanceof Test ? "test" : "suite";
|
||||
}
|
||||
}
|
||||
116
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_link.js
Normal file
116
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_link.js
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
import { FILTER_SCHEMA } from "../core/config";
|
||||
import { createUrlFromId } from "../core/url";
|
||||
import { ensureArray, INCLUDE_LEVEL } from "../hoot_utils";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* class?: string;
|
||||
* ids?: Record<import("../core/config").SearchFilter, string[]>;
|
||||
* onClick?: (event: PointerEvent) => any;
|
||||
* options?: import("../core/url").CreateUrlFromIdOptions;
|
||||
* slots: { default: any };
|
||||
* style?: string;
|
||||
* target?: string;
|
||||
* title?: string;
|
||||
* }} HootLinkProps
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Object: { entries: $entries },
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Link component which computes its href lazily (i.e. on focus or pointerenter).
|
||||
*
|
||||
* @extends {Component<HootLinkProps, import("../hoot").Environment>}
|
||||
*/
|
||||
export class HootLink extends Component {
|
||||
static template = xml`
|
||||
<a
|
||||
t-att-class="props.class"
|
||||
t-att-href="state.href"
|
||||
t-att-target="props.target"
|
||||
t-att-title="props.title"
|
||||
t-att-style="props.style"
|
||||
t-on-click.stop="onClick"
|
||||
t-on-focus="updateHref"
|
||||
t-on-pointerenter="updateHref"
|
||||
>
|
||||
<t t-slot="default" />
|
||||
</a>
|
||||
`;
|
||||
static props = {
|
||||
class: { type: String, optional: true },
|
||||
ids: {
|
||||
type: Object,
|
||||
values: [String, { type: Array, element: String }],
|
||||
optional: true,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
shape: {
|
||||
debug: { type: Boolean, optional: true },
|
||||
ignore: { type: Boolean, optional: true },
|
||||
},
|
||||
optional: true,
|
||||
},
|
||||
slots: {
|
||||
type: Object,
|
||||
shape: {
|
||||
default: { type: Object, optional: true },
|
||||
},
|
||||
},
|
||||
style: { type: String, optional: true },
|
||||
target: { type: String, optional: true },
|
||||
title: { type: String, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({ href: "#" });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} ev
|
||||
*/
|
||||
onClick(ev) {
|
||||
const { ids, options } = this.props;
|
||||
if (ids && ev.altKey) {
|
||||
const { includeSpecs } = this.env.runner.state;
|
||||
let appliedFilter = false;
|
||||
for (const [type, idOrIds] of $entries(ids)) {
|
||||
if (!(type in FILTER_SCHEMA)) {
|
||||
continue;
|
||||
}
|
||||
const targetValue = options?.ignore ? -INCLUDE_LEVEL.url : +INCLUDE_LEVEL.url;
|
||||
for (const id of ensureArray(idOrIds)) {
|
||||
const finalValue = includeSpecs[type][id] === targetValue ? 0 : targetValue;
|
||||
this.env.runner.include(type, id, finalValue);
|
||||
appliedFilter = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (appliedFilter) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
} else {
|
||||
this.props.onClick?.(ev);
|
||||
}
|
||||
}
|
||||
|
||||
updateHref() {
|
||||
const { ids, options } = this.props;
|
||||
const simplifiedIds = this.env.runner.simplifyUrlIds(ids);
|
||||
this.state.href = createUrlFromId(simplifiedIds, options);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* logs: { error: number, warn: number };
|
||||
* }} HootLogCountersProps
|
||||
*/
|
||||
|
||||
/** @extends {Component<HootLogCountersProps, import("../hoot").Environment>} */
|
||||
export class HootLogCounters extends Component {
|
||||
static components = {};
|
||||
static props = {
|
||||
logs: {
|
||||
type: Object,
|
||||
shape: {
|
||||
error: Number,
|
||||
warn: Number,
|
||||
},
|
||||
},
|
||||
};
|
||||
static template = xml`
|
||||
<t t-if="props.logs.error">
|
||||
<span
|
||||
class="flex items-center gap-1 text-rose"
|
||||
t-attf-title="{{ props.logs.error }} error log(s) (check the console)"
|
||||
>
|
||||
<i class="fa fa-times-circle" />
|
||||
<strong t-esc="props.logs.error" />
|
||||
</span>
|
||||
</t>
|
||||
<t t-if="props.logs.warn">
|
||||
<span
|
||||
class="flex items-center gap-1 text-amber"
|
||||
t-attf-title="{{ props.logs.warn }} warning log(s) (check the console)"
|
||||
>
|
||||
<i class="fa fa-exclamation-triangle" />
|
||||
<strong t-esc="props.logs.warn" />
|
||||
</span>
|
||||
</t>
|
||||
`;
|
||||
}
|
||||
194
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_main.js
Normal file
194
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_main.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
import { createUrl, refresh } from "../core/url";
|
||||
import { callHootKey, useHootKey, useWindowListener } from "../hoot_utils";
|
||||
import { HootButtons } from "./hoot_buttons";
|
||||
import { HootConfigMenu } from "./hoot_config_menu";
|
||||
import { HootDebugToolBar } from "./hoot_debug_toolbar";
|
||||
import { HootDropdown } from "./hoot_dropdown";
|
||||
import { HootReporting } from "./hoot_reporting";
|
||||
import { HootSearch } from "./hoot_search";
|
||||
import { HootSideBar } from "./hoot_side_bar";
|
||||
import { HootStatusPanel } from "./hoot_status_panel";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* }} HootMainProps
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const { setTimeout } = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
// Indenpendant from Hoot style classes since it is not loaded in headless
|
||||
const HEADLESS_CONTAINER_STYLE = [
|
||||
"position: absolute",
|
||||
"bottom: 0",
|
||||
"inset-inline-start: 50%",
|
||||
"transform: translateX(-50%)",
|
||||
"display: flex",
|
||||
"z-index: 4",
|
||||
"margin-bottom: 1rem",
|
||||
"padding-left: 1rem",
|
||||
"padding-right: 1rem",
|
||||
"padding-top: 0.5rem",
|
||||
"padding-bottom: 0.5rem",
|
||||
"gap: 0.5rem",
|
||||
"white-space: nowrap",
|
||||
"border-radius: 9999px",
|
||||
"box-shadow: 2px 1px 5px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)",
|
||||
"background-color: #e2e8f0",
|
||||
].join(";");
|
||||
const HEADLESS_LINK_STYLE = ["color: #714b67", "text-decoration: underline"].join(";");
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/** @extends {Component<HootMainProps, import("../hoot").Environment>} */
|
||||
export class HootMain extends Component {
|
||||
static components = {
|
||||
HootButtons,
|
||||
HootConfigMenu,
|
||||
HootDebugToolBar,
|
||||
HootDropdown,
|
||||
HootReporting,
|
||||
HootSearch,
|
||||
HootSideBar,
|
||||
HootStatusPanel,
|
||||
};
|
||||
|
||||
static props = {};
|
||||
|
||||
static template = xml`
|
||||
<t t-if="env.runner.headless">
|
||||
<div style="${HEADLESS_CONTAINER_STYLE}">
|
||||
Running in headless mode
|
||||
<a style="${HEADLESS_LINK_STYLE}" t-att-href="createUrl({ headless: null })">
|
||||
Run with UI
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<main
|
||||
class="${HootMain.name} flex flex-col w-full h-full bg-base relative"
|
||||
t-att-class="{ 'hoot-animations': env.runner.config.fun }"
|
||||
>
|
||||
<header class="flex flex-col bg-gray-200 dark:bg-gray-800">
|
||||
<nav class="hoot-controls py-1 px-2">
|
||||
<h1
|
||||
class="hoot-logo m-0 select-none"
|
||||
title="Hierarchically Organized Odoo Tests"
|
||||
>
|
||||
<strong class="flex">HOOT</strong>
|
||||
</h1>
|
||||
<HootButtons />
|
||||
<HootSearch />
|
||||
<HootDropdown buttonClassName="'bg-btn'">
|
||||
<t t-set-slot="toggler" t-slot-scope="dropdownState">
|
||||
<i class="fa fa-cog transition" t-att-class="{ 'rotate-90': dropdownState.open }" />
|
||||
</t>
|
||||
<t t-set-slot="menu">
|
||||
<HootConfigMenu />
|
||||
</t>
|
||||
</HootDropdown>
|
||||
</nav>
|
||||
</header>
|
||||
<HootStatusPanel />
|
||||
<div class="flex h-full overflow-y-auto">
|
||||
<HootSideBar />
|
||||
<HootReporting />
|
||||
</div>
|
||||
</main>
|
||||
<t t-if="state.debugTest">
|
||||
<HootDebugToolBar test="state.debugTest" />
|
||||
</t>
|
||||
</t>
|
||||
`;
|
||||
|
||||
createUrl = createUrl;
|
||||
escapeKeyPresses = 0;
|
||||
|
||||
setup() {
|
||||
const { runner } = this.env;
|
||||
this.state = useState({
|
||||
debugTest: null,
|
||||
});
|
||||
|
||||
runner.beforeAll(() => {
|
||||
if (!runner.debug) {
|
||||
return;
|
||||
}
|
||||
if (runner.debug === true) {
|
||||
this.state.debugTest = runner.state.tests[0];
|
||||
} else {
|
||||
this.state.debugTest = runner.debug;
|
||||
}
|
||||
});
|
||||
runner.afterAll(() => {
|
||||
this.state.debugTest = null;
|
||||
});
|
||||
|
||||
useWindowListener("resize", (ev) => this.onWindowResize(ev));
|
||||
useWindowListener("keydown", callHootKey, { capture: true });
|
||||
useHootKey(["Enter"], this.manualStart);
|
||||
useHootKey(["Escape"], this.abort);
|
||||
|
||||
if (!runner.config.headless) {
|
||||
useHootKey(["Alt", "d"], this.toggleDebug);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
abort(ev) {
|
||||
const { runner } = this.env;
|
||||
this.escapeKeyPresses++;
|
||||
setTimeout(() => this.escapeKeyPresses--, 500);
|
||||
|
||||
if (runner.state.status === "running" && this.escapeKeyPresses >= 2) {
|
||||
ev.preventDefault();
|
||||
runner.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
manualStart(ev) {
|
||||
const { runner } = this.env;
|
||||
if (runner.state.status !== "ready") {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
|
||||
if (runner.config.manual) {
|
||||
runner.manualStart();
|
||||
} else {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
onWindowResize() {
|
||||
this.env.runner.checkPresetForViewPort();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
toggleDebug(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
const { runner } = this.env;
|
||||
runner.config.debugTest = !runner.config.debugTest;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,375 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component, onWillRender, useState, xml } from "@odoo/owl";
|
||||
import { Test } from "../core/test";
|
||||
import { formatTime, parseQuery } from "../hoot_utils";
|
||||
import { HootJobButtons } from "./hoot_job_buttons";
|
||||
import { HootLogCounters } from "./hoot_log_counters";
|
||||
import { HootTestPath } from "./hoot_test_path";
|
||||
import { HootTestResult } from "./hoot_test_result";
|
||||
|
||||
/**
|
||||
* @typedef {import("../core/test").Test} Test
|
||||
*
|
||||
* @typedef {{
|
||||
* }} HootReportingProps
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const { Boolean } = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {keyof import("../core/runner").Runner["state"]} varName
|
||||
* @param {string} colorClassName
|
||||
*/
|
||||
const issueTemplate = (varName, colorClassName) => /* xml */ `
|
||||
<t t-foreach="runnerState['${varName}']" t-as="key" t-key="key">
|
||||
<t t-set="issue" t-value="runnerState['${varName}'][key]" />
|
||||
<div
|
||||
class="flex flex-col justify-center px-3 py-2 gap-2 border-gray border-b text-${colorClassName} bg-${colorClassName}-900"
|
||||
t-att-title="issue.message"
|
||||
>
|
||||
<h3 class="flex items-center gap-1 whitespace-nowrap">
|
||||
<span class="min-w-3 min-h-3 rounded-full bg-${colorClassName}" />
|
||||
Global <t t-esc="issue.name" />
|
||||
<span t-if="issue.count > 1">
|
||||
(x<t t-esc="issue.count" />)
|
||||
</span>:
|
||||
<small class="ms-auto text-gray whitespace-nowrap italic font-normal">
|
||||
stack trace available in the console
|
||||
</small>
|
||||
</h3>
|
||||
<ul>
|
||||
<t t-foreach="issue.message.split('\\n')" t-as="messagePart" t-key="messagePart_index">
|
||||
<li class="truncate" t-esc="messagePart" />
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</t>`;
|
||||
|
||||
/**
|
||||
* @param {Test} a
|
||||
* @param {Test} b
|
||||
*/
|
||||
function sortByDurationAscending(a, b) {
|
||||
return a.duration - b.duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Test} a
|
||||
* @param {Test} b
|
||||
*/
|
||||
function sortByDurationDescending(a, b) {
|
||||
return b.duration - a.duration;
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
failed: "text-rose",
|
||||
passed: "text-emerald",
|
||||
skipped: "text-cyan",
|
||||
todo: "text-purple",
|
||||
};
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/** @extends {Component<HootReportingProps, import("../hoot").Environment>} */
|
||||
export class HootReporting extends Component {
|
||||
static components = {
|
||||
HootLogCounters,
|
||||
HootJobButtons,
|
||||
HootTestPath,
|
||||
HootTestResult,
|
||||
};
|
||||
|
||||
static props = {};
|
||||
|
||||
static template = xml`
|
||||
<div class="${HootReporting.name} flex-1 overflow-y-auto">
|
||||
<!-- Errors -->
|
||||
${issueTemplate("globalErrors", "rose")}
|
||||
|
||||
<!-- Warnings -->
|
||||
${issueTemplate("globalWarnings", "amber")}
|
||||
|
||||
<!-- Test results -->
|
||||
<t t-set="resultStart" t-value="uiState.resultsPage * uiState.resultsPerPage" />
|
||||
<t t-foreach="filteredResults.slice(resultStart, resultStart + uiState.resultsPerPage)" t-as="result" t-key="result.id">
|
||||
<HootTestResult
|
||||
open="state.openTests.includes(result.test.id)"
|
||||
test="result.test"
|
||||
>
|
||||
<div class="flex items-center gap-2 overflow-hidden">
|
||||
<HootTestPath canCopy="true" showStatus="true" test="result.test" />
|
||||
<HootLogCounters logs="result.test.logs" />
|
||||
</div>
|
||||
<div class="flex items-center ms-1 gap-2">
|
||||
<small
|
||||
class="whitespace-nowrap"
|
||||
t-attf-class="text-{{ result.test.config.skip ? 'skip' : 'gray' }}"
|
||||
>
|
||||
<t t-if="result.test.config.skip">
|
||||
skipped
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-if="result.test.status === Test.ABORTED">
|
||||
aborted after
|
||||
</t>
|
||||
<t t-esc="formatTime(result.test.duration, 'ms')" />
|
||||
</t>
|
||||
</small>
|
||||
<HootJobButtons job="result.test" />
|
||||
</div>
|
||||
</HootTestResult>
|
||||
</t>
|
||||
|
||||
<!-- "No test" panel -->
|
||||
<t t-if="!filteredResults.length">
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<t t-set="message" t-value="getEmptyMessage()" />
|
||||
<t t-if="message">
|
||||
<em class="p-5 rounded bg-gray-200 dark:bg-gray-800 whitespace-nowrap text-gray">
|
||||
No
|
||||
<span
|
||||
t-if="message.statusFilter"
|
||||
t-att-class="message.statusFilterClassName"
|
||||
t-esc="message.statusFilter"
|
||||
/>
|
||||
tests found
|
||||
<t t-if="message.filter">
|
||||
matching
|
||||
<strong class="text-primary" t-esc="message.filter" />
|
||||
</t>
|
||||
<t t-if="message.selectedSuiteName">
|
||||
in suite
|
||||
<strong class="text-primary" t-esc="message.selectedSuiteName" />
|
||||
</t>.
|
||||
</em>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="flex flex-col gap-3 p-5 rounded bg-gray-200 dark:bg-gray-800">
|
||||
<h3 class="border-b border-gray pb-1">
|
||||
<strong class="text-primary" t-esc="runnerReporting.tests" />
|
||||
/
|
||||
<span class="text-primary" t-esc="runnerState.tests.length" />
|
||||
tests completed
|
||||
</h3>
|
||||
<ul class="flex flex-col gap-2">
|
||||
<t t-if="runnerReporting.passed">
|
||||
<li class="flex gap-1">
|
||||
<button
|
||||
class="flex items-center gap-1 text-emerald"
|
||||
t-on-click.stop="() => this.filterResults('passed')"
|
||||
>
|
||||
<i class="fa fa-check-circle" />
|
||||
<strong t-esc="runnerReporting.passed" />
|
||||
</button>
|
||||
tests passed
|
||||
</li>
|
||||
</t>
|
||||
<t t-if="runnerReporting.failed">
|
||||
<li class="flex gap-1">
|
||||
<button
|
||||
class="flex items-center gap-1 text-rose"
|
||||
t-on-click.stop="() => this.filterResults('failed')"
|
||||
>
|
||||
<i class="fa fa-times-circle" />
|
||||
<strong t-esc="runnerReporting.failed" />
|
||||
</button>
|
||||
tests failed
|
||||
</li>
|
||||
</t>
|
||||
<t t-if="runnerReporting.skipped">
|
||||
<li class="flex gap-1">
|
||||
<button
|
||||
class="flex items-center gap-1 text-cyan"
|
||||
t-on-click.stop="() => this.filterResults('skipped')"
|
||||
>
|
||||
<i class="fa fa-pause-circle" />
|
||||
<strong t-esc="runnerReporting.skipped" />
|
||||
</button>
|
||||
tests skipped
|
||||
</li>
|
||||
</t>
|
||||
<t t-if="runnerReporting.todo">
|
||||
<li class="flex gap-1">
|
||||
<button
|
||||
class="flex items-center gap-1 text-purple"
|
||||
t-on-click.stop="() => this.filterResults('todo')"
|
||||
>
|
||||
<i class="fa fa-exclamation-circle" />
|
||||
<strong t-esc="runnerReporting.todo" />
|
||||
</button>
|
||||
tests to do
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Test = Test;
|
||||
formatTime = formatTime;
|
||||
|
||||
setup() {
|
||||
const { runner, ui } = this.env;
|
||||
|
||||
this.config = useState(runner.config);
|
||||
this.runnerReporting = useState(runner.reporting);
|
||||
this.runnerState = useState(runner.state);
|
||||
this.state = useState({
|
||||
/** @type {string[]} */
|
||||
openGroups: [],
|
||||
/** @type {string[]} */
|
||||
openTests: [],
|
||||
});
|
||||
this.uiState = useState(ui);
|
||||
|
||||
const { showdetail } = this.config;
|
||||
|
||||
let didShowDetail = false;
|
||||
runner.afterPostTest((test) => {
|
||||
if (
|
||||
showdetail &&
|
||||
!(showdetail === "first-fail" && didShowDetail) &&
|
||||
[Test.FAILED, Test.ABORTED].includes(test.status)
|
||||
) {
|
||||
didShowDetail = true;
|
||||
this.state.openTests.push(test.id);
|
||||
}
|
||||
});
|
||||
|
||||
onWillRender(() => {
|
||||
this.filteredResults = this.computeFilteredResults();
|
||||
this.uiState.totalResults = this.filteredResults.length;
|
||||
});
|
||||
}
|
||||
|
||||
computeFilteredResults() {
|
||||
const { selectedSuiteId, sortResults, statusFilter } = this.uiState;
|
||||
|
||||
const queryFilter = this.getQueryFilter();
|
||||
|
||||
const results = [];
|
||||
for (const test of this.runnerState.done) {
|
||||
let matchFilter = false;
|
||||
switch (statusFilter) {
|
||||
case "failed": {
|
||||
matchFilter = !test.config.skip && test.results.some((r) => !r.pass);
|
||||
break;
|
||||
}
|
||||
case "passed": {
|
||||
matchFilter =
|
||||
!test.config.todo && !test.config.skip && test.results.some((r) => r.pass);
|
||||
break;
|
||||
}
|
||||
case "skipped": {
|
||||
matchFilter = test.config.skip;
|
||||
break;
|
||||
}
|
||||
case "todo": {
|
||||
matchFilter = test.config.todo;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
matchFilter = Boolean(selectedSuiteId) || test.results.some((r) => !r.pass);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchFilter && selectedSuiteId) {
|
||||
matchFilter = test.path.some((suite) => suite.id === selectedSuiteId);
|
||||
}
|
||||
if (matchFilter && queryFilter) {
|
||||
matchFilter = queryFilter(test.key);
|
||||
}
|
||||
if (!matchFilter) {
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
duration: test.lastResults?.duration,
|
||||
status: test.status,
|
||||
id: `test#${test.id}`,
|
||||
test: test,
|
||||
});
|
||||
}
|
||||
|
||||
if (!sortResults) {
|
||||
return results;
|
||||
}
|
||||
|
||||
return results.sort(
|
||||
sortResults === "asc" ? sortByDurationAscending : sortByDurationDescending
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {typeof this.uiState.statusFilter} status
|
||||
*/
|
||||
filterResults(status) {
|
||||
this.uiState.resultsPage = 0;
|
||||
if (this.uiState.statusFilter === status) {
|
||||
this.uiState.statusFilter = null;
|
||||
} else {
|
||||
this.uiState.statusFilter = status;
|
||||
}
|
||||
}
|
||||
|
||||
getEmptyMessage() {
|
||||
const { selectedSuiteId, statusFilter } = this.uiState;
|
||||
if (!statusFilter && !selectedSuiteId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
statusFilter,
|
||||
statusFilterClassName: COLORS[statusFilter],
|
||||
filter: this.config.filter,
|
||||
selectedSuiteName: selectedSuiteId && this.env.runner.suites.get(selectedSuiteId).name,
|
||||
};
|
||||
}
|
||||
|
||||
getQueryFilter() {
|
||||
const parsedQuery = parseQuery(this.config.filter || "");
|
||||
if (!parsedQuery.length) {
|
||||
return null;
|
||||
}
|
||||
return (key) =>
|
||||
parsedQuery.every((qp) => {
|
||||
const pass = qp.matchValue(key);
|
||||
return qp.exclude ? !pass : pass;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} ev
|
||||
* @param {string} id
|
||||
*/
|
||||
toggleGroup(ev, id) {
|
||||
const index = this.state.openGroups.indexOf(id);
|
||||
if (ev.altKey) {
|
||||
if (index in this.state.openGroups) {
|
||||
this.state.openGroups = [];
|
||||
} else {
|
||||
this.state.openGroups = this.filteredResults
|
||||
.filter((r) => r.suite)
|
||||
.map((r) => r.suite.id);
|
||||
}
|
||||
} else {
|
||||
if (index in this.state.openGroups) {
|
||||
this.state.openGroups.splice(index, 1);
|
||||
} else {
|
||||
this.state.openGroups.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
890
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_search.js
Normal file
890
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_search.js
Normal file
|
|
@ -0,0 +1,890 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component, onPatched, onWillPatch, useRef, useState, xml } from "@odoo/owl";
|
||||
import { getActiveElement } from "@web/../lib/hoot-dom/helpers/dom";
|
||||
import { R_REGEX, REGEX_MARKER } from "@web/../lib/hoot-dom/hoot_dom_utils";
|
||||
import { Suite } from "../core/suite";
|
||||
import { Tag } from "../core/tag";
|
||||
import { Test } from "../core/test";
|
||||
import { refresh } from "../core/url";
|
||||
import {
|
||||
debounce,
|
||||
EXACT_MARKER,
|
||||
INCLUDE_LEVEL,
|
||||
lookup,
|
||||
parseQuery,
|
||||
R_QUERY_EXACT,
|
||||
STORAGE,
|
||||
storageGet,
|
||||
storageSet,
|
||||
stringify,
|
||||
title,
|
||||
useHootKey,
|
||||
useWindowListener,
|
||||
} from "../hoot_utils";
|
||||
import { HootTagButton } from "./hoot_tag_button";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* }} HootSearchProps
|
||||
*
|
||||
* @typedef {import("../core/config").SearchFilter} SearchFilter
|
||||
*
|
||||
* @typedef {import("../core/tag").Tag} Tag
|
||||
*
|
||||
* @typedef {import("../core/test").Test} Test
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Math: { abs: $abs },
|
||||
Object: { entries: $entries, values: $values },
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {string} query
|
||||
*/
|
||||
function addExact(query) {
|
||||
return EXACT_MARKER + query + EXACT_MARKER;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} query
|
||||
*/
|
||||
function addRegExp(query) {
|
||||
return REGEX_MARKER + query + REGEX_MARKER;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"suite" | "tag" | "test"} category
|
||||
*/
|
||||
function categoryToType(category) {
|
||||
return category === "tag" ? category : "id";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} query
|
||||
*/
|
||||
function removeExact(query) {
|
||||
return query.replaceAll(EXACT_MARKER, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} query
|
||||
*/
|
||||
function removeRegExp(query) {
|
||||
return query.slice(1, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* /!\ Requires "job" and "category" to be in scope
|
||||
*
|
||||
* @param {string} tagName
|
||||
*/
|
||||
const templateIncludeWidget = (tagName) => /* xml */ `
|
||||
<t t-set="type" t-value="category === 'tag' ? category : 'id'" />
|
||||
<t t-set="includeStatus" t-value="runnerState.includeSpecs[type][job.id] or 0" />
|
||||
<t t-set="readonly" t-value="isReadonly(includeStatus)" />
|
||||
|
||||
<${tagName}
|
||||
class="flex items-center gap-1 cursor-pointer select-none"
|
||||
t-on-click.stop="() => this.toggleInclude(type, job.id)"
|
||||
>
|
||||
<div
|
||||
class="hoot-include-widget h-5 p-px flex items-center relative border rounded-full"
|
||||
t-att-class="{
|
||||
'border-gray': readonly,
|
||||
'border-primary': !readonly,
|
||||
'opacity-50': readonly,
|
||||
}"
|
||||
t-att-title="readonly and 'Cannot change because it depends on a tag modifier in the code'"
|
||||
t-on-pointerup="focusSearchInput"
|
||||
t-on-change="(ev) => this.onIncludeChange(type, job.id, ev.target.value)"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="w-4 h-4 cursor-pointer appearance-none"
|
||||
t-att-title="!readonly and 'Exclude'"
|
||||
t-att-disabled="readonly"
|
||||
t-att-name="job.id" value="exclude"
|
||||
t-att-checked="includeStatus lt 0"
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
class="w-4 h-4 cursor-pointer appearance-none"
|
||||
t-att-disabled="readonly"
|
||||
t-att-name="job.id" value="null"
|
||||
t-att-checked="!includeStatus"
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
class="w-4 h-4 cursor-pointer appearance-none"
|
||||
t-att-title="!readonly and 'Include'"
|
||||
t-att-disabled="readonly"
|
||||
t-att-name="job.id" value="include"
|
||||
t-att-checked="includeStatus gt 0"
|
||||
/>
|
||||
</div>
|
||||
<t t-if="isTag(job)">
|
||||
<HootTagButton tag="job" inert="true" />
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span
|
||||
class="flex items-center font-bold whitespace-nowrap overflow-hidden"
|
||||
t-att-title="job.fullName"
|
||||
>
|
||||
<t t-foreach="getShortPath(job.path)" t-as="suite" t-key="suite.id">
|
||||
<span class="text-gray px-1" t-esc="suite.name" />
|
||||
<span class="font-normal">/</span>
|
||||
</t>
|
||||
<t t-set="isSet" t-value="job.id in runnerState.includeSpecs.id" />
|
||||
<span
|
||||
class="truncate px-1"
|
||||
t-att-class="{
|
||||
'font-extrabold': isSet,
|
||||
'text-emerald': includeStatus gt 0,
|
||||
'text-rose': includeStatus lt 0,
|
||||
'text-gray': !isSet and hasIncludeValue,
|
||||
'text-primary': !isSet and !hasIncludeValue,
|
||||
'italic': hasIncludeValue ? includeStatus lte 0 : includeStatus lt 0,
|
||||
}"
|
||||
t-esc="job.name"
|
||||
/>
|
||||
</span>
|
||||
</t>
|
||||
</${tagName}>
|
||||
`;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {ReturnType<typeof useRef<HTMLInputElement>>} ref
|
||||
*/
|
||||
function useKeepSelection(ref) {
|
||||
/**
|
||||
* @param {number} nextOffset
|
||||
*/
|
||||
function keepSelection(nextOffset) {
|
||||
offset = nextOffset || 0;
|
||||
}
|
||||
|
||||
let offset = null;
|
||||
let start = 0;
|
||||
let end = 0;
|
||||
onWillPatch(() => {
|
||||
if (offset === null || !ref.el) {
|
||||
return;
|
||||
}
|
||||
start = ref.el.selectionStart;
|
||||
end = ref.el.selectionEnd;
|
||||
});
|
||||
onPatched(() => {
|
||||
if (offset === null || !ref.el) {
|
||||
return;
|
||||
}
|
||||
ref.el.selectionStart = start + offset;
|
||||
ref.el.selectionEnd = end + offset;
|
||||
offset = null;
|
||||
});
|
||||
|
||||
return keepSelection;
|
||||
}
|
||||
|
||||
const EMPTY_SUITE = new Suite(null, "…", []);
|
||||
const SECRET_SEQUENCE = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65];
|
||||
const RESULT_LIMIT = 5;
|
||||
|
||||
// Template parts, because 16 levels of indent is a bit much
|
||||
|
||||
const TEMPLATE_FILTERS_AND_CATEGORIES = /* xml */ `
|
||||
<div class="flex mb-2">
|
||||
<t t-if="trimmedQuery">
|
||||
<button
|
||||
class="flex items-center gap-1"
|
||||
type="submit"
|
||||
title="Run this filter"
|
||||
t-on-pointerdown="updateFilterParam"
|
||||
>
|
||||
<h4 class="text-primary m-0">
|
||||
Filter using
|
||||
<t t-if="hasRegExpFilter()">
|
||||
regular expression
|
||||
</t>
|
||||
<t t-else="">
|
||||
text
|
||||
</t>
|
||||
</h4>
|
||||
<t t-esc="wrappedQuery()" />
|
||||
</button>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<em class="text-gray ms-1">
|
||||
Start typing to show filters...
|
||||
</em>
|
||||
</t>
|
||||
</div>
|
||||
<t t-foreach="categories" t-as="category" t-key="category">
|
||||
<t t-set="jobs" t-value="state.categories[category][0]" />
|
||||
<t t-set="remainingCount" t-value="state.categories[category][1]" />
|
||||
<t t-if="jobs?.length">
|
||||
<div class="flex flex-col mb-2 max-h-48 overflow-hidden">
|
||||
<h4
|
||||
class="text-primary font-bold flex items-center mb-2"
|
||||
t-esc="title(category)"
|
||||
/>
|
||||
<ul class="flex flex-col overflow-y-auto gap-1">
|
||||
<t t-foreach="jobs" t-as="job" t-key="job.id">
|
||||
${templateIncludeWidget("li")}
|
||||
</t>
|
||||
<t t-if="remainingCount > 0">
|
||||
<div class="italic">
|
||||
<t t-esc="remainingCount" /> more items ...
|
||||
</div>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
`;
|
||||
|
||||
const TEMPLATE_SEARCH_DASHBOARD = /* xml */ `
|
||||
<div class="flex flex-col gap-4 sm:grid sm:grid-cols-3 sm:gap-0">
|
||||
<div class="flex flex-col sm:px-4">
|
||||
<h4 class="text-primary font-bold flex items-center mb-2">
|
||||
<span class="w-full">
|
||||
Recent searches
|
||||
</span>
|
||||
</h4>
|
||||
<ul class="flex flex-col overflow-y-auto gap-1">
|
||||
<t t-foreach="getLatestSearches()" t-as="text" t-key="text_index">
|
||||
<li>
|
||||
<button
|
||||
class="w-full px-2 hover:bg-gray-300 dark:hover:bg-gray-700"
|
||||
type="button"
|
||||
t-on-click.stop="() => this.setQuery(text)"
|
||||
t-esc="text"
|
||||
/>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex flex-col sm:px-4 border-gray sm:border-x">
|
||||
<h4 class="text-primary font-bold flex items-center mb-2">
|
||||
<span class="w-full">
|
||||
Available suites
|
||||
</span>
|
||||
</h4>
|
||||
<ul class="flex flex-col overflow-y-auto gap-1">
|
||||
<t t-foreach="getTop(env.runner.rootSuites)" t-as="job" t-key="job.id">
|
||||
<t t-set="category" t-value="'suite'" />
|
||||
${templateIncludeWidget("li")}
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex flex-col sm:px-4">
|
||||
<h4 class="text-primary font-bold flex items-center mb-2">
|
||||
<span class="w-full">
|
||||
Available tags
|
||||
</span>
|
||||
</h4>
|
||||
<ul class="flex flex-col overflow-y-auto gap-1">
|
||||
<t t-foreach="getTop(env.runner.tags.values())" t-as="job" t-key="job.id">
|
||||
<t t-set="category" t-value="'tag'" />
|
||||
${templateIncludeWidget("li")}
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/** @extends {Component<HootSearchProps, import("../hoot").Environment>} */
|
||||
export class HootSearch extends Component {
|
||||
static components = { HootTagButton };
|
||||
|
||||
static props = {};
|
||||
|
||||
static template = xml`
|
||||
<t t-set="hasIncludeValue" t-value="getHasIncludeValue()" />
|
||||
<t t-set="isRunning" t-value="runnerState.status === 'running'" />
|
||||
<search class="${HootSearch.name} flex-1" t-ref="root" t-on-keydown="onKeyDown">
|
||||
<form class="relative" t-on-submit.prevent="refresh">
|
||||
<div class="hoot-search-bar flex border rounded items-center bg-base px-1 gap-1 w-full transition-colors">
|
||||
<t t-foreach="getCategoryCounts()" t-as="count" t-key="count.category">
|
||||
<button
|
||||
type="button"
|
||||
class="flex border border-primary rounded"
|
||||
t-att-title="count.tip"
|
||||
>
|
||||
<span class="bg-btn px-1 transition-colors" t-esc="count.category" />
|
||||
<span class="mx-1 flex gap-1">
|
||||
<t t-if="count.include.length">
|
||||
<span class="text-emerald" t-esc="count.include.length" />
|
||||
</t>
|
||||
<t t-if="count.exclude.length">
|
||||
<span class="text-rose" t-esc="count.exclude.length" />
|
||||
</t>
|
||||
</span>
|
||||
</button>
|
||||
</t>
|
||||
<input
|
||||
type="search"
|
||||
class="w-full rounded p-1 outline-none"
|
||||
autofocus="autofocus"
|
||||
placeholder="Filter suites, tests or tags"
|
||||
t-ref="search-input"
|
||||
t-att-class="{ 'text-gray': !config.filter }"
|
||||
t-att-disabled="isRunning"
|
||||
t-att-value="state.query"
|
||||
t-on-change="onSearchInputChange"
|
||||
t-on-input="onSearchInputInput"
|
||||
t-on-keydown="onSearchInputKeyDown"
|
||||
/>
|
||||
<label
|
||||
class="hoot-search-icon cursor-pointer p-1"
|
||||
title="Use exact match (Alt + X)"
|
||||
tabindex="0"
|
||||
t-on-keydown="onExactKeyDown"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
t-att-checked="hasExactFilter()"
|
||||
t-att-disabled="isRunning"
|
||||
t-on-change="toggleExact"
|
||||
/>
|
||||
<i class="fa fa-quote-right text-gray transition-colors" />
|
||||
</label>
|
||||
<label
|
||||
class="hoot-search-icon cursor-pointer p-1"
|
||||
title="Use regular expression (Alt + R)"
|
||||
tabindex="0"
|
||||
t-on-keydown="onRegExpKeyDown"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
t-att-checked="hasRegExpFilter()"
|
||||
t-att-disabled="isRunning"
|
||||
t-on-change="toggleRegExp"
|
||||
/>
|
||||
<i class="fa fa-asterisk text-gray transition-colors" />
|
||||
</label>
|
||||
<label
|
||||
class="hoot-search-icon cursor-pointer p-1"
|
||||
title="Debug mode (Alt + D)"
|
||||
t-on-keydown="onDebugKeyDown"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
t-att-checked="config.debugTest"
|
||||
t-att-disabled="isRunning"
|
||||
t-on-change="toggleDebug"
|
||||
/>
|
||||
<i class="fa fa-bug text-gray transition-colors" />
|
||||
</label>
|
||||
</div>
|
||||
<t t-if="state.showDropdown">
|
||||
<div class="hoot-dropdown-lg flex flex-col animate-slide-down bg-base text-base absolute mt-1 p-3 shadow rounded z-2">
|
||||
<t t-if="state.empty">
|
||||
${TEMPLATE_SEARCH_DASHBOARD}
|
||||
</t>
|
||||
<t t-else="">
|
||||
${TEMPLATE_FILTERS_AND_CATEGORIES}
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</form>
|
||||
</search>
|
||||
`;
|
||||
|
||||
categories = ["suite", "test", "tag"];
|
||||
debouncedUpdateSuggestions = debounce(this.updateSuggestions.bind(this), 16);
|
||||
refresh = refresh;
|
||||
title = title;
|
||||
|
||||
get trimmedQuery() {
|
||||
return this.state.query.trim();
|
||||
}
|
||||
|
||||
setup() {
|
||||
const { runner } = this.env;
|
||||
|
||||
runner.beforeAll(() => {
|
||||
this.state.categories = this.findSuggestions();
|
||||
this.state.empty = this.isEmpty();
|
||||
});
|
||||
runner.afterAll(() => this.focusSearchInput());
|
||||
|
||||
this.rootRef = useRef("root");
|
||||
this.searchInputRef = useRef("search-input");
|
||||
|
||||
this.config = useState(runner.config);
|
||||
const query = this.config.filter || "";
|
||||
this.state = useState({
|
||||
categories: {
|
||||
/** @type {Suite[]} */
|
||||
suite: [],
|
||||
/** @type {Tag[]} */
|
||||
tag: [],
|
||||
/** @type {Test[]} */
|
||||
test: [],
|
||||
},
|
||||
disabled: false,
|
||||
empty: !query.trim(),
|
||||
query,
|
||||
showDropdown: false,
|
||||
});
|
||||
this.runnerState = useState(runner.state);
|
||||
|
||||
useHootKey(["Alt", "r"], this.toggleRegExp);
|
||||
useHootKey(["Alt", "x"], this.toggleExact);
|
||||
useHootKey(["Escape"], this.closeDropdown);
|
||||
|
||||
useWindowListener(
|
||||
"click",
|
||||
(ev) => {
|
||||
if (this.runnerState.status !== "running") {
|
||||
const shouldOpen = ev.composedPath().includes(this.rootRef.el);
|
||||
if (shouldOpen && !this.state.showDropdown) {
|
||||
this.debouncedUpdateSuggestions();
|
||||
}
|
||||
this.state.showDropdown = shouldOpen;
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
|
||||
this.keepSelection = useKeepSelection(this.searchInputRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
closeDropdown(ev) {
|
||||
if (!this.state.showDropdown) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
this.state.showDropdown = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} parsedQuery
|
||||
* @param {Map<string, Suite | Tag | Test>} items
|
||||
* @param {SearchFilter} category
|
||||
*/
|
||||
filterItems(parsedQuery, items, category) {
|
||||
const checked = this.runnerState.includeSpecs[category];
|
||||
|
||||
const result = [];
|
||||
const remaining = [];
|
||||
for (const item of items.values()) {
|
||||
const value = $abs(checked[item.id]);
|
||||
if (value === INCLUDE_LEVEL.url) {
|
||||
result.push(item);
|
||||
} else {
|
||||
remaining.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const matching = lookup(parsedQuery, remaining);
|
||||
result.push(...matching.slice(0, RESULT_LIMIT));
|
||||
|
||||
return [result, matching.length - RESULT_LIMIT];
|
||||
}
|
||||
|
||||
findSuggestions() {
|
||||
const { suites, tags, tests } = this.env.runner;
|
||||
const parsedQuery = parseQuery(this.trimmedQuery);
|
||||
return {
|
||||
suite: this.filterItems(parsedQuery, suites, "id"),
|
||||
tag: this.filterItems(parsedQuery, tags, "tag"),
|
||||
test: this.filterItems(parsedQuery, tests, "id"),
|
||||
};
|
||||
}
|
||||
|
||||
focusSearchInput() {
|
||||
this.searchInputRef.el?.focus();
|
||||
}
|
||||
|
||||
getCategoryCounts() {
|
||||
const { includeSpecs } = this.runnerState;
|
||||
const { suites, tests } = this.env.runner;
|
||||
const counts = [];
|
||||
for (const category of this.categories) {
|
||||
const include = [];
|
||||
const exclude = [];
|
||||
for (const [id, value] of $entries(includeSpecs[categoryToType(category)])) {
|
||||
if (
|
||||
(category === "suite" && !suites.has(id)) ||
|
||||
(category === "test" && !tests.has(id))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
switch (value) {
|
||||
case +INCLUDE_LEVEL.url:
|
||||
case +INCLUDE_LEVEL.tag: {
|
||||
include.push(id);
|
||||
break;
|
||||
}
|
||||
case -INCLUDE_LEVEL.url:
|
||||
case -INCLUDE_LEVEL.tag: {
|
||||
exclude.push(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (include.length || exclude.length) {
|
||||
counts.push({ category, tip: `Remove all ${category}`, include, exclude });
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
getHasIncludeValue() {
|
||||
return $values(this.runnerState.includeSpecs).some((values) =>
|
||||
$values(values).some((value) => value > 0)
|
||||
);
|
||||
}
|
||||
|
||||
getLatestSearches() {
|
||||
return storageGet(STORAGE.searches) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {(Suite | Test)[]} path
|
||||
*/
|
||||
getShortPath(path) {
|
||||
if (path.length <= 3) {
|
||||
return path.slice(0, -1);
|
||||
} else {
|
||||
return [path.at(0), EMPTY_SUITE, path.at(-2)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Iterable<Suite | Tag>} items
|
||||
*/
|
||||
getTop(items) {
|
||||
return [...items].sort((a, b) => b.weight - a.weight).slice(0, 5);
|
||||
}
|
||||
|
||||
hasExactFilter(query = this.trimmedQuery) {
|
||||
R_QUERY_EXACT.lastIndex = 0;
|
||||
return R_QUERY_EXACT.test(query);
|
||||
}
|
||||
|
||||
hasRegExpFilter(query = this.trimmedQuery) {
|
||||
return R_REGEX.test(query);
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return !(
|
||||
this.trimmedQuery ||
|
||||
$values(this.runnerState.includeSpecs).some((values) =>
|
||||
$values(values).some((value) => $abs(value) === INCLUDE_LEVEL.url)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
*/
|
||||
isReadonly(value) {
|
||||
return $abs(value) > INCLUDE_LEVEL.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} item
|
||||
*/
|
||||
isTag(item) {
|
||||
return item instanceof Tag;
|
||||
}
|
||||
/**
|
||||
* @param {number} inc
|
||||
*/
|
||||
navigate(inc) {
|
||||
const elements = [
|
||||
this.searchInputRef.el,
|
||||
...this.rootRef.el.querySelectorAll("input[type=radio]:checked:enabled"),
|
||||
];
|
||||
let nextIndex = elements.indexOf(getActiveElement(document)) + inc;
|
||||
if (nextIndex >= elements.length) {
|
||||
nextIndex = 0;
|
||||
} else if (nextIndex < -1) {
|
||||
nextIndex = -1;
|
||||
}
|
||||
elements.at(nextIndex).focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
onExactKeyDown(ev) {
|
||||
switch (ev.key) {
|
||||
case "Enter":
|
||||
case " ": {
|
||||
this.toggleExact(ev);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SearchFilter} type
|
||||
* @param {string} id
|
||||
* @param {"exclude" | "include"} value
|
||||
*/
|
||||
onIncludeChange(type, id, value) {
|
||||
if (value === "include" || value === "exclude") {
|
||||
this.setInclude(
|
||||
type,
|
||||
id,
|
||||
value === "include" ? +INCLUDE_LEVEL.url : -INCLUDE_LEVEL.url
|
||||
);
|
||||
} else {
|
||||
this.setInclude(type, id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
onKeyDown(ev) {
|
||||
switch (ev.key) {
|
||||
case "ArrowDown": {
|
||||
ev.preventDefault();
|
||||
return this.navigate(+1);
|
||||
}
|
||||
case "ArrowUp": {
|
||||
ev.preventDefault();
|
||||
return this.navigate(-1);
|
||||
}
|
||||
case "Enter": {
|
||||
return refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
onRegExpKeyDown(ev) {
|
||||
switch (ev.key) {
|
||||
case "Enter":
|
||||
case " ": {
|
||||
this.toggleRegExp(ev);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onSearchInputChange() {
|
||||
if (!this.trimmedQuery) {
|
||||
return;
|
||||
}
|
||||
const latestSearches = this.getLatestSearches();
|
||||
latestSearches.unshift(this.trimmedQuery);
|
||||
storageSet(STORAGE.searches, [...new Set(latestSearches)].slice(0, 5));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputEvent & { currentTarget: HTMLInputElement }} ev
|
||||
*/
|
||||
onSearchInputInput(ev) {
|
||||
this.state.query = ev.currentTarget.value;
|
||||
|
||||
this.env.ui.resultsPage = 0;
|
||||
|
||||
this.updateFilterParam();
|
||||
this.debouncedUpdateSuggestions();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent & { currentTarget: HTMLInputElement }} ev
|
||||
*/
|
||||
onSearchInputKeyDown(ev) {
|
||||
switch (ev.key) {
|
||||
case "Backspace": {
|
||||
if (ev.currentTarget.selectionStart === 0 && ev.currentTarget.selectionEnd === 0) {
|
||||
this.uncheckLastCategory();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.fun) {
|
||||
this.verifySecretSequenceStep(ev);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SearchFilter} type
|
||||
* @param {string} id
|
||||
* @param {number} [value]
|
||||
*/
|
||||
setInclude(type, id, value) {
|
||||
this.config.filter = "";
|
||||
this.env.runner.include(type, id, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} query
|
||||
*/
|
||||
setQuery(query) {
|
||||
this.state.query = query;
|
||||
|
||||
this.updateFilterParam();
|
||||
this.updateSuggestions();
|
||||
this.focusSearchInput();
|
||||
}
|
||||
|
||||
toggleDebug() {
|
||||
this.config.debugTest = !this.config.debugTest;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} ev
|
||||
*/
|
||||
toggleExact(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
const currentQuery = this.trimmedQuery;
|
||||
let query = currentQuery;
|
||||
if (this.hasRegExpFilter(query)) {
|
||||
query = removeRegExp(query);
|
||||
}
|
||||
if (this.hasExactFilter(query)) {
|
||||
query = removeExact(query);
|
||||
} else {
|
||||
query = addExact(query);
|
||||
}
|
||||
this.keepSelection((query.length - currentQuery.length) / 2);
|
||||
this.setQuery(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SearchFilter} type
|
||||
* @param {string} id
|
||||
*/
|
||||
toggleInclude(type, id) {
|
||||
const currentValue = this.runnerState.includeSpecs[type][id];
|
||||
if (this.isReadonly(currentValue)) {
|
||||
return; // readonly
|
||||
}
|
||||
if (currentValue > 0) {
|
||||
this.setInclude(type, id, -INCLUDE_LEVEL.url);
|
||||
} else if (currentValue < 0) {
|
||||
this.setInclude(type, id, 0);
|
||||
} else {
|
||||
this.setInclude(type, id, +INCLUDE_LEVEL.url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} ev
|
||||
*/
|
||||
toggleRegExp(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
const currentQuery = this.trimmedQuery;
|
||||
let query = currentQuery;
|
||||
if (this.hasExactFilter(query)) {
|
||||
query = removeExact(query);
|
||||
}
|
||||
if (this.hasRegExpFilter(query)) {
|
||||
query = removeRegExp(query);
|
||||
} else {
|
||||
query = addRegExp(query);
|
||||
}
|
||||
this.keepSelection((query.length - currentQuery.length) / 2);
|
||||
this.setQuery(query);
|
||||
}
|
||||
|
||||
uncheckLastCategory() {
|
||||
for (const count of this.getCategoryCounts().reverse()) {
|
||||
const type = categoryToType(count.category);
|
||||
const includeSpecs = this.runnerState.includeSpecs[type];
|
||||
for (const id of [...count.exclude, ...count.include]) {
|
||||
const value = includeSpecs[id];
|
||||
if (this.isReadonly(value)) {
|
||||
continue;
|
||||
}
|
||||
this.setInclude(type, id, 0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
updateFilterParam() {
|
||||
this.config.filter = this.trimmedQuery;
|
||||
}
|
||||
|
||||
updateSuggestions() {
|
||||
this.state.empty = this.isEmpty();
|
||||
this.state.categories = this.findSuggestions();
|
||||
this.state.showDropdown = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
verifySecretSequenceStep(ev) {
|
||||
this.secretSequence ||= 0;
|
||||
if (ev.keyCode === SECRET_SEQUENCE[this.secretSequence]) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.secretSequence++;
|
||||
} else {
|
||||
this.secretSequence = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.secretSequence === SECRET_SEQUENCE.length) {
|
||||
this.secretSequence = 0;
|
||||
|
||||
const { runner } = this.env;
|
||||
runner.stop();
|
||||
runner.reporting.passed += runner.reporting.failed;
|
||||
runner.reporting.passed += runner.reporting.todo;
|
||||
runner.reporting.failed = 0;
|
||||
runner.reporting.todo = 0;
|
||||
for (const [, suite] of runner.suites) {
|
||||
suite.reporting.passed += suite.reporting.failed;
|
||||
suite.reporting.passed += suite.reporting.todo;
|
||||
suite.reporting.failed = 0;
|
||||
suite.reporting.todo = 0;
|
||||
}
|
||||
for (const [, test] of runner.tests) {
|
||||
test.config.todo = false;
|
||||
test.status = Test.PASSED;
|
||||
for (const result of test.results) {
|
||||
result.pass = true;
|
||||
result.currentErrors = [];
|
||||
for (const assertion of result.getEvents("assertion")) {
|
||||
assertion.pass = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.__owl__.app.root.render(true);
|
||||
console.warn("Secret sequence activated: all tests pass!");
|
||||
}
|
||||
}
|
||||
|
||||
wrappedQuery(query = this.trimmedQuery) {
|
||||
return this.hasRegExpFilter(query) ? query : stringify(query);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,458 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component, onWillRender, useEffect, useRef, useState, xml } from "@odoo/owl";
|
||||
import { Suite } from "../core/suite";
|
||||
import { createUrlFromId } from "../core/url";
|
||||
import { lookup, parseQuery } from "../hoot_utils";
|
||||
import { HootJobButtons } from "./hoot_job_buttons";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* multi?: number;
|
||||
* name: string;
|
||||
* hasSuites: boolean;
|
||||
* reporting: import("../hoot_utils").Reporting;
|
||||
* selected: boolean;
|
||||
* unfolded: boolean;
|
||||
* }} HootSideBarSuiteProps
|
||||
*
|
||||
* @typedef {{
|
||||
* reporting: import("../hoot_utils").Reporting;
|
||||
* statusFilter: import("./setup_hoot_ui").StatusFilter | null;
|
||||
* }} HootSideBarCounterProps
|
||||
*
|
||||
* @typedef {{
|
||||
* }} HootSideBarProps
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const { Boolean, location: actualLocation, Object, String } = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const SUITE_CLASSNAME = "hoot-sidebar-suite";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @extends {Component<HootSideBarSuiteProps, import("../hoot").Environment>}
|
||||
*/
|
||||
export class HootSideBarSuite extends Component {
|
||||
static props = {
|
||||
multi: { type: Number, optional: true },
|
||||
name: String,
|
||||
hasSuites: Boolean,
|
||||
reporting: Object,
|
||||
selected: Boolean,
|
||||
unfolded: Boolean,
|
||||
};
|
||||
|
||||
static template = xml`
|
||||
<t t-if="props.hasSuites">
|
||||
<i
|
||||
class="fa fa-chevron-right text-xs transition"
|
||||
t-att-class="{
|
||||
'rotate-90': props.unfolded,
|
||||
'opacity-25': !props.reporting.failed and !props.reporting.tests
|
||||
}"
|
||||
/>
|
||||
</t>
|
||||
<span t-ref="root" t-att-class="getClassName()" t-esc="props.name" />
|
||||
<t t-if="props.multi">
|
||||
<strong class="text-amber whitespace-nowrap me-1">
|
||||
x<t t-esc="props.multi" />
|
||||
</strong>
|
||||
</t>
|
||||
`;
|
||||
|
||||
setup() {
|
||||
const rootRef = useRef("root");
|
||||
let wasSelected = false;
|
||||
useEffect(
|
||||
(selected) => {
|
||||
if (selected && !wasSelected) {
|
||||
rootRef.el.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
wasSelected = selected;
|
||||
},
|
||||
() => [this.props.selected]
|
||||
);
|
||||
}
|
||||
|
||||
getClassName() {
|
||||
const { reporting, selected } = this.props;
|
||||
let className = "truncate transition";
|
||||
if (reporting.failed) {
|
||||
className += " text-rose";
|
||||
} else if (!reporting.tests) {
|
||||
className += " opacity-25";
|
||||
}
|
||||
if (selected) {
|
||||
className += " font-bold";
|
||||
}
|
||||
return className;
|
||||
}
|
||||
}
|
||||
|
||||
/** @extends {Component<HootSideBarCounterProps, import("../hoot").Environment>} */
|
||||
export class HootSideBarCounter extends Component {
|
||||
static props = {
|
||||
reporting: Object,
|
||||
statusFilter: [String, { value: null }],
|
||||
};
|
||||
|
||||
static template = xml`
|
||||
<t t-set="info" t-value="getCounterInfo()" />
|
||||
<span
|
||||
t-attf-class="${HootSideBarCounter.name} {{ info[1] ? info[0] : 'text-gray' }} {{ info[1] ? 'font-bold' : '' }}"
|
||||
t-esc="info[1]"
|
||||
/>
|
||||
`;
|
||||
|
||||
getCounterInfo() {
|
||||
const { reporting, statusFilter } = this.props;
|
||||
switch (statusFilter) {
|
||||
case "failed":
|
||||
return ["text-rose", reporting.failed];
|
||||
case "passed":
|
||||
return ["text-emerald", reporting.passed];
|
||||
case "skipped":
|
||||
return ["text-cyan", reporting.skipped];
|
||||
case "todo":
|
||||
return ["text-purple", reporting.todo];
|
||||
default:
|
||||
return ["text-primary", reporting.tests];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @extends {Component<HootSideBarProps, import("../hoot").Environment>}
|
||||
*/
|
||||
export class HootSideBar extends Component {
|
||||
static components = { HootJobButtons, HootSideBarSuite, HootSideBarCounter };
|
||||
|
||||
static props = {};
|
||||
|
||||
static template = xml`
|
||||
<div
|
||||
class="${HootSideBar.name} flex-col w-64 h-full resize-x shadow bg-gray-200 dark:bg-gray-800 z-1 hidden md:flex"
|
||||
t-on-click.stop="onClick"
|
||||
>
|
||||
<form class="flex p-2 items-center gap-1">
|
||||
<div class="hoot-search-bar border rounded bg-base w-full">
|
||||
<input
|
||||
class="w-full rounded px-2 py-1 outline-none"
|
||||
type="search"
|
||||
placeholder="Search suites"
|
||||
t-ref="search-input"
|
||||
t-model="state.filter"
|
||||
t-on-keydown="onSearchInputKeydown"
|
||||
/>
|
||||
</div>
|
||||
<t t-if="env.runner.hasFilter">
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary p-1 transition-colors"
|
||||
t-att-title="state.hideEmpty ? 'Show all suites' : 'Hide other suites'"
|
||||
t-on-click.stop="toggleHideEmpty"
|
||||
>
|
||||
<i t-attf-class="fa fa-{{ state.hideEmpty ? 'eye' : 'eye-slash' }}" />
|
||||
</button>
|
||||
</t>
|
||||
<t t-set="expanded" t-value="unfoldedIds.size === env.runner.suites.size" />
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary p-1 transition-colors"
|
||||
t-attf-title="{{ expanded ? 'Collapse' : 'Expand' }} all"
|
||||
t-on-click.stop="() => this.toggleExpand(expanded)"
|
||||
>
|
||||
<i t-attf-class="fa fa-{{ expanded ? 'compress' : 'expand' }}" />
|
||||
</button>
|
||||
</form>
|
||||
<ul class="overflow-x-hidden overflow-y-auto" t-ref="suites-list">
|
||||
<t t-foreach="filteredSuites" t-as="suite" t-key="suite.id">
|
||||
<li class="flex items-center h-7 animate-slide-down">
|
||||
<button
|
||||
class="${SUITE_CLASSNAME} flex items-center w-full h-full gap-1 px-2 overflow-hidden hover:bg-gray-300 dark:hover:bg-gray-700"
|
||||
t-att-class="{ 'bg-gray-300 dark:bg-gray-700': uiState.selectedSuiteId === suite.id }"
|
||||
t-attf-style="margin-left: {{ (suite.path.length - 1) + 'rem' }};"
|
||||
t-attf-title="{{ suite.fullName }}\n- {{ suite.totalTestCount }} tests\n- {{ suite.totalSuiteCount }} suites"
|
||||
t-on-click.stop="(ev) => this.toggleItem(suite)"
|
||||
t-on-keydown="(ev) => this.onSuiteKeydown(ev, suite)"
|
||||
>
|
||||
<div class="flex items-center truncate gap-1 flex-1">
|
||||
<HootSideBarSuite
|
||||
multi="suite.config.multi"
|
||||
name="suite.name"
|
||||
hasSuites="hasSuites(suite)"
|
||||
reporting="suite.reporting"
|
||||
selected="uiState.selectedSuiteId === suite.id"
|
||||
unfolded="unfoldedIds.has(suite.id)"
|
||||
/>
|
||||
<span class="text-gray">
|
||||
(<t t-esc="suite.totalTestCount" />)
|
||||
</span>
|
||||
</div>
|
||||
<HootJobButtons hidden="true" job="suite" />
|
||||
<t t-if="env.runner.state.suites.includes(suite)">
|
||||
<HootSideBarCounter
|
||||
reporting="suite.reporting"
|
||||
statusFilter="uiState.statusFilter"
|
||||
/>
|
||||
</t>
|
||||
</button>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
filteredSuites = [];
|
||||
runningSuites = new Set();
|
||||
unfoldedIds = new Set();
|
||||
|
||||
setup() {
|
||||
const { runner, ui } = this.env;
|
||||
|
||||
this.searchInputRef = useRef("search-input");
|
||||
this.suitesListRef = useRef("suites-list");
|
||||
this.uiState = useState(ui);
|
||||
this.state = useState({
|
||||
filter: "",
|
||||
hideEmpty: false,
|
||||
suites: [],
|
||||
/** @type {Set<string>} */
|
||||
unfoldedIds: new Set(),
|
||||
});
|
||||
|
||||
runner.beforeAll(() => {
|
||||
const singleRootSuite = runner.rootSuites.filter((suite) => suite.currentJobs.length);
|
||||
if (singleRootSuite.length === 1) {
|
||||
// Unfolds only root suite containing jobs
|
||||
this.unfoldAndSelect(singleRootSuite[0]);
|
||||
} else {
|
||||
// As the runner might have registered suites after the initial render,
|
||||
// with those suites not being read by this component yet, it will
|
||||
// not have subscribed and re-rendered automatically.
|
||||
// This here allows the opportunity to read all suites one last time
|
||||
// before starting the run.
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
|
||||
onWillRender(() => {
|
||||
[this.filteredSuites, this.unfoldedIds] = this.getFilteredVisibleSuites();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters
|
||||
*/
|
||||
getFilteredVisibleSuites() {
|
||||
const { runner } = this.env;
|
||||
const { hideEmpty } = this.state;
|
||||
const allSuites = runner.suites.values();
|
||||
let allowedIds;
|
||||
let unfoldedIds;
|
||||
let rootSuites;
|
||||
|
||||
// Filtering suites
|
||||
|
||||
const parsedQuery = parseQuery(this.state.filter);
|
||||
if (parsedQuery.length) {
|
||||
allowedIds = new Set();
|
||||
unfoldedIds = new Set(this.state.unfoldedIds);
|
||||
rootSuites = new Set();
|
||||
for (const matchingSuite of lookup(parsedQuery, allSuites, "name")) {
|
||||
for (const suite of matchingSuite.path) {
|
||||
allowedIds.add(suite.id);
|
||||
unfoldedIds.add(suite.id);
|
||||
if (!suite.parent) {
|
||||
rootSuites.add(suite);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
unfoldedIds = this.state.unfoldedIds;
|
||||
rootSuites = runner.rootSuites;
|
||||
}
|
||||
|
||||
// Computing unfolded suites
|
||||
|
||||
/**
|
||||
* @param {Suite} suite
|
||||
*/
|
||||
function addSuite(suite) {
|
||||
if (
|
||||
!(suite instanceof Suite) || // Not a suite
|
||||
(allowedIds && !allowedIds.has(suite.id)) || // Not "allowed" (by parent)
|
||||
(hideEmpty && !(suite.reporting.tests || suite.currentJobs.length)) // Filtered because empty
|
||||
) {
|
||||
return;
|
||||
}
|
||||
unfoldedSuites.push(suite);
|
||||
if (!unfoldedIds.has(suite.id)) {
|
||||
return;
|
||||
}
|
||||
for (const child of suite.jobs) {
|
||||
addSuite(child);
|
||||
}
|
||||
}
|
||||
|
||||
const unfoldedSuites = [];
|
||||
for (const suite of rootSuites) {
|
||||
addSuite(suite);
|
||||
}
|
||||
|
||||
return [unfoldedSuites, unfoldedIds];
|
||||
}
|
||||
|
||||
getSuiteElements() {
|
||||
return this.suitesListRef.el
|
||||
? [...this.suitesListRef.el.getElementsByClassName(SUITE_CLASSNAME)]
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../core/job").Job} job
|
||||
*/
|
||||
hasSuites(job) {
|
||||
return job.jobs.some((subJob) => subJob instanceof Suite);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
// Unselect suite when clicking outside of a suite & in the side bar
|
||||
this.uiState.selectedSuiteId = null;
|
||||
this.uiState.resultsPage = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent & { currentTarget: HTMLInputElement }} ev
|
||||
*/
|
||||
onSearchInputKeydown(ev) {
|
||||
switch (ev.key) {
|
||||
case "ArrowDown": {
|
||||
if (ev.currentTarget.selectionEnd === ev.currentTarget.value.length) {
|
||||
const suiteElements = this.getSuiteElements();
|
||||
suiteElements[0]?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent & { currentTarget: HTMLButtonElement }} ev
|
||||
* @param {Suite} suite
|
||||
*/
|
||||
onSuiteKeydown(ev, suite) {
|
||||
const { currentTarget, key } = ev;
|
||||
switch (key) {
|
||||
case "ArrowDown": {
|
||||
return this.selectElementAt(currentTarget, +1);
|
||||
}
|
||||
case "ArrowLeft": {
|
||||
if (this.state.unfoldedIds.has(suite.id)) {
|
||||
return this.toggleItem(suite, false);
|
||||
} else {
|
||||
return this.selectElementAt(currentTarget, -1);
|
||||
}
|
||||
}
|
||||
case "ArrowRight": {
|
||||
if (this.state.unfoldedIds.has(suite.id)) {
|
||||
return this.selectElementAt(currentTarget, +1);
|
||||
} else {
|
||||
return this.toggleItem(suite, true);
|
||||
}
|
||||
}
|
||||
case "ArrowUp": {
|
||||
return this.selectElementAt(currentTarget, -1);
|
||||
}
|
||||
case "Enter": {
|
||||
ev.preventDefault();
|
||||
actualLocation.href = createUrlFromId({ id: suite.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} target
|
||||
* @param {number} delta
|
||||
*/
|
||||
selectElementAt(target, delta) {
|
||||
const suiteElements = this.getSuiteElements();
|
||||
const nextIndex = suiteElements.indexOf(target) + delta;
|
||||
if (nextIndex < 0) {
|
||||
this.searchInputRef.el?.focus();
|
||||
} else if (nextIndex >= suiteElements.length) {
|
||||
suiteElements[0].focus();
|
||||
} else {
|
||||
suiteElements[nextIndex].focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} expanded
|
||||
*/
|
||||
toggleExpand(expanded) {
|
||||
if (expanded) {
|
||||
this.state.unfoldedIds.clear();
|
||||
} else {
|
||||
for (const { id } of this.env.runner.suites.values()) {
|
||||
this.state.unfoldedIds.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleHideEmpty() {
|
||||
this.state.hideEmpty = !this.state.hideEmpty;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Suite} suite
|
||||
* @param {boolean} [forceAdd]
|
||||
*/
|
||||
toggleItem(suite, forceAdd) {
|
||||
if (this.uiState.selectedSuiteId !== suite.id) {
|
||||
this.uiState.selectedSuiteId = suite.id;
|
||||
this.uiState.resultsPage = 0;
|
||||
|
||||
if (this.state.unfoldedIds.has(suite.id)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (forceAdd ?? !this.state.unfoldedIds.has(suite.id)) {
|
||||
this.unfoldAndSelect(suite);
|
||||
} else {
|
||||
this.state.unfoldedIds.delete(suite.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Suite} suite
|
||||
*/
|
||||
unfoldAndSelect(suite) {
|
||||
this.state.unfoldedIds.add(suite.id);
|
||||
|
||||
while (suite.currentJobs.length === 1) {
|
||||
suite = suite.currentJobs[0];
|
||||
if (!(suite instanceof Suite)) {
|
||||
break;
|
||||
}
|
||||
this.state.unfoldedIds.add(suite.id);
|
||||
this.uiState.selectedSuiteId = suite.id;
|
||||
this.uiState.resultsPage = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,366 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component, onWillRender, useEffect, useRef, useState, xml } from "@odoo/owl";
|
||||
import { getColorHex } from "../../hoot-dom/hoot_dom_utils";
|
||||
import { Test } from "../core/test";
|
||||
import { formatTime } from "../hoot_utils";
|
||||
import { getTitle, setTitle } from "../mock/window";
|
||||
import { onColorSchemeChange } from "./hoot_colors";
|
||||
import { HootTestPath } from "./hoot_test_path";
|
||||
|
||||
/**
|
||||
* @typedef {import("../core/runner").Runner} Runner
|
||||
*
|
||||
* @typedef {{
|
||||
* }} HootStatusPanelProps
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Object: { values: $values },
|
||||
Math: { ceil: $ceil, floor: $floor, max: $max, min: $min, random: $random },
|
||||
clearInterval,
|
||||
document,
|
||||
performance,
|
||||
setInterval,
|
||||
} = globalThis;
|
||||
/** @type {Performance["now"]} */
|
||||
const $now = performance.now.bind(performance);
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {HTMLCanvasElement | null} canvas
|
||||
*/
|
||||
function setupCanvas(canvas) {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
[canvas.width, canvas.height] = [canvas.clientWidth, canvas.clientHeight];
|
||||
canvas.getContext("2d").clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
*/
|
||||
function randInt(min, max) {
|
||||
return $floor($random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} content
|
||||
*/
|
||||
function spawnIncentive(content) {
|
||||
const incentive = document.createElement("div");
|
||||
const params = [
|
||||
`--_content: '${content}'`,
|
||||
`--_fly-duration: ${randInt(2000, 3000)}`,
|
||||
`--_size: ${randInt(32, 48)}`,
|
||||
`--_wiggle-duration: ${randInt(800, 2000)}`,
|
||||
`--_wiggle-range: ${randInt(5, 30)}`,
|
||||
`--_x: ${randInt(0, 100)}`,
|
||||
`--_y: ${randInt(100, 150)}`,
|
||||
];
|
||||
incentive.setAttribute("class", `incentive fixed`);
|
||||
incentive.setAttribute("style", params.join(";"));
|
||||
|
||||
/** @param {AnimationEvent} ev */
|
||||
function onEnd(ev) {
|
||||
return ev.animationName === "animation-incentive-travel" && incentive.remove();
|
||||
}
|
||||
incentive.addEventListener("animationend", onEnd);
|
||||
incentive.addEventListener("animationcancel", onEnd);
|
||||
|
||||
document.querySelector("hoot-container").shadowRoot.appendChild(incentive);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} failed
|
||||
*/
|
||||
function updateTitle(failed) {
|
||||
const toAdd = failed ? TITLE_PREFIX.fail : TITLE_PREFIX.pass;
|
||||
let title = getTitle();
|
||||
if (title.startsWith(toAdd)) {
|
||||
return;
|
||||
}
|
||||
for (const prefix of $values(TITLE_PREFIX)) {
|
||||
if (title.startsWith(prefix)) {
|
||||
title = title.slice(prefix.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
setTitle(`${toAdd} ${title}`);
|
||||
}
|
||||
|
||||
const TIMER_PRECISION = 100; // in ms
|
||||
const TITLE_PREFIX = {
|
||||
fail: "✖",
|
||||
pass: "✔",
|
||||
};
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/** @extends {Component<HootStatusPanelProps, import("../hoot").Environment>} */
|
||||
export class HootStatusPanel extends Component {
|
||||
static components = { HootTestPath };
|
||||
|
||||
static props = {};
|
||||
|
||||
static template = xml`
|
||||
<div class="${HootStatusPanel.name} flex items-center justify-between gap-3 px-3 py-1 bg-gray-300 dark:bg-gray-700" t-att-class="state.className">
|
||||
<div class="flex items-center gap-2 overflow-hidden">
|
||||
<t t-if="runnerState.status === 'ready'">
|
||||
Ready
|
||||
</t>
|
||||
<t t-elif="runnerState.status === 'running'">
|
||||
<i t-if="state.debug" class="text-cyan fa fa-bug" title="Debugging" />
|
||||
<div
|
||||
t-else=""
|
||||
class="animate-spin shrink-0 grow-0 w-4 h-4 border-2 border-emerald border-t-transparent rounded-full"
|
||||
role="status"
|
||||
title="Running"
|
||||
/>
|
||||
<strong class="text-primary" t-esc="env.runner.totalTime" />
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="hidden md:block">
|
||||
<strong class="text-primary" t-esc="runnerReporting.tests" />
|
||||
tests completed
|
||||
(total time: <strong class="text-primary" t-esc="env.runner.totalTime" />
|
||||
<t t-if="env.runner.aborted">, run aborted by user</t>)
|
||||
</span>
|
||||
<span class="md:hidden flex items-center gap-1">
|
||||
<i class="fa fa-clock-o" />
|
||||
<strong class="text-primary" t-esc="env.runner.totalTime" />
|
||||
</span>
|
||||
</t>
|
||||
<t t-if="runnerState.currentTest">
|
||||
<HootTestPath test="runnerState.currentTest" />
|
||||
</t>
|
||||
<t t-if="state.timer">
|
||||
<span class="text-cyan" t-esc="formatTime(state.timer, 's')" />
|
||||
</t>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<t t-if="runnerReporting.passed">
|
||||
<t t-set="color" t-value="!uiState.statusFilter or uiState.statusFilter === 'passed' ? 'emerald' : 'gray'" />
|
||||
<button
|
||||
t-attf-class="text-{{ color }} transition-colors flex items-center gap-1 p-1 font-bold"
|
||||
t-on-click.stop="() => this.filterResults('passed')"
|
||||
t-attf-title="Show {{ runnerReporting.passed }} passed tests"
|
||||
>
|
||||
<i class="fa fa-check-circle" />
|
||||
<t t-esc="runnerReporting.passed" />
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="runnerReporting.failed">
|
||||
<t t-set="color" t-value="!uiState.statusFilter or uiState.statusFilter === 'failed' ? 'rose' : 'gray'" />
|
||||
<button
|
||||
t-attf-class="text-{{ color }} transition-colors flex items-center gap-1 p-1 font-bold"
|
||||
t-on-click.stop="() => this.filterResults('failed')"
|
||||
t-attf-title="Show {{ runnerReporting.failed }} failed tests"
|
||||
>
|
||||
<i class="fa fa-times-circle" />
|
||||
<t t-esc="runnerReporting.failed" />
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="runnerReporting.skipped">
|
||||
<t t-set="color" t-value="!uiState.statusFilter or uiState.statusFilter === 'skipped' ? 'cyan' : 'gray'" />
|
||||
<button
|
||||
t-attf-class="text-{{ color }} transition-colors flex items-center gap-1 p-1 font-bold"
|
||||
t-on-click.stop="() => this.filterResults('skipped')"
|
||||
t-attf-title="Show {{ runnerReporting.skipped }} skipped tests"
|
||||
>
|
||||
<i class="fa fa-pause-circle" />
|
||||
<t t-esc="runnerReporting.skipped" />
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="runnerReporting.todo">
|
||||
<t t-set="color" t-value="!uiState.statusFilter or uiState.statusFilter === 'todo' ? 'purple' : 'gray'" />
|
||||
<button
|
||||
t-attf-class="text-{{ color }} transition-colors flex items-center gap-1 p-1 font-bold"
|
||||
t-on-click.stop="() => this.filterResults('todo')"
|
||||
t-attf-title="Show {{ runnerReporting.todo }} tests to do"
|
||||
>
|
||||
<i class="fa fa-exclamation-circle" />
|
||||
<t t-esc="runnerReporting.todo" />
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="uiState.totalResults gt uiState.resultsPerPage">
|
||||
<t t-set="lastPage" t-value="getLastPage()" />
|
||||
<div class="flex gap-1 animate-slide-left">
|
||||
<button
|
||||
class="px-1 transition-color"
|
||||
title="Previous page"
|
||||
t-att-disabled="uiState.resultsPage === 0"
|
||||
t-on-click.stop="previousPage"
|
||||
>
|
||||
<i class="fa fa-chevron-left" />
|
||||
</button>
|
||||
<strong class="text-primary" t-esc="uiState.resultsPage + 1" />
|
||||
<span class="text-gray">/</span>
|
||||
<t t-esc="lastPage + 1" />
|
||||
<button
|
||||
class="px-1 transition-color"
|
||||
title="Next page"
|
||||
t-att-disabled="uiState.resultsPage === lastPage"
|
||||
t-on-click.stop="nextPage"
|
||||
>
|
||||
<i class="fa fa-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<canvas t-ref="progress-canvas" class="flex h-1 w-full" />
|
||||
`;
|
||||
|
||||
currentTestStart;
|
||||
formatTime = formatTime;
|
||||
intervalId = 0;
|
||||
|
||||
setup() {
|
||||
const { runner, ui } = this.env;
|
||||
this.canvasRef = useRef("progress-canvas");
|
||||
this.runnerReporting = useState(runner.reporting);
|
||||
this.runnerState = useState(runner.state);
|
||||
this.state = useState({
|
||||
className: "",
|
||||
timer: null,
|
||||
});
|
||||
this.uiState = useState(ui);
|
||||
this.progressBarIndex = 0;
|
||||
|
||||
runner.beforeAll(this.globalSetup.bind(this));
|
||||
runner.afterAll(this.globalCleanup.bind(this));
|
||||
if (!runner.headless) {
|
||||
runner.beforeEach(this.startTimer.bind(this));
|
||||
runner.afterPostTest(this.stopTimer.bind(this));
|
||||
}
|
||||
|
||||
useEffect(setupCanvas, () => [this.canvasRef.el]);
|
||||
|
||||
onColorSchemeChange(this.onColorSchemeChange.bind(this));
|
||||
onWillRender(this.updateProgressBar.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {typeof this.uiState.statusFilter} status
|
||||
*/
|
||||
filterResults(status) {
|
||||
this.uiState.resultsPage = 0;
|
||||
if (this.uiState.statusFilter === status) {
|
||||
this.uiState.statusFilter = null;
|
||||
} else {
|
||||
this.uiState.statusFilter = status;
|
||||
}
|
||||
}
|
||||
|
||||
getLastPage() {
|
||||
const { resultsPerPage, totalResults } = this.uiState;
|
||||
return $max($floor((totalResults - 1) / resultsPerPage), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Runner} runner
|
||||
*/
|
||||
globalCleanup(runner) {
|
||||
if (!runner.headless) {
|
||||
this.stopTimer();
|
||||
}
|
||||
updateTitle(this.runnerReporting.failed > 0);
|
||||
|
||||
if (runner.config.fun) {
|
||||
for (let i = 0; i < this.runnerReporting.failed; i++) {
|
||||
spawnIncentive("😭");
|
||||
}
|
||||
for (let i = 0; i < this.runnerReporting.passed; i++) {
|
||||
spawnIncentive("🦉");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Runner} runner
|
||||
*/
|
||||
globalSetup(runner) {
|
||||
this.state.debug = runner.debug;
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
this.uiState.resultsPage = $min(this.uiState.resultsPage + 1, this.getLastPage());
|
||||
}
|
||||
|
||||
onColorSchemeChange() {
|
||||
this.progressBarIndex = 0;
|
||||
this.updateProgressBar();
|
||||
}
|
||||
|
||||
previousPage() {
|
||||
this.uiState.resultsPage = $max(this.uiState.resultsPage - 1, 0);
|
||||
}
|
||||
|
||||
startTimer() {
|
||||
this.stopTimer();
|
||||
|
||||
this.currentTestStart = $now();
|
||||
this.intervalId = setInterval(() => {
|
||||
this.state.timer =
|
||||
$floor(($now() - this.currentTestStart) / TIMER_PRECISION) * TIMER_PRECISION;
|
||||
}, TIMER_PRECISION);
|
||||
}
|
||||
|
||||
stopTimer() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = 0;
|
||||
}
|
||||
|
||||
this.state.timer = 0;
|
||||
}
|
||||
|
||||
updateProgressBar() {
|
||||
const canvas = this.canvasRef.el;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
const { width, height } = canvas;
|
||||
const { done, tests } = this.runnerState;
|
||||
const doneList = [...done];
|
||||
const cellSize = width / tests.length;
|
||||
const minSize = $ceil(cellSize);
|
||||
|
||||
while (this.progressBarIndex < done.size) {
|
||||
const test = doneList[this.progressBarIndex];
|
||||
const x = $floor(this.progressBarIndex * cellSize);
|
||||
switch (test.status) {
|
||||
case Test.ABORTED:
|
||||
ctx.fillStyle = getColorHex("amber");
|
||||
break;
|
||||
case Test.FAILED:
|
||||
ctx.fillStyle = getColorHex("rose");
|
||||
break;
|
||||
case Test.PASSED:
|
||||
ctx.fillStyle = test.config.todo
|
||||
? getColorHex("purple")
|
||||
: getColorHex("emerald");
|
||||
break;
|
||||
case Test.SKIPPED:
|
||||
ctx.fillStyle = getColorHex("cyan");
|
||||
break;
|
||||
}
|
||||
ctx.fillRect(x, 0, minSize, height);
|
||||
this.progressBarIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
1686
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_style.css
Normal file
1686
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_style.css
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,53 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { Tag } from "../core/tag";
|
||||
import { HootLink } from "./hoot_link";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* inert?: boolean;
|
||||
* tag: Tag;
|
||||
* }} HootTagButtonProps
|
||||
*/
|
||||
|
||||
/** @extends {Component<HootTagButtonProps, import("../hoot").Environment>} */
|
||||
export class HootTagButton extends Component {
|
||||
static components = { HootLink };
|
||||
|
||||
static props = {
|
||||
inert: { type: Boolean, optional: true },
|
||||
tag: Tag,
|
||||
};
|
||||
|
||||
static template = xml`
|
||||
<t t-if="props.inert">
|
||||
<span
|
||||
class="rounded-full px-2"
|
||||
t-att-style="style"
|
||||
t-att-title="title"
|
||||
>
|
||||
<small class="text-xs font-bold" t-esc="props.tag.name" />
|
||||
</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<HootLink
|
||||
ids="{ tag: props.tag.name }"
|
||||
class="'rounded-full px-2'"
|
||||
style="style"
|
||||
title="title"
|
||||
>
|
||||
<small class="text-xs font-bold hidden md:inline" t-esc="props.tag.name" />
|
||||
<span class="md:hidden">‍</span>
|
||||
</HootLink>
|
||||
</t>
|
||||
`;
|
||||
|
||||
get style() {
|
||||
return `background-color: ${this.props.tag.color[0]}; color: ${this.props.tag.color[1]};`;
|
||||
}
|
||||
|
||||
get title() {
|
||||
return `Tag ${this.props.tag.name}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import {
|
||||
Component,
|
||||
onWillRender,
|
||||
onWillUpdateProps,
|
||||
xml as owlXml,
|
||||
toRaw,
|
||||
useState,
|
||||
} from "@odoo/owl";
|
||||
import { isNode, toSelector } from "@web/../lib/hoot-dom/helpers/dom";
|
||||
import { isInstanceOf, isIterable } from "@web/../lib/hoot-dom/hoot_dom_utils";
|
||||
import { logger } from "../core/logger";
|
||||
import {
|
||||
getTypeOf,
|
||||
isSafe,
|
||||
Markup,
|
||||
S_ANY,
|
||||
S_NONE,
|
||||
stringify,
|
||||
toExplicitString,
|
||||
} from "../hoot_utils";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* value?: any;
|
||||
* }} TechnicalValueProps
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Object: { keys: $keys },
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compacted version of {@link owlXml} removing all whitespace between tags.
|
||||
*
|
||||
* @type {typeof String.raw}
|
||||
*/
|
||||
function xml(template, ...substitutions) {
|
||||
return owlXml({
|
||||
raw: String.raw(template, ...substitutions)
|
||||
.replace(/>\s+/g, ">")
|
||||
.replace(/\s+</g, "<"),
|
||||
});
|
||||
}
|
||||
|
||||
const INVARIABLE_OBJECTS = [Promise, RegExp];
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/** @extends {Component<TechnicalValueProps, import("../hoot").Environment>} */
|
||||
export class HootTechnicalValue extends Component {
|
||||
static components = { HootTechnicalValue };
|
||||
|
||||
static props = {
|
||||
value: { optional: true },
|
||||
};
|
||||
|
||||
static template = xml`
|
||||
<t t-if="isMarkup">
|
||||
<t t-if="value.type === 'technical'">
|
||||
<pre class="hoot-technical" t-att-class="value.className">
|
||||
<t t-foreach="value.content" t-as="subValue" t-key="subValue_index">
|
||||
<HootTechnicalValue value="subValue" />
|
||||
</t>
|
||||
</pre>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-if="value.tagName === 't'" t-esc="value.content" />
|
||||
<t t-else="" t-tag="value.tagName" t-att-class="value.className" t-esc="value.content" />
|
||||
</t>
|
||||
</t>
|
||||
<t t-elif="isNode(value)">
|
||||
<t t-set="elParts" t-value="toSelector(value, { object: true })" />
|
||||
<button
|
||||
class="hoot-html"
|
||||
t-on-click.stop="log"
|
||||
>
|
||||
<t><<t t-esc="elParts.tag" /></t>
|
||||
<t t-if="elParts.id">
|
||||
<span class="hoot-html-id" t-esc="elParts.id" />
|
||||
</t>
|
||||
<t t-if="elParts.class">
|
||||
<span class="hoot-html-class" t-esc="elParts.class" />
|
||||
</t>
|
||||
<t>/></t>
|
||||
</button>
|
||||
</t>
|
||||
<t t-elif="value === S_ANY or value === S_NONE">
|
||||
<span class="italic">
|
||||
<<t t-esc="symbolValue(value)" />>
|
||||
</span>
|
||||
</t>
|
||||
<t t-elif="typeof value === 'symbol'">
|
||||
<span>
|
||||
Symbol(<span class="hoot-string" t-esc="stringify(symbolValue(value))" />)
|
||||
</span>
|
||||
</t>
|
||||
<t t-elif="value and typeof value === 'object'">
|
||||
<t t-set="labelSize" t-value="getLabelAndSize()" />
|
||||
<pre class="hoot-technical">
|
||||
<button
|
||||
class="hoot-object inline-flex items-center gap-1 me-1"
|
||||
t-on-click.stop="onClick"
|
||||
>
|
||||
<t t-if="labelSize[1] > 0">
|
||||
<i
|
||||
class="fa fa-caret-right"
|
||||
t-att-class="{ 'rotate-90': state.open }"
|
||||
/>
|
||||
</t>
|
||||
<t t-esc="labelSize[0]" />
|
||||
<t t-if="state.promiseState">
|
||||
<
|
||||
<span class="text-gray" t-esc="state.promiseState[0]" />
|
||||
<t t-if="state.promiseState[0] !== 'pending'">
|
||||
: <HootTechnicalValue value="state.promiseState[1]" />
|
||||
</t>
|
||||
>
|
||||
</t>
|
||||
<t t-elif="labelSize[1] !== null">
|
||||
(<t t-esc="labelSize[1]" />)
|
||||
</t>
|
||||
</button>
|
||||
<t t-if="state.open and labelSize[1] > 0">
|
||||
<t t-if="isIterable(value)">
|
||||
<t>[</t>
|
||||
<ul class="ps-4">
|
||||
<t t-foreach="value" t-as="subValue" t-key="subValue_index">
|
||||
<li class="flex">
|
||||
<HootTechnicalValue value="subValue" />
|
||||
<t t-esc="displayComma(subValue)" />
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
<t>]</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t>{</t>
|
||||
<ul class="ps-4">
|
||||
<t t-foreach="value" t-as="key" t-key="key">
|
||||
<li class="flex">
|
||||
<span class="hoot-key" t-esc="key" />
|
||||
<span class="me-1">:</span>
|
||||
<HootTechnicalValue value="value[key]" />
|
||||
<t t-esc="displayComma(value[key])" />
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
<t>}</t>
|
||||
</t>
|
||||
</t>
|
||||
</pre>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-attf-class="hoot-{{ getTypeOf(value) }}">
|
||||
<t t-esc="typeof value === 'string' ? stringify(explicitValue) : explicitValue" />
|
||||
</span>
|
||||
</t>
|
||||
`;
|
||||
|
||||
getTypeOf = getTypeOf;
|
||||
isIterable = isIterable;
|
||||
isNode = isNode;
|
||||
stringify = stringify;
|
||||
toSelector = toSelector;
|
||||
|
||||
S_ANY = S_ANY;
|
||||
S_NONE = S_NONE;
|
||||
|
||||
get explicitValue() {
|
||||
return toExplicitString(this.value);
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.logged = false;
|
||||
this.state = useState({
|
||||
open: false,
|
||||
promiseState: null,
|
||||
});
|
||||
this.wrapPromiseValue(this.props.value);
|
||||
|
||||
onWillRender(() => {
|
||||
this.isMarkup = Markup.isMarkup(this.props.value);
|
||||
this.value = toRaw(this.props.value);
|
||||
this.isSafe = isSafe(this.value);
|
||||
});
|
||||
onWillUpdateProps((nextProps) => {
|
||||
this.state.open = false;
|
||||
this.wrapPromiseValue(nextProps.value);
|
||||
});
|
||||
}
|
||||
|
||||
onClick() {
|
||||
this.log(this.value);
|
||||
this.state.open = !this.state.open;
|
||||
}
|
||||
|
||||
getLabelAndSize() {
|
||||
if (isInstanceOf(this.value, Date)) {
|
||||
return [this.value.toISOString(), null];
|
||||
}
|
||||
if (isInstanceOf(this.value, RegExp)) {
|
||||
return [String(this.value), null];
|
||||
}
|
||||
return [this.value.constructor.name, this.getSize()];
|
||||
}
|
||||
|
||||
getSize() {
|
||||
for (const Class of INVARIABLE_OBJECTS) {
|
||||
if (isInstanceOf(this.value, Class)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!this.isSafe) {
|
||||
return 0;
|
||||
}
|
||||
const values = isIterable(this.value) ? [...this.value] : $keys(this.value);
|
||||
return values.length;
|
||||
}
|
||||
|
||||
displayComma(value) {
|
||||
return value && typeof value === "object" ? "" : ",";
|
||||
}
|
||||
|
||||
log() {
|
||||
if (this.logged) {
|
||||
return;
|
||||
}
|
||||
this.logged = true;
|
||||
logger.debug(this.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Symbol} symbol
|
||||
*/
|
||||
symbolValue(symbol) {
|
||||
return symbol.toString().slice(7, -1);
|
||||
}
|
||||
|
||||
wrapPromiseValue(promise) {
|
||||
if (!isInstanceOf(promise, Promise)) {
|
||||
return;
|
||||
}
|
||||
this.state.promiseState = ["pending", null];
|
||||
Promise.resolve(promise).then(
|
||||
(value) => {
|
||||
this.state.promiseState = ["fulfilled", value];
|
||||
return value;
|
||||
},
|
||||
(reason) => {
|
||||
this.state.promiseState = ["rejected", reason];
|
||||
throw reason;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
import { Test } from "../core/test";
|
||||
import { HootCopyButton } from "./hoot_copy_button";
|
||||
import { HootLink } from "./hoot_link";
|
||||
import { HootTagButton } from "./hoot_tag_button";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* canCopy?: boolean;
|
||||
* full?: boolean;
|
||||
* inert?: boolean;
|
||||
* showStatus?: boolean;
|
||||
* test: Test;
|
||||
* }} HootTestPathProps
|
||||
*/
|
||||
|
||||
/** @extends {Component<HootTestPathProps, import("../hoot").Environment>} */
|
||||
export class HootTestPath extends Component {
|
||||
static components = { HootCopyButton, HootLink, HootTagButton };
|
||||
|
||||
static props = {
|
||||
canCopy: { type: Boolean, optional: true },
|
||||
full: { type: Boolean, optional: true },
|
||||
inert: { type: Boolean, optional: true },
|
||||
showStatus: { type: Boolean, optional: true },
|
||||
test: Test,
|
||||
};
|
||||
|
||||
static template = xml`
|
||||
<t t-set="statusInfo" t-value="getStatusInfo()" />
|
||||
<div class="flex items-center gap-1 whitespace-nowrap overflow-hidden">
|
||||
<t t-if="props.showStatus">
|
||||
<span
|
||||
t-attf-class="inline-flex min-w-3 min-h-3 rounded-full bg-{{ statusInfo.className }}"
|
||||
t-att-title="statusInfo.text"
|
||||
/>
|
||||
</t>
|
||||
<span class="flex items-center overflow-hidden">
|
||||
<t t-if="uiState.selectedSuiteId and !props.full">
|
||||
<span class="text-gray font-bold p-1 select-none hidden md:inline">...</span>
|
||||
<span class="select-none hidden md:inline">/</span>
|
||||
</t>
|
||||
<t t-foreach="getTestPath()" t-as="suite" t-key="suite.id">
|
||||
<t t-if="props.inert">
|
||||
<span
|
||||
class="text-gray whitespace-nowrap font-bold p-1 hidden md:inline transition-colors"
|
||||
t-esc="suite.name"
|
||||
/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<HootLink
|
||||
ids="{ id: suite.id }"
|
||||
class="'text-gray hover:text-primary hover:underline whitespace-nowrap font-bold p-1 hidden md:inline transition-colors'"
|
||||
title="'Run ' + suite.fullName"
|
||||
t-esc="suite.name"
|
||||
/>
|
||||
<t t-if="suite.config.multi">
|
||||
<strong class="text-amber whitespace-nowrap me-1">
|
||||
x<t t-esc="suite.config.multi" />
|
||||
</strong>
|
||||
</t>
|
||||
</t>
|
||||
<span class="select-none hidden md:inline" t-att-class="{ 'text-cyan': suite.config.skip }">/</span>
|
||||
</t>
|
||||
<span
|
||||
class="text-primary truncate font-bold p-1"
|
||||
t-att-class="{ 'text-cyan': props.test.config.skip }"
|
||||
t-att-title="props.test.name"
|
||||
t-esc="props.test.name"
|
||||
/>
|
||||
<t t-if="props.canCopy">
|
||||
<HootCopyButton text="props.test.name" altText="props.test.id" />
|
||||
</t>
|
||||
<t t-if="results.length > 1">
|
||||
<strong class="text-amber whitespace-nowrap mx-1">
|
||||
x<t t-esc="results.length" />
|
||||
</strong>
|
||||
</t>
|
||||
</span>
|
||||
<t t-if="props.test.tags.length">
|
||||
<ul class="flex items-center gap-1">
|
||||
<t t-foreach="props.test.tags.slice(0, 5)" t-as="tag" t-key="tag.name">
|
||||
<li class="flex">
|
||||
<HootTagButton tag="tag" />
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setup() {
|
||||
this.results = useState(this.props.test.results);
|
||||
this.uiState = useState(this.env.ui);
|
||||
}
|
||||
|
||||
getStatusInfo() {
|
||||
switch (this.props.test.status) {
|
||||
case Test.ABORTED: {
|
||||
return { className: "amber", text: "aborted" };
|
||||
}
|
||||
case Test.FAILED: {
|
||||
if (this.props.test.config.todo) {
|
||||
return { className: "purple", text: "todo" };
|
||||
} else {
|
||||
return { className: "rose", text: "failed" };
|
||||
}
|
||||
}
|
||||
case Test.PASSED: {
|
||||
if (this.props.test.config.todo) {
|
||||
return { className: "purple", text: "todo" };
|
||||
} else {
|
||||
return { className: "emerald", text: "passed" };
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return { className: "cyan", text: "skipped" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../core/suite").Suite} suite
|
||||
*/
|
||||
getSuiteInfo(suite) {
|
||||
let suites = 0;
|
||||
let tests = 0;
|
||||
let assertions = 0;
|
||||
for (const job of suite.jobs) {
|
||||
if (job instanceof Test) {
|
||||
tests++;
|
||||
assertions += job.lastResults?.counts.assertion || 0;
|
||||
} else {
|
||||
suites++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: suite.id,
|
||||
name: suite.name,
|
||||
parent: suite.parent?.name || null,
|
||||
suites,
|
||||
tests,
|
||||
assertions,
|
||||
};
|
||||
}
|
||||
|
||||
getTestPath() {
|
||||
const { selectedSuiteId } = this.uiState;
|
||||
const { test } = this.props;
|
||||
const path = test.path.slice(0, -1);
|
||||
if (this.props.full || !selectedSuiteId) {
|
||||
return path;
|
||||
}
|
||||
const index = path.findIndex((suite) => suite.id === selectedSuiteId) + 1;
|
||||
return path.slice(index);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,410 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component, onWillRender, useState, xml } from "@odoo/owl";
|
||||
import { isFirefox } from "../../hoot-dom/hoot_dom_utils";
|
||||
import { Tag } from "../core/tag";
|
||||
import { Test } from "../core/test";
|
||||
import { subscribeToURLParams } from "../core/url";
|
||||
import {
|
||||
CASE_EVENT_TYPES,
|
||||
formatHumanReadable,
|
||||
formatTime,
|
||||
getTypeOf,
|
||||
isLabel,
|
||||
Markup,
|
||||
ordinal,
|
||||
} from "../hoot_utils";
|
||||
import { HootCopyButton } from "./hoot_copy_button";
|
||||
import { HootLink } from "./hoot_link";
|
||||
import { HootTechnicalValue } from "./hoot_technical_value";
|
||||
|
||||
/**
|
||||
* @typedef {import("../core/expect").CaseEvent} CaseEvent
|
||||
* @typedef {import("../core/expect").CaseEventType} CaseEventType
|
||||
* @typedef {import("../core/expect").CaseResult} CaseResult
|
||||
* @typedef {import("./setup_hoot_ui").StatusFilter} StatusFilter
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Boolean,
|
||||
Object: { entries: $entries, fromEntries: $fromEntries },
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {[number, CaseEvent][]} indexedResults
|
||||
* @param {number} events
|
||||
*/
|
||||
function filterEvents(indexedResults, events) {
|
||||
/** @type {Record<number, CaseEvent[]>} */
|
||||
const filteredEvents = {};
|
||||
for (const [i, result] of indexedResults) {
|
||||
filteredEvents[i] = result.getEvents(events);
|
||||
}
|
||||
return filteredEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CaseEvent[]} results
|
||||
* @param {StatusFilter} statusFilter
|
||||
*/
|
||||
function filterResults(results, statusFilter) {
|
||||
const ordinalResults = [];
|
||||
const hasFailed = results.some((r) => !r.pass);
|
||||
const shouldPass = statusFilter === "passed";
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
if (!hasFailed || results[i].pass === shouldPass) {
|
||||
ordinalResults.push([i + 1, results[i]]);
|
||||
}
|
||||
}
|
||||
return ordinalResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {string} owner
|
||||
*/
|
||||
function stackTemplate(label, owner) {
|
||||
// Defined with string concat because line returns are taken into account in <pre> tags.
|
||||
const preContent =
|
||||
/* xml */ `<t t-foreach="parseStack(${owner}.stack)" t-as="part" t-key="part_index">` +
|
||||
/* xml */ `<t t-if="typeof part === 'string'" t-esc="part" />` +
|
||||
/* xml */ `<span t-else="" t-att-class="part.className" t-esc="part.value" />` +
|
||||
/* xml */ `</t>`;
|
||||
return /* xml */ `
|
||||
<t t-if="${owner}?.stack">
|
||||
<div class="flex col-span-2 gap-x-2 px-2 mt-1">
|
||||
<span class="text-rose">
|
||||
${label}:
|
||||
</span>
|
||||
<pre class="hoot-technical m-0">${preContent}</pre>
|
||||
</div>
|
||||
</t>
|
||||
`;
|
||||
}
|
||||
|
||||
const ERROR_TEMPLATE = /* xml */ `
|
||||
<div class="text-rose flex items-center gap-1 px-2 truncate">
|
||||
<i class="fa fa-exclamation" />
|
||||
<strong t-esc="event.label" />
|
||||
<span class="flex truncate" t-esc="event.message.join(' ')" />
|
||||
</div>
|
||||
<t t-set="timestamp" t-value="formatTime(event.ts - (result.ts || 0), 'ms')" />
|
||||
<small class="text-gray flex items-center" t-att-title="timestamp">
|
||||
<t t-esc="'@' + timestamp" />
|
||||
</small>
|
||||
${stackTemplate("Source", "event")}
|
||||
${stackTemplate("Cause", "event.cause")}
|
||||
`;
|
||||
|
||||
const EVENT_TEMPLATE = /* xml */ `
|
||||
<div
|
||||
t-attf-class="text-{{ eventColor }} flex items-center gap-1 px-2 truncate"
|
||||
>
|
||||
<t t-if="sType === 'assertion'">
|
||||
<t t-esc="event.number + '.'" />
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa" t-att-class="eventIcon" />
|
||||
</t>
|
||||
<!-- TODO: add documentation links once they exist -->
|
||||
<a href="#" class="hover:text-primary flex gap-1 items-center" t-att-class="{ 'text-cyan': sType === 'assertion' }">
|
||||
<t t-if="event.flags">
|
||||
<i t-if="event.hasFlag('rejects')" class="fa fa-times" />
|
||||
<i t-elif="event.hasFlag('resolves')" class="fa fa-arrow-right" />
|
||||
<i t-if="event.hasFlag('not')" class="fa fa-exclamation" />
|
||||
</t>
|
||||
<strong t-esc="event.label" />
|
||||
</a>
|
||||
<span class="flex gap-1 truncate items-center">
|
||||
<t t-foreach="event.message" t-as="part" t-key="part_index">
|
||||
<t t-if="isLabel(part)">
|
||||
<t t-if="!part[1]">
|
||||
<span t-esc="part[0]" />
|
||||
</t>
|
||||
<t t-elif="part[1].endsWith('[]')">
|
||||
<strong class="hoot-array">
|
||||
<t>[</t>
|
||||
<span t-attf-class="hoot-{{ part[1].slice(0, -2) }}" t-esc="part[0].slice(1, -1)" />
|
||||
<t>]</t>
|
||||
</strong>
|
||||
</t>
|
||||
<t t-elif="part[1] === 'icon'">
|
||||
<i t-att-class="part[0]" />
|
||||
</t>
|
||||
<t t-else="">
|
||||
<strong t-attf-class="hoot-{{ part[1] }}">
|
||||
<t t-if="part[1] === 'url'">
|
||||
<a
|
||||
class="underline"
|
||||
t-att-href="part[0]"
|
||||
t-esc="part[0]"
|
||||
target="_blank"
|
||||
/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-esc="part[0]" />
|
||||
</t>
|
||||
</strong>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-esc="part" />
|
||||
</t>
|
||||
</t>
|
||||
</span>
|
||||
</div>
|
||||
<t t-set="timestamp" t-value="formatTime(event.ts - (result.ts || 0), 'ms')" />
|
||||
<small class="flex items-center text-gray" t-att-title="timestamp">
|
||||
<t t-esc="'@' + timestamp" />
|
||||
</small>
|
||||
<t t-if="event.additionalMessage">
|
||||
<div class="flex items-center ms-4 px-2 gap-1 col-span-2">
|
||||
<em class="text-blue truncate" t-esc="event.additionalMessage" />
|
||||
<HootCopyButton text="event.additionalMessage" />
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="!event.pass">
|
||||
<t t-if="event.failedDetails">
|
||||
<div class="hoot-info grid col-span-2 gap-x-2 px-2">
|
||||
<t t-foreach="event.failedDetails" t-as="details" t-key="details_index">
|
||||
<t t-if="isMarkup(details, 'group')">
|
||||
<div class="col-span-2 flex gap-2 ps-2 mt-1" t-att-class="details.className">
|
||||
<t t-esc="details.groupIndex" />.
|
||||
<HootTechnicalValue value="details.content" />
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<HootTechnicalValue value="details[0]" />
|
||||
<HootTechnicalValue value="details[1]" />
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
${stackTemplate("Source", "event")}
|
||||
</t>
|
||||
`;
|
||||
|
||||
const CASE_EVENT_TYPES_INVERSE = $fromEntries(
|
||||
$entries(CASE_EVENT_TYPES).map(([k, v]) => [v.value, k])
|
||||
);
|
||||
|
||||
const R_STACK_LINE_START = isFirefox()
|
||||
? /^\s*(?<prefix>@)(?<rest>.*)/i
|
||||
: /^\s*(?<prefix>at)(?<rest>.*)/i;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* open?: boolean | "always";
|
||||
* slots: any;
|
||||
* test: Test;
|
||||
* }} TestResultProps
|
||||
*/
|
||||
|
||||
/** @extends {Component<TestResultProps, import("../hoot").Environment>} */
|
||||
export class HootTestResult extends Component {
|
||||
static components = { HootCopyButton, HootLink, HootTechnicalValue };
|
||||
|
||||
static props = {
|
||||
open: [{ type: Boolean }, { value: "always" }],
|
||||
slots: {
|
||||
type: Object,
|
||||
shape: {
|
||||
default: Object,
|
||||
},
|
||||
},
|
||||
test: Test,
|
||||
};
|
||||
|
||||
static template = xml`
|
||||
<div
|
||||
class="${HootTestResult.name}
|
||||
flex flex-col w-full border-b overflow-hidden
|
||||
border-gray-300 dark:border-gray-600"
|
||||
t-att-class="getClassName()"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 flex items-center justify-between"
|
||||
t-on-click.stop="toggleDetails"
|
||||
>
|
||||
<t t-slot="default" />
|
||||
</button>
|
||||
<t t-if="state.showDetails and !props.test.config.skip">
|
||||
<t t-foreach="filteredResults" t-as="indexedResult" t-key="indexedResult[0]">
|
||||
<t t-set="index" t-value="indexedResult[0]" />
|
||||
<t t-set="result" t-value="indexedResult[1]" />
|
||||
<t t-if="results.length > 1">
|
||||
<div class="flex justify-between mx-2 my-1">
|
||||
<span t-attf-class="text-{{ result.pass ? 'emerald' : 'rose' }}">
|
||||
<t t-esc="ordinal(index)" /> run:
|
||||
</span>
|
||||
<t t-set="timestamp" t-value="formatTime(result.duration, 'ms')" />
|
||||
<small class="text-gray flex items-center" t-att-title="timestamp">
|
||||
<t t-esc="timestamp" />
|
||||
</small>
|
||||
</div>
|
||||
</t>
|
||||
<div class="hoot-result-detail grid gap-1 rounded overflow-x-auto p-1 mx-2 animate-slide-down">
|
||||
<t t-if="!filteredEvents[index].length">
|
||||
<em class="text-gray px-2 py-1">No test event to show</em>
|
||||
</t>
|
||||
<t t-foreach="filteredEvents[index]" t-as="event" t-key="event_index">
|
||||
<t t-set="sType" t-value="getTypeName(event.type)" />
|
||||
<t t-set="eventIcon" t-value="CASE_EVENT_TYPES[sType].icon" />
|
||||
<t t-set="eventColor" t-value="
|
||||
'pass' in event ?
|
||||
(event.pass ? 'emerald' : 'rose') :
|
||||
CASE_EVENT_TYPES[sType].color"
|
||||
/>
|
||||
<t t-if="sType === 'error'">
|
||||
${ERROR_TEMPLATE}
|
||||
</t>
|
||||
<t t-else="">
|
||||
${EVENT_TEMPLATE}
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<div class="flex flex-col overflow-y-hidden">
|
||||
<nav class="flex items-center gap-2 p-2 text-gray">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center px-1 gap-1 text-sm hover:text-primary"
|
||||
t-on-click.stop="toggleCode"
|
||||
>
|
||||
<t t-if="state.showCode">
|
||||
Hide source code
|
||||
</t>
|
||||
<t t-else="">
|
||||
Show source code
|
||||
</t>
|
||||
</button>
|
||||
</nav>
|
||||
<t t-if="state.showCode">
|
||||
<div class="m-2 mt-0 rounded animate-slide-down overflow-auto">
|
||||
<pre
|
||||
class="language-javascript"
|
||||
style="margin: 0"
|
||||
><code class="language-javascript" t-out="props.test.code" /></pre>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
`;
|
||||
|
||||
CASE_EVENT_TYPES = CASE_EVENT_TYPES;
|
||||
|
||||
Tag = Tag;
|
||||
formatHumanReadable = formatHumanReadable;
|
||||
formatTime = formatTime;
|
||||
getTypeOf = getTypeOf;
|
||||
isLabel = isLabel;
|
||||
isMarkup = Markup.isMarkup;
|
||||
ordinal = ordinal;
|
||||
|
||||
/** @type {ReturnType<typeof filterEvents>} */
|
||||
filteredEvents;
|
||||
/** @type {[number, CaseEvent][]} */
|
||||
filteredResults;
|
||||
|
||||
setup() {
|
||||
subscribeToURLParams("*");
|
||||
|
||||
const { runner, ui } = this.env;
|
||||
this.config = useState(runner.config);
|
||||
this.logs = useState(this.props.test.logs);
|
||||
this.results = useState(this.props.test.results);
|
||||
this.state = useState({
|
||||
showCode: false,
|
||||
showDetails: Boolean(this.props.open),
|
||||
});
|
||||
this.uiState = useState(ui);
|
||||
|
||||
onWillRender(this.onWillRender.bind(this));
|
||||
}
|
||||
|
||||
getClassName() {
|
||||
if (this.logs.error) {
|
||||
return "bg-rose-900";
|
||||
}
|
||||
switch (this.props.test.status) {
|
||||
case Test.ABORTED: {
|
||||
return "bg-amber-900";
|
||||
}
|
||||
case Test.FAILED: {
|
||||
if (this.props.test.config.todo) {
|
||||
return "bg-purple-900";
|
||||
} else {
|
||||
return "bg-rose-900";
|
||||
}
|
||||
}
|
||||
case Test.PASSED: {
|
||||
if (this.logs.warn) {
|
||||
return "bg-amber-900";
|
||||
} else if (this.props.test.config.todo) {
|
||||
return "bg-purple-900";
|
||||
} else {
|
||||
return "bg-emerald-900";
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return "bg-cyan-900";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} nType
|
||||
*/
|
||||
getTypeName(nType) {
|
||||
return CASE_EVENT_TYPES_INVERSE[nType];
|
||||
}
|
||||
|
||||
onWillRender() {
|
||||
this.filteredResults = filterResults(this.results, this.uiState.statusFilter);
|
||||
this.filteredEvents = filterEvents(this.filteredResults, this.config.events);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} stack
|
||||
*/
|
||||
parseStack(stack) {
|
||||
const result = [];
|
||||
for (const line of stack.split("\n")) {
|
||||
const match = line.match(R_STACK_LINE_START);
|
||||
if (match) {
|
||||
result.push(
|
||||
{ className: "text-rose", value: match.groups.prefix },
|
||||
match.groups.rest + "\n"
|
||||
);
|
||||
} else {
|
||||
result.push(line + "\n");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
toggleCode() {
|
||||
this.state.showCode = !this.state.showCode;
|
||||
}
|
||||
|
||||
toggleDetails() {
|
||||
if (this.props.open === "always") {
|
||||
return;
|
||||
}
|
||||
this.state.showDetails = !this.state.showDetails;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { mount, reactive } from "@odoo/owl";
|
||||
import { HootFixtureElement } from "../core/fixture";
|
||||
import { waitForDocument } from "../hoot_utils";
|
||||
import { getRunner } from "../main_runner";
|
||||
import { patchWindow } from "../mock/window";
|
||||
import {
|
||||
generateStyleSheets,
|
||||
getColorScheme,
|
||||
onColorSchemeChange,
|
||||
setColorRoot,
|
||||
} from "./hoot_colors";
|
||||
import { HootMain } from "./hoot_main";
|
||||
|
||||
/**
|
||||
* @typedef {"failed" | "passed" | "skipped" | "todo"} StatusFilter
|
||||
*
|
||||
* @typedef {ReturnType<typeof makeUiState>} UiState
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
customElements,
|
||||
document,
|
||||
fetch,
|
||||
HTMLElement,
|
||||
Object: { entries: $entries },
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {string} href
|
||||
*/
|
||||
function createLinkElement(href) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = href;
|
||||
return link;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} content
|
||||
*/
|
||||
function createStyleElement(content) {
|
||||
const style = document.createElement("style");
|
||||
style.innerText = content;
|
||||
return style;
|
||||
}
|
||||
|
||||
function getPrismStyleUrl() {
|
||||
const theme = getColorScheme() === "dark" ? "okaida" : "default";
|
||||
return `/web/static/lib/prismjs/themes/${theme}.css`;
|
||||
}
|
||||
|
||||
function loadAsset(tagName, attributes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const el = document.createElement(tagName);
|
||||
Object.assign(el, attributes);
|
||||
el.addEventListener("load", resolve);
|
||||
el.addEventListener("error", reject);
|
||||
document.head.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadBundle(bundle) {
|
||||
const bundleResponse = await fetch(`/web/bundle/${bundle}`);
|
||||
const result = await bundleResponse.json();
|
||||
const promises = [];
|
||||
for (const { src, type } of result) {
|
||||
if (src && type === "link") {
|
||||
loadAsset("link", {
|
||||
rel: "stylesheet",
|
||||
href: src,
|
||||
});
|
||||
} else if (src && type === "script") {
|
||||
promises.push(
|
||||
loadAsset("script", {
|
||||
src,
|
||||
type: "text/javascript",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
class HootContainer extends HTMLElement {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
setColorRoot(this);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
setColorRoot(null);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("hoot-container", HootContainer);
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export function makeUiState() {
|
||||
return reactive({
|
||||
resultsPage: 0,
|
||||
resultsPerPage: 40,
|
||||
/** @type {string | null} */
|
||||
selectedSuiteId: null,
|
||||
/** @type {"asc" | "desc" | false} */
|
||||
sortResults: false,
|
||||
/** @type {StatusFilter | null} */
|
||||
statusFilter: null,
|
||||
totalResults: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the main Hoot UI components in a container, which itself will be appended
|
||||
* on the current document body.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function setupHootUI() {
|
||||
// - Patch window before code from other modules is executed
|
||||
patchWindow();
|
||||
|
||||
const runner = getRunner();
|
||||
|
||||
const container = document.createElement("hoot-container");
|
||||
container.style.display = "contents";
|
||||
|
||||
await waitForDocument(document);
|
||||
|
||||
document.head.appendChild(HootFixtureElement.styleElement);
|
||||
document.body.appendChild(container);
|
||||
|
||||
const promises = [
|
||||
// Mount main container
|
||||
mount(HootMain, container.shadowRoot, {
|
||||
env: {
|
||||
runner,
|
||||
ui: makeUiState(),
|
||||
},
|
||||
name: "HOOT",
|
||||
}),
|
||||
];
|
||||
|
||||
if (!runner.headless) {
|
||||
// In non-headless: also wait for lazy-loaded libs (Highlight & DiffMatchPatch)
|
||||
promises.push(loadBundle("web.assets_unit_tests_setup_ui"));
|
||||
|
||||
let colorStyleContent = "";
|
||||
for (const [className, content] of $entries(generateStyleSheets())) {
|
||||
const selector = className === "default" ? ":host" : `:host(.${className})`;
|
||||
colorStyleContent += `${selector}{${content}}`;
|
||||
}
|
||||
|
||||
const prismStyleLink = createLinkElement(getPrismStyleUrl());
|
||||
onColorSchemeChange(() => {
|
||||
prismStyleLink.href = getPrismStyleUrl();
|
||||
});
|
||||
|
||||
container.shadowRoot.append(
|
||||
createStyleElement(colorStyleContent),
|
||||
createLinkElement("/web/static/src/libs/fontawesome/css/font-awesome.css"),
|
||||
prismStyleLink,
|
||||
// Hoot-specific style is loaded last to take priority over other stylesheets
|
||||
createLinkElement("/web/static/lib/hoot/ui/hoot_style.css")
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
177
odoo-bringout-oca-ocb-web/web/static/lib/pdfjs/LICENSE
Normal file
177
odoo-bringout-oca-ocb-web/web/static/lib/pdfjs/LICENSE
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
111
odoo-bringout-oca-ocb-web/web/static/lib/pdfjs/web/debugger.css
Normal file
111
odoo-bringout-oca-ocb-web/web/static/lib/pdfjs/web/debugger.css
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/* Copyright 2014 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--panel-width: 300px;
|
||||
}
|
||||
|
||||
#PDFBug,
|
||||
#PDFBug :is(input, button, select) {
|
||||
font: message-box;
|
||||
}
|
||||
#PDFBug {
|
||||
background-color: rgb(255 255 255);
|
||||
border: 1px solid rgb(102 102 102);
|
||||
position: fixed;
|
||||
top: 32px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
font-size: 10px;
|
||||
padding: 0;
|
||||
width: var(--panel-width);
|
||||
}
|
||||
#PDFBug .controls {
|
||||
background: rgb(238 238 238);
|
||||
border-bottom: 1px solid rgb(102 102 102);
|
||||
padding: 3px;
|
||||
}
|
||||
#PDFBug .panels {
|
||||
inset: 27px 0 0;
|
||||
overflow: auto;
|
||||
position: absolute;
|
||||
}
|
||||
#PDFBug .panels > div {
|
||||
padding: 5px;
|
||||
}
|
||||
#PDFBug button.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
.debuggerShowText,
|
||||
.debuggerHideText:hover {
|
||||
background-color: rgb(255 255 0 / 0.25);
|
||||
}
|
||||
#PDFBug .stats {
|
||||
font-family: courier;
|
||||
font-size: 10px;
|
||||
white-space: pre;
|
||||
}
|
||||
#PDFBug .stats .title {
|
||||
font-weight: bold;
|
||||
}
|
||||
#PDFBug table {
|
||||
font-size: 10px;
|
||||
white-space: pre;
|
||||
}
|
||||
#PDFBug table.showText {
|
||||
border-collapse: collapse;
|
||||
text-align: center;
|
||||
}
|
||||
#PDFBug table.showText,
|
||||
#PDFBug table.showText :is(tr, td) {
|
||||
border: 1px solid black;
|
||||
padding: 1px;
|
||||
}
|
||||
#PDFBug table.showText td.advance {
|
||||
color: grey;
|
||||
}
|
||||
|
||||
#viewer.textLayer-visible .textLayer {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#viewer.textLayer-visible .canvasWrapper {
|
||||
background-color: rgb(128 255 128);
|
||||
}
|
||||
|
||||
#viewer.textLayer-visible .canvasWrapper canvas {
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
#viewer.textLayer-visible .textLayer span {
|
||||
background-color: rgb(255 255 0 / 0.1);
|
||||
color: rgb(0 0 0);
|
||||
border: solid 1px rgb(255 0 0 / 0.5);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#viewer.textLayer-visible .textLayer span[aria-owns] {
|
||||
background-color: rgb(255 0 0 / 0.3);
|
||||
}
|
||||
|
||||
#viewer.textLayer-hover .textLayer span:hover {
|
||||
background-color: rgb(255 255 255);
|
||||
color: rgb(0 0 0);
|
||||
}
|
||||
|
||||
#viewer.textLayer-shadow .textLayer span {
|
||||
background-color: rgb(255 255 255 / 0.6);
|
||||
color: rgb(0 0 0);
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.375 7.625V11.875C5.375 12.0408 5.44085 12.1997 5.55806 12.3169C5.67527 12.4342 5.83424 12.5 6 12.5C6.16576 12.5 6.32473 12.4342 6.44194 12.3169C6.55915 12.1997 6.625 12.0408 6.625 11.875V7.625L7.125 7.125H11.375C11.5408 7.125 11.6997 7.05915 11.8169 6.94194C11.9342 6.82473 12 6.66576 12 6.5C12 6.33424 11.9342 6.17527 11.8169 6.05806C11.6997 5.94085 11.5408 5.875 11.375 5.875H7.125L6.625 5.375V1.125C6.625 0.95924 6.55915 0.800269 6.44194 0.683058C6.32473 0.565848 6.16576 0.5 6 0.5C5.83424 0.5 5.67527 0.565848 5.55806 0.683058C5.44085 0.800269 5.375 0.95924 5.375 1.125V5.375L4.875 5.875H0.625C0.45924 5.875 0.300269 5.94085 0.183058 6.05806C0.065848 6.17527 0 6.33424 0 6.5C0 6.66576 0.065848 6.82473 0.183058 6.94194C0.300269 7.05915 0.45924 7.125 0.625 7.125H4.762L5.375 7.625Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 920 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 0.5C5.21207 0.5 4.43185 0.655195 3.7039 0.956723C2.97595 1.25825 2.31451 1.70021 1.75736 2.25736C1.20021 2.81451 0.758251 3.47595 0.456723 4.2039C0.155195 4.93185 0 5.71207 0 6.5C0 7.28793 0.155195 8.06815 0.456723 8.7961C0.758251 9.52405 1.20021 10.1855 1.75736 10.7426C2.31451 11.2998 2.97595 11.7417 3.7039 12.0433C4.43185 12.3448 5.21207 12.5 6 12.5C7.5913 12.5 9.11742 11.8679 10.2426 10.7426C11.3679 9.61742 12 8.0913 12 6.5C12 4.9087 11.3679 3.38258 10.2426 2.25736C9.11742 1.13214 7.5913 0.5 6 0.5ZM5.06 8.9L2.9464 6.7856C2.85273 6.69171 2.80018 6.56446 2.80033 6.43183C2.80048 6.29921 2.85331 6.17207 2.9472 6.0784C3.04109 5.98473 3.16834 5.93218 3.30097 5.93233C3.43359 5.93248 3.56073 5.98531 3.6544 6.0792L5.3112 7.7368L8.3464 4.7008C8.44109 4.6109 8.56715 4.56153 8.69771 4.56322C8.82827 4.56492 8.95301 4.61754 9.04534 4.70986C9.13766 4.80219 9.19028 4.92693 9.19198 5.05749C9.19367 5.18805 9.1443 5.31411 9.0544 5.4088L5.5624 8.9H5.06Z" fill="#FBFBFE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -0,0 +1,6 @@
|
|||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" height="40" width="40">
|
||||
<path d="M9 3.5a1.5 1.5 0 0 0-3-.001v7.95C6 12.83 7.12 14 8.5 14s2.5-1.17 2.5-2.55V5.5a.5.5 0 0 1 1 0v6.03C11.955 13.427 10.405 15 8.5 15S5.044 13.426 5 11.53V3.5a2.5 2.5 0 0 1 5 0v7.003a1.5 1.5 0 0 1-3-.003v-5a.5.5 0 0 1 1 0v5a.5.5 0 0 0 1 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 552 B |
|
|
@ -0,0 +1,7 @@
|
|||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" height="40" width="40">
|
||||
<path d="M8.156 12.5a.99.99 0 0 0 .707-.294l.523-2.574L10.5 8.499l1.058-1.04 2.65-.601a.996.996 0 0 0 0-1.414l-3.657-3.658a.996.996 0 0 0-1.414 0l-.523 2.576L7.5 5.499 6.442 6.535l-2.65.6a.996.996 0 0 0 0 1.413l3.657 3.658a.999.999 0 0 0 .707.295z"/>
|
||||
<path d="M9.842.996c-.386 0-.77.146-1.06.44a.5.5 0 0 0-.136.251l-.492 2.43-1.008 1.03-.953.933-2.511.566a.5.5 0 0 0-.243.133 1.505 1.505 0 0 0-.002 2.123l1.477 1.477-2.768 2.767a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l2.767-2.767 1.475 1.474a1.494 1.494 0 0 0 2.123-.002.5.5 0 0 0 .135-.254l.492-2.427 1.008-1.024.953-.937 2.511-.57a.5.5 0 0 0 .243-.132c.586-.58.583-1.543.002-2.125l-3.659-3.656A1.501 1.501 0 0 0 9.842.996Zm.05 1.025a.394.394 0 0 1 .305.12l3.658 3.657c.18.18.141.432.002.627l-2.41.545a.5.5 0 0 0-.24.131L10.15 8.142a.5.5 0 0 0-.007.006L9.029 9.283a.5.5 0 0 0-.133.25l-.48 2.36c-.082.053-.165.109-.26.109a.492.492 0 0 1-.353-.149L4.145 8.195c-.18-.18-.141-.432-.002-.627l2.41-.545a.5.5 0 0 0 .238-.13L7.85 5.857a.5.5 0 0 0 .007-.008l1.114-1.138a.5.5 0 0 0 .133-.25l.472-2.323a.619.619 0 0 1 .317-.117Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue