From 51e8f7494fb5b874cdcd4045e67a694c464e011e Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 13 Aug 2015 14:50:12 +0200 Subject: [PATCH] Bug 1169576 - [Messages][NG] Implement Conversation service: method for streaming joined threads and drafts list. r=julien, schung --- .../navigator_moz_mobilemessage.js | 4 +- .../js/conversation/conversation_client.js | 99 ++++++ .../js/conversation/conversation_service.js | 218 ++++++++++-- apps/sms/services/js/drafts.js | 23 +- .../moz_mobile_message_shim.js | 76 ++++- .../conversation/conversation_client_test.js | 143 ++++++++ .../conversation/conversation_service_test.js | 317 ++++++++++++++++++ .../conversation/mock_conversation_client.js | 10 + apps/sms/services/test/unit/drafts_test.js | 20 +- apps/sms/services/test/unit/mock_bridge.js | 1 + apps/sms/services/test/unit/mock_drafts.js | 1 + .../services/test/unit/mock_shared_worker.js | 5 + .../moz_mobile_message_shim_test.js | 60 +++- apps/sms/views/conversation/index.html | 1 - apps/sms/views/inbox/index.html | 4 +- apps/sms/views/inbox/js/startup.js | 24 +- .../sms/views/inbox/test/unit/startup_test.js | 15 +- apps/sms/views/shared/js/app.js | 10 + apps/sms/views/shared/js/bootstrap.js | 2 + .../views/shared/js/localization_helper.js | 21 +- .../views/shared/js/shim_host_bootstrap.js | 6 +- .../test/unit/localization_helper_test.js | 28 +- apps/sms/views/shared/test/unit/mock_app.js | 1 + .../test/unit/mock_broadcast_channel.js | 4 +- shared/js/event_dispatcher.js | 2 +- 25 files changed, 1022 insertions(+), 73 deletions(-) create mode 100644 apps/sms/services/js/conversation/conversation_client.js create mode 100644 apps/sms/services/test/unit/conversation/conversation_client_test.js create mode 100644 apps/sms/services/test/unit/conversation/conversation_service_test.js create mode 100644 apps/sms/services/test/unit/conversation/mock_conversation_client.js create mode 100644 apps/sms/services/test/unit/mock_shared_worker.js diff --git a/apps/sms/desktop-mock/navigator_moz_mobilemessage.js b/apps/sms/desktop-mock/navigator_moz_mobilemessage.js index 91c60e08beff..b6b604d9f836 100644 --- a/apps/sms/desktop-mock/navigator_moz_mobilemessage.js +++ b/apps/sms/desktop-mock/navigator_moz_mobilemessage.js @@ -1262,7 +1262,9 @@ var request = { error: null }; - var threads = messagesDb.threads.slice(); + var threads = messagesDb.threads.slice().sort((threadA, threadB) => { + return threadB.timestamp - threadA.timestamp; + }); var idx = 0; var len, continueCursor; diff --git a/apps/sms/services/js/conversation/conversation_client.js b/apps/sms/services/js/conversation/conversation_client.js new file mode 100644 index 000000000000..b80d0acd8667 --- /dev/null +++ b/apps/sms/services/js/conversation/conversation_client.js @@ -0,0 +1,99 @@ +/* global bridge, + streamClient, + Thread +*/ + +(function(exports) { + 'use strict'; + + const debug = 0 ? + (arg1, ...args) => console.log(`[ConversationClient] ${arg1}`, ...args): + () => {}; + + const mark = 0 ? + (...args) => exports.performance.mark(`[ConversationClient] ${args}`): + () => {}; + + const priv = { + client: Symbol('client'), + appInstanceId: Symbol('appInstanceId') + }; + + /** + * Name of the service that is responsible for managing conversation. + * @type {string} + */ + const SERVICE_NAME = 'conversation-service'; + + /** + * ConversationClient is a wrapper around bridge client connected to the + * bridge service hosted in a Shared Worker. + * @type {Object} + */ + var ConversationClient = { + /** + * Reference to active bridge client instance. + * @type {Client} + */ + [priv.client]: null, + + /** + * Unique identifier of app instance where client resides in. + * @type {string} + */ + [priv.appInstanceId]: null, + + /** + * Initialized conversation service client bridge. + * @param {string} appInstanceId Unique identifier of app instance where + * client resides in. + */ + init(appInstanceId) { + if (!appInstanceId) { + throw new Error('AppInstanceId is required!'); + } + + this[priv.client] = bridge.client({ + service: SERVICE_NAME, + endpoint: new SharedWorker( + '/services/js/conversation/conversation_service.js' + ), + timeout: false + }).plugin(streamClient); + + this[priv.appInstanceId] = appInstanceId; + }, + + /** + * Retrieves all conversations and drafts. + * @param {Function} onConversationRetrieved Callback that is called for + * every retrieved conversation. + * @returns {Promise} Promise is resolved once all conversations are + * retrieved and rejected if conversation stream is closed unexpectedly. + */ + getAllConversations(onConversationRetrieved) { + mark('start retrieving conversations'); + + var getAllConversationsStream = this[priv.client].stream( + 'getAllConversations', this[priv.appInstanceId] + ); + + var index = 0; + getAllConversationsStream.listen((thread) => { + mark(++index, ' conversation retrieved'); + debug('Conversation with id %s is retrieved from stream', thread.id); + + onConversationRetrieved(new Thread(thread)); + }); + + return getAllConversationsStream.closed.then(() => { + mark('all conversations retrieved'); + }).catch((e) => { + console.error('Conversation stream is closed unexpectedly', e); + throw e; + }); + } + }; + + exports.ConversationClient = Object.seal(ConversationClient); +})(window); diff --git a/apps/sms/services/js/conversation/conversation_service.js b/apps/sms/services/js/conversation/conversation_service.js index 45b53f389865..a01cc7e4506d 100644 --- a/apps/sms/services/js/conversation/conversation_service.js +++ b/apps/sms/services/js/conversation/conversation_service.js @@ -1,47 +1,187 @@ -/*global BridgeServiceMixin */ +/*global bridge, + BridgeServiceMixin, + BroadcastChannel, + Draft, + Drafts, + streamClient +*/ 'use strict'; -if (!('BridgeServiceMixin' in self)) { - importScripts('/services/js/bridge_service_mixin.js'); -} +/** + * Type description for the ConversationSummary object that is return from the + * Conversation service. + * @typedef {Object} ConversationSummary + * @property {number} id Unique identifier of the the conversation. + * @property {string} body Short content of the last message in the + * conversation. + * @property {Array.} participants List of the conversation + * participants. + * @property {Date} timestamp Conversation timestamp. + * @property {{ hasUnread: boolean, hasNewError: boolean }} status Conversation + * status that indicates whether conversation has unread messages or/and failed + * messages that user hasn't seen yet. + * @property {string} lastMessageType Type of the last message in the + * conversation. Can be either 'sms' or 'mms'. + * @property {Draft} draft If conversation is draft or has draft, then this + * field contains appropriate draft information, otherwise it's null. + */ + +[ + ['bridge', '/lib/bridge/bridge.js'], + ['streamClient', '/lib/bridge/plugins/stream/client.js'], + ['BridgeServiceMixin', '/services/js/bridge_service_mixin.js'], + ['Drafts', '/services/js/drafts.js'] +].forEach(([dependencyName, dependencyPath]) => { + if (!(dependencyName in self)) { + importScripts(dependencyPath); + } +}); (function(exports) { - const SERVICE_NAME = 'conversation-service'; + const debug = 0 ? + (arg1, ...args) => console.log(`[ConversationService] ${arg1}`, ...args): + () => {}; - const STREAMS = Object.freeze( - ['getAllConversations', 'getMessagesForConversation'] - ); + // We use @-directive to override marker context that is scoped by + // SharedWorker lifetime by default, but we need it to be aligned with the + // main app context. + const mark = 0 ? + (...args) => exports.performance.mark( + `[ConversationService] ${args}@sms.gaiamobile.org` + ): + () => {}; + + const priv = { + mobileMessageClients: Symbol('mobileMessageClients'), + + getMobileMessageClient: Symbol('getMobileMessageClient') + }; + + const SERVICE_CONTRACT = Object.freeze({ + name: 'conversation-service', + + streams: Object.freeze( + ['getAllConversations', 'getMessagesForConversation'] + ), + + methods: Object.freeze([ + 'deleteConversations', 'deleteMessages', 'markConversationsAs', + 'getConversationSummary', 'getMessage', 'findConversationFromAddress' + ]), - const METHODS = Object.freeze([ - 'deleteConversations', 'deleteMessages', 'markConversationsAs', - 'getConversationSummary', 'getMessage', 'findConversationFromAddress' - ]); + events: Object.freeze(['message-change']) + }); - const EVENTS = Object.freeze([ - 'message-change' - ]); + function draftToConversationSummary(draft) { + var body = Array.isArray(draft.content) ? + draft.content.find((content) => typeof content === 'string') : ''; + + return { + id: +draft.id, + participants: Array.isArray(draft.recipients) ? draft.recipients : [], + body: body, + timestamp: new Date(draft.timestamp), + status: { hasUnread: false, hasNewError: false }, + lastMessageType: draft.type || 'sms', + draft: new Draft(draft) + }; + } + + function threadToConversationSummary(thread) { + return { + id: thread.id, + participants: thread.participants, + body: thread.body, + timestamp: thread.timestamp, + status: { hasUnread: thread.unreadCount > 0, hasNewError: false }, + lastMessageType: thread.lastMessageType, + draft: Drafts.byThreadId(thread.id) + }; + } var ConversationService = { + /** + * List of mobileMessageClients mapped to specific application id. + * @type {Map.} + */ + [priv.mobileMessageClients]: null, + /** * Initializes our service using the Service mixin's initService. */ init() { this.initService(); + + this[priv.mobileMessageClients] = new Map(); }, /** * Stream that returns everything needed to display conversations in the * inbox view. - * @param {ServiceStream.} The stream to use in the - * implementation: it will be passed autoamtically by the Bridge library. + * @param {ServiceStream.} serviceStream The stream to + * use in the implementation: it will be passed automatically by the Bridge + * library. + * @param {appInstanceId} appInstanceId Unique id of the app instance + * requesting service. */ - getAllConversations(stream) {}, + getAllConversations(serviceStream, appInstanceId) { + mark('start retrieving conversations'); + + var getThreadsStream = this[priv.getMobileMessageClient]( + appInstanceId + ).stream('getThreads'); + + // If conversation stream is cancelled we should cancel internal threads + // stream as well. + serviceStream.cancel = (reason) => { + debug('getAllConversations stream is cancelled: %s', reason); + return getThreadsStream.cancel(reason); + }; + + var draftsPromise = Drafts.request().then(() => { + // Return draft list that is sorted by timestamp in inverse order. + return Drafts.getAllThreadless().sort( + (draftA, draftB) => draftB.timestamp - draftA.timestamp + ); + }); + + // Assume that we get the data in an inverse sort order. + var index = 0; + getThreadsStream.listen((thread) => { + mark(++index, ' conversation retrieved'); + debug('Retrieved conversation with id %s', thread.id); + + draftsPromise.then((drafts) => { + // Flush drafts that are created earlier than current thread. + while(drafts.length && drafts[0].timestamp >= thread.timestamp) { + serviceStream.write(draftToConversationSummary(drafts.shift())); + } + + serviceStream.write(threadToConversationSummary(thread)); + }); + }); + + return getThreadsStream.closed.then(() => draftsPromise).then( + (drafts) => { + for(var draft of drafts) { + serviceStream.write(draftToConversationSummary(draft)); + } + + mark('All conversations retrieved'); + + serviceStream.close(); + }).catch((e) => { + // Pass error object once the following issue is fixed: + // https://github.com/gaia-components/threads/issues/74 + serviceStream.abort(`[${e.name}] ${e.message || ''}`); + }); + }, /** * Stream that returns messages in a conversation, with all suitable * information to display in the conversation view. * @param {ServiceStream.} The stream to use in the - * implementation: it will be passed autoamtically by the Bridge library. + * implementation: it will be passed automatically by the Bridge library. * @param {Number} conversationId Conversation to retrieve. */ getMessagesForConversation(stream, conversationId) {}, @@ -50,6 +190,7 @@ if (!('BridgeServiceMixin' in self)) { * Returns all information related to a single conversation, to display in * the conversation view. * @param {Number} conversationId Conversation to retrieve. + * @returns {ConversationSummary} */ getConversationSummary(conversationId) {}, @@ -91,14 +232,45 @@ if (!('BridgeServiceMixin' in self)) { * match an existing conversation from. * @returns {Number?} The id of the conversation, or null if not found. */ - findConversationFromAddress(address) {} + findConversationFromAddress(address) {}, + + /** + * Returns client that serves to specified app instance, client is created + * if it's requested for the first time. + * @param {string} appInstanceId Unique identified of the app instance. + */ + [priv.getMobileMessageClient](appInstanceId) { + var mobileMessageClient = this[priv.mobileMessageClients].get( + appInstanceId + ); + + if (!mobileMessageClient) { + mobileMessageClient = bridge.client({ + service: 'moz-mobile-message-shim', + endpoint: new BroadcastChannel( + 'moz-mobile-message-shim-channel-' + appInstanceId + ) + }).plugin(streamClient); + + this[priv.mobileMessageClients].set(appInstanceId, mobileMessageClient); + + debug( + 'Create MobileMessageClient for app instance %s', appInstanceId + ); + } + + return mobileMessageClient; + } }; exports.ConversationService = Object.seal( BridgeServiceMixin.mixin( - ConversationService, - SERVICE_NAME, - { methods: METHODS, streams: STREAMS, events: EVENTS } + ConversationService, SERVICE_CONTRACT.name, SERVICE_CONTRACT ) ); + + // Automatically init service if it's run inside worker. + if (!self.document) { + ConversationService.init(); + } })(self); diff --git a/apps/sms/services/js/drafts.js b/apps/sms/services/js/drafts.js index 6af1a59afdc2..0c9703745065 100644 --- a/apps/sms/services/js/drafts.js +++ b/apps/sms/services/js/drafts.js @@ -5,6 +5,17 @@ */ (function(exports) { 'use strict'; + + [ + ['asyncStorage', '/shared/js/async_storage.js'], + ['EventDispatcher', '/shared/js/event_dispatcher.js'], + ['Utils', '/views/shared/js/utils.js'] + ].forEach(([dependencyName, dependencyPath]) => { + if (!(dependencyName in self)) { + importScripts(dependencyPath); + } + }); + var draftIndex = new Map(); var deferredDraftRequest = null; @@ -115,8 +126,8 @@ // a dataset property. id = +id; - for (var draft of draftIndex.get(null)) { - if (draft.id === id) { + for (var draft of this.getAllThreadless()) { + if (draft.id === id) { return draft; } } @@ -135,6 +146,14 @@ } }, + /** + * Returns list of all thread less drafts. + * @returns {Array.} + */ + getAllThreadless: function () { + return draftIndex.get(null) || []; + }, + /** * clear * diff --git a/apps/sms/services/js/moz_mobile_message/moz_mobile_message_shim.js b/apps/sms/services/js/moz_mobile_message/moz_mobile_message_shim.js index 57c53d066818..2bc1575b917c 100644 --- a/apps/sms/services/js/moz_mobile_message/moz_mobile_message_shim.js +++ b/apps/sms/services/js/moz_mobile_message/moz_mobile_message_shim.js @@ -7,6 +7,14 @@ (function(exports) { 'use strict'; +const debug = 0 ? + (arg1, ...args) => console.log(`[MozMobileMessageShim] ${arg1}`, ...args): + () => {}; + +const mark = 0 ? + (...args) => exports.performance.mark(`[MozMobileMessageShim] ${args}`): + () => {}; + /** * Name of the service for mozMobileMessage API shim. * @type {string} @@ -43,19 +51,38 @@ const STREAMS = Object.freeze(['getThreads', 'getMessages']); var mozMobileMessage = null; +/** + * Clones DOM thread object to plain JS object, so that it's possible to pass it + * via postMessage. Should be removed once bug 1172794 is landed. + * @param {DOMMozMobileMessageThread} thread DOM Message thread instance. + * @returns {Object} + */ +function cloneThread(thread) { + // See nsIDOMMozMobileMessageThread.idl file at: + // http://mxr.mozilla.org/mozilla-central/source/dom/mobilemessage/interfaces + return { + id: thread.id, + body: thread.body, + participants: thread.participants, + timestamp: thread.timestamp, + unreadCount: thread.unreadCount, + lastMessageType: thread.lastMessageType + }; +} + var MozMobileMessageShim = { - init(mobileMessage) { + init(appInstanceId, mobileMessage) { if (!mobileMessage) { return; } - function capitalize(str) { + function capitalize(str) { return str[0].toUpperCase() + str.slice(1); } - var endPoint = new BroadcastChannel(SERVICE_NAME + '-channel'); + var broadcastChannelName = `${SERVICE_NAME}-channel-${appInstanceId}`; mozMobileMessage = mobileMessage; - this.initService(endPoint); + this.initService(new BroadcastChannel(broadcastChannelName)); Object.keys(EVENTS).forEach((event) => { mozMobileMessage.addEventListener( @@ -63,6 +90,11 @@ var MozMobileMessageShim = { this['on' + capitalize(event)].bind(this) ); }); + + debug( + 'Listen incoming connections on "%s" broadcast channel', + broadcastChannelName + ); }, /* Events */ @@ -109,7 +141,7 @@ var MozMobileMessageShim = { * Call platform retrieveMMS API to retrieve the MMS by ID. * @param {Number} id MMS message id. * @returns {Promise.} return void if MMS is retrieved - * successfully or error while failed. + * successfully or error while failed. */ retrieveMMS(id) { return mozMobileMessage.retrieveMMS(id).then((message) => { @@ -145,35 +177,51 @@ var MozMobileMessageShim = { * @param {ServiceStream} stream Channel for returning thread. */ getThreads(stream) { + mark('start retrieving conversations'); + var cursor = null; + var isStreamCancelled = false; + + stream.cancel = (reason) => { + debug('getThreads stream is cancelled: %s', reason); + isStreamCancelled = true; + return Promise.resolve(); + }; // WORKAROUND for bug 958738. We can remove 'try\catch' block once this bug // is resolved try { cursor = mozMobileMessage.getThreads(); } catch(e) { - console.error('Error occurred while retrieving threads: ' + e.name); - stream.abort(); + console.error('Error occurred while retrieving threads:', e); + // Pass error object once the following issue is fixed: + // https://github.com/gaia-components/threads/issues/74 + stream.abort(`[${e.name}] ${e.message || ''}`); return; } + var index = 0; cursor.onsuccess = function onsuccess() { var result = this.result; - if (result) { - // TODO: we might need to track result of write to stop iterating - // through cursor. - stream.write(result); + if (result && !isStreamCancelled) { + mark(++index, ' conversation retrieved'); + stream.write(cloneThread(result)); this.continue(); return; } + mark('all conversations retrieved'); + stream.close(); }; cursor.onerror = function onerror() { - console.error('Reading the database. Error: ' + this.error.name); - stream.abort(); + console.error('Error occurred while reading the database', this.error); + + // Pass error object once the following issue is fixed: + // https://github.com/gaia-components/threads/issues/74 + stream.abort(`[${this.error.name}] ${this.error.message || ''}`); }; }, @@ -218,7 +266,7 @@ var MozMobileMessageShim = { exports.MozMobileMessageShim = Object.seal( BridgeServiceMixin.mixin( MozMobileMessageShim, - SERVICE_NAME, { + SERVICE_NAME, { methods: METHODS, streams: STREAMS, events: Object.keys(EVENTS).map((key) => EVENTS[key]) diff --git a/apps/sms/services/test/unit/conversation/conversation_client_test.js b/apps/sms/services/test/unit/conversation/conversation_client_test.js new file mode 100644 index 000000000000..fb4286fa1ca8 --- /dev/null +++ b/apps/sms/services/test/unit/conversation/conversation_client_test.js @@ -0,0 +1,143 @@ +/*global bridge, + ConversationClient, + MocksHelper, + streamClient, + Thread +*/ + +'use strict'; + +require('/services/test/unit/mock_bridge.js'); +require('/services/test/unit/mock_shared_worker.js'); +require('/services/test/unit/mock_threads.js'); +require('/services/js/conversation/conversation_client.js'); + +var MocksHelperForAttachment = new MocksHelper([ + 'bridge', + 'SharedWorker', + 'streamClient', + 'Thread' +]).init(); + +suite('ConversationClient >', function() { + const APP_ID = 'fake-app-id'; + + var clientStub; + + MocksHelperForAttachment.attachTestHelpers(); + + setup(function() { + clientStub = sinon.stub({ + stream: () => {}, + plugin: () => {} + }); + + clientStub.plugin.returns(clientStub); + + this.sinon.spy(window, 'SharedWorker'); + + this.sinon.stub(bridge, 'client').returns(clientStub); + }); + + test('throws if app instance id is not provided', function() { + assert.throws(() => ConversationClient.init()); + }); + + test('bridge client is correctly initialized', function() { + ConversationClient.init(APP_ID); + + sinon.assert.calledWith( + SharedWorker, + '/services/js/conversation/conversation_service.js' + ); + + sinon.assert.calledOnce(bridge.client); + sinon.assert.calledWith(bridge.client, { + service: 'conversation-service', + endpoint: SharedWorker.lastCall.returnValue, + timeout: false + }); + sinon.assert.calledWith(clientStub.plugin, streamClient); + }); + + suite('getAllConversations', function() { + var streamStub, resolveStream, rejectStream; + setup(function() { + ConversationClient.init(APP_ID); + + streamStub = sinon.stub({ + listen: () => {}, + closed: new Promise((resolve, reject) => { + resolveStream = resolve; + rejectStream = reject; + }) + }); + + clientStub.stream.withArgs('getAllConversations', APP_ID).returns( + streamStub + ); + }); + + test('calls callback on every retrieved item', function() { + var onConversationRetrievedStub = sinon.stub(); + + ConversationClient.getAllConversations(onConversationRetrievedStub); + + sinon.assert.notCalled(onConversationRetrievedStub); + + streamStub.listen.yield({ id: 1, body: 'body' }); + + sinon.assert.calledOnce(onConversationRetrievedStub); + sinon.assert.calledWith( + onConversationRetrievedStub, sinon.match({ id: 1, body: 'body'}) + ); + assert.instanceOf(onConversationRetrievedStub.lastCall.args[0], Thread); + + streamStub.listen.yield({ id: 2, body: 'body2' }); + + sinon.assert.calledTwice(onConversationRetrievedStub); + sinon.assert.calledWith( + onConversationRetrievedStub, sinon.match({ id: 2, body: 'body2'}) + ); + assert.instanceOf(onConversationRetrievedStub.lastCall.args[0], Thread); + }); + + test('is resolved when stream.closed is resolved', function(done) { + var resolveStub = sinon.stub(); + + var streamPromise = ConversationClient.getAllConversations(() => {}).then( + resolveStub + ); + + Promise.resolve().then(() => { + sinon.assert.notCalled(resolveStub); + + resolveStream(); + + return streamPromise; + }).then(() => { + sinon.assert.calledOnce(resolveStub); + }).then(done, done); + }); + + test('is rejected when stream.closed is rejected', function(done) { + var rejectStub = sinon.stub(); + var error = new Error('Error'); + + var streamPromise = ConversationClient.getAllConversations(() => {}).then( + () => { throw new Error('getAllConversations should be rejected'); }, + rejectStub + ); + + Promise.resolve().then(() => { + sinon.assert.notCalled(rejectStub); + + rejectStream(error); + + return streamPromise; + }).catch(() => { + sinon.assert.calledWith(rejectStub, error); + }).then(done, done); + }); + }); +}); diff --git a/apps/sms/services/test/unit/conversation/conversation_service_test.js b/apps/sms/services/test/unit/conversation/conversation_service_test.js new file mode 100644 index 000000000000..5cca4086c576 --- /dev/null +++ b/apps/sms/services/test/unit/conversation/conversation_service_test.js @@ -0,0 +1,317 @@ +/*global bridge, + BroadcastChannel, + ConversationService, + Drafts, + MocksHelper +*/ + +'use strict'; + +require('/services/test/unit/mock_bridge.js'); +require('/services/test/unit/mock_drafts.js'); +require('/services/js/bridge_service_mixin.js'); +require('/views/shared/test/unit/mock_broadcast_channel.js'); +require('/services/js/conversation/conversation_service.js'); + +var MocksHelperForAttachment = new MocksHelper([ + 'bridge', + 'BroadcastChannel', + 'Drafts', + 'Draft', + 'streamClient', + 'streamService' +]).init(); + +suite('ConversationService >', function() { + var serviceStub, mobileMessageClientStub, mobileMessageStreamStub; + + function matchMobileMessageShim(channelName) { + return { + service: 'moz-mobile-message-shim', + endpoint: sinon.match.instanceOf(BroadcastChannel).and( + sinon.match.has('name', channelName) + ) + }; + } + + MocksHelperForAttachment.attachTestHelpers(); + + setup(function() { + serviceStub = sinon.stub({ + method: () => {}, + stream: () => {}, + broadcast: () => {}, + listen: () => {}, + plugin: () => {} + }); + + mobileMessageClientStub = sinon.stub({ + stream: () => {}, + plugin: () => {}, + disconnect: () => {} + }); + mobileMessageClientStub.plugin.returns(mobileMessageClientStub); + + this.sinon.spy(self, 'BroadcastChannel'); + + this.sinon.stub(bridge, 'service').withArgs('conversation-service').returns( + serviceStub + ); + + this.sinon.stub(bridge, 'client').withArgs( + matchMobileMessageShim('moz-mobile-message-shim-channel-1') + ).returns(mobileMessageClientStub); + + ConversationService.init(); + }); + + suite('getAllConversations >', function() { + var serviceStreamStub, resolveMobileMessageStream, + rejectMobileMessageStream, drafts, threadDraft, threads; + + function threadToConversationSummary(thread, threadDraft) { + return { + id: thread.id, + participants: thread.participants, + body: thread.body, + timestamp: thread.timestamp, + status: { hasUnread: thread.unreadCount > 0, hasNewError: false }, + lastMessageType: thread.lastMessageType, + draft: threadDraft || null + }; + } + + function draftToConversationSummary(draft) { + return { + id: draft.id, + participants: draft.recipients, + body: draft.content[0], + timestamp: new Date(draft.timestamp), + status: { hasUnread: false, hasNewError: false }, + lastMessageType: draft.type || 'sms', + draft: draft + }; + } + + setup(function() { + serviceStreamStub = sinon.stub({ + write: () => {}, + close: () => {}, + abort: () => {} + }); + + mobileMessageStreamStub = sinon.stub({ + listen: () => {}, + cancel: () => {}, + closed: new Promise((resolve, reject) => { + resolveMobileMessageStream = resolve; + rejectMobileMessageStream = reject; + }) + }); + + mobileMessageClientStub.stream.withArgs('getThreads').returns( + mobileMessageStreamStub + ); + + drafts = [{ + id: 1, + recipients: ['+1'], + content: ['body1'], + type: 'sms', + timestamp: 1 + }, { + id: 3, + recipients: ['a@abc.xyz'], + content: ['body3'], + type: 'mms', + timestamp: 3 + }]; + + threadDraft = { + id: 100, + recipients: ['+100'], + content: ['body100'], + type: 'sms', + threadId: 5, + timestamp: 100 + }; + + threads = [{ + id: 2, + participants: ['+2'], + body: 'body2', + timestamp: 2, + unreadCount: 0, + lastMessageType: 'sms' + }, { + id: 4, + participants: ['a@abc.xyz'], + body: 'body4', + timestamp: 4, + unreadCount: 1, + lastMessageType: 'mms' + }, { + id: 5, + participants: ['+5'], + body: 'body5', + timestamp: 5, + unreadCount: 0, + lastMessageType: 'sms' + }]; + + // Sort threads in the reverse order. + threads.sort((threadA, threadB) => threadB.timestamp - threadA.timestamp); + + this.sinon.stub(Drafts, 'getAllThreadless').returns(drafts.slice()); + this.sinon.stub(Drafts, 'byThreadId').returns(null); + Drafts.byThreadId.withArgs(5).returns(threadDraft); + }); + + test('spawns mobileMessageClient for every app instance', function() { + // Prepare stub for the second app instance. + var mobileMessageClientStub2 = sinon.stub({ + stream: () => {}, + plugin: () => {}, + disconnect: () => {} + }); + mobileMessageClientStub2.plugin.returns(mobileMessageClientStub2); + mobileMessageClientStub2.stream.withArgs('getThreads').returns( + mobileMessageStreamStub + ); + + bridge.client.withArgs( + matchMobileMessageShim('moz-mobile-message-shim-channel-2') + ).returns(mobileMessageClientStub2); + + // Request stream from app instance with ID = 1. + ConversationService.getAllConversations(serviceStreamStub, 1); + + sinon.assert.calledOnce(bridge.client); + sinon.assert.calledWith( + bridge.client, + matchMobileMessageShim('moz-mobile-message-shim-channel-1') + ); + sinon.assert.calledOnce(mobileMessageClientStub.stream); + sinon.assert.calledWith(mobileMessageClientStub.stream, 'getThreads'); + + // Request stream from app instance with ID = 2. + ConversationService.getAllConversations(serviceStreamStub, 2); + + sinon.assert.calledTwice(bridge.client); + sinon.assert.calledWith( + bridge.client, + matchMobileMessageShim('moz-mobile-message-shim-channel-2') + ); + sinon.assert.calledOnce(mobileMessageClientStub2.stream); + sinon.assert.calledWith(mobileMessageClientStub2.stream, 'getThreads'); + + // Once we created client for the app instance we should reuse for + // consequent requests. + bridge.client.reset(); + + ConversationService.getAllConversations(serviceStreamStub, 1); + sinon.assert.notCalled(bridge.client); + sinon.assert.calledTwice(mobileMessageClientStub.stream); + + serviceStub.stream.withArgs('getAllConversations').yield( + serviceStreamStub, 2 + ); + sinon.assert.notCalled(bridge.client); + sinon.assert.calledTwice(mobileMessageClientStub2.stream); + }); + + test('mobileMessage stream is cancelled if main stream is cancelled', + function() { + ConversationService.getAllConversations(serviceStreamStub, 1); + + serviceStreamStub.cancel('ReAsOn'); + + sinon.assert.calledOnce(mobileMessageStreamStub.cancel); + sinon.assert.calledWith(mobileMessageStreamStub.cancel, 'ReAsOn'); + }); + + test('threads and drafts are returned in the correct order', + function(done) { + var getAllConversationsPromise = ConversationService.getAllConversations( + serviceStreamStub, 1 + ); + + threads.forEach((thread) => { + mobileMessageStreamStub.listen.yield(thread); + }); + + resolveMobileMessageStream(); + + getAllConversationsPromise.then(() => { + sinon.assert.callCount(serviceStreamStub.write, 5); + sinon.assert.callOrder( + serviceStreamStub.write.withArgs( + sinon.match(threadToConversationSummary(threads[0], threadDraft)) + ), + serviceStreamStub.write.withArgs( + sinon.match(threadToConversationSummary(threads[1])) + ), + serviceStreamStub.write.withArgs( + sinon.match(draftToConversationSummary(drafts[1])) + ), + serviceStreamStub.write.withArgs( + sinon.match(threadToConversationSummary(threads[2])) + ), + serviceStreamStub.write.withArgs( + sinon.match(draftToConversationSummary(drafts[0])) + ) + ); + }).then(done, done); + }); + + test('only drafts are returned if there is no threads', function(done) { + var getAllConversationsPromise = ConversationService.getAllConversations( + serviceStreamStub, 1 + ); + + resolveMobileMessageStream(); + + getAllConversationsPromise.then(() => { + sinon.assert.callCount(serviceStreamStub.write, 2); + sinon.assert.callOrder( + serviceStreamStub.write.withArgs( + sinon.match(draftToConversationSummary(drafts[1])) + ), + serviceStreamStub.write.withArgs( + sinon.match(draftToConversationSummary(drafts[0])) + ) + ); + }).then(done, done); + }); + + test('stream is closed when mobileMessageStream is successfully closed', + function(done) { + var getAllConversationsPromise = ConversationService.getAllConversations( + serviceStreamStub, 1 + ); + + resolveMobileMessageStream(); + + getAllConversationsPromise.then(() => { + sinon.assert.called(serviceStreamStub.close); + }, () => { + throw new Error('getAllConversations should always be resolved'); + }).then(done, done); + }); + + test('stream is aborted when mobileMessageStream is unexpectedly closed', + function(done) { + var getAllConversationsPromise = ConversationService.getAllConversations( + serviceStreamStub, 1 + ); + + rejectMobileMessageStream(new Error('Exception')); + + getAllConversationsPromise.then(() => { + sinon.assert.calledWith(serviceStreamStub.abort, '[Error] Exception'); + }, () => { + throw new Error('getAllConversations should always be resolved'); + }).then(done, done); + }); + }); +}); diff --git a/apps/sms/services/test/unit/conversation/mock_conversation_client.js b/apps/sms/services/test/unit/conversation/mock_conversation_client.js new file mode 100644 index 000000000000..8e9c5ca31651 --- /dev/null +++ b/apps/sms/services/test/unit/conversation/mock_conversation_client.js @@ -0,0 +1,10 @@ +/* exported MockConversationClient */ + +(function(exports) { + 'use strict'; + + exports.MockConversationClient = { + init: () => {}, + getAllConversations: () => Promise.resolve() + }; +})(window); diff --git a/apps/sms/services/test/unit/drafts_test.js b/apps/sms/services/test/unit/drafts_test.js index 6bd22b591bb7..671721a0ea37 100644 --- a/apps/sms/services/test/unit/drafts_test.js +++ b/apps/sms/services/test/unit/drafts_test.js @@ -8,8 +8,8 @@ 'use strict'; require('/shared/js/event_dispatcher.js'); -require('/services/js/drafts.js'); require('/views/shared/js/utils.js'); +require('/services/js/drafts.js'); require('/shared/test/unit/mocks/mock_async_storage.js'); require('/views/shared/test/unit/mock_inter_instance_event_dispatcher.js'); @@ -386,6 +386,24 @@ suite('Drafts', function() { }); }); + suite('getAllThreadless()>', function() { + setup(function() { + [ + threadDraft1, + draft1, + draft2 + ].forEach(Drafts.add, Drafts); + }); + + teardown(function() { + Drafts.clear(); + }); + + test('getAllThreadless returns only thread less drafts', function() { + assert.deepEqual(Drafts.getAllThreadless(), [draft1, draft2]); + }); + }); + suite('clear() >', function() { suiteSetup(function() { [threadDraft1, threadDraft2, threadDraft3, threadDraft4].forEach( diff --git a/apps/sms/services/test/unit/mock_bridge.js b/apps/sms/services/test/unit/mock_bridge.js index 684319292596..c14fd57c3af4 100644 --- a/apps/sms/services/test/unit/mock_bridge.js +++ b/apps/sms/services/test/unit/mock_bridge.js @@ -9,4 +9,5 @@ }; exports.MockstreamService = {}; + exports.MockstreamClient = {}; })(window); diff --git a/apps/sms/services/test/unit/mock_drafts.js b/apps/sms/services/test/unit/mock_drafts.js index 4d3578bb5515..10f34d4de017 100644 --- a/apps/sms/services/test/unit/mock_drafts.js +++ b/apps/sms/services/test/unit/mock_drafts.js @@ -13,6 +13,7 @@ var MockDrafts = { store: () => Promise.resolve(), request: () => Promise.resolve(), getAll: () => [], + getAllThreadless: () => [], on: () => {} }; diff --git a/apps/sms/services/test/unit/mock_shared_worker.js b/apps/sms/services/test/unit/mock_shared_worker.js new file mode 100644 index 000000000000..95e498688643 --- /dev/null +++ b/apps/sms/services/test/unit/mock_shared_worker.js @@ -0,0 +1,5 @@ +/*exported MockSharedWorker */ + +'use strict'; + +function MockSharedWorker() {} diff --git a/apps/sms/services/test/unit/moz_mobile_message/moz_mobile_message_shim_test.js b/apps/sms/services/test/unit/moz_mobile_message/moz_mobile_message_shim_test.js index 29ad63fe1224..4b4c8bd4b7de 100644 --- a/apps/sms/services/test/unit/moz_mobile_message/moz_mobile_message_shim_test.js +++ b/apps/sms/services/test/unit/moz_mobile_message/moz_mobile_message_shim_test.js @@ -10,18 +10,22 @@ require('/services/test/unit/mock_bridge.js'); require('/services/test/unit/mock_navigatormoz_sms.js'); +require('/views/shared/test/unit/mock_broadcast_channel.js'); require('/views/shared/js/utils.js'); require('/services/js/bridge_service_mixin.js'); require('/services/js/moz_mobile_message/moz_mobile_message_shim.js'); var MocksHelperForAttachment = new MocksHelper([ 'bridge', + 'BroadcastChannel', 'streamService' ]).init(); suite('MozMobileMessageShim >', function() { var serviceStub; + const APP_ID = 'fake-app-id'; + MocksHelperForAttachment.attachTestHelpers(); suiteSetup(function() { @@ -33,12 +37,13 @@ suite('MozMobileMessageShim >', function() { listen: () => {} }); + sinon.spy(window, 'BroadcastChannel'); sinon.stub(bridge, 'service').returns(serviceStub); sinon.stub(MockNavigatormozMobileMessage, 'addEventListener'); }); setup(function() { - MozMobileMessageShim.init(MockNavigatormozMobileMessage); + MozMobileMessageShim.init(APP_ID, MockNavigatormozMobileMessage); }); test('bridge service is correctly initialized', function() { @@ -50,6 +55,11 @@ suite('MozMobileMessageShim >', function() { serviceStub.listen, sinon.match.instanceOf(BroadcastChannel) ); + + sinon.assert.calledWith( + BroadcastChannel, + 'moz-mobile-message-shim-channel-' + APP_ID + ); }); suite('event broadcast', function() { @@ -163,7 +173,7 @@ suite('MozMobileMessageShim >', function() { ...args ); }); - }); + }); }); test('send', function() { @@ -236,21 +246,45 @@ suite('MozMobileMessageShim >', function() { var cursor; setup(function() { - cursor = {}; + cursor = { + result: { + id: 1, + body: 'body', + participants: ['+1234'], + timestamp: 0, + unreadCount: 0, + lastMessageType: 'sms' + }, + + continue: sinon.stub() + }; + this.sinon.stub(MockNavigatormozMobileMessage, 'getThreads'); }); test('continue', function() { MockNavigatormozMobileMessage.getThreads.returns(cursor); MozMobileMessageShim.getThreads(streamStub); - cursor.result = {}; - cursor.continue = sinon.stub(); + cursor.onsuccess(); sinon.assert.calledWith(streamStub.write, cursor.result); sinon.assert.called(cursor.continue); }); + test('stream cancelled', function() { + MockNavigatormozMobileMessage.getThreads.returns(cursor); + MozMobileMessageShim.getThreads(streamStub); + + streamStub.cancel(); + + cursor.onsuccess(); + + sinon.assert.notCalled(streamStub.write); + sinon.assert.notCalled(cursor.continue); + sinon.assert.called(streamStub.close); + }); + test('done', function() { MockNavigatormozMobileMessage.getThreads.returns(cursor); MozMobileMessageShim.getThreads(streamStub); @@ -261,29 +295,31 @@ suite('MozMobileMessageShim >', function() { }); test('error while retrieving threads', function() { + var error = new Error('retrieving error'); + this.sinon.spy(console, 'error'); - MockNavigatormozMobileMessage.getThreads.throws('retrieving error'); + MockNavigatormozMobileMessage.getThreads.throws(error); MozMobileMessageShim.getThreads(streamStub); sinon.assert.calledWith( - console.error, - 'Error occurred while retrieving threads: retrieving error' + console.error, 'Error occurred while retrieving threads:', error ); - sinon.assert.called(streamStub.abort); + sinon.assert.calledWith(streamStub.abort, '[Error] retrieving error'); }); test('error while reading the database', function() { MockNavigatormozMobileMessage.getThreads.returns(cursor); MozMobileMessageShim.getThreads(streamStub); - cursor.error = { name: 'fake error' }; + cursor.error = new Error('fake error'); this.sinon.spy(console, 'error'); cursor.onerror(); sinon.assert.calledWith( console.error, - 'Reading the database. Error: fake error' + 'Error occurred while reading the database', + cursor.error ); - sinon.assert.called(streamStub.abort); + sinon.assert.calledWith(streamStub.abort, '[Error] fake error'); }); }); diff --git a/apps/sms/views/conversation/index.html b/apps/sms/views/conversation/index.html index 3808fc9f681f..cf553a121bd3 100644 --- a/apps/sms/views/conversation/index.html +++ b/apps/sms/views/conversation/index.html @@ -46,7 +46,6 @@ - diff --git a/apps/sms/views/inbox/index.html b/apps/sms/views/inbox/index.html index dd08951b2632..14829048c20e 100644 --- a/apps/sms/views/inbox/index.html +++ b/apps/sms/views/inbox/index.html @@ -44,7 +44,6 @@ - @@ -74,6 +73,9 @@ + + + diff --git a/apps/sms/views/inbox/js/startup.js b/apps/sms/views/inbox/js/startup.js index bd92ee8001c8..c673185966fa 100644 --- a/apps/sms/views/inbox/js/startup.js +++ b/apps/sms/views/inbox/js/startup.js @@ -1,4 +1,6 @@ -/* global InboxView, +/* global App, + ConversationClient, + InboxView, InterInstanceEventDispatcher, LazyLoader, MessageManager, @@ -29,8 +31,28 @@ }); } + function initShimHost() { + var shimHostIframe = document.querySelector('.shim-host'); + + var promise = shimHostIframe.contentDocument.readyState === 'complete' ? + Promise.resolve() : + new Promise((resolve) => { + shimHostIframe.addEventListener('load', function onLoad() { + shimHostIframe.removeEventListener('load', onLoad); + resolve(); + }); + }); + + return promise.then( + () => shimHostIframe.contentWindow.bootstrap(App.instanceId) + ); + } + exports.Startup = { init() { + initShimHost(); + + ConversationClient.init(App.instanceId); MessageManager.init(); Navigation.init(); InboxView.init(); diff --git a/apps/sms/views/inbox/test/unit/startup_test.js b/apps/sms/views/inbox/test/unit/startup_test.js index 2a3d8bdf19f9..c4f717c18876 100644 --- a/apps/sms/views/inbox/test/unit/startup_test.js +++ b/apps/sms/views/inbox/test/unit/startup_test.js @@ -1,4 +1,6 @@ -/*global InboxView, +/*global App, + ConversationClient, + InboxView, InterInstanceEventDispatcher, LazyLoader, MessageManager, @@ -16,12 +18,16 @@ require('/views/shared/test/unit/mock_navigation.js'); require('/views/shared/test/unit/mock_settings.js'); require('/views/shared/test/unit/mock_inter_instance_event_dispatcher.js'); require('/views/shared/test/unit/mock_inbox.js'); +require('/views/shared/test/unit/mock_app.js'); require('/shared/test/unit/mocks/mock_lazy_loader.js'); require('/services/test/unit/mock_message_manager.js'); +require('/services/test/unit/conversation/mock_conversation_client.js'); require('/views/inbox/js/startup.js'); var MocksHelperForInboxStartup = new MocksHelper([ + 'App', + 'ConversationClient', 'InboxView', 'InterInstanceEventDispatcher', 'LazyLoader', @@ -45,11 +51,18 @@ suite('InboxView Startup', function() { this.sinon.spy(InterInstanceEventDispatcher, 'connect'); this.sinon.stub(InboxView, 'once'); this.sinon.stub(LazyLoader, 'load').returns(Promise.resolve()); + this.sinon.stub(ConversationClient, 'init'); + + var shimHostIframe = document.createElement('iframe'); + shimHostIframe.className = 'shim-host'; + shimHostIframe.src = 'data:text/html,'; + document.body.appendChild(shimHostIframe); Startup.init(); }); test('correctly initializes dependencies', function() { + sinon.assert.calledWith(ConversationClient.init, App.instanceId); sinon.assert.calledOnce(MessageManager.init); sinon.assert.calledOnce(Navigation.init); sinon.assert.calledOnce(InboxView.init); diff --git a/apps/sms/views/shared/js/app.js b/apps/sms/views/shared/js/app.js index 4f5058f03497..929692f705dd 100644 --- a/apps/sms/views/shared/js/app.js +++ b/apps/sms/views/shared/js/app.js @@ -9,6 +9,12 @@ */ const APPLICATION_READY_CLASS_NAME = 'js-app-ready'; + /** + * Unique identifier of the app instance. + * @type {number} + */ + const INSTANCE_ID = Date.now(); + var app = { isReady: function app_isReady() { return document.body.classList.contains(APPLICATION_READY_CLASS_NAME); @@ -55,6 +61,10 @@ attributeFilter: ['class'] }); }.bind(this)); + }, + + get instanceId() { + return INSTANCE_ID; } }; diff --git a/apps/sms/views/shared/js/bootstrap.js b/apps/sms/views/shared/js/bootstrap.js index ed899b1615be..fb054b596ec2 100644 --- a/apps/sms/views/shared/js/bootstrap.js +++ b/apps/sms/views/shared/js/bootstrap.js @@ -1,5 +1,7 @@ /* global Startup */ +(function() { 'use strict'; Startup.init(); +})(self); diff --git a/apps/sms/views/shared/js/localization_helper.js b/apps/sms/views/shared/js/localization_helper.js index 98904b8c3a8e..10bfb3036692 100644 --- a/apps/sms/views/shared/js/localization_helper.js +++ b/apps/sms/views/shared/js/localization_helper.js @@ -3,12 +3,16 @@ 'use strict'; /** - * Look for any iframes and localize them - mozL10n doesn't do this + * Look for attachment iframes and localize them - mozL10n doesn't do this * XXX: remove once bug 1020130 is fixed */ - function localizeIFrames() { - Array.forEach(document.querySelectorAll('iframe'), function(iframe) { - var doc = iframe.contentDocument; + function localizeAttachmentIFrames() { + var attachmentContainers = document.querySelectorAll( + 'iframe.attachment-container' + ); + + Array.forEach(attachmentContainers, function(attachmentContainer) { + var doc = attachmentContainer.contentDocument; doc.documentElement.lang = navigator.mozL10n.language.code; doc.documentElement.dir = navigator.mozL10n.language.direction; navigator.mozL10n.translateFragment(doc.body); @@ -37,14 +41,15 @@ } formatL10nId = JSON.parse(formatL10nId); - + if (isTimeFormatChanged && !formatL10nId.hour) { return; } formatL10nId.hour12 = navigator.mozHour12; - var formatter = - new Intl.DateTimeFormat(navigator.languages, formatL10nId); + var formatter = new Intl.DateTimeFormat( + navigator.languages, formatL10nId + ); var localeData = formatter.format(new Date(+element.dataset.l10nDate)); if (element.hasAttribute('data-l10n-id') && @@ -63,7 +68,7 @@ // This will be called during startup, and every time the language is // changed navigator.mozL10n.ready(function localized() { - localizeIFrames(); + localizeAttachmentIFrames(); localizeDateTime(); }); diff --git a/apps/sms/views/shared/js/shim_host_bootstrap.js b/apps/sms/views/shared/js/shim_host_bootstrap.js index 1f2eb4ff3928..b3e28bf71c02 100644 --- a/apps/sms/views/shared/js/shim_host_bootstrap.js +++ b/apps/sms/views/shared/js/shim_host_bootstrap.js @@ -1,7 +1,9 @@ /* global MozMobileMessageShim */ -(function() { +(function(exports) { 'use strict'; -MozMobileMessageShim.init(navigator.mozMobileMessage); +exports.bootstrap = function(appInstanceId) { + MozMobileMessageShim.init(appInstanceId, navigator.mozMobileMessage); +}; })(self); diff --git a/apps/sms/views/shared/test/unit/localization_helper_test.js b/apps/sms/views/shared/test/unit/localization_helper_test.js index 2c3799862baa..389fc68035c8 100644 --- a/apps/sms/views/shared/test/unit/localization_helper_test.js +++ b/apps/sms/views/shared/test/unit/localization_helper_test.js @@ -35,21 +35,41 @@ suite('LocalizationHelper >', function() { navigator.mozL10n = navigatorMozL10n; }); - test('localizes iframes on l10n.ready', function() { - document.body.appendChild(document.createElement('iframe')); - document.body.appendChild(document.createElement('iframe')); + test('localizes only attachment iframes on l10n.ready', function() { + [ + 'attachment-container', + 'attachment-container', + 'custom' + ].forEach((iframeClassName) => { + var iframe = document.createElement('iframe'); + iframe.className = iframeClassName; + document.body.appendChild(iframe); + }); + + var customIframeDocument = document.querySelector( + 'iframe.custom' + ).contentDocument; + customIframeDocument.documentElement.lang = 'en-US'; + customIframeDocument.documentElement.dir = 'ltr'; navigator.mozL10n.language.code = 'be-BY'; navigator.mozL10n.language.direction = 'rtl'; navigator.mozL10n.ready.yield(); sinon.assert.calledTwice(navigator.mozL10n.translateFragment); - Array.forEach(document.querySelectorAll('iframe'), (iframe) => { + var attachmentContainers = document.querySelectorAll( + 'iframe.attachment-container' + ); + Array.forEach(attachmentContainers, (iframe) => { var doc = iframe.contentDocument; assert.equal(doc.documentElement.lang, 'be-BY'); assert.equal(doc.documentElement.dir, 'rtl'); sinon.assert.calledWith(navigator.mozL10n.translateFragment, doc.body); }); + + // Custom iframe should stay untouched. + assert.equal(customIframeDocument.documentElement.lang, 'en-US'); + assert.equal(customIframeDocument.documentElement.dir, 'ltr'); }); [onL10nReady, onTimeFormatChange].forEach(function(forceUpdateMethod) { diff --git a/apps/sms/views/shared/test/unit/mock_app.js b/apps/sms/views/shared/test/unit/mock_app.js index 42686b0ed83c..a59d734653c6 100644 --- a/apps/sms/views/shared/test/unit/mock_app.js +++ b/apps/sms/views/shared/test/unit/mock_app.js @@ -4,6 +4,7 @@ 'use strict'; exports.MockApp = { + instanceId: 'app-instance-id', whenReady: () => Promise.resolve() }; })(window); diff --git a/apps/sms/views/shared/test/unit/mock_broadcast_channel.js b/apps/sms/views/shared/test/unit/mock_broadcast_channel.js index 294dba3ae024..41ac05e6fc6f 100644 --- a/apps/sms/views/shared/test/unit/mock_broadcast_channel.js +++ b/apps/sms/views/shared/test/unit/mock_broadcast_channel.js @@ -2,7 +2,9 @@ 'use strict'; -function MockBroadcastChannel() {} +function MockBroadcastChannel(name) { + this.name = name; +} MockBroadcastChannel.prototype.addEventListener = () => {}; MockBroadcastChannel.prototype.removeEventListener = () => {}; diff --git a/shared/js/event_dispatcher.js b/shared/js/event_dispatcher.js index 51c3f7923825..940fc7d35d92 100644 --- a/shared/js/event_dispatcher.js +++ b/shared/js/event_dispatcher.js @@ -228,4 +228,4 @@ return target; } }; -})(window); +})(self);