* = calendar, hr, hr_holidays, im_livechat, note, sms, snailmail, test_mail, website_livechat, website_slides Task-2759586 closes odoo/odoo#86791 Related: odoo/enterprise#25491 Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
1024 lines
38 KiB
JavaScript
1024 lines
38 KiB
JavaScript
/** @odoo-module **/
|
|
|
|
import BusService from 'bus.BusService';
|
|
|
|
import { MessagingMenuContainer } from '@mail/components/messaging_menu_container/messaging_menu_container';
|
|
import {
|
|
addMessagingToEnv,
|
|
addTimeControlToEnv,
|
|
} from '@mail/env/test_env';
|
|
import { insertAndReplace, replace } from '@mail/model/model_field_command';
|
|
import { ChatWindowService } from '@mail/services/chat_window_service/chat_window_service';
|
|
import { MessagingService } from '@mail/services/messaging/messaging';
|
|
import { makeDeferred } from '@mail/utils/deferred';
|
|
import { DialogService } from '@mail/services/dialog_service/dialog_service';
|
|
import { getMessagingComponent } from '@mail/utils/messaging_component';
|
|
import { nextTick } from '@mail/utils/utils';
|
|
import { DiscussWidget } from '@mail/widgets/discuss/discuss';
|
|
|
|
import core from 'web.core';
|
|
import AbstractStorageService from 'web.AbstractStorageService';
|
|
import RamStorage from 'web.RamStorage';
|
|
import {
|
|
createView,
|
|
mock,
|
|
} from 'web.test_utils';
|
|
import Widget from 'web.Widget';
|
|
import { registry } from '@web/core/registry';
|
|
import { registerCleanup } from "@web/../tests/helpers/cleanup";
|
|
import { getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
|
|
import { createWebClient, getActionManagerServerData } from "@web/../tests/webclient/helpers";
|
|
|
|
import LegacyRegistry from "web.Registry";
|
|
import MockServer from 'web.MockServer';
|
|
|
|
const { App, Component, EventBus } = owl;
|
|
const { afterNextRender } = App;
|
|
const {
|
|
addMockEnvironment,
|
|
patch: legacyPatch,
|
|
unpatch: legacyUnpatch,
|
|
} = mock;
|
|
|
|
const modelDefinitionsPromise = new Promise(resolve => {
|
|
QUnit.begin(() => resolve(getModelDefinitions()));
|
|
});
|
|
|
|
//------------------------------------------------------------------------------
|
|
// Private
|
|
//------------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create a fake object 'dataTransfer', linked to some files,
|
|
* which is passed to drag and drop events.
|
|
*
|
|
* @param {Object[]} files
|
|
* @returns {Object}
|
|
*/
|
|
function _createFakeDataTransfer(files) {
|
|
return {
|
|
dropEffect: 'all',
|
|
effectAllowed: 'all',
|
|
files,
|
|
items: [],
|
|
types: ['Files'],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Object} callbacks
|
|
* @param {function[]} callbacks.init
|
|
* @param {function[]} callbacks.mount
|
|
* @param {function[]} callbacks.destroy
|
|
* @param {function[]} callbacks.return
|
|
* @param {Object) params
|
|
* @param {function) params.afterNextRender
|
|
* @returns {Object} update callbacks
|
|
*/
|
|
function _useChatWindow(callbacks, { afterNextRender }) {
|
|
const {
|
|
mount: prevMount,
|
|
destroy: prevDestroy,
|
|
} = callbacks;
|
|
return Object.assign({}, callbacks, {
|
|
mount: prevMount.concat(async () => {
|
|
// trigger mounting of chat window manager
|
|
await afterNextRender(() => Component.env.services['chat_window']._onWebClientReady());
|
|
}),
|
|
destroy: prevDestroy.concat(() => {
|
|
Component.env.services['chat_window'].destroy();
|
|
}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Object} callbacks
|
|
* @param {function[]} callbacks.init
|
|
* @param {function[]} callbacks.mount
|
|
* @param {function[]} callbacks.destroy
|
|
* @param {function[]} callbacks.return
|
|
* @param {Object) params
|
|
* @param {function) params.afterNextRender
|
|
* @returns {Object} update callbacks
|
|
*/
|
|
function _useDialog(callbacks, { afterNextRender }) {
|
|
const {
|
|
mount: prevMount,
|
|
destroy: prevDestroy,
|
|
} = callbacks;
|
|
return Object.assign({}, callbacks, {
|
|
mount: prevMount.concat(async () => {
|
|
// trigger mounting of dialog manager
|
|
await afterNextRender(() => Component.env.services['dialog']._onWebClientReady());
|
|
}),
|
|
destroy: prevDestroy.concat(() => {
|
|
Component.env.services['dialog'].destroy();
|
|
}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Object} callbacks
|
|
* @param {function[]} callbacks.init
|
|
* @param {function[]} callbacks.mount
|
|
* @param {function[]} callbacks.destroy
|
|
* @param {function[]} callbacks.return
|
|
* @param {Object) params
|
|
* @param {function) params.afterNextRender
|
|
* @return {Object} update callbacks
|
|
*/
|
|
function _useDiscuss(callbacks, { afterNextRender }) {
|
|
const {
|
|
init: prevInit,
|
|
mount: prevMount,
|
|
return: prevReturn,
|
|
} = callbacks;
|
|
let discussWidget;
|
|
const state = {
|
|
discussData: {},
|
|
};
|
|
return Object.assign({}, callbacks, {
|
|
init: prevInit.concat(params => {
|
|
const {
|
|
discuss: discussData = state.discussData
|
|
} = params;
|
|
Object.assign(state, { discussData });
|
|
delete params.discuss;
|
|
}),
|
|
mount: prevMount.concat(async params => {
|
|
const { selector, widget } = params;
|
|
DiscussWidget.prototype._pushStateActionManager = () => {};
|
|
discussWidget = new DiscussWidget(widget, state.discussData);
|
|
await discussWidget.appendTo($(selector));
|
|
}),
|
|
return: prevReturn.concat(result => {
|
|
Object.assign(result, { discussWidget });
|
|
}),
|
|
});
|
|
}
|
|
|
|
//------------------------------------------------------------------------------
|
|
// Public: rendering timers
|
|
//------------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Returns a promise resolved at the next animation frame.
|
|
*
|
|
* @returns {Promise}
|
|
*/
|
|
function nextAnimationFrame() {
|
|
return new Promise(function (resolve) {
|
|
setTimeout(() => requestAnimationFrame(() => resolve()));
|
|
});
|
|
}
|
|
|
|
//------------------------------------------------------------------------------
|
|
// Model definitions setup
|
|
//------------------------------------------------------------------------------
|
|
|
|
export const TEST_USER_IDS = {
|
|
partnerRootId: 2,
|
|
currentPartnerId: 3,
|
|
currentUserId: 2,
|
|
publicPartnerId: 4,
|
|
publicUserId: 3,
|
|
};
|
|
|
|
/**
|
|
* Fetch model definitions from the server then insert fields present in the
|
|
* `mail.model.definitions` registry. Use `addModelNamesToFetch`/`insertModelFields`
|
|
* helpers in order to add models to be fetched, default values to the fields,
|
|
* fields to a model definition.
|
|
*
|
|
* @return {Map<string, Object>} A map from model names to model fields definitions.
|
|
* @see model_definitions_setup.js
|
|
*/
|
|
async function getModelDefinitions() {
|
|
const modelDefinitionsRegistry = registry.category('mail.model.definitions');
|
|
const modelNamesToFetch = modelDefinitionsRegistry.get('modelNamesToFetch');
|
|
const fieldsToInsertRegistry = modelDefinitionsRegistry.category('fieldsToInsert');
|
|
|
|
// fetch the model definitions.
|
|
const formData = new FormData();
|
|
formData.append('csrf_token', core.csrf_token);
|
|
formData.append('model_names_to_fetch', JSON.stringify(modelNamesToFetch));
|
|
const response = await window.fetch('/mail/get_model_definitions', { body: formData, method: 'POST' });
|
|
if (response.status !== 200) {
|
|
throw new Error('Error while fetching required models');
|
|
}
|
|
const modelDefinitions = new Map(Object.entries(await response.json()));
|
|
|
|
for (const [modelName, fields] of modelDefinitions) {
|
|
// insert fields present in the fieldsToInsert registry : if the field
|
|
// exists, update its default value according to the one in the
|
|
// registry; If it does not exist, add it to the model definition.
|
|
const fieldNamesToFieldToInsert = fieldsToInsertRegistry.category(modelName).getEntries();
|
|
for (const [fname, fieldToInsert] of fieldNamesToFieldToInsert) {
|
|
if (fname in fields) {
|
|
fields[fname].default = fieldToInsert.default;
|
|
} else {
|
|
fields[fname] = fieldToInsert;
|
|
}
|
|
}
|
|
// apply default values for date like fields if none was passed.
|
|
for (const fname in fields) {
|
|
const field = fields[fname];
|
|
if (['date', 'datetime'].includes(field.type) && !field.default) {
|
|
const defaultFieldValue = field.type === 'date'
|
|
? () => moment.utc().format('YYYY-MM-DD')
|
|
: () => moment.utc().format("YYYY-MM-DD HH:mm:ss");
|
|
field.default = defaultFieldValue;
|
|
}
|
|
}
|
|
}
|
|
// add models present in the fake models registry to the model definitions.
|
|
const fakeModels = modelDefinitionsRegistry.category('fakeModels').getEntries();
|
|
for (const [modelName, fields] of fakeModels) {
|
|
modelDefinitions.set(modelName, fields);
|
|
}
|
|
return modelDefinitions;
|
|
}
|
|
|
|
async function getInitialServerData() {
|
|
const data = { ...TEST_USER_IDS };
|
|
const modelDefinitions = await modelDefinitionsPromise;
|
|
const recordsToInsertRegistry = registry.category('mail.model.definitions').category('recordsToInsert');
|
|
for (const [modelName, fields] of modelDefinitions) {
|
|
const records = [];
|
|
if (recordsToInsertRegistry.contains(modelName)) {
|
|
// prevent tests from mutating the records.
|
|
records.push(...JSON.parse(JSON.stringify(recordsToInsertRegistry.get(modelName))));
|
|
}
|
|
data[modelName] = { fields, records };
|
|
}
|
|
return data;
|
|
}
|
|
|
|
let pyEnv;
|
|
/**
|
|
* Creates an environment that can be used to setup test data as well as
|
|
* creating data after test start.
|
|
*
|
|
* @returns {Object} An environment that can be used to interact with the mock
|
|
* server (creation, deletion, update of records...)
|
|
*/
|
|
export async function startServer() {
|
|
pyEnv = new Proxy(
|
|
{
|
|
mockServer: new MockServer(await getInitialServerData(), {}),
|
|
...TEST_USER_IDS,
|
|
},
|
|
{
|
|
get(target, name) {
|
|
if (target[name]) {
|
|
return target[name];
|
|
}
|
|
return {
|
|
/**
|
|
* Simulate a 'create' operation on a model.
|
|
*
|
|
* @param {Object[]|Object} values records to be created.
|
|
* @returns {integer[]|integer} array of ids if more than one value was passed,
|
|
* id of created record otherwise.
|
|
*/
|
|
create(values = {}) {
|
|
if (!Array.isArray(values)) {
|
|
values = [values];
|
|
}
|
|
const recordIds = values.map(value => target.mockServer._mockCreate(name, value))
|
|
return recordIds.length === 1 ? recordIds[0] : recordIds;
|
|
},
|
|
/**
|
|
* Simulate a 'search' operation on a model.
|
|
*
|
|
* @param {Array} domain
|
|
* @param {Object} context
|
|
* @returns {integer[]} array of ids corresponding to the given domain.
|
|
*/
|
|
search(domain, context = {}) {
|
|
return target.mockServer._mockSearch(name, [domain], context);
|
|
},
|
|
/**
|
|
* Simulate a 'search_read' operation on a model.
|
|
*
|
|
* @param {Array} domain
|
|
* @param {Object} context
|
|
* @returns {Object[]} array of records corresponding to the given domain.
|
|
*/
|
|
searchRead(domain, context = {}) {
|
|
return target.mockServer._mockSearchRead(name, [], { domain, context });
|
|
},
|
|
/**
|
|
* Simulate an 'unlink' operation on a model.
|
|
*
|
|
* @param {integer[]} ids
|
|
* @returns {boolean} mockServer 'unlink' method always returns true.
|
|
*/
|
|
unlink(ids) {
|
|
return target.mockServer._mockUnlink(name, [ids]);
|
|
},
|
|
/**
|
|
* Simulate a 'write' operation on a model.
|
|
*
|
|
* @param {integer[]} ids ids of records to write on.
|
|
* @param {Object} values values to write on the records matching given ids.
|
|
* @returns {boolean} mockServer 'write' method always returns true.
|
|
*/
|
|
write(ids, values) {
|
|
return target.mockServer._mockWrite(name, [ids, values]);
|
|
},
|
|
};
|
|
},
|
|
set(target, name, value) {
|
|
return target[name] = value;
|
|
},
|
|
},
|
|
);
|
|
registerCleanup(() => pyEnv = undefined);
|
|
return pyEnv;
|
|
}
|
|
|
|
//------------------------------------------------------------------------------
|
|
// Public: test lifecycle
|
|
//------------------------------------------------------------------------------
|
|
|
|
async function beforeEach(self) {
|
|
const data = await getInitialServerData();
|
|
Object.assign(self, {
|
|
data,
|
|
widget: undefined
|
|
});
|
|
|
|
Object.defineProperty(self, 'messaging', {
|
|
get() {
|
|
if (!this.env || !this.env.services.messaging) {
|
|
return undefined;
|
|
}
|
|
return this.env.services.messaging.modelManager.messaging;
|
|
},
|
|
});
|
|
}
|
|
|
|
function getAfterEvent({ messagingBus }) {
|
|
/**
|
|
* Returns a promise resolved after the expected event is received.
|
|
*
|
|
* @param {Object} param0
|
|
* @param {string} param0.eventName event to wait
|
|
* @param {function} param0.func function which, when called, is expected to
|
|
* trigger the event
|
|
* @param {string} [param0.message] assertion message
|
|
* @param {function} [param0.predicate] predicate called with event data.
|
|
* If not provided, only the event name has to match.
|
|
* @param {number} [param0.timeoutDelay=5000] how long to wait at most in ms
|
|
* @returns {Promise}
|
|
*/
|
|
return async function afterEvent({ eventName, func, message, predicate, timeoutDelay = 5000 }) {
|
|
// Set up the timeout to reject if the event is not triggered.
|
|
let timeoutNoEvent;
|
|
const timeoutProm = new Promise((resolve, reject) => {
|
|
timeoutNoEvent = setTimeout(() => {
|
|
let error = message
|
|
? new Error(message)
|
|
: new Error(`Timeout: the event ${eventName} was not triggered.`);
|
|
console.error(error);
|
|
reject(error);
|
|
}, timeoutDelay);
|
|
});
|
|
// Set up the promise to resolve if the event is triggered.
|
|
const eventProm = new Promise(resolve => {
|
|
messagingBus.on(eventName, null, data => {
|
|
if (!predicate || predicate(data)) {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
// Start the function expected to trigger the event after the
|
|
// promise has been registered to not miss any potential event.
|
|
const funcRes = func();
|
|
// Make them race (first to resolve/reject wins).
|
|
await Promise.race([eventProm, timeoutProm]);
|
|
clearTimeout(timeoutNoEvent);
|
|
// If the event is triggered before the end of the async function,
|
|
// ensure the function finishes its job before returning.
|
|
await funcRes;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a new root Component, with the given props, and mounts it on target.
|
|
* Assumes that self.env is set to the correct value.
|
|
* Components created this way are automatically registered for clean up after
|
|
* the test.
|
|
*
|
|
* @param {Object} env the current environment
|
|
* @param {Object} Component the class of the component to create
|
|
* @param {Object} param2
|
|
* @param {Object} [param2.props={}] forwarded to component constructor
|
|
* @param {DOM.Element} param2.target mount target for the component
|
|
* @returns {Component}
|
|
*/
|
|
async function createRootComponent(env, Component, { props = {}, target }) {
|
|
const app = new App(Component, {
|
|
props,
|
|
templates: window.__OWL_TEMPLATES__,
|
|
env,
|
|
test: true,
|
|
});
|
|
// The components must be destroyed before the widget, because the
|
|
// widget might destroy the models before destroying the components,
|
|
// and the components might still rely on messaging (or other) record(s).
|
|
registerCleanup(() => app.destroy());
|
|
let component;
|
|
await afterNextRender(() => {
|
|
component = app.mount(target);
|
|
});
|
|
return component;
|
|
}
|
|
|
|
/**
|
|
* Creates and returns a new root messaging component, based on the given
|
|
* componentName and with the given props, and mounts it on target.
|
|
* Assumes that self.env is set to the correct value.
|
|
* Components created this way are automatically registered for clean up after
|
|
* the test.
|
|
*
|
|
* @param {Object} env the current environment
|
|
* @param {string} componentName the class name of the component to create
|
|
* @param {Object} param2
|
|
* @param {Object} [param2.props={}] forwarded to component constructor
|
|
* @param {DOM.Element} param2.target mount target for the component
|
|
* @returns {Component}
|
|
*/
|
|
async function createRootMessagingComponent(env, componentName, { props = {}, target }) {
|
|
return await createRootComponent(env, getMessagingComponent(componentName), { props, target });
|
|
}
|
|
|
|
function getClick({ afterNextRender }) {
|
|
return async function click(selector) {
|
|
await afterNextRender(() => document.querySelector(selector).click());
|
|
};
|
|
}
|
|
|
|
function getCreateChatterContainerComponent({ afterEvent, env, widget }) {
|
|
return async function createChatterContainerComponent(props, { waitUntilMessagesLoaded = true } = {}) {
|
|
let chatterContainerComponent;
|
|
async function func() {
|
|
chatterContainerComponent = await createRootMessagingComponent(env, "ChatterContainer", {
|
|
props,
|
|
target: widget.el,
|
|
});
|
|
}
|
|
if (waitUntilMessagesLoaded) {
|
|
await afterNextRender(() => afterEvent({
|
|
eventName: 'o-thread-view-hint-processed',
|
|
func,
|
|
message: "should wait until chatter loaded messages after creating chatter container component",
|
|
predicate: ({ hint, threadViewer }) => {
|
|
return (
|
|
hint.type === 'messages-loaded' &&
|
|
threadViewer &&
|
|
threadViewer.chatter &&
|
|
threadViewer.chatter.threadModel === props.threadModel &&
|
|
threadViewer.chatter.threadId === props.threadId
|
|
);
|
|
},
|
|
}));
|
|
} else {
|
|
await func();
|
|
}
|
|
return chatterContainerComponent;
|
|
};
|
|
}
|
|
|
|
function getCreateComposerComponent({ env, modelManager, widget }) {
|
|
return async function createComposerComponent(composer, props) {
|
|
const composerView = modelManager.messaging.models['ComposerView'].create({
|
|
qunitTest: insertAndReplace({
|
|
composer: replace(composer),
|
|
}),
|
|
});
|
|
return await createRootMessagingComponent(env, "Composer", {
|
|
props: { localId: composerView.localId, ...props },
|
|
target: widget.el,
|
|
});
|
|
};
|
|
}
|
|
|
|
function getCreateComposerSuggestionComponent({ env, modelManager, widget }) {
|
|
return async function createComposerSuggestionComponent(composer, props) {
|
|
const composerView = modelManager.messaging.models['ComposerView'].create({
|
|
qunitTest: insertAndReplace({
|
|
composer: replace(composer),
|
|
}),
|
|
});
|
|
await createRootMessagingComponent(env, "ComposerSuggestion", {
|
|
props: { ...props, composerViewLocalId: composerView.localId },
|
|
target: widget.el,
|
|
});
|
|
};
|
|
}
|
|
|
|
function getCreateMessageComponent({ env, modelManager, widget }) {
|
|
return async function createMessageComponent(message) {
|
|
const messageView = modelManager.messaging.models['MessageView'].create({
|
|
message: replace(message),
|
|
qunitTest: insertAndReplace(),
|
|
});
|
|
await createRootMessagingComponent(env, "Message", {
|
|
props: { localId: messageView.localId },
|
|
target: widget.el,
|
|
});
|
|
};
|
|
}
|
|
|
|
function getCreateMessagingMenuComponent({ env, widget }) {
|
|
return async function createMessagingMenuComponent() {
|
|
return await createRootComponent({ env }, MessagingMenuContainer, { target: widget.el });
|
|
};
|
|
}
|
|
|
|
function getCreateNotificationListComponent({ env, modelManager, widget }) {
|
|
return async function createNotificationListComponent({ filter = 'all' } = {}) {
|
|
const notificationListView = modelManager.messaging.models['NotificationListView'].create({
|
|
filter,
|
|
qunitTestOwner: insertAndReplace(),
|
|
});
|
|
await createRootMessagingComponent(env, "NotificationList", {
|
|
props: { localId: notificationListView.localId },
|
|
target: widget.el,
|
|
});
|
|
};
|
|
}
|
|
|
|
function getCreateThreadViewComponent({ afterEvent, env, widget }) {
|
|
return async function createThreadViewComponent(threadView, otherProps = {}, { isFixedSize = false, waitUntilMessagesLoaded = true } = {}) {
|
|
let target;
|
|
if (isFixedSize) {
|
|
// needed to allow scrolling in some tests
|
|
const div = document.createElement('div');
|
|
Object.assign(div.style, {
|
|
display: 'flex',
|
|
'flex-flow': 'column',
|
|
height: '300px',
|
|
});
|
|
widget.el.append(div);
|
|
target = div;
|
|
} else {
|
|
target = widget.el;
|
|
}
|
|
async function func() {
|
|
return createRootMessagingComponent(env, "ThreadView", { props: { localId: threadView.localId, ...otherProps }, target });
|
|
}
|
|
if (waitUntilMessagesLoaded) {
|
|
await afterNextRender(() => afterEvent({
|
|
eventName: 'o-thread-view-hint-processed',
|
|
func,
|
|
message: "should wait until thread loaded messages after creating thread view component",
|
|
predicate: ({ hint, threadViewer }) => {
|
|
return (
|
|
hint.type === 'messages-loaded' &&
|
|
threadViewer.threadView === threadView
|
|
);
|
|
},
|
|
}));
|
|
} else {
|
|
await func();
|
|
}
|
|
};
|
|
}
|
|
|
|
function getOpenDiscuss({ afterNextRender, discussWidget }) {
|
|
return async function openDiscuss() {
|
|
await afterNextRender(() => discussWidget.on_attach_callback());
|
|
// Some changes in the models are made on mount, but these changes don't
|
|
// cause a rerender directly, they cause the model to fetch more data
|
|
// but we cannot wait for that data to come back as the model manager
|
|
// doesn't expose it. This means that in the following microticks, the
|
|
// data will come back from the server and cause a render. The following
|
|
// is a way for us to catch the render cascade caused by the data coming
|
|
// back and wait for it.
|
|
await afterNextRender(() => {
|
|
discussWidget.app.root.render();
|
|
});
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Main function used to make a mocked environment with mocked messaging env.
|
|
*
|
|
* @param {Object} [param0={}]
|
|
* @param {string} [param0.arch] makes only sense when `param0.hasView` is set:
|
|
* the arch to use in createView.
|
|
* @param {Object} [param0.archs]
|
|
* @param {boolean} [param0.autoOpenDiscuss=false] makes only sense when
|
|
* `param0.hasDiscuss` is set: determine whether mounted discuss should be
|
|
* open initially. Deprecated, call openDiscuss() instead.
|
|
* @param {boolean} [param0.debug=false]
|
|
* @param {Object} [param0.data] makes only sense when `param0.hasView` is set:
|
|
* the data to use in createView.
|
|
* @param {Object} [param0.discuss={}] makes only sense when `param0.hasDiscuss`
|
|
* is set: provide data that is passed to discuss widget (= client action) as
|
|
* 2nd positional argument.
|
|
* @param {Object} [param0.env={}]
|
|
* @param {function} [param0.mockFetch]
|
|
* @param {function} [param0.mockRPC]
|
|
* @param {boolean} [param0.hasWebClient=false] if set, use
|
|
* createWebClient
|
|
* @param {boolean} [param0.hasChatWindow=false] if set, mount chat window
|
|
* service.
|
|
* @param {boolean} [param0.hasDiscuss=false] if set, mount discuss app.
|
|
* @param {boolean} [param0.hasTimeControl=false] if set, all flow of time
|
|
* with `env.browser.setTimeout` are fully controlled by test itself.
|
|
* @see addTimeControlToEnv that adds `advanceTime` function in
|
|
* `env.testUtils`.
|
|
* @param {boolean} [param0.hasView=false] if set, use createView to create a
|
|
* view instead of a generic widget.
|
|
* @param {integer} [param0.loadingBaseDelayDuration=0]
|
|
* @param {Deferred|Promise} [param0.messagingBeforeCreationDeferred=Promise.resolve()]
|
|
* Deferred that let tests block messaging creation and simulate resolution.
|
|
* Useful for testing working components when messaging is not yet created.
|
|
* @param {string} [param0.model] makes only sense when `param0.hasView` is set:
|
|
* the model to use in createView.
|
|
* @param {integer} [param0.res_id] makes only sense when `param0.hasView` is set:
|
|
* the res_id to use in createView.
|
|
* @param {Object} [param0.services]
|
|
* @param {Object} [param0.session]
|
|
* @param {Element} [param0.target] if provided, the component will be mounted inside
|
|
* that element (only used if `params0.hasWebClient` is true)
|
|
* @param {Object} [param0.View] makes only sense when `param0.hasView` is set:
|
|
* the View class to use in createView.
|
|
* @param {Object} [param0.viewOptions] makes only sense when `param0.hasView`
|
|
* is set: the view options to use in createView.
|
|
* @param {Object} [param0.waitUntilEvent]
|
|
* @param {String} [param0.waitUntilEvent.eventName]
|
|
* @param {String} [param0.waitUntilEvent.message]
|
|
* @param {function} [param0.waitUntilEvent.predicate]
|
|
* @param {integer} [param0.waitUntilEvent.timeoutDelay]
|
|
* @param {string} [param0.waitUntilMessagingCondition='initialized'] Determines
|
|
* the condition of messaging when this function is resolved.
|
|
* Supported values: ['none', 'created', 'initialized'].
|
|
* - 'none': the function resolves regardless of whether messaging is created.
|
|
* - 'created': the function resolves when messaging is created, but
|
|
* regardless of whether messaging is initialized.
|
|
* - 'initialized' (default): the function resolves when messaging is
|
|
* initialized.
|
|
* To guarantee messaging is not created, test should pass a pending deferred
|
|
* as param of `messagingBeforeCreationDeferred`. To make sure messaging is
|
|
* not initialized, test should mock RPC `mail/init_messaging` and block its
|
|
* resolution.
|
|
* @param {...Object} [param0.kwargs]
|
|
* @throws {Error} in case some provided parameters are wrong, such as
|
|
* `waitUntilMessagingCondition`.
|
|
* @returns {Object}
|
|
*/
|
|
async function start(param0 = {}) {
|
|
// patch _.debounce and _.throttle to be fast and synchronous.
|
|
patchWithCleanup(_, {
|
|
debounce: func => func,
|
|
throttle: func => func,
|
|
});
|
|
let callbacks = {
|
|
init: [],
|
|
mount: [],
|
|
destroy: [],
|
|
return: [],
|
|
};
|
|
const {
|
|
autoOpenDiscuss = false,
|
|
env: providedEnv,
|
|
hasWebClient = false,
|
|
hasChatWindow = false,
|
|
hasDialog = false,
|
|
hasDiscuss = false,
|
|
hasTimeControl = false,
|
|
hasView = false,
|
|
loadingBaseDelayDuration = 0,
|
|
messagingBeforeCreationDeferred = Promise.resolve(),
|
|
target = getFixture(),
|
|
waitUntilEvent,
|
|
waitUntilMessagingCondition = 'initialized',
|
|
} = param0;
|
|
if (!['none', 'created', 'initialized'].includes(waitUntilMessagingCondition)) {
|
|
throw Error(`Unknown parameter value ${waitUntilMessagingCondition} for 'waitUntilMessaging'.`);
|
|
}
|
|
delete param0.autoOpenDiscuss;
|
|
delete param0.env;
|
|
delete param0.hasWebClient;
|
|
delete param0.hasChatWindow;
|
|
delete param0.hasDiscuss;
|
|
delete param0.hasTimeControl;
|
|
delete param0.hasView;
|
|
delete param0.target;
|
|
if (hasChatWindow) {
|
|
callbacks = _useChatWindow(callbacks, { afterNextRender });
|
|
}
|
|
if (hasDialog) {
|
|
callbacks = _useDialog(callbacks, { afterNextRender });
|
|
}
|
|
if (hasDiscuss) {
|
|
callbacks = _useDiscuss(callbacks, { afterNextRender });
|
|
}
|
|
const messagingBus = new EventBus();
|
|
const {
|
|
init: initCallbacks,
|
|
mount: mountCallbacks,
|
|
destroy: destroyCallbacks,
|
|
return: returnCallbacks,
|
|
} = callbacks;
|
|
const { debug = false } = param0;
|
|
initCallbacks.forEach(callback => callback(param0));
|
|
const testSetupDoneDeferred = makeDeferred();
|
|
let env = Object.assign(providedEnv || {});
|
|
env.session = Object.assign(
|
|
{
|
|
is_bound: Promise.resolve(),
|
|
url: s => s,
|
|
},
|
|
env.session
|
|
);
|
|
env.isDebug = env.isDebug || (() => true);
|
|
env = addMessagingToEnv(env);
|
|
if (hasTimeControl) {
|
|
env = addTimeControlToEnv(env);
|
|
}
|
|
|
|
const services = Object.assign({}, {
|
|
bus_service: BusService.extend({
|
|
_beep() {}, // Do nothing
|
|
_poll() {}, // Do nothing
|
|
_registerWindowUnload() {}, // Do nothing
|
|
isOdooFocused() {
|
|
return true;
|
|
},
|
|
updateOption() {},
|
|
}),
|
|
chat_window: ChatWindowService.extend({
|
|
_getParentNode() {
|
|
return document.querySelector(debug ? 'body' : '#qunit-fixture');
|
|
},
|
|
_listenHomeMenu: () => {},
|
|
}),
|
|
dialog: DialogService.extend({
|
|
_getParentNode() {
|
|
return document.querySelector(debug ? 'body' : '#qunit-fixture');
|
|
},
|
|
_listenHomeMenu: () => {},
|
|
}),
|
|
local_storage: AbstractStorageService.extend({ storage: new RamStorage() }),
|
|
messaging: MessagingService.extend({
|
|
// test specific values
|
|
messagingValues: {
|
|
autofetchPartnerImStatus: false,
|
|
disableAnimation: true,
|
|
isQUnitTest: true,
|
|
loadingBaseDelayDuration,
|
|
messagingBus,
|
|
},
|
|
/**
|
|
* Override to ensure tests run in debug mode to catch all potential
|
|
* programming errors and provide better message when they happen.
|
|
*/
|
|
init(...args) {
|
|
this._super(...args);
|
|
this.modelManager.isDebug = true;
|
|
},
|
|
/**
|
|
* Override:
|
|
* - to ensure the test setup is complete before starting otherwise
|
|
* for example the mock server might not be ready yet at init
|
|
* messaging,
|
|
* - to add control on when messaging is created, useful to test
|
|
* spinners and race conditions.
|
|
*
|
|
* @override
|
|
*/
|
|
async start() {
|
|
const _super = this._super.bind(this);
|
|
await testSetupDoneDeferred;
|
|
await messagingBeforeCreationDeferred;
|
|
_super();
|
|
},
|
|
}),
|
|
}, param0.services);
|
|
|
|
if (!param0.data && !param0.serverData) {
|
|
pyEnv = pyEnv || await startServer();
|
|
const data = pyEnv.mockServer.data;
|
|
Object.assign(data, TEST_USER_IDS);
|
|
if (hasWebClient) {
|
|
param0.serverData = { models: data };
|
|
} else {
|
|
param0.data = data;
|
|
}
|
|
}
|
|
|
|
const kwargs = Object.assign({}, param0, {
|
|
archs: Object.assign({}, {
|
|
'mail.message,false,search': '<search/>'
|
|
}, param0.archs),
|
|
debug: param0.debug || false,
|
|
services: Object.assign({}, services, param0.services),
|
|
}, { env });
|
|
let widget;
|
|
let mockServer;
|
|
let testEnv;
|
|
const selector = debug ? 'body' : '#qunit-fixture';
|
|
if (hasView) {
|
|
widget = await createView(kwargs);
|
|
legacyPatch(widget, {
|
|
destroy() {
|
|
destroyCallbacks.forEach(callback => callback({ widget }));
|
|
this._super(...arguments);
|
|
legacyUnpatch(widget);
|
|
}
|
|
});
|
|
} else if (hasWebClient) {
|
|
let serverData;
|
|
if (!kwargs.serverData) {
|
|
serverData = getActionManagerServerData();
|
|
} else {
|
|
serverData = kwargs.serverData;
|
|
delete kwargs.serverData;
|
|
}
|
|
|
|
if (kwargs.actions) {
|
|
const actions = {};
|
|
kwargs.actions.forEach((act) => {
|
|
actions[act.xml_id || act.id] = act;
|
|
});
|
|
Object.assign(serverData.actions, actions);
|
|
delete kwargs.actions;
|
|
}
|
|
|
|
Object.assign(serverData.views, kwargs.archs);
|
|
delete kwargs.archs;
|
|
|
|
Object.assign(serverData.models, kwargs.data);
|
|
delete kwargs.data;
|
|
|
|
const mockRPC = kwargs.mockRPC;
|
|
delete kwargs.mockRPC;
|
|
|
|
if (kwargs.services) {
|
|
const serviceRegistry = kwargs.serviceRegistry = new LegacyRegistry();
|
|
for (const sname in kwargs.services) {
|
|
serviceRegistry.add(sname, kwargs.services[sname]);
|
|
}
|
|
delete kwargs.services;
|
|
}
|
|
|
|
const legacyParams = kwargs;
|
|
legacyParams.withLegacyMockServer = true;
|
|
legacyParams.env = env;
|
|
|
|
widget = await createWebClient({ target, serverData, mockRPC, legacyParams });
|
|
|
|
legacyPatch(widget, {
|
|
destroy() {
|
|
destroyCallbacks.forEach(callback => callback({ widget }));
|
|
legacyUnpatch(widget);
|
|
}
|
|
});
|
|
} else {
|
|
const Parent = Widget.extend({ do_push_state() {} });
|
|
const parent = new Parent();
|
|
mockServer = await addMockEnvironment(parent, kwargs);
|
|
widget = new Widget(parent);
|
|
await widget.appendTo($(selector));
|
|
Object.assign(widget, {
|
|
destroy() {
|
|
delete widget.destroy;
|
|
destroyCallbacks.forEach(callback => callback({ widget }));
|
|
parent.destroy();
|
|
},
|
|
});
|
|
}
|
|
// get the final test env after execution of createView/createWebClient/addMockEnvironment
|
|
testEnv = Component.env;
|
|
// link the pyEnv to the actual mockServer after execution of createView/createWebClient/addMockEnvironment
|
|
if (pyEnv) {
|
|
pyEnv.mockServer = MockServer.currentMockServer;
|
|
mockServer = pyEnv.mockServer;
|
|
}
|
|
const afterEvent = getAfterEvent({ messagingBus });
|
|
let waitUntilEventPromise;
|
|
if (waitUntilEvent) {
|
|
waitUntilEventPromise = afterEvent({ func: () => testSetupDoneDeferred.resolve(), ...waitUntilEvent, });
|
|
} else {
|
|
testSetupDoneDeferred.resolve();
|
|
waitUntilEventPromise = Promise.resolve();
|
|
}
|
|
const result = {
|
|
afterEvent,
|
|
env: testEnv,
|
|
mockServer,
|
|
pyEnv,
|
|
widget,
|
|
};
|
|
const { modelManager } = testEnv.services.messaging;
|
|
registerCleanup(async() => {
|
|
widget.destroy();
|
|
await modelManager.messagingInitializedPromise;
|
|
modelManager.messaging.delete();
|
|
});
|
|
if (waitUntilMessagingCondition === 'created') {
|
|
await modelManager.messagingCreatedPromise;
|
|
}
|
|
if (waitUntilMessagingCondition === 'initialized') {
|
|
await modelManager.messagingCreatedPromise;
|
|
await modelManager.messagingInitializedPromise;
|
|
}
|
|
if (mountCallbacks.length > 0) {
|
|
await Promise.all(mountCallbacks.map(callback => callback({ selector, widget })));
|
|
}
|
|
returnCallbacks.forEach(callback => callback(result));
|
|
const openDiscuss = getOpenDiscuss({ afterNextRender, discussWidget: result.discussWidget });
|
|
if (autoOpenDiscuss) {
|
|
await openDiscuss();
|
|
}
|
|
await waitUntilEventPromise;
|
|
return {
|
|
...result,
|
|
afterNextRender,
|
|
click: getClick({ afterNextRender }),
|
|
createChatterContainerComponent: getCreateChatterContainerComponent({ afterEvent, env: testEnv, widget }),
|
|
createComposerComponent: getCreateComposerComponent({ env: testEnv, modelManager, widget }),
|
|
createComposerSuggestionComponent: getCreateComposerSuggestionComponent({ env: testEnv, modelManager, widget }),
|
|
createMessageComponent: getCreateMessageComponent({ env: testEnv, modelManager, widget }),
|
|
createMessagingMenuComponent: getCreateMessagingMenuComponent({ env: testEnv, widget }),
|
|
createNotificationListComponent: getCreateNotificationListComponent({ env: testEnv, modelManager, widget }),
|
|
createThreadViewComponent: getCreateThreadViewComponent({ afterEvent, env: testEnv, widget }),
|
|
messaging: modelManager.messaging,
|
|
openDiscuss,
|
|
};
|
|
}
|
|
|
|
//------------------------------------------------------------------------------
|
|
// Public: file utilities
|
|
//------------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Drag some files over a DOM element
|
|
*
|
|
* @param {DOM.Element} el
|
|
* @param {Object[]} file must have been create beforehand
|
|
* @see testUtils.file.createFile
|
|
*/
|
|
function dragenterFiles(el, files) {
|
|
const ev = new Event('dragenter', { bubbles: true });
|
|
Object.defineProperty(ev, 'dataTransfer', {
|
|
value: _createFakeDataTransfer(files),
|
|
});
|
|
el.dispatchEvent(ev);
|
|
}
|
|
|
|
/**
|
|
* Drop some files on a DOM element
|
|
*
|
|
* @param {DOM.Element} el
|
|
* @param {Object[]} files must have been created beforehand
|
|
* @see testUtils.file.createFile
|
|
*/
|
|
function dropFiles(el, files) {
|
|
const ev = new Event('drop', { bubbles: true });
|
|
Object.defineProperty(ev, 'dataTransfer', {
|
|
value: _createFakeDataTransfer(files),
|
|
});
|
|
el.dispatchEvent(ev);
|
|
}
|
|
|
|
/**
|
|
* Paste some files on a DOM element
|
|
*
|
|
* @param {DOM.Element} el
|
|
* @param {Object[]} files must have been created beforehand
|
|
* @see testUtils.file.createFile
|
|
*/
|
|
function pasteFiles(el, files) {
|
|
const ev = new Event('paste', { bubbles: true });
|
|
Object.defineProperty(ev, 'clipboardData', {
|
|
value: _createFakeDataTransfer(files),
|
|
});
|
|
el.dispatchEvent(ev);
|
|
}
|
|
|
|
//------------------------------------------------------------------------------
|
|
// Export
|
|
//------------------------------------------------------------------------------
|
|
|
|
export {
|
|
afterNextRender,
|
|
beforeEach,
|
|
createRootMessagingComponent,
|
|
dragenterFiles,
|
|
dropFiles,
|
|
nextAnimationFrame,
|
|
nextTick,
|
|
pasteFiles,
|
|
start,
|
|
};
|