Files
odoo_source/addons/web/static/tests/helpers/test_utils_mock.js
T
Xavier Morel 350ed97c45 [FIX] web: async operations should not live in init
Support for the "allow exports" group was implemented by checking the
group in init(), which is sync. Move that check over to willStart
instead.

Also add a default mock for user_has_group (makes it so it always
replies that the user doesn't have the group), and override that
specifically for the group we're interested in in the export tests, so
those tests have access to the Export action / option.

closes odoo/odoo#54254

X-original-commit: 30968ac93373990338c23f49e678d272b8e60c6b
Signed-off-by: Xavier Morel (xmo) <xmo@odoo.com>
2020-07-09 07:23:04 +00:00

770 lines
28 KiB
JavaScript

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.
*/
const AbstractStorageService = require('web.AbstractStorageService');
const AjaxService = require('web.AjaxService');
const basic_fields = require('web.basic_fields');
const Bus = require('web.Bus');
const config = require('web.config');
const core = require('web.core');
const dom = require('web.dom');
const makeTestEnvironment = require('web.test_env');
const MockServer = require('web.MockServer');
const RamStorage = require('web.RamStorage');
const session = require('web.session');
const DebouncedField = basic_fields.DebouncedField;
//------------------------------------------------------------------------------
// Private functions
//------------------------------------------------------------------------------
/**
* Returns a mocked environment to be used by OWL components in tests, with
* requested services (+ ajax, local_storage and session_storage) deployed.
*
* @private
* @param {Object} params
* @param {Bus} [params.bus]
* @param {boolean} [params.debug]
* @param {Object} [params.env]
* @param {Bus} [params.env.bus]
* @param {Object} [params.env.dataManager]
* @param {Object} [params.env.services]
* @param {Object[]} [params.favoriteFilters]
* @param {Object} [params.services]
* @param {Object} [params.session]
* @param {MockServer} [mockServer]
* @returns {Promise<Object>} env
*/
async function _getMockedOwlEnv(params, mockServer) {
params.env = params.env || {};
// build the env
const favoriteFilters = params.favoriteFilters;
const debug = params.debug;
const services = {};
const env = Object.assign({}, params.env, {
bus: params.bus || params.env.bus || new Bus(),
dataManager: Object.assign({
load_action: (actionID, context) => {
return mockServer.performRpc('/web/action/load', {
action_id: actionID,
additional_context: context,
});
},
load_views: (params, options) => {
return mockServer.performRpc('/web/dataset/call_kw/' + params.model, {
args: [],
kwargs: {
context: params.context,
options: options,
views: params.views_descr,
},
method: 'load_views',
model: params.model,
}).then(function (views) {
views = _.mapObject(views, viewParams => {
return fieldsViewGet(mockServer, viewParams);
});
if (favoriteFilters && 'search' in views) {
views.search.favoriteFilters = favoriteFilters;
}
return views;
});
},
load_filters: params => {
if (debug) {
console.log('[mock] load_filters', params);
}
return Promise.resolve([]);
},
}, params.env.dataManager),
services: Object.assign(services, params.env.services),
session: params.env.session || params.session || {},
});
// deploy services into the env
// determine services to instantiate (classes), and already register function services
const servicesToDeploy = {};
for (const name in params.services || {}) {
const Service = params.services[name];
if (Service.constructor.name === 'Class') {
servicesToDeploy[name] = Service;
} else {
services[name] = Service;
}
}
// always deploy ajax, local storage and session storage
if (!servicesToDeploy.ajax) {
const MockedAjaxService = AjaxService.extend({
rpc: mockServer.performRpc.bind(mockServer),
});
services.ajax = new MockedAjaxService(env);
}
const RamStorageService = AbstractStorageService.extend({
storage: new RamStorage(),
});
if (!servicesToDeploy.local_storage) {
services.local_storage = new RamStorageService(env);
}
if (!servicesToDeploy.session_storage) {
services.session_storage = new RamStorageService(env);
}
// deploy other requested services
let done = false;
while (!done) {
const serviceName = Object.keys(servicesToDeploy).find(serviceName => {
const Service = servicesToDeploy[serviceName];
return Service.prototype.dependencies.every(depName => {
return env.services[depName];
});
});
if (serviceName) {
const Service = servicesToDeploy[serviceName];
services[serviceName] = new Service(env);
delete servicesToDeploy[serviceName];
services[serviceName].start();
} else {
const serviceNames = _.keys(servicesToDeploy);
if (serviceNames.length) {
console.warn("Non loaded services:", serviceNames);
}
done = true;
}
}
// wait for asynchronous services to properly start
await new Promise(setTimeout);
return env;
}
/**
* This function is used to mock global objects (session, config...) in tests.
* It is necessary for legacy widgets. It returns a cleanUp function to call at
* the end of the test.
*
* The function could be removed as soon as we do not support legacy widgets
* anymore.
*
* @private
* @param {Object} params
* @param {Object} [params.config] if given, it is used to extend the global
* config,
* @param {Object} [params.session] if given, it is used to extend the current,
* real session.
* @param {Object} [params.translateParameters] if given, it will be used to
* extend the core._t.database.parameters object.
* @returns {function} a cleanUp function to restore everything, to call at the
* end of the test
*/
function _mockGlobalObjects(params) {
// store initial session state (for restoration)
const initialSession = Object.assign({}, session);
// patch session
Object.assign(session, {
getTZOffset() { return 0; },
async user_has_group() { return false; },
}, params.session);
// patch config
let initialConfig;
if ('config' in params) {
initialConfig = Object.assign({}, config);
initialConfig.device = Object.assign({}, config.device);
if ('device' in params.config) {
Object.assign(config.device, params.config.device);
}
if ('debug' in params.config) {
odoo.debug = params.config.debug;
}
}
// patch translate params
let initialParameters;
if ('translateParameters' in params) {
initialParameters = Object.assign({}, core._t.database.parameters);
Object.assign(core._t.database.parameters, params.translateParameters);
}
// build the cleanUp function to restore everything at the end of the test
function cleanUp() {
let key;
if ('session' in params) {
for (key in session) {
delete session[key];
}
}
Object.assign(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);
}
}
return cleanUp;
}
/**
* 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
//------------------------------------------------------------------------------
/**
* 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);
};
}
/**
* 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 {HTMLElement} el
* @param {[function]} rpc
*/
function removeSrcAttribute(el, rpc) {
var nodes;
if (el.nodeName === "#comment") {
return;
}
el = el.nodeType === 8 ? el.nextSibling : el;
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 (rpc) {
rpc(src, []);
}
$(node).trigger('load');
}
}
}
/**
* Add a mock environment to test Owl Components. This function generates a test
* env and sets it on the given Component. It also has several side effects,
* like patching the global session or config objects. It returns a cleanup
* function to call at the end of the test.
*
* @param {Component} Component
* @param {Object} [params]
* @param {Object} [params.actions]
* @param {Object} [params.archs]
* @param {string} [params.currentDate]
* @param {Object} [params.data]
* @param {boolean} [params.debug]
* @param {function} [params.mockRPC]
* @param {number} [params.fieldDebounce=0] the value of the DEBOUNCE attribute
* of fields
* @param {boolean} [params.debounce=true] if false, patch _.debounce to remove
* its behavior
* @param {boolean} [params.throttle=false] by default, _.throttle is patched to
* remove its behavior, except if this params is set to true
* @param {boolean} [params.mockSRC=false] if true, redirect src GET requests to
* the mockServer
* @param {MockServer} [mockServer]
* @returns {Promise<function>} the cleanup function
*/
async function addMockEnvironmentOwl(Component, params, mockServer) {
params = params || {};
// instantiate a mockServer if not provided
if (!mockServer) {
let Server = MockServer;
if (params.mockRPC) {
Server = MockServer.extend({ _performRpc: params.mockRPC });
}
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
const initialDebounceValue = DebouncedField.prototype.DEBOUNCE;
DebouncedField.prototype.DEBOUNCE = params.fieldDebounce || 0;
const initialDOMDebounceValue = dom.DEBOUNCE;
dom.DEBOUNCE = 0;
// patch underscore debounce/throttle functions
const initialDebounce = _.debounce;
if (params.debounce === false) {
_.debounce = function (func) {
return func;
};
}
// fixme: throttle is inactive by default, should we make it explicit ?
const initialThrottle = _.throttle;
if (!('throttle' in params) || !params.throttle) {
_.throttle = function (func) {
return func;
};
}
// make sure images do not trigger a GET on the server
$('body').on('DOMNodeInserted.removeSRC', function (ev) {
let rpc;
if (params.mockSRC) {
rpc = mockServer.performRpc.bind(mockServer);
}
removeSrcAttribute(ev.target, rpc);
});
// mock global objects for legacy widgets (session, config...)
const restoreMockedGlobalObjects = _mockGlobalObjects(params);
// set the test env on owl Component
const env = await _getMockedOwlEnv(params, mockServer);
const originalEnv = Component.env;
Component.env = makeTestEnvironment(env, mockServer.performRpc.bind(mockServer));
// while we have a mix between Owl and legacy stuff, some of them triggering
// events on the env.bus (a new Bus instance especially created for the current
// test), the others using core.bus, we have to ensure that events triggered
// on env.bus are also triggered on core.bus (note that outside the testing
// environment, both are the exact same instance of Bus)
const envBusTrigger = env.bus.trigger;
env.bus.trigger = function () {
core.bus.trigger(...arguments);
envBusTrigger.call(env.bus, ...arguments);
};
// build the clean up function to call at the end of the test
function cleanUp() {
env.bus.destroy();
Object.keys(env.services).forEach(function (s) {
var service = env.services[s];
if (service.destroy && !service.isDestroyed()) {
service.destroy();
}
});
DebouncedField.prototype.DEBOUNCE = initialDebounceValue;
dom.DEBOUNCE = initialDOMDebounceValue;
_.debounce = initialDebounce;
_.throttle = initialThrottle;
// clear the caches (e.g. data_manager, ModelFieldSelector) at the end
// of each test to avoid collisions
core.bus.trigger('clear_cache');
$('body').off('DOMNodeInserted.removeSRC');
$('.blockUI').remove(); // fixme: move to qunit_config in OdooAfterTestHook?
restoreMockedGlobalObjects();
Component.env = originalEnv;
}
return cleanUp;
}
/**
* 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 {Object} [params.bus] the instance of Bus that will be used (in the env)
* @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 {Promise<MockServer>} the instance of the mock server, created by this
* function. It is necessary for createView so that method can call some
* other methods on it.
*/
async function addMockEnvironment(widget, params) {
// log events triggered up if debug flag is true
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);
}
// instantiate mock server
var Server = MockServer;
if (params.mockRPC) {
Server = MockServer.extend({ _performRpc: params.mockRPC });
}
var mockServer = new Server(params.data, {
actions: params.actions,
archs: params.archs,
currentDate: params.currentDate,
debug: params.debug,
widget: widget,
});
// build and set the Owl env on Component
if (!('mockSRC' in params)) { // redirect src rpcs to the mock server
params.mockSRC = true;
}
const cleanUp = await addMockEnvironmentOwl(owl.Component, params, mockServer);
const env = owl.Component.env;
// ensure to clean up everything when the widget will be destroyed
const destroy = widget.destroy;
widget.destroy = function () {
cleanUp();
destroy.call(this, ...arguments);
};
// intercept service/data manager calls and redirect them to the env
intercept(widget, 'call_service', function (ev) {
if (env.services[ev.data.service]) {
var service = env.services[ev.data.service];
const result = service[ev.data.method].apply(service, ev.data.args || []);
ev.data.callback(result);
}
});
intercept(widget, 'load_action', async ev => {
const action = await env.dataManager.load_action(ev.data.actionID, ev.data.context);
ev.data.on_success(action);
});
intercept(widget, "load_views", async ev => {
const params = {
model: ev.data.modelName,
context: ev.data.context,
views_descr: ev.data.views,
};
const views = await env.dataManager.load_views(params, ev.data.options);
if ('search' in views && params.favoriteFilters) {
views.search.favoriteFilters = params.favoriteFilters;
}
ev.data.on_success(views);
});
intercept(widget, "get_session", ev => {
ev.data.callback(session);
});
intercept(widget, "load_filters", async ev => {
const filters = await env.dataManager.load_filters(ev.data);
ev.data.on_success(filters);
});
// make sure all other Odoo events bubbling up are intercepted
Object.keys(params.intercepts || {}).forEach(function (name) {
intercept(widget, name, params.intercepts[name]);
});
return mockServer;
}
/**
* 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
// exception: 'now' uses fake date as reference
Date.now = function () {
var date = new NativeDate();
var time = date.getTime();
time -= timeInterval;
return time;
};
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;
}
window.originalSetTimeout = window.setTimeout;
function patchSetTimeout() {
var original = window.setTimeout;
var self = this;
window.setTimeout = function (handler, delay) {
console.log("calling setTimeout on " + (handler.name || "some function") + "with delay of " + delay);
console.trace();
var handlerArguments = Array.prototype.slice.call(arguments, 1);
return original(function () {
handler.bind(self, handlerArguments)();
console.log('after doing the action of the setTimeout');
}, delay);
};
return function () {
window.setTimeout = original;
};
}
return {
addMockEnvironment: addMockEnvironment,
fieldsViewGet: fieldsViewGet,
addMockEnvironmentOwl: addMockEnvironmentOwl,
intercept: intercept,
patchDate: patchDate,
patch: patch,
unpatch: unpatch,
patchSetTimeout: patchSetTimeout,
};
});