/*jshint node: true, browser: true */ 'use strict'; function Email(client) { this.client = client.scope({ searchTimeout: 20000 }); } module.exports = Email; Email.EMAIL_ORIGIN = 'app://email.gaiamobile.org'; var Selector = { setupNameInput: 'cards-setup-account-info .sup-info-name', setupEmailInput: 'cards-setup-account-info .sup-info-email', nextButton: 'cards-setup-account-info .sup-info-next-btn', prefsNextButton: 'cards-setup-account-prefs .sup-info-next-btn', manualSetupNameInput: '.sup-manual-form .sup-info-name', manualSetupEmailInput: '.sup-manual-form .sup-info-email', manualSetupImapPasswordInput: '.sup-manual-form .sup-manual-composite-password', manualSetupImapUsernameInput: '.sup-manual-form .sup-manual-composite-username', manualSetupImapHostnameInput: '.sup-manual-form .sup-manual-composite-hostname', manualSetupImapPortInput: '.sup-manual-form .sup-manual-composite-port', manualSetupImapSocket: '.sup-manual-form .sup-manual-composite-socket', manualSetupSmtpUsernameInput: '.sup-manual-form .sup-manual-smtp-username', manualSetupSmtpHostnameInput: '.sup-manual-form .sup-manual-smtp-hostname', manualSetupSmtpPasswordInput: '.sup-manual-form .sup-manual-smtp-password', manualSetupSmtpPortInput: '.sup-manual-form .sup-manual-smtp-port', manualSetupSmtpSocket: '.sup-manual-form .sup-manual-smtp-socket', manualNextButton: 'cards-setup-manual-config .sup-manual-next-btn', msgDownBtn: 'cards-message-reader .msg-down-btn', msgListScrollOuter: 'cards-message-list .msg-list-scrollouter', editMode: 'cards-message-list .msg-edit-btn', editModeCheckBoxes: 'cards-message-list .msg-header-item[data-index="0"] label.pack-checkbox', editModeTrash: 'cards-message-list button.msg-delete-btn', msgUpBtn: 'cards-message-reader .msg-up-btn', msgEnvelopeSubject: 'cards-message-reader .msg-envelope-subject', showMailButton: 'cards-setup-done .sup-show-mail-btn', confirmDialogOkButton: '.confirm-dialog-form .confirm-dialog-ok', manualConfigButton: '.scrollregion-below-header .sup-manual-config-btn', composeButton: '.msg-list-header .msg-compose-btn', composeEmailContainer: 'cards-compose .cmp-to-container', composeEmailInput: 'cards-compose .cmp-addr-text', composeSubjectInput: 'cards-compose .cmp-subject-text', composeBodyInput: 'cards-compose .cmp-body-text', composeSendButton: 'cards-compose .cmp-send-btn', composeBackButton: 'cards-compose .cmp-back-btn', composeDraftDiscard: '#cmp-draft-discard', composeDraftSave: '#cmp-draft-save', composeErrorMessage: 'cards-compose .cmp-error-message', refreshButton: '.card.center .msg-refresh-btn', messageHeaderItem: '.msg-vscroll-container .msg-header-item', cardMessageReader: 'cards-message-reader', currentCardInputs: '.card.center input[type="text"]', replyMenuButton: '.msg-reply-btn', replyMenu: '.msg-reply-menu', replyMenuReply: '.msg-reply-menu-reply', replyMenuForward: '.msg-reply-menu-forward', replyMenuAll: '.msg-reply-menu-reply-all', searchTextTease: '.msg-search-text-tease', searchCard: 'cards-message-list-search', folderListButton: '.msg-list-header .msg-folder-list-btn', folderListCloseButton: 'cards-folder-picker .fld-header-back', folderListContents: 'cards-folder-picker .fld-acct-scrollinner', settingsButton: '.fld-nav-toolbar', settingsDoneButton: 'cards-settings-main [data-l10n-id="settings-done"]', addAccountButton: 'cards-settings-main .tng-account-add', accountListButton: '.fld-acct-header', accountListContainer: '.fld-accountlist-container', settingsMainAccountItems: '.tng-accounts-container .tng-account-item', syncIntervalSelect: '.tng-account-check-interval ', // Checkboxes are weird: hidden to marionette, but the associated label // is clickable and does the job. notifyEmailCheckbox: '.tng-notify-mail-label', accountSettingsBackButton: 'cards-settings-account .tng-back-btn', localDraftsItem: '.fld-folders-container a[data-type=localdrafts]', outboxItem: '.fld-folders-container a[data-type=outbox]', outboxItemSyncIcon: '.msg-header-syncing-section', msgLastSync: '.msg-last-synced-value', toaster: 'section[role="status"]' }; Email.prototype = { /** * Send some emails and then receive them. * * @param {Array} messages list of messages with to, subject, and body. * @param {Number} [messageSyncIndex] the index into the list of messages in * the message list that indicates synchronization is complete. Uses the last * index in messages by default. */ sendAndReceiveMessages: function(messages, messageSyncIndex) { if (messageSyncIndex === undefined) { messageSyncIndex = messages.length - 1; } messages.forEach(function(message) { this.tapCompose(); this.typeTo(message.to); this.typeSubject(message.subject); this.typeBody(message.body); this.tapSend(); }.bind(this)); this.tapRefreshButton(); this.waitForSynchronized(messageSyncIndex); }, waitForToaster: function() { var toaster = this.client.helper.waitForElement(Selector.toaster); this.client.helper.waitForElementToDisappear(toaster); }, get msgDownBtn() { return this.client.helper.waitForElement(Selector.msgDownBtn); }, get msgListScrollOuter() { return this.client.helper.waitForElement(Selector.msgListScrollOuter); }, get msgUpBtn() { return this.client.helper.waitForElement(Selector.msgUpBtn); }, /** * @param {boolean} up whether we're advancing up or down. */ advanceMessageReader: function(up) { var el = up ? this.msgUpBtn : this.msgDownBtn; el.click(); this.waitForMessageReader(); }, getMessageReaderSubject: function() { return this.client.helper .waitForElement(Selector.msgEnvelopeSubject) .text(); }, getComposeTo: function() { var container = this.client.helper.waitForElement(Selector.composeEmailContainer); var text = container.text(); return text; }, getComposeErrorMessage: function() { return this.client.helper .waitForElement(Selector.composeErrorMessage) .text(); }, getLastSyncText: function() { return this.client.helper .waitForElement(Selector.msgLastSync) .text(); }, manualSetupImapEmail: function(server, finalActionName) { // setup a IMAP email account var email = server.imap.username + '@' + server.imap.hostname; // wait for the setup page is loaded this._setupTypeName(server.imap.username); this._setupTypeEmail(email); this._waitForElementNoTransition(Selector.manualConfigButton).tap(); this._waitForTransitionEnd('setup_manual_config'); this._manualSetupTypeImapUsername(server.imap.username); this._manualSetupTypeImapHostname(server.imap.hostname); this._manualSetupTypeImapPassword(server.imap.password); this._manualSetupTypeImapPort(server.imap.port); this._manualSetupUpdateSocket('manualSetupImapSocket'); this._manualSetupTypeSmtpUsername(server.smtp.username); this._manualSetupTypeSmtpHostname(server.smtp.hostname); this._manualSetupTypeSmtpPassword(server.smtp.password); this._manualSetupTypeSmtpPort(server.smtp.port); this._manualSetupUpdateSocket('manualSetupSmtpSocket'); this._finishSetup(Selector.manualNextButton, finalActionName); }, _finishSetup: function(nextSelector, finalActionName) { this._tapNext(nextSelector); this._waitForElementNoTransition(Selector.prefsNextButton); this._tapNext(Selector.prefsNextButton, 'setup_done'); this._waitForElementNoTransition(Selector.showMailButton); this.client.helper .waitForElement(Selector.showMailButton) .tap(); return this[finalActionName || 'waitForMessageList'](); }, // Waits for confirm dialog to show up, then clicks OK to confirm // going to setting up a new account after triggering email launch // from an activity. confirmWantAccount: function() { this.client.switchToFrame(); this.client.apps.switchToApp(Email.EMAIL_ORIGIN); this.client.helper.waitForElement(Selector.confirmDialogOkButton).tap(); this.client.helper.waitForElement(Selector.manualConfigButton); this.client.helper.waitForElement(Selector.manualConfigButton).tap(); }, tapFolderListButton: function() { this._tapSelector(Selector.folderListButton); this._waitForElementNoTransition(Selector.settingsButton); this._waitForTransitionEnd('folder_picker'); this.client.helper.waitForElement(Selector.folderListContents); }, tapFolderListCloseButton: function() { this._tapSelector(Selector.folderListCloseButton); this.waitForMessageList(); }, tapAccountListButton: function() { // XXX: Workaround util http://bugzil.la/912873 is fixed. // Wait for 500ms to let the element be clickable this.client.helper.wait(500); this.client.helper.waitForElement(Selector.accountListButton).tap(); this.client.helper.waitForElement(Selector.accountListContainer); }, tapLocalDraftsItem: function() { // we should already be looking at the folder list, no need to wait. this._waitForElementNoTransition(Selector.localDraftsItem).tap(); // clicking that transitions us back to the message list; wait for us // to get there. this._waitForTransitionEnd('message_list'); }, tapOutboxItem: function() { this._waitForElementNoTransition(Selector.outboxItem).tap(); this._waitForTransitionEnd('message_list'); }, getOutboxItemSyncIconForIndex: function(index) { var header = this.getHeaderAtIndex(index); var iconEl = header.findElement(Selector.outboxItemSyncIcon); var className = iconEl.getAttribute('className'); if (/-syncing$/.test(className)) { return 'syncing'; } else if (/-error$/.test(className)) { return 'error'; } else { return ''; } }, switchAccount: function(number) { var accountSelector = '.fld-accountlist-container ' + 'a:nth-child(' + number + ')'; this.client.helper .waitForElement(accountSelector) .tap(); this._waitForTransitionEnd('folder_picker'); }, tapSettingsButton: function() { this.client.helper .waitForElement(Selector.settingsButton) .tap(); this._waitForTransitionEnd('settings_main'); }, tapSettingsDoneButton: function() { this.client.helper .waitForElement(Selector.settingsDoneButton) .tap(); this._waitForTransitionEnd('folder_picker'); }, tapSettingsAccountIndex: function(index) { var elements = this.client.findElements(Selector.settingsMainAccountItems); this.client.helper.waitForElement(elements[index]).tap(); this._waitForTransitionEnd('settings_account'); }, tapAddAccountButton: function() { this.client.helper .waitForElement(Selector.addAccountButton) .tap(); this._waitForTransitionEnd('setup_account_info'); }, tapNotifyEmailCheckbox: function() { this._tapSelector(Selector.notifyEmailCheckbox); }, tapAccountSettingsBackButton: function() { this.client.helper .waitForElement(Selector.accountSettingsBackButton) .tap(); this._waitForTransitionEnd('settings_main'); }, tapCompose: function() { this._tapSelector(Selector.composeButton); this.waitForCompose(); }, typeTo: function(email) { this.client.helper .waitForElement(Selector.composeEmailInput) .sendKeys(email); }, typeSubject: function(subject) { this.client.helper .waitForElement(Selector.composeSubjectInput) .sendKeys(subject); }, typeBody: function(string) { var bodyInput = this.client. findElement(Selector.composeBodyInput); bodyInput.click(); bodyInput.sendKeys(string); }, /** * Retrieve the textual representation of the contenteditable that is our * compose area using the app logic that does this for when we save and * send the draft. Using innerHTML would not be realistic and textContent * over-simplifies our newline handling as well as risking oddities in the * future if we inline various affordances proposed by UX. */ getComposeBody: function() { return this.client.executeScript(function() { var cards = window.wrappedJSObject.require('cards'), card = cards._cardStack[cards.activeCardIndex]; if (card.nodeName.toLowerCase() !== 'cards-compose') { throw new Error('active card should be compose!'); } return card.fromEditor(); }, []); }, /** * Enters edit mode. * Waits for an edit checkbox to appear. */ editMode: function() { this.client.helper .waitForElement(Selector.editMode) .tap(); this.client.helper .waitForElement(Selector.editModeCheckBoxes); }, /** * Returns the edit mode checkboxes. */ editModeCheckboxes: function() { var elements = this.client.findElements(Selector.editModeCheckBoxes); return elements; }, isElementDisabled: function(selector) { var client = this.client; client.helper.waitForElement(selector); client.waitFor(function() { return client.executeScript(function(selector) { var doc = window.wrappedJSObject.document, selectNode = doc.querySelector(selector); return selectNode.disabled; }, [selector]); }); }, /** * Taps the trash button in edit mode. */ isEditModeTrashDisabled: function() { this.isElementDisabled(Selector.editModeTrash); }, abortCompose: function(cardId) { this._waitForElementNoTransition(Selector.composeBackButton).tap(); this._waitForElementNoTransition(Selector.composeDraftDiscard).tap(); this._waitForTransitionEnd(cardId); }, /** * Returns the visible input elements for the current card. */ getVisibleCardInputs: function() { var elements = this.client.findElements(Selector.currentCardInputs) .filter(function(element) { return element.displayed(); }); return elements; }, saveLocalDrafts: function() { this._waitForElementNoTransition(Selector.composeBackButton).tap(); this._waitForElementNoTransition(Selector.composeDraftSave).tap(); this._waitForTransitionEnd('message_list'); }, tapSend: function() { var client = this.client; /* * We cannot tap the Selector.composeSendButton element with (0, 0) offset, * because the attachment button covers the left side of it a little bit. * * We could refer the css style in: * https://github.com/mozilla-b2g/gaia/blob/master/shared/style/headers.css#L117 * And the patch in http://bugzil.la/907061 make us skip the issue luckily. * * We discuss and fix the issue in http://bugzil.la/907092 */ client.helper .waitForElement(Selector.composeSendButton) .tap(); // wait for being in the email list page this._waitForElementNoTransition(Selector.refreshButton); this.waitForMessageList(); }, tapRefreshButton: function() { this.client.helper .waitForElement(Selector.refreshButton) .tap(); }, tapSearchArea: function() { this.client.helper .waitForElement(Selector.searchTextTease) .sendKeys('a'); var client = this.client; client.waitFor(function() { return client.executeScript(function(selector) { var doc = window.wrappedJSObject.document, selectNode = doc.querySelector(selector); // Synthesize an event since focus does not work // through marionette API var event = document.createEvent('Event'); event.initEvent('focus', true, true); selectNode.dispatchEvent(event); return true; }, [Selector.searchTextTease]); }); this.client.helper .waitForElement(Selector.searchCard); this._waitForTransitionEnd('message_list_search'); }, waitForMessageList: function() { this._waitForTransitionEnd('message_list'); }, waitForMessageReader: function() { this._waitForTransitionEnd('message_reader'); }, waitForCompose: function() { this._waitForTransitionEnd('compose'); }, waitForSynchronized: function(index) { this.getHeaderAtIndex(index); }, launch: function() { var client = this.client; client.apps.launch(Email.EMAIL_ORIGIN); client.apps.switchToApp(Email.EMAIL_ORIGIN); // wait for the document body to know we're really launched client.helper.waitForElement('body'); }, close: function() { var client = this.client; client.apps.close(Email.EMAIL_ORIGIN); }, getHeaderAtIndex: function(index) { var element = this.client.findElement(Selector.messageHeaderItem + '[data-index="' + index + '"]'); return this.client.helper.waitForElement(element); }, tapEmailAtIndex: function(index) { var element = this.getHeaderAtIndex(index); element.tap(); this.waitForMessageReader(); }, tapEmailBySubject: function(subject, cardId) { // The emails may not be present in the list yet. So keep checking until // we see one. Then tap on it. this.client.waitFor(function() { var element = this.getEmailBySubject(subject); if (!element) { return false; } element.tap(); this._waitForTransitionEnd(cardId); return true; }.bind(this)); }, getMessageCount: function() { return this.client.findElements(Selector.messageHeaderItem).length; }, getEmailBySubject: function(subject) { var messageHeaders = this.client.findElements(Selector.messageHeaderItem), messageHeadersLength = messageHeaders.length, element; for (var i = 0; i < messageHeadersLength; i++) { var header = this.client.helper .waitForChild(messageHeaders[i], '.msg-header-subject') .text(); if (header === subject) { element = messageHeaders[i]; } } return element; }, /** * Opens the reply menu and selects 'reply', 'all', or 'forward'. */ tapReply: function(mode) { var client = this.client; // open the reply menu client.helper.waitForElement(Selector.replyMenuButton).tap(); client.helper.waitForElement(Selector.replyMenu); // select the appropriate option var whichButton; switch (mode) { case 'all': whichButton = Selector.replyMenuAll; break; case 'forward': whichButton = Selector.replyMenuForward; break; case 'reply': whichButton = Selector.replyMenuReply; break; default: whichButton = Selector.replyMenuReply; break; } client.helper.waitForElement(whichButton).tap(); this._waitForTransitionEnd('compose'); }, setSyncIntervalSelectValue: function(value) { return this._setSelectValue(Selector.syncIntervalSelect, value); }, // TODO: switch to https://github.com/mozilla-b2g/marionette-plugin-forms // once this bug is fixed: // https://bugzilla.mozilla.org/show_bug.cgi?id=915324 _setSelectValue: function(selector, value) { var client = this.client; client.waitFor(function() { return client.executeScript(function(selector, value) { var doc = window.wrappedJSObject.document, selectNode = doc.querySelector(selector); selectNode.value = value; // Synthesize an event since changing the value on its own does // not trigger change listeners. var event = document.createEvent('Event'); event.initEvent('change', true, true); selectNode.dispatchEvent(event); return true; }, [selector, value]); }); }, _onTransitionEndScriptTimeout: function(cardId) { var result = this.client.executeScript(function(cardId) { var cards = window.wrappedJSObject.require('cards'), htmlCache = window.wrappedJSObject.require('html_cache'), cardNode = cards._cardStack[cards.activeCardIndex]; return { cardNode: !!cardNode, centered: cardNode && cardNode.classList.contains('center'), correctId: cardNode && htmlCache.nodeToKey(cardNode) === cardId, eventsClear: !cards._eatingEventsUntilNextCard }; }, [cardId]); console.log('TRANSITION END TIMEOUT:'); console.log(JSON.stringify(result, null, ' ')); }, _waitForTransitionEnd: function(cardId) { var client = this.client; // To find out what is wrong with an intermittent failure in here, // log the script test criteria client.onScriptTimeout = this._onTransitionEndScriptTimeout .bind(this, cardId); client.waitFor(function() { return client.executeScript(function(cardId) { var cards = window.wrappedJSObject.require('cards'), htmlCache = window.wrappedJSObject.require('html_cache'), cardNode = cards._cardStack[cards.activeCardIndex]; return !!cardNode && cardNode.classList.contains('center') && htmlCache.nodeToKey(cardNode) === cardId && !cards._eatingEventsUntilNextCard; }, [cardId]); }); client.onScriptTimeout = null; }, _onNoTransitionScriptTimeout: function() { var result = this.client.executeScript(function() { var cards = window.wrappedJSObject.require('cards'); return { cards: !!cards, eventsClear: !!cards && !cards._eatingEventsUntilNextCard }; }); console.log('NO TRANSITION TIMEOUT:'); console.log(JSON.stringify(result, null, ' ')); }, _waitForNoTransition: function() { var client = this.client; // To find out what is wrong with an intermittent failure in here, // log the script test criteria client.onScriptTimeout = this._onNoTransitionScriptTimeout .bind(this); client.waitFor(function() { return client.executeScript(function() { var cards = window.wrappedJSObject.require('cards'); return !cards._eatingEventsUntilNextCard; }); }); client.onScriptTimeout = null; }, _setupTypeName: function(name) { this.client.helper .waitForElement(Selector.setupNameInput) .sendKeys(name); }, _setupTypeEmail: function(email) { this.client.helper .waitForElement(Selector.setupEmailInput) .sendKeys(email); }, _waitForElementNoTransition: function(selector) { this._waitForNoTransition(); return this.client.helper.waitForElement(selector); }, _tapSelector: function(selector) { this.client.helper.waitForElement(selector).tap(); }, _tapNext: function(selector, cardId) { this._tapSelector(selector); if (cardId) { this._waitForTransitionEnd(cardId); } }, _clearAndSendKeys: function(selector, value) { var el = this.client.helper.waitForElement(selector); el.clear(); el.sendKeys(value); }, _manualSetupTypeName: function(name) { this._clearAndSendKeys(Selector.manualSetupNameInput, name); }, _manualSetupTypeEmail: function(email) { this._clearAndSendKeys(Selector.manualSetupEmailInput, email); }, _manualSetupTypeImapPassword: function(password) { this._clearAndSendKeys(Selector.manualSetupImapPasswordInput, password); }, _manualSetupTypeSmtpPassword: function(password) { this._clearAndSendKeys(Selector.manualSetupSmtpPasswordInput, password); }, _manualSetupTypeImapUsername: function(name) { this._clearAndSendKeys(Selector.manualSetupImapUsernameInput, name); }, _manualSetupTypeImapHostname: function(hostname) { this._clearAndSendKeys(Selector.manualSetupImapHostnameInput, hostname); }, _manualSetupTypeImapPort: function(port) { this._clearAndSendKeys(Selector.manualSetupImapPortInput, port); }, _manualSetupTypeSmtpUsername: function(name) { this._clearAndSendKeys(Selector.manualSetupSmtpUsernameInput, name); }, _manualSetupTypeSmtpHostname: function(hostname) { this._clearAndSendKeys(Selector.manualSetupSmtpHostnameInput, hostname); }, _manualSetupTypeSmtpPort: function(port) { this._clearAndSendKeys(Selector.manualSetupSmtpPortInput, port); }, /** * Because we never expose "plain" (zero security for users) as an option we * need to hack the html to expose it (the backend will know about this). */ _manualSetupUpdateSocket: function(type) { var element = this.client.helper.waitForElement(Selector[type]); // select is a real dom select element element.scriptWith(function(select) { // create the option var option = document.createElement('option'); option.value = 'plain'; select.add(option, select.options[select.options.length - 1]); // update the form to plain so we can use insecure sockets for the // fakeserver. select.value = 'plain'; }); } }; require('./debug')('email', Email.prototype);