From 4ad004e95fa1a6a892f8cd2d9453af16cc0ba231 Mon Sep 17 00:00:00 2001 From: Henry Wilkes Date: Thu, 29 Jul 2021 10:01:26 +0000 Subject: [PATCH] Bug 1695039 - Show changes to an invitation email by editing the Document directly rather than replacing it. r=darktrojan Differential Revision: https://phabricator.services.mozilla.com/D120712 --HG-- extra : moz-landing-system : lando --- calendar/base/content/imip-bar.js | 111 +- .../base/modules/utils/calInvitationUtils.jsm | 773 ++++----- calendar/base/src/CalMimeConverter.jsm | 7 +- calendar/base/themes/common/imip.css | 6 +- calendar/test/unit/test_invitationutils.js | 1537 ++++++++++------- 5 files changed, 1404 insertions(+), 1030 deletions(-) diff --git a/calendar/base/content/imip-bar.js b/calendar/base/content/imip-bar.js index 28b9a7736d..ced96ec892 100644 --- a/calendar/base/content/imip-bar.js +++ b/calendar/base/content/imip-bar.js @@ -39,7 +39,7 @@ var calImipBar = { actionFunc: null, itipItem: null, foundItems: null, - msgOverlay: null, + loadingItipItem: null, /** * Thunderbird Message listener interface, hide the bar before we begin @@ -94,22 +94,21 @@ var calImipBar = { // Do not show the imip bar if the user has opted out of seeing it. return; } - + // NOTE: Itip item is *about* to be loaded into the #messagepane when this + // callback is triggered by CalMimeConverter.convertToHTML. let itipItem = null; - let msgOverlay = null; try { if (!subject) { let sinkProps = msgWindow.msgHeaderSink.properties; // This property was set by CalMimeConverter.jsm. itipItem = sinkProps.getPropertyAsInterface("itipItem", Ci.calIItipItem); - msgOverlay = sinkProps.getPropertyAsAUTF8String("msgOverlay"); } } catch (e) { // This will throw on every message viewed that doesn't have the // itipItem property set on it. So we eat the errors and move on. // XXX TODO: Only swallow the errors we need to. Throw all others. } - if (!itipItem || !msgOverlay || !gMessageDisplay.displayedMessage) { + if (!itipItem || !gMessageDisplay.displayedMessage) { return; } @@ -119,9 +118,39 @@ var calImipBar = { imipBar.collapsed = false; imipBar.label = cal.itip.getMethodText(itipItem.receivedMethod); - calImipBar.msgOverlay = msgOverlay; - + // This is triggered by CalMimeConverter.convertToHTML, so we know that + // the message is not yet loaded with the invite. Keep track of this for + // displayModifications. + calImipBar.overlayLoaded = false; + document + .getElementById("messagepane") + .addEventListener("DOMContentLoaded", () => (calImipBar.overlayLoaded = true), { + once: true, + }); + // NOTE: processItipItem may call setupOptions asynchronously because the + // getItem method it triggers is async for *some* calendars. In theory, + // this could complete after a different item has been loaded, so we + // record the loading item now, and early exit setupOptions if the loading + // item has since changed. + // NOTE: loadingItipItem is reset on changing messages in resetBar. + calImipBar.loadingItipItem = itipItem; cal.itip.processItipItem(itipItem, calImipBar.setupOptions); + + // NOTE: At this point we essentially have two parallel async operations: + // 1. Load the CalMimeConverter.convertToHTML into the #messagepane and + // then set overlayLoaded to true. + // 2. Find a corresponding event through processItipItem and then call + // setupOptions. Note that processItipItem may be instantaneous for + // some calendars. + // + // In the mean time, if we switch messages, then loadingItipItem will be + // set to some other value: either another item, or null by resetBar. + // + // Once setupOptions is called, if the message has since changed we do + // nothing and exit. Otherwise, if we found a corresponding item in the + // calendar, we proceed to displayModifications. If overlayLoaded is true + // we update the #messagepane immediately, otherwise we update it on + // DOMContentLoaded, which has not yet happened. } }, @@ -135,6 +164,7 @@ var calImipBar = { // Clear our iMIP/iTIP stuff so it doesn't contain stale information. cal.itip.cleanupItipItem(calImipBar.itipItem); calImipBar.itipItem = null; + calImipBar.loadingItipItem = null; }, /** @@ -218,6 +248,11 @@ var calImipBar = { * in subscribed calendars */ setupOptions(itipItem, rc, actionFunc, foundItems) { + if (itipItem !== calImipBar.loadingItipItem) { + // The given itipItem refers to an earlier displayed message. + return; + } + let data = cal.itip.getOptionsText(itipItem, rc, actionFunc, foundItems); if (Components.isSuccessCode(rc)) { @@ -276,43 +311,65 @@ var calImipBar = { } // adjust button style if necessary calImipBar.conformButtonType(); + calImipBar.displayModifications(); }, /** - * Displays changes in case of invitation updates in invitation overlay + * Displays changes in case of invitation updates in invitation overlay. + * + * NOTE: This should only be called if the invitation is already loaded in the + * #messagepane, in which case calImipBar.overlayLoaded should be set to true, + * or is guaranteed to be loaded next in #messagepane. */ displayModifications() { if ( - !calImipBar.msgOverlay || - !msgWindow || !calImipBar.foundItems || !calImipBar.foundItems[0] || - !calImipBar.itipItem + !calImipBar.itipItem || + !Services.prefs.getBoolPref("calendar.itip.displayInvitationChanges", false) ) { return; } - let msgOverlay = calImipBar.msgOverlay; - let diff = cal.itip.compare(calImipBar.itipItem.getItemList()[0], calImipBar.foundItems[0]); - // displaying changes is only needed if that is enabled, an item already exists and there are - // differences - if (diff != 0 && Services.prefs.getBoolPref("calendar.itip.displayInvitationChanges", false)) { - let foundOverlay = cal.invitation.createInvitationOverlay( - calImipBar.foundItems[0], - calImipBar.itipItem - ); - let serializedOverlay = cal.xml.serializeDOM(foundOverlay); + let itipItem = calImipBar.itipItem; + let foundEvent = calImipBar.foundItems[0]; + let currentEvent = itipItem.getItemList()[0]; + let diff = cal.itip.compare(currentEvent, foundEvent); + if (diff != 0) { + let newEvent; + let oldEvent; + if (diff == 1) { - // this is an update to previously accepted invitation - msgOverlay = cal.invitation.compareInvitationOverlay(serializedOverlay, msgOverlay); + // This is an update to previously accepted invitation. + oldEvent = foundEvent; + newEvent = currentEvent; } else { - // this is a copy of a previously sent out invitation or a previous revision of a - // meanwhile accepted invitation, so we flip comparison order - msgOverlay = cal.invitation.compareInvitationOverlay(msgOverlay, serializedOverlay); + // This is a copy of a previously sent out invitation or a previous + // revision of a meanwhile accepted invitation, so we flip the order. + oldEvent = currentEvent; + newEvent = foundEvent; + } + + let browser = document.getElementById("messagepane"); + let doUpdate = () => + cal.invitation.updateInvitationOverlay( + browser.contentDocument, + newEvent, + itipItem, + oldEvent + ); + if (calImipBar.overlayLoaded) { + // Document is already loaded. + doUpdate(); + } else { + // The event is not yet shown. This can happen if setupOptions is called + // before CalMimeConverter.convertToHTML has finished, or the + // corresponding HTML string has not yet been loaded. + // Wait until the event is shown, then immediately update it. + browser.addEventListener("DOMContentLoaded", doUpdate, { once: true }); } } - msgWindow.displayHTMLInMessagePane("", msgOverlay, false); }, /** diff --git a/calendar/base/modules/utils/calInvitationUtils.jsm b/calendar/base/modules/utils/calInvitationUtils.jsm index 61961f59d3..bc7ae773dc 100644 --- a/calendar/base/modules/utils/calInvitationUtils.jsm +++ b/calendar/base/modules/utils/calInvitationUtils.jsm @@ -66,22 +66,49 @@ var calinvitation = { return header; }, + _createAddedElement(doc) { + let el = doc.createElement("ins"); + el.classList.add("added"); + return el; + }, + + _createRemovedElement(doc) { + let el = doc.createElement("del"); + el.classList.add("removed"); + return el; + }, + /** * Creates new icon and text label for the given event attendee. * * @param {Document} doc - The document the new label will belong to. * @param {calIAttendee} attendee - The attendee to create the label for. - * @param {calIAttendee[]} attendees - The full list of attendees for the + * @param {calIAttendee[]} attendeeList - The full list of attendees for the * event. + * @param {calIAttendee} [oldAttendee] - The previous version of this attendee + * for this event. + * @param {calIAttendee[]} [attendeeList] - The previous list of attendees for + * this event. This is not optional if oldAttendee is given. * * @return {HTMLDivElement} - The new attendee label. */ - createAttendeeLabel(doc, attendee, attendees) { + createAttendeeLabel(doc, attendee, attendeeList, oldAttendee, oldAttendeeList) { let userType = attendee.userType || "INDIVIDUAL"; let role = attendee.role || "REQ-PARTICIPANT"; let partstat = attendee.participationStatus || "NEEDS-ACTION"; + + let modified = + oldAttendee && + ((oldAttendee.userType || "INDIVIDUAL") != userType || + (oldAttendee.role || "REQ-PARTICIPANT") != role || + (oldAttendee.participationStatus || "NEEDS-ACTION") != partstat); + // resolve delegatees/delegators to display also the CN - let del = cal.itip.resolveDelegation(attendee, attendees); + let del = cal.itip.resolveDelegation(attendee, attendeeList); + if (oldAttendee && !modified) { + let oldDel = cal.itip.resolveDelegation(oldAttendee, oldAttendeeList); + modified = oldDel.delegatees !== del.delegatees || oldDel.delegator !== del.delegator; + } let userTypeString = cal.l10n.getLtnString("imipHtml.attendeeUserType2." + userType, [ attendee.toString(), @@ -98,24 +125,28 @@ var calinvitation = { name += " " + cal.l10n.getLtnString("imipHtml.attendeeDelegatedFrom", [del.delegators]); } - let attendeeLabel = doc.createElementNS("http://www.w3.org/1999/xhtml", "div"); + let attendeeLabel = doc.createElement("div"); attendeeLabel.classList.add("attendee-label"); // NOTE: tooltip will not appear when the top level is XUL. attendeeLabel.setAttribute("title", tooltip); attendeeLabel.setAttribute("attendeeid", attendee.id); + if (modified) { + attendeeLabel.classList.add("modified"); + } + // FIXME: Replace icon with an img element with src and alt. The current // problem is that the icon image is set in CSS on the itip-icon class // with a background image that changes with the role attribute. This is // generally inaccessible (see Bug 1702560). - let icon = doc.createElementNS("http://www.w3.org/1999/xhtml", "div"); + let icon = doc.createElement("div"); icon.classList.add("itip-icon"); icon.setAttribute("partstat", partstat); icon.setAttribute("usertype", userType); icon.setAttribute("role", role); attendeeLabel.appendChild(icon); - let text = doc.createElementNS("http://www.w3.org/1999/xhtml", "div"); + let text = doc.createElement("div"); text.classList.add("attendee-name"); text.appendChild(doc.createTextNode(name)); attendeeLabel.appendChild(text); @@ -133,7 +164,7 @@ var calinvitation = { * return {HTMLLIElement} - The attendee list item. */ createAttendeeListItem(doc, attendeeLabel) { - let listItem = doc.createElementNS("http://www.w3.org/1999/xhtml", "li"); + let listItem = doc.createElement("li"); listItem.classList.add("attendee-list-item"); listItem.appendChild(attendeeLabel); return listItem; @@ -144,17 +175,96 @@ var calinvitation = { * * @param {Document} doc - The document the new list will belong to. * @param {calIAttendee[]} attendees - The attendees to create the list for. + * @param {calIAttendee[]} [oldAttendees] - A list of attendees for a + * previous version of the event. * * @return {HTMLUListElement} - The list of attendees. */ - createAttendeesList(doc, attendees) { - let list = doc.createElementNS("http://www.w3.org/1999/xhtml", "ul"); + createAttendeesList(doc, attendees, oldAttendees) { + let list = doc.createElement("ul"); list.classList.add("attendee-list"); + let oldAttendeeData; + if (oldAttendees) { + oldAttendeeData = []; + for (let attendee of oldAttendees) { + let data = { attendee, item: null }; + oldAttendeeData.push(data); + } + } + for (let attendee of attendees) { - list.appendChild( - this.createAttendeeListItem(doc, this.createAttendeeLabel(doc, attendee, attendees)) - ); + let attendeeLabel; + let oldData; + if (oldAttendeeData) { + oldData = oldAttendeeData.find(old => old.attendee.id == attendee.id); + if (oldData) { + // Same attendee. + attendeeLabel = this.createAttendeeLabel( + doc, + attendee, + attendees, + oldData.attendee, + oldAttendees + ); + } else { + // Added attendee. + attendeeLabel = this._createAddedElement(doc); + attendeeLabel.appendChild(this.createAttendeeLabel(doc, attendee, attendees)); + } + } else { + attendeeLabel = this.createAttendeeLabel(doc, attendee, attendees); + } + let listItem = this.createAttendeeListItem(doc, attendeeLabel); + if (oldData) { + oldData.item = listItem; + } + list.appendChild(listItem); + } + + if (oldAttendeeData) { + let next = null; + // Traverse from the end of the list to the start. + for (let i = oldAttendeeData.length - 1; i >= 0; i--) { + let data = oldAttendeeData[i]; + if (!data.item) { + // Removed attendee. + let attendeeLabel = this._createRemovedElement(doc); + attendeeLabel.appendChild(this.createAttendeeLabel(doc, data.attendee, attendees)); + let listItem = this.createAttendeeListItem(doc, attendeeLabel); + data.item = listItem; + + // Insert the removed attendee list item *before* the list item that + // corresponds to the attendee that follows this attendee in the + // oldAttendees list. + // + // NOTE: by traversing from the end of the list to the start, we are + // prioritising being next to the attendee that follows us, rather + // than being next to the attendee that precedes us in the oldAttendee + // list. + // + // Specifically, if a new attendee is added between these two old + // neighbours, the added attendee will be shown earlier than the + // removed attendee in the list. + // + // E.g., going from the list + // [first@person, removed@person, second@person] + // to + // [first@person, added@person, second@person] + // will be shown as + // first@person + // + added@person + // - removed@person + // second@person + // because the removed@person's uses second@person as their reference + // point. + // + // NOTE: next.item is always non-null because next.item is always set + // by the end of the last loop. + list.insertBefore(listItem, next ? next.item : null); + } + next = data; + } } return list; @@ -163,88 +273,199 @@ var calinvitation = { /** * Returns the html representation of the event as a DOM document. * - * @param {calIItemBase} aEvent The event to parse into html. - * @param {calItipItem} aItipItem The itip item, which contains aEvent. - * @return {DOM} The html representation of aEvent. + * @param {calIItemBase} event - The event to parse into html. + * @param {calItipItem} itipItem - The itip item, which contains the event. + * @return {Document} The html representation of the event. */ - createInvitationOverlay(aEvent, aItipItem) { + createInvitationOverlay(event, itipItem) { // Creates HTML using the Node strings in the properties file - let doc = cal.xml.parseString(calinvitation.htmlTemplate); + const parser = new DOMParser(); + let doc = parser.parseFromString(calinvitation.htmlTemplate, "text/html"); + this.updateInvitationOverlay(doc, event, itipItem); + return doc; + }, + + /** + * Update the document created by createInvitationOverlay to show the new + * event details, and optionally show changes in the event against an older + * version of it. + * + * For example, this can be used for email invitations to update the invite to + * show the most recent version of the event found in the calendar, whilst + * also showing the event details that were removed since the original email + * invitation. I.e. contrasting the event found in the calendar with the event + * found within the email. Alternatively, if the email invitation is newer + * than the event found in the calendar, you can switch the comparison around. + * (As used in imip-bar.js.) + * + * @param {Document} doc - The document to update, previously created through + * createInvitationOverlay. + * @param {calIItemBase} event - The newest version of the event. + * @param {calItipItem} itipItem - The itip item, which contains the event. + * @param {calIItemBase} [oldEvent] - A previous version of the event to + * show as updated. + */ + updateInvitationOverlay(doc, event, itipItem, oldEvent) { + doc.body.toggleAttribute( + "systemcolors", + Services.prefs.getBoolPref("calendar.view.useSystemColors", false) + ); + + let headerDescr = doc.getElementById("imipHtml-header"); + if (headerDescr) { + headerDescr.textContent = calinvitation.getItipHeader(itipItem); + } + let formatter = cal.dtz.formatter; - let field = function(aField, aContentText, aConvert, aContentHTML) { - let descr = doc.getElementById("imipHtml-" + aField + "-descr"); - if (descr) { - let labelText = cal.l10n.getLtnString("imipHtml." + aField); - descr.textContent = labelText; + /** + * Set whether the given field should be shown. + * + * @param {string} fieldName - The name of the field. + * @param {boolean} show - Whether the field should be shown. + */ + let showField = (fieldName, show) => { + let row = doc.getElementById("imipHtml-" + fieldName + "-row"); + if (row.hidden && show) { + // Make sure the field name is set. + doc.getElementById("imipHtml-" + fieldName + "-descr").textContent = cal.l10n.getLtnString( + "imipHtml." + fieldName + ); } - if (aContentText) { - let content = doc.getElementById("imipHtml-" + aField + "-content"); - doc.getElementById("imipHtml-" + aField + "-row").hidden = false; - if (aConvert) { - let docFragment = cal.view.textToHtmlDocumentFragment(aContentText, doc, aContentHTML); - content.appendChild(docFragment); - } else { - content.textContent = aContentText; - } + row.hidden = !show; + }; + + /** + * Set the given element to display the given value. + * + * @param {Element} element - The element to display the value within. + * @param {string} value - The value to show. + * @param {boolean} [convert=false] - Whether the value will need converting + * to a sanitised document fragment. + * @param {string} [html] - The html to use as the value. This is only used + * if convert is set to true. + */ + let setElementValue = (element, value, convert = false, html) => { + if (convert) { + element.appendChild(cal.view.textToHtmlDocumentFragment(value, doc, html)); + } else { + element.textContent = value; } }; - // Simple fields - let headerDescr = doc.getElementById("imipHtml-header"); - if (headerDescr) { - headerDescr.textContent = calinvitation.getItipHeader(aItipItem); - } - - field("summary", aEvent.title, true); - field("location", aEvent.getProperty("LOCATION"), true); - - let dateString = formatter.formatItemInterval(aEvent); - - if (aEvent.recurrenceInfo) { - let kDefaultTimezone = cal.dtz.defaultTimezone; - let startDate = aEvent.startDate; - let endDate = aEvent.endDate; - startDate = startDate ? startDate.getInTimezone(kDefaultTimezone) : null; - endDate = endDate ? endDate.getInTimezone(kDefaultTimezone) : null; - let repeatString = recurrenceRule2String( - aEvent.recurrenceInfo, - startDate, - endDate, - startDate.isDate - ); - if (repeatString) { - dateString = repeatString; + /** + * Set the given field. + * + * If oldEvent is set, and the new value differs from the old one, it will + * be shown as added and/or removed content. + * + * If neither events have a value, the field will be hidden. + * + * @param {string} fieldName - The name of the field to set. + * @param {Function} getValue - A method to retrieve the field value from an + * event. Should return a string, or a falsey value if the event has no + * value for this field. + * @param {boolean} [convert=false] - Whether the value will need converting + * to a sanitised document fragment. + * @param {Function} [getHtml] - A method to retrieve the value as a html. + */ + let setField = (fieldName, getValue, convert = false, getHtml) => { + let cell = doc.getElementById("imipHtml-" + fieldName + "-content"); + while (cell.lastChild) { + cell.lastChild.remove(); } + let value = getValue(event); + let oldValue = oldEvent && getValue(oldEvent); + let html = getHtml && getHtml(event); + let oldHtml = oldEvent && getHtml && getHtml(event); + if (oldEvent && (oldValue || value) && oldValue !== value) { + // Different values, with at least one being truthy. + showField(fieldName, true); + if (!oldValue) { + let added = this._createAddedElement(doc); + setElementValue(added, value, convert, html); + cell.appendChild(added); + } else if (!value) { + let removed = this._createRemovedElement(doc); + setElementValue(removed, oldValue, convert, oldHtml); + cell.appendChild(removed); + } else { + let added = this._createAddedElement(doc); + setElementValue(added, value, convert, html); + let removed = this._createRemovedElement(doc); + setElementValue(removed, oldValue, convert, oldHtml); + cell.appendChild(added); + cell.appendChild(doc.createElement("br")); + cell.appendChild(removed); + } + } else if (value) { + // Same truthy value. + showField(fieldName, true); + setElementValue(cell, value, convert, html); + } else { + showField(fieldName, false); + } + }; + + setField("summary", ev => ev.title, true); + setField("location", ev => ev.getProperty("LOCATION"), true); + + let kDefaultTimezone = cal.dtz.defaultTimezone; + setField("when", ev => { + if (ev.recurrenceInfo) { + let startDate = ev.startDate?.getInTimezone(kDefaultTimezone) ?? null; + let endDate = ev.endDate?.getInTimezone(kDefaultTimezone) ?? null; + let repeatString = recurrenceRule2String( + ev.recurrenceInfo, + startDate, + endDate, + startDate.isDate + ); + if (repeatString) { + return repeatString; + } + } + return formatter.formatItemInterval(ev); + }); + + setField("canceledOccurrences", ev => { + if (!ev.recurrenceInfo) { + return null; + } let formattedExDates = []; - let modifiedOccurrences = []; - - let dateComptor = function(a, b) { - return a.startDate.compare(b.startDate); - }; // Show removed instances - for (let exc of aEvent.recurrenceInfo.getRecurrenceItems()) { - if (exc instanceof Ci.calIRecurrenceDate) { - if (exc.isNegative) { - // This is an EXDATE - let excDate = exc.date.getInTimezone(kDefaultTimezone); - formattedExDates.push(formatter.formatDateTime(excDate)); - } else { - // This is an RDATE, close enough to a modified occurrence - let excItem = aEvent.recurrenceInfo.getOccurrenceFor(exc.date); - cal.data.binaryInsert(modifiedOccurrences, excItem, dateComptor, true); - } + for (let exc of ev.recurrenceInfo.getRecurrenceItems()) { + if (exc instanceof Ci.calIRecurrenceDate && exc.isNegative) { + // This is an EXDATE + let excDate = exc.date.getInTimezone(kDefaultTimezone); + formattedExDates.push(formatter.formatDateTime(excDate)); } } if (formattedExDates.length > 0) { - field("canceledOccurrences", formattedExDates.join("\n")); + return formattedExDates.join("\n"); } + return null; + }); - // Show modified occurrences - for (let recurrenceId of aEvent.recurrenceInfo.getExceptionIds()) { - let exc = aEvent.recurrenceInfo.getExceptionFor(recurrenceId); + let dateComptor = (a, b) => a.startDate.compare(b.startDate); + + setField("modifiedOccurrences", ev => { + if (!ev.recurrenceInfo) { + return null; + } + let modifiedOccurrences = []; + + for (let exc of ev.recurrenceInfo.getRecurrenceItems()) { + if (exc instanceof Ci.calIRecurrenceDate && !exc.isNegative) { + // This is an RDATE, close enough to a modified occurrence + let excItem = ev.recurrenceInfo.getOccurrenceFor(exc.date); + cal.data.binaryInsert(modifiedOccurrences, excItem, dateComptor, true); + } + } + for (let recurrenceId of ev.recurrenceInfo.getExceptionIds()) { + let exc = ev.recurrenceInfo.getExceptionFor(recurrenceId); let excLocation = exc.getProperty("LOCATION"); // Only show modified occurrence if start, duration or location @@ -252,336 +473,118 @@ var calinvitation = { exc.QueryInterface(Ci.calIEvent); if ( exc.startDate.compare(exc.recurrenceId) != 0 || - exc.duration.compare(aEvent.duration) != 0 || - excLocation != aEvent.getProperty("LOCATION") + exc.duration.compare(ev.duration) != 0 || + excLocation != ev.getProperty("LOCATION") ) { cal.data.binaryInsert(modifiedOccurrences, exc, dateComptor, true); } } - let stringifyOcc = function(occ) { - let formattedExc = formatter.formatItemInterval(occ); - let occLocation = occ.getProperty("LOCATION"); - if (occLocation != aEvent.getProperty("LOCATION")) { - let location = cal.l10n.getLtnString("imipHtml.newLocation", [occLocation]); - formattedExc += " (" + location + ")"; - } - return formattedExc; - }; - if (modifiedOccurrences.length > 0) { - field("modifiedOccurrences", modifiedOccurrences.map(stringifyOcc).join("\n")); + let evLocation = ev.getProperty("LOCATION"); + return modifiedOccurrences + .map(occ => { + let formattedExc = formatter.formatItemInterval(occ); + let occLocation = occ.getProperty("LOCATION"); + if (occLocation != evLocation) { + formattedExc += + " (" + cal.l10n.getLtnString("imipHtml.newLocation", [occLocation]) + ")"; + } + return formattedExc; + }) + .join("\n"); } - } + return null; + }); - field("when", dateString); - field("comment", aEvent.getProperty("COMMENT"), true); + setField( + "description", + // We remove the useless "Outlookism" squiggle. + ev => ev.descriptionText?.replace("*~*~*~*~*~*~*~*~*~*", ""), + true, + ev => ev.descriptionHTML + ); - // DESCRIPTION field - let eventDescription = (aEvent.descriptionText || "") - /* Remove the useless "Outlookism" squiggle. */ - .replace("*~*~*~*~*~*~*~*~*~*", ""); - field("description", eventDescription, true, aEvent.descriptionHTML); - - // URL - field("url", aEvent.getProperty("URL"), true); - - // ATTACH - we only display URI but no BINARY type attachments here - let links = []; - let attachments = aEvent.getAttachments(); - for (let attachment of attachments) { - if (attachment.uri) { - links.push(attachment.uri.spec); - } - } - field("attachments", links.join("\n"), true); + setField("url", ev => ev.getProperty("URL"), true); + setField( + "attachments", + ev => { + // ATTACH - we only display URI but no BINARY type attachments here + let links = []; + for (let attachment of ev.getAttachments()) { + if (attachment.uri) { + links.push(attachment.uri.spec); + } + } + return links.join("\n"); + }, + true + ); // ATTENDEE and ORGANIZER fields + let attendees = event.getAttendees(); + let oldAttendees = oldEvent?.getAttendees(); + let organizerCell = doc.getElementById("imipHtml-organizer-cell"); - let attendeeCell = doc.getElementById("imipHtml-attendees-cell"); - let attendees = aEvent.getAttendees(); - doc.getElementById("imipHtml-attendees-row").hidden = attendees.length < 1; - doc.getElementById("imipHtml-organizer-row").hidden = !aEvent.organizer; - - field("organizer"); - if (aEvent.organizer) { - organizerCell.appendChild(this.createAttendeeLabel(doc, aEvent.organizer, attendees)); + while (organizerCell.lastChild) { + organizerCell.lastChild.remove(); } - // Fill rows for attendees and organizer - field("attendees"); - attendeeCell.appendChild(this.createAttendeesList(doc, attendees)); - - return doc; - }, - - /** - * Expects and return a serialized DOM - use cal.xml.serializeDOM(aDOM) - * @param {String} aOldDoc serialized DOM of the the old document - * @param {String} aNewDoc serialized DOM of the the new document - * @return {String} updated serialized DOM of the new document - */ - compareInvitationOverlay(aOldDoc, aNewDoc) { - let systemColors = Services.prefs.getBoolPref("calendar.view.useSystemColors", false); - /** - * Add a styling class to the given element. - * - * @param {Element} el - The element to add the class to. - * @param {string} className - The name of the styling class to add. - */ - function _addStyleClass(el, className) { - el.classList.add(className); - el.toggleAttribute("systemcolors", systemColors); - } - - /** - * Extract the elements from an element and place them within a new element - * that represents a change in content. - * - * @param {Element} el - The element to extract content from. This will be - * empty after the method returns. - * @param {string} change - The change that the returned element should - * represent. - * - * @return {HTMLModElement} - A new container for the previous content of - * the element. It will be styled and semantically tagged according to the - * given change. - */ - function _extractChangedContent(el, change) { - // Static list of children, including text nodes. - let nodeDoc = el.ownerDocument; - let children = Array.from(el.childNodes); - let wrapper; - if (change === "removed") { - wrapper = nodeDoc.createElementNS("http://www.w3.org/1999/xhtml", "del"); + let organizer = event.organizer; + if (oldEvent) { + let oldOrganizer = oldEvent.organizer; + if (!organizer && !oldOrganizer) { + showField("organizer", false); } else { - wrapper = nodeDoc.createElementNS("http://www.w3.org/1999/xhtml", "ins"); + showField("organizer", true); + + let removed = false; + let added = false; + if (!organizer) { + removed = true; + } else if (!oldOrganizer) { + added = true; + } else if (organizer.id !== oldOrganizer.id) { + removed = true; + added = true; + } else { + // Same organizer, potentially modified. + organizerCell.appendChild( + this.createAttendeeLabel(doc, organizer, attendees, oldOrganizer, oldAttendees) + ); + } + // Append added first. + if (added) { + let addedEl = this._createAddedElement(doc); + addedEl.appendChild(this.createAttendeeLabel(doc, organizer, attendees)); + organizerCell.appendChild(addedEl); + } + if (removed) { + let removedEl = this._createRemovedElement(doc); + removedEl.appendChild(this.createAttendeeLabel(doc, oldOrganizer, oldAttendees)); + organizerCell.appendChild(removedEl); + } } - _addStyleClass(wrapper, change); - for (let child of children) { - el.removeChild(child); - wrapper.appendChild(child); - } - return wrapper; + } else if (!organizer) { + showField("organizer", false); + } else { + showField("organizer", true); + organizerCell.appendChild(this.createAttendeeLabel(doc, organizer, attendees)); } - /** - * Compares a row across the two documents. The row in the new document will - * be shown if the row was shown in either document. Otherwise, it will - * remain hidden. - * - * @param {Document} doc - The current document. - * @param {Document} oldDoc - The old document to compare against. - * @param {String} rowId - The id for the row to compare. - * @param {Function} removedCallback - Method to call if the row is hidden - * in the current document, but shown in the old document. - * @param {Function} addedCallback - Method to call if the row is shown - * in the current document, but hidden in the old document. - * @param {Function} modifiedCallback - Method to call if the row is shown - * in both documents. - */ - function _compareRows(doc, oldDoc, rowId, removedCallback, addedCallback, modifiedCallback) { - let oldRow = oldDoc.getElementById(rowId); - let row = doc.getElementById(rowId); - if (row.hidden && !oldRow.hidden) { - removedCallback(); - row.hidden = false; - } else if (!row.hidden && oldRow.hidden) { - addedCallback(); - } else if (!row.hidden && !oldRow.hidden) { - modifiedCallback(); - } + let attendeesCell = doc.getElementById("imipHtml-attendees-cell"); + while (attendeesCell.lastChild) { + attendeesCell.lastChild.remove(); } - /** - * Compares content across the two documents. The content of the new - * document will be modified to reflect the changes. - * - * @param {Document} doc - The current document (which will be modified). - * @param {Document} oldDoc - The old document to compare against. - * @param {String} rowId - The id for the row that contains the content. - * @param {String} contentId - The id for the content element. - */ - function _compareContent(doc, oldDoc, rowId, contentId) { - let content = doc.getElementById(contentId); - let oldContent = oldDoc.getElementById(contentId); - _compareRows( - doc, - oldDoc, - rowId, - // Removed row. - () => { - let removed = _extractChangedContent(oldContent, "removed"); - while (content.lastChild) { - content.lastChild.remove(); - } - content.appendChild(removed); - }, - // Added row. - () => { - let added = _extractChangedContent(content, "added"); - content.appendChild(added); - }, - // Modified row. - () => { - if (content.textContent !== oldContent.textContent) { - let added = _extractChangedContent(content, "added"); - let removed = _extractChangedContent(oldContent, "removed"); - content.appendChild(added); - content.appendChild(doc.createElementNS("http://www.w3.org/1999/xhtml", "br")); - content.appendChild(removed); - } - } - ); + // Hide if we have no attendees, and neither does the old event. + if (attendees.length == 0 && (!oldEvent || oldAttendees.length == 0)) { + showField("attendees", false); + } else { + // oldAttendees is undefined if oldEvent is undefined. + showField("attendees", true); + attendeesCell.appendChild(this.createAttendeesList(doc, attendees, oldAttendees)); } - - let oldDoc = cal.xml.parseString(aOldDoc); - let doc = cal.xml.parseString(aNewDoc); - // elements to consider for comparison - [ - ["imipHtml-summary-row", "imipHtml-summary-content"], - ["imipHtml-location-row", "imipHtml-location-content"], - ["imipHtml-when-row", "imipHtml-when-content"], - ["imipHtml-canceledOccurrences-row", "imipHtml-canceledOccurrences-content"], - ["imipHtml-modifiedOccurrences-row", "imipHtml-modifiedOccurrences-content"], - ].forEach(ids => _compareContent(doc, oldDoc, ids[0], ids[1])); - - /** - * Relate two attendee labels. - * - * @param {Element} attendeeLabel - An attendee label. - * @param {Element} otherAttendeeLabel - Another attendee label to compare - * against. - * - * @return {string} - The relation between the two labels: - * "different" if the attendee names differ, - * "modified" if the attendance details differ, - * "same" otherwise. - */ - function _attendeeDiff(attendeeLabel, otherAttendeeLabel) { - if (attendeeLabel.textContent !== otherAttendeeLabel.textContent) { - return "different"; - } - let otherIcon = otherAttendeeLabel.querySelector(".itip-icon"); - let icon = attendeeLabel.querySelector(".itip-icon"); - for (let attr of ["role", "partstat", "usertype"]) { - if (icon.getAttribute(attr) !== otherIcon.getAttribute(attr)) { - return "modified"; - } - } - return "same"; - } - - /** - * Wrap the given element in-place to describe the given change. - * The wrapper will semantically and/or stylistically describe the change. - * - * @param {Element} - The element to wrap. The new wrapper will take its - * place in the parent container. - * @param {string} - The change that the wrapper should represent. - */ - function _wrapChanged(el, change) { - let nodeDoc = el.ownerDocument; - let wrapper; - switch (change) { - case "removed": - wrapper = nodeDoc.createElementNS("http://www.w3.org/1999/xhtml", "del"); - break; - case "added": - wrapper = nodeDoc.createElementNS("http://www.w3.org/1999/xhtml", "ins"); - break; - } - if (wrapper) { - el.replaceWith(wrapper); - wrapper.appendChild(el); - el = wrapper; - } - _addStyleClass(el, change); - } - - let organizerCell = doc.querySelector("#imipHtml-organizer-cell"); - let organizerLabel = organizerCell.querySelector(".attendee-label"); - let oldOrganizerLabel = oldDoc.querySelector("#imipHtml-organizer-cell .attendee-label"); - _compareRows( - doc, - oldDoc, - "imipHtml-organizer-row", - // Removed row. - () => { - oldOrganizerLabel.remove(); - if (organizerLabel) { - organizerLabel.remove(); - } - organizerCell.appendChild(oldOrganizerLabel); - _wrapChanged(oldOrganizerLabel, "removed"); - }, - // Added row. - () => _wrapChanged(organizerLabel, "added"), - // Modified row. - () => { - switch (_attendeeDiff(organizerLabel, oldOrganizerLabel)) { - case "different": - _wrapChanged(organizerLabel, "added"); - oldOrganizerLabel.remove(); - organizerCell.appendChild(oldOrganizerLabel); - _wrapChanged(oldOrganizerLabel, "removed"); - break; - case "modified": - _wrapChanged(organizerLabel, "modified"); - break; - } - } - ); - - let attendeeCell = doc.querySelector("#imipHtml-attendees-cell"); - let attendeeList = attendeeCell.querySelector(".attendee-list"); - let oldAttendeeList = oldDoc.querySelector("#imipHtml-attendees-cell .attendee-list"); - _compareRows( - doc, - oldDoc, - "imipHtml-attendees-row", - // Removed row. - () => { - oldAttendeeList.remove(); - if (attendeeList) { - attendeeList.remove(); - } - attendeeCell.appendChild(oldAttendeeList); - _wrapChanged(oldAttendeeList, "removed"); - }, - // Added row. - () => _wrapChanged(attendeeList, "added"), - // Modified row. - () => { - let oldAttendees = Array.from(oldAttendeeList.querySelectorAll(".attendee-label")); - for (let attendeeLabel of attendeeList.querySelectorAll(".attendee-label")) { - let added = true; - for (let i = 0; added && i < oldAttendees.length; i++) { - switch (_attendeeDiff(attendeeLabel, oldAttendees[i])) { - case "different": - break; - case "modified": - _wrapChanged(attendeeLabel, "modified"); - // Fallthrough. - case "same": - oldAttendees.splice(i, 1); - added = false; - break; - } - } - if (added) { - _wrapChanged(attendeeLabel, "added"); - } - } - for (let oldAttendeeLabel of oldAttendees) { - oldAttendeeLabel.remove(); - attendeeList.appendChild(this.createAttendeeListItem(doc, oldAttendeeLabel)); - _wrapChanged(oldAttendeeLabel, "removed"); - } - } - ); - - return cal.xml.serializeDOM(doc); }, /** diff --git a/calendar/base/src/CalMimeConverter.jsm b/calendar/base/src/CalMimeConverter.jsm index 37ee047f83..19110cd904 100644 --- a/calendar/base/src/CalMimeConverter.jsm +++ b/calendar/base/src/CalMimeConverter.jsm @@ -39,11 +39,9 @@ CalMimeConverter.prototype = { return ""; } - let itipItem = null; - let msgOverlay = ""; let msgWindow = null; - itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem); + let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem); itipItem.init(data); // this.uri is the message URL that we are processing. @@ -62,12 +60,11 @@ CalMimeConverter.prototype = { // msgOverlay needs to be defined irrespectively of the existence of msgWindow to not break // printing of invitation emails let dom = cal.invitation.createInvitationOverlay(event, itipItem); - msgOverlay = cal.xml.serializeDOM(dom); + let msgOverlay = cal.xml.serializeDOM(dom); if (msgWindow) { let sinkProps = msgWindow.msgHeaderSink.properties; sinkProps.setPropertyAsInterface("itipItem", itipItem); - sinkProps.setPropertyAsAUTF8String("msgOverlay", msgOverlay); // Notify the observer that the itipItem is available Services.obs.notifyObservers(null, "onItipItemCreation"); diff --git a/calendar/base/themes/common/imip.css b/calendar/base/themes/common/imip.css index b852844a98..5c2a98872f 100644 --- a/calendar/base/themes/common/imip.css +++ b/calendar/base/themes/common/imip.css @@ -49,7 +49,7 @@ text-decoration-line: none; } -.invitation-table .added[systemcolors] { +body[systemcolors] .invitation-table .added { color: currentColor; font-weight: bold; } @@ -59,7 +59,7 @@ font-style: italic; } -.invitation-table .modified[systemcolors] { +body[systemcolors] .invitation-table .modified { color: currentColor; } @@ -67,6 +67,6 @@ color: rgb(125, 125, 125); } -.invitation-table .removed[systemcolors] { +body[systemcolors] .invitation-table .removed { color: currentColor; } diff --git a/calendar/test/unit/test_invitationutils.js b/calendar/test/unit/test_invitationutils.js index f5cb11e783..5b2c345d82 100644 --- a/calendar/test/unit/test_invitationutils.js +++ b/calendar/test/unit/test_invitationutils.js @@ -17,13 +17,109 @@ function run_test() { // tests for calInvitationUtils.jsm -function getIcs(aAsArray = false) { +// Make sure that the Europe/Berlin timezone and long datetime format is set +// and to use the app locale to avoid test failures when running locally on +// an OS with a regional setting other than en-US. +// +// NOTE: If your OS is in English but not US English this will not switch it +// to en-US. But the test is still known to work with en-GB. +// If it doesn't work, try `export LC_TIME=en_US.UTF-8` before running the +// test. Or you can update the test time regular expressions to also include +// your English locale. +Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", false); +Services.prefs.setIntPref("calendar.date.format", 0); +Services.prefs.setStringPref("calendar.timezone.local", "Europe/Berlin"); + +/** + * typedef {Object} FullIcsValue + * @property {Object.} params - Parameters for the ics property, + * mapping from the parameter name to its value. Each name should be in camel + * case. For example, to set "PARTSTAT=ACCEPTED" on the "attendee" property, + * use `{ partstat: "ACCEPTED" }`. + * @property {string} value - The property value. + */ + +/** + * An accepted property value. + * typedef {(FullIcsValue|string)} IcsValue + */ + +/** + * Get a ics string for an event. + * + * @param {Object.} [eventProperties] - Object + * used to set the event properties, mapping from the ics property name to its + * value. The property name should be in camel case, so "propertyName" should + * be used for the "PROPERTY-NAME" property. The value can either be a single + * IcsValue, or a IcsValue array if you want more than one such property + * in the event (e.g. to set several "attendee" properties). If you give an + * empty value for the property, then the property will be excluded. + * For the "attendee" and "organizer" properties, "mailto:" will be prefixed + * to the value (unless it is empty). + * For the "dtstart" and "dtend" properties, the "TZID=Europe/Berlin" + * parameter will be set by default. + * Some properties will have default values set if they are not specified in + * the object. Note that to avoid a property with a default value, you must + * pass an empty value for the property. + * + * @return {string} - The ics string. + */ +function getIcs(eventProperties) { // we use an unfolded ics blueprint here to make replacing of properties easier - let item = [ - "BEGIN:VCALENDAR", - "PRODID:-//Google Inc//Google Calendar V1.0//EN", - "VERSION:2.0", - "METHOD:REQUEST", + let item = ["BEGIN:VCALENDAR", "PRODID:-//Google Inc//Google Calendar V1.0//EN", "VERSION:2.0"]; + + let eventPropertyNames = eventProperties ? Object.keys(eventProperties) : []; + + // Convert camel case object property name to upper case with dashes. + let convertPropertyName = n => n.replace(/[A-Z]/, match => `-${match}`).toUpperCase(); + + let propertyToString = (name, value) => { + let propertyString = convertPropertyName(name); + let setTzid = false; + if (typeof value == "object") { + for (let paramName in value.params) { + if (paramName == "tzid") { + setTzid = true; + } + propertyString += `;${convertPropertyName(paramName)}=${value.params[paramName]}`; + } + value = value.value; + } + if (!setTzid && (name == "dtstart" || name == "dtend")) { + propertyString += ";TZID=Europe/Berlin"; + } + if (name == "organizer" || name == "attendee") { + value = `mailto:${value}`; + } + return `${propertyString}:${value}`; + }; + + let appendProperty = (name, value) => { + if (!value) { + // leave out. + return; + } + if (Array.isArray(value)) { + value.forEach(val => item.push(propertyToString(name, val))); + } else { + item.push(propertyToString(name, value)); + } + }; + + let appendPropertyWithDefault = (name, defaultValue) => { + let value = defaultValue; + let index = eventPropertyNames.findIndex(n => n == name); + if (index >= 0) { + value = eventProperties[name]; + // Remove the name to show that we have already handled it. + eventPropertyNames.splice(index, 1); + } + appendProperty(name, value); + }; + + appendPropertyWithDefault("method", "METHOD:REQUEST"); + + item = item.concat([ "BEGIN:VTIMEZONE", "TZID:Europe/Berlin", "BEGIN:DAYLIGHT", @@ -42,305 +138,415 @@ function getIcs(aAsArray = false) { "END:STANDARD", "END:VTIMEZONE", "BEGIN:VEVENT", - "CREATED:20150909T180909Z", - "LAST-MODIFIED:20150909T181048Z", - "DTSTAMP:20150909T181048Z", - "UID:cb189fdc-ed47-4db6-a8d7-31a08802249d", - "SUMMARY:Test Event", - "ORGANIZER;RSVP=TRUE;CN=Organizer;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:organizer@example.net", - "ATTENDEE;RSVP=TRUE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:attende" + - "e@example.net", - "DTSTART;TZID=Europe/Berlin:20150909T210000", - "DTEND;TZID=Europe/Berlin:20150909T220000", - "SEQUENCE:1", - "TRANSP:OPAQUE", - "LOCATION:Room 1", - "DESCRIPTION:Let us get together", - "URL:http://www.example.com", - "ATTACH:http://www.example.com", - "END:VEVENT", - "END:VCALENDAR", - ]; - if (!aAsArray) { - item = item.join("\r\n"); + ]); + + for (let [name, defaultValue] of [ + ["created", "20150909T180909Z"], + ["lastModified", "20150909T181048Z"], + ["dtstamp", "20150909T181048Z"], + ["uid", "cb189fdc-ed47-4db6-a8d7-31a08802249d"], + ["summary", "Test Event"], + [ + "organizer", + { + params: { rsvp: "TRUE", cn: "Organizer", partstat: "ACCEPTED", role: "CHAIR" }, + value: "organizer@example.net", + }, + ], + [ + "attendee", + { + params: { rsvp: "TRUE", cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" }, + value: "attendee@example.net", + }, + ], + ["dtstart", "20150909T210000"], + ["dtend", "20150909T220000"], + ["sequence", "1"], + ["transp", "OPAQUE"], + ["location", "Room 1"], + ["description", "Let us get together"], + ["url", "http://www.example.com"], + ["attach", "http://www.example.com"], + ]) { + appendPropertyWithDefault(name, defaultValue); } - return item; + + // Add other properties with no default. + for (let name of eventPropertyNames) { + appendProperty(name, eventProperties[name]); + } + + item.push("END:VEVENT"); + item.push("END:VCALENDAR"); + + return item.join("\r\n"); +} + +function getEvent(eventProperties) { + let item = getIcs(eventProperties); + let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem); + itipItem.init(item); + let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + parser.parseString(item); + return { event: parser.getItems()[0], itipItem }; } add_task(async function getItipHeader_test() { let data = [ { + name: "Organizer sends invite", input: { - method: "METHOD:REQUEST\r\n", - attendees: [null], + method: "REQUEST", + attendee: "", }, expected: "Organizer has invited you to Test Event", }, { + name: "Organizer cancels event", input: { - method: "METHOD:CANCEL\r\n", - attendees: [null], + method: "CANCEL", + attendee: "", }, expected: "Organizer has canceled this event: Test Event", }, { + name: "Organizer declines counter proposal", input: { - method: "METHOD:DECLINECOUNTER\r\n", - attendees: [ - "ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=ACCEPTED;" + - "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net", - ], + method: "DECLINECOUNTER", + attendee: { + params: { rsvp: "TRUE", cn: "Attendee1", partstat: "ACCEPTED", role: "REQ-PARTICIPANT" }, + value: "attendee1@example.net", + }, }, expected: 'Organizer has declined your counterproposal for "Test Event".', }, { + name: "Attendee makes counter proposal", input: { - method: "METHOD:COUNTER\r\n", - attendees: [ - "ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=DECLINED;" + - "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net", - ], + method: "COUNTER", + attendee: { + params: { rsvp: "TRUE", cn: "Attendee1", partstat: "DECLINED", role: "REQ-PARTICIPANT" }, + value: "attendee1@example.net", + }, }, expected: 'Attendee1 has made a counterproposal for "Test Event":', }, { + name: "Attendee replies with acceptance", input: { - method: "METHOD:REPLY\r\n", - attendees: [ - "ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=ACCEPTED;" + - "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net", - ], + method: "REPLY", + attendee: { + params: { rsvp: "TRUE", cn: "Attendee1", partstat: "ACCEPTED", role: "REQ-PARTICIPANT" }, + value: "attendee1@example.net", + }, }, expected: "Attendee1 has accepted your event invitation.", }, { + name: "Attendee replies with tentative acceptance", input: { - method: "METHOD:REPLY\r\n", - attendees: [ - "ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=TENTATIVE;" + - "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net", - ], + method: "REPLY", + attendee: { + params: { rsvp: "TRUE", cn: "Attendee1", partstat: "TENTATIVE", role: "REQ-PARTICIPANT" }, + value: "attendee1@example.net", + }, }, expected: "Attendee1 has accepted your event invitation.", }, { + name: "Attendee replies with declined", input: { - method: "METHOD:REPLY\r\n", - attendees: [ - "ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=DECLINED;" + - "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net", - ], + method: "REPLY", + attendee: { + params: { rsvp: "TRUE", cn: "Attendee1", partstat: "DECLINED", role: "REQ-PARTICIPANT" }, + value: "attendee1@example.net", + }, }, expected: "Attendee1 has declined your event invitation.", }, { + name: "Attendee1 accepts and Attendee2 declines", input: { - method: "METHOD:REPLY\r\n", - attendees: [ - "ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=ACCEPTED;" + - "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net", - "ATTENDEE;RSVP=TRUE;CN=Attendee2;PARTSTAT=DECLINED;" + - "ROLE=REQ-PARTICIPANT:mailto:attendee2@example.net", + method: "REPLY", + attendee: [ + { + params: { + rsvp: "TRUE", + cn: "Attendee1", + partstat: "ACCEPTED", + role: "REQ-PARTICIPANT", + }, + value: "attendee1@example.net", + }, + { + params: { + rsvp: "TRUE", + cn: "Attendee2", + partstat: "DECLINED", + role: "REQ-PARTICIPANT", + }, + value: "attendee2@example.net", + }, ], }, expected: "Attendee1 has accepted your event invitation.", }, { + name: "Unsupported method", input: { - method: "METHOD:UNSUPPORTED\r\n", - attendees: [null], + method: "UNSUPPORTED", + attendee: "", }, expected: "Event Invitation", }, { + name: "No method", input: { method: "", - attendees: [""], + attendee: "", }, expected: "Event Invitation", }, ]; - let i = 0; for (let test of data) { - i++; let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem); - let item = getIcs(); - let sender; - if (test.input.method || test.input.method == "") { - item = item.replace(/METHOD:REQUEST\r\n/, test.input.method); - } - if (test.input.attendees.length) { - let attendees = test.input.attendees.filter(aAtt => !!aAtt).join("\r\n"); - item = item.replace(/(ATTENDEE.+(?:\r\n))/, attendees + "\r\n"); - if (test.input.attendees[0]) { - sender = new CalAttendee(); - sender.icalString = test.input.attendees[0]; - } - } + let item = getIcs(test.input); itipItem.init(item); - if (sender) { + if (test.input.attendee) { + let sender = new CalAttendee(); + sender.icalString = item.match(/^ATTENDEE.*$/m)[0]; itipItem.sender = sender.id; } - equal(cal.invitation.getItipHeader(itipItem), test.expected, "(test #" + i + ")"); + equal(cal.invitation.getItipHeader(itipItem), test.expected, `(test ${test.name})`); } }); +function assertHiddenRow(node, hidden, testName) { + let row = node.closest("tr"); + ok(row, `Row above ${node.id} should exist (test ${testName})`); + if (hidden) { + equal( + node.textContent, + "", + `Node ${node.id} should be empty below a hidden row (test ${testName})` + ); + ok(row.hidden, `Row above ${node.id} should be hidden (test ${testName})`); + } else { + ok(!row.hidden, `Row above ${node.id} should not be hidden (test ${testName})`); + } +} + add_task(async function createInvitationOverlay_test() { let data = [ { - input: { description: "DESCRIPTION:Go to https://www.example.net if you can.\r\n" }, + name: "No description", + input: { description: "" }, + expected: { node: "imipHtml-description-content", hidden: true }, + }, + { + name: "Description with https link", + input: { description: "Go to https://www.example.net if you can." }, expected: { node: "imipHtml-description-content", - value: - 'Go to https://www.example.net if you can.', + content: + 'Go to ' + + "https://www.example.net if you can.", }, }, { - input: { description: "DESCRIPTION:Go to www.example.net if you can.\r\n" }, + name: "Description plain link", + input: { description: "Go to www.example.net if you can." }, expected: { node: "imipHtml-description-content", - value: - 'Go to www.example.net if you can.', + content: + 'Go to ' + + "www.example.net if you can.", }, }, { - input: { description: "DESCRIPTION:Let's see if +/- still can be displayed.\r\n" }, + name: "Description with +/-", + input: { description: "Let's see if +/- still can be displayed." }, expected: { node: "imipHtml-description-content", - value: "Let's see if +/- still can be displayed.", + content: "Let's see if +/- still can be displayed.", }, }, { - input: { description: "DESCRIPTION:Or write to mailto:faq@example.net instead.\r\n" }, + name: "Description with mailto", + input: { description: "Or write to mailto:faq@example.net instead." }, expected: { node: "imipHtml-description-content", - value: - 'Or write to mailto:faq@example.net instead.', + content: + 'Or write to mailto:faq@example.net instead.', }, }, { - input: { description: "DESCRIPTION:Or write to faq@example.net instead.\r\n" }, + name: "Description with email", + input: { description: "Or write to faq@example.net instead." }, expected: { node: "imipHtml-description-content", - value: - 'Or write to faq@example.net instead.', + content: + 'Or write to faq@example.net instead.', }, }, { - input: { description: "DESCRIPTION:It's up to you ;-)\r\n" }, + name: "Description with emoticon", + input: { description: "It's up to you ;-)" }, expected: { node: "imipHtml-description-content", - value: "It's up to you ;-)", + content: "It's up to you ;-)", }, }, { + name: "Removed script injection from description", input: { description: - "DESCRIPTION:Let's see how evil we can be: \r\n', + 'Let\'s see how evil we can be: ', }, expected: { node: "imipHtml-description-content", - value: "Let's see how evil we can be: ", + content: "Let's see how evil we can be: ", }, }, { + name: "Removed img src injection from description", input: { description: - 'DESCRIPTION:Or we can try: \r\n', + 'Or we can try: ', }, expected: { node: "imipHtml-description-content", - value: "Or we can try: ", + content: "Or we can try: ", }, }, { + name: "Description with special characters", input: { description: - 'DESCRIPTION:Check example.com  — only 3 €\r\n', + 'Check example.com  — only 3 €', }, expected: { node: "imipHtml-description-content", - value: - 'Check example.com\u00a0\u00a0\u2014 only 3 €', + content: 'Check example.com  — only 3 €', }, }, { - input: { url: "URL:http://www.example.org/event.ics\r\n" }, + name: "URL", + input: { url: "http://www.example.org/event.ics" }, expected: { node: "imipHtml-url-content", - value: - 'http://www.example.org/event.ics', + content: + '' + + "http://www.example.org/event.ics", }, }, { - input: { attach: "ATTACH:http://www.example.org\r\n" }, + name: "URL attachment", + input: { attach: "http://www.example.org" }, expected: { node: "imipHtml-attachments-content", - value: - 'http://www.example.org/', + content: + '' + + "http://www.example.org/", }, }, { + name: "Non-URL attachment is ignored", input: { - attach: - "ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:VGhlIHF1aWNrIGJyb3duI" + - "GZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4\r\n", + attach: { + params: { fmttype: "text/plain", encoding: "BASE64", value: "BINARY" }, + value: "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4", + }, + }, + expected: { node: "imipHtml-attachments-content", hidden: true }, + }, + { + name: "Several attachments", + input: { + attach: [ + "http://www.example.org/first/", + "http://www.example.org/second", + "file:///N:/folder/third.file", + ], }, expected: { node: "imipHtml-attachments-content", - value: "", + content: + '' + + "http://www.example.org/first/
" + + '' + + "http://www.example.org/second
" + + 'file:///N:/folder/third.file', }, }, { + name: "Attendees", input: { - attach: - "ATTACH:http://www.example.org/first/\r\n" + - "ATTACH:http://www.example.org/second\r\n" + - "ATTACH:file:///N:/folder/third.file\r\n", - }, - expected: { - node: "imipHtml-attachments-content", - value: - 'http://www.example.org/first/' + - '
' + - 'http://www.example.org/second' + - '
' + - '' + - "file:///N:/folder/third.file", - }, - }, - { - input: { - attendee: - "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=OPT-PARTICIPANT;CUTYPE=INDIV" + - 'IDUAL;CN="Attendee 1":mailto:attendee1@example.net\r\n' + - "ATTENDEE;RSVP=TRUE;PARTSTAT=ACCEPTED;ROLE=NON-PARTICIPANT;CUTYPE=GROUP:mai" + - "lto:attendee2@example.net\r\n" + - "ATTENDEE;RSVP=TRUE;PARTSTAT=TENTATIVE;ROLE=REQ-PARTICIPANT;CUTYPE=RESOURCE" + - ":mailto:attendee3@example.net\r\n" + - "ATTENDEE;RSVP=TRUE;PARTSTAT=DECLINED;ROLE=OPT-PARTICIPANT;DELEGATED-FROM=" + - '"mailto:attendee5@example.net";CUTYPE=ROOM:mailto:attendee4@example.' + - "net\r\n" + - 'ATTENDEE;RSVP=TRUE;PARTSTAT=DELEGATED;ROLE=OPT-PARTICIPANT;DELEGATED-TO="' + - 'mailto:attendee4@example.net";CUTYPE=UNKNOWN:mailto:attendee5@example.net' + - "\r\n" + - "ATTENDEE;RSVP=TRUE:mailto:attendee6@example.net\r\n" + - "ATTENDEE:mailto:attendee7@example.net\r\n", + attendee: [ + { + params: { + rsvp: "TRUE", + partstat: "NEEDS-ACTION", + role: "OPT-PARTICIPANT", + cutype: "INDIVIDUAL", + cn: '"Attendee 1"', + }, + value: "attendee1@example.net", + }, + { + params: { + rsvp: "TRUE", + partstat: "ACCEPTED", + role: "NON-PARTICIPANT", + cutype: "GROUP", + }, + value: "attendee2@example.net", + }, + { + params: { + rsvp: "TRUE", + partstat: "TENTATIVE", + role: "REQ-PARTICIPANT", + cutype: "RESOURCE", + }, + value: "attendee3@example.net", + }, + { + params: { + rsvp: "TRUE", + partstat: "DECLINED", + role: "OPT-PARTICIPANT", + delegatedFrom: '"mailto:attendee5@example.net"', + cutype: "ROOM", + }, + value: "attendee4@example.net", + }, + { + params: { + rsvp: "TRUE", + partstat: "DELEGATED", + role: "OPT-PARTICIPANT", + delegatedTo: '"mailto:attendee4@example.net"', + cutype: "UNKNOWN", + }, + value: "attendee5@example.net", + }, + { + params: { rsvp: "TRUE" }, + value: "attendee6@example.net", + }, + "attendee7@example.net", + ], }, expected: { node: "imipHtml-attendees-cell", - values: [ + attendeesList: [ { name: "Attendee 1 ", title: @@ -424,240 +630,466 @@ add_task(async function createInvitationOverlay_test() { }, }, { + name: "Organizer", input: { - organizer: - 'ORGANIZER;PARTSTAT=ACCEPTED;ROLE=CHAIR;CUTYPE="INDIVIDUAL";CN="The Org' + - 'anizer":mailto:organizer@example.net\r\n', + organizer: { + params: { + partstat: "ACCEPTED", + role: "CHAIR", + cutype: "INDIVIDUAL", + cn: '"The Organizer"', + }, + value: "organizer@example.net", + }, }, expected: { node: "imipHtml-organizer-cell", - values: [ - { - name: "The Organizer ", - title: - "The Organizer chairs the event. " + - "The Organizer has confirmed attendance.", - icon: { - role: "CHAIR", - usertype: "INDIVIDUAL", - partstat: "ACCEPTED", - }, + organizer: { + name: "The Organizer ", + title: + "The Organizer chairs the event. " + + "The Organizer has confirmed attendance.", + icon: { + role: "CHAIR", + usertype: "INDIVIDUAL", + partstat: "ACCEPTED", }, - ], + }, }, }, ]; - let i = 0; - for (let test of data) { - i++; - let item = getIcs(); - for (let attribute of Object.keys(test.input)) { - switch (attribute) { - case "description": - item = item.replace(/DESCRIPTION:[^\r]+\r\n/, test.input.description); - break; - case "attendee": - item = item.replace(/ATTENDEE;[^\r]+\r\n/, test.input.attendee); - break; - case "organizer": - item = item.replace(/ORGANIZER;[^\r]+\r\n/, test.input.organizer); - break; - case "attach": - item = item.replace(/ATTACH:[^\r]+\r\n/, test.input.attach); - break; - case "url": - item = item.replace(/URL:[^\r]+\r\n/, test.input.url); - break; - } + + function assertAttendee(attendee, name, title, icon, testName) { + equal(attendee.textContent, name, `Attendee names (test ${testName})`); + equal(attendee.getAttribute("title"), title, `Title for ${name} (test ${testName})`); + let attendeeIcon = attendee.querySelector(".itip-icon"); + ok(attendeeIcon, `icon for ${name} should exist (test ${testName})`); + for (let attr in icon) { + equal( + attendeeIcon.getAttribute(attr), + icon[attr], + `${attr} for icon for ${name} (test ${testName})` + ); } - let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem); - itipItem.init(item); - let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); - parser.parseString(item); - let dom = cal.invitation.createInvitationOverlay(parser.getItems()[0], itipItem); - // we remove line-breaks and leading white spaces here so we can keep expected test results - // above more comprehensive - switch (test.expected.node) { - case "imipHtml-organizer-cell": - case "imipHtml-attendees-cell": - let attendeeNodes = Array.from( - dom.querySelectorAll(`#${test.expected.node} .attendee-label`) + } + + for (let test of data) { + info(`testing ${test.name}`); + let { event, itipItem } = getEvent(test.input); + let dom = cal.invitation.createInvitationOverlay(event, itipItem); + let node = dom.getElementById(test.expected.node); + ok(node, `Element with id ${test.expected.node} should exist (test ${test.name})`); + if (test.expected.hidden) { + assertHiddenRow(node, true, test.name); + continue; + } + assertHiddenRow(node, false, test.name); + + if ("attendeesList" in test.expected) { + let attendeeNodes = node.querySelectorAll(".attendee-label"); + // Assert same order. + let i; + for (i = 0; i < test.expected.attendeesList.length; i++) { + let { name, title, icon } = test.expected.attendeesList[i]; + ok( + attendeeNodes.length > i, + `Enough attendees for expected attendee #${i} ${name} (test ${test.name})` ); - equal(attendeeNodes.length, test.expected.values.length); - for (let { name, title, icon } of test.expected.values) { - let index = attendeeNodes.findIndex(el => el.textContent === name); - ok(index !== -1, `Attendee with name ${name}`); - let node = attendeeNodes.splice(index, 1)[0]; - equal(node.getAttribute("title"), title, `Title for ${name}`); - let nodeIcon = node.querySelector(".itip-icon"); - ok(nodeIcon, `icon for ${name}`); - for (let attr in icon) { - equal(nodeIcon.getAttribute(attr), icon[attr], `${attr} for icon for ${name}`); - } - } - break; - default: - let observed = dom.getElementById(test.expected.node).innerHTML; - equal(observed, test.expected.value, "(test #" + i + ")"); - break; + assertAttendee(attendeeNodes[i], name, title, icon, test.name); + } + equal(attendeeNodes.length, i, `Same number of attendees (test ${test.name})`); + } else if ("organizer" in test.expected) { + let { name, title, icon } = test.expected.organizer; + let organizerNode = node.querySelector(".attendee-label"); + ok(organizerNode, `Organizer node should exist (test ${test.name})`); + assertAttendee(organizerNode, name, title, icon, test.name); + } else { + equal(node.innerHTML, test.expected.content, `innerHTML (test ${test.name})`); } } }); -add_task(async function compareInvitationOverlay_test() { - // eventually it would make sense to set local timezone to Europe/Berlin to avoid test - // failures when executing in a different timezone - function getDom(aInput) { - let item = getIcs(); - let props = ["attendee", "organizer", "dtstart", "dtend", "summary", "location"]; - for (let prop of props) { - if (Object.keys(aInput).includes(prop)) { - let regex = - prop.toUpperCase() + (["summary", "location"].includes(prop) ? ":" : ";") + "[^\r]+\r\n"; - item = item.replace(new RegExp(regex), aInput[prop]); - } - } - let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem); - itipItem.init(item); - let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); - parser.parseString(item); - let dom = cal.invitation.createInvitationOverlay(parser.getItems()[0], itipItem); - return cal.xml.serializeDOM(dom); - } +add_task(async function updateInvitationOverlay_test() { let data = [ { + name: "No description before or after", + input: { previous: { description: "" }, current: { description: "" } }, + expected: { node: "imipHtml-description-content", hidden: true }, + }, + { + name: "Same description before and after", input: { - previous: { location: "LOCATION:This place\r\n" }, - current: { location: "LOCATION:Another location\r\n" }, + previous: { description: "This is the description" }, + current: { description: "This is the description" }, + }, + expected: { + node: "imipHtml-description-content", + content: [{ type: "same", text: "This is the description" }], + }, + }, + { + name: "Added description", + input: { + previous: { description: "" }, + current: { description: "Added this description" }, + }, + expected: { + node: "imipHtml-description-content", + content: [{ type: "added", text: "Added this description" }], + }, + }, + { + name: "Removed description", + input: { + previous: { description: "Removed this description" }, + current: { description: "" }, + }, + expected: { + node: "imipHtml-description-content", + content: [{ type: "removed", text: "Removed this description" }], + }, + }, + { + name: "Location", + input: { + previous: { location: "This place" }, + current: { location: "Another location" }, }, expected: { node: "imipHtml-location-content", - ins: ["Another location"], - del: ["This place"], - mod: [], + content: [ + { type: "added", text: "Another location" }, + { type: "removed", text: "This place" }, + ], }, }, { + name: "Summary", input: { - previous: { summary: "SUMMARY:My invitation\r\n" }, - current: { summary: "SUMMARY:My new invitation\r\n" }, + previous: { summary: "My invitation" }, + current: { summary: "My new invitation" }, }, expected: { node: "imipHtml-summary-content", - ins: ["My new invitation"], - del: ["My invitation"], - mod: [], + content: [ + { type: "added", text: "My new invitation" }, + { type: "removed", text: "My invitation" }, + ], }, }, { + name: "When", input: { previous: { - dtstart: "DTSTART;TZID=Europe/Berlin:20150909T130000\r\n", - dtend: "DTEND;TZID=Europe/Berlin:20150909T140000\r\n", + dtstart: "20150909T130000", + dtend: "20150909T140000", }, current: { - dtstart: "DTSTART;TZID=Europe/Berlin:20150909T140000\r\n", - dtend: "DTEND;TZID=Europe/Berlin:20150909T150000\r\n", + dtstart: "20150909T140000", + dtend: "20150909T150000", }, }, expected: { - // Time format is platform dependent, so we use alternative result sets here. - // The first two are configurations running for automated tests. - // If you get a failure for this test, add your pattern here. node: "imipHtml-when-content", - // For Windows. - ins: [/^Wednesday, (September 0?9,|0?9 September) 2015 (2:00 PM – 3:00 PM|14:00 – 15:00)$/], - del: [/^Wednesday, (September 0?9,|0?9 September) 2015 (1:00 PM – 2:00 PM|13:00 – 14:00)$/], - mod: [], + content: [ + // Time format is platform dependent, so we use alternative result + // sets here. + // If you get a failure for this test, add your pattern here. + { + type: "added", + text: /^Wednesday, (September 0?9,|0?9 September) 2015 (2:00 PM – 3:00 PM|14:00 – 15:00)$/, + }, + { + type: "removed", + text: /^Wednesday, (September 0?9,|0?9 September) 2015 (1:00 PM – 2:00 PM|13:00 – 14:00)$/, + }, + ], }, }, { + name: "Organizer same", input: { - previous: { organizer: "ORGANIZER:mailto:organizer1@example.net\r\n" }, - current: { organizer: "ORGANIZER:mailto:organizer2@example.net\r\n" }, + previous: { organizer: "organizer1@example.net" }, + current: { organizer: "organizer1@example.net" }, }, expected: { node: "imipHtml-organizer-cell", - ins: ["organizer2@example.net"], - del: ["organizer1@example.net"], - mod: [], + organizer: [{ type: "same", text: "organizer1@example.net" }], }, }, { + name: "Organizer modified", + input: { + // Modify ROLE from CHAIR to REQ-PARTICIPANT. + previous: { organizer: { params: { role: "CHAIR" }, value: "organizer1@example.net" } }, + current: { + organizer: { params: { role: "REQ-PARTICIPANT" }, value: "organizer1@example.net" }, + }, + }, + expected: { + node: "imipHtml-organizer-cell", + organizer: [{ type: "modified", text: "organizer1@example.net" }], + }, + }, + { + name: "Organizer added", + input: { + previous: { organizer: "" }, + current: { organizer: "organizer2@example.net" }, + }, + expected: { + node: "imipHtml-organizer-cell", + organizer: [{ type: "added", text: "organizer2@example.net" }], + }, + }, + { + name: "Organizer removed", + input: { + previous: { organizer: "organizer2@example.net" }, + current: { organizer: "" }, + }, + expected: { + node: "imipHtml-organizer-cell", + organizer: [{ type: "removed", text: "organizer2@example.net" }], + }, + }, + { + name: "Organizer changed", + input: { + previous: { organizer: "organizer1@example.net" }, + current: { organizer: "organizer2@example.net" }, + }, + expected: { + node: "imipHtml-organizer-cell", + organizer: [ + { type: "added", text: "organizer2@example.net" }, + { type: "removed", text: "organizer1@example.net" }, + ], + }, + }, + { + name: "Attendees: modify one, remove one, add one", input: { previous: { - attendee: - "ATTENDEE;RSVP=TRUE;CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION:" + - "mailto:attendee1@example.net\r\n" + - "ATTENDEE;RSVP=TRUE;CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION:" + - "mailto:attendee2@example.net\r\n" + - "ATTENDEE;RSVP=TRUE;CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION:" + - "mailto:attendee3@example.net\r\n", + attendee: [ + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee1@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee2@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee3@example.net", + }, + ], }, current: { - attendee: - "ATTENDEE;RSVP=TRUE;CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:mail" + - "to:attendee2@example.net\r\n" + - "ATTENDEE;RSVP=TRUE;CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION:" + - "mailto:attendee3@example.net\r\n" + - "ATTENDEE;RSVP=TRUE;CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION:" + - "mailto:attendee4@example.net\r\n", + attendee: [ + { + // Modify PARTSTAT from NEEDS-ACTION. + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "ACCEPTED" }, + value: "attendee2@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee3@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee4@example.net", + }, + ], }, }, expected: { node: "imipHtml-attendees-cell", - ins: ["attendee4@example.net"], - del: ["attendee1@example.net"], - mod: ["attendee2@example.net"], + attendeesList: [ + { type: "removed", text: "attendee1@example.net" }, + { type: "modified", text: "attendee2@example.net" }, + { type: "same", text: "attendee3@example.net" }, + { type: "added", text: "attendee4@example.net" }, + ], + }, + }, + { + name: "Attendees: modify one, remove three, add two", + input: { + previous: { + attendee: [ + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee-remove1@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "GROUP", partstat: "NEEDS-ACTION" }, + value: "attendee1@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee-remove2@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee-remove3@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee3@example.net", + }, + ], + }, + current: { + attendee: [ + { + // Modify CUTYPE from GROUP. + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee1@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee-add1@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee-add2@example.net", + }, + { + params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" }, + value: "attendee3@example.net", + }, + ], + }, + }, + expected: { + node: "imipHtml-attendees-cell", + attendeesList: [ + { type: "removed", text: "attendee-remove1@example.net" }, + { type: "modified", text: "attendee1@example.net" }, + // Added shown first, then removed, and in between the common + // attendees. + { type: "added", text: "attendee-add1@example.net" }, + { type: "added", text: "attendee-add2@example.net" }, + { type: "removed", text: "attendee-remove2@example.net" }, + { type: "removed", text: "attendee-remove3@example.net" }, + { type: "same", text: "attendee3@example.net" }, + ], }, }, ]; - // make sure that the Europe/Berlin timezone and long datetime format is set - // and to use the app locale to avoid test failures when running locally on - // an OS with a regional setting other than en-US - // XXX: doesn't work if your OS is in English but not US English. - // Work around it by `export LC_TIME=en_US.UTF-8` before running the test. - let dateformat = Services.prefs.getIntPref("calendar.date.format", 0); - let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Europe/Berlin"); - let useOsLocale = Services.prefs.getBoolPref("intl.regional_prefs.use_os_locales", false); - Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", false); - Services.prefs.setIntPref("calendar.date.format", 0); - Services.prefs.setStringPref("calendar.timezone.local", "Europe/Berlin"); - for (let test of data) { - let dom1 = getDom(test.input.previous); - let dom2 = getDom(test.input.current); - let result = cal.invitation.compareInvitationOverlay(dom1, dom2); - let dom = cal.xml.parseString(result); - let id = test.expected.node; - function assertChanges(name, nodes, expectedText) { - equal(nodes.length, expectedText.length, `Equal number of ${name} for ${id}`); - for (let text of expectedText) { - let index; - if (text instanceof RegExp) { - index = nodes.findIndex(el => text.test(el.textContent)); - } else { - index = nodes.findIndex(el => el.textContent === text); - } - ok(index !== -1, `${name} node with text ${text} for ${id}`); - nodes.splice(index, 1); - } + function assertElement(node, text, type, testName) { + let found = node.textContent; + if (text instanceof RegExp) { + ok(text.test(found), `Text content "${found}" matches regex (test ${testName})`); + } else { + equal(text, found, `Text content matches (test ${testName})`); + } + switch (type) { + case "added": + equal(node.tagName, "INS", `Text "${text}" is inserted (test ${testName})`); + ok(node.classList.contains("added"), `Text "${text}" is added (test ${testName})`); + break; + case "removed": + equal(node.tagName, "DEL", `Text "${text}" is deleted (test ${testName})`); + ok(node.classList.contains("removed"), `Text "${text}" is removed (test ${testName})`); + break; + case "modified": + ok(node.tagName !== "DEL", `Text "${text}" is not deleted (test ${testName})`); + ok(node.tagName !== "INS", `Text "${text}" is not inserted (test ${testName})`); + ok(node.classList.contains("modified"), `Text "${text}" is modified (test ${testName})`); + break; + case "same": + // NOTE: node may be a Text node. + ok(node.tagName !== "DEL", `Text "${text}" is not deleted (test ${testName})`); + ok(node.tagName !== "INS", `Text "${text}" is not inserted (test ${testName})`); + if (node.classList) { + ok(!node.classList.contains("added"), `Text "${text}" is not added (test ${testName})`); + ok( + !node.classList.contains("removed"), + `Text "${text}" is not removed (test ${testName})` + ); + ok( + !node.classList.contains("modified"), + `Text "${text}" is not modified (test ${testName})` + ); + } + break; + default: + ok(false, `Unknown type ${type} for text "${text}" (test ${testName})`); + break; } - let node = dom.getElementById(id); - ok(node, `Element with id ${id}`); - assertChanges("", Array.from(node.querySelectorAll("ins.added")), test.expected.ins); - assertChanges("", Array.from(node.querySelectorAll("del.removed")), test.expected.del); - assertChanges("modified", Array.from(node.querySelectorAll(".modified")), test.expected.mod); } - // let's reset setting - Services.prefs.setIntPref("calendar.date.format", dateformat); - Services.prefs.setStringPref("calendar.timezone.local", tzlocal); - Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", useOsLocale); + + for (let test of data) { + info(`testing ${test.name}`); + let { event, itipItem } = getEvent(test.input.current); + let dom = cal.invitation.createInvitationOverlay(event, itipItem); + let { event: oldEvent } = getEvent(test.input.previous); + cal.invitation.updateInvitationOverlay(dom, event, itipItem, oldEvent); + + let node = dom.getElementById(test.expected.node); + ok(node, `Element with id ${test.expected.node} should exist (test ${test.name})`); + if (test.expected.hidden) { + assertHiddenRow(node, true, test.name); + continue; + } + assertHiddenRow(node, false, test.name); + + let insertBreaks = false; + let nodeList; + let expectList; + + if ("attendeesList" in test.expected) { + // Insertions, deletions and modifications are all within separate + // list-items. + nodeList = node.querySelectorAll(":scope > .attendee-list > .attendee-list-item > *"); + expectList = test.expected.attendeesList; + } else if ("organizer" in test.expected) { + nodeList = node.childNodes; + expectList = test.expected.organizer; + } else { + nodeList = node.childNodes; + expectList = test.expected.content; + insertBreaks = true; + } + + // Assert in same order. + let first = true; + let nodeIndex = 0; + for (let { text, type } of expectList) { + if (first) { + first = false; + } else if (insertBreaks) { + ok( + nodeList.length > nodeIndex, + `Enough child nodes for expected break node at index ${nodeIndex} (test ${test.name})` + ); + equal( + nodeList[nodeIndex].tagName, + "BR", + `Break node at index ${nodeIndex} (test ${test.name})` + ); + nodeIndex++; + } + + ok( + nodeList.length > nodeIndex, + `Enough child nodes for expected node at index ${nodeIndex} "${text}" (test ${test.name})` + ); + assertElement(nodeList[nodeIndex], text, type, test.name); + nodeIndex++; + } + equal(nodeList.length, nodeIndex, `Covered all nodes (test ${test.name})`); + } }); add_task(async function getHeaderSection_test() { let data = [ { + // test #1 input: { toList: "recipient@example.net", subject: "Invitation: test subject", @@ -681,6 +1113,7 @@ add_task(async function getHeaderSection_test() { "Bcc: bcc@example.net\r\n", }, { + // test #2 input: { toList: 'rec1@example.net, Recipient 2 , "Rec, 3" ', subject: "Invitation: test subject", @@ -704,6 +1137,7 @@ add_task(async function getHeaderSection_test() { 'Bcc: bcc1@example.net, BCc 2 , "Bcc, 3"\r\n \r\n', }, { + // test #3 input: { toList: "recipient@example.net", subject: "Invitation: test subject", @@ -716,6 +1150,7 @@ add_task(async function getHeaderSection_test() { "Subject: Invitation: test subject\r\n", }, { + // test #4 input: { toList: "Max Müller ", subject: "Invitation: Diacritis check (üäé)", @@ -743,6 +1178,7 @@ add_task(async function getHeaderSection_test() { let i = 0; for (let test of data) { i++; + info(`testing test #${i}`); let identity = MailServices.accounts.createIdentity(); identity.email = test.input.identity.email || null; identity.fullName = test.input.identity.fullName || null; @@ -778,6 +1214,7 @@ add_task(async function getHeaderSection_test() { add_task(async function convertFromUnicode_test() { let data = [ { + // test #1 input: { charset: "UTF-8", text: "müller", @@ -785,6 +1222,7 @@ add_task(async function convertFromUnicode_test() { expected: "müller", }, { + // test #2 input: { charset: "UTF-8", text: "muller", @@ -792,6 +1230,7 @@ add_task(async function convertFromUnicode_test() { expected: "muller", }, { + // test #3 input: { charset: "UTF-8", text: "müller\nmüller", @@ -799,6 +1238,7 @@ add_task(async function convertFromUnicode_test() { expected: "müller\nmüller", }, { + // test #4 input: { charset: "UTF-8", text: "müller\r\nmüller", @@ -820,22 +1260,27 @@ add_task(async function convertFromUnicode_test() { add_task(async function encodeUTF8_test() { let data = [ { + // test #1 input: "müller", expected: "müller", }, { + // test #2 input: "muller", expected: "muller", }, { + // test #3 input: "müller\nmüller", expected: "müller\r\nmüller", }, { + // test #4 input: "müller\r\nmüller", expected: "müller\r\nmüller", }, { + // test #5 input: "", expected: "", }, @@ -850,6 +1295,7 @@ add_task(async function encodeUTF8_test() { add_task(async function encodeMimeHeader_test() { let data = [ { + // test #1 input: { header: "Max Müller ", isEmail: true, @@ -857,6 +1303,7 @@ add_task(async function encodeMimeHeader_test() { expected: "=?UTF-8?Q?Max_M=c3=bcller?= ", }, { + // test #2 input: { header: "Max Mueller ", isEmail: true, @@ -864,6 +1311,7 @@ add_task(async function encodeMimeHeader_test() { expected: "Max Mueller ", }, { + // test #3 input: { header: "Müller & Müller", isEmail: false, @@ -887,34 +1335,42 @@ add_task(async function getRfc5322FormattedDate_test() { let data = { input: [ { + // test #1 date: null, timezone: "America/New_York", }, { + // test #2 date: "Sat, 24 Jan 2015 09:24:49 +0100", timezone: "America/New_York", }, { + // test #3 date: "Sat, 24 Jan 2015 09:24:49 GMT+0100", timezone: "America/New_York", }, { + // test #4 date: "Sat, 24 Jan 2015 09:24:49 GMT", timezone: "America/New_York", }, { + // test #5 date: "Sat, 24 Jan 2015 09:24:49", timezone: "America/New_York", }, { + // test #6 date: "Sat, 24 Jan 2015 09:24:49", timezone: null, }, { + // test #7 date: "Sat, 24 Jan 2015 09:24:49", timezone: "UTC", }, { + // test #8 date: "Sat, 24 Jan 2015 09:24:49", timezone: "floating", }, @@ -943,416 +1399,277 @@ add_task(async function parseCounter_test() { /* eslint-disable object-curly-newline */ let data = [ { - // #1: basic test to check all currently supported properties + name: "Basic test to check all currently supported properties", input: { - existing: [], - proposed: [ - { - method: "METHOD:COUNTER", + proposed: { + method: "COUNTER", + dtstart: "20150910T210000", + dtend: "20150910T220000", + location: "Room 2", + summary: "Test Event 2", + attendee: { + params: { cn: "Attendee", partstat: "DECLINED", role: "REQ-PARTICIPANT" }, + value: "attendee@example.net", }, - { - dtStart: "DTSTART;TZID=Europe/Berlin:20150910T210000", - }, - { - dtEnd: "DTEND;TZID=Europe/Berlin:20150910T220000", - }, - { - location: "LOCATION:Room 2", - }, - { - summary: "SUMMARY:Test Event 2", - }, - { - attendee: - "ATTENDEE;CN=Attendee;PARTSTAT=DECLINED;ROLE=REQ-PARTICIPANT:" + - "mailto:attendee@example.net", - }, - { - dtStamp: "DTSTAMP:20150909T182048Z", - }, - { - attach: "COMMENT:Sorry, I cannot make it that time.", - }, - ], + dtstamp: "20150909T182048Z", + comment: "Sorry, I cannot make it that time.", + }, }, expected: { // Time format is platform dependent, so we use alternative result sets here. // The first two are configurations running for automated tests. // If you get a failure for this test, add your pattern here. result: { descr: "", type: "OK" }, - differences: [ - { - property: "SUMMARY", + differences: { + summary: { proposed: "Test Event 2", original: "Test Event", }, - { - property: "LOCATION", + location: { proposed: "Room 2", original: "Room 1", }, - { - property: "DTSTART", - proposed: [ - "Thursday, September 10, 2015 9:00 PM Europe/Berlin", - "Thursday, September 10, 2015 21:00 Europe/Berlin", - ], - original: [ - "Wednesday, September 09, 2015 9:00 PM Europe/Berlin", // Windows - "Wednesday, September 09, 2015 21:00 Europe/Berlin", - "Wednesday, September 9, 2015 9:00 PM Europe/Berlin", // Linux and Mac - "Wednesday, September 9, 2015 21:00 Europe/Berlin", - ], + dtstart: { + proposed: /^Thursday, (September 10,|10 September) 2015 (9:00 PM|21:00) Europe\/Berlin$/, + original: /^Wednesday, (September 0?9,|0?9 September) 2015 (9:00 PM|21:00) Europe\/Berlin$/, }, - { - property: "DTEND", - proposed: [ - "Thursday, September 10, 2015 10:00 PM Europe/Berlin", - "Thursday, September 10, 2015 22:00 Europe/Berlin", - ], - original: [ - "Wednesday, September 09, 2015 10:00 PM Europe/Berlin", // Windows - "Wednesday, September 09, 2015 22:00 Europe/Berlin", - "Wednesday, September 9, 2015 10:00 PM Europe/Berlin", // Linux and Mac - "Wednesday, September 9, 2015 22:00 Europe/Berlin", - ], + dtend: { + proposed: /^Thursday, (September 10,|10 September) 2015 (10:00 PM|22:00) Europe\/Berlin$/, + original: /^Wednesday, (September 0?9,|0?9 September) 2015 (10:00 PM|22:00) Europe\/Berlin$/, }, - { - property: "COMMENT", + comment: { proposed: "Sorry, I cannot make it that time.", original: null, }, - ], + }, }, }, { - // #2: test with an unsupported property has been changed + name: "Test with an unsupported property has been changed", input: { - existing: [], - proposed: [ - { - method: "METHOD:COUNTER", + proposed: { + method: "COUNTER", + attendee: { + params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" }, + value: "attendee@example.net", }, - { - attendee: - "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" + - "mailto:attendee@example.net", - }, - { - location: "LOCATION:Room 2", - }, - { - attach: "ATTACH:http://www.example2.com", - }, - { - dtStamp: "DTSTAMP:20150909T182048Z", - }, - ], + location: "Room 2", + attach: "http://www.example2.com", + dtstamp: "20150909T182048Z", + }, }, expected: { result: { descr: "", type: "OK" }, - differences: [{ property: "LOCATION", proposed: "Room 2", original: "Room 1" }], + differences: { location: { proposed: "Room 2", original: "Room 1" } }, }, }, { - // #3: proposed change not based on the latest update of the invitation + name: "Proposed change not based on the latest update of the invitation", input: { - existing: [], - proposed: [ - { - method: "METHOD:COUNTER", + proposed: { + method: "COUNTER", + attendee: { + params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" }, + value: "attendee@example.net", }, - { - attendee: - "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" + - "mailto:attendee@example.net", - }, - { - location: "LOCATION:Room 2", - }, - { - dtStamp: "DTSTAMP:20150909T171048Z", - }, - ], + location: "Room 2", + dtstamp: "20150909T171048Z", + }, }, expected: { result: { descr: "This is a counterproposal not based on the latest event update.", type: "NOTLATESTUPDATE", }, - differences: [{ property: "LOCATION", proposed: "Room 2", original: "Room 1" }], + differences: { location: { proposed: "Room 2", original: "Room 1" } }, }, }, { - // #4: proposed change based on a meanwhile reschuled invitation + name: "Proposed change based on a meanwhile reschuled invitation", input: { - existing: [], - proposed: [ - { - method: "METHOD:COUNTER", + proposed: { + method: "COUNTER", + attendee: { + params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" }, + value: "attendee@example.net", }, - { - attendee: - "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" + - "mailto:attendee@example.net", - }, - { - location: "LOCATION:Room 2", - }, - { - sequence: "SEQUENCE:0", - }, - { - dtStamp: "DTSTAMP:20150909T182048Z", - }, - ], + location: "Room 2", + sequence: "0", + dtstamp: "20150909T182048Z", + }, }, expected: { result: { descr: "This is a counterproposal to an already rescheduled event.", type: "OUTDATED", }, - differences: [{ property: "LOCATION", proposed: "Room 2", original: "Room 1" }], + differences: { location: { proposed: "Room 2", original: "Room 1" } }, }, }, { - // #5: proposed change for an later sequence of the event + name: "Proposed change for an later sequence of the event", input: { - existing: [], - proposed: [ - { - method: "METHOD:COUNTER", + proposed: { + method: "COUNTER", + attendee: { + params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" }, + value: "attendee@example.net", }, - { - attendee: - "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" + - "mailto:attendee@example.net", - }, - { - location: "LOCATION:Room 2", - }, - { - sequence: "SEQUENCE:2", - }, - { - dtStamp: "DTSTAMP:20150909T182048Z", - }, - ], + location: "Room 2", + sequence: "2", + dtstamp: "20150909T182048Z", + }, }, expected: { result: { descr: "Invalid sequence number in counterproposal.", type: "ERROR", }, - differences: [], + differences: {}, }, }, { - // #6: proposal to a different event + name: "Proposal to a different event", input: { - existing: [], - proposed: [ - { - method: "METHOD:COUNTER", + proposed: { + method: "COUNTER", + uid: "cb189fdc-0000-0000-0000-31a08802249d", + attendee: { + params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" }, + value: "attendee@example.net", }, - { - uid: "UID:cb189fdc-0000-0000-0000-31a08802249d", - }, - { - attendee: - "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" + - "mailto:attendee@example.net", - }, - { - location: "LOCATION:Room 2", - }, - { - dtStamp: "DTSTAMP:20150909T182048Z", - }, - ], + location: "Room 2", + dtstamp: "20150909T182048Z", + }, }, expected: { result: { descr: "Mismatch of uid or organizer in counterproposal.", type: "ERROR", }, - differences: [], + differences: {}, }, }, { - // #7: proposal with a different organizer + name: "Proposal with a different organizer", input: { - existing: [], - proposed: [ - { - method: "METHOD:COUNTER", + proposed: { + method: "COUNTER", + organizer: { + params: { rsvp: "TRUE", cn: "Organizer", partstat: "ACCEPTED", role: "CHAIR" }, + value: "organizer2@example.net", }, - { - organizer: - "ORGANIZER;RSVP=TRUE;CN=Organizer;PARTSTAT=ACCEPTED;ROLE=CHAI" + - "R:mailto:organizer2@example.net", + attendee: { + params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" }, + value: "attendee@example.net", }, - { - attendee: - "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" + - "mailto:attendee@example.net", - }, - { - dtStamp: "DTSTAMP:20150909T182048Z", - }, - ], + dtstamp: "20150909T182048Z", + }, }, expected: { result: { descr: "Mismatch of uid or organizer in counterproposal.", type: "ERROR", }, - differences: [], + differences: {}, }, }, { - // #8:counterproposal without any difference + name: "Counterproposal without any difference", input: { - existing: [], - proposed: [{ method: "METHOD:COUNTER" }], + proposed: { method: "COUNTER" }, }, expected: { result: { descr: "No difference in counterproposal detected.", type: "NODIFF", }, - differences: [], + differences: {}, }, }, ]; /* eslint-enable object-curly-newline */ - // make sure to use the app locale to avoid test failures when running - // locally on an OS with a regional setting other than en-US - let useOsLocale = Services.prefs.getBoolPref("intl.regional_prefs.use_os_locales", false); - Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", false); - let getItem = function(aProperties) { - let item = getIcs(true); - - let modifyProperty = function(aRegex, aReplacement, aInVevent) { - let inVevent = false; - let i = 0; - item.forEach(aProp => { - if (aProp == "BEGIN:VEVENT" && !inVevent) { - inVevent = true; - } else if (aProp == "END:VEVENT" && inVevent) { - inVevent = false; - } - if ((aInVevent && inVevent) || !aInVevent) { - item[i] = aProp.replace(aRegex, aReplacement); - } - i++; - }); - }; - - if (aProperties) { - aProperties.forEach(aProp => { - if ("method" in aProp && aProp.method) { - modifyProperty(/(METHOD.+)/, aProp.method, false); - } else if ("attendee" in aProp && aProp.attendee) { - modifyProperty(/(ATTENDEE.+)/, aProp.attendee, true); - } else if ("attach" in aProp && aProp.attach) { - modifyProperty(/(ATTACH.+)/, aProp.attach, true); - } else if ("summary" in aProp && aProp.summary) { - modifyProperty(/(SUMMARY.+)/, aProp.summary, true); - } else if ("location" in aProp && aProp.location) { - modifyProperty(/(LOCATION.+)/, aProp.location, true); - } else if ("dtStart" in aProp && aProp.dtStart) { - modifyProperty(/(DTSTART.+)/, aProp.dtStart, true); - } else if ("dtEnd" in aProp && aProp.dtEnd) { - modifyProperty(/(DTEND.+)/, aProp.dtEnd, true); - } else if ("sequence" in aProp && aProp.sequence) { - modifyProperty(/(SEQUENCE.+)/, aProp.sequence, true); - } else if ("dtStamp" in aProp && aProp.dtStamp) { - modifyProperty(/(DTSTAMP.+)/, aProp.dtStamp, true); - } else if ("organizer" in aProp && aProp.organizer) { - modifyProperty(/(ORGANIZER.+)/, aProp.organizer, true); - } else if ("uid" in aProp && aProp.uid) { - modifyProperty(/(UID.+)/, aProp.uid, true); - } - }); - } - item = item.join("\r\n"); + let item = getIcs(aProperties); return createEventFromIcalString(item); }; let formatDt = function(aDateTime) { + if (!aDateTime) { + return null; + } let datetime = cal.dtz.formatter.formatDateTime(aDateTime); return datetime + " " + aDateTime.timezone.displayName; }; - for (let i = 1; i <= data.length; i++) { - let test = data[i - 1]; - let existingItem = getItem(test.input.existing); + for (let test of data) { + info(`testing ${test.name}`); + let existingItem = getItem(); let proposedItem = getItem(test.input.proposed); let parsed = cal.invitation.parseCounter(proposedItem, existingItem); - equal(parsed.result.type, test.expected.result.type, "(test #" + i + ": result.type)"); - equal(parsed.result.descr, test.expected.result.descr, "(test #" + i + ": result.descr)"); + equal(parsed.result.type, test.expected.result.type, `(test ${test.name}: result.type)`); + equal(parsed.result.descr, test.expected.result.descr, `(test ${test.name}: result.descr)`); let parsedProps = []; let additionalProps = []; let missingProps = []; parsed.differences.forEach(aDiff => { - let expected = test.expected.differences.filter(bDiff => bDiff.property == aDiff.property); - if (expected.length == 1) { - if (["DTSTART", "DTEND"].includes(aDiff.property)) { - let prop = aDiff.proposed ? formatDt(aDiff.proposed) : null; + let prop = aDiff.property.toLowerCase(); + if (prop in test.expected.differences) { + let { proposed, original } = test.expected.differences[prop]; + let foundProposed = aDiff.proposed; + let foundOriginal = aDiff.original; + if (["dtstart", "dtend"].includes(prop)) { + foundProposed = formatDt(foundProposed); + foundOriginal = formatDt(foundOriginal); + ok(foundProposed, `(test ${test.name}: have proposed time value for ${prop})`); + ok(foundOriginal, `(test ${test.name}: have original time value for ${prop})`); + } + + if (proposed instanceof RegExp) { ok( - prop && expected[0].proposed.includes(prop), - "(test #" + i + ": difference " + aDiff.property + ": proposed '" + prop + "')" - ); - prop = aDiff.original ? formatDt(aDiff.original) : null; - ok( - prop && expected[0].original.includes(prop), - "(test #" + i + ": difference " + aDiff.property + ": original '" + prop + "')" + proposed.test(foundProposed), + `(test ${test.name}: proposed "${foundProposed}" for ${prop} matches expected regex)` ); } else { equal( - aDiff.proposed, - expected[0].proposed, - "(test #" + i + ": difference " + aDiff.property + ": proposed)" - ); - equal( - aDiff.original, - expected[0].original, - "(test #" + i + ": difference " + aDiff.property + ": original)" + foundProposed, + proposed, + `(test ${test.name}: proposed for ${prop} matches expected)` ); } - parsedProps.push(aDiff.property); - } else if (expected.length == 0) { - additionalProps.push(aDiff.property); + + if (original instanceof RegExp) { + ok( + original.test(foundOriginal), + `(test ${test.name}: original "${foundOriginal}" for ${prop} matches expected regex)` + ); + } else { + equal( + foundOriginal, + original, + `(test ${test.name}: original for ${prop} matches expected)` + ); + } + + parsedProps.push(prop); + } else { + additionalProps.push(prop); } }); - test.expected.differences.forEach(aDiff => { - if (!parsedProps.includes(aDiff.property)) { - missingProps.push(aDiff.property); + for (let prop in test.expected.differences) { + if (!parsedProps.includes(prop)) { + missingProps.push(prop); } - }); + } ok( additionalProps.length == 0, - "(test #" + - i + - ": differences: check for unexpectedly " + - "occurring additional properties " + - additionalProps + - ")" + `(test ${test.name}: should be no additional properties: ${additionalProps})` ); ok( missingProps.length == 0, - "(test #" + - i + - ": differences: check for unexpectedly " + - "missing properties " + - missingProps + - ")" + `(test ${test.name}: should be no missing properties: ${missingProps})` ); } - - Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", useOsLocale); });