Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

View file

@ -0,0 +1,17 @@
{
"extends": "../../../../.jshintrc",
"predef": [
"suite",
"test",
"setup",
"teardown",
"suiteSetup",
"suiteTeardown",
"marionette",
"marionetteScriptFinished",
"require",
"define",
"requireApp",
"sinon"
]
}

View file

@ -0,0 +1,20 @@
BEGIN:VCARD
VERSION:4.0
N:Gump;Forrest;;;
FN:Forrest Gump
ORG:Bubba Gump Shrimp Co.
TITLE:Shrimp Man
PHOTO:data:image/bmp;base64,Qk02BAAAAAAAADYAAAAoAAAAEAAAAPD///8BACAAAAAAAAAAAABiBwAAYgcAAAAAAAAAAAAA///////////////////////////////////////////////////+///////////////////////////////////////////////////////////////////////V0s3/b21l/1pbVf9ydHL/u8HB//D09f/29vb///////////////////////////////////////Lx8P9ycWv/EhEO/wAAAP8AAgD/Gh0Z/15mav+tt7z/6+zr/////////////v////////////////////////+IiYf/AgYF/wAAAP8AAAD/AAAA/x8iI/88Ojv/e3l6///+/v/////////////////////////////////p5+f/am5z/xoeI/8iKDH/HSIr/ztATP+wtL//sbfC/zI4P//Ky8r///////////////////////7+/f/s7O3/vcDH/2Fkbf8+Qk7/foSW/5Wcsv+5vtP/7ezz//////97g5P/j5Oh//f4+P//////+/v7//Py8v/z8vP/9/j5/8jL2/8zNkD/MDM4/3Nydf/X0+H/0cvZ/7Srrv/Hvrz/h4uX/2Vsf/+5vcP/5OXj//Tz8//5+fn///////7+/v+eoa3/NjxE/2Vnaf9vbGr/pqSt/+ro8v+xqrP/6ePi/4SLnP85QE//e4SQ/5CWmv////////////z8+//X1tf/kZWc/09UXP9hY2f/TEhL/62twP/o6vP/n5ij/9LM0f+WnbX/PkRS/4CFkv99gor//f39//j39//19PP/+Pj3//////+ysrj/cnqP/21viv/c2On/9PL3//Hv+v/l5/T/x8jd/7Cvv//Y0NX/5NzD//X19f/7+/v///////Py9v/W3uf/z9Xa/4WFm/9UVGn/tK3D/+/o8v/Y0uD/9fL8/+3q9f/w7fn/+/n+///////////////////////08/b/2eLo/8XT2P9zdYb/XFxy/8S80//68vz/urTE/9XU5//j4+//9vX6////////////7e/u/+Dk5v/a4OT/8vf5///////g5+n/kZKe/25of/9sa3D/ZGNn/2pndf/ExdP/0tTf/+nm4//+/Pr//////87V2v+KmKf/cYOV/3mMn/+NnK3/TVll/1dYZP9kYHn/ODBG/yAcMP9fXW//ubzK/9PW3/9JREX/OTIu/7OxsP//////y9HZ/4WSov9xfo//TVhx/x8nNP8ZGiH/Y2F//3Bphv9vZYj/mZm3/7C1yv/Ey9n/DREX/wYIDf9ZaYb//v7+/8vS2v+Hk6T/h4+c/2hxg/8iJi//AwQE/0pHYP9nX4f/kIuv/4WIo/+GjaT/doOb/wECBv8dIS3/VWWL/w==
TEL;TYPE=work,voice;VALUE=uri:tel:+1-111-555-1212
TEL;TYPE=home,voice;VALUE=uri:tel:+1-404-555-1212
ADR;TYPE=work;LABEL="100 Waters Edge
Baytown,LA 30314
United States of America"
:;;100 Waters Edge;Baytown;LA;30314;United States of America
ADR;TYPE=home;LABEL="42 Plantation St.
Baytown,LA 30314
United States of America"
:;;42 Plantation St.;Baytown;LA;30314;United States of America
EMAIL:forrestgump@example.com
REV:20080424T195243Z
END:VCARD

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Messages Activity Caller</title>
<script defer src="/js/activity_caller.js"></script>
</head>
<body>
<div>I am Messages activity caller</div>
<button id="share-image">Share Image</button>
<button id="pick-image">Pick Image</button>
<button id="pick-contact">Pick Contact</button>
<button id="send-message">Send message</button>
</body>
</html>

View file

@ -0,0 +1,113 @@
/* global MozActivity */
(function(window) {
'use strict';
function generateCanvas(width, height) {
var canvas = document.createElement('canvas');
var context = canvas.getContext('2d');
canvas.width = width;
canvas.height = height;
var linearGradient = context.createLinearGradient(0, 0, width, height);
linearGradient.addColorStop(0, 'blue');
linearGradient.addColorStop(1, 'red');
context.fillStyle = linearGradient;
context.fillRect (0, 0, width, height);
return canvas;
}
function generateImageBlob(width, height, type, quality) {
var canvas = generateCanvas(width, height);
return new Promise(function(resolve) {
canvas.toBlob(function(blob) {
canvas.width = canvas.height = 0;
canvas = null;
resolve(blob);
}, type, quality);
});
}
function generateVcardBlob() {
return new Promise(function(resolve) {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/data/vcard_4.vcf');
xhr.overrideMimeType('text/vcard');
xhr.responseType = 'blob';
xhr.onload = function() {
resolve(xhr.response);
};
xhr.send();
});
}
window.addEventListener('load', function() {
var shareImageButton = document.getElementById('share-image');
shareImageButton.addEventListener('click', function() {
generateImageBlob(100, 100, 'image/jpeg').then(function(blob) {
return new MozActivity({
name: 'share',
data: {
type: 'image/*',
number: 1,
blobs: [blob],
filenames: 'image.jpg',
filepaths: 'image.jpg'
}
});
}).catch(function(e) {
console.warn('share activity error:', e.message || e.name);
});
});
var sendMessageButton = document.getElementById('send-message');
sendMessageButton.addEventListener('click', function() {
var activity = new MozActivity({
name: 'new',
data: {
type: 'websms/sms',
number: this.dataset.number,
body: this.dataset.body
}
});
activity.onerror = function() {
console.warn('new activity error:', activity.error.name);
};
});
});
window.navigator.mozSetMessageHandler('activity', function(activity) {
var pick = document.getElementById('pick-image');
pick.addEventListener('click', function() {
generateImageBlob(100, 100, 'image/jpeg').then(function(blob) {
activity.postResult({
type: blob.type,
blob: blob
});
}).catch(function(e) {
activity.postError(e);
});
});
var pickContactButton = document.getElementById('pick-contact');
pickContactButton.addEventListener('click', function() {
generateVcardBlob().then(function(blob) {
activity.postResult({
name: 'test_file.vcf',
blob: blob
});
});
});
});
})(window);

View file

