summaryrefslogtreecommitdiff
path: root/addons/web/static/tests/helpers
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/tests/helpers')
-rw-r--r--addons/web/static/tests/helpers/qunit_asserts.js229
-rw-r--r--addons/web/static/tests/helpers/test_utils.js1008
-rw-r--r--addons/web/static/tests/helpers/test_utils_create.js288
-rw-r--r--addons/web/static/tests/helpers/test_utils_dom.js235
-rw-r--r--addons/web/static/tests/helpers/test_utils_fields.js172
-rw-r--r--addons/web/static/tests/helpers/test_utils_form.js74
-rw-r--r--addons/web/static/tests/helpers/test_utils_graph.js28
-rw-r--r--addons/web/static/tests/helpers/test_utils_kanban.js97
-rw-r--r--addons/web/static/tests/helpers/test_utils_mock.js545
-rw-r--r--addons/web/static/tests/helpers/test_utils_modal.js34
-rw-r--r--addons/web/static/tests/helpers/test_utils_pivot.js57
11 files changed, 1829 insertions, 938 deletions
diff --git a/addons/web/static/tests/helpers/qunit_asserts.js b/addons/web/static/tests/helpers/qunit_asserts.js
new file mode 100644
index 00000000000..bce6ef2a777
--- /dev/null
+++ b/addons/web/static/tests/helpers/qunit_asserts.js
@@ -0,0 +1,229 @@
+odoo.define('web.qunit_asserts', function (require) {
+"use strict";
+
+/**
+ * In this file, we extend QUnit by adding some specialized assertions. The goal
+ * of these new assertions is twofold:
+ * - ease of use: they should allow us to simplify some common complex assertions
+ * - safer: these assertions will fail when some preconditions are not met.
+ *
+ * For example, the assert.isVisible assertion will also check that the target
+ * matches exactly one element.
+ */
+
+var Widget = require('web.Widget');
+
+//------------------------------------------------------------------------------
+// Private functions
+//------------------------------------------------------------------------------
+
+/**
+ * Helper function, to check if a given element
+ * - is unique (if it is a jquery node set)
+ * - has (or has not) a css class
+ *
+ * @private
+ * @param {Widget|jQuery|HTMLElement} w
+ * @param {string} className
+ * @param {boolean} shouldHaveClass
+ * @param {string} [msg]
+ */
+function _checkClass(w, className, shouldHaveClass, msg) {
+ var $el = w instanceof Widget ? w.$el :
+ w instanceof HTMLElement ? $(w) :
+ w; // jquery element
+
+ if ($el.length !== 1) {
+ var descr = `${shouldHaveClass ? 'hasClass' : 'doesNotHaveClass'} ${className}`;
+ QUnit.assert.ok(false, `Assertion '${descr}' targets ${$el.length} elements instead of 1`);
+ } else {
+ msg = msg || `target ${$el.selector || ''} should ${shouldHaveClass ? '' : 'not'} have class ${className}`;
+ var hasClass = $el.hasClass(className);
+ var condition = shouldHaveClass ? hasClass : !hasClass;
+ QUnit.assert.ok(condition, msg);
+ }
+}
+
+/**
+ * Helper function, to check if a given element
+ * - is unique (if it is a jquery node set)
+ * - is (or not) visible
+ *
+ * @private
+ * @param {Widget|jQuery|HTMLElement} w
+ * @param {boolean} shouldBeVisible
+ * @param {string} [msg]
+ */
+function _checkVisible(w, shouldBeVisible, msg) {
+ var $el = w instanceof Widget ? w.$el :
+ w instanceof HTMLElement ? $(w) :
+ w; // jquery element
+
+ if ($el.length !== 1) {
+ var descr = `${shouldBeVisible ? 'isVisible' : 'isNotVisible'}`;
+ QUnit.assert.ok(false, `Assertion '${descr}' targets ${$el.length} elements instead of 1`);
+ } else {
+ msg = msg || `target ${$el.selector} should ${shouldBeVisible ? '' : 'not'} be visible`;
+ var isVisible = $el.is(':visible');
+ var condition = shouldBeVisible ? isVisible : !isVisible;
+ QUnit.assert.ok(condition, msg);
+ }
+}
+
+//------------------------------------------------------------------------------
+// Public functions
+//------------------------------------------------------------------------------
+
+/**
+ * Checks that the target element (described by widget/jquery or html element)
+ * contains exactly n matches for the selector.
+ *
+ * Example: assert.containsN(document.body, '.modal', 0)
+ *
+ * @param {Widget|jQuery|HTMLElement} w
+ * @param {string} selector
+ * @param {number} n
+ * @param {string} [msg]
+ */
+function containsN(w, selector, n, msg) {
+ if (typeof n !== 'number') {
+ throw Error("containsN assert should be called with a number as third argument");
+ }
+ var $el = w instanceof Widget ? w.$el :
+ w instanceof HTMLElement ? $(w) :
+ w; // jquery element
+
+ var $matches = $el.find(selector);
+ if (!msg) {
+ msg = `Selector '${selector}' should have exactly ${n} matches`;
+ if ($el.selector) {
+ msg += ` (inside '${$el.selector}')`;
+ }
+ }
+ QUnit.assert.strictEqual($matches.length, n, msg);
+}
+
+/**
+ * Checks that the target element (described by widget/jquery or html element)
+ * contains exactly 1 match for the selector.
+ *
+ * @param {Widget|jQuery|HTMLElement} w
+ * @param {string} selector
+ * @param {string} [msg]
+ */
+function containsOnce(w, selector, msg) {
+ containsN(w, selector, 1, msg);
+}
+
+/**
+ * Checks that the target element (described by widget/jquery or html element)
+ * contains exactly 0 match for the selector.
+ *
+ * @param {Widget|jQuery|HTMLElement} w
+ * @param {string} selector
+ * @param {string} [msg]
+ */
+function containsNone(w, selector, msg) {
+ containsN(w, selector, 0, msg);
+}
+
+/**
+ * Checks that the target element (described by widget/jquery or html element)
+ * - exists
+ * - is unique
+ * - has the given class (specified by className)
+ *
+ * Note that it uses the hasClass jQuery method, so it can be used to check the
+ * presence of more than one class ('some-class other-class'), but it is a
+ * little brittle, because it depends on the order of these classes:
+ *
+ * div.a.b.c: has class 'a b c', but does not have class 'a c b'
+ *
+ * @param {Widget|jQuery|HTMLElement} w
+ * @param {string} className
+ * @param {string} [msg]
+ */
+function hasClass(w, className, msg) {
+ _checkClass(w, className, true, msg);
+}
+
+/**
+ * Checks that the target element (described by widget/jquery or html element)
+ * - exists
+ * - is unique
+ * - does not have the given class (specified by className)
+ *
+ * @param {Widget|jQuery|HTMLElement} w
+ * @param {string} className
+ * @param {string} [msg]
+ */
+function doesNotHaveClass(w, className, msg) {
+ _checkClass(w, className, false, msg);
+}
+
+/**
+ * Checks that the target element (described by widget/jquery or html element)
+ * - exists
+ * - is unique
+ * - has the given attribute with the proper value
+ *
+ * @param {Widget|jQuery|HTMLElement} w
+ * @param {string} attr
+ * @param {string} value
+ * @param {string} [msg]
+ */
+function hasAttrValue(w, attr, value, msg) {
+ var $el = w instanceof Widget ? w.$el :
+ w instanceof HTMLElement ? $(w) :
+ w; // jquery element
+
+ if ($el.length !== 1) {
+ var descr = `hasAttrValue (${attr}: ${value})`;
+ QUnit.assert.ok(false, `Assertion '${descr}' targets ${$el.length} elements instead of 1`);
+ } else {
+ msg = msg || `attribute '${attr}' of target ${$el.selector || ''} should be '${value}'`;
+ QUnit.assert.strictEqual($el.attr(attr), value, msg);
+ }
+}
+
+/**
+ * Checks that the target element (described by widget/jquery or html element)
+ * - exists
+ * - is visible (as far as jQuery can tell: not in display none, ...)
+ *
+ * @param {Widget|jQuery|HTMLElement} w
+ * @param {string} [msg]
+ */
+function isVisible(w, msg) {
+ _checkVisible(w, true, msg);
+}
+
+/**
+ * Checks that the target element (described by widget/jquery or html element)
+ * - exists
+ * - is not visible (as far as jQuery can tell: display none, ...)
+ *
+ * @param {Widget|jQuery|HTMLElement} w
+ * @param {string} [msg]
+ */
+function isNotVisible(w, msg) {
+ _checkVisible(w, false, msg);
+}
+
+//------------------------------------------------------------------------------
+// Exposed API
+//------------------------------------------------------------------------------
+
+QUnit.assert.containsOnce = containsOnce;
+QUnit.assert.containsNone = containsNone;
+QUnit.assert.containsN = containsN;
+
+QUnit.assert.hasClass = hasClass;
+QUnit.assert.doesNotHaveClass = doesNotHaveClass;
+
+QUnit.assert.hasAttrValue = hasAttrValue;
+
+QUnit.assert.isVisible = isVisible;
+QUnit.assert.isNotVisible = isNotVisible;
+
+});
diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js
index ab46c05cfa8..2ecc30b3872 100644
--- a/addons/web/static/tests/helpers/test_utils.js
+++ b/addons/web/static/tests/helpers/test_utils.js
@@ -10,928 +10,18 @@ odoo.define('web.test_utils', function (require) {
* instance of a view, appended in the dom, ready to be tested.
*/
-var ActionManager = require('web.ActionManager');
var ajax = require('web.ajax');
-var basic_fields = require('web.basic_fields');
-var config = require('web.config');
-var ControlPanel = require('web.ControlPanel');
var core = require('web.core');
-var DebugManager = require('web.DebugManager');
-var dom = require('web.dom');
var session = require('web.session');
-var MockServer = require('web.MockServer');
-var Widget = require('web.Widget');
-
-var DebouncedField = basic_fields.DebouncedField;
-
-/**
- * intercepts an event bubbling up the widget hierarchy. The event intercepted
- * must be a "custom event", i.e. an event generated by the method 'trigger_up'.
- *
- * Note that this method really intercepts the event if @propagate is not set.
- * It will not be propagated further, and even the handlers on the target will
- * not fire.
- *
- * @param {Widget} widget the target widget (any Odoo widget)
- * @param {string} eventName description of the event
- * @param {function} fn callback executed when the even is intercepted
- * @param {boolean} [propagate=false]
- */
-function intercept(widget, eventName, fn, propagate) {
- var _trigger_up = widget._trigger_up.bind(widget);
- widget._trigger_up = function (event) {
- if (event.name === eventName) {
- fn(event);
- if (!propagate) { return; }
- }
- _trigger_up(event);
- };
-}
-
-/**
- * logs all event going through the target widget.
- *
- * @param {Widget} widget
- */
-function observe(widget) {
- var _trigger_up = widget._trigger_up.bind(widget);
- widget._trigger_up = function (event) {
- console.log('%c[event] ' + event.name, 'color: blue; font-weight: bold;', event);
- _trigger_up(event);
- };
-}
-
-/**
- * create and return an instance of ActionManager with all rpcs going through a
- * mock method using the data, actions and archs objects as sources.
- *
- * @param {Object} [params]
- * @param {Object} [params.actions] the actions given to the mock server
- * @param {Object} [params.archs] this archs given to the mock server
- * @param {Object} [params.data] the business data given to the mock server
- * @param {boolean} [params.debug]
- * @param {function} [params.mockRPC]
- * @returns {ActionManager}
- */
-var createActionManager = function (params) {
- params = params || {};
- var $target = $('#qunit-fixture');
- if (params.debug) {
- $target = $('body');
- $target.addClass('debug');
- }
-
- var widget = new Widget();
- // when 'document' addon is installed, the sidebar does a 'search_read' on
- // model 'ir_attachment' each time a record is open, so we monkey-patch
- // 'mockRPC' to mute those RPCs, so that the tests can be written uniformly,
- // whether or not 'document' is installed
- var mockRPC = params.mockRPC;
- _.extend(params, {
- mockRPC: function (route, args) {
- if (args.model === 'ir.attachment') {
- return $.when([]);
- }
- if (mockRPC) {
- return mockRPC.apply(this, arguments);
- }
- return this._super.apply(this, arguments);
- },
- });
- addMockEnvironment(widget, _.defaults(params, { debounce: false }));
- widget.prependTo($target);
- widget.$el.addClass('o_web_client');
- if (config.device.isMobile) {
- widget.$el.addClass('o_touch_device');
- }
-
- var userContext = params.context && params.context.user_context || {};
- var actionManager = new ActionManager(widget, userContext);
-
- var originalDestroy = ActionManager.prototype.destroy;
- actionManager.destroy = function () {
- actionManager.destroy = originalDestroy;
- widget.destroy();
- };
- actionManager.appendTo(widget.$el);
-
- return actionManager;
-};
-/**
- * Create and return an instance of DebugManager with all rpcs going through a
- * mock method, assuming that the user has access rights, and is an admin.
- *
- * @param {Object} [params={}]
- */
-var createDebugManager = function (params) {
- params = params || {};
- var mockRPC = params.mockRPC;
- _.extend(params, {
- mockRPC: function (route, args) {
- if (args.method === 'check_access_rights') {
- return $.when(true);
- }
- if (args.method === 'xmlid_to_res_id') {
- return $.when(true);
- }
- if (mockRPC) {
- return mockRPC.apply(this, arguments);
- }
- return this._super.apply(this, arguments);
- },
- session: {
- user_has_group: function (group) {
- if (group === 'base.group_no_one') {
- return $.when(true);
- }
- return this._super.apply(this, arguments);
- },
- },
- });
- var debugManager = new DebugManager();
- addMockEnvironment(debugManager, params);
- return debugManager;
-};
-
-/**
- * performs a fields_view_get, and mocks the postprocessing done by the
- * data_manager to return an equivalent structure.
- *
- * @param {MockServer} server
- * @param {Object} params
- * @param {string} params.model
- * @returns {Object} an object with 3 keys: arch, fields and viewFields
- */
-function fieldsViewGet(server, params) {
- var fieldsView = server.fieldsViewGet(params);
- // mock the structure produced by the DataManager
- fieldsView.viewFields = fieldsView.fields;
- fieldsView.fields = server.fieldsGet(params.model);
- return fieldsView;
-}
-
-/**
- * create a view synchronously. This method uses the createAsyncView method.
- * Most views are synchronous, so the deferred can be resolved immediately and
- * this method will work.
- *
- * Be careful, if for some reason a view is async, this method will crash.
- * @see createAsyncView
- *
- * @param {Object} params will be given to createAsyncView
- * @returns {AbstractView}
- */
-function createView(params) {
- var view;
- createAsyncView(params).then(function (result) {
- view = result;
- });
- if (!view) {
- throw "The view that you are trying to create is async. Please use createAsyncView instead";
- }
- return view;
-}
-
-/**
- * create a view from various parameters. Here, a view means a javascript
- * instance of an AbstractView class, such as a form view, a list view or a
- * kanban view.
- *
- * It returns the instance of the view, properly created, with all rpcs going
- * through a mock method using the data object as source, and already loaded/
- * started.
- *
- * Most views can be tested synchronously (@see createView), but some view have
- * external dependencies (like lazy loaded libraries). In that case, it is
- * necessary to use this method.
- *
- * @param {Object} params
- * @param {string} params.arch the xml (arch) of the view to be instantiated
- * @param {any[]} [params.domain] the initial domain for the view
- * @param {Object} [params.context] the initial context for the view
- * @param {Object} [params.debug=false] if true, the widget will be appended in
- * the DOM. Also, RPCs and uncaught OdooEvent will be logged
- * @param {string[]} [params.groupBy] the initial groupBy for the view
- * @param {integer} [params.fieldDebounce=0] the debounce value to use for the
- * duration of the test.
- * @param {AbstractView} params.View the class that will be instantiated
- * @param {string} params.model a model name, will be given to the view
- * @param {Object} params.intercepts an object with event names as key, and
- * callback as value. Each key,value will be used to intercept the event.
- * Note that this is particularly useful if you want to intercept events going
- * up in the init process of the view, because there are no other way to do it
- * after this method returns
- * @returns {Deferred<AbstractView>} resolves with the instance of the view
- */
-function createAsyncView(params) {
- var $target = $('#qunit-fixture');
- var widget = new Widget();
- if (params.debug) {
- $target = $('body');
- $target.addClass('debug');
- }
-
- // add mock environment: mock server, session, fieldviewget, ...
- var mockServer = addMockEnvironment(widget, params);
- var viewInfo = fieldsViewGet(mockServer, params);
- // create the view
- var viewOptions = {
- modelName: params.model || 'foo',
- ids: 'res_id' in params ? [params.res_id] : undefined,
- currentId: 'res_id' in params ? params.res_id : undefined,
- domain: params.domain || [],
- context: params.context || {},
- groupBy: params.groupBy || [],
- };
- if (params.hasSelectors) {
- viewOptions.hasSelectors = params.hasSelectors;
- }
-
- _.extend(viewOptions, params.viewOptions);
-
- var view = new params.View(viewInfo, viewOptions);
-
- // reproduce the DOM environment of views
- var $web_client = $('<div>').addClass('o_web_client').prependTo($target);
- var controlPanel = new ControlPanel(widget);
- controlPanel.appendTo($web_client);
- var $content = $('<div>').addClass('o_content').appendTo($web_client);
-
- if (params.interceptsPropagate) {
- _.each(params.interceptsPropagate, function (cb, name) {
- intercept(widget, name, cb, true);
- });
- }
-
- return view.getController(widget).then(function (view) {
- // override the view's 'destroy' so that it calls 'destroy' on the widget
- // instead, as the widget is the parent of the view and the mockServer.
- view.__destroy = view.destroy;
- view.destroy = function () {
- // remove the override to properly destroy the view and its children
- // when it will be called the second time (by its parent)
- delete view.destroy;
- widget.destroy();
- };
-
- // link the view to the control panel
- view.set_cp_bus(controlPanel.get_bus());
-
- // render the view in a fragment as they must be able to render correctly
- // without being in the DOM
- var fragment = document.createDocumentFragment();
- return view.appendTo(fragment).then(function () {
- dom.append($content, fragment, {
- callbacks: [{ widget: view }],
- in_DOM: true,
- });
- view.$el.on('click', 'a', function (ev) {
- ev.preventDefault();
- });
-
- return view;
- });
- });
-}
-
-/**
- * Patch window.Date so that the time starts its flow from the provided Date.
- *
- * Usage:
- *
- * ```
- * var unpatchDate = testUtils.patchDate(2018, 0, 10, 17, 59, 30)
- * new window.Date(); // "Wed Jan 10 2018 17:59:30 GMT+0100 (Central European Standard Time)"
- * ... // 5 hours delay
- * new window.Date(); // "Wed Jan 10 2018 22:59:30 GMT+0100 (Central European Standard Time)"
- * ...
- * unpatchDate();
- * new window.Date(); // actual current date time
- * ```
- *
- * @param {integer} year
- * @param {integer} month index of the month, starting from zero.
- * @param {integer} day the day of the month.
- * @param {integer} hours the digits for hours (24h)
- * @param {integer} minutes
- * @param {integer} seconds
- * @returns {function} a callback to unpatch window.Date.
- */
-function patchDate(year, month, day, hours, minutes, seconds) {
- var RealDate = window.Date;
- var actualDate = new RealDate();
- var fakeDate = new RealDate(year, month, day, hours, minutes, seconds);
- var timeInterval = actualDate.getTime() - (fakeDate.getTime());
-
- Date = (function (NativeDate) {
- function Date(Y, M, D, h, m, s, ms) {
- var length = arguments.length;
- if (arguments.length > 0) {
- var date = length == 1 && String(Y) === Y ? // isString(Y)
- // We explicitly pass it through parse:
- new NativeDate(Date.parse(Y)) :
- // We have to manually make calls depending on argument
- // length here
- length >= 7 ? new NativeDate(Y, M, D, h, m, s, ms) :
- length >= 6 ? new NativeDate(Y, M, D, h, m, s) :
- length >= 5 ? new NativeDate(Y, M, D, h, m) :
- length >= 4 ? new NativeDate(Y, M, D, h) :
- length >= 3 ? new NativeDate(Y, M, D) :
- length >= 2 ? new NativeDate(Y, M) :
- length >= 1 ? new NativeDate(Y) :
- new NativeDate();
- // Prevent mixups with unfixed Date object
- date.constructor = Date;
- return date;
- } else {
- var date = new NativeDate();
- var time = date.getTime();
- time -= timeInterval;
- date.setTime(time);
- return date;
- }
- }
-
- // Copy any custom methods a 3rd party library may have added
- for (var key in NativeDate) {
- Date[key] = NativeDate[key];
- }
-
- // Copy "native" methods explicitly; they may be non-enumerable
- Date.now = NativeDate.now;
- Date.UTC = NativeDate.UTC;
- Date.prototype = NativeDate.prototype;
- Date.prototype.constructor = Date;
-
- // Upgrade Date.parse to handle simplified ISO 8601 strings
- Date.parse = NativeDate.parse;
- return Date;
- })(Date);
-
- return function () { window.Date = RealDate; };
-}
-
-/**
- * Add a mock environment to a widget. This helper function can simulate
- * various kind of side effects, such as mocking RPCs, changing the session,
- * or the translation settings.
- *
- * The simulated environment lasts for the lifecycle of the widget, meaning it
- * disappears when the widget is destroyed. It is particularly relevant for the
- * session mocks, because the previous session is restored during the destroy
- * call. So, it means that you have to be careful and make sure that it is
- * properly destroyed before another test is run, otherwise you risk having
- * interferences between tests.
- *
- * @param {Widget} widget
- * @param {Object} params
- * @param {Object} [params.archs] a map of string [model,view_id,view_type] to
- * a arch object. It is used to mock answers to 'load_views' custom events.
- * This is useful when the widget instantiate a formview dialog that needs
- * to load a particular arch.
- * @param {string} [params.currentDate] a string representation of the current
- * date. It is given to the mock server.
- * @param {Object} params.data the data given to the created mock server. It is
- * used to generate mock answers for every kind of routes supported by odoo
- * @param {number} [params.debug] if set to true, logs RPCs and uncaught Odoo
- * events.
- * @param {function} [params.mockRPC] a function that will be used to override
- * the _performRpc method from the mock server. It is really useful to add
- * some custom rpc mocks, or to check some assertions.
- * @param {Object} [params.session] if it is given, it will be used as answer
- * for all calls to this.getSession() by the widget, of its children. Also,
- * it will be used to extend the current, real session. This side effect is
- * undone when the widget is destroyed.
- * @param {Object} [params.translateParameters] if given, it will be used to
- * extend the core._t.database.parameters object. After the widget
- * destruction, the original parameters will be restored.
- * @param {Object} [params.intercepts] an object with event names as key, and
- * callback as value. Each key,value will be used to intercept the event.
- * Note that this is particularly useful if you want to intercept events going
- * up in the init process of the view, because there are no other way to do it
- * after this method returns. Some events ('call_service', "load_views",
- * "get_session", "load_filters") have a special treatment beforehand.
- * @param {Object} [params.services={}] list of services to load in
- * addition to the ajax service. For instance, if a test needs the local
- * storage service in order to work, it can provide a mock version of it.
- * @param {boolean} [debounce=true] set to false to completely remove the
- * debouncing, forcing the handler to be called directly (not on the next
- * execution stack, like it does with delay=0).
- * @param {boolean} [throttle=false] set to true to keep the throttling, which
- * is completely removed by default.
- *
- * @returns {MockServer} the instance of the mock server, created by this
- * function. It is necessary for createAsyncView so that method can call some
- * other methods on it.
- */
-function addMockEnvironment(widget, params) {
- var Server = MockServer;
- params.services = params.services || {};
- if (params.mockRPC) {
- Server = MockServer.extend({ _performRpc: params.mockRPC });
- }
- if (params.debug) {
- observe(widget);
- var separator = window.location.href.indexOf('?') !== -1 ? "&" : "?";
- var url = window.location.href + separator + 'testId=' + QUnit.config.current.testId;
- console.log('%c[debug] debug mode activated', 'color: blue; font-weight: bold;', url);
- }
-
- var mockServer = new Server(params.data, {
- actions: params.actions,
- archs: params.archs,
- currentDate: params.currentDate,
- debug: params.debug,
- widget: widget,
- });
-
- // make sure images do not trigger a GET on the server
- $('body').on('DOMNodeInserted.removeSRC', function (event) {
- removeSrcAttribute(event.target, widget);
- });
-
- // make sure the debounce value for input fields is set to 0
- var initialDebounceValue = DebouncedField.prototype.DEBOUNCE;
- DebouncedField.prototype.DEBOUNCE = params.fieldDebounce || 0;
- var initialSession, initialConfig, initialParameters, initialDebounce, initialThrottle;
- initialSession = _.extend({}, session);
- session.getTZOffset = function () {
- return 0; // by default, but may be overriden in specific tests
- };
- if ('session' in params) {
- _.extend(session, params.session);
- }
- if ('config' in params) {
- initialConfig = _.clone(config);
- initialConfig.device = _.clone(config.device);
- if ('device' in params.config) {
- _.extend(config.device, params.config.device);
- }
- if ('debug' in params.config) {
- config.debug = params.config.debug;
- }
- }
- if ('translateParameters' in params) {
- initialParameters = _.extend({}, core._t.database.parameters);
- _.extend(core._t.database.parameters, params.translateParameters);
- }
- if (params.debounce === false) {
- initialDebounce = _.debounce;
- _.debounce = function (func) {
- return func;
- };
- }
- if (!('throttle' in params) || !params.throttle) {
- initialThrottle = _.throttle;
- _.throttle = function (func) {
- return func;
- };
- }
-
- var widgetDestroy = widget.destroy;
- widget.destroy = function () {
- // clear the caches (e.g. data_manager, ModelFieldSelector) when the
- // widget is destroyed, at the end of each test to avoid collisions
- core.bus.trigger('clear_cache');
-
- DebouncedField.prototype.DEBOUNCE = initialDebounceValue;
- if (params.debounce === false) {
- _.debounce = initialDebounce;
- }
- if (!('throttle' in params) || !params.throttle) {
- _.throttle = initialThrottle;
- }
-
- var key;
- if ('session' in params) {
- for (key in session) {
- delete session[key];
- }
- }
- _.extend(session, initialSession);
- if ('config' in params) {
- for (key in config) {
- delete config[key];
- }
- _.extend(config, initialConfig);
- }
- if ('translateParameters' in params) {
- for (key in core._t.database.parameters) {
- delete core._t.database.parameters[key];
- }
- _.extend(core._t.database.parameters, initialParameters);
- }
-
- $('body').off('DOMNodeInserted.removeSRC');
- $('.blockUI').remove();
-
- widgetDestroy.call(this);
- };
-
- // Dispatch service calls
- // Note: some services could call other services at init,
- // Which is why we have to init services after that
- var services = {};
- intercept(widget, 'call_service', function (ev) {
- var args, result;
- if (services[ev.data.service]) {
- var service = services[ev.data.service];
- args = (ev.data.args || []);
- result = service[ev.data.method].apply(service, args);
- } else if (ev.data.service === 'ajax') {
- // use ajax service that is mocked by the server
- var route = ev.data.args[0];
- args = ev.data.args[1];
- result = mockServer.performRpc(route, args);
- }
- ev.data.callback(result);
- });
-
- intercept(widget, 'load_action', function (event) {
- mockServer.performRpc('/web/action/load', {
- kwargs: {
- action_id: event.data.actionID,
- additional_context: event.data.context,
- },
- }).then(function (action) {
- event.data.on_success(action);
- });
- });
-
- intercept(widget, "load_views", function (event) {
- mockServer.performRpc('/web/dataset/call_kw/' + event.data.modelName, {
- args: [],
- kwargs: {
- context: event.data.context,
- options: event.data.options,
- views: event.data.views,
- },
- method: 'load_views',
- model: event.data.modelName,
- }).then(function (views) {
- views = _.mapObject(views, function (viewParams) {
- return fieldsViewGet(mockServer, viewParams);
- });
- event.data.on_success(views);
- });
- });
-
- intercept(widget, "get_session", function (event) {
- event.data.callback(session);
- });
-
- intercept(widget, "load_filters", function (event) {
- if (params.debug) {
- console.log('[mock] load_filters', event.data);
- }
- event.data.on_success([]);
- });
-
- // make sure all Odoo events bubbling up are intercepted
- if ('intercepts' in params) {
- _.each(params.intercepts, function (cb, name) {
- intercept(widget, name, cb);
- });
- }
-
- // Deploy services
- var done = false;
- var servicesToDeploy = _.clone(params.services);
- if (!servicesToDeploy.ajax) {
- services.ajax = null; // use mocked ajax from mocked server
- }
- while (!done) {
- var serviceName = _.findKey(servicesToDeploy, function (Service) {
- return !_.some(Service.prototype.dependencies, function (depName) {
- return !_.has(services, depName);
- });
- });
- if (serviceName) {
- var Service = servicesToDeploy[serviceName];
- var service = services[serviceName] = new Service(widget);
- delete servicesToDeploy[serviceName];
-
- intercept(service, "get_session", function (event) {
- event.data.callback(session);
- });
-
- service.start();
- } else {
- var serviceNames = _.keys(servicesToDeploy);
- if (serviceNames.length) {
- console.warn("Non loaded services:", serviceNames);
- }
- done = true;
- }
- }
-
- return mockServer;
-}
-
-/**
- * create a model from given parameters.
- *
- * @param {Object} params This object will be given to addMockEnvironment, so
- * any parameters from that method applies
- * @param {Class} params.Model the model class to use
- * @returns {Model}
- */
-function createModel(params) {
- var widget = new Widget();
-
- var model = new params.Model(widget);
-
- addMockEnvironment(widget, params);
-
- // override the model's 'destroy' so that it calls 'destroy' on the widget
- // instead, as the widget is the parent of the model and the mockServer.
- model.destroy = function () {
- // remove the override to properly destroy the model when it will be
- // called the second time (by its parent)
- delete model.destroy;
- widget.destroy();
- };
-
- return model;
-}
-
-/**
- * create a widget parent from given parameters.
- *
- * @param {Object} params This object will be given to addMockEnvironment, so
- * any parameters from that method applies
- * @returns {Widget}
- */
-function createParent(params) {
- var widget = new Widget();
- addMockEnvironment(widget, params);
- return widget;
-}
-
-/**
- * simulate a drag and drop operation between 2 jquery nodes: $el and $to.
- * This is a crude simulation, with only the mousedown, mousemove and mouseup
- * events, but it is enough to help test drag and drop operations with jqueryUI
- * sortable.
- *
- * @param {jqueryElement} $el
- * @param {jqueryElement} $to
- * @param {Object} [options]
- * @param {string|Object} [options.position='center'] target position:
- * can either be one of {'top', 'bottom', 'left', 'right'} or
- * an object with two attributes (top and left))
- * @param {boolean} [options.disableDrop=false] whether to trigger the drop action
- * @param {boolean} [options.continueMove=false] whether to trigger the
- * mousedown action (will only work after another call of this function with
- * without this option)
- */
-function dragAndDrop($el, $to, options) {
- var position = (options && options.position) || 'center';
- var elementCenter = $el.offset();
- var toOffset = $to.offset();
-
- if (_.isObject(position)) {
- toOffset.top += position.top;
- toOffset.left += position.left;
- } else {
- toOffset.top += $to.outerHeight() / 2;
- toOffset.left += $to.outerWidth() / 2;
- var vertical_offset = (toOffset.top < elementCenter.top) ? -1 : 1;
- if (position === 'top') {
- toOffset.top -= $to.outerHeight() / 2 + vertical_offset;
- } else if (position === 'bottom') {
- toOffset.top += $to.outerHeight() / 2 - vertical_offset;
- } else if (position === 'left') {
- toOffset.left -= $to.outerWidth() / 2;
- } else if (position === 'right') {
- toOffset.left += $to.outerWidth() / 2;
- }
- }
-
- if ($to[0].ownerDocument !== document) {
- // we are in an iframe
- var bound = $('iframe')[0].getBoundingClientRect();
- toOffset.left += bound.left;
- toOffset.top += bound.top;
- }
- $el.trigger($.Event("mouseenter"));
- if (!(options && options.continueMove)) {
- elementCenter.left += $el.outerWidth() / 2;
- elementCenter.top += $el.outerHeight() / 2;
-
- $el.trigger($.Event("mousedown", {
- which: 1,
- pageX: elementCenter.left,
- pageY: elementCenter.top
- }));
- }
-
- $el.trigger($.Event("mousemove", {
- which: 1,
- pageX: toOffset.left,
- pageY: toOffset.top
- }));
-
- if (!(options && options.disableDrop)) {
- $el.trigger($.Event("mouseup", {
- which: 1,
- pageX: toOffset.left,
- pageY: toOffset.top
- }));
- } else {
- // It's impossible to drag another element when one is already
- // being dragged. So it's necessary to finish the drop when the test is
- // over otherwise it's impossible for the next tests to drag and
- // drop elements.
- $el.on("remove", function () {
- $el.trigger($.Event("mouseup"));
- });
- }
-}
-
-/**
- * simulate a mouse event with a custom event who add the item position. This is
- * sometimes necessary because the basic way to trigger an event (such as
- * $el.trigger('mousemove')); ) is too crude for some uses.
- *
- * @param {jqueryElement} $el
- * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove'
- */
-function triggerMouseEvent($el, type) {
- var pos = $el.offset();
- var e = new $.Event(type);
- e.pageX = e.layerX = e.screenX = pos.left;
- e.pageY = e.layerY = e.screenY = pos.top;
- e.which = 1;
- $el.trigger(e);
-}
-
-/**
- * simulate a mouse event with a custom event on a position x and y. This is
- * sometimes necessary because the basic way to trigger an event (such as
- * $el.trigger('mousemove')); ) is too crude for some uses.
- *
- * @param {integer} x
- * @param {integer} y
- * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove'
- */
-function triggerPositionalMouseEvent(x, y, type) {
- var ev = document.createEvent("MouseEvent");
- var el = document.elementFromPoint(x, y);
- ev.initMouseEvent(
- type,
- true /* bubble */,
- true /* cancelable */,
- window, null,
- x, y, x, y, /* coordinates */
- false, false, false, false, /* modifier keys */
- 0 /*left button*/, null
- );
- el.dispatchEvent(ev);
- return el;
-}
-
-/**
- * simulate a keypress event for a given character
- * @param {string} the character
- */
-function triggerKeypressEvent(char) {
- var keycode;
- if (char === "Enter") {
- keycode = $.ui.keyCode.ENTER;
- } else {
- keycode = char.charCodeAt(0);
- }
- return $('body').trigger($.Event('keypress', { which: keycode, keyCode: keycode }));
-}
-
-/**
- * Removes the src attribute on images and iframes to prevent not found errors,
- * and optionally triggers an rpc with the src url as route on a widget.
- * This method is critical and must be fastest (=> no jQuery, no underscore)
- *
- * @param {DOM Node} el
- * @param {[Widget]} widget the widget on which the rpc should be performed
- */
-function removeSrcAttribute(el, widget) {
- var nodes;
- if (el.nodeName === 'IMG' || el.nodeName === 'IFRAME') {
- nodes = [el];
- } else {
- nodes = Array.prototype.slice.call(el.getElementsByTagName('img'))
- .concat(Array.prototype.slice.call(el.getElementsByTagName('iframe')));
- }
- var node;
- while (node = nodes.pop()) {
- var src = node.attributes.src && node.attributes.src.value;
- if (src && src !== 'about:blank') {
- var $el = $(node);
- node.setAttribute('data-src', src);
- if (node.nodeName === 'IMG') {
- node.attributes.removeNamedItem('src');
- } else {
- node.setAttribute('src', 'about:blank');
- }
- if (widget) {
- widget._rpc({ route: src });
- }
- }
- }
-}
-
-var patches = {};
-/**
- * Patches a given Class or Object with the given properties.
- *
- * @param {Class|Object} target
- * @param {Object} props
- */
-function patch(target, props) {
- var patchID = _.uniqueId('patch_');
- target.__patchID = patchID;
- patches[patchID] = {
- target: target,
- otherPatchedProps: [],
- ownPatchedProps: [],
- };
- if (target.prototype) {
- _.each(props, function (value, key) {
- if (target.prototype.hasOwnProperty(key)) {
- patches[patchID].ownPatchedProps.push({
- key: key,
- initialValue: target.prototype[key],
- });
- } else {
- patches[patchID].otherPatchedProps.push(key);
- }
- });
- target.include(props);
- } else {
- _.each(props, function (value, key) {
- if (key in target) {
- var oldValue = target[key];
- patches[patchID].ownPatchedProps.push({
- key: key,
- initialValue: oldValue,
- });
- if (typeof value === 'function') {
- target[key] = function () {
- var oldSuper = this._super;
- this._super = oldValue;
- var result = value.apply(this, arguments);
- if (oldSuper === undefined) {
- delete this._super;
- } else {
- this._super = oldSuper;
- }
- return result;
- };
- } else {
- target[key] = value;
- }
- } else {
- patches[patchID].otherPatchedProps.push(key);
- target[key] = value;
- }
- });
- }
-}
-
-/**
- * Unpatches a given Class or Object.
- *
- * @param {Class|Object} target
- */
-function unpatch(target) {
- var patchID = target.__patchID;
- var patch = patches[patchID];
- if (target.prototype) {
- _.each(patch.ownPatchedProps, function (p) {
- target.prototype[p.key] = p.initialValue;
- });
- _.each(patch.otherPatchedProps, function (key) {
- delete target.prototype[key];
- });
- } else {
- _.each(patch.ownPatchedProps, function (p) {
- target[p.key] = p.initialValue;
- });
- _.each(patch.otherPatchedProps, function (key) {
- delete target[key];
- });
- }
- delete patches[patchID];
- delete target.__patchID;
-}
-
-/**
- * Opens the datepicker of a given element.
- *
- * @param {jQuery} $datepickerEl element to which a datepicker is attached
- */
-function openDatepicker($datepickerEl) {
- $datepickerEl.find('.o_datepicker_input').trigger('focus.datetimepicker');
-}
+var testUtilsCreate = require('web.test_utils_create');
+var testUtilsDom = require('web.test_utils_dom');
+var testUtilsFields = require('web.test_utils_fields');
+var testUtilsForm = require('web.test_utils_form');
+var testUtilsGraph = require('web.test_utils_graph');
+var testUtilsKanban = require('web.test_utils_kanban');
+var testUtilsMock = require('web.test_utils_mock');
+var testUtilsModal = require('web.test_utils_modal');
+var testUtilsPivot = require('web.test_utils_pivot');
// Loading static files cannot be properly simulated when their real content is
// really needed. This is the case for static XML files so we load them here,
@@ -948,25 +38,67 @@ return $.when(
QUnit.start();
}, 0);
return {
- addMockEnvironment: addMockEnvironment,
- createActionManager: createActionManager,
- createDebugManager: createDebugManager,
- createAsyncView: createAsyncView,
- createModel: createModel,
- createParent: createParent,
- createView: createView,
- dragAndDrop: dragAndDrop,
- fieldsViewGet: fieldsViewGet,
- intercept: intercept,
- observe: observe,
- openDatepicker: openDatepicker,
- patch: patch,
- patchDate: patchDate,
- removeSrcAttribute: removeSrcAttribute,
- triggerKeypressEvent: triggerKeypressEvent,
- triggerMouseEvent: triggerMouseEvent,
- triggerPositionalMouseEvent: triggerPositionalMouseEvent,
- unpatch: unpatch,
+ mock: {
+ addMockEnvironment: testUtilsMock.addMockEnvironment,
+ intercept: testUtilsMock.intercept,
+ patch: testUtilsMock.patch,
+ patchDate: testUtilsMock.patchDate,
+ unpatch: testUtilsMock.unpatch,
+ fieldsViewGet: testUtilsMock.fieldsViewGet,
+ },
+ dom: {
+ triggerKeypressEvent: testUtilsDom.triggerKeypressEvent,
+ triggerMouseEvent: testUtilsDom.triggerMouseEvent,
+ triggerPositionalMouseEvent: testUtilsDom.triggerPositionalMouseEvent,
+ dragAndDrop: testUtilsDom.dragAndDrop,
+ openDatepicker: testUtilsDom.openDatepicker,
+ click: testUtilsDom.click,
+ clickFirst: testUtilsDom.clickFirst,
+ clickLast: testUtilsDom.clickLast,
+ },
+ form: {
+ clickEdit: testUtilsForm.clickEdit,
+ clickSave: testUtilsForm.clickSave,
+ clickCreate: testUtilsForm.clickCreate,
+ clickDiscard: testUtilsForm.clickDiscard,
+ reload: testUtilsForm.reload,
+ },
+ graph: {
+ reload: testUtilsGraph.reload,
+ },
+ kanban: {
+ reload: testUtilsKanban.reload,
+ clickCreate: testUtilsKanban.clickCreate,
+ quickCreate: testUtilsKanban.quickCreate,
+ toggleGroupSettings: testUtilsKanban.toggleGroupSettings,
+ toggleRecordDropdown: testUtilsKanban.toggleRecordDropdown,
+ },
+ modal: {
+ clickButton: testUtilsModal.clickButton,
+ },
+ pivot: {
+ clickMeasure: testUtilsPivot.clickMeasure,
+ toggleMeasuresDropdown: testUtilsPivot.toggleMeasuresDropdown,
+ reload: testUtilsPivot.reload,
+ },
+ fields: {
+ many2one: {
+ clickOpenDropdown: testUtilsFields.clickOpenM2ODropdown,
+ clickHighlightedItem: testUtilsFields.clickM2OHighlightedItem,
+ clickItem: testUtilsFields.clickM2OItem,
+ searchAndClickItem: testUtilsFields.searchAndClickM2OItem,
+ },
+ editInput: testUtilsFields.editInput,
+ editSelect: testUtilsFields.editSelect,
+ editAndTrigger: testUtilsFields.editAndTrigger,
+ },
+
+ createActionManager: testUtilsCreate.createActionManager,
+ createDebugManager: testUtilsCreate.createDebugManager,
+ createAsyncView: testUtilsCreate.createAsyncView,
+ createView: testUtilsCreate.createView,
+ createModel: testUtilsCreate.createModel,
+ createParent: testUtilsCreate.createParent,
};
});
diff --git a/addons/web/static/tests/helpers/test_utils_create.js b/addons/web/static/tests/helpers/test_utils_create.js
new file mode 100644
index 00000000000..d4d4e2a6b55
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_create.js
@@ -0,0 +1,288 @@
+odoo.define('web.test_utils_create', function (require) {
+"use strict";
+
+/**
+ * Create Test Utils
+ *
+ * This module defines various utility functions to help creating mock widgets
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+var ActionManager = require('web.ActionManager');
+var config = require('web.config');
+var ControlPanel = require('web.ControlPanel');
+var dom = require('web.dom');
+var DebugManager = require('web.DebugManager');
+var testUtilsMock = require('web.test_utils_mock');
+var Widget = require('web.Widget');
+
+/**
+ * create and return an instance of ActionManager with all rpcs going through a
+ * mock method using the data, actions and archs objects as sources.
+ *
+ * @param {Object} [params]
+ * @param {Object} [params.actions] the actions given to the mock server
+ * @param {Object} [params.archs] this archs given to the mock server
+ * @param {Object} [params.data] the business data given to the mock server
+ * @param {boolean} [params.debug]
+ * @param {function} [params.mockRPC]
+ * @returns {ActionManager}
+ */
+function createActionManager (params) {
+ params = params || {};
+ var $target = $('#qunit-fixture');
+ if (params.debug) {
+ $target = $('body');
+ $target.addClass('debug');
+ }
+
+ var widget = new Widget();
+ // when 'document' addon is installed, the sidebar does a 'search_read' on
+ // model 'ir_attachment' each time a record is open, so we monkey-patch
+ // 'mockRPC' to mute those RPCs, so that the tests can be written uniformly,
+ // whether or not 'document' is installed
+ var mockRPC = params.mockRPC;
+ _.extend(params, {
+ mockRPC: function (route, args) {
+ if (args.model === 'ir.attachment') {
+ return $.when([]);
+ }
+ if (mockRPC) {
+ return mockRPC.apply(this, arguments);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ testUtilsMock.addMockEnvironment(widget, _.defaults(params, { debounce: false }));
+ widget.prependTo($target);
+ widget.$el.addClass('o_web_client');
+ if (config.device.isMobile) {
+ widget.$el.addClass('o_touch_device');
+ }
+
+ var userContext = params.context && params.context.user_context || {};
+ var actionManager = new ActionManager(widget, userContext);
+
+ var originalDestroy = ActionManager.prototype.destroy;
+ actionManager.destroy = function () {
+ actionManager.destroy = originalDestroy;
+ widget.destroy();
+ };
+ actionManager.appendTo(widget.$el);
+
+ return actionManager;
+}
+
+/**
+ * create a view from various parameters. Here, a view means a javascript
+ * instance of an AbstractView class, such as a form view, a list view or a
+ * kanban view.
+ *
+ * It returns the instance of the view, properly created, with all rpcs going
+ * through a mock method using the data object as source, and already loaded/
+ * started.
+ *
+ * Most views can be tested synchronously (@see createView), but some view have
+ * external dependencies (like lazy loaded libraries). In that case, it is
+ * necessary to use this method.
+ *
+ * @param {Object} params
+ * @param {string} params.arch the xml (arch) of the view to be instantiated
+ * @param {any[]} [params.domain] the initial domain for the view
+ * @param {Object} [params.context] the initial context for the view
+ * @param {Object} [params.debug=false] if true, the widget will be appended in
+ * the DOM. Also, RPCs and uncaught OdooEvent will be logged
+ * @param {string[]} [params.groupBy] the initial groupBy for the view
+ * @param {integer} [params.fieldDebounce=0] the debounce value to use for the
+ * duration of the test.
+ * @param {AbstractView} params.View the class that will be instantiated
+ * @param {string} params.model a model name, will be given to the view
+ * @param {Object} params.intercepts an object with event names as key, and
+ * callback as value. Each key,value will be used to intercept the event.
+ * Note that this is particularly useful if you want to intercept events going
+ * up in the init process of the view, because there are no other way to do it
+ * after this method returns
+ * @returns {Deferred<AbstractView>} resolves with the instance of the view
+ */
+function createAsyncView(params) {
+ var $target = $('#qunit-fixture');
+ var widget = new Widget();
+ if (params.debug) {
+ $target = $('body');
+ $target.addClass('debug');
+ }
+
+ // add mock environment: mock server, session, fieldviewget, ...
+ var mockServer = testUtilsMock.addMockEnvironment(widget, params);
+ var viewInfo = testUtilsMock.fieldsViewGet(mockServer, params);
+ // create the view
+ var viewOptions = {
+ modelName: params.model || 'foo',
+ ids: 'res_id' in params ? [params.res_id] : undefined,
+ currentId: 'res_id' in params ? params.res_id : undefined,
+ domain: params.domain || [],
+ context: params.context || {},
+ groupBy: params.groupBy || [],
+ };
+ if (params.hasSelectors) {
+ viewOptions.hasSelectors = params.hasSelectors;
+ }
+
+ _.extend(viewOptions, params.viewOptions);
+
+ var view = new params.View(viewInfo, viewOptions);
+
+ // reproduce the DOM environment of views
+ var $web_client = $('<div>').addClass('o_web_client').prependTo($target);
+ var controlPanel = new ControlPanel(widget);
+ controlPanel.appendTo($web_client);
+ var $content = $('<div>').addClass('o_content').appendTo($web_client);
+
+ if (params.interceptsPropagate) {
+ _.each(params.interceptsPropagate, function (cb, name) {
+ testUtilsMock.intercept(widget, name, cb, true);
+ });
+ }
+
+ return view.getController(widget).then(function (view) {
+ // override the view's 'destroy' so that it calls 'destroy' on the widget
+ // instead, as the widget is the parent of the view and the mockServer.
+ view.__destroy = view.destroy;
+ view.destroy = function () {
+ // remove the override to properly destroy the view and its children
+ // when it will be called the second time (by its parent)
+ delete view.destroy;
+ widget.destroy();
+ };
+
+ // link the view to the control panel
+ view.set_cp_bus(controlPanel.get_bus());
+
+ // render the view in a fragment as they must be able to render correctly
+ // without being in the DOM
+ var fragment = document.createDocumentFragment();
+ return view.appendTo(fragment).then(function () {
+ dom.append($content, fragment, {
+ callbacks: [{ widget: view }],
+ in_DOM: true,
+ });
+ view.$el.on('click', 'a', function (ev) {
+ ev.preventDefault();
+ });
+
+ return view;
+ });
+ });
+}
+
+/**
+ * Create and return an instance of DebugManager with all rpcs going through a
+ * mock method, assuming that the user has access rights, and is an admin.
+ *
+ * @param {Object} [params={}]
+ */
+function createDebugManager (params) {
+ params = params || {};
+ var mockRPC = params.mockRPC;
+ _.extend(params, {
+ mockRPC: function (route, args) {
+ if (args.method === 'check_access_rights') {
+ return $.when(true);
+ }
+ if (args.method === 'xmlid_to_res_id') {
+ return $.when(true);
+ }
+ if (mockRPC) {
+ return mockRPC.apply(this, arguments);
+ }
+ return this._super.apply(this, arguments);
+ },
+ session: {
+ user_has_group: function (group) {
+ if (group === 'base.group_no_one') {
+ return $.when(true);
+ }
+ return this._super.apply(this, arguments);
+ },
+ },
+ });
+ var debugManager = new DebugManager();
+ testUtilsMock.addMockEnvironment(debugManager, params);
+ return debugManager;
+}
+
+/**
+ * create a model from given parameters.
+ *
+ * @param {Object} params This object will be given to addMockEnvironment, so
+ * any parameters from that method applies
+ * @param {Class} params.Model the model class to use
+ * @returns {Model}
+ */
+function createModel(params) {
+ var widget = new Widget();
+
+ var model = new params.Model(widget);
+
+ testUtilsMock.addMockEnvironment(widget, params);
+
+ // override the model's 'destroy' so that it calls 'destroy' on the widget
+ // instead, as the widget is the parent of the model and the mockServer.
+ model.destroy = function () {
+ // remove the override to properly destroy the model when it will be
+ // called the second time (by its parent)
+ delete model.destroy;
+ widget.destroy();
+ };
+
+ return model;
+}
+
+/**
+ * create a widget parent from given parameters.
+ *
+ * @param {Object} params This object will be given to addMockEnvironment, so
+ * any parameters from that method applies
+ * @returns {Widget}
+ */
+function createParent(params) {
+ var widget = new Widget();
+ testUtilsMock.addMockEnvironment(widget, params);
+ return widget;
+}
+
+/**
+ * create a view synchronously. This method uses the createAsyncView method.
+ * Most views are synchronous, so the deferred can be resolved immediately and
+ * this method will work.
+ *
+ * Be careful, if for some reason a view is async, this method will crash.
+ * @see createAsyncView
+ *
+ * @param {Object} params will be given to createAsyncView
+ * @returns {AbstractView}
+ */
+function createView(params) {
+ var view;
+ createAsyncView(params).then(function (result) {
+ view = result;
+ });
+ if (!view) {
+ throw "The view that you are trying to create is async. Please use createAsyncView instead";
+ }
+ return view;
+}
+
+
+return {
+ createActionManager: createActionManager,
+ createAsyncView: createAsyncView,
+ createDebugManager: createDebugManager,
+ createModel: createModel,
+ createParent: createParent,
+ createView: createView,
+};
+
+});
diff --git a/addons/web/static/tests/helpers/test_utils_dom.js b/addons/web/static/tests/helpers/test_utils_dom.js
new file mode 100644
index 00000000000..b89f5a8eb5f
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_dom.js
@@ -0,0 +1,235 @@
+odoo.define('web.test_utils_dom', function () {
+"use strict";
+
+/**
+ * DOM Test Utils
+ *
+ * This module defines various utility functions to help simulate DOM events.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+
+/**
+ * simulate a drag and drop operation between 2 jquery nodes: $el and $to.
+ * This is a crude simulation, with only the mousedown, mousemove and mouseup
+ * events, but it is enough to help test drag and drop operations with jqueryUI
+ * sortable.
+ *
+ * @param {jqueryElement} $el
+ * @param {jqueryElement} $to
+ * @param {Object} [options]
+ * @param {string|Object} [options.position='center'] target position:
+ * can either be one of {'top', 'bottom', 'left', 'right'} or
+ * an object with two attributes (top and left))
+ * @param {boolean} [options.disableDrop=false] whether to trigger the drop action
+ * @param {boolean} [options.continueMove=false] whether to trigger the
+ * mousedown action (will only work after another call of this function with
+ * without this option)
+ */
+function dragAndDrop($el, $to, options) {
+ var position = (options && options.position) || 'center';
+ var elementCenter = $el.offset();
+ var toOffset = $to.offset();
+
+ if (_.isObject(position)) {
+ toOffset.top += position.top;
+ toOffset.left += position.left;
+ } else {
+ toOffset.top += $to.outerHeight() / 2;
+ toOffset.left += $to.outerWidth() / 2;
+ var vertical_offset = (toOffset.top < elementCenter.top) ? -1 : 1;
+ if (position === 'top') {
+ toOffset.top -= $to.outerHeight() / 2 + vertical_offset;
+ } else if (position === 'bottom') {
+ toOffset.top += $to.outerHeight() / 2 - vertical_offset;
+ } else if (position === 'left') {
+ toOffset.left -= $to.outerWidth() / 2;
+ } else if (position === 'right') {
+ toOffset.left += $to.outerWidth() / 2;
+ }
+ }
+
+ if ($to[0].ownerDocument !== document) {
+ // we are in an iframe
+ var bound = $('iframe')[0].getBoundingClientRect();
+ toOffset.left += bound.left;
+ toOffset.top += bound.top;
+ }
+ $el.trigger($.Event("mouseenter"));
+ if (!(options && options.continueMove)) {
+ elementCenter.left += $el.outerWidth() / 2;
+ elementCenter.top += $el.outerHeight() / 2;
+
+ $el.trigger($.Event("mousedown", {
+ which: 1,
+ pageX: elementCenter.left,
+ pageY: elementCenter.top
+ }));
+ }
+
+ $el.trigger($.Event("mousemove", {
+ which: 1,
+ pageX: toOffset.left,
+ pageY: toOffset.top
+ }));
+
+ if (!(options && options.disableDrop)) {
+ $el.trigger($.Event("mouseup", {
+ which: 1,
+ pageX: toOffset.left,
+ pageY: toOffset.top
+ }));
+ } else {
+ // It's impossible to drag another element when one is already
+ // being dragged. So it's necessary to finish the drop when the test is
+ // over otherwise it's impossible for the next tests to drag and
+ // drop elements.
+ $el.on("remove", function () {
+ $el.trigger($.Event("mouseup"));
+ });
+ }
+}
+
+/**
+ * simulate a mouse event with a custom event who add the item position. This is
+ * sometimes necessary because the basic way to trigger an event (such as
+ * $el.trigger('mousemove')); ) is too crude for some uses.
+ *
+ * @param {jqueryElement} $el
+ * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove'
+ */
+function triggerMouseEvent($el, type) {
+ var pos = $el.offset();
+ var e = new $.Event(type);
+ e.pageX = e.layerX = e.screenX = pos.left;
+ e.pageY = e.layerY = e.screenY = pos.top;
+ e.which = 1;
+ $el.trigger(e);
+}
+
+/**
+ * simulate a mouse event with a custom event on a position x and y. This is
+ * sometimes necessary because the basic way to trigger an event (such as
+ * $el.trigger('mousemove')); ) is too crude for some uses.
+ *
+ * @param {integer} x
+ * @param {integer} y
+ * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove'
+ */
+function triggerPositionalMouseEvent(x, y, type) {
+ var ev = document.createEvent("MouseEvent");
+ var el = document.elementFromPoint(x, y);
+ ev.initMouseEvent(
+ type,
+ true /* bubble */,
+ true /* cancelable */,
+ window, null,
+ x, y, x, y, /* coordinates */
+ false, false, false, false, /* modifier keys */
+ 0 /*left button*/, null
+ );
+ el.dispatchEvent(ev);
+ return el;
+}
+
+/**
+ * simulate a keypress event for a given character
+ *
+ * @param {string} char the character, or 'ENTER'
+ */
+function triggerKeypressEvent(char) {
+ var keycode;
+ if (char === "Enter") {
+ keycode = $.ui.keyCode.ENTER;
+ } else {
+ keycode = char.charCodeAt(0);
+ }
+ return $('body').trigger($.Event('keypress', { which: keycode, keyCode: keycode }));
+}
+
+/**
+ * Opens the datepicker of a given element.
+ *
+ * @param {jQuery} $datepickerEl element to which a datepicker is attached
+ */
+function openDatepicker($datepickerEl) {
+ $datepickerEl.find('.o_datepicker_input').trigger('focus.datetimepicker');
+}
+
+/**
+ * Click on a specified element. If the option first or last is not specified,
+ * this method also check the unicity and the visibility of the target.
+ *
+ * @param {string|NodeList|jQuery} el (if string: it is a (jquery) selector)
+ * @param {Object} [options]
+ * @param {boolean} [options.first=false] if true, clicks on the first element
+ * @param {boolean} [options.last=false] if true, clicks on the last element
+ */
+function click(el, options) {
+ options = options || {};
+ var matches = typeof el === 'string' ? $(el) : el;
+ var selectorMsg = el.selector ? `(selector: ${el.selector})` : '';
+ if (!matches.filter) { // it might be an array of dom elements
+ matches = $(matches);
+ }
+
+ var visibleMatches = matches.filter(':visible');
+ if (options.first) {
+ if (visibleMatches.length === 1) {
+ throw new Error(`There should be more than one visible target ${selectorMsg}. If` +
+ ' you are sure that there is exactly one target, please use the ' +
+ 'click function instead of the clickFirst function');
+ }
+ visibleMatches = visibleMatches.first();
+ } else if (options.last) {
+ if (visibleMatches.length === 1) {
+ throw new Error(`There should be more than one visible target ${selectorMsg}. If` +
+ ' you are sure that there is exactly one target, please use the ' +
+ 'click function instead of the clickLast function');
+ }
+ visibleMatches = visibleMatches.last();
+ }
+ if (visibleMatches.length === 0 && matches.length > 0) {
+ throw new Error(`Element to click on is not visible ${selectorMsg}`);
+ } else if (visibleMatches.length !== 1) {
+ throw new Error(`Found ${visibleMatches.length} elements to click on, instead of 1 ${selectorMsg}`);
+ }
+ visibleMatches.click();
+}
+
+/**
+ * Click on the first element of a list of elements. Note that if the list has
+ * only one visible element, we trigger an error. In that case, it is better to
+ * use the click helper instead.
+ *
+ * @param {string|NodeList|jQuery} el (if string: it is a (jquery) selector)
+ */
+function clickFirst(el) {
+ click(el, {first: true});
+}
+
+/**
+ * Click on the last element of a list of elements. Note that if the list has
+ * only one visible element, we trigger an error. In that case, it is better to
+ * use the click helper instead.
+ *
+ * @param {string|NodeList|jQuery} el
+ */
+function clickLast(el) {
+ click(el, {last: true});
+}
+
+return {
+ triggerKeypressEvent: triggerKeypressEvent,
+ triggerMouseEvent: triggerMouseEvent,
+ triggerPositionalMouseEvent: triggerPositionalMouseEvent,
+ dragAndDrop: dragAndDrop,
+ openDatepicker: openDatepicker,
+ click: click,
+ clickFirst: clickFirst,
+ clickLast: clickLast,
+};
+
+});
diff --git a/addons/web/static/tests/helpers/test_utils_fields.js b/addons/web/static/tests/helpers/test_utils_fields.js
new file mode 100644
index 00000000000..f7d8829ce82
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_fields.js
@@ -0,0 +1,172 @@
+odoo.define('web.test_utils_fields', function (require) {
+"use strict";
+
+/**
+ * Field Test Utils
+ *
+ * This module defines various utility functions to help testing field widgets.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+var concurrency = require('web.concurrency');
+
+//------------------------------------------------------------------------------
+// Public functions
+//------------------------------------------------------------------------------
+
+/**
+ * Sets the value of an element and then, trigger all specified events.
+ * Note that this helper also checks the unicity of the target.
+ *
+ * Example:
+ * testUtils.fields.editAndTrigger($('selector'), 'test', ['input', 'change']);
+ *
+ * @param {jQuery} $el should target an input, textarea or select
+ * @param {string|number} value
+ * @param {string[]} events
+ */
+function editAndTrigger($el, value, events) {
+ if ($el.length !== 1) {
+ throw new Error(`target ${$el.selector} has length ${$el.length} instead of 1`);
+ }
+ $el.val(value);
+ events.forEach(function (event) {
+ $el.trigger(event);
+ });
+}
+
+/**
+ * Sets the value of an input.
+ *
+ * Note that this helper also checks the unicity of the target.
+ *
+ * Example:
+ * testUtils.fields.editInput($('selector'), 'somevalue');
+ *
+ * @param {jQuery} $el
+ * @param {string|number} value
+ */
+function editInput($el, value) {
+ editAndTrigger($el, value, ['input']);
+}
+
+/**
+ * Sets the value of a select.
+ *
+ * Note that this helper also checks the unicity of the target.
+ *
+ * Example:
+ * testUtils.fields.editSelect($('selector'), 'somevalue');
+ *
+ * @param {jQuery} $el
+ * @param {string|number} value
+ */
+function editSelect($el, value) {
+ editAndTrigger($el, value, ['change']);
+}
+
+/**
+ * Click to open the dropdown on a many2one
+ *
+ * @param {string} fieldName
+ * @param {[string]} selector if set, this will restrict the search for the m2o
+ * input
+ * @returns {HTMLInputElement} the main many2one input
+ */
+function clickOpenM2ODropdown(fieldName, selector) {
+ var m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`;
+ var matches = document.querySelectorAll(m2oSelector);
+ if (matches.length !== 1) {
+ throw new Error(`cannot open m2o: selector ${selector} has been found ${matches.length} instead of 1`);
+ }
+ matches[0].click();
+ return matches[0];
+}
+
+/**
+ * Click on the active (highlighted) selection in a m2o dropdown.
+ *
+ * @param {string} fieldName
+ * @param {[string]} selector if set, this will restrict the search for the m2o
+ * input
+ */
+function clickM2OHighlightedItem(fieldName, selector) {
+ var m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`;
+ var $dropdown = $(m2oSelector).autocomplete('widget');
+ // clicking on an li (no matter which one), will select the focussed one
+ $dropdown.find('li:first()').click();
+}
+
+/**
+ * Click on a menuitem in the m2o dropdown. This helper will target an element
+ * which contains some specific text. Note that it assumes that the dropdown
+ * is currently open.
+ *
+ * Example:
+ * testUtils.fields.many2one.clickM2OItem('partner_id', 'George');
+ *
+ * @param {string} fieldName
+ * @param {string} searchText
+ */
+function clickM2OItem(fieldName, searchText) {
+ var m2oSelector = `.o_field_many2one[name=${fieldName}] input`;
+ var $dropdown = $(m2oSelector).autocomplete('widget');
+ var $target = $dropdown.find(`li:contains(${searchText})`).first();
+ if ($target.length !== 1 || !$target.is(':visible')) {
+ throw new Error('Menu item should be unique and visible');
+ }
+ $target.mouseenter().click();
+}
+
+/**
+ * This helper is useful to test many2one fields. Here is what it does:
+ * - click to open the dropdown
+ * - enter a search string in the input
+ * - wait for the selection
+ * - click on the active menuitem
+ *
+ * Example:
+ * testUtils.fields.many2one.searchAndClickM2OItem('partner_id', 'George');
+ *
+ * @param {string} fieldName
+ * @param {[Object]} options
+ * @param {[string]} options.selector
+ * @param {[string]} options.search
+ * @returns {Promise}
+ */
+function searchAndClickM2OItem(fieldName, options) {
+ options = options || {};
+
+ var input = clickOpenM2ODropdown(fieldName, options.selector);
+
+ var def;
+ if (options.search) {
+ // jquery autocomplete refines the search in a setTimeout() parameterized
+ // with a delay, so we force this delay to 0 s.t. the dropdown is filtered
+ // directly on the next tick
+ var $input = $(input);
+ var delay = $input.autocomplete('option', 'delay');
+ $input.autocomplete('option', 'delay', 0);
+ input.value = options.search;
+ input.dispatchEvent(new Event('input'));
+ $input.autocomplete('option', 'delay', delay);
+ def = concurrency.delay(0);
+ }
+
+ return $.when(def).then(function () {
+ clickM2OHighlightedItem(fieldName, options.selector);
+ });
+}
+return {
+ clickOpenM2ODropdown: clickOpenM2ODropdown,
+ clickM2OHighlightedItem: clickM2OHighlightedItem,
+ clickM2OItem: clickM2OItem,
+ editAndTrigger: editAndTrigger,
+ editInput: editInput,
+ editSelect: editSelect,
+ searchAndClickM2OItem: searchAndClickM2OItem,
+};
+
+});
diff --git a/addons/web/static/tests/helpers/test_utils_form.js b/addons/web/static/tests/helpers/test_utils_form.js
new file mode 100644
index 00000000000..379350e1ca6
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_form.js
@@ -0,0 +1,74 @@
+odoo.define('web.test_utils_form', function (require) {
+"use strict";
+
+/**
+ * Form Test Utils
+ *
+ * This module defines various utility functions to help test form views.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+var testUtilsDom = require('web.test_utils_dom');
+
+/**
+ * Clicks on the Edit button in a form view, to set it to edit mode. Note that
+ * it checks that the button is visible, so calling this method in edit mode
+ * will fail.
+ *
+ * @param {FormController} form
+ */
+function clickEdit(form) {
+ testUtilsDom.click(form.$buttons.find('.o_form_button_edit'));
+}
+
+/**
+ * Clicks on the Save button in a form view. Note that this method checks that
+ * the Save button is visible.
+ *
+ * @param {FormController} form
+ */
+function clickSave(form) {
+ testUtilsDom.click(form.$buttons.find('.o_form_button_save'));
+}
+
+/**
+ * Clicks on the Create button in a form view. Note that this method checks that
+ * the Create button is visible.
+ *
+ * @param {FormController} form
+ */
+function clickCreate(form) {
+ testUtilsDom.click(form.$buttons.find('.o_form_button_create'));
+}
+
+/**
+ * Clicks on the Discard button in a form view. Note that this method checks that
+ * the Discard button is visible.
+ *
+ * @param {FormController} form
+ */
+function clickDiscard(form) {
+ testUtilsDom.click(form.$buttons.find('.o_form_button_cancel'));
+}
+
+/**
+ * Reloads a form view.
+ *
+ * @param {FormController} form
+ * @param {[Object]} params given to the controller reload method
+ */
+function reload(form, params) {
+ form.reload(params);
+}
+
+return {
+ clickEdit: clickEdit,
+ clickSave: clickSave,
+ clickCreate: clickCreate,
+ clickDiscard: clickDiscard,
+ reload: reload,
+};
+
+});
diff --git a/addons/web/static/tests/helpers/test_utils_graph.js b/addons/web/static/tests/helpers/test_utils_graph.js
new file mode 100644
index 00000000000..8867f82c349
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_graph.js
@@ -0,0 +1,28 @@
+odoo.define('web.test_utils_graph', function () {
+"use strict";
+
+/**
+ * Graph Test Utils
+ *
+ * This module defines various utility functions to help test graph views.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+
+/**
+ * Reloads a graph view.
+ *
+ * @param {GraphController} graph
+ * @param {[Object]} params given to the controller reload method
+ */
+function reload(graph, params) {
+ graph.reload(params);
+}
+
+return {
+ reload: reload,
+};
+
+});
diff --git a/addons/web/static/tests/helpers/test_utils_kanban.js b/addons/web/static/tests/helpers/test_utils_kanban.js
new file mode 100644
index 00000000000..166ae2a2572
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_kanban.js
@@ -0,0 +1,97 @@
+odoo.define('web.test_utils_kanban', function (require) {
+"use strict";
+
+/**
+ * Kanban Test Utils
+ *
+ * This module defines various utility functions to help testing kanban views.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+var testUtilsDom = require('web.test_utils_dom');
+var testUtilsFields = require('web.test_utils_fields');
+
+/**
+ * Clicks on the Create button in a kanban view. Note that this method checks that
+ * the Create button is visible.
+ *
+ * @param {KanbanController} kanban
+ */
+function clickCreate(kanban) {
+ testUtilsDom.click(kanban.$buttons.find('.o-kanban-button-new'));
+}
+
+/**
+ * Open the settings menu for a column (in a grouped kanban view)
+ *
+ * @param {jQuery} $column
+ */
+function toggleGroupSettings($column) {
+ var $dropdownToggler = $column.find('.o_kanban_config > a.dropdown-toggle');
+ if (!$dropdownToggler.is(':visible')) {
+ $dropdownToggler.css('display', 'block');
+ }
+ testUtilsDom.click($dropdownToggler);
+}
+
+/**
+ * Edit a value in a quickcreate form view (this method assumes that the quick
+ * create feature is active, and a sub form view is open)
+ *
+ * @param {kanbanController} kanban
+ * @param {string|number} value
+ * @param {[string]} fieldName
+ */
+function quickCreate(kanban, value, fieldName) {
+ var additionalSelector = fieldName ? ('[name=' + fieldName + ']'): '';
+ var enterEvent = $.Event(
+ 'keydown',
+ {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }
+ );
+ testUtilsFields.editAndTrigger(
+ kanban.$('.o_kanban_quick_create input' + additionalSelector),
+ value,
+ ['input', enterEvent]
+ );
+}
+
+/**
+ * Reloads a kanban view.
+ *
+ * @param {KanbanController} kanban
+ * @param {[Object]} params given to the controller reload method
+ */
+function reload(kanban, params) {
+ kanban.reload(params);
+}
+
+/**
+ * Open the setting dropdown of a kanban record. Note that the template of a
+ * kanban record is not standardized, so this method will fail if the template
+ * does not comply with the usual dom structure.
+ *
+ * @param {jQuery} $record
+ */
+function toggleRecordDropdown($record) {
+ var $dropdownToggler = $record.find('.o_dropdown_kanban > a.dropdown-toggle');
+ if (!$dropdownToggler.is(':visible')) {
+ $dropdownToggler.css('display', 'block');
+ }
+ testUtilsDom.click($dropdownToggler);
+}
+
+
+return {
+ clickCreate: clickCreate,
+ quickCreate: quickCreate,
+ reload: reload,
+ toggleGroupSettings: toggleGroupSettings,
+ toggleRecordDropdown: toggleRecordDropdown,
+};
+
+});
diff --git a/addons/web/static/tests/helpers/test_utils_mock.js b/addons/web/static/tests/helpers/test_utils_mock.js
new file mode 100644
index 00000000000..769c179472a
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_mock.js
@@ -0,0 +1,545 @@
+odoo.define('web.test_utils_mock', function (require) {
+"use strict";
+
+/**
+ * Mock Test Utils
+ *
+ * This module defines various utility functions to help mocking data.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+var basic_fields = require('web.basic_fields');
+var config = require('web.config');
+var core = require('web.core');
+var MockServer = require('web.MockServer');
+var session = require('web.session');
+
+var DebouncedField = basic_fields.DebouncedField;
+
+//------------------------------------------------------------------------------
+// Private functions
+//------------------------------------------------------------------------------
+
+/**
+ * logs all event going through the target widget.
+ *
+ * @param {Widget} widget
+ */
+function _observe(widget) {
+ var _trigger_up = widget._trigger_up.bind(widget);
+ widget._trigger_up = function (event) {
+ console.log('%c[event] ' + event.name, 'color: blue; font-weight: bold;', event);
+ _trigger_up(event);
+ };
+}
+
+//------------------------------------------------------------------------------
+// Public functions
+//------------------------------------------------------------------------------
+
+/**
+ * Removes the src attribute on images and iframes to prevent not found errors,
+ * and optionally triggers an rpc with the src url as route on a widget.
+ * This method is critical and must be fastest (=> no jQuery, no underscore)
+ *
+ * @param {DOM Node} el
+ * @param {[Widget]} widget the widget on which the rpc should be performed
+ */
+function removeSrcAttribute(el, widget) {
+ var nodes;
+ if (el.nodeName === 'IMG' || el.nodeName === 'IFRAME') {
+ nodes = [el];
+ } else {
+ nodes = Array.prototype.slice.call(el.getElementsByTagName('img'))
+ .concat(Array.prototype.slice.call(el.getElementsByTagName('iframe')));
+ }
+ var node;
+ while (node = nodes.pop()) {
+ var src = node.attributes.src && node.attributes.src.value;
+ if (src && src !== 'about:blank') {
+ node.setAttribute('data-src', src);
+ if (node.nodeName === 'IMG') {
+ node.attributes.removeNamedItem('src');
+ } else {
+ node.setAttribute('src', 'about:blank');
+ }
+ if (widget) {
+ widget._rpc({ route: src });
+ }
+ }
+ }
+}
+
+/**
+ * Add a mock environment to a widget. This helper function can simulate
+ * various kind of side effects, such as mocking RPCs, changing the session,
+ * or the translation settings.
+ *
+ * The simulated environment lasts for the lifecycle of the widget, meaning it
+ * disappears when the widget is destroyed. It is particularly relevant for the
+ * session mocks, because the previous session is restored during the destroy
+ * call. So, it means that you have to be careful and make sure that it is
+ * properly destroyed before another test is run, otherwise you risk having
+ * interferences between tests.
+ *
+ * @param {Widget} widget
+ * @param {Object} params
+ * @param {Object} [params.archs] a map of string [model,view_id,view_type] to
+ * a arch object. It is used to mock answers to 'load_views' custom events.
+ * This is useful when the widget instantiate a formview dialog that needs
+ * to load a particular arch.
+ * @param {string} [params.currentDate] a string representation of the current
+ * date. It is given to the mock server.
+ * @param {Object} params.data the data given to the created mock server. It is
+ * used to generate mock answers for every kind of routes supported by odoo
+ * @param {number} [params.debug] if set to true, logs RPCs and uncaught Odoo
+ * events.
+ * @param {function} [params.mockRPC] a function that will be used to override
+ * the _performRpc method from the mock server. It is really useful to add
+ * some custom rpc mocks, or to check some assertions.
+ * @param {Object} [params.session] if it is given, it will be used as answer
+ * for all calls to this.getSession() by the widget, of its children. Also,
+ * it will be used to extend the current, real session. This side effect is
+ * undone when the widget is destroyed.
+ * @param {Object} [params.translateParameters] if given, it will be used to
+ * extend the core._t.database.parameters object. After the widget
+ * destruction, the original parameters will be restored.
+ * @param {Object} [params.intercepts] an object with event names as key, and
+ * callback as value. Each key,value will be used to intercept the event.
+ * Note that this is particularly useful if you want to intercept events going
+ * up in the init process of the view, because there are no other way to do it
+ * after this method returns. Some events ('call_service', "load_views",
+ * "get_session", "load_filters") have a special treatment beforehand.
+ * @param {Object} [params.services={}] list of services to load in
+ * addition to the ajax service. For instance, if a test needs the local
+ * storage service in order to work, it can provide a mock version of it.
+ * @param {boolean} [debounce=true] set to false to completely remove the
+ * debouncing, forcing the handler to be called directly (not on the next
+ * execution stack, like it does with delay=0).
+ * @param {boolean} [throttle=false] set to true to keep the throttling, which
+ * is completely removed by default.
+ *
+ * @returns {MockServer} the instance of the mock server, created by this
+ * function. It is necessary for createAsyncView so that method can call some
+ * other methods on it.
+ */
+function addMockEnvironment(widget, params) {
+ var Server = MockServer;
+ params.services = params.services || {};
+ if (params.mockRPC) {
+ Server = MockServer.extend({ _performRpc: params.mockRPC });
+ }
+ if (params.debug) {
+ _observe(widget);
+ var separator = window.location.href.indexOf('?') !== -1 ? "&" : "?";
+ var url = window.location.href + separator + 'testId=' + QUnit.config.current.testId;
+ console.log('%c[debug] debug mode activated', 'color: blue; font-weight: bold;', url);
+ }
+
+ var mockServer = new Server(params.data, {
+ actions: params.actions,
+ archs: params.archs,
+ currentDate: params.currentDate,
+ debug: params.debug,
+ widget: widget,
+ });
+
+ // make sure images do not trigger a GET on the server
+ $('body').on('DOMNodeInserted.removeSRC', function (event) {
+ removeSrcAttribute(event.target, widget);
+ });
+
+ // make sure the debounce value for input fields is set to 0
+ var initialDebounceValue = DebouncedField.prototype.DEBOUNCE;
+ DebouncedField.prototype.DEBOUNCE = params.fieldDebounce || 0;
+ var initialSession, initialConfig, initialParameters, initialDebounce, initialThrottle;
+ initialSession = _.extend({}, session);
+ session.getTZOffset = function () {
+ return 0; // by default, but may be overriden in specific tests
+ };
+ if ('session' in params) {
+ _.extend(session, params.session);
+ }
+ if ('config' in params) {
+ initialConfig = _.clone(config);
+ initialConfig.device = _.clone(config.device);
+ if ('device' in params.config) {
+ _.extend(config.device, params.config.device);
+ }
+ if ('debug' in params.config) {
+ config.debug = params.config.debug;
+ }
+ }
+ if ('translateParameters' in params) {
+ initialParameters = _.extend({}, core._t.database.parameters);
+ _.extend(core._t.database.parameters, params.translateParameters);
+ }
+ if (params.debounce === false) {
+ initialDebounce = _.debounce;
+ _.debounce = function (func) {
+ return func;
+ };
+ }
+ if (!('throttle' in params) || !params.throttle) {
+ initialThrottle = _.throttle;
+ _.throttle = function (func) {
+ return func;
+ };
+ }
+
+ var widgetDestroy = widget.destroy;
+ widget.destroy = function () {
+ // clear the caches (e.g. data_manager, ModelFieldSelector) when the
+ // widget is destroyed, at the end of each test to avoid collisions
+ core.bus.trigger('clear_cache');
+
+ DebouncedField.prototype.DEBOUNCE = initialDebounceValue;
+ if (params.debounce === false) {
+ _.debounce = initialDebounce;
+ }
+ if (!('throttle' in params) || !params.throttle) {
+ _.throttle = initialThrottle;
+ }
+
+ var key;
+ if ('session' in params) {
+ for (key in session) {
+ delete session[key];
+ }
+ }
+ _.extend(session, initialSession);
+ if ('config' in params) {
+ for (key in config) {
+ delete config[key];
+ }
+ _.extend(config, initialConfig);
+ }
+ if ('translateParameters' in params) {
+ for (key in core._t.database.parameters) {
+ delete core._t.database.parameters[key];
+ }
+ _.extend(core._t.database.parameters, initialParameters);
+ }
+
+ $('body').off('DOMNodeInserted.removeSRC');
+ $('.blockUI').remove();
+
+ widgetDestroy.call(this);
+ };
+
+ // Dispatch service calls
+ // Note: some services could call other services at init,
+ // Which is why we have to init services after that
+ var services = {};
+ intercept(widget, 'call_service', function (ev) {
+ var args, result;
+ if (services[ev.data.service]) {
+ var service = services[ev.data.service];
+ args = (ev.data.args || []);
+ result = service[ev.data.method].apply(service, args);
+ } else if (ev.data.service === 'ajax') {
+ // use ajax service that is mocked by the server
+ var route = ev.data.args[0];
+ args = ev.data.args[1];
+ result = mockServer.performRpc(route, args);
+ }
+ ev.data.callback(result);
+ });
+
+ intercept(widget, 'load_action', function (event) {
+ mockServer.performRpc('/web/action/load', {
+ kwargs: {
+ action_id: event.data.actionID,
+ additional_context: event.data.context,
+ },
+ }).then(function (action) {
+ event.data.on_success(action);
+ });
+ });
+
+ intercept(widget, "load_views", function (event) {
+ mockServer.performRpc('/web/dataset/call_kw/' + event.data.modelName, {
+ args: [],
+ kwargs: {
+ context: event.data.context,
+ options: event.data.options,
+ views: event.data.views,
+ },
+ method: 'load_views',
+ model: event.data.modelName,
+ }).then(function (views) {
+ views = _.mapObject(views, function (viewParams) {
+ return fieldsViewGet(mockServer, viewParams);
+ });
+ event.data.on_success(views);
+ });
+ });
+
+ intercept(widget, "get_session", function (event) {
+ event.data.callback(session);
+ });
+
+ intercept(widget, "load_filters", function (event) {
+ if (params.debug) {
+ console.log('[mock] load_filters', event.data);
+ }
+ event.data.on_success([]);
+ });
+
+ // make sure all Odoo events bubbling up are intercepted
+ if ('intercepts' in params) {
+ _.each(params.intercepts, function (cb, name) {
+ intercept(widget, name, cb);
+ });
+ }
+
+ // Deploy services
+ var done = false;
+ var servicesToDeploy = _.clone(params.services);
+ if (!servicesToDeploy.ajax) {
+ services.ajax = null; // use mocked ajax from mocked server
+ }
+ while (!done) {
+ var serviceName = _.findKey(servicesToDeploy, function (Service) {
+ return !_.some(Service.prototype.dependencies, function (depName) {
+ return !_.has(services, depName);
+ });
+ });
+ if (serviceName) {
+ var Service = servicesToDeploy[serviceName];
+ var service = services[serviceName] = new Service(widget);
+ delete servicesToDeploy[serviceName];
+
+ intercept(service, "get_session", function (event) {
+ event.data.callback(session);
+ });
+
+ service.start();
+ } else {
+ var serviceNames = _.keys(servicesToDeploy);
+ if (serviceNames.length) {
+ console.warn("Non loaded services:", serviceNames);
+ }
+ done = true;
+ }
+ }
+
+ return mockServer;
+}
+
+/**
+ * performs a fields_view_get, and mocks the postprocessing done by the
+ * data_manager to return an equivalent structure.
+ *
+ * @param {MockServer} server
+ * @param {Object} params
+ * @param {string} params.model
+ * @returns {Object} an object with 3 keys: arch, fields and viewFields
+ */
+function fieldsViewGet(server, params) {
+ var fieldsView = server.fieldsViewGet(params);
+ // mock the structure produced by the DataManager
+ fieldsView.viewFields = fieldsView.fields;
+ fieldsView.fields = server.fieldsGet(params.model);
+ return fieldsView;
+}
+
+/**
+ * intercepts an event bubbling up the widget hierarchy. The event intercepted
+ * must be a "custom event", i.e. an event generated by the method 'trigger_up'.
+ *
+ * Note that this method really intercepts the event if @propagate is not set.
+ * It will not be propagated further, and even the handlers on the target will
+ * not fire.
+ *
+ * @param {Widget} widget the target widget (any Odoo widget)
+ * @param {string} eventName description of the event
+ * @param {function} fn callback executed when the even is intercepted
+ * @param {boolean} [propagate=false]
+ */
+function intercept(widget, eventName, fn, propagate) {
+ var _trigger_up = widget._trigger_up.bind(widget);
+ widget._trigger_up = function (event) {
+ if (event.name === eventName) {
+ fn(event);
+ if (!propagate) { return; }
+ }
+ _trigger_up(event);
+ };
+}
+
+/**
+ * Patch window.Date so that the time starts its flow from the provided Date.
+ *
+ * Usage:
+ *
+ * ```
+ * var unpatchDate = testUtils.mock.patchDate(2018, 0, 10, 17, 59, 30)
+ * new window.Date(); // "Wed Jan 10 2018 17:59:30 GMT+0100 (Central European Standard Time)"
+ * ... // 5 hours delay
+ * new window.Date(); // "Wed Jan 10 2018 22:59:30 GMT+0100 (Central European Standard Time)"
+ * ...
+ * unpatchDate();
+ * new window.Date(); // actual current date time
+ * ```
+ *
+ * @param {integer} year
+ * @param {integer} month index of the month, starting from zero.
+ * @param {integer} day the day of the month.
+ * @param {integer} hours the digits for hours (24h)
+ * @param {integer} minutes
+ * @param {integer} seconds
+ * @returns {function} a callback to unpatch window.Date.
+ */
+function patchDate(year, month, day, hours, minutes, seconds) {
+ var RealDate = window.Date;
+ var actualDate = new RealDate();
+ var fakeDate = new RealDate(year, month, day, hours, minutes, seconds);
+ var timeInterval = actualDate.getTime() - (fakeDate.getTime());
+
+ Date = (function (NativeDate) {
+ function Date(Y, M, D, h, m, s, ms) {
+ var length = arguments.length;
+ if (arguments.length > 0) {
+ var date = length == 1 && String(Y) === Y ? // isString(Y)
+ // We explicitly pass it through parse:
+ new NativeDate(Date.parse(Y)) :
+ // We have to manually make calls depending on argument
+ // length here
+ length >= 7 ? new NativeDate(Y, M, D, h, m, s, ms) :
+ length >= 6 ? new NativeDate(Y, M, D, h, m, s) :
+ length >= 5 ? new NativeDate(Y, M, D, h, m) :
+ length >= 4 ? new NativeDate(Y, M, D, h) :
+ length >= 3 ? new NativeDate(Y, M, D) :
+ length >= 2 ? new NativeDate(Y, M) :
+ length >= 1 ? new NativeDate(Y) :
+ new NativeDate();
+ // Prevent mixups with unfixed Date object
+ date.constructor = Date;
+ return date;
+ } else {
+ var date = new NativeDate();
+ var time = date.getTime();
+ time -= timeInterval;
+ date.setTime(time);
+ return date;
+ }
+ }
+
+ // Copy any custom methods a 3rd party library may have added
+ for (var key in NativeDate) {
+ Date[key] = NativeDate[key];
+ }
+
+ // Copy "native" methods explicitly; they may be non-enumerable
+ Date.now = NativeDate.now;
+ Date.UTC = NativeDate.UTC;
+ Date.prototype = NativeDate.prototype;
+ Date.prototype.constructor = Date;
+
+ // Upgrade Date.parse to handle simplified ISO 8601 strings
+ Date.parse = NativeDate.parse;
+ return Date;
+ })(Date);
+
+ return function () { window.Date = RealDate; };
+}
+
+var patches = {};
+/**
+ * Patches a given Class or Object with the given properties.
+ *
+ * @param {Class|Object} target
+ * @param {Object} props
+ */
+function patch(target, props) {
+ var patchID = _.uniqueId('patch_');
+ target.__patchID = patchID;
+ patches[patchID] = {
+ target: target,
+ otherPatchedProps: [],
+ ownPatchedProps: [],
+ };
+ if (target.prototype) {
+ _.each(props, function (value, key) {
+ if (target.prototype.hasOwnProperty(key)) {
+ patches[patchID].ownPatchedProps.push({
+ key: key,
+ initialValue: target.prototype[key],
+ });
+ } else {
+ patches[patchID].otherPatchedProps.push(key);
+ }
+ });
+ target.include(props);
+ } else {
+ _.each(props, function (value, key) {
+ if (key in target) {
+ var oldValue = target[key];
+ patches[patchID].ownPatchedProps.push({
+ key: key,
+ initialValue: oldValue,
+ });
+ if (typeof value === 'function') {
+ target[key] = function () {
+ var oldSuper = this._super;
+ this._super = oldValue;
+ var result = value.apply(this, arguments);
+ if (oldSuper === undefined) {
+ delete this._super;
+ } else {
+ this._super = oldSuper;
+ }
+ return result;
+ };
+ } else {
+ target[key] = value;
+ }
+ } else {
+ patches[patchID].otherPatchedProps.push(key);
+ target[key] = value;
+ }
+ });
+ }
+}
+
+/**
+ * Unpatches a given Class or Object.
+ *
+ * @param {Class|Object} target
+ */
+function unpatch(target) {
+ var patchID = target.__patchID;
+ var patch = patches[patchID];
+ if (target.prototype) {
+ _.each(patch.ownPatchedProps, function (p) {
+ target.prototype[p.key] = p.initialValue;
+ });
+ _.each(patch.otherPatchedProps, function (key) {
+ delete target.prototype[key];
+ });
+ } else {
+ _.each(patch.ownPatchedProps, function (p) {
+ target[p.key] = p.initialValue;
+ });
+ _.each(patch.otherPatchedProps, function (key) {
+ delete target[key];
+ });
+ }
+ delete patches[patchID];
+ delete target.__patchID;
+}
+
+
+return {
+ addMockEnvironment: addMockEnvironment,
+ fieldsViewGet: fieldsViewGet,
+ intercept: intercept,
+ patchDate: patchDate,
+ patch: patch,
+ unpatch: unpatch,
+};
+
+});
diff --git a/addons/web/static/tests/helpers/test_utils_modal.js b/addons/web/static/tests/helpers/test_utils_modal.js
new file mode 100644
index 00000000000..d2162aebfa0
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_modal.js
@@ -0,0 +1,34 @@
+odoo.define('web.test_utils_modal', function (require) {
+"use strict";
+
+/**
+ * Modal Test Utils
+ *
+ * This module defines various utility functions to help test pivot views.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+var core = require('web.core');
+
+/**
+ * Click on a button in the footer of a modal (which contains a given string).
+ * Note that this method checks the unicity of the button.
+ *
+ * @param {string} text (in english: this method will perform the translation)
+ */
+function clickButton(text) {
+ var selector = `.modal-footer button:contains(${core._t(text)})`;
+ var $button = $(selector);
+ if ($button.length !== 1) {
+ throw new Error(`Found ${$button.length} button(s) containing '${text}'`);
+ }
+ $button.click();
+}
+
+return {
+ clickButton: clickButton,
+};
+
+});
diff --git a/addons/web/static/tests/helpers/test_utils_pivot.js b/addons/web/static/tests/helpers/test_utils_pivot.js
new file mode 100644
index 00000000000..18a5a7307f3
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_pivot.js
@@ -0,0 +1,57 @@
+odoo.define('web.test_utils_pivot', function (require) {
+"use strict";
+
+var testUtilsDom = require('web.test_utils_dom');
+
+/**
+ * Pivot Test Utils
+ *
+ * This module defines various utility functions to help test pivot views.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+
+/**
+ * Select a measure by clicking on the corresponding dropdown item (in the
+ * control panel 'Measure' submenu).
+ *
+ * Note that this method assumes that the dropdown menu is open.
+ * @see toggleMeasuresDropdown
+ *
+ * @param {PivotController} pivot
+ * @param {string} measure
+ */
+function clickMeasure(pivot, measure) {
+ testUtilsDom.click(pivot.$buttons.find(`.dropdown-item[data-field=${measure}]`));
+}
+
+/**
+ * Open the 'Measure' dropdown menu (in the control panel)
+ *
+ * @see clickMeasure
+ *
+ * @param {PivotController} pivot
+ */
+function toggleMeasuresDropdown(pivot) {
+ testUtilsDom.click(pivot.$buttons.filter('.btn-group:first').find('> button'));
+}
+
+/**
+ * Reloads a graph view.
+ *
+ * @param {PivotController} pivot
+ * @param {[Object]} params given to the controller reload method
+ */
+function reload(pivot, params) {
+ pivot.reload(params);
+}
+
+return {
+ clickMeasure: clickMeasure,
+ reload: reload,
+ toggleMeasuresDropdown: toggleMeasuresDropdown,
+};
+
+});