Files
odoo_source/addons/web/static/tests/helpers/test_utils.js
T
Alexandre Kühn 89e2679f08 [FIX] web: tests: turn patchWindowGetSelection stateless
test_utils.js contains helper functions, which should be stateless
(good practices). This was not the case with patchWindowGetSelection
and unpatchWindowGetSelection, so instead we keep only the first
function which returns the second one.

From the test POV, instead of this:

    utils.patchWindowGetSelection();
    // ...
    utils.unpatchWindowGetSelection();

It now looks like that:

    var unpatchWindowGetSelection = utils.patchWindowGetSelection();
    // ...
    unpatchWindowGetSelection();
2018-02-22 14:23:03 +01:00

836 lines
29 KiB
JavaScript

odoo.define('web.test_utils', function (require) {
"use strict";
/**
* Test Utils
*
* In this module, we define various utility functions to help simulate a mock
* environment as close as possible as a real environment. The main function is
* certainly createView, which takes a bunch of parameters and give you back an
* 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 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');
}
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.appendTo($target);
widget.$el.addClass('o_web_client');
// make sure images and iframes do not trigger a GET on the server
// AAB; this should be done in addMockEnvironment
$target.on('DOMNodeInserted.removeSRC', function () {
removeSrcAttribute($(this), widget);
});
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;
$target.off('DOMNodeInserted.removeSRC');
widget.destroy();
};
actionManager.appendTo(widget.$el);
return actionManager;
};
/**
* 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 || [],
};
_.extend(viewOptions, params.viewOptions);
var view = new params.View(viewInfo, viewOptions);
// make sure images do not trigger a GET on the server
$target.on('DOMNodeInserted.removeSRC', function () {
removeSrcAttribute($(this), widget);
});
// 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);
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();
$('#qunit-fixture').off('DOMNodeInserted.removeSRC');
};
// 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;
});
});
}
/**
* 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 {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;
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,
});
// 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);
config.device.isMobile = config.device.size_class <= config.device.SIZES.XS;
}
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);
}
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 = {ajax: null}; // mocked ajax service already loaded
intercept(widget, 'call_service', function (ev) {
var args, result;
if (ev.data.service === 'ajax') {
// ajax service is already mocked by the server
var route = ev.data.args[0];
args = ev.data.args[1];
result = mockServer.performRpc(route, args);
} else if (services[ev.data.service]) {
var service = services[ev.data.service];
args = (ev.data.args || []);
result = service[ev.data.method].apply(service, args);
}
ev.data.callback(result);
});
// Deploy services
var done = false;
while (!done) {
var index = _.findIndex(params.services, function (Service) {
return !_.some(Service.prototype.dependencies, function (depName) {
return !_.has(services, depName);
});
});
if (index !== -1) {
var Service = params.services.splice(index, 1)[0];
services[Service.prototype.name] = new Service(widget);
} else {
done = true;
}
}
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(params.session || 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);
});
}
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} [options.position=center] target position
* @param {string} [options.disableDrop=false] whether to trigger the drop action
*/
function dragAndDrop($el, $to, options) {
var position = (options && options.position) || 'center';
var elementCenter = $el.offset();
elementCenter.left += $el.outerWidth()/2;
elementCenter.top += $el.outerHeight()/2;
var toOffset = $to.offset();
toOffset.top += $to.outerHeight()/2;
toOffset.left += $to.outerWidth()/2;
if (position === 'top') {
toOffset.top -= $to.outerHeight()/2;
} else if (position === 'bottom') {
toOffset.top += $to.outerHeight()/2;
} else if (position === 'left') {
toOffset.left -= $to.outerWidth()/2;
} else if (position === 'right') {
toOffset.left += $to.outerWidth()/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.
*
* @param {JQueryElement} $el
* @param {[Widget]} widget the widget on which the rpc should be performed
*/
function removeSrcAttribute($el, widget) {
$el.find('img, iframe[src]').each(function () {
var $el = $(this);
var src = $el.attr('src');
if (src[0] !== '#' && src !== 'about:blank') {
if ($el[0].nodeName === 'IMG') {
$el.attr('src', '#test:' + src);
} else {
$el.attr('data-src', src);
$el.attr('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;
}
});
}
}
/**
* In Phantomjs, there is a crash when calling window.getSelection
* in order for the tests to work, for the specific test that uses it, replace
* the default window.getSelection by a mock
*
* usage:
* QUnit.test('...',function(done){
* var unpatchWindowGetSelection = testUtils.patchWindowGetSelection();
*
* // do something that needs to use window.getSelection()
* assert.strictEqual(....);
*
* // restore the original function
* unpatchWindowGetSelection();
*
* // finish the test
* done();
* })
*
* @returns {function} the unpatch function
*/
function patchWindowGetSelection() {
var originalWindowGetSelection = window.getSelection;
window.getSelection = function () {
return {
removeAllRanges: function () {},
addRange: function (range) {},
getRangeAt: function (index) {
return {
startOffset : 0
};
},
anchorNode: {
parentNode: {
childNodes: [{
outerHTML: "@",
nodeType: 3,
textContent: '@',
}],
},
},
};
};
return function () {
window.getSelection = originalWindowGetSelection;
};
}
/**
* Unpatches a given Class or Object.
*
* @param {Class|Object} target
*/
function unpatch(target) {
var patchID = target.__patchID;
var patch = patches[patchID];
_.each(patch.ownPatchedProps, function (p) {
target[p.key] = p.initialValue;
});
if (target.prototype) {
_.each(patch.otherPatchedProps, function (key) {
delete target.prototype[key];
});
} else {
_.each(patch.otherPatchedProps, function (key) {
delete target[key];
});
}
delete patches[patchID];
delete target.__patchID;
}
// 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,
// before starting the qunit test suite.
// (session.js is in charge of loading the static xml bundle and we also have
// to load xml files that are normally lazy loaded by specific widgets).
return $.when(
session.is_bound,
ajax.loadXML('/web/static/src/xml/dialog.xml', core.qweb)
).then(function () {
setTimeout(function () {
// this is done with the hope that tests are
// only started all together...
QUnit.start();
}, 0);
return {
addMockEnvironment: addMockEnvironment,
createActionManager: createActionManager,
createAsyncView: createAsyncView,
createModel: createModel,
createParent: createParent,
createView: createView,
dragAndDrop: dragAndDrop,
fieldsViewGet: fieldsViewGet,
intercept: intercept,
observe: observe,
patch: patch,
patchWindowGetSelection: patchWindowGetSelection,
removeSrcAttribute: removeSrcAttribute,
triggerKeypressEvent: triggerKeypressEvent,
triggerMouseEvent: triggerMouseEvent,
triggerPositionalMouseEvent: triggerPositionalMouseEvent,
unpatch: unpatch,
};
});
});