@ -0,0 +1,20 @@
{
"name": "Messages Activity Caller",
"description": "Messages activity caller app",
"type": "certified",
"launch_path": "/index.html",
"developer": {
"name": "The Gaia Team",
"url": "https://github.com/mozilla-b2g/gaia"
},
"activities": {
"pick": {
"filters": {
"type": ["image/*", "image/jpeg", "image/png", "text/vcard"]
},
"disposition": "inline",
"returnValue": true,
"href": "/index.html#pick"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1,68 @@
/* global require, marionette, setup, suite, test, __dirname */
'use strict';
var assert = require('chai').assert;
var Messages = require('./lib/messages.js');
var MessagesActivityCaller = require('./lib/messages_activity_caller.js');
marionette('Attachment picking and sending tests', function() {
var apps = {};
apps[MessagesActivityCaller.ORIGIN] = __dirname + '/apps/activitycaller';
var client = marionette.client({
profile: {
prefs: {
'focusmanager.testmode': true
},
apps: apps
},
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
var messagesApp, activityCallerApp;
setup(function() {
messagesApp = Messages.create(client);
activityCallerApp = MessagesActivityCaller.create(client);
client.loader.getMockManager('sms').inject([
'navigator_moz_icc_manager',
'navigator_moz_mobile_message'
]);
});
suite('Test Suite', function() {
setup(function() {
messagesApp.launch();
messagesApp.Inbox.navigateToComposer();
});
test('A contact can be enclosed as attachment', function() {
messagesApp.addRecipient('+346666666');
var composer = messagesApp.Composer;
client.waitFor(function() {
return composer.attachButton.enabled();
});
composer.attachButton.tap();
messagesApp.Menu.selectSystemMenuOption('Messages Activity Caller');
activityCallerApp.switchTo();
activityCallerApp.pickContact();
messagesApp.switchTo();
messagesApp.send();
client.helper.waitForElement(messagesApp.Selectors.Message.
vcardAttachment);
var fileName = client.helper.waitForElement(messagesApp.Selectors.
Message.fileName);
assert.equal(fileName.text(), 'test_file.vcf');
});
});
});

View file

@ -0,0 +1,354 @@
/* global require, marionette, setup, suite, test, __dirname */
'use strict';
var assert = require('chai').assert;
var Messages = require('./lib/messages.js');
var InboxView = require('./lib/views/inbox/view');
var MessagesActivityCaller = require('./lib/messages_activity_caller.js');
var Storage = require('./lib/storage.js');
var Tools = require('./lib/views/shared/tools.js');
marionette('Messages Composer', function() {
var apps = {};
apps[MessagesActivityCaller.ORIGIN] = __dirname + '/apps/activitycaller';
var client = marionette.client({
profile: {
prefs: {
'focusmanager.testmode': true
},
apps: apps
},
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
var messagesApp, activityCallerApp;
var storage;
function assertIsDisplayed(element) {
assert.isTrue(element.displayed(), 'Element should be displayed');
}
function assertIsNotDisplayed(element) {
assert.isFalse(element.displayed(), 'Element should not be displayed');
}
setup(function() {
messagesApp = Messages.create(client);
storage = Storage.create(client);
activityCallerApp = MessagesActivityCaller.create(client);
client.loader.getMockManager('sms').inject([
'test_storages',
'test_blobs',
'navigator_moz_icc_manager',
'navigator_moz_mobile_message',
'navigator_moz_contacts'
]);
});
suite('Preserve message input while navigating', function() {
var inboxView, conversationView;
var message = 'test message';
setup(function() {
messagesApp.launch();
inboxView = new InboxView(client);
var newMessageView = inboxView.createNewMessage();
newMessageView.addNewRecipient('a@b.c');
newMessageView.addNewRecipient('s@p.c');
newMessageView.typeMessage('MMS thread.');
conversationView = newMessageView.send();
conversationView.typeMessage(message);
});
test('Message input is preserved when navigating to and from group-view',
function() {
var participantsView = conversationView.openParticipants();
participantsView.back();
assert.equal(conversationView.messageText, message);
});
test('Message input is preserved when navigating to and from ' +
'message-report', function() {
var reportView = conversationView.openReport(
conversationView.messages()[0].id
);
reportView.back();
assert.equal(conversationView.messageText, message);
});
});
suite('Sending a message', function() {
setup(function() {
messagesApp.launch();
messagesApp.Inbox.navigateToComposer();
// Create new thread from the scratch
messagesApp.addRecipient('+1');
messagesApp.Composer.messageInput.sendKeys('message');
messagesApp.send();
});
test('Header should be updated accordingly', function() {
client.helper.waitForElement(messagesApp.Conversation.message);
assert.equal(
messagesApp.Conversation.headerTitle.text(),
'+1',
'Header title should display contact phone number'
);
});
});
suite('Messages Composer Test Suite', function() {
var message = 'long long long long long message long long message long ' +
'message long long message message long message long message long ' +
'for message long message long message long message long message long';
setup(function() {
messagesApp.launch();
messagesApp.Inbox.navigateToComposer();
});
test('Message char counter and MMS label', function() {
var composer = messagesApp.Composer;
// Case #1: When we open composer initially we should not see neither char
// counter nor MMS label.
assertIsNotDisplayed(composer.charCounter);
assertIsNotDisplayed(composer.mmsLabel);
// Case #2: Focus on message editor and enter some text. If we have more
// than 20 chars left then we don't show available char counter yet
composer.messageInput.tap();
composer.messageInput.sendKeys(message.slice(0, 70));
assertIsNotDisplayed(composer.charCounter);
assertIsNotDisplayed(composer.mmsLabel);
// Case #3: Enter some more text to see available char counter
composer.messageInput.sendKeys(message.slice(0, 70));
client.helper.waitForElement(composer.charCounter);
assert.equal(composer.charCounter.text(), '20/1');
assertIsNotDisplayed(composer.mmsLabel);
// Case #4: Remove one character and char counter should disappear
composer.messageInput.sendKeys(Messages.Chars.BACKSPACE);
client.helper.waitForElementToDisappear(composer.charCounter);
assertIsNotDisplayed(composer.mmsLabel);
// Case #5: Add empty subject, if subject is empty then we still have SMS
// and char counter can be displayed
messagesApp.showSubject();
assert.equal(composer.subjectInput.text(), '');
assertIsNotDisplayed(composer.mmsLabel);
assertIsNotDisplayed(composer.charCounter);
// Case #6: Enter some text to message input to show available char
// counter while subject is empty
composer.messageInput.sendKeys('ab');
client.helper.waitForElement(composer.charCounter);
assert.equal(composer.charCounter.text(), '19/1');
assertIsNotDisplayed(composer.mmsLabel);
// Case #7: Enter some text to subject input. Message should be converted
// to MMS and appropriate label should appear near subject field,
// available char counter should disappear
composer.subjectInput.sendKeys('subject');
client.helper.waitForElement(composer.mmsLabel);
client.helper.waitForElementToDisappear(composer.charCounter);
// Case #8: Enter some text to message input while subject is not empty
// and visible. In this case we still update available char counter, but
// doesn't display it to the user as currently message is MMS
composer.messageInput.sendKeys('cd');
assertIsDisplayed(composer.mmsLabel);
assertIsNotDisplayed(composer.charCounter);
// Case #9: Remove subject when there is enough text to show available
// char counter (with actual value), message converted to SMS
messagesApp.hideSubject();
client.helper.waitForElementToDisappear(composer.mmsLabel);
client.helper.waitForElement(composer.charCounter);
assert.equal(composer.charCounter.text(), '17/1');
// Case #10: Add attachment, message is converted to MMS and appropriate
// label appears in the subject line.
client.waitFor(function() {
return composer.attachButton.enabled();
}.bind(this));
composer.attachButton.tap();
messagesApp.Menu.selectSystemMenuOption('Messages Activity Caller');
activityCallerApp.switchTo();
activityCallerApp.pickImage();
messagesApp.switchTo();
client.helper.waitForElement(composer.mmsLabel);
client.helper.waitForElementToDisappear(composer.charCounter);
// Since we have an attachment we don't track available characters and old
// available char value should be removed to not confusing user
assert.equal(composer.charCounter.text(), '');
// Case #11: show subject when we have an attachment, message is still
// MMS.
messagesApp.showSubject();
assertIsDisplayed(composer.mmsLabel);
assertIsNotDisplayed(composer.charCounter);
// Case #12: remove subject when we have an attachment, message is still
// MMS
messagesApp.hideSubject();
assertIsDisplayed(composer.mmsLabel);
assertIsNotDisplayed(composer.charCounter);
// Case #13: edit some text and remove attachment, in this case message is
// converted to SMS and we should show actual available char counter
composer.messageInput.sendKeys('ef');
composer.attachments[0].scriptWith(function(el) {
el.scrollIntoView(false);
});
// Remove this workaround once Marionette bug is resolved:
// "Bug 1046706 - "tap" does not find the element after scrolling in APZC"
client.helper.wait(600);
composer.attachments[0].tap();
messagesApp.Menu.selectAppMenuOption('Remove image');
client.helper.waitForElementToDisappear(composer.mmsLabel);
client.helper.waitForElement(composer.charCounter);
assert.equal(composer.charCounter.text(), '15/1');
// Case #14: add an email recipient, the message is converted to MMS.
messagesApp.addRecipient('a@b.com');
client.helper.waitForElement(composer.mmsLabel);
// Case #15: remove the email recipient, the message is converted to SMS.
messagesApp.clearRecipient();
client.helper.waitForElementToDisappear(composer.mmsLabel);
});
test('Subject focus management', function() {
var composer = messagesApp.Composer;
// Case #1: Add subject input, once added it should be focused
messagesApp.showSubject();
Tools.assertElementFocused(composer.subjectInput,
'Subject input should be focused');
// Case #2: Hide subject field, focus should be moved to message field
messagesApp.hideSubject();
Tools.assertElementFocused(composer.messageInput,
'Message input should be focused');
// Case #3: Focus should be moved to message input when subject is removed
// by user with backspace key as well
messagesApp.showSubject();
Tools.assertElementFocused(composer.subjectInput,
'Subject input should be focused');
composer.subjectInput.sendKeys(Messages.Chars.BACKSPACE);
Tools.assertElementFocused(composer.messageInput,
'Message input should be focused');
});
});
suite('Recipients', function() {
var newMessage;
var contact = {
name: ['Existing Contact'],
givenName: ['Existing'],
familyName: ['Contact'],
tel: [{
value: '5551234567',
type: 'Mobile'
}]
};
setup(function() {
storage.setContactsStorage([contact]);
messagesApp.launch();
var inbox = new InboxView(client);
newMessage = inbox.createNewMessage();
});
test('should match an existing contact', function() {
newMessage.addNewRecipient('Existing Contact');
assert.deepEqual(contact.name, newMessage.recipients);
assert.equal(contact.tel[0].value, newMessage.recipientsPhoneNumbers);
});
suite('Invalid recipients', function() {
suite('Recipients list', function() {
test('should display that a non existing contact is invalid',
function() {
newMessage.addNewRecipient('non_exisiting_contact');
assert.isTrue(newMessage.containsInvalidRecipients());
});
test('should allow to correct an invalid contact', function() {
newMessage.addNewRecipient('non_exisiting_contact');
newMessage.clearRecipients();
newMessage.addNewRecipient(123);
assert.lengthOf(newMessage.recipients, 1);
assert.equal(newMessage.recipients[0], '123');
assert.isFalse(newMessage.containsInvalidRecipients());
});
});
suite('Content composer', function() {
test('should not enable send button if the contact is invalid',
function() {
newMessage.addNewRecipient('invalidContact');
newMessage.typeMessage('Test message');
assert.isFalse(newMessage.isSendButtonEnabled());
});
});
});
suite('Semicolon as separator', function() {
var separator = ';';
test('should complete the entered recipients', function() {
newMessage.addNewRecipient(123, separator);
assert.lengthOf(newMessage.recipients, 1);
assert.notInclude(newMessage.recipients[0], separator);
});
test('should leave the input ready to accept another recipient',
function() {
newMessage.addNewRecipient(123, separator);
newMessage.addNewRecipient(456, separator);
assert.lengthOf(newMessage.recipients, 2);
});
});
});
});

View file

@ -0,0 +1,683 @@
/* global require, marionette, setup, suite, test, __dirname */
'use strict';
var assert = require('chai').assert;
var _ = require('SMS/node_modules/lodash');
var ThreadGenerator = require('./generators/thread');
var Messages = require('./lib/messages.js');
var InboxView = require('./lib/views/inbox/view');
var Storage = require('./lib/storage.js');
var Tools = require('./lib/views/shared/tools.js');
var MessagesActivityCaller = require('./lib/messages_activity_caller.js');
var Keyboard = require('../../../keyboard/test/marionette/lib/keyboard.js');
marionette('Conversation Panel Tests', function() {
var apps = {};
apps[MessagesActivityCaller.ORIGIN] = __dirname + '/apps/activitycaller';
var client = marionette.client({
desiredCapabilities: { raisesAccessibilityExceptions: false },
profile: {
prefs: {
'focusmanager.testmode': true
},
apps: apps
}
});
var messagesApp, storage, activityCallerApp, keyboardApp;
function assertIsDisplayed(element) {
assert.isTrue(element.displayed(), 'Element should be displayed');
}
setup(function() {
messagesApp = Messages.create(client);
keyboardApp = new Keyboard(client);
storage = Storage.create(client);
activityCallerApp = MessagesActivityCaller.create(client);
client.loader.getMockManager('sms').inject([
'test_storages',
'test_blobs',
'navigator_moz_icc_manager',
'navigator_moz_mobile_message',
'navigator_moz_contacts'
]);
});
suite('General use cases', function() {
suite('Long SMS thread', function() {
var thread;
setup(function() {
thread = ThreadGenerator.generate({
numberOfMessages: 150
});
storage.setMessagesStorage([thread], ThreadGenerator.uniqueMessageId);
messagesApp.launch();
});
test('User can see all messages when scrolls up', function() {
messagesApp.Inbox.firstConversation.tap();
// Composer panel should always be visible
assertIsDisplayed(messagesApp.Composer.sendButton);
assertIsDisplayed(messagesApp.Composer.messageInput);
thread.messages.forEach(function(message) {
var messageNode = messagesApp.Conversation.findMessage(message.id);
// If node is not visible, let's just scroll up and wait until it
// becomes visible.
if (!messageNode.displayed()) {
client.waitFor(function() {
messagesApp.Conversation.scrollUp();
return messageNode.displayed();
});
}
});
assertIsDisplayed(messagesApp.Composer.sendButton);
assertIsDisplayed(messagesApp.Composer.messageInput);
});
});
suite('Long MMS thread', function() {
var thread;
var inboxView;
var attachmentScreenshot;
suiteSetup(function() {
attachmentScreenshot = Tools.loadAsset('mms-attachment.png');
});
suiteTeardown(function() {
attachmentScreenshot = null;
});
setup(function() {
thread = ThreadGenerator.generate({
numberOfMessages: 150,
baseTimestamp: Date.UTC(2015, 8, 1, 13, 40),
messageType: 'mms',
attachments: [
{ type: 'image/png', width: 10, height: 10 }
]
});
storage.setMessagesStorage([thread], ThreadGenerator.uniqueMessageId);
messagesApp.launch();
inboxView = new InboxView(client);
});
test('MMS are properly displayed after going to a subview', function() {
var conversationView = inboxView.goToConversation(thread.id);
conversationView.waitForFullRendering(thread);
var recentId = thread.messages[0].id;
var reportView = conversationView.openReport(recentId);
reportView.back();
var message = conversationView.findMessage(recentId);
var screenshot = message.attachments[0].screenshot();
assert.equal(screenshot, attachmentScreenshot);
conversationView.fakeScrollUpTo(0);
// Find the first displayed message. Displayed does not especially mean
// that we see it in the viewport, it merely means it's not "hidden"
// using CSS (eg: "display: none" here).
// I want to find the topmost already displayed message, and then scroll
// to it so that Gecko tries to load the attachment's background image.
var displayedMessage = _.find(
conversationView.messages(),
function(message) { return message.isDisplayed; }
);
conversationView.fakeScrollUpTo(0);
screenshot = displayedMessage.attachments[0].screenshot();
assert.equal(screenshot, attachmentScreenshot);
});
});
});
suite('Forward message', function() {
var smsBody = 'this is a sms body';
var mmsBody = 'this is a mms body';
var smsThread, mmsThread;
setup(function() {
smsThread = ThreadGenerator.generate({
body: smsBody
});
mmsThread = ThreadGenerator.generate({
numberOfMessages: 1,
messageType: 'mms',
participants: ['a@b.c'],
attachments: [
{ type: 'image/png', width: 10, height: 10 },
{ type: 'text/plain', content: mmsBody }
]
});
storage.setMessagesStorage(
[smsThread, mmsThread],
ThreadGenerator.uniqueMessageId
);
messagesApp.launch();
});
test('Forward a SMS', function() {
messagesApp.Inbox.findConversation(smsThread.id).tap();
// Forward message
messagesApp.contextMenu(messagesApp.Conversation.message);
messagesApp.Menu.selectAppMenuOption('Forward');
// Wait for message to be forwarded to fill out composer fields
client.waitFor(function() {
return messagesApp.Composer.messageInput.text() !== '';
});
assert.equal(
messagesApp.Composer.messageInput.text(),
smsThread.messages[0].body,
'Forwarded body is the initial body'
);
assert.equal(
messagesApp.Conversation.headerTitle.text(),
'New message',
'Header title should indicate that we are composing new message'
);
Tools.assertElementFocused(
messagesApp.NewMessage.recipientsInput,
'Recipients input should be focused'
);
});
test('Forwarding a MMS', function() {
messagesApp.Inbox.findConversation(mmsThread.id).tap();
// Forward message
messagesApp.contextMenu(messagesApp.Conversation.message);
messagesApp.Menu.selectAppMenuOption('Forward');
// Wait for message to be forwarded to fill out composer fields
client.waitFor(function() {
return messagesApp.Composer.messageInput.text() !== '';
});
assert.equal(
messagesApp.Composer.messageInput.text(),
mmsBody,
'Forwarded body is the initial body'
);
var composerAttachments = messagesApp.Composer.attachments;
assert.equal(composerAttachments.length, 1);
assert.equal(
composerAttachments[0].getAttribute('data-attachment-type'), 'img'
);
assert.equal(
messagesApp.Conversation.headerTitle.text(),
'New message',
'Header title should indicate that we are composing new message'
);
Tools.assertElementFocused(
messagesApp.NewMessage.recipientsInput,
'Recipients input should be focused'
);
});
});
suite('Action links in messages', function() {
var threads;
setup(function() {
threads = [
ThreadGenerator.generate({
participants: ['+400000'],
body: 'Call +400000 or send message to mozilla@mozilla.org'
}),
ThreadGenerator.generate({
participants: ['+100000'],
body: 'Use these numbers: +200000, +300000 or +400000'
})
];
storage.setMessagesStorage(threads, ThreadGenerator.uniqueMessageId);
storage.setContactsStorage([{
name: ['Alan Turing'],
tel: [{
value: '+300000',
type: 'Mobile'
}]
}]);
messagesApp.launch();
messagesApp.Inbox.findConversation(threads[1].id).tap();
});
test('Send message to unknown number', function() {
// Try to send message to the unknown number
var unknownNumberLink = messagesApp.Conversation.message.findElement(
'[data-dial="+200000"]'
);
unknownNumberLink.tap();
messagesApp.Menu.selectContactPromptMenuOption('Send message');
// Wait for the recipient input to be filled
var recipients = messagesApp.NewMessage.recipients;
client.waitFor(function() {
return recipients.length === 1;
});
assert.equal(recipients[0].text(), '+200000');
assert.equal(recipients[0].getAttribute('data-source'), 'manual');
Tools.assertElementFocused(
messagesApp.Composer.messageInput, 'Message input should be focused'
);
});
test('Send message to contact number', function() {
// Try to send message to number that has contact associated with it
var unknownNumberLink = messagesApp.Conversation.message.findElement(
'[data-dial="+300000"]'
);
unknownNumberLink.tap();
messagesApp.Menu.selectContactPromptMenuOption('Send message');
// Wait for the recipient input to be filled
var recipients = messagesApp.NewMessage.recipients;
client.waitFor(function() {
return recipients.length === 1;
});
assert.equal(recipients[0].text(), 'Alan Turing');
assert.equal(recipients[0].getAttribute('data-source'), 'contacts');
Tools.assertElementFocused(
messagesApp.Composer.messageInput, 'Message input should be focused'
);
});
test('Send message to thread number', function() {
// Try to send message to number that has thread associated with it
var threadNumberLink = messagesApp.Conversation.message.findElement(
'[data-dial="+400000"]'
);
threadNumberLink.tap();
messagesApp.Menu.selectContactPromptMenuOption('Send message');
// Verify that we entered the right thread
var message = messagesApp.Conversation.message;
assert.equal(
messagesApp.Conversation.getMessageContent(message).text(),
threads[0].messages[0].body
);
Tools.assertElementFocused(
messagesApp.Composer.messageInput, 'Message input should be focused'
);
});
});
suite('Retrieve MMS', function() {
var conversationView;
setup(function() {
var thread = ThreadGenerator.generate({
messageType: 'mms',
delivery: 'not-downloaded',
attachments: [
{ type: 'image/png', width: 10, height: 10 },
{ type: 'text/plain', content: 'Attachment' }
],
expiryDate: Date.now() + 100000
});
storage.setMessagesStorage([thread], ThreadGenerator.uniqueMessageId);
messagesApp.launch();
var inboxView = new InboxView(client);
conversationView = inboxView.goToConversation(thread.id);
});
test('MMS should be retrieved successfully', function() {
var notDownloadedMessage = conversationView.messages()[0];
assert.isFalse(notDownloadedMessage.isDownloaded);
assert.isFalse(notDownloadedMessage.isPending);
assert.equal(notDownloadedMessage.attachments.length, 0);
conversationView.downloadMessage(notDownloadedMessage.id);
// Now old message should be removed and new message is received.
var downloadedMessage;
client.waitFor(function() {
downloadedMessage = conversationView.findMessage(
notDownloadedMessage.id
);
return downloadedMessage && downloadedMessage.isDownloaded;
});
assert.equal(downloadedMessage.attachments.length, 1);
assert.equal(downloadedMessage.attachments[0].type, 'img');
assert.equal(downloadedMessage.content.trim(), 'Attachment');
});
});
suite('Edit Mode', function() {
var thread, conversationView;
setup(function() {
thread = ThreadGenerator.generate({
numberOfMessages: 4
});
storage.setMessagesStorage([thread], ThreadGenerator.uniqueMessageId);
messagesApp.launch();
var inboxView = new InboxView(client);
conversationView = inboxView.goToConversation(thread.id);
});
test('User can enter and exit edit mode', function () {
conversationView.enterEditMode();
conversationView.messages().forEach(function(message) {
assert.isTrue(message.isInEditMode);
});
conversationView.exitEditMode();
conversationView.messages().forEach(function(message) {
assert.isFalse(message.isInEditMode);
});
});
suite('Toggle selection button behavior', function() {
setup(function() {
conversationView.enterEditMode();
});
test('User can select/deselect all messages at once', function () {
// Selecting all messages
conversationView.toggleMessagesSelection();
conversationView.messages().forEach(function(message) {
assert.isTrue(message.isSelected);
});
assert.equal(
conversationView.toggleSelectionButtonTitle,
'Deselect all',
'Select / Deselect all button should display correct text'
);
assert.equal(
conversationView.editHeaderTitle,
'4 selected',
'Edit mode header should show correct number of messages'
);
// Deselecting all messages
conversationView.toggleMessagesSelection();
conversationView.messages().forEach(function(message) {
assert.isFalse(message.isSelected);
});
assert.equal(
conversationView.toggleSelectionButtonTitle,
'Select all',
'Select / Deselect all button should display correct text'
);
assert.equal(
conversationView.editHeaderTitle,
'Delete messages',
'Edit mode header should not indicate that any message is selected'
);
});
test('User selects a few and then selects/deselects all', function() {
// Selecting the 1st and 3rd message
var messageIndicesToSelect = [2, 0];
var messages = conversationView.messages();
messageIndicesToSelect.forEach(function(messageIndex) {
conversationView.tapOnMessage(messages[messageIndex].id);
});
conversationView.messages().forEach(function(message, index) {
if (messageIndicesToSelect.indexOf(index) >= 0) {
assert.isTrue(message.isSelected);
} else {
assert.isFalse(message.isSelected);
}
});
assert.equal(
conversationView.editHeaderTitle,
'2 selected',
'Edit mode header should show correct number of messages'
);
// Selecting all messages
conversationView.toggleMessagesSelection();
conversationView.messages().forEach(function(message) {
assert.isTrue(message.isSelected);
});
assert.equal(
conversationView.toggleSelectionButtonTitle,
'Deselect all',
'Select / Deselect all button should display correct text'
);
assert.equal(
conversationView.editHeaderTitle,
'4 selected',
'Edit mode header should show correct number of messages'
);
// Deselecting all messages
conversationView.toggleMessagesSelection();
conversationView.messages().forEach(function(message) {
assert.isFalse(message.isSelected);
});
assert.equal(
conversationView.toggleSelectionButtonTitle,
'Select all',
'Select / Deselect all button should display correct text'
);
assert.equal(
conversationView.editHeaderTitle,
'Delete messages',
'Edit mode header should not indicate that any message is selected'
);
});
test('User selects all, deselects some, selects all', function() {
// Selecting all messages
conversationView.toggleMessagesSelection();
conversationView.messages().forEach(function(message) {
assert.isTrue(message.isSelected);
});
assert.equal(
conversationView.toggleSelectionButtonTitle,
'Deselect all',
'Select / Deselect all button should display correct text'
);
assert.equal(
conversationView.editHeaderTitle,
'4 selected',
'Edit mode header should show correct number of messages'
);
// Deselecting 1st and 3rd message
var messageIndicesToDeselect = [2, 0];
var messages = conversationView.messages();
messageIndicesToDeselect.forEach(function(messageIndex) {
conversationView.tapOnMessage(messages[messageIndex].id);
});
conversationView.messages().forEach(function(message, index) {
if (messageIndicesToDeselect.indexOf(index) >= 0) {
assert.isFalse(message.isSelected);
} else {
assert.isTrue(message.isSelected);
}
});
assert.equal(
conversationView.editHeaderTitle,
'2 selected',
'Edit mode header should show correct number of messages'
);
// Selecting all messages
conversationView.toggleMessagesSelection();
conversationView.messages().forEach(function(message) {
assert.isTrue(message.isSelected);
});
assert.equal(
conversationView.toggleSelectionButtonTitle,
'Deselect all',
'Select / Deselect all button should display correct text'
);
assert.equal(
conversationView.editHeaderTitle,
'4 selected',
'Edit mode header should show correct number of messages'
);
});
});
});
suite('Add recipients from thread', function() {
var conversationView;
setup(function() {
var thread = ThreadGenerator.generate({
numberOfMessages: 1
});
storage.setMessagesStorage([thread], ThreadGenerator.uniqueMessageId);
messagesApp.launch();
var inboxView = new InboxView(client);
conversationView = inboxView.goToConversation(thread.id);
});
test('With empty composer: recipients should be kept',
function() {
var newMessageView = conversationView.includeSomeoneElse();
// this also ensure we are on Composer view
assert.equal(newMessageView.recipients, '+123',
'Recipients should be kept');
assert.equal(newMessageView.messageText, '', 'SMS text should be empty');
// add a recipient, a subject and send a mms to create a thread with
// several recipients
newMessageView.typeMessage('message2');
newMessageView.addNewRecipient('+234');
newMessageView.showSubject();
newMessageView.typeSubject('subject1');
conversationView = newMessageView.send();
newMessageView = conversationView.includeSomeoneElse();
var recipients = newMessageView.recipients;
assert.lengthOf(recipients, 2, 'There should be 2 recipients');
assert.include(recipients, '+123', '+123 should be in the recipients');
assert.include(recipients, '+234', '+234 should be in the recipients');
assert.equal(newMessageView.messageText, '', 'MMS text should be empty');
});
test('Sms being written: message and recipients should be kept',
function() {
conversationView.typeMessage('message2');
var newMessageView = conversationView.includeSomeoneElse();
assert.equal(newMessageView.recipients, '+123',
'Recipient should be +123');
assert.equal(newMessageView.messageText, 'message2',
'message text should be kept');
});
test('Mms being written: all info must be kept', function() {
conversationView.typeMessage('message2');
// add a subject
conversationView.showSubject();
conversationView.typeSubject('subject1');
var newMessageView = conversationView.includeSomeoneElse();
// assert that we kept all the infos
assert.equal(newMessageView.recipients, '+123',
'Recipient should be +123');
assert.equal(newMessageView.messageText, 'message2',
'Message text should be kept');
assert.equal(newMessageView.subject, 'subject1',
'Subject should be kept');
// let's add a recipients and send the message.
// we will have a thread with several recipients
newMessageView.addNewRecipient('+234');
conversationView = newMessageView.send();
// write another MMS
conversationView.typeMessage('message3');
conversationView.showSubject();
conversationView.typeSubject('subject2');
// add a blob
conversationView.addAttachment().choose('Messages Activity Caller')
.pickImage();
messagesApp.switchTo();
// take screenshot of current attachment to compare it later
var beforeScreenshot = conversationView
.takeComposerAttachmentScreenshot(0);
// redirect to new message to add recipients
newMessageView = conversationView.includeSomeoneElse();
// assert the content
var recipients = newMessageView.recipients;
assert.lengthOf(recipients, 2, 'There should be 2 recipients');
assert.include(recipients, '+123', '+123 should be in the recipients');
assert.include(recipients, '+234', '+234 should be in the recipients');
assert.equal(newMessageView.messageText, 'message3',
'Message text should be kept');
assert.equal(newMessageView.subject, 'subject2',
'Subject should be kept');
// hide the keyboard: when it is displayed, the attachment is hidden,
// which prevents the screenshot to be taken
client.switchToFrame();
var systemInputMgmt = client.loader.getAppClass(
'system', 'input_management'
);
systemInputMgmt.waitForKeyboardFrameDisplayed();
keyboardApp.switchTo();
keyboardApp.longPressSpaceBar(0.7);
client.switchToFrame();
// wait for the keyboard to be hidden
systemInputMgmt.waitForKeyboardFrameHidden();
messagesApp.switchTo();
assert.equal(
beforeScreenshot,
newMessageView.takeComposerAttachmentScreenshot(0)
);
});
});
});

View file

@ -0,0 +1,162 @@
/* global require, marionette, setup, suite, test */
'use strict';
var assert = require('chai').assert;
var Messages = require('./lib/messages.js');
var Storage = require('./lib/storage.js');
var ThreadGenerator = require('./generators/thread');
marionette('Message Type Conversion Banner', function() {
var apps = {};
var client = marionette.client({
profile: {
prefs: {
'focusmanager.testmode': true
},
apps: apps
},
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
var messagesApp, storage, composer, inbox, newMessage;
function assertIsDisplayed(element) {
assert.isTrue(element.displayed(), 'Element should be displayed');
}
function assertIsNotDisplayed(element) {
assert.isFalse(element.displayed(), 'Element should not be displayed');
}
function waitForBannerToDisappear() {
client.helper.waitForElementToDisappear(newMessage.conversionBanner);
}
function exitConversation() {
messagesApp.performHeaderAction();
// Wait for the thread list to appear
inbox.waitToAppear();
// Wait a little bit, much less than the time needed for the banner to
// expire in order to give the render loop time to clear the banners.
client.helper.wait(300);
}
setup(function() {
messagesApp = Messages.create(client);
storage = Storage.create(client);
composer = messagesApp.Composer;
inbox = messagesApp.Inbox;
newMessage = messagesApp.NewMessage;
client.loader.getMockManager('sms').inject([
'test_storages',
'test_blobs',
'navigator_moz_icc_manager',
'navigator_moz_mobile_message'
]);
});
suite('Message Type Conversion Banner for new threads', function() {
setup(function() {
messagesApp.launch();
inbox.navigateToComposer();
});
test('Old conversion banner is cleared before entering a thread',
function() {
// Force it to be MMS
messagesApp.addRecipient('a@b.c');
// Without waiting for the banner to disappear, return to thread list
exitConversation();
// Create another new message
inbox.navigateToComposer();
// The banner should not be displayed
assertIsNotDisplayed(newMessage.conversionBanner);
});
test('MMS to email conversion and reminders', function() {
// Case #1: When we open composer initially we should see not banner while
// we are in SMS mode.
assertIsNotDisplayed(newMessage.conversionBanner);
// Case #2: Add an email recipient and the message becomes MMS and the
// banner should appear.
messagesApp.addRecipient('a@b.c');
assertIsDisplayed(newMessage.conversionBanner);
// Case #3: Wait for the banner to disappear, then send a message to
// be redirected to the thread view. We are still in MMS mode but no
// banner should be displayed.
waitForBannerToDisappear();
composer.messageInput.tap();
composer.messageInput.sendKeys('test');
messagesApp.send();
assertIsNotDisplayed(newMessage.conversionBanner);
// Case #4: Return to threads view and re-enter the thread to be reminded
// about being in a MMS thread.
exitConversation();
inbox.firstConversation.tap();
client.helper.waitForElement(newMessage.conversionBanner);
});
});
suite('Message Type Conversion Banner for pre-existent threads', function() {
function navigateToSMSConversation() {
inbox.smsConversation.tap();
messagesApp.Conversation.waitToAppear();
}
function navigateToMMSConversation() {
inbox.mmsConversation.tap();
messagesApp.Conversation.waitToAppear();
}
setup(function() {
var smsThread = ThreadGenerator.generate();
var mmsThread = ThreadGenerator.generate({
numberOfMessages: 1,
messageType: 'mms',
participants: ['a@b.c'],
attachments: [
{ type: 'image/png', width: 10, height: 10 },
{ type: 'text/plain', content: 'Email attachment' }
]
});
storage.setMessagesStorage(
[smsThread, mmsThread],
ThreadGenerator.uniqueMessageId
);
messagesApp.launch();
});
test('The banner is not shown after sending another message',
function() {
navigateToMMSConversation();
composer.messageInput.sendKeys('Another message');
waitForBannerToDisappear();
messagesApp.send();
assertIsNotDisplayed(newMessage.conversionBanner);
});
test('The banner for SMS is not shown when entering a SMS thread after ' +
'visiting a MMS thread', function() {
navigateToMMSConversation();
exitConversation();
navigateToSMSConversation();
assertIsNotDisplayed(newMessage.conversionBanner);
});
});
});

View file

@ -0,0 +1,446 @@
/* global require, marionette, setup, suite, test, __dirname */
'use strict';
var assert = require('chai').assert;
var MessagesActivityCaller = require('./lib/messages_activity_caller.js');
var ThreadGenerator = require('./generators/thread');
var Messages = require('./lib/messages.js');
var Storage = require('./lib/storage.js');
var InboxView = require('./lib/views/inbox/view');
marionette('Messages Drafts', function() {
var apps = {};
apps[MessagesActivityCaller.ORIGIN] = __dirname + '/apps/activitycaller';
var client = marionette.client({
profile: {
prefs: {
'focusmanager.testmode': true
},
apps: apps
},
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
var messagesApp, storage;
function createAndSaveDraft(view, draft) {
// Add text content.
if (draft.text) {
view.typeMessage(draft.text);
}
// Add subject.
if (draft.subject) {
view.showSubject();
view.typeSubject(draft.subject);
}
// Add recipient.
if (draft.recipients) {
draft.recipients.forEach(function(recipient) {
view.addNewRecipient(recipient);
});
}
// Add attachment.
if (draft.shouldHaveAttachment) {
view.addAttachment().choose('Messages Activity Caller').pickImage();
messagesApp.switchTo();
}
var inboxView = view.backToInbox();
// It's important to wait for the updated thread node (when old node is
// removed, and new one is inserted), otherwise we can get old node
// reference and when we try to tap on it, we'll get stale element
// reference exception if node has been removed in between.
var conversationWithDraft = null;
client.waitFor(function() {
var conversations = inboxView.conversations;
if (!conversations.length) {
return false;
}
conversationWithDraft = conversations[0];
if (!conversationWithDraft.isDraft && !conversationWithDraft.hasDraft) {
return false;
}
var expectedBodyText = (draft.subject || draft.shouldHaveAttachment) ?
'' : draft.text;
return conversationWithDraft.bodyText === expectedBodyText;
});
return conversationWithDraft;
}
function discardDraft(view, draft) {
if (draft.text || draft.shouldHaveAttachment) {
view.clearMessage();
}
if (draft.subject) {
view.hideSubject();
}
if (draft.recipients) {
view.clearRecipients();
}
view.backToInbox();
}
function assertDraft(view, draft) {
// Check that recipient is loaded from draft.
if (draft.recipients) {
var recipients = view.recipients;
assert.equal(recipients.length, draft.recipients.length);
draft.recipients.forEach(function(recipient, index) {
assert.equal(recipients[index], recipient);
});
}
// Check that text content is loaded.
if (draft.text) {
assert.equal(view.messageText, draft.text);
}
// Check that subject is loaded.
if (draft.subject) {
assert.isTrue(view.isSubjectVisible());
assert.equal(view.subject, draft.subject);
} else {
assert.isFalse(view.isSubjectVisible());
}
// Check that attachment is loaded.
if (draft.shouldHaveAttachment) {
assert.equal(view.attachments.length, 1);
}
view.assertMessageInputFocused('Message input should be focused');
}
setup(function() {
messagesApp = Messages.create(client);
storage = Storage.create(client);
client.loader.getMockManager('sms').inject([
'test_storages',
'test_blobs',
'navigator_moz_mobile_message'
]);
});
suite('Messages Drafts Test Suite', function() {
var conversation, draft, draftWithoutRecipient, inboxView;
function assertDraftIsDiscarded() {
// Check that draft is discarded and removed from inbox view.
var originalConversation = null;
client.waitFor(function() {
var conversations = inboxView.conversations;
// In discard draft case we expect exactly one conversation in inbox.
if (conversations.length !== 1) {
return false;
}
originalConversation = conversations[0];
return !originalConversation.isDraft && !originalConversation.hasDraft;
});
assert.deepEqual(originalConversation, {
id: conversation.id,
title: conversation.participants[0],
bodyText: conversation.body,
lastMessageType: 'sms',
isDraft: false,
hasDraft: false
});
}
function relaunchApp() {
messagesApp.close();
messagesApp.launch();
}
setup(function() {
conversation = ThreadGenerator.generate();
draft = {
text: 'some message',
subject: 'some subject',
recipients: ['+123'],
shouldHaveAttachment: true
};
draftWithoutRecipient = {
text: 'some message',
subject: 'some subject',
shouldHaveAttachment: true
};
storage.setMessagesStorage(
[conversation], ThreadGenerator.uniqueMessageId
);
messagesApp.launch();
inboxView = new InboxView(client);
});
suite('Conversation-less drafts', function() {
var newMessageView, originalDraftConversation;
setup(function() {
newMessageView = inboxView.createNewMessage();
originalDraftConversation = createAndSaveDraft(newMessageView, draft);
assert.deepEqual(originalDraftConversation, {
// We don't know draft id in advance, but we need every field for the
// assert.deepEqual to work.
id: originalDraftConversation.id,
isDraft: true,
hasDraft: true,
lastMessageType: 'mms',
title: draft.recipients[0],
bodyText: ''
});
inboxView.goToConversation(originalDraftConversation.id);
assertDraft(newMessageView, draft);
});
test('Draft is correctly saved', function() {
// Relaunch application and verify draft is persisted.
relaunchApp();
assert.deepEqual(
inboxView.conversations[0], originalDraftConversation
);
inboxView.goToConversation(originalDraftConversation.id);
assertDraft(newMessageView, draft);
});
test('Draft is correctly modified', function() {
var newDraft = {
text: 'content_changed',
recipients: ['+234']
};
newMessageView.clearRecipients();
newMessageView.clearMessage();
newMessageView.hideSubject();
var updatedDraftConversation = createAndSaveDraft(
newMessageView, newDraft
);
assert.deepEqual(updatedDraftConversation, {
id: originalDraftConversation.id,
isDraft: true,
hasDraft: true,
lastMessageType: 'sms',
title: newDraft.recipients[0],
bodyText: newDraft.text
});
inboxView.goToConversation(updatedDraftConversation.id);
assertDraft(newMessageView, newDraft);
// Relaunch application and verify draft is persisted.
relaunchApp();
assert.deepEqual(
inboxView.conversations[0], updatedDraftConversation
);
inboxView.goToConversation(updatedDraftConversation.id);
assertDraft(newMessageView, newDraft);
});
test('Draft is correctly discarded', function() {
discardDraft(newMessageView, draft);
assertDraftIsDiscarded();
// Relaunch application and verify draft is persistently discarded.
relaunchApp();
assertDraftIsDiscarded();
});
});
suite('Conversation-less recipient-less drafts', function() {
var newMessageView, originalDraftConversation;
test('Recipient field is focused when draft has no recipients',
function() {
newMessageView = inboxView.createNewMessage();
originalDraftConversation = createAndSaveDraft(newMessageView,
draftWithoutRecipient);
assert.deepEqual(originalDraftConversation, {
// We don't know draft id in advance, but we need every field for the
// assert.deepEqual to work.
id: originalDraftConversation.id,
isDraft: true,
hasDraft: true,
lastMessageType: 'mms',
title: '(No recipient)',
bodyText: ''
});
inboxView.goToConversation(originalDraftConversation.id);
newMessageView.assertRecipientsInputFocused(
'Message input should be focused'
);
});
});
suite('Conversation drafts', function() {
var conversationView, originalConversationWithDraft;
setup(function() {
// Conversation draft can't contain any recipients.
delete draft.recipients;
conversationView = inboxView.goToConversation(conversation.id);
originalConversationWithDraft = createAndSaveDraft(
conversationView, draft
);
assert.deepEqual(originalConversationWithDraft, {
id: conversation.id,
lastMessageType: 'mms',
title: conversation.participants[0],
bodyText: '',
isDraft: false,
hasDraft: true
});
inboxView.goToConversation(originalConversationWithDraft.id);
assertDraft(conversationView, draft);
});
test('Conversation draft is correctly saved', function() {
// Relaunch application and verify draft is persisted.
relaunchApp();
assert.deepEqual(
inboxView.conversations[0], originalConversationWithDraft
);
inboxView.goToConversation(originalConversationWithDraft.id);
assertDraft(conversationView, draft);
});
test('Conversation Draft is correctly modified', function() {
var newDraft = {
text: 'content_changed'
};
conversationView.clearMessage();
conversationView.hideSubject();
var updatedConversationWithDraft = createAndSaveDraft(
conversationView, newDraft
);
assert.deepEqual(updatedConversationWithDraft, {
id: originalConversationWithDraft.id,
lastMessageType: 'sms',
title: originalConversationWithDraft.title,
bodyText: newDraft.text,
isDraft: false,
hasDraft: true
});
inboxView.goToConversation(updatedConversationWithDraft.id);
assertDraft(conversationView, newDraft);
// Relaunch application and verify draft is persisted.
relaunchApp();
assert.deepEqual(
inboxView.conversations[0], updatedConversationWithDraft
);
inboxView.goToConversation(updatedConversationWithDraft.id);
assertDraft(conversationView, newDraft);
});
test('Conversation Draft is correctly discarded', function() {
discardDraft(conversationView, draft);
assertDraftIsDiscarded();
// Relaunch application and verify draft is persisted.
relaunchApp();
assertDraftIsDiscarded();
});
test('Conversation Draft is correctly converted to conversation-less one',
function() {
conversationView.deleteMessage(conversation.messages[0].id);
var newDraftConversation = null;
client.waitFor(function() {
var conversations = inboxView.conversations;
if (conversations.length !== 1) {
return false;
}
newDraftConversation = conversations[0];
return newDraftConversation.isDraft;
});
assert.deepEqual(newDraftConversation, {
// We don't know draft id in advance, but we need every field for the
// assert.deepEqual to work.
id: newDraftConversation.id,
lastMessageType: 'mms',
title: originalConversationWithDraft.title,
bodyText: originalConversationWithDraft.bodyText,
isDraft: true,
hasDraft: true
});
// New draft should contain recipients from the removed conversation.
var newDraft = {
text: draft.text,
subject: draft.subject,
recipients: conversation.participants,
shouldHaveAttachment: draft.shouldHaveAttachment
};
var newMessageView = inboxView.goToConversation(
newDraftConversation.id
);
assertDraft(newMessageView, newDraft);
// Relaunch application and verify draft is persisted.
relaunchApp();
assert.deepEqual(inboxView.conversations[0], newDraftConversation);
inboxView.goToConversation(newDraftConversation.id);
assertDraft(newMessageView, newDraft);
});
});
});
});

View file

@ -0,0 +1,124 @@
'use strict';
/* global module */
// TODO(gaye): This is no bueno and we need to fix.
var _ = require('SMS/node_modules/lodash');
var DEFAULT_THREAD_PARAMETERS = {
numberOfMessages: 1,
messageType: 'sms',
participants: ['+123'],
body: 'Thread message content',
baseTimestamp: Date.now()
};
var DEFAULT_MESSAGE = {
iccId: null,
sender: null,
delivery: 'sent',
timestamp: 0,
sentTimestamp: 0,
read: false
};
var DEFAULT_SMS = _.assign({
type: 'sms',
body: 'Thread message content',
receiver: null,
messageClass: 'class-1',
deliveryStatus: 'not-applicable',
deliveryTimestamp: 0
}, DEFAULT_MESSAGE);
var DEFAULT_MMS = _.assign({
type: 'mms',
receivers: [],
deliveryInfo: [{ deliveryStatus: 'not-applicable' }],
subject: 'Message subject',
smil: null,
attachments: [],
expiryDate: 0,
readReportRequested: false
}, DEFAULT_MESSAGE);
var ThreadGenerator = {
uniqueThreadId: 0,
uniqueMessageId: 0,
generate: function(parameters) {
parameters = _.assign({}, DEFAULT_THREAD_PARAMETERS, parameters);
var thread = {
id: ++ThreadGenerator.uniqueThreadId,
body: parameters.body,
lastMessageType: parameters.messageType,
timestamp: parameters.baseTimestamp,
messages: [],
participants: parameters.participants
};
for (var i = 0; i < parameters.numberOfMessages; i++) {
var message = null;
var messageBody = thread.body + ' (threadId: ' + thread.id + ')';
if (i) {
messageBody +=
' (messageId: ' + (ThreadGenerator.uniqueMessageId + 1) + ')';
}
if (parameters.messageType === 'sms') {
message = this.generateSMS({
threadId: thread.id,
receiver: parameters.participants[0],
delivery: parameters.delivery,
body: messageBody,
timestamp: parameters.baseTimestamp - i * 10000
});
} else {
message = this.generateMMS({
threadId: thread.id,
receivers: parameters.participants,
delivery: parameters.delivery,
expiryDate: parameters.expiryDate,
timestamp: parameters.baseTimestamp - i * 10000,
subject: parameters.subject,
attachments: parameters.attachments || [{
type: 'text/plain', content: messageBody
}]
});
}
thread.messages.push(message);
}
thread.timestamp = thread.messages[0].timestamp;
return thread;
},
generateSMS: function(smsParameters) {
if (!smsParameters.threadId) {
throw new Error('ThreadId should be specified!');
}
return _.assign(
{ id: smsParameters.id || ++ThreadGenerator.uniqueMessageId },
DEFAULT_SMS,
smsParameters
);
},
generateMMS: function(mmsParameters) {
if (!mmsParameters.threadId) {
throw new Error('ThreadId should be specified!');
}
return _.assign(
{ id: mmsParameters.id || ++ThreadGenerator.uniqueMessageId },
DEFAULT_MMS,
mmsParameters
);
}
};
module.exports = ThreadGenerator;

View file

@ -0,0 +1,141 @@
/* global require, marionette, setup, suite, test, __dirname */
'use strict';
var assert = require('chai').assert;
var ThreadGenerator = require('./generators/thread');
var Messages = require('./lib/messages.js');
var InboxView = require('./lib/views/inbox/view');
var Storage = require('./lib/storage.js');
marionette('Inbox View tests', function() {
var client = marionette.client({
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
var messagesApp, storage;
setup(function() {
messagesApp = Messages.create(client);
storage = Storage.create(client);
client.loader.getMockManager('sms').inject([
'test_storages',
'test_blobs',
'navigator_moz_icc_manager',
'navigator_moz_mobile_message',
'navigator_moz_contacts'
]);
});
suite('Long list of conversations', function() {
setup(function() {
var phoneNumbers = [];
for (var i = 0; i < 450; i++) {
phoneNumbers.push('+1234' + i);
}
// Only few first conversations will have photos.
var contacts = phoneNumbers.slice(0, 10).map(function(phoneNumber) {
return {
name: ['Contact ' + phoneNumber],
tel: [{ value: phoneNumber, type: 'Mobile' }],
photo: [{ width: 50, height: 50, type: 'image/jpg' }]
};
});
var conversations = phoneNumbers.map(function(phoneNumber) {
return ThreadGenerator.generate({ participants: [phoneNumber] });
});
storage.setContactsStorage(contacts);
storage.setMessagesStorage(
conversations, ThreadGenerator.uniqueMessageId
);
messagesApp.launch(true /* doNotWaitForAppToBecomeReady */);
});
test('User could navigate without waiting for the app to be fully loaded',
function() {
var inboxView = new InboxView(client);
// Make sure we've entered new message view.
var newMessageView = inboxView.createNewMessage();
newMessageView.assertRecipientsInputFocused();
// We should enter new message view even if not all messages are rendered.
assert.isFalse(inboxView.hasConversation(400));
// And rendering should still continue.
client.waitFor(function() {
return inboxView.hasConversation(400);
});
});
});
suite('Split Inbox view tests', function() {
const NUMBER_OF_CONVERSATIONS = 10;
const NUMBER_OF_MESSAGES_IN_CONVERSATION = 5;
setup(function() {
client.contentScript.inject(__dirname + '/mocks/mock_split_view_mode.js');
ThreadGenerator.uniqueThreadId = 0;
var conversations = [];
for (var i = 0; i < NUMBER_OF_CONVERSATIONS; i++) {
conversations.push(
ThreadGenerator.generate({
participants: ['+1234' + i],
numberOfMessages: NUMBER_OF_MESSAGES_IN_CONVERSATION
})
);
}
storage.setMessagesStorage(
conversations, ThreadGenerator.uniqueMessageId
);
messagesApp.launch();
});
test('All conversations are loaded', function() {
var inboxView = new InboxView(client);
client.waitFor(function() {
return inboxView.conversations.length === NUMBER_OF_CONVERSATIONS;
});
});
test('User can enter New Message view from Inbox and go back', function() {
var inboxView = new InboxView(client);
// Make sure we've entered new message view.
var newMessageView = inboxView.createNewMessage();
newMessageView.assertRecipientsInputFocused();
newMessageView.backToInbox();
client.waitFor(function() {
return inboxView.conversations.length === NUMBER_OF_CONVERSATIONS;
});
});
test('User can enter Conversation view from Inbox and go back', function() {
var inboxView = new InboxView(client);
// Make sure we've entered conversation view.
var conversationView = inboxView.goToConversation(1);
assert.equal(
conversationView.messages().length, NUMBER_OF_MESSAGES_IN_CONVERSATION
);
conversationView.backToInbox();
client.waitFor(function() {
return inboxView.conversations.length === NUMBER_OF_CONVERSATIONS;
});
});
});
});

View file

@ -0,0 +1,198 @@
'use strict';
/* global module */
var InboxAccessor = require('./views/inbox/accessors');
var NewMessageAccessor = require('./views/new-message/accessors');
var NewMessageView = require('./views/new-message/view');
var ConversationAccessor = require('./views/conversation/accessors');
var MenuAccessor = require('./views/shared/menu_accessors');
var ComposerAccessor = require('./views/shared/composer_accessors');
var ReportAccessor = require('./views/report/accessors');
var ParticipantsAccessor = require('./views/participants/accessors');
(function(module) {
var ORIGIN_URL = 'app://sms.gaiamobile.org';
var MANIFEST_URL = ORIGIN_URL + '/manifest.webapp';
// TODO Move these constants to marionette, see bug 1207516
var Chars = {
ENTER: '\ue007',
BACKSPACE: '\ue003'
};
var SELECTORS = Object.freeze({
main: '#main-wrapper',
appReady: 'body.js-app-ready',
Message: {
content: '.message-content > p:first-child',
vcardAttachment: '[data-attachment-type="vcard"]',
fileName: '.file-name'
}
});
module.exports = {
create: function(client) {
var actions = client.loader.getActions();
var newMessageView = new NewMessageView(client);
return {
Selectors: SELECTORS,
manifestURL: MANIFEST_URL,
Composer: new ComposerAccessor(client),
NewMessage: new NewMessageAccessor(client),
Conversation: new ConversationAccessor(client),
Inbox: new InboxAccessor(client),
Menu: new MenuAccessor(client),
Report: new ReportAccessor(client),
Participants: new ParticipantsAccessor(client),
launch(doNotWaitForAppToBecomeReady) {
client.switchToFrame();
client.apps.launch(ORIGIN_URL);
client.apps.switchToApp(ORIGIN_URL);
// In 99.99% cases we'd like to wait for the app to become fully ready
// before we do anything in the tests, but sometimes we'd like to
// verify that app is actionable before it's marked as ready, in this
// case we just wait for the main app container to appear.
if (!doNotWaitForAppToBecomeReady) {
client.helper.waitForElement(SELECTORS.appReady);
} else {
client.helper.waitForElement(SELECTORS.main);
}
},
close: function() {
client.apps.close(ORIGIN_URL);
},
loadMocks: function() {
client.loader.getMockManager('sms').inject([
'test_storages',
'test_blobs',
'navigator_moz_icc_manager',
'navigator_moz_mobile_message',
'navigator_moz_contacts'
]);
},
/**
* Sends system message to the Messages app using SystemMessageInternal
* class available in chrome context. Should be replaced by marionette
* apps built-in method or shared lib (see bug 1162165).
* @param {string} name Name of the system message to send.
* @param {Object} parameters Parameters object to pass with system
* message.
*/
sendSystemMessage: function(name, parameters) {
var chromeClient = client.scope({ context: 'chrome' });
chromeClient.executeScript(function(manifestURL, name, parameters) {
/* global Components, Services */
var managerClass = Components.classes[
'@mozilla.org/system-message-internal;1'
];
var systemMessageManager = managerClass.getService(
Components.interfaces.nsISystemMessagesInternal
);
systemMessageManager.sendMessage(
name,
parameters,
null, /* pageURI */
Services.io.newURI(manifestURL, null, null)
);
}, [MANIFEST_URL, name, parameters]);
},
switchTo: function() {
client.switchToFrame();
client.apps.switchToApp(ORIGIN_URL);
client.helper.waitForElement(SELECTORS.appReady);
},
waitForAppToAppear: function() {
client.switchToFrame();
var frame = client.scope({ searchTimeout: 50 }).helper.waitForElement(
'iframe[src*="' + ORIGIN_URL + '"]'
);
return frame;
},
waitForAppToDisappear: function() {
client.switchToFrame();
client.scope({ searchTimeout: 50 }).helper.waitForElementToDisappear(
'iframe[src*="' + ORIGIN_URL + '"]'
);
},
addRecipient: function(recipient) {
newMessageView.addNewRecipient(recipient);
},
getActivePanelName: function() {
var cls = client.findElement('.panel-active').getAttribute('class');
var matchResult = /\bpanel-([A-Z]\w+)\b/g.exec(cls);
return matchResult && matchResult[1];
},
getRecipient: function(number) {
return client.helper.waitForElement(
'#messages-recipients-list .recipient[data-number="' + number + '"]'
);
},
clearRecipient: function() {
newMessageView.clearRecipients();
},
send: function() {
newMessageView.send();
},
showSubject: function() {
newMessageView.showSubject();
},
hideSubject: function() {
newMessageView.hideSubject();
},
contextMenu: function(element) {
actions.longPress(element, 1).perform();
},
performHeaderAction: function() {
client.switchToShadowRoot(this.NewMessage.header);
this.NewMessage.headerActionButton.tap();
client.switchToShadowRoot();
},
performReportHeaderAction: function() {
client.switchToShadowRoot(this.Report.header);
this.Report.headerActionButton.tap();
client.switchToShadowRoot();
},
performGroupHeaderAction: function() {
client.switchToShadowRoot(this.Participants.header);
this.Participants.headerActionButton.tap();
client.switchToShadowRoot();
}
};
},
Chars: Chars
};
})(module);

View file

@ -0,0 +1,87 @@
/* global module */
(function(module) {
'use strict';
var SELECTORS = Object.freeze({
shareMenu: 'form[data-z-index-level="action-menu"]',
shareImageButton: '#share-image',
pickImageButton: '#pick-image',
pickContactButton: '#pick-contact',
sendMessageButton: '#send-message'
});
var ORIGIN = 'messages.activity.gaiamobile.org';
module.exports = {
ORIGIN: ORIGIN,
create: function(client) {
var originURL = 'app://' + ORIGIN;
return {
get shareMenu() {
// Switch to the system app first.
client.switchToFrame();
return client.helper.waitForElement(SELECTORS.shareMenu);
},
get shareWithMessagesButton() {
var shareWithOptions = this.shareMenu.findElements('button');
for (var i = 0; i < shareWithOptions.length; i++) {
var shareWithOption = shareWithOptions[i];
if (shareWithOption.text() === 'Messages') {
return shareWithOption;
}
}
},
launch: function() {
client.switchToFrame();
client.apps.launch(originURL);
client.apps.switchToApp(originURL);
},
switchTo: function() {
client.switchToFrame();
client.apps.switchToApp(originURL);
},
shareImage: function() {
client.helper.waitForElement(SELECTORS.shareImageButton).tap();
this.shareWithMessagesButton.tap();
},
pickImage: function() {
client.helper.waitForElement(SELECTORS.pickImageButton).tap();
},
pickContact: function() {
client.helper.waitForElement(SELECTORS.pickContactButton).tap();
},
/**
* Forces app to issue "websms/sms" new activity request with phone
* number and body.
* @param {{number: Number, body: string }} parameters Object that
* contains parameters to pass to "websms/sms" new activity.
*/
sendMessage: function(parameters) {
var button = client.helper.waitForElement(
SELECTORS.sendMessageButton
);
// We set button dataset attributes, that will be used by test
// activity app as "websms/sms" activity parameters.
button.scriptWith(function(element, parameters) {
Object.keys(parameters).forEach(function(parameterKey) {
element.dataset[parameterKey] = parameters[parameterKey];
});
}, [parameters]);
button.tap();
}
};
}
};
})(module);

View file

@ -0,0 +1,56 @@
/* global Components, module, Services */
(function(module) {
'use strict';
module.exports = {
create: function(client) {
return {
/**
* Sets pre-populated thread/message storage.
* @param {Array.<Thread>?} threads List of the thread to pre-populate
* storage with.
* @param {number?} uniqueMessageIdCounter Start value for the unique
* message id counter, it's used to avoid message "id" collision between
* message ids from predefined store and message ids that can be
* generated during test (e.g. send or receive new message).
*/
setMessagesStorage: function(threads, uniqueMessageIdCounter) {
this.setStorage('messagesDB', {
threads: threads || [],
uniqueMessageIdCounter: uniqueMessageIdCounter || 0
});
},
/**
* Sets pre-populated contacts storage.
* @param {Array.<MozContact>?} contacts List of the contacts to
* pre-populate storage with.
*/
setContactsStorage: function(contacts) {
this.setStorage('contactsDB', {
contacts: contacts || []
});
},
/**
* Sets pre-populated custom storage.
* @param {string} storageName Name of the custom storage.
* @param {*} storage Custom storage data.
* @private
*/
setStorage: function(storageName, storage) {
client.scope({ context: 'chrome' }).executeScript(
function(storage, storageName) {
Services.obs.addObserver(function(window) {
window.wrappedJSObject.TestStorages.set(
storageName, Components.utils.cloneInto(storage, window)
);
}, 'test-storage-ready', false);
},
[storage, storageName]
);
}
};
}
};
})(module);

View file

@ -0,0 +1,32 @@
'use strict';
/* global module */
var MenuAccessor = require('../shared/menu_accessors');
function ActivityChooserView(client) {
this.client = client;
this.menuAccessors = new MenuAccessor(client);
}
ActivityChooserView.prototype = {
choose: function(appName) {
this.menuAccessors.selectSystemMenuOption(appName);
var activityApp = this.createAppViewByName(appName);
activityApp.switchTo();
return activityApp;
},
createAppViewByName: function(appName) {
switch(appName) {
case 'Messages Activity Caller':
return require('../../messages_activity_caller.js').create(this.client);
default:
throw new Error('Unsupported app name: ' + appName);
}
}
};
module.exports = ActivityChooserView;

View file

@ -0,0 +1,132 @@
'use strict';
/* global module */
var SELECTORS = Object.freeze({
main: '.panel-ConversationView',
message: '.message',
messageBubble: '.message .bubble',
header: '#messages-header',
headerTitle: '#messages-header-text',
headerActionButton: '.action-button',
container: '#messages-container',
callButton: '#messages-call-number-button',
// TODO: Remove the next selector once Message has its own accessors
messageContent: '.message-content > p:first-child',
createNewContactMenuOption: '.contact-prompt ' +
'[data-l10n-id="createNewContact"]',
addToExistingContactOption: '.contact-prompt ' +
'[data-l10n-id="addToExistingContact"]',
carrierHeaderPhoneNumber: '#contact-carrier .phone-number',
optionsButton: '#messages-options-button'
});
var EDIT_MODE_SELECTORS = Object.freeze({
toggleSelectionButton: '#messages-check-uncheck-all-button',
editHeaderTitle: '#messages-edit-mode'
});
function ConversationAccessor(client) {
this.client = client;
this.actions = client.loader.getActions();
}
ConversationAccessor.prototype = {
get messages() {
return this.client.findElements(SELECTORS.message);
},
get message() {
return this.client.helper.waitForElement(SELECTORS.messageBubble);
},
get header() {
return this.client.helper.waitForElement(SELECTORS.header);
},
get headerTitle() {
return this.client.helper.waitForElement(SELECTORS.headerTitle);
},
get editHeaderTitle() {
return this.client.helper.waitForElement(
EDIT_MODE_SELECTORS.editHeaderTitle
);
},
get headerActionButton() {
return this.client.helper.waitForElement(SELECTORS.headerActionButton);
},
get carrierHeaderPhoneNumber() {
return this.client.helper.waitForElement(
SELECTORS.carrierHeaderPhoneNumber
);
},
get callButton() {
return this.client.helper.waitForElement(SELECTORS.callButton);
},
get optionsButton() {
return this.client.helper.waitForElement(SELECTORS.optionsButton);
},
get toggleSelectionButton() {
return this.client.helper.waitForElement(
EDIT_MODE_SELECTORS.toggleSelectionButton
);
},
get createNewContactOption() {
return this.client.helper.waitForElement(
SELECTORS.createNewContactMenuOption
);
},
get addToExistingContactOption() {
return this.client.helper.waitForElement(
SELECTORS.addToExistingContactOption
);
},
getMessageContent: function(message) {
return this.client.helper.waitForElement(
message.findElement(SELECTORS.messageContent)
);
},
findMessage: function(id) {
return this.client.findElement(
'.message[data-message-id="' + id + '"] .bubble'
);
},
scrollUp: function() {
var conversationContainer = this.client.findElement(
SELECTORS.container
);
this.actions.flick(conversationContainer, 50, 50, 50, 350).perform();
},
fakeScrollUpTo: function(scrollTop) {
var conversationContainer = this.client.findElement(
SELECTORS.container
);
conversationContainer.scriptWith(function(container, scrollTop) {
container.scrollTop = scrollTop;
}, [scrollTop]);
},
waitToAppear: function() {
var conversationPanel = this.client.helper.waitForElement(SELECTORS.main);
this.client.waitFor(function() {
return conversationPanel.location().x === 0;
});
return conversationPanel;
}
};
module.exports = ConversationAccessor;

View file

@ -0,0 +1,72 @@
'use strict';
/* global module */
var SELECTORS = Object.freeze({
main: '.message[data-message-id="{id}"]',
bubble: '.bubble',
content: '.message-content-body',
attachment: '.attachment-container',
downloadButton: 'button.download'
});
function MessageAccessor(client) {
this.client = client.scope({ searchTimeout: 50 });
this.actions = client.loader.getActions();
}
MessageAccessor.prototype = {
find: function(messageId) {
return this.client.findElement(SELECTORS.main.replace('{id}', messageId));
},
parse: function(messageNode) {
// It's workaround for the case when message node reference is stale,
// so when we try to access MarionetteElement from the node that's not
// attached to the dom we'll get an exception.
try {
var messageClass = messageNode.getAttribute('class');
var checkbox = messageNode.findElement('input[type=checkbox]');
// Since most of the messages don't contain any attachments, let's make
// it much faster.
var attachments = this.client.scope({ searchTimeout: 50 }).findElements(
SELECTORS.attachment, null /* default search method */, messageNode.id
);
return {
id: +messageNode.getAttribute('data-message-id'),
type: messageClass.indexOf('mms') >= 0 ? 'mms' : 'sms',
content: messageNode.findElement(SELECTORS.content).text(),
attachments: attachments.map(function(attachment) {
return {
screenshot: function() {
return this.client.screenshot({ element: attachment });
}.bind(this),
type: attachment.getAttribute('data-attachment-type')
};
}, this),
isDownloaded: messageClass.indexOf('not-downloaded') < 0,
isPending: messageClass.indexOf('pending') >= 0,
isInEditMode: messageNode.findElement('.pack-checkbox').displayed(),
isSelected: checkbox.getAttribute('checked') === 'true',
isDisplayed: messageNode.displayed()
};
} catch(e) {
console.warn('Message node is not available', e, e.stack);
return null;
}
},
download: function(messageId) {
var messageNode = this.find(messageId);
if (this.parse(messageNode).isDownloaded) {
throw new Error('Message has been already downloaded!');
}
messageNode.findElement(SELECTORS.downloadButton).tap();
}
};
module.exports = MessageAccessor;

View file

@ -0,0 +1,287 @@
'use strict';
/* global module */
var ConversationAccessor = require('./accessors');
var MessageAccessor = require('./message_accessors');
var ComposerAccessor = require('../shared/composer_accessors');
var MenuAccessor = require('../shared/menu_accessors');
var Tools = require('../shared/tools.js');
var appRoot = require('app-root-path');
// TODO Change the path once requireFromApp becomes its own module
var fromApp = require(appRoot + '/shared/test/integration/require_from_app');
var Contacts = fromApp('contacts').require('lib/contacts');
function ConversationView(client) {
this.client = client;
this.accessors = new ConversationAccessor(client);
this.messageAccessors = new MessageAccessor(client);
this.composerAccessors = new ComposerAccessor(client);
this.menuAccessors = new MenuAccessor(client);
}
ConversationView.prototype = {
// TODO Move these constants to marionette, see bug 1207516
KEYS: {
backspace: '\ue003'
},
get headerTitle() {
return this.accessors.headerTitle.text();
},
get editHeaderTitle() {
return this.accessors.editHeaderTitle.text();
},
get toggleSelectionButtonTitle() {
return this.accessors.toggleSelectionButton.text();
},
/**
* Returns the messages in this conversation, following an optional filter.
* @param {Object} [filter] Optional filter
* @param {Number} [filter.first] Will return the first n messages.
* @returns {ParsedMessage[]} List of the messages found.
*/
messages: function(filter) {
filter = filter || [];
var messages = this.accessors.messages;
if (filter.first) {
messages = messages.slice(0, filter.first);
}
return messages.map(function(message) {
return this.messageAccessors.parse(message);
}, this);
},
get carrierHeaderPhoneNumber() {
return this.accessors.carrierHeaderPhoneNumber.text();
},
findMessage: function(messageId) {
var messageNode = this.messageAccessors.find(messageId);
return messageNode && this.messageAccessors.parse(messageNode);
},
waitForFullRendering: function(thread) {
var lastId = thread.messages[thread.messages.length - 1].id;
this.accessors.findMessage(lastId);
},
downloadMessage: function(messageId) {
this.messageAccessors.download(messageId);
},
get attachments() {
return this.composerAccessors.attachments;
},
get messageText() {
return this.composerAccessors.messageInput.text().trim();
},
get subject() {
return this.composerAccessors.subjectInput.text().trim();
},
back: function() {
this.client.switchToShadowRoot(this.accessors.header);
this.accessors.headerActionButton.tap();
this.client.switchToShadowRoot();
},
callContact: function() {
this.accessors.callButton.tap();
var Dialer = fromApp('dialer').require('lib/dialer');
var dialer = new Dialer(this.client);
dialer.switchTo();
return dialer;
},
openCreateNewContact: function() {
this.accessors.headerTitle.tap();
this.accessors.createNewContactOption.tap();
var contacts = new Contacts(this.client);
contacts.switchToCreateNewContactActivity();
return contacts;
},
openAddToExistingContact: function() {
this.accessors.headerTitle.tap();
this.accessors.addToExistingContactOption.tap();
return this._switchToContactsApp();
},
openParticipants: function() {
this.accessors.headerTitle.tap();
var ParticipantsView = require('../participants/view');
var participantsView = new ParticipantsView(this.client);
participantsView.accessors.waitToAppear();
return participantsView;
},
openReport: function(messageId) {
var message = this.accessors.findMessage(messageId);
// Show context menu on message bubble.
this.client.loader.getActions().longPress(message, 1 /* sec */).perform();
this.menuAccessors.selectAppMenuOption('View message report');
var ReportView = require('../report/view');
var reportView = new ReportView(this.client, this);
reportView.accessors.waitToAppear();
return reportView;
},
deleteMessage: function(messageId) {
var lastMessageWillBeDeleted = this.messages().length === 1;
var message = this.accessors.findMessage(messageId);
// Show context menu on message bubble.
this.client.loader.getActions().longPress(message, 1 /* sec */).perform();
this.menuAccessors.selectAppMenuOption('Delete');
var dialogView = new (require('../dialog/view'))(this.client);
dialogView.chooseAction('Delete');
// If deleted message was the last one, user will be returned back to Inbox.
var currentView;
if (lastMessageWillBeDeleted) {
currentView = new (require('../inbox/view'))(this.client);
currentView.accessors.waitToAppear();
} else {
currentView = this;
}
return currentView;
},
clearMessage: function() {
var messageInput = this.composerAccessors.messageInput;
messageInput.tap();
while (messageInput.text() !== '') {
messageInput.sendKeys(this.KEYS.backspace);
}
},
typeMessage: function(message) {
this.composerAccessors.messageInput.sendKeys(message);
},
addAttachment: function() {
return this.composerAccessors.addAttachment();
},
/**
* Take a screenshot of an attachment thumbnail in the composer.
* @param {Number} The attachment index
* @return {String} The screenshot
*/
takeComposerAttachmentScreenshot: function(index) {
return this.composerAccessors.takeAttachmentScreenshot(index);
},
typeSubject: function(subject) {
this.composerAccessors.subjectInput.sendKeys(subject);
},
showOptions: function() {
this.accessors.optionsButton.tap();
},
showSubject: function() {
this.showOptions();
this.menuAccessors.selectAppMenuOption('Add subject');
this.client.helper.waitForElement(this.composerAccessors.subjectInput);
},
/**
* Launch the "include someone else" action.
* @returns {NewMessageView} the new message view we navigated to.
*/
includeSomeoneElse: function() {
this.showOptions();
this.menuAccessors.selectAppMenuOption('Include someone else');
var NewMessageView = require('../new-message/view');
var newMessageView = new NewMessageView(this.client);
newMessageView.accessors.waitToAppear();
return newMessageView;
},
hideSubject: function() {
this.showOptions();
this.menuAccessors.selectAppMenuOption('Remove subject');
this.client.helper.waitForElementToDisappear(
this.composerAccessors.subjectInput
);
},
enterEditMode: function() {
this.showOptions();
this.menuAccessors.selectAppMenuOption('Select Messages');
},
exitEditMode: function() {
this.back();
},
tapOnMessage: function(messageId) {
var messageNode = this.messageAccessors.find(messageId);
messageNode.scriptWith(function(node) {
node.scrollIntoView();
});
messageNode.tap();
},
toggleMessagesSelection: function() {
var header = this.accessors.header;
// This is a workaround needed as the screen is moving app is
// moving outside the screen on running tapOnMessage().
// This is needed to get the header back in the frame
header.scriptWith(function(node) {
node.scrollIntoView();
});
this.accessors.toggleSelectionButton.tap();
},
isSubjectVisible: function() {
var subjectInput = this.composerAccessors.subjectInput;
return subjectInput && this.composerAccessors.subjectInput.displayed();
},
fakeScrollUpTo: function(scrollTop) {
this.accessors.fakeScrollUpTo(scrollTop);
},
assertMessageInputFocused: function(message) {
Tools.assertElementFocused(this.composerAccessors.messageInput, message);
},
backToInbox: function() {
this.back();
var InboxView = require('../inbox/view');
var inboxView = new InboxView(this.client);
inboxView.accessors.waitToAppear();
return inboxView;
},
_switchToContactsApp: function() {
var contacts = new Contacts(this.client);
contacts.switchToApp();
return contacts;
}
};
module.exports = ConversationView;

View file

@ -0,0 +1,44 @@
'use strict';
/* global module */
var SELECTORS = Object.freeze({
container: 'form[data-type=confirm][data-subtype=menu][role=dialog]',
// The following rules should be scoped by the container.
header: 'form h1',
body: 'form p',
button: 'form button'
});
function DialogAccessor(client) {
this.client = client;
}
DialogAccessor.prototype = {
get container() {
return this.client.helper.waitForElement(SELECTORS.container);
},
get buttons() {
return this.container.findElements(SELECTORS.button);
},
get header() {
return this.container.findElement(SELECTORS.header);
},
get body() {
return this.container.findElement(SELECTORS.body);
},
waitToAppear: function() {
this.client.waitFor(function() {
var body = this.client.findElement('body');
return this.container.getAttribute('class').indexOf('visible') >= 0 &&
body.getAttribute('class').indexOf('dialog-animating') < 0;
}.bind(this));
}
};
module.exports = DialogAccessor;

View file

@ -0,0 +1,35 @@
'use strict';
/* global module */
var DialogAccessor = require('./accessors');
function DialogView(client) {
this.client = client;
this.accessors = new DialogAccessor(client);
this.accessors.waitToAppear();
}
DialogView.prototype = {
get header() {
return this.accessors.header.text();
},
get body() {
return this.accessors.body.text();
},
chooseAction: function(actionName) {
var actionButtons = this.accessors.buttons;
for (var i = 0; i < actionButtons.length; i++) {
var actionButton = actionButtons[i];
if (actionButton.text().toLowerCase() === actionName.toLowerCase()) {
actionButton.tap();
break;
}
}
}
};
module.exports = DialogView;

View file

@ -0,0 +1,74 @@
'use strict';
/* global module */
var SELECTORS = Object.freeze({
main: '.panel-InboxView',
conversation: '.threadlist-item',
smsConversation: '.threadlist-item[data-last-message-type="sms"]',
mmsConversation: '.threadlist-item[data-last-message-type="mms"]',
conversationTitle: '.threadlist-item-title',
navigateToComposerHeaderButton: '#threads-composer-link'
});
function InboxAccessor(client) {
this.client = client;
}
InboxAccessor.prototype = {
get firstConversation() {
return this.client.helper.waitForElement(SELECTORS.conversation);
},
get smsConversation() {
return this.client.helper.waitForElement(SELECTORS.smsConversation);
},
get mmsConversation() {
return this.client.helper.waitForElement(SELECTORS.mmsConversation);
},
get conversations() {
return this.client.findElements(SELECTORS.conversation);
},
get createNewMessageButton() {
return this.client.helper.waitForElement(
SELECTORS.navigateToComposerHeaderButton
);
},
findConversation: function(id) {
return this.client.findElement('#thread-' + id);
},
hasConversation: function(id) {
try {
return !!this.client.scope({ searchTimeout: 100 }).findElement(
'#thread-' + id
);
} catch(e) {
return false;
}
},
getConversationTitle: function(conversation) {
return conversation.findElement(SELECTORS.conversationTitle);
},
waitToAppear: function() {
return this.client.helper.waitForElement(SELECTORS.main);
},
// Temporary solution to keep the current tests running.
// TODO: bug 1167103
navigateToComposer: function() {
this.createNewMessageButton.tap();
},
doubleTapOnFirstConversation: function() {
this.client.loader.getActions().doubleTap(this.firstConversation).perform();
}
};
module.exports = InboxAccessor;

View file

@ -0,0 +1,76 @@
'use strict';
/* global module */
var InboxAccessor = require('./accessors');
function InboxView(client) {
this.client = client;
this.accessors = new InboxAccessor(client);
}
InboxView.prototype = {
get conversations() {
return this.accessors.conversations.reduce(function(list, conversation) {
// It's workaround for the case when conversation node reference is stale,
// so when we try to access MarionetteElement from the node that's not
// attached to the dom we'll get an exception.
try {
var bodyText = conversation.findElement('.body-text');
list.push({
id: +conversation.getAttribute('data-thread-id'),
lastMessageType: conversation.getAttribute('data-last-message-type'),
title: this.accessors.getConversationTitle(conversation).text(),
bodyText: bodyText.displayed() ? bodyText.text() : '',
isDraft: !!conversation.getAttribute('data-draft-id'),
hasDraft: conversation.getAttribute('class').indexOf('draft') !== -1
});
} catch(e) {
console.error('Conversation node is not available', e);
}
return list;
}.bind(this), []);
},
goToConversation: function(conversationId) {
var conversation = this.accessors.findConversation(conversationId);
// We should retrieve all required info from the conversation node before we
// tap on it, as in split view mode tap triggers document to change and
// the node will be discarded.
var isConversationLessDraft = !!conversation.getAttribute('data-draft-id');
conversation.tap();
return isConversationLessDraft ?
this._createNewMessageView() :
this._createConversationView();
},
hasConversation: function(conversationId) {
return this.accessors.hasConversation(conversationId);
},
createNewMessage: function() {
this.accessors.createNewMessageButton.tap();
return this._createNewMessageView();
},
_createNewMessageView: function() {
var NewMessageView = require('../new-message/view');
var newMessageView = new NewMessageView(this.client);
newMessageView.accessors.waitToAppear();
return newMessageView;
},
_createConversationView: function() {
var ConversationView = require('../conversation/view');
var conversationView = new ConversationView(this.client);
conversationView.accessors.waitToAppear();
return conversationView;
}
};
module.exports = InboxView;

View file

@ -0,0 +1,65 @@
'use strict';
/* global module */
var SELECTORS = Object.freeze({
main: '.panel-ConversationView',
toField: '#messages-to-field',
// last-child is a temporary workaround for bug 1097575
recipientsInput: '#messages-to-field [contenteditable=true]:last-child',
recipientsList: '#messages-recipients-list',
recipients: '#messages-recipients-list .recipient[contenteditable=false]',
pickContactButton: '#messages-contact-pick-button',
header: '#messages-header',
headerActionButton: '.action-button',
optionsButton: '#messages-options-button',
messageConvertNotice: '#messages-convert-notice'
});
function NewMessageAccessor(client) {
this.client = client;
}
NewMessageAccessor.prototype = {
get toField() {
return this.client.helper.waitForElement(SELECTORS.toField);
},
get recipientsInput() {
return this.client.helper.waitForElement(SELECTORS.recipientsInput);
},
get recipientsList() {
return this.client.helper.waitForElement(SELECTORS.recipientsList);
},
get recipients() {
return this.client.findElements(SELECTORS.recipients);
},
get pickContactButton() {
return this.client.helper.waitForElement(SELECTORS.pickContactButton);
},
get optionsButton() {
return this.client.helper.waitForElement(SELECTORS.optionsButton);
},
get header() {
return this.client.helper.waitForElement(SELECTORS.header);
},
get headerActionButton() {
return this.client.helper.waitForElement(SELECTORS.headerActionButton);
},
get conversionBanner() {
return this.client.findElement(SELECTORS.messageConvertNotice);
},
waitToAppear: function() {
return this.client.helper.waitForElement(SELECTORS.main);
}
};
module.exports = NewMessageAccessor;

View file

@ -0,0 +1,207 @@
'use strict';
/* global module */
var NewMessageAccessor = require('./accessors');
var ComposerAccessor = require('../shared/composer_accessors');
var MenuAccessor = require('../shared/menu_accessors');
var Tools = require('../shared/tools');
var appRoot = require('app-root-path');
// TODO Change the path once requireFromApp becomes its own module
var fromApp = require(appRoot +
'/shared/test/integration/require_from_app');
function NewMessageView(client) {
this.client = client;
this.accessors = new NewMessageAccessor(client);
this.composerAccessors = new ComposerAccessor(client);
this.menuAccessors = new MenuAccessor(client);
}
NewMessageView.prototype = {
// TODO Move these constants to marionette, see bug 1207516
KEYS: {
backspace: '\ue003',
enter: '\ue007'
},
get recipients() {
return this.accessors.recipients.map(function(recipient) {
return recipient.text();
});
},
get recipientsPhoneNumbers() {
return this.accessors.recipients.map(function(recipient) {
return recipient.getAttribute('data-number');
});
},
get attachments() {
return this.composerAccessors.attachments;
},
get messageText() {
return this.composerAccessors.messageInput.text().trim();
},
get subject() {
return this.composerAccessors.subjectInput.text().trim();
},
addNewRecipient: function(recipient, separator) {
separator = separator === undefined ? this.KEYS.enter : separator;
this.accessors.recipientsInput.sendKeys(recipient + separator);
// Since recipient.js re-renders recipients all the time (when new
// recipient is added or old is removed) and it can happen several
// times during single "add" or "remove" operation we should
// wait until Recipients View is in a final state. The problem here is
// that between "findElement" and "displayed" calls element can
// actually be removed from DOM and re-created again that will lead to
// "stale element" exception.
var toField = this.accessors.toField;
toField.scriptWith(observeElementStability);
this.client.helper.waitFor(function() {
return toField.scriptWith(function(el) {
return !!el.dataset.__stable;
});
});
},
clearRecipients: function() {
var recipientsList = this.accessors.recipientsList;
this.accessors.recipientsInput.tap(); // tapping the available space
while (recipientsList.text() !== '') {
// note: this works because in the app we handle the keys at the list
// level, but we should rather send the keys to the focussed element.
recipientsList.sendKeys(this.KEYS.backspace);
}
},
clearMessage: function() {
var messageInput = this.composerAccessors.messageInput;
messageInput.tap();
while (messageInput.text() !== '') {
messageInput.sendKeys(this.KEYS.backspace);
}
},
containsInvalidRecipients: function() {
return this.accessors.recipients.some(function(recipient) {
return recipient.getAttribute('class').indexOf('invalid') > -1;
});
},
openContactPicker: function() {
this.accessors.pickContactButton.tap();
var Contacts = fromApp('contacts').require('lib/contacts');
var contacts = new Contacts(this.client);
contacts.switchToApp();
return contacts;
},
typeMessage: function(message) {
this.composerAccessors.messageInput.sendKeys(message);
},
addAttachment: function() {
return this.composerAccessors.addAttachment();
},
/**
* Take a screenshot of an attachment thumbnail in the composer.
* @param {Number} the attachment index
* @return {String} The screenshot
*/
takeComposerAttachmentScreenshot: function(index) {
return this.composerAccessors.takeAttachmentScreenshot(index);
},
typeSubject: function(subject) {
this.composerAccessors.subjectInput.sendKeys(subject);
},
showOptions: function() {
this.accessors.optionsButton.tap();
},
showSubject: function() {
this.showOptions();
this.menuAccessors.selectAppMenuOption('Add subject');
this.client.helper.waitForElement(this.composerAccessors.subjectInput);
},
hideSubject: function() {
this.showOptions();
this.menuAccessors.selectAppMenuOption('Remove subject');
this.client.helper.waitForElementToDisappear(
this.composerAccessors.subjectInput
);
},
send: function() {
this.composerAccessors.send();
var ConversationView = require('../conversation/view');
var conversationView = new ConversationView(this.client);
conversationView.accessors.waitToAppear();
return conversationView;
},
backToInbox: function() {
this.client.switchToShadowRoot(this.accessors.header);
this.accessors.headerActionButton.tap();
this.client.switchToShadowRoot();
var InboxView = require('../inbox/view');
var inboxView = new InboxView(this.client);
inboxView.accessors.waitToAppear();
return inboxView;
},
isSendButtonEnabled: function() {
var sendButton = this.composerAccessors.sendButton;
return sendButton.getAttribute('disabled') !== 'true';
},
isSubjectVisible: function() {
var subjectInput = this.composerAccessors.subjectInput;
return subjectInput && this.composerAccessors.subjectInput.displayed();
},
assertMessageInputFocused: function(message) {
Tools.assertElementFocused(this.composerAccessors.messageInput, message);
},
assertRecipientsInputFocused: function(message) {
Tools.assertElementFocused(this.accessors.recipientsInput, message);
}
};
function observeElementStability(el) {
delete el.dataset.__stable;
function markElementAsStable() {
return setTimeout(function() {
el.dataset.__stable = 'true';
observer.disconnect();
}, 1000);
}
var timeout = markElementAsStable();
var observer = new MutationObserver(function() {
if (timeout) {
clearTimeout(timeout);
timeout = markElementAsStable();
}
});
observer.observe(el, { childList: true, subtree: true });
}
module.exports = NewMessageView;

View file

@ -0,0 +1,29 @@
'use strict';
/* global module */
var SELECTORS = Object.freeze({
main: '.panel-GroupView',
header: '#information-group-header',
headerActionButton: '.action-button'
});
function ParticipantsAccessor(client) {
this.client = client;
}
ParticipantsAccessor.prototype = {
get header() {
return this.client.helper.waitForElement(SELECTORS.header);
},
get headerActionButton() {
return this.client.helper.waitForElement(SELECTORS.headerActionButton);
},
waitToAppear: function() {
return this.client.helper.waitForElement(SELECTORS.main);
}
};
module.exports = ParticipantsAccessor;

View file

@ -0,0 +1,20 @@
'use strict';
/* global module */
var ParticipantsAccessor = require('./accessors');
function ParticipantsView(client) {
this.client = client;
this.accessors = new ParticipantsAccessor(client);
}
ParticipantsView.prototype = {
back: function() {
this.client.switchToShadowRoot(this.accessors.header);
this.accessors.headerActionButton.tap();
this.client.switchToShadowRoot();
}
};
module.exports = ParticipantsView;

View file

@ -0,0 +1,29 @@
'use strict';
/* global module */
var SELECTORS = Object.freeze({
main: '.panel-ReportView',
header: '#information-report-header',
headerActionButton: '.action-button'
});
function ReportAccessor(client) {
this.client = client;
}
ReportAccessor.prototype = {
get header() {
return this.client.helper.waitForElement(SELECTORS.header);
},
get headerActionButton() {
return this.client.helper.waitForElement(SELECTORS.headerActionButton);
},
waitToAppear: function() {
return this.client.helper.waitForElement(SELECTORS.main);
}
};
module.exports = ReportAccessor;

View file

@ -0,0 +1,23 @@
'use strict';
/* global module */
var ReportAccessor = require('./accessors');
function ReportView(client, parentView) {
this.client = client;
this.parentView = parentView;
this.accessors = new ReportAccessor(client);
}
ReportView.prototype = {
back: function() {
this.client.switchToShadowRoot(this.accessors.header);
this.accessors.headerActionButton.tap();
this.client.switchToShadowRoot();
this.parentView.accessors.waitToAppear();
return this.parentView;
}
};
module.exports = ReportView;

View file

@ -0,0 +1,72 @@
'use strict';
/* global module */
var SELECTORS = Object.freeze({
messageInput: '#messages-input',
subjectInput: '.subject-composer-input',
sendButton: '#messages-send-button',
attachButton: '#messages-attach-button',
charCounter: '.message-counter',
mmsLabel: '.mms-label',
attachment: '#messages-input .attachment-container'
});
function ComposerAccessor(client) {
this.client = client;
}
ComposerAccessor.prototype = {
get messageInput() {
return this.client.helper.waitForElement(SELECTORS.messageInput);
},
get subjectInput() {
return this.client.findElement(SELECTORS.subjectInput);
},
get sendButton() {
return this.client.helper.waitForElement(SELECTORS.sendButton);
},
get attachButton() {
return this.client.helper.waitForElement(SELECTORS.attachButton);
},
get charCounter() {
return this.client.findElement(SELECTORS.charCounter);
},
get mmsLabel() {
return this.client.findElement(SELECTORS.mmsLabel);
},
get attachments() {
return this.client.findElements(SELECTORS.attachment);
},
addAttachment: function() {
this.client.waitFor(function() {
return this.attachButton.enabled();
}.bind(this));
this.attachButton.tap();
var ActivityChooserView = require('../activity-chooser/view');
return new ActivityChooserView(this.client);
},
takeAttachmentScreenshot(index) {
return this.client.screenshot({ element: this.attachments[index] });
},
send: function() {
this.client.waitFor(function() {
return this.sendButton.enabled();
}.bind(this));
this.sendButton.tap();
}
};
module.exports = ComposerAccessor;

View file

@ -0,0 +1,57 @@
'use strict';
/* global module */
var SELECTORS = Object.freeze({
optionMenu: 'body > form[data-type=action] menu',
systemMenu: 'form[data-z-index-level="action-menu"]',
contactPromptMenu: '.contact-prompt menu'
});
function MenuAccessor(client) {
this.client = client;
}
MenuAccessor.prototype = {
get systemMenu() {
// Switch to the system app first.
this.client.switchToFrame();
return this.client.helper.waitForElement(SELECTORS.systemMenu);
},
get optionMenu() {
return this.client.helper.waitForElement(SELECTORS.optionMenu);
},
get contactPromptMenu() {
return this.client.helper.waitForElement(SELECTORS.contactPromptMenu);
},
selectAppMenuOption: function(text) {
this.selectMenuOption(this.optionMenu, text);
},
selectSystemMenuOption: function(text) {
this.selectMenuOption(this.systemMenu, text);
},
selectContactPromptMenuOption: function(text) {
this.selectMenuOption(this.contactPromptMenu, text);
},
selectMenuOption: function(menuElement, text) {
var menuOptions = menuElement.findElements('button');
for (var i = 0; i < menuOptions.length; i++) {
var menuOption = menuOptions[i];
if (menuOption.text().toLowerCase() === text.toLowerCase()) {
// XXX: Workaround until http://bugzil.la/912873 is fixed.
// Wait for 750ms to let the element be clickable
this.client.helper.wait(750);
menuOption.tap();
break;
}
}
}
};
module.exports = MenuAccessor;

View file

@ -0,0 +1,26 @@
/* global module, require */
'use strict';
var assert = require('chai').assert;
var ASSETS_DIR = './apps/sms/test/marionette/assets/';
module.exports = {
assertElementFocused: function(element, message) {
assert.isTrue(
element.scriptWith(function(el) {
return document.activeElement === el;
}),
message
);
},
/**
* @param {String} assertName The file name, relative to the asset directory.
* @returns {String} The file content in base64 form.
*/
loadAsset: function(assetName) {
var fileName = ASSETS_DIR + assetName;
return require('fs').readFileSync(fileName, { encoding: 'base64' });
}
};

View file

@ -0,0 +1,140 @@
/* global require, marionette, setup, suite, test */
'use strict';
var assert = require('chai').assert;
var Messages = require('./lib/messages.js');
var Storage = require('./lib/storage.js');
var ThreadGenerator = require('./generators/thread');
var InboxView = require('./lib/views/inbox/view');
var DialogView = require('./lib/views/dialog/view');
var NotificationList = require(
'../../../system/test/marionette/lib/notification.js'
).NotificationList;
marionette('Incoming messages tests', function() {
var client = marionette.client();
var messagesApp, storage, notificationList, system;
setup(function() {
system = client.loader.getAppClass('system');
system.waitForFullyLoaded();
messagesApp = Messages.create(client);
storage = Storage.create(client);
notificationList = new NotificationList(client);
client.loader.getMockManager('sms').inject([
'test_storages',
'test_blobs',
'navigator_moz_icc_manager',
'navigator_moz_mobile_message'
]);
});
suite('SMS received', function() {
var smsMessage;
setup(function() {
smsMessage = ThreadGenerator.generateSMS({
threadId: 1,
delivery: 'received',
sender: '+100',
body: 'Brand new message'
});
storage.setMessagesStorage([{
id: smsMessage.threadId,
body: smsMessage.body,
lastMessageType: smsMessage.type,
timestamp: smsMessage.timestamp,
messages: [smsMessage],
participants: [smsMessage.sender]
}]);
});
test('Incoming message notification is displayed correctly', function() {
// Receive 'sms-received' system message and generate notification.
messagesApp.sendSystemMessage('sms-received', smsMessage);
// We should make Messages app visible, otherwise switchToApp will not
// work.
messagesApp.launch();
// Switch to system app to be sure that notification is generated.
client.switchToFrame();
notificationList.waitForNotificationCount(1);
notificationList.refresh();
assert.equal(
notificationList.getCount({
manifestURL: messagesApp.manifestURL,
title: smsMessage.sender,
body: smsMessage.body
}),
1
);
});
});
suite('Flash message received', function() {
var class0Message;
setup(function() {
class0Message = ThreadGenerator.generateSMS({
threadId: 1,
delivery: 'received',
sender: '+123',
body: 'Class-0 New Message',
messageClass: 'class-0'
});
storage.setMessagesStorage([{
id: class0Message.threadId,
body: class0Message.body,
lastMessageType: class0Message.type,
timestamp: class0Message.timestamp,
messages: [class0Message],
participants: [class0Message.sender]
}]);
});
test('Flash message is displayed correctly', function() {
// Receive 'sms-received' system message and generate notification.
messagesApp.sendSystemMessage('sms-received', class0Message);
// We should make Messages app visible, otherwise switchToApp will not
// work.
messagesApp.launch();
// Verify dialog content.
var dialog = new DialogView(client);
assert.equal(dialog.header, class0Message.sender);
assert.equal(dialog.body, class0Message.body);
// Dismiss dialog.
dialog.chooseAction('OK');
// Since Inbox should be ready by this moment and we know that there
// shouldn't be any conversation, let's decrease search timeout,
// otherwise we'll have to wait full timeout as Marionette will try to
// find at least one conversation.
var inbox = new InboxView(client.scope({ searchTimeout: 100 }));
inbox.accessors.waitToAppear();
assert.equal(inbox.conversations.length, 0);
// We should not generate any notifications for class-0 messages, so
// switch to system app to be sure that notification is not generated.
client.switchToFrame();
notificationList.refresh();
assert.equal(
notificationList.getCount({ manifestURL: messagesApp.manifestURL }), 0
);
});
});
});

View file

@ -0,0 +1,137 @@
/* global Components, Services, PhoneNumberUtils */
'use strict';
const Cu = Components.utils;
Cu.import('resource://gre/modules/Services.jsm');
Cu.import('resource://gre/modules/PhoneNumberUtils.jsm');
/**
* Mock object of navigator.mozContacts used in marionette js tests.
* Developers using this mock should follow these rules:
* - non-primitive types that are returned from the mock should be wrapped with
* Components.utils.cloneInto - to create clone of data to be passed from
* privileged (chrome) code to less privileged (content) one, so that content
* won't be able to change source data directly;
* - mock returns results immediately, without any delay.
*/
Services.obs.addObserver(function(document) {
if (!document || !document.location) {
return;
}
var window = document.defaultView;
function rehydratePhotos(contacts) {
let photoPromises = [];
contacts.forEach((contact) => {
contact.photo && contact.photo.forEach((photo, i) => {
photoPromises.push(
window.wrappedJSObject.TestBlobs.generateImageBlob(
photo.width, photo.height, photo.type
).then(
(blob) => contact.photo[i] = blob
)
);
});
});
return Promise.all(photoPromises);
}
var storagePromise = null;
function getStorage() {
if (!storagePromise) {
var storage = window.wrappedJSObject.TestStorages &&
window.wrappedJSObject.TestStorages.get('contactsDB');
storagePromise = storage ?
rehydratePhotos(storage.contacts).then(() => storage) :
Promise.resolve({ contacts: [] });
}
return storagePromise;
}
function contains(items, value) {
return items && items.length && items.some(
(item) => (typeof item === 'string' ? item : item.value).includes(value)
);
}
/**
* Very simplified search strategies, see real "find" implementation at:
* http://mxr.mozilla.org/mozilla-central/source/dom/contacts/
* @param {Object} filter Filter object.
* @returns {Function} Search strategy function that accepts contact and
* returns boolean indicating that contact matches filter.
*/
function getFilterStrategy(filter) {
var isInvalidFilter = !filter ||
!filter.filterValue ||
!filter.filterOp ||
!filter.filterBy ||
!filter.filterBy.length;
if (isInvalidFilter) {
return null;
}
// Contacts.findByPhoneNumber case
if (filter.filterBy.length === 1 && filter.filterBy[0] === 'tel' &&
filter.filterOp === 'match') {
var number = PhoneNumberUtils.normalize(
filter.filterValue,
/* numbers only */ true
);
if (!number.length) {
return null;
}
return (contact) => contains(contact.tel, number);
}
if (filter.filterOp === 'contains') {
return (contact) => {
return !!filter.filterBy.find(
(filterBy) => contains(contact[filterBy], filter.filterValue)
);
};
}
return null;
}
var Contacts = {
find: function(filter) {
var request = Services.DOMRequest.createRequest(window);
var filterStrategy = getFilterStrategy(filter);
if (!filterStrategy) {
Services.DOMRequest.fireErrorAsync(request, 'Filter is not supported');
return request;
}
getStorage().then((storage) => {
var contacts = storage.contacts.filter(filterStrategy);
// See description at the top of the file about "cloneInto" necessity.
Services.DOMRequest.fireSuccessAsync(
request, Cu.cloneInto(contacts, window)
);
});
return request;
},
addEventListener: () => {}
};
Object.defineProperty(window.wrappedJSObject.navigator, 'mozContacts', {
configurable: false,
writable: true,
value: Cu.cloneInto(Contacts, window, {cloneFunctions: true})
});
}, 'document-element-inserted', false);

View file

@ -0,0 +1,447 @@
/* global Components, Services */
'use strict';
const Cu = Components.utils;
Cu.import('resource://gre/modules/Services.jsm');
/**
* Mock object of navigator.mozMobileMessage used in marionette js tests.
* Developers using this mock should follow these rules:
* - non-primitive types that are returned from the mock should be wrapped with
* Components.utils.cloneInto - to create clone of data to be passed from
* privileged (chrome) code to less privileged (content) one, so that content
* won't be able to change source data directly;
* - mock returns results immediately, without any delay.
*/
Services.obs.addObserver(function(document) {
if (!document || !document.location) {
return;
}
var window = document.defaultView;
var eventHandlers = new Map();
function callEventHandlers(eventName, parameters) {
var handlers = eventHandlers.get(eventName);
if (handlers) {
handlers.forEach(function(handler) {
window.setTimeout(() => handler.call(null, parameters), 0);
});
}
}
function rehydrateAttachments(messages) {
var attachmentPromises = [];
messages.forEach((message) => {
message.attachments && message.attachments.forEach((attachment, i) => {
var blobPromise;
if (attachment.type.startsWith('image/')) {
blobPromise = window.wrappedJSObject.TestBlobs.generateImageBlob(
attachment.width, attachment.height, attachment.type
);
} else {
blobPromise = Promise.resolve(
new window.Blob([attachment.content], { type: attachment.type })
);
}
attachmentPromises.push(
blobPromise.then((blob) => {
message.attachments[i] = Cu.cloneInto({ content: blob }, window);
})
);
});
});
return Promise.all(attachmentPromises);
}
var storagePromise = null;
function getStorage() {
if (storagePromise) {
return storagePromise;
}
let storage = window.wrappedJSObject.TestStorages &&
window.wrappedJSObject.TestStorages.get('messagesDB');
if (!storage) {
storagePromise = Promise.resolve({
threads: new Map(),
messages: new Map(),
recipientToThreadId: new Map(),
uniqueMessageIdCounter: 0
});
} else {
let recipientToThreadId = new Map();
let messages = new Map();
let threads = new Map(
storage.threads.map((thread) => {
recipientToThreadId.set(thread.participants[0], thread.id);
// Messages should be placed into dedicated map to simplify
// message-based manipulations.
var threadMessages;
if (thread.messages) {
threadMessages = new Set(thread.messages.map((message) => {
messages.set(message.id, message);
return message.id;
}));
}
return [
thread.id,
Object.assign({}, thread, { messages: threadMessages || new Set() })
];
})
);
storagePromise = rehydrateAttachments(messages).then(() => {
return {
threads,
messages,
recipientToThreadId,
uniqueMessageIdCounter: storage.uniqueMessageIdCounter
};
});
}
return storagePromise;
}
function getOrCreateThreadForRecipient(storage, recipient) {
var threadId = storage.recipientToThreadId.get(recipient);
if (!threadId) {
threadId = storage.recipientToThreadId.size + 1;
storage.threads.set(threadId, { id: threadId, messages: new Set() });
storage.recipientToThreadId.set(recipient, threadId);
}
return storage.threads.get(threadId);
}
function createMessage(parameters) {
return getStorage().then(function(storage) {
var thread = getOrCreateThreadForRecipient(
storage,
parameters.receiver || parameters.receivers[0]
);
var message = {
id: ++storage.uniqueMessageIdCounter,
iccId: null,
threadId: thread.id,
sender: null,
receivers: parameters.receivers,
receiver: parameters.receiver,
type: parameters.type,
delivery: 'sending',
deliveryInfo: [{
receiver: null,
deliveryStatus: 'not-applicable',
readStatus: 'not-applicable'
}],
subject: parameters.subject,
smil: parameters.smil,
body: parameters.body,
attachments: parameters.attachments,
timestamp: Date.now(),
sentTimestamp: Date.now(),
read: true
};
storage.messages.set(message.id, message);
thread.messages.add(message.id);
return message;
});
}
function getMessageParticipants(message) {
var numbers = [];
if (message.sender) {
numbers.push(message.sender);
}
if (message.receiver) {
numbers.push(message.receiver);
}
if (message.receivers) {
numbers.push(...message.receivers);
}
return numbers;
}
function getMessagesWithFilter(storage, filter) {
var messages = [];
if (filter.threadId) {
var threadByFilter = storage.threads.get(filter.threadId);
if (threadByFilter) {
messages = Array.from(threadByFilter.messages).map(
(messageId) => storage.messages.get(messageId)
);
}
} else if (filter.numbers && filter.numbers.length) {
messages = Array.from(storage.messages.values()).filter((message) => {
// It's very simplified number matching logic, in real code matching
// strategy is more complex and based on PhoneNumberUtils.
return getMessageParticipants(message).some(
(number) => filter.numbers.indexOf(number) >= 0
);
});
}
return new Set(messages).values();
}
var MobileMessage = {
getSegmentInfoForText: function(text) {
var request = Services.DOMRequest.createRequest(window);
var segmentLength = 160;
var charsUsedInLastSegment = (text.length % segmentLength);
// Very simple example of message segmentation logic, in reality it's more
// complex, e.g. see real "calculateLength" method here:
// https://android.googlesource.com/platform/frameworks/opt/telephony/+/android-5.0.1_r1/src/java/android/telephony/SmsMessage.java
var result = {
segments: Math.ceil(text.length / segmentLength),
charsAvailableInLastSegment: charsUsedInLastSegment ?
segmentLength - charsUsedInLastSegment : 0
};
// See description at the top of the file about "cloneInto" necessity.
Services.DOMRequest.fireSuccessAsync(
request, Cu.cloneInto(result, window)
);
return request;
},
sendMMS: function(parameters, options) {
var request = Services.DOMRequest.createRequest(window);
parameters.type = 'mms';
createMessage(parameters, options).then(function(message) {
Services.DOMRequest.fireSuccessAsync(request, null);
// See description at the top of the file about "cloneInto" necessity.
callEventHandlers(
'sending', Cu.cloneInto({ message: message }, window)
);
});
return request;
},
send: function(recipients, content, options) {
// If we don't clone array here, content code will complain that it
// can't access "length" array property, also we clone only "array" here
// as we should not clone DOMRequest itself.
var requests = Cu.cloneInto([], window);
return recipients.reduce((requests, receiver) => {
var request = Services.DOMRequest.createRequest(window);
var parameters = {
type: 'sms',
body: content,
receiver: receiver
};
createMessage(parameters, options).then(function(message) {
// Since it's test we just return result immediately
Services.DOMRequest.fireSuccessAsync(request, null);
// See description at the top of the file about "cloneInto" necessity.
callEventHandlers(
'sending', Cu.cloneInto({ message: message }, window)
);
});
requests.push(request);
return requests;
}, requests);
},
getThreads: function() {
var threads = null;
var handleCursor = function() {
var iteratorResult = threads.next();
if (!iteratorResult.done) {
// See description at the top of the file about "cloneInto" necessity.
Services.DOMRequest.fireSuccessAsync(
cursor, Cu.cloneInto(iteratorResult.value, window)
);
} else {
Services.DOMRequest.fireDone(cursor);
}
};
var cursor = Services.DOMRequest.createCursor(window, handleCursor);
getStorage().then(function(storage) {
threads = storage.threads.values();
handleCursor();
});
return cursor;
},
getMessages: function(filter) {
var messages = null;
var handleCursor = function() {
var iteratorResult = messages.next();
if (!iteratorResult.done) {
// See description at the top of the file about "cloneInto" necessity.
Services.DOMRequest.fireSuccessAsync(
cursor, Cu.cloneInto(iteratorResult.value, window)
);
} else {
Services.DOMRequest.fireDone(cursor);
}
};
var cursor = Services.DOMRequest.createCursor(window, handleCursor);
if (filter) {
getStorage().then((storage) => {
messages = getMessagesWithFilter(storage, filter);
handleCursor();
});
} else {
Services.DOMRequest.fireErrorAsync(cursor, 'Filter is not defined');
}
return cursor;
},
getMessage: function(id) {
var request = Services.DOMRequest.createRequest(window);
getStorage().then(function(storage) {
if (storage.messages.has(id)) {
// See description at the top of the file about "cloneInto" necessity.
Services.DOMRequest.fireSuccessAsync(
request, Cu.cloneInto(storage.messages.get(id), window)
);
} else {
Services.DOMRequest.fireErrorAsync(request, 'No message found!');
}
});
return request;
},
delete: function(id) {
var request = Services.DOMRequest.createRequest(window);
var messageToDeleteIds = Array.isArray(id) ? id : [id];
getStorage().then((storage) => {
var deletedThreadIds = [];
var deletedMessageIds = messageToDeleteIds.filter((messageId) => {
var message = storage.messages.get(messageId);
if (!message) {
return false;
}
// Delete message from global messages map.
storage.messages.delete(messageId);
// Delete message from thread message id list.
var thread = storage.threads.get(message.threadId);
thread.messages.delete(messageId);
// If thread doesn't contain any messages, delete thread as well.
if (!thread.messages.size) {
storage.threads.delete(thread.id);
deletedThreadIds.push(thread.id);
}
return true;
});
if (deletedMessageIds.length) {
// See description at the top of the file about "cloneInto" necessity.
Services.DOMRequest.fireSuccess(request, null);
callEventHandlers(
'deleted',
Cu.cloneInto({
deletedMessageIds: deletedMessageIds,
deletedThreadIds: deletedThreadIds
}, window)
);
} else {
Services.DOMRequest.fireError(request, 'No message found!');
}
});
return request;
},
markMessageRead: function() {
var request = Services.DOMRequest.createRequest(window);
Services.DOMRequest.fireSuccessAsync(request, null);
return request;
},
retrieveMMS: function(messageId) {
var request = Services.DOMRequest.createRequest(window);
getStorage().then((storage) => {
var notDownloadedMMS = storage.messages.get(messageId);
if (notDownloadedMMS && notDownloadedMMS.type === 'mms' &&
notDownloadedMMS.delivery === 'not-downloaded') {
Services.DOMRequest.fireSuccess(request, null);
notDownloadedMMS.delivery = 'received';
callEventHandlers(
'received', Cu.cloneInto({ message: notDownloadedMMS }, window)
);
} else {
Services.DOMRequest.fireError(
request, 'No message to download found!'
);
}
});
return request;
},
addEventListener: function(event, handler) {
var listeners = eventHandlers.get(event) || [];
listeners.push(handler);
eventHandlers.set(event, listeners);
}
};
var appWindow = window.wrappedJSObject;
Object.defineProperty(appWindow.navigator, 'mozMobileMessage', {
configurable: false,
// If we're in iframe we should use parent's mozMobileMessage instead.
value: appWindow.parent !== appWindow ?
appWindow.parent.navigator.mozMobileMessage :
Cu.cloneInto(MobileMessage, window, { cloneFunctions: true })
});
}, 'document-element-inserted', false);

View file

@ -0,0 +1,19 @@
/* global Services */
'use strict';
/**
* This mock is intended to switch split-view mode on if needed. Under the hood
* it just forwards user from default launch path to the split Inbox view
* index.html file.
* @module
*/
Services.obs.addObserver(function(document) {
if (!document || !document.location ||
document.location.origin !== 'app://sms.gaiamobile.org') {
return;
}
// Switch to split-view mode Inbox instead.
if (document.location.pathname === '/index.html') {
document.location.replace('/views/inbox/index.html');
}
}, 'document-element-inserted', false);

View file

@ -0,0 +1,51 @@
/* global Components, Services */
'use strict';
const Cu = Components.utils;
Services.obs.addObserver(function(document) {
if (!document || !document.location) {
return;
}
function generateCanvas(width, height) {
var canvas = document.createElement('canvas');
var context = canvas.getContext('2d');
canvas.width = width;
canvas.height = height;
var linearGradient = context.createLinearGradient(0, 0, width, height);
linearGradient.addColorStop(0, 'blue');
linearGradient.addColorStop(1, 'red');
context.fillStyle = linearGradient;
context.fillRect (0, 0, width, height);
return canvas;
}
let TestBlobs = {
generateImageBlob(width, height, type, quality) {
var canvas = generateCanvas(width, height);
return new Promise((resolve) => {
canvas.toBlob((blob) => {
canvas.width = canvas.height = 0;
canvas = null;
resolve(blob);
}, type, quality);
});
}
};
var window = document.defaultView;
Object.defineProperty(window.wrappedJSObject, 'TestBlobs', {
// The property should be writable since mock is inserted/rewritten in
// setup function that is called for every test in the suite.
writable: true,
value: Cu.cloneInto(TestBlobs, window, { cloneFunctions: true })
});
}, 'document-element-inserted', false);

View file

@ -0,0 +1,16 @@
/* global Services */
'use strict';
Services.obs.addObserver(function(document) {
if (!document || !document.location) {
return;
}
var window = document.defaultView;
Object.defineProperty(window.wrappedJSObject, 'TestStorages', {
value: new window.wrappedJSObject.Map()
});
Services.obs.notifyObservers(window, 'test-storage-ready', null);
}, 'document-element-inserted', false);

View file

@ -0,0 +1,59 @@
/* global require, marionette, setup, suite, test */
'use strict';
var assert = require('chai').assert;
var ThreadGenerator = require('./generators/thread');
var Messages = require('./lib/messages.js');
var Storage = require('./lib/storage.js');
marionette('Navigation Tests', function() {
var client = marionette.client();
var messagesApp, storage;
function assertIsDisplayed(element) {
assert.isTrue(element.displayed(), 'Element should be displayed');
}
setup(function() {
messagesApp = Messages.create(client);
storage = Storage.create(client);
client.loader.getMockManager('sms').inject([
'test_storages',
'test_blobs',
'navigator_moz_icc_manager',
'navigator_moz_mobile_message',
'navigator_moz_contacts'
]);
});
suite('Edge case use cases', function() {
suite('Double tap', function() {
var thread;
setup(function() {
thread = ThreadGenerator.generate({
numberOfMessages: 10
});
storage.setMessagesStorage([thread], ThreadGenerator.uniqueMessageId);
messagesApp.launch();
});
test('Double tap on conversation and back', function() {
messagesApp.Inbox.doubleTapOnFirstConversation();
// Composer panel should always be visible
assertIsDisplayed(messagesApp.Composer.sendButton);
assertIsDisplayed(messagesApp.Composer.messageInput);
messagesApp.performHeaderAction();
// Wait for the thread list to appear
messagesApp.Inbox.waitToAppear();
assertIsDisplayed(messagesApp.Inbox.firstConversation);
});
});
});
});

View file

@ -0,0 +1,258 @@
/* global require, marionette, setup, suite, test, __dirname */
'use strict';
var assert = require('chai').assert;
var Messages = require('./lib/messages.js');
var MessagesActivityCaller = require('./lib/messages_activity_caller.js');
var Storage = require('./lib/storage.js');
marionette('Messages as "new" activity target', function() {
var apps = {};
apps[MessagesActivityCaller.ORIGIN] = __dirname + '/apps/activitycaller';
var client = marionette.client({
profile: {
apps: apps
}
});
var messagesApp, activityCallerApp, storage;
function launchAsActivity(activityData) {
var smsMessage = {
id: 1,
iccId: null,
threadId: 1,
sender: null,
receiver: '+200',
type: 'sms',
delivery: 'sent',
body: 'Workload message',
timestamp: Date.now()
};
storage.setMessagesStorage([{
id: smsMessage.threadId,
body: smsMessage.body,
lastMessageType: smsMessage.type,
timestamp: smsMessage.timestamp,
messages: [smsMessage],
participants: [smsMessage.receiver]
}]);
storage.setContactsStorage([{
name: ['Alan Turing'],
tel: [{
value: '+100',
type: 'Mobile'
}]
}]);
activityCallerApp.sendMessage(activityData);
messagesApp.switchTo();
}
function assertIsFocused(element, message) {
assert.isTrue(element.scriptWith(function(el) {
return document.activeElement === el;
}), message);
}
setup(function() {
messagesApp = Messages.create(client);
activityCallerApp = MessagesActivityCaller.create(client);
storage = Storage.create(client);
client.loader.getMockManager('sms').inject([
'test_storages',
'test_blobs',
'navigator_moz_icc_manager',
'navigator_moz_mobile_message',
'navigator_moz_contacts'
]);
});
suite('Send new message', function() {
var content = 'Let\'s rock - http://mozilla.org!';
var number = '+123456';
setup(function() {
activityCallerApp.launch();
});
test('Activity with malicious content', function() {
var maliciousContent =
content + '<img src="/" onerror="delete window.Compose;" />';
launchAsActivity({ number: number, body: maliciousContent });
// Wait until message input is filled with the content
client.scope({ searchTimeout: 100 }).waitFor(function() {
return messagesApp.Composer.messageInput.text() === maliciousContent;
});
// Verify that xss wasn't successful for composer case
assert.isTrue(client.executeScript(function() {
return !!window.wrappedJSObject.Compose;
}), 'XSS should not be performed');
client.waitFor(() => messagesApp.Composer.sendButton.enabled());
assert.equal(
messagesApp.NewMessage.header.getAttribute('action'), 'close',
'Close activity button should be visible'
);
messagesApp.send();
// Verify that everything is fine with rendered message content
var message = messagesApp.Conversation.message;
assert.equal(
messagesApp.Conversation.getMessageContent(message).text(),
maliciousContent
);
// Validate that header is correctly set even that thread list hasn't
// been loaded yet.
assert.equal(messagesApp.Conversation.headerTitle.text(), number);
assert.equal(
messagesApp.NewMessage.header.getAttribute('action'), 'close',
'Close activity button should be visible'
);
// Verify that xss wasn't successful for thread case
assert.isTrue(client.executeScript(function() {
return !!window.wrappedJSObject.Compose;
}), 'XSS should not be performed');
});
test('Activity with "body" only', function() {
launchAsActivity({ body: content });
// Wait until message input is filled with the content
client.waitFor(
() => messagesApp.Composer.messageInput.text() === content
);
assert.isFalse(messagesApp.Composer.sendButton.enabled());
assert.equal(messagesApp.NewMessage.recipients.length, 0);
assert.equal(
messagesApp.NewMessage.header.getAttribute('action'), 'close',
'Close activity button should be visible'
);
assertIsFocused(
messagesApp.NewMessage.recipientsInput,
'Recipient input should be focused'
);
});
test('Activity with "body" and unknown "number"', function() {
launchAsActivity({ number: number, body: content });
// Wait until message input is filled with the content
client.waitFor(
() => messagesApp.Composer.messageInput.text() === content
);
var recipients = messagesApp.NewMessage.recipients;
assert.equal(recipients.length, 1);
assert.equal(recipients[0].text(), number);
assert.equal(recipients[0].getAttribute('data-source'), 'manual');
client.waitFor(() => messagesApp.Composer.sendButton.enabled());
assert.equal(
messagesApp.NewMessage.header.getAttribute('action'), 'close',
'Close activity button should be visible'
);
assertIsFocused(
messagesApp.Composer.messageInput, 'Message input should be focused'
);
});
test('Activity with "body" and contact "number"', function() {
// Send message to number that has contact associated with it
launchAsActivity({ number: '+100', body: content });
// Wait until message input is filled with the content
client.waitFor(
() => messagesApp.Composer.messageInput.text() === content
);
var recipients = messagesApp.NewMessage.recipients;
assert.equal(recipients.length, 1);
assert.equal(recipients[0].text(), 'Alan Turing');
assert.equal(recipients[0].getAttribute('data-source'), 'contacts');
client.waitFor(() => messagesApp.Composer.sendButton.enabled());
assert.equal(
messagesApp.NewMessage.header.getAttribute('action'), 'close',
'Close activity button should be visible'
);
assertIsFocused(
messagesApp.Composer.messageInput, 'Message input should be focused'
);
});
test('Activity with thread "number"', function() {
// Send message to number that has thread associated with it
launchAsActivity({ number: '+200' });
// Verify that we entered the right thread
var message = messagesApp.Conversation.message;
assert.equal(
messagesApp.Conversation.getMessageContent(message).text(),
'Workload message'
);
assert.isFalse(messagesApp.Composer.sendButton.enabled());
assert.equal(
messagesApp.NewMessage.header.getAttribute('action'), 'close',
'Close activity button should be visible'
);
// Validate that header is correctly set even that thread list hasn't
// been loaded yet.
assert.equal(messagesApp.Conversation.headerTitle.text(), '+200');
assertIsFocused(
messagesApp.Composer.messageInput, 'Message input should be focused'
);
});
test('Activity with thread "number" and "body"', function() {
// Send message to number that has thread associated with it
launchAsActivity({ number: '+200', body: content });
// Wait until message input is filled with the content
client.waitFor(
() => messagesApp.Composer.messageInput.text() === content
);
var recipients = messagesApp.NewMessage.recipients;
assert.equal(recipients.length, 1);
assert.equal(recipients[0].text(), '+200');
assert.equal(recipients[0].getAttribute('data-source'), 'manual');
client.waitFor(() => messagesApp.Composer.sendButton.enabled());
assert.equal(
messagesApp.NewMessage.header.getAttribute('action'), 'close',
'Close activity button should be visible'
);
assertIsFocused(
messagesApp.Composer.messageInput, 'Message input should be focused'
);
});
});
});

View file

@ -0,0 +1,279 @@
/* global require, marionette, setup, suite, test */
'use strict';
var assert = require('chai').assert;
var ThreadGenerator = require('./generators/thread');
var Messages = require('./lib/messages.js');
var Storage = require('./lib/storage.js');
var InboxView = require('./lib/views/inbox/view');
var UtilityTray = require('../../../system/test/marionette/lib/utility_tray');
var NotificationList = require(
'../../../system/test/marionette/lib/notification.js'
).NotificationList;
marionette('Message notification tests', function() {
var client = marionette.client({
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
var messagesApp, storage, notificationList, utilityTray, system;
function assertIsNotFocused(element, message) {
assert.isTrue(element.scriptWith(function(el) {
return document.activeElement !== el;
}), message);
}
setup(function() {
system = client.loader.getAppClass('system');
system.waitForFullyLoaded();
messagesApp = Messages.create(client);
storage = Storage.create(client);
notificationList = new NotificationList(client);
utilityTray = new UtilityTray(client);
client.loader.getMockManager('sms').inject([
'test_storages',
'test_blobs',
'navigator_moz_icc_manager',
'navigator_moz_mobile_message'
]);
});
suite('Run application via notification', function() {
var smsMessage;
function openNotification() {
client.switchToFrame();
notificationList.waitForNotificationCount(1);
utilityTray.open();
utilityTray.waitForOpened();
// Make sure we have our notification to click and tap on it.
notificationList.refresh();
notificationList.tap(
notificationList.getForApp(messagesApp.manifestURL)[0]
);
// Switch to messages so that it's able to remove notification.
messagesApp.switchTo();
// Make sure we enter conversation view directly.
assert.equal(messagesApp.getActivePanelName(), 'ConversationView');
// Verify that notification has been removed.
client.switchToFrame();
notificationList.waitForNotificationCount(0);
messagesApp.switchTo();
}
function removeNotification() {
client.switchToFrame();
notificationList.waitForNotificationCount(1);
utilityTray.open();
utilityTray.waitForOpened();
// Make sure we have our notification to remove.
notificationList.refresh();
var notificationNode = client.helper.waitForElement(
notificationList.getForApp(messagesApp.manifestURL)[0].query
);
// flick() sends a sequence of touch events that allows us to simulate
// swipe from left (x1=5) to right (x2=125) within 100ms period of time.
client.loader.getActions().flick(
notificationNode, 5, 15, 125, 15, 100
).perform();
utilityTray.close();
utilityTray.waitForClosed();
// Switch to messages so that it's able to remove notification.
messagesApp.launch();
messagesApp.switchTo();
// Verify that notification has been removed.
client.switchToFrame();
notificationList.waitForNotificationCount(0);
messagesApp.switchTo();
}
function assertMessagesIsInCorrectState() {
// Verify that we entered the right thread
var message = messagesApp.Conversation.message;
assert.equal(
messagesApp.Conversation.getMessageContent(message).text(),
smsMessage.body
);
assert.isFalse(messagesApp.Composer.sendButton.enabled());
// Validate that header is correctly set even that thread list hasn't
// been loaded yet.
assert.equal(
messagesApp.Conversation.headerTitle.text(), smsMessage.sender
);
// When we open thread from notification, message input should not be
// focused.
assertIsNotFocused(
messagesApp.Composer.messageInput, 'Message input should not be focused'
);
}
setup(function() {
smsMessage = {
id: 1,
iccId: null,
threadId: 1,
sender: '+100',
receiver: null,
type: 'sms',
delivery: 'received',
body: 'Workload message',
timestamp: Date.now()
};
storage.setMessagesStorage([{
id: smsMessage.threadId,
body: smsMessage.body,
lastMessageType: smsMessage.type,
timestamp: smsMessage.timestamp,
messages: [smsMessage],
participants: [smsMessage.sender]
}]);
});
test('when "notification" system message is generated', function() {
// Receive 'notification' system message and fill up the storage.
messagesApp.sendSystemMessage('notification', {
clicked: true,
data: { threadId: smsMessage.threadId, id: smsMessage.id }
});
messagesApp.switchTo();
assertMessagesIsInCorrectState();
});
test('when user taps on notification in Utility tray', function() {
// Receive 'sms-received' system message and generate notification.
messagesApp.sendSystemMessage('sms-received', smsMessage);
// We should make Messages app visible, otherwise switchToApp won't work.
messagesApp.launch();
// Switch to system app to be sure that notification is generated.
client.switchToFrame();
notificationList.waitForNotificationCount(1);
// Close Message app since we want to run fresh instance.
messagesApp.close();
openNotification();
assertMessagesIsInCorrectState();
// Let's go to the inbox and see if we still can receive new messages.
messagesApp.switchTo();
messagesApp.performHeaderAction();
messagesApp.Inbox.waitToAppear();
messagesApp.sendSystemMessage('sms-received', smsMessage);
openNotification();
assertMessagesIsInCorrectState();
});
test('when user removes notification from Utility tray', function() {
// Receive 'sms-received' system message and generate notification.
messagesApp.sendSystemMessage('sms-received', smsMessage);
// We should make Messages app visible, otherwise switchToApp won't work.
messagesApp.launch();
// Switch to system app to be sure that notification is generated.
client.switchToFrame();
notificationList.waitForNotificationCount(1);
// Close Message app since we want to run fresh instance.
messagesApp.close();
removeNotification();
// When notication is removed Messages is run to handle corresponding
// system message, in this case app should be in valid state i.e. it
// should have correctly initialized inbox view.
var inboxView = new InboxView(client);
var conversations = inboxView.conversations;
assert.equal(conversations.length, 1);
assert.equal(conversations[0].title, smsMessage.sender);
// Let's make sure that we still can receive new messages.
messagesApp.sendSystemMessage('sms-received', smsMessage);
openNotification();
assertMessagesIsInCorrectState();
});
});
suite('Display a conversation from a notification', function() {
var thread1, thread2;
setup(function() {
thread1 = ThreadGenerator.generate({
numberOfMessages: 500,
participants: ['999']
});
thread2 = ThreadGenerator.generate({
numberOfMessages: 5,
participants: ['888']
});
storage.setMessagesStorage(
[thread1, thread2], ThreadGenerator.uniqueMessageId
);
});
test('Clicking a notification while a conversation is loading', function() {
messagesApp.launch();
var inbox = new InboxView(client);
var conversation = inbox.goToConversation(thread1.id);
// Receive 'notification' system message and fill up the storage.
messagesApp.sendSystemMessage('notification', {
clicked: true,
data: { threadId: thread2.id, id: thread2.messages[0].id }
});
var thread2Ids = thread2.messages.map(
function(message) { return message.id; }
);
// Our testing strategy is:
// 1. Wait that all messages are rendered for thread2.
thread2Ids.forEach(function(messageId) {
conversation.findMessage(messageId);
});
// 2. Look at all rendered messages and see if one of these is part of
// thread1.
conversation.messages(
{ first: thread2Ids.length + 1 }
).forEach(function(message) {
assert.include(
thread2Ids, message.id,
'Message ' + message.id + ' is in thread 2'
);
});
});
});
});

View file

@ -0,0 +1,63 @@
'use strict';
var assert = require('assert');
var TARGET_APP = 'app://sms.gaiamobile.org';
marionette('mozPhoneNumberService: ', function() {
var client = marionette.client();
var fuzzyMatchTests = [
['123', '123', true, 'match same number'],
['abcdef', '222333', true, 'normalize first numer match'],
['1234567', '1234568', false, 'different numbers fail'],
['1234567', '123456---', false, 'invalid number not match valid one'],
['111', undefined, false, 'missing second argument not match'],
[undefined, '222', false, 'missing first argument not match'],
[null, '', true, 'missing argument matched empty string'],
['+552155555555', '2155555555', true, 'internationalize'],
['aaa123456789', 'zzzzz123456789', true, 'substring matching in effect']
];
setup(function() {
client.apps.launch(TARGET_APP);
client.apps.switchToApp(TARGET_APP);
});
test('test fuzzy matches', function() {
var results = client.executeAsyncScript(function(tests) {
var remaining = tests.length;
var results = new Array(tests.length);
function testComplete() {
if (--remaining <= 0) {
marionetteScriptFinished(results);
}
}
tests.forEach(function(data, i) {
var req = navigator.mozPhoneNumberService.fuzzyMatch(data[0], data[1]);
req.onsuccess = function() {
results[i] = data[2] === req.result;
testComplete();
};
req.onerror = function(e) {
results[i] = '' + (e ? e.name : 'UnknownError');
testComplete();
};
});
}, [fuzzyMatchTests]);
assert.equal(fuzzyMatchTests.length, results.length,
'should have the same number of tests and results');
results.forEach(function(result, i) {
if (typeof result === 'string') {
assert.ok(false, 'Gecko failure: ' + result +
': ' + fuzzyMatchTests[i][3]);
} else {
assert.ok(result, fuzzyMatchTests[i][3]);
}
});
});
});

View file

@ -0,0 +1,138 @@
/* global require, marionette, setup, suite, test, __dirname */
'use strict';
var assert = require('assert');
var Messages = require('./lib/messages.js');
var MessagesActivityCaller = require('./lib/messages_activity_caller.js');
marionette('Messages as share target', function() {
var apps = {};
apps[MessagesActivityCaller.ORIGIN] = __dirname + '/apps/activitycaller';
var client = marionette.client({
profile: {
apps: apps
},
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
var messagesApp,
activityCallerApp;
setup(function() {
messagesApp = Messages.create(client);
activityCallerApp = MessagesActivityCaller.create(client);
client.loader.getMockManager('sms').inject([
'navigator_moz_icc_manager',
'navigator_moz_mobile_message'
]);
});
suite('Share via Messages', function() {
suite('Activity close button', function() {
setup(function() {
activityCallerApp.launch();
activityCallerApp.shareImage();
messagesApp.switchTo();
});
test('Should close activity if in NewMessage view', function() {
assert.equal(
messagesApp.NewMessage.header.getAttribute('action'), 'close',
'Close activity button should be visible'
);
// Exit from activity and verify that Messages is dismissed
messagesApp.performHeaderAction();
messagesApp.waitForAppToDisappear();
});
test('Should close activity if in Conversation view', function() {
// Send message to be forwarded to Conversation panel afterwards
messagesApp.addRecipient('+1');
messagesApp.send();
assert.equal(
messagesApp.NewMessage.header.getAttribute('action'), 'close',
'Close activity button should be visible'
);
// Exit from activity and verify that Messages is dismissed
messagesApp.performHeaderAction();
messagesApp.waitForAppToDisappear();
});
test('Should return to Conversation view if in Report view', function() {
// Send message to be forwarded to Conversation panel afterwards
messagesApp.addRecipient('+1');
messagesApp.send();
// Go to the Report panel
messagesApp.contextMenu(messagesApp.Conversation.message);
messagesApp.Menu.selectAppMenuOption('View message report');
messagesApp.Report.waitToAppear();
assert.equal(
messagesApp.Report.header.getAttribute('action'), 'close',
'Close activity button should be visible'
);
// Close report panel
messagesApp.performReportHeaderAction();
client.helper.waitForElement(messagesApp.Conversation.message);
assert.equal(
messagesApp.NewMessage.header.getAttribute('action'), 'close',
'Close activity button should be visible'
);
// Exit from activity and verify that Messages is dismissed
messagesApp.performHeaderAction();
messagesApp.waitForAppToDisappear();
});
// Temporarily skip due to intermittent failures - Participants view is
// not opened after tapping on the conversation header.
test.skip('Should return to Conversation panel if in Participants panel',
function() {
// Send group MMS message
messagesApp.addRecipient('+1');
messagesApp.addRecipient('+2');
// TODO: This should be moved to the appropriate test case in:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1043903
messagesApp.Composer.messageInput.sendKeys(
'very_very_very_very_very_very_long_message_i_can_ever_imagine'
);
messagesApp.send();
// Go to Participants panel
messagesApp.Conversation.headerTitle.tap();
messagesApp.Participants.waitToAppear();
assert.equal(
messagesApp.Participants.header.getAttribute('action'), 'back',
'Close activity button should be visible'
);
// Go back to Conversation panel
messagesApp.performGroupHeaderAction();
client.helper.waitForElement(messagesApp.Conversation.message);
assert.equal(
messagesApp.NewMessage.header.getAttribute('action'), 'close',
'Close activity button should be visible'
);
// Exit from activity and verify that Messages is dismissed
messagesApp.performHeaderAction();
messagesApp.waitForAppToDisappear();
});
});
});
});