From 618c77259bb831cec0acd1104f80fd97bbd6b22f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Mon, 7 Sep 2020 11:06:36 +0000 Subject: [PATCH] [FIX] mail: allow tests to await specific events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit task-2333915 closes odoo/odoo#57187 X-original-commit: 699106ce65a87f7a24f04e1d8f232b5a6b4e210a Related: odoo/enterprise#13018 Signed-off-by: Alexandre Kühn (aku) Signed-off-by: Sébastien Theys (seb) --- .../components/discuss/tests/discuss_tests.js | 157 +++++++++--------- .../components/message_list/message_list.js | 14 +- addons/mail/static/src/utils/test_utils.js | 130 +++++++++++---- 3 files changed, 192 insertions(+), 109 deletions(-) diff --git a/addons/mail/static/src/components/discuss/tests/discuss_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_tests.js index 87ad3640919..c63ec07520b 100644 --- a/addons/mail/static/src/components/discuss/tests/discuss_tests.js +++ b/addons/mail/static/src/components/discuss/tests/discuss_tests.js @@ -3,7 +3,6 @@ odoo.define('mail/static/src/components/discuss/tests/discuss_tests.js', functio const BusService = require('bus.BusService'); -const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); const { afterEach, afterNextRender, @@ -23,11 +22,12 @@ QUnit.module('discuss_tests.js', { beforeEach(this); this.start = async params => { - const { env, widget } = await start(Object.assign({}, params, { + const { afterEvent, env, widget } = await start(Object.assign({}, params, { autoOpenDiscuss: true, data: this.data, hasDiscuss: true, })); + this.afterEvent = afterEvent; this.env = env; this.widget = widget; }; @@ -1837,29 +1837,19 @@ QUnit.test('restore thread scroll position', async function (assert) { res_id: 12, }); } - const messageFetchChannel11Def = makeDeferred(); - const messageFetchChannel12Def = makeDeferred(); - await afterNextRender(async () => { - await this.start({ - discuss: { - params: { - default_active_id: 'mail.channel_11', - }, + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_11', }, - async mockRPC(route, args) { - const res = await this._super(...arguments); - // domain should be like [['channel_id', 'in', [X]]] with X the channel id - if (route.includes('message_fetch') && args.kwargs.domain[0][2].includes(11)) { - messageFetchChannel11Def.resolve(); - } - if (route.includes('message_fetch') && args.kwargs.domain[0][2].includes(12)) { - messageFetchChannel12Def.resolve(); - } - return res; + }, + waitUntilEvent: { + eventName: 'o-component-message-list-scrolled', + message: "should wait until channel 11 scrolled to its last message", + predicate: ({ threadViewer }) => { + return threadViewer.thread.model === 'mail.channel' && threadViewer.thread.id === 11; }, - }); - // wait until messages are fetched, ignore other renders that are too early - await messageFetchChannel11Def; + }, }); assert.strictEqual( document.querySelectorAll(` @@ -1869,34 +1859,42 @@ QUnit.test('restore thread scroll position', async function (assert) { "should have 25 messages in channel 11" ); - // scroll to top of channel11 - await afterNextRender(() => { - document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`).scrollTop = 0; + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`).scrollTop = 0, + message: "should wait until channel 11 changed its scroll position to top", + predicate: ({ threadViewer }) => { + return threadViewer.thread.model === 'mail.channel' && threadViewer.thread.id === 11; + }, }); assert.strictEqual( document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`).scrollTop, 0, - "should have scrolled to top of thread" + "should have scrolled to top of channel 11", ); - const scrollChannel12Def = makeDeferred(); - const onScroll = () => { - scrollChannel12Def.resolve(); - }; - document.addEventListener('o-message-list-scrolled', onScroll); - // select channel 12 - await afterNextRender(async () => { - document.querySelector(` - .o_DiscussSidebar_groupChannel - .o_DiscussSidebar_item[data-thread-local-id="${ - this.env.models['mail.thread'].find(thread => - thread.id === 12 && - thread.model === 'mail.channel' - ).localId - }"] - `).click(); - // wait until messages are fetched, ignore other renders that are too early - await messageFetchChannel12Def; + // Ensure scrollIntoView of channel 12 has enough time to complete before + // going back to channel 11. Await is needed to prevent the scrollIntoView + // initially planned for channel 12 to actually apply on channel 11. + // task-2333535 + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => { + // select channel 12 + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 12 && + thread.model === 'mail.channel' + ).localId + }"] + `).click(); + }, + message: "should wait until channel 12 scrolled to its last message", + predicate: ({ threadViewer }) => { + return threadViewer.thread.model === 'mail.channel' && threadViewer.thread.id === 12; + }, }); assert.strictEqual( document.querySelectorAll(` @@ -1906,43 +1904,50 @@ QUnit.test('restore thread scroll position', async function (assert) { "should have 24 messages in channel 12" ); - // Ensure scrollIntoView of channel 12 has enough time to complete before - // going back to channel 11. Await is needed to prevent the scrollIntoView - // initially planned for channel 12 to actually apply on channel 11. - // task-2333535 - await scrollChannel12Def; - document.removeEventListener('o-message-list-scrolled', onScroll); - - // select channel 11 - await afterNextRender(() => - document.querySelector(` - .o_DiscussSidebar_groupChannel - .o_DiscussSidebar_item[data-thread-local-id="${ - this.env.models['mail.thread'].find(thread => - thread.id === 11 && - thread.model === 'mail.channel' - ).localId - }"] - `).click() - ); + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => { + // select channel 11 + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 11 && + thread.model === 'mail.channel' + ).localId + }"] + `).click(); + }, + message: "should wait until channel 11 restored its scroll position", + predicate: ({ threadViewer }) => { + return threadViewer.thread.model === 'mail.channel' && threadViewer.thread.id === 11; + }, + }); assert.strictEqual( document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`).scrollTop, 0, "should have recovered scroll position of channel 11 (scroll to top)" ); - // select channel12 - await afterNextRender(() => - document.querySelector(` - .o_DiscussSidebar_groupChannel - .o_DiscussSidebar_item[data-thread-local-id="${ - this.env.models['mail.thread'].find(thread => - thread.id === 12 && - thread.model === 'mail.channel' - ).localId - }"] - `).click() - ); + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => { + // select channel 12 + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 12 && + thread.model === 'mail.channel' + ).localId + }"] + `).click(); + }, + message: "should wait until channel 12 recovered its scroll position", + predicate: ({ threadViewer }) => { + return threadViewer.thread.model === 'mail.channel' && threadViewer.thread.id === 12; + }, + }); const messageList = document.querySelector(` .o_Discuss_thread .o_ThreadView_messageList diff --git a/addons/mail/static/src/components/message_list/message_list.js b/addons/mail/static/src/components/message_list/message_list.js index 17531eabbca..3189b97b627 100644 --- a/addons/mail/static/src/components/message_list/message_list.js +++ b/addons/mail/static/src/components/message_list/message_list.js @@ -333,6 +333,9 @@ class MessageList extends Component { isProcessed = true; } if (isProcessed) { + this.env.messagingBus.trigger('o-component-message-list-thread-cache-changed', { + threadViewer: this.threadView.threadViewer, + }); this.threadView.markComponentHintProcessed(hint); } } @@ -397,6 +400,9 @@ class MessageList extends Component { if (this.props.order === 'asc' && this.props.hasScrollAdjust) { this.el.scrollTop = this.el.scrollHeight - scrollHeight + scrollTop; } + this.env.messagingBus.trigger('o-component-message-list-more-messages-loaded', { + threadViewer: this.threadView.threadViewer, + }); this.threadView.markComponentHintProcessed(hint); } @@ -526,8 +532,12 @@ class MessageList extends Component { if (!this.threadView || !this.threadView.threadViewer) { return; } - this.trigger('o-message-list-scrolled'); - this.threadView.threadViewer.saveThreadCacheScrollPositionsAsInitial(this.el.scrollTop); + const scrollTop = this.el.scrollTop; + this.env.messagingBus.trigger('o-component-message-list-scrolled', { + scrollTop, + threadViewer: this.threadView.threadViewer, + }); + this.threadView.threadViewer.saveThreadCacheScrollPositionsAsInitial(scrollTop); if (!this._isAutoLoadOnScrollActive) { return; } diff --git a/addons/mail/static/src/utils/test_utils.js b/addons/mail/static/src/utils/test_utils.js index 0513eb37672..be15afe751d 100644 --- a/addons/mail/static/src/utils/test_utils.js +++ b/addons/mail/static/src/utils/test_utils.js @@ -321,6 +321,12 @@ function beforeEach(self) { } function afterEach(self) { + if (self.env) { + self.env.bus.off('hide_home_menu', null); + self.env.bus.off('show_home_menu', null); + self.env.bus.off('will_hide_home_menu', null); + self.env.bus.off('will_show_home_menu', null); + } // 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). @@ -404,6 +410,11 @@ async function createRootComponent(self, Component, { props = {}, target }) { * 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']. @@ -438,6 +449,7 @@ async function start(param0 = {}) { hasTimeControl = false, hasView = false, messagingBeforeCreationDeferred = Promise.resolve(), + waitUntilEvent, waitUntilMessagingCondition = 'initialized', } = param0; if (!['none', 'created', 'initialized'].includes(waitUntilMessagingCondition)) { @@ -594,39 +606,95 @@ async function start(param0 = {}) { () => testEnv.messagingBus.trigger('will_show_home_menu') ); - const result = { env: testEnv, mockServer, widget }; - - messagingBeforeCreationDeferred.then(async () => { - /** - * Some models require session data, like locale text direction - * (depends on fully loaded translation). - */ - await env.session.is_bound; - - testEnv.modelManager = new ModelManager(testEnv); - testEnv.modelManager.start(); - /** - * Create the messaging singleton record. - */ - testEnv.messaging = testEnv.models['mail.messaging'].create(); - testEnv.messaging.start().then(() => - testEnv.messagingInitializedDeferred.resolve() - ); - testEnv.messagingCreatedPromise.resolve(); - }); - if (waitUntilMessagingCondition === 'created') { - await testEnv.messagingCreatedPromise; - } - if (waitUntilMessagingCondition === 'initialized') { - await testEnv.messagingInitializedDeferred; - } - - if (mountCallbacks.length > 0) { - await afterNextRender(async () => { - await Promise.all(mountCallbacks.map(callback => callback({ selector, widget }))); + /** + * 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} + */ + const afterEvent = (async ({ 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 => { + testEnv.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; + }); + + const result = { + afterEvent, + env: testEnv, + mockServer, + widget, + }; + + const start = async () => { + messagingBeforeCreationDeferred.then(async () => { + /** + * Some models require session data, like locale text direction + * (depends on fully loaded translation). + */ + await env.session.is_bound; + + testEnv.modelManager = new ModelManager(testEnv); + testEnv.modelManager.start(); + /** + * Create the messaging singleton record. + */ + testEnv.messaging = testEnv.models['mail.messaging'].create(); + testEnv.messaging.start().then(() => + testEnv.messagingInitializedDeferred.resolve() + ); + testEnv.messagingCreatedPromise.resolve(); + }); + if (waitUntilMessagingCondition === 'created') { + await testEnv.messagingCreatedPromise; + } + if (waitUntilMessagingCondition === 'initialized') { + await testEnv.messagingInitializedDeferred; + } + + if (mountCallbacks.length > 0) { + await afterNextRender(async () => { + await Promise.all(mountCallbacks.map(callback => callback({ selector, widget }))); + }); + } + returnCallbacks.forEach(callback => callback(result)); + }; + if (waitUntilEvent) { + await afterEvent(Object.assign({ func: start }, waitUntilEvent)); + } else { + await start(); } - returnCallbacks.forEach(callback => callback(result)); return result; }