summaryrefslogtreecommitdiffstats
path: root/comm/calendar/test
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /comm/calendar/test
parentInitial commit. (diff)
downloadthunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz
thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/calendar/test')
-rw-r--r--comm/calendar/test/.eslintrc.js74
-rw-r--r--comm/calendar/test/CalDAVServer.jsm627
-rw-r--r--comm/calendar/test/CalendarTestUtils.jsm1203
-rw-r--r--comm/calendar/test/CalendarUtils.jsm87
-rw-r--r--comm/calendar/test/ICSServer.jsm153
-rw-r--r--comm/calendar/test/ItemEditingHelpers.jsm681
-rw-r--r--comm/calendar/test/browser/browser.ini39
-rw-r--r--comm/calendar/test/browser/browser_basicFunctionality.js78
-rw-r--r--comm/calendar/test/browser/browser_calDAV_discovery.js241
-rw-r--r--comm/calendar/test/browser/browser_calDAV_oAuth.js201
-rw-r--r--comm/calendar/test/browser/browser_calendarList.js341
-rw-r--r--comm/calendar/test/browser/browser_calendarTelemetry.js119
-rw-r--r--comm/calendar/test/browser/browser_calendarUnifinder.js76
-rw-r--r--comm/calendar/test/browser/browser_dragEventItem.js414
-rw-r--r--comm/calendar/test/browser/browser_eventDisplay_dayView.js133
-rw-r--r--comm/calendar/test/browser/browser_eventDisplay_multiWeekView.js275
-rw-r--r--comm/calendar/test/browser/browser_eventDisplay_weekView.js151
-rw-r--r--comm/calendar/test/browser/browser_eventUndoRedo.js260
-rw-r--r--comm/calendar/test/browser/browser_import.js285
-rw-r--r--comm/calendar/test/browser/browser_localICS.js63
-rw-r--r--comm/calendar/test/browser/browser_tabs.js26
-rw-r--r--comm/calendar/test/browser/browser_taskDelete.js185
-rw-r--r--comm/calendar/test/browser/browser_taskDisplay.js274
-rw-r--r--comm/calendar/test/browser/browser_taskUndoRedo.js244
-rw-r--r--comm/calendar/test/browser/browser_todayPane.js820
-rw-r--r--comm/calendar/test/browser/browser_todayPane_dragAndDrop.js262
-rw-r--r--comm/calendar/test/browser/browser_todayPane_visibility.js167
-rw-r--r--comm/calendar/test/browser/contextMenu/browser.ini14
-rw-r--r--comm/calendar/test/browser/contextMenu/browser_edit.js187
-rw-r--r--comm/calendar/test/browser/data/attachment.pngbin0 -> 82 bytes
-rw-r--r--comm/calendar/test/browser/data/calendars.sjs126
-rw-r--r--comm/calendar/test/browser/data/dns.sjs56
-rw-r--r--comm/calendar/test/browser/data/event.ics10
-rw-r--r--comm/calendar/test/browser/data/import.ics24
-rw-r--r--comm/calendar/test/browser/data/principal.sjs39
-rw-r--r--comm/calendar/test/browser/eventDialog/browser.ini27
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_alarmDialog.js88
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_attachMenu.js266
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_attendeesDialog.js462
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_attendeesDialogAdd.js248
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_attendeesDialogNoEdit.js68
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_attendeesDialogRemove.js147
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_attendeesDialogUpdate.js140
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_eventDialog.js399
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_eventDialogDescriptionEditor.js154
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_eventDialogEditButton.js223
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_eventDialogModificationPrompt.js160
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_utf8.js56
-rw-r--r--comm/calendar/test/browser/eventDialog/data/guests.txt2
-rw-r--r--comm/calendar/test/browser/eventDialog/head.js97
-rw-r--r--comm/calendar/test/browser/head.js374
-rw-r--r--comm/calendar/test/browser/invitations/browser.ini31
-rw-r--r--comm/calendar/test/browser/invitations/browser_attachedPublishEvent.js72
-rw-r--r--comm/calendar/test/browser/invitations/browser_icsAttachment.js71
-rw-r--r--comm/calendar/test/browser/invitations/browser_identityPrompt.js144
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBar.js199
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarCancel.js129
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarEmail.js168
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarExceptionCancel.js137
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarExceptionOnly.js262
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarExceptions.js288
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarRepeat.js218
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarRepeatCancel.js186
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarRepeatUpdates.js247
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarUpdates.js223
-rw-r--r--comm/calendar/test/browser/invitations/browser_invitationDisplayNew.js257
-rw-r--r--comm/calendar/test/browser/invitations/browser_unsupportedFreq.js107
-rw-r--r--comm/calendar/test/browser/invitations/data/cancel-repeat-event.eml49
-rw-r--r--comm/calendar/test/browser/invitations/data/cancel-single-event.eml78
-rw-r--r--comm/calendar/test/browser/invitations/data/exception-major.eml49
-rw-r--r--comm/calendar/test/browser/invitations/data/exception-minor.eml49
-rw-r--r--comm/calendar/test/browser/invitations/data/meet-meeting-invite.eml384
-rw-r--r--comm/calendar/test/browser/invitations/data/message-containing-event.eml44
-rw-r--r--comm/calendar/test/browser/invitations/data/message-non-invite.eml115
-rw-r--r--comm/calendar/test/browser/invitations/data/outlook-test-invite.eml102
-rw-r--r--comm/calendar/test/browser/invitations/data/repeat-event.eml49
-rw-r--r--comm/calendar/test/browser/invitations/data/repeat-update-major.eml49
-rw-r--r--comm/calendar/test/browser/invitations/data/repeat-update-minor.eml49
-rw-r--r--comm/calendar/test/browser/invitations/data/single-event.eml78
-rw-r--r--comm/calendar/test/browser/invitations/data/teams-meeting-invite.eml167
-rw-r--r--comm/calendar/test/browser/invitations/data/update-major.eml78
-rw-r--r--comm/calendar/test/browser/invitations/data/update-minor.eml78
-rw-r--r--comm/calendar/test/browser/invitations/head.js942
-rw-r--r--comm/calendar/test/browser/preferences/browser.ini16
-rw-r--r--comm/calendar/test/browser/preferences/browser_alarmDefaultValue.js176
-rw-r--r--comm/calendar/test/browser/preferences/browser_categoryColors.js90
-rw-r--r--comm/calendar/test/browser/preferences/head.js64
-rw-r--r--comm/calendar/test/browser/providers/browser.ini21
-rw-r--r--comm/calendar/test/browser/providers/browser_caldavCalendar_cached.js64
-rw-r--r--comm/calendar/test/browser/providers/browser_caldavCalendar_uncached.js61
-rw-r--r--comm/calendar/test/browser/providers/browser_icsCalendar_cached.js73
-rw-r--r--comm/calendar/test/browser/providers/browser_icsCalendar_uncached.js64
-rw-r--r--comm/calendar/test/browser/providers/browser_storageCalendar.js13
-rw-r--r--comm/calendar/test/browser/providers/head.js402
-rw-r--r--comm/calendar/test/browser/recurrence/browser.ini23
-rw-r--r--comm/calendar/test/browser/recurrence/browser_annual.js69
-rw-r--r--comm/calendar/test/browser/recurrence/browser_biweekly.js85
-rw-r--r--comm/calendar/test/browser/recurrence/browser_daily.js162
-rw-r--r--comm/calendar/test/browser/recurrence/browser_lastDayOfMonth.js112
-rw-r--r--comm/calendar/test/browser/recurrence/browser_recurrenceNavigation.js138
-rw-r--r--comm/calendar/test/browser/recurrence/browser_rotated.ini24
-rw-r--r--comm/calendar/test/browser/recurrence/browser_weeklyN.js268
-rw-r--r--comm/calendar/test/browser/recurrence/browser_weeklyUntil.js175
-rw-r--r--comm/calendar/test/browser/recurrence/browser_weeklyWithException.js264
-rw-r--r--comm/calendar/test/browser/recurrence/head.js26
-rw-r--r--comm/calendar/test/browser/timezones/browser.ini17
-rw-r--r--comm/calendar/test/browser/timezones/browser_minimonth.js215
-rw-r--r--comm/calendar/test/browser/timezones/browser_timezones.js867
-rw-r--r--comm/calendar/test/browser/views/browser.ini32
-rw-r--r--comm/calendar/test/browser/views/browser_dayView.js185
-rw-r--r--comm/calendar/test/browser/views/browser_monthView.js86
-rw-r--r--comm/calendar/test/browser/views/browser_multiweekView.js88
-rw-r--r--comm/calendar/test/browser/views/browser_propertyChanges.js248
-rw-r--r--comm/calendar/test/browser/views/browser_taskView.js148
-rw-r--r--comm/calendar/test/browser/views/browser_viewSwitch.js138
-rw-r--r--comm/calendar/test/browser/views/browser_weekView.js81
-rw-r--r--comm/calendar/test/browser/views/head.js13
-rw-r--r--comm/calendar/test/moz.build30
-rw-r--r--comm/calendar/test/unit/data/bug1790339.sql194
-rw-r--r--comm/calendar/test/unit/data/import.ics24
-rw-r--r--comm/calendar/test/unit/head.js337
-rw-r--r--comm/calendar/test/unit/providers/head.js152
-rw-r--r--comm/calendar/test/unit/providers/test_caldavCalendar_cached.js201
-rw-r--r--comm/calendar/test/unit/providers/test_caldavCalendar_uncached.js96
-rw-r--r--comm/calendar/test/unit/providers/test_icsCalendar_cached.js53
-rw-r--r--comm/calendar/test/unit/providers/test_icsCalendar_uncached.js46
-rw-r--r--comm/calendar/test/unit/providers/test_storageCalendar.js17
-rw-r--r--comm/calendar/test/unit/providers/xpcshell.ini11
-rw-r--r--comm/calendar/test/unit/test_CalendarFileImporter.js46
-rw-r--r--comm/calendar/test/unit/test_alarm.js674
-rw-r--r--comm/calendar/test/unit/test_alarmservice.js606
-rw-r--r--comm/calendar/test/unit/test_alarmutils.js171
-rw-r--r--comm/calendar/test/unit/test_attachment.js112
-rw-r--r--comm/calendar/test/unit/test_attendee.js318
-rw-r--r--comm/calendar/test/unit/test_auth_utils.js100
-rw-r--r--comm/calendar/test/unit/test_bug1199942.js81
-rw-r--r--comm/calendar/test/unit/test_bug1204255.js146
-rw-r--r--comm/calendar/test/unit/test_bug1209399.js117
-rw-r--r--comm/calendar/test/unit/test_bug1790339.js71
-rw-r--r--comm/calendar/test/unit/test_bug272411.js15
-rw-r--r--comm/calendar/test/unit/test_bug343792.js66
-rw-r--r--comm/calendar/test/unit/test_bug350845.js43
-rw-r--r--comm/calendar/test/unit/test_bug356207.js47
-rw-r--r--comm/calendar/test/unit/test_bug485571.js99
-rw-r--r--comm/calendar/test/unit/test_bug486186.js21
-rw-r--r--comm/calendar/test/unit/test_bug494140.js57
-rw-r--r--comm/calendar/test/unit/test_bug523860.js15
-rw-r--r--comm/calendar/test/unit/test_bug653924.js20
-rw-r--r--comm/calendar/test/unit/test_bug668222.js28
-rw-r--r--comm/calendar/test/unit/test_bug759324.js74
-rw-r--r--comm/calendar/test/unit/test_calIteratorUtils.js38
-rw-r--r--comm/calendar/test/unit/test_calStorageHelpers.js23
-rw-r--r--comm/calendar/test/unit/test_caldav_requests.js970
-rw-r--r--comm/calendar/test/unit/test_calmgr.js411
-rw-r--r--comm/calendar/test/unit/test_calreadablestreamfactory.js195
-rw-r--r--comm/calendar/test/unit/test_data_bags.js151
-rw-r--r--comm/calendar/test/unit/test_datetime.js99
-rw-r--r--comm/calendar/test/unit/test_datetime_before_1970.js31
-rw-r--r--comm/calendar/test/unit/test_datetimeformatter.js604
-rw-r--r--comm/calendar/test/unit/test_deleted_items.js106
-rw-r--r--comm/calendar/test/unit/test_duration.js10
-rw-r--r--comm/calendar/test/unit/test_email_utils.js265
-rw-r--r--comm/calendar/test/unit/test_extract.js225
-rw-r--r--comm/calendar/test/unit/test_extract_parser.js160
-rw-r--r--comm/calendar/test/unit/test_extract_parser_parse.js1317
-rw-r--r--comm/calendar/test/unit/test_extract_parser_service.js96
-rw-r--r--comm/calendar/test/unit/test_extract_parser_tokenize.js367
-rw-r--r--comm/calendar/test/unit/test_filter.js406
-rw-r--r--comm/calendar/test/unit/test_filter_mixin.js1083
-rw-r--r--comm/calendar/test/unit/test_filter_tree_view.js451
-rw-r--r--comm/calendar/test/unit/test_freebusy.js88
-rw-r--r--comm/calendar/test/unit/test_freebusy_service.js201
-rw-r--r--comm/calendar/test/unit/test_hashedarray.js210
-rw-r--r--comm/calendar/test/unit/test_ics.js235
-rw-r--r--comm/calendar/test/unit/test_ics_parser.js220
-rw-r--r--comm/calendar/test/unit/test_ics_service.js289
-rw-r--r--comm/calendar/test/unit/test_imip.js47
-rw-r--r--comm/calendar/test/unit/test_invitationutils.js1654
-rw-r--r--comm/calendar/test/unit/test_items.js465
-rw-r--r--comm/calendar/test/unit/test_itip_message_sender.js358
-rw-r--r--comm/calendar/test/unit/test_itip_utils.js831
-rw-r--r--comm/calendar/test/unit/test_l10n_utils.js99
-rw-r--r--comm/calendar/test/unit/test_lenient_parsing.js41
-rw-r--r--comm/calendar/test/unit/test_providers.js426
-rw-r--r--comm/calendar/test/unit/test_recur.js1361
-rw-r--r--comm/calendar/test/unit/test_recurrence_utils.js371
-rw-r--r--comm/calendar/test/unit/test_relation.js133
-rw-r--r--comm/calendar/test/unit/test_rfc3339_parser.js188
-rw-r--r--comm/calendar/test/unit/test_startup_service.js46
-rw-r--r--comm/calendar/test/unit/test_storage.js85
-rw-r--r--comm/calendar/test/unit/test_storage_connection.js127
-rw-r--r--comm/calendar/test/unit/test_storage_get_items.js338
-rw-r--r--comm/calendar/test/unit/test_timezone.js89
-rw-r--r--comm/calendar/test/unit/test_timezone_changes.js93
-rw-r--r--comm/calendar/test/unit/test_timezone_definition.js32
-rw-r--r--comm/calendar/test/unit/test_transaction_manager.js431
-rw-r--r--comm/calendar/test/unit/test_unifinder_utils.js137
-rw-r--r--comm/calendar/test/unit/test_utils.js185
-rw-r--r--comm/calendar/test/unit/test_view_utils.js127
-rw-r--r--comm/calendar/test/unit/test_webcal.js44
-rw-r--r--comm/calendar/test/unit/test_weekinfo_service.js33
-rw-r--r--comm/calendar/test/unit/xpcshell.ini82
202 files changed, 40615 insertions, 0 deletions
diff --git a/comm/calendar/test/.eslintrc.js b/comm/calendar/test/.eslintrc.js
new file mode 100644
index 0000000000..fc89c84bb1
--- /dev/null
+++ b/comm/calendar/test/.eslintrc.js
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Calendar tests run with the pref calendar.timezone.local set to UTC. This
+// works fine on the CI, where the system clock is also UTC, but on developers'
+// machines the time difference causes some problems. If you have to use the
+// Date object, make sure that you use UTC methods.
+
+module.exports = {
+ rules: {
+ "no-restricted-properties": [
+ "error",
+ {
+ property: "getFullYear",
+ message: "These tests run in UTC. Use 'getUTCFullYear' instead.",
+ },
+ {
+ property: "getMonth",
+ message: "These tests run in UTC. Use 'getUTCMonth' instead.",
+ },
+ {
+ property: "getDay",
+ message: "These tests run in UTC. Use 'getUTCDay' instead.",
+ },
+ {
+ property: "getDate",
+ message: "These tests run in UTC. Use 'getUTCDate' instead.",
+ },
+ {
+ property: "getHours",
+ message: "These tests run in UTC. Use 'getUTCHours' instead.",
+ },
+ {
+ property: "getMinutes",
+ message: "These tests run in UTC. Use 'getUTCMinutes' instead.",
+ },
+ {
+ property: "setFullYear",
+ message: "These tests run in UTC. Use 'setUTCFullYear' instead.",
+ },
+ {
+ property: "setMonth",
+ message: "These tests run in UTC. Use 'setUTCMonth' instead.",
+ },
+ {
+ property: "setDay",
+ message: "These tests run in UTC. Use 'setUTCDay' instead.",
+ },
+ {
+ property: "setDate",
+ message: "These tests run in UTC. Use 'setUTCDate' instead.",
+ },
+ {
+ property: "setHours",
+ message: "These tests run in UTC. Use 'setUTCHours' instead.",
+ },
+ {
+ property: "setMinutes",
+ message: "These tests run in UTC. Use 'setUTCMinutes' instead.",
+ },
+ ],
+ "no-restricted-syntax": [
+ "error",
+ {
+ selector: "[callee.name='Date'][arguments.length>=2]",
+ message:
+ "These tests run in UTC. Use 'new Date(Date.UTC(...))' to construct a Date with arguments.",
+ },
+ ],
+ },
+};
diff --git a/comm/calendar/test/CalDAVServer.jsm b/comm/calendar/test/CalDAVServer.jsm
new file mode 100644
index 0000000000..aff2242f43
--- /dev/null
+++ b/comm/calendar/test/CalDAVServer.jsm
@@ -0,0 +1,627 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["CalDAVServer"];
+
+const PREFIX_BINDINGS = {
+ c: "urn:ietf:params:xml:ns:caldav",
+ cs: "http://calendarserver.org/ns/",
+ d: "DAV:",
+ i: "http://apple.com/ns/ical/",
+};
+const NAMESPACE_STRING = Object.entries(PREFIX_BINDINGS)
+ .map(([prefix, url]) => `xmlns:${prefix}="${url}"`)
+ .join(" ");
+
+const { Assert } = ChromeUtils.importESModule("resource://testing-common/Assert.sys.mjs");
+const { CommonUtils } = ChromeUtils.importESModule("resource://services-common/utils.sys.mjs");
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+const logger = console.createInstance({
+ prefix: "CalDAVServer",
+ maxLogLevel: "Log",
+});
+
+// The response bodies Google sends if you exceed its rate limit.
+let MULTIGET_RATELIMIT_ERROR = `<?xml version="1.0" encoding="UTF-8"?>
+<D:error xmlns:D="DAV:"/>
+`;
+let PROPFIND_RATELIMIT_ERROR = `<?xml version="1.0" encoding="UTF-8"?>
+<errors xmlns="http://schemas.google.com/g/2005">
+ <error>
+ <domain>GData</domain>
+ <code>rateLimitExceeded</code>
+ <internalReason>Some text we're not looking at anyway</internalReason>
+ </error>
+</errors>
+`;
+
+var CalDAVServer = {
+ items: new Map(),
+ deletedItems: new Map(),
+ changeCount: 0,
+ server: null,
+ isOpen: false,
+
+ /**
+ * The "current-user-privilege-set" in responses. Set to null to have no privilege set.
+ */
+ privileges: "<d:privilege><d:all/></d:privilege>",
+
+ open(username, password) {
+ this.server = new HttpServer();
+ this.server.start(-1);
+ this.isOpen = true;
+
+ this.username = username;
+ this.password = password;
+ this.server.registerPathHandler("/ping", this.ping);
+
+ this.reset();
+ },
+
+ reset() {
+ this.items.clear();
+ this.deletedItems.clear();
+ this.changeCount = 0;
+ this.privileges = "<d:privilege><d:all/></d:privilege>";
+ this.resetHandlers();
+ },
+
+ resetHandlers() {
+ this.server.registerPathHandler("/.well-known/caldav", this.wellKnown.bind(this));
+ this.server.registerPathHandler("/principals/", this.principals.bind(this));
+ this.server.registerPathHandler("/principals/me/", this.myPrincipal.bind(this));
+ this.server.registerPathHandler("/calendars/me/", this.myCalendars.bind(this));
+
+ this.server.registerPathHandler(this.path, this.directoryHandler.bind(this));
+ this.server.registerPrefixHandler(this.path, this.itemHandler.bind(this));
+ },
+
+ close() {
+ if (!this.isOpen) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve =>
+ this.server.stop({
+ onStopped: () => {
+ this.isOpen = false;
+ resolve();
+ },
+ })
+ );
+ },
+
+ get origin() {
+ return `http://localhost:${this.server.identity.primaryPort}`;
+ },
+
+ get path() {
+ return "/calendars/me/test/";
+ },
+
+ get url() {
+ return `${this.origin}${this.path}`;
+ },
+
+ get altPath() {
+ return "/addressbooks/me/default/";
+ },
+
+ get altURL() {
+ return `${this.origin}${this.altPath}`;
+ },
+
+ checkAuth(request, response) {
+ if (!this.username || !this.password) {
+ return true;
+ }
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ let value = request.getHeader("Authorization");
+ if (!value.startsWith("Basic ")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ let [username, password] = atob(value.substring(6)).split(":");
+ if (username != this.username || password != this.password) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ return true;
+ },
+
+ ping(request, response) {
+ response.setStatusLine("1.1", 200, "OK");
+ response.setHeader("Content-Type", "text/plain");
+ response.write("pong");
+ },
+
+ wellKnown(request, response) {
+ response.setStatusLine("1.1", 301, "Moved Permanently");
+ response.setHeader("Location", "/principals/");
+ },
+
+ principals(request, response) {
+ if (!this.checkAuth(request, response)) {
+ return;
+ }
+
+ let input = new DOMParser().parseFromString(
+ CommonUtils.readBytesFromInputStream(request.bodyInputStream),
+ "text/xml"
+ );
+
+ let propNames = this._inputProps(input);
+ let propValues = {
+ "d:current-user-principal": "<href>/principals/me/</href>",
+ };
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(
+ `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
+ <response>
+ <href>/principals/</href>
+ ${this._outputProps(propNames, propValues)}
+ </response>
+ </multistatus>`.replace(/>\s+</g, "><")
+ );
+ },
+
+ myPrincipal(request, response) {
+ if (!this.checkAuth(request, response)) {
+ return;
+ }
+
+ let input = new DOMParser().parseFromString(
+ CommonUtils.readBytesFromInputStream(request.bodyInputStream),
+ "text/xml"
+ );
+
+ let propNames = this._inputProps(input);
+ let propValues = {
+ "d:resourcetype": "<principal/>",
+ "c:calendar-home-set": "<d:href>/calendars/me/</d:href>",
+ "c:calendar-user-address-set": `<d:href preferred="1">mailto:me@invalid</d:href>`,
+ "c:schedule-inbox-URL": "<d:href>/calendars/me/inbox/</d:href>",
+ "c:schedule-outbox-URL": "<d:href>/calendars/me/inbox/</d:href>",
+ };
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(
+ `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
+ <response>
+ <href>/principals/me/</href>
+ ${this._outputProps(propNames, propValues)}
+ </response>
+ </multistatus>`.replace(/>\s+</g, "><")
+ );
+ },
+
+ myCalendars(request, response) {
+ if (!this.checkAuth(request, response)) {
+ return;
+ }
+
+ if (request.method == "OPTIONS") {
+ response.setStatusLine("1.1", 200, "OK");
+ response.setHeader("DAV", "1,2,3, calendar-access, calendar-schedule");
+ return;
+ }
+
+ let input = new DOMParser().parseFromString(
+ CommonUtils.readBytesFromInputStream(request.bodyInputStream),
+ "text/xml"
+ );
+
+ let propNames = this._inputProps(input);
+ let propValues = {
+ "d:resourcetype": "<collection/><c:calendar/>",
+ "d:displayname": "CalDAV Test",
+ "i:calendar-color": "#ff8000",
+ "d:current-user-privilege-set": this.privileges,
+ };
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(
+ `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
+ <response>
+ <href>/addressbooks/me/</href>
+ ${this._outputProps(propNames, {
+ "d:resourcetype": "<collection/>",
+ "d:displayname": "#calendars",
+ })}
+ </response>
+ <response>
+ <href>${this.path}</href>
+ ${this._outputProps(propNames, propValues)}
+ </response>
+ </multistatus>`.replace(/>\s+</g, "><")
+ );
+ },
+
+ /** Handle any requests to the calendar itself. */
+
+ directoryHandler(request, response) {
+ if (!this.checkAuth(request, response)) {
+ return;
+ }
+
+ if (request.method == "OPTIONS") {
+ response.setStatusLine("1.1", 204, "No Content");
+ return;
+ }
+
+ let input = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ logger.log("C: " + input);
+ input = new DOMParser().parseFromString(input, "text/xml");
+
+ switch (input.documentElement.localName) {
+ case "calendar-query":
+ Assert.equal(request.method, "REPORT");
+ Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.c);
+ this.calendarQuery(input, response);
+ return;
+ case "calendar-multiget":
+ Assert.equal(request.method, "REPORT");
+ Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.c);
+ this.calendarMultiGet(input, response);
+ return;
+ case "propfind":
+ Assert.equal(request.method, "PROPFIND");
+ Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.d);
+ this.propFind(input, request.hasHeader("Depth") ? request.getHeader("Depth") : 0, response);
+ return;
+ case "sync-collection":
+ Assert.equal(request.method, "REPORT");
+ Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.d);
+ this.syncCollection(input, response);
+ return;
+ }
+
+ Assert.report(true, undefined, undefined, "Should not have reached here");
+ response.setStatusLine("1.1", 404, "Not Found");
+ response.setHeader("Content-Type", "text/plain");
+ response.write(`No handler found for <${input.documentElement.localName}>`);
+ },
+
+ calendarQuery(input, response) {
+ let propNames = this._inputProps(input);
+ let output = `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>`;
+ for (let [href, item] of this.items) {
+ output += this._itemResponse(href, item, propNames);
+ }
+ output += `</multistatus>`;
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(output.replace(/>\s+</g, "><"));
+ logger.log("S: " + output.replace(/>\s+</g, "><"));
+ },
+
+ async calendarMultiGet(input, response) {
+ let propNames = this._inputProps(input);
+ let output = `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>`;
+ for (let href of input.querySelectorAll("href")) {
+ href = href.textContent;
+ let item = this.items.get(href);
+ if (item) {
+ output += this._itemResponse(href, item, propNames);
+ }
+ }
+ output += `</multistatus>`;
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(output.replace(/>\s+</g, "><"));
+ logger.log("S: " + output.replace(/>\s+</g, "><"));
+ },
+
+ propFind(input, depth, response) {
+ if (this.throwRateLimitErrors) {
+ response.setStatusLine("1.1", 403, "Forbidden");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(PROPFIND_RATELIMIT_ERROR);
+ logger.log("S: " + PROPFIND_RATELIMIT_ERROR);
+ return;
+ }
+
+ let propNames = this._inputProps(input);
+ let propValues = {
+ "d:resourcetype": "<d:collection/><c:calendar/>",
+ "d:owner": "/principals/me/",
+ "d:current-user-principal": "<href>/principals/me/</href>",
+ "d:current-user-privilege-set": this.privileges,
+ "d:supported-report-set":
+ "<d:supported-report><d:report><c:calendar-multiget/></d:report></d:supported-report>" +
+ "<d:supported-report><d:report><sync-collection/></d:report></d:supported-report>",
+ "c:supported-calendar-component-set": "",
+ "d:getcontenttype": "text/calendar; charset=utf-8",
+ "c:calendar-home-set": `<d:href>/calendars/me/</d:href>`,
+ "c:calendar-user-address-set": `<d:href preferred="1">mailto:me@invalid</d:href>`,
+ "c:schedule-inbox-url": `<d:href>/calendars/me/inbox/</d:href>`,
+ "c:schedule-outbox-url": `<d:href>/calendars/me/outbox/</d:href>`,
+ "cs:getctag": this.changeCount,
+ "d:getetag": this.changeCount,
+ };
+
+ let output = `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
+ <response>
+ <href>${this.path}</href>
+ ${this._outputProps(propNames, propValues)}
+ </response>`;
+ if (depth == 1) {
+ for (let [href, item] of this.items) {
+ output += this._itemResponse(href, item, propNames);
+ }
+ }
+ output += `</multistatus>`;
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(output.replace(/>\s+</g, "><"));
+ logger.log("S: " + output.replace(/>\s+</g, "><"));
+ },
+
+ syncCollection(input, response) {
+ if (this.throwRateLimitErrors) {
+ response.setStatusLine("1.1", 403, "Forbidden");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(MULTIGET_RATELIMIT_ERROR);
+ logger.log("S: " + MULTIGET_RATELIMIT_ERROR);
+ return;
+ }
+
+ // The maximum number of responses to make at any one request.
+ let pageSize = 3;
+ // The last-seen token. Changes before this won't be returned.
+ let token = 0;
+ // Which page of responses to return.
+ let page = 0;
+
+ let tokenStr = input.querySelector("sync-token")?.textContent.replace(/.*\//g, "");
+ if (tokenStr?.includes("#")) {
+ [token, page] = tokenStr.split("#");
+ token = parseInt(token, 10);
+ page = parseInt(page, 10);
+ } else if (tokenStr) {
+ token = parseInt(tokenStr, 10);
+ }
+
+ let nextPage = page + 1;
+
+ // Collect all responses, even if we know some won't be returned.
+ // This is a test, who cares about performance?
+ let propNames = this._inputProps(input);
+ let responses = [];
+ for (let [href, item] of this.items) {
+ if (item.changed > token) {
+ responses.push(this._itemResponse(href, item, propNames));
+ }
+ }
+ for (let [href, deleted] of this.deletedItems) {
+ if (deleted > token) {
+ responses.push(`<response>
+ <status>HTTP/1.1 404 Not Found</status>
+ <href>${href}</href>
+ <propstat>
+ <prop/>
+ <status>HTTP/1.1 418 I'm a teapot</status>
+ </propstat>
+ </response>`);
+ }
+ }
+
+ let output = `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>`;
+ // Use only the responses that match those requested.
+ output += responses.slice(page * pageSize, nextPage * pageSize).join("");
+ if (responses.length > nextPage * pageSize) {
+ output += `<response>
+ <status>HTTP/1.1 507 Insufficient Storage</status>
+ <href>${this.path}</href>
+ </response>`;
+ output += `<sync-token>http://mochi.test/sync/${token}#${nextPage}</sync-token>`;
+ } else {
+ output += `<sync-token>http://mochi.test/sync/${this.changeCount}</sync-token>`;
+ }
+ output += `</multistatus>`;
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(output.replace(/>\s+</g, "><"));
+ logger.log("S: " + output.replace(/>\s+</g, "><"));
+ },
+
+ _itemResponse(href, item, propNames) {
+ let propValues = {
+ "c:calendar-data": item.ics,
+ "d:getetag": item.etag,
+ "d:getcontenttype": "text/calendar; charset=utf-8; component=VEVENT",
+ };
+
+ let outString = `<response>
+ <href>${href}</href>
+ ${this._outputProps(propNames, propValues)}
+ </response>`;
+ return outString;
+ },
+
+ _inputProps(input) {
+ let props = input.querySelectorAll("prop > *");
+ let propNames = [];
+
+ for (let p of props) {
+ Assert.equal(p.childElementCount, 0);
+ switch (p.localName) {
+ case "calendar-home-set":
+ case "calendar-user-address-set":
+ case "schedule-inbox-URL":
+ case "schedule-outbox-URL":
+ case "supported-calendar-component-set":
+ case "calendar-data":
+ Assert.equal(p.namespaceURI, PREFIX_BINDINGS.c);
+ propNames.push(`c:${p.localName}`);
+ break;
+ case "getctag":
+ Assert.equal(p.namespaceURI, PREFIX_BINDINGS.cs);
+ propNames.push(`cs:${p.localName}`);
+ break;
+ case "getetag":
+ case "owner":
+ case "current-user-principal":
+ case "current-user-privilege-set":
+ case "supported-report-set":
+ case "displayname":
+ case "resourcetype":
+ case "sync-token":
+ case "getcontenttype":
+ Assert.equal(p.namespaceURI, PREFIX_BINDINGS.d);
+ propNames.push(`d:${p.localName}`);
+ break;
+ case "calendar-color":
+ Assert.equal(p.namespaceURI, PREFIX_BINDINGS.i);
+ propNames.push(`i:${p.localName}`);
+ break;
+ default:
+ Assert.report(true, undefined, undefined, `Unknown property requested: ${p.nodeName}`);
+ break;
+ }
+ }
+
+ return propNames;
+ },
+
+ _outputProps(propNames, propValues) {
+ let output = "";
+
+ let found = [];
+ let notFound = [];
+ for (let p of propNames) {
+ if (p in propValues && propValues[p] != null) {
+ found.push(`<${p}>${propValues[p]}</${p}>`);
+ } else {
+ notFound.push(`<${p}/>`);
+ }
+ }
+
+ if (found.length > 0) {
+ output += `<propstat>
+ <prop>
+ ${found.join("\n")}
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>`;
+ }
+ if (notFound.length > 0) {
+ output += `<propstat>
+ <prop>
+ ${notFound.join("\n")}
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>`;
+ }
+
+ return output;
+ },
+
+ /** Handle any requests to calendar items. */
+
+ itemHandler(request, response) {
+ if (!this.checkAuth(request, response)) {
+ return;
+ }
+
+ if (!/\/[\w-]+\.ics$/.test(request.path)) {
+ response.setStatusLine("1.1", 404, "Not Found");
+ response.setHeader("Content-Type", "text/plain");
+ response.write(`Item not found at ${request.path}`);
+ return;
+ }
+
+ switch (request.method) {
+ case "GET":
+ this.getItem(request, response);
+ return;
+ case "PUT":
+ this.putItem(request, response);
+ return;
+ case "DELETE":
+ this.deleteItem(request, response);
+ return;
+ }
+
+ Assert.report(true, undefined, undefined, "Should not have reached here");
+ response.setStatusLine("1.1", 405, "Method Not Allowed");
+ response.setHeader("Content-Type", "text/plain");
+ response.write(`Method not allowed: ${request.method}`);
+ },
+
+ async getItem(request, response) {
+ let item = this.items.get(request.path);
+ if (!item) {
+ response.setStatusLine("1.1", 404, "Not Found");
+ response.setHeader("Content-Type", "text/plain");
+ response.write(`Item not found at ${request.path}`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 200, "OK");
+ response.setHeader("Content-Type", "text/calendar");
+ response.setHeader("ETag", item.etag);
+ response.write(item.ics);
+ },
+
+ async putItem(request, response) {
+ if (request.hasHeader("If-Match")) {
+ let item = this.items.get(request.path);
+ if (!item || item.etag != request.getHeader("If-Match")) {
+ response.setStatusLine("1.1", 412, "Precondition Failed");
+ return;
+ }
+ }
+
+ response.processAsync();
+
+ let ics = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ await this.putItemInternal(request.path, ics);
+ response.setStatusLine("1.1", 204, "No Content");
+
+ response.finish();
+ },
+
+ async putItemInternal(name, ics) {
+ if (!name.startsWith("/")) {
+ name = this.path + name;
+ }
+
+ let hash = await crypto.subtle.digest("sha-1", new TextEncoder().encode(ics));
+ let etag = Array.from(new Uint8Array(hash), c => c.toString(16).padStart(2, "0")).join("");
+ this.items.set(name, { etag, ics, changed: ++this.changeCount });
+ this.deletedItems.delete(name);
+ },
+
+ deleteItem(request, response) {
+ this.deleteItemInternal(request.path);
+ response.setStatusLine("1.1", 204, "No Content");
+ },
+
+ deleteItemInternal(name) {
+ if (!name.startsWith("/")) {
+ name = this.path + name;
+ }
+ this.items.delete(name);
+ this.deletedItems.set(name, ++this.changeCount);
+ },
+};
diff --git a/comm/calendar/test/CalendarTestUtils.jsm b/comm/calendar/test/CalendarTestUtils.jsm
new file mode 100644
index 0000000000..42e587f540
--- /dev/null
+++ b/comm/calendar/test/CalendarTestUtils.jsm
@@ -0,0 +1,1203 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["CalendarTestUtils"];
+
+const EventUtils = ChromeUtils.import("resource://testing-common/mozmill/EventUtils.jsm");
+const { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs");
+const { Assert } = ChromeUtils.importESModule("resource://testing-common/Assert.sys.mjs");
+const { cancelItemDialog, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+async function clickAndWait(win, button) {
+ EventUtils.synthesizeMouseAtCenter(button, { clickCount: 1 }, win);
+ await new Promise(resolve => win.setTimeout(resolve));
+}
+
+/**
+ * @typedef EditItemAtResult
+ * @property {Window} dialogWindow - The window of the dialog.
+ * @property {Document} dialogDocument - The document of the dialog window.
+ * @property {Window} iframeWindow - The contentWindow property of the embedded
+ * iframe.
+ * @property {Document} iframeDocument - The contentDocument of the embedded
+ * iframe.
+ */
+
+/**
+ * Helper class for testing the day view of the calendar.
+ */
+class CalendarDayViewTestUtils {
+ #helper = new CalendarWeekViewTestUtils("#day-view");
+
+ /**
+ * Provides the column container element for the displayed day.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ *
+ * @returns {HTMLElement} - The column container element.
+ */
+ getColumnContainer(win) {
+ return this.#helper.getColumnContainer(win, 1);
+ }
+
+ /**
+ * Provides the element containing the formatted date for the displayed day.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ *
+ * @returns {HTMLElement} - The column heading container.
+ */
+ getColumnHeading(win) {
+ return this.#helper.getColumnHeading(win, 1);
+ }
+
+ /**
+ * Provides the calendar-event-column for the day displayed.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ *
+ * @returns {MozCalendarEventColumn} - The column.
+ */
+ getEventColumn(win) {
+ return this.#helper.getEventColumn(win, 1);
+ }
+
+ /**
+ * Provides the calendar-event-box elements for the day.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ *
+ * @returns {MozCalendarEventBox[]} - The event boxes.
+ */
+ getEventBoxes(win) {
+ return this.#helper.getEventBoxes(win, 1);
+ }
+
+ /**
+ * Provides the calendar-event-box at "index" located in the event column for
+ * the day displayed.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates which event box to select.
+ *
+ * @returns {MozCalendarEventBox|undefined} - The event box, if it exists.
+ */
+ getEventBoxAt(win, index) {
+ return this.#helper.getEventBoxAt(win, 1, index);
+ }
+
+ /**
+ * Provides the .multiday-hour-box element for the specified hour. This
+ * element can be double clicked to create a new event at that hour.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} hour - Must be between 0-23.
+ *
+ * @returns {XULElement} - The hour box.
+ */
+ getHourBoxAt(win, hour) {
+ return this.#helper.getHourBoxAt(win, 1, hour);
+ }
+
+ /**
+ * Provides the all-day header, which can be double clicked to create a new
+ * all-day event.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ *
+ * @returns {CalendarHeaderContainer} - The all-day header.
+ */
+ getAllDayHeader(win) {
+ return this.#helper.getAllDayHeader(win, 1);
+ }
+
+ /**
+ * Provides the all-day calendar-editable-item located at index for the
+ * current day.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates which item to select (1-based).
+ *
+ * @returns {MozCalendarEditableItem|undefined} - The all-day item, if it
+ * exists.
+ */
+ getAllDayItemAt(win, index) {
+ return this.#helper.getAllDayItemAt(win, 1, index);
+ }
+
+ /**
+ * Waits for the calendar-event-box at "index", located in the event
+ * column for the day displayed to appear.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates which item to select (1-based).
+ *
+ * @returns {Promise<MozCalendarEventBox>} - The event box.
+ */
+ async waitForEventBoxAt(win, index) {
+ return this.#helper.waitForEventBoxAt(win, 1, index);
+ }
+
+ /**
+ * Waits for the calendar-event-box at "index", located in the event column
+ * for the current day to disappear.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates the event box (1-based).
+ */
+ async waitForNoEventBoxAt(win, index) {
+ return this.#helper.waitForNoEventBoxAt(win, 1, index);
+ }
+
+ /**
+ * Wait for the all-day calendar-editable-item for the day.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates which item to select (1-based).
+ *
+ * @returns {Promise<MozCalendarEditableItem>} - The all-day item.
+ */
+ async waitForAllDayItemAt(win, index) {
+ return this.#helper.waitForAllDayItemAt(win, 1, index);
+ }
+
+ /**
+ * Opens the event dialog for viewing for the event box located at the
+ * specified index.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates which event to select.
+ *
+ * @returns {Promise<Window>} - The summary event dialog window.
+ */
+ async viewEventAt(win, index) {
+ return this.#helper.viewEventAt(win, 1, index);
+ }
+
+ /**
+ * Opens the event dialog for editing for the event box located at the
+ * specified index.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates which event to select.
+ *
+ * @returns {Promise<EditItemAtResult>}
+ */
+ async editEventAt(win, index) {
+ return this.#helper.editEventAt(win, 1, index);
+ }
+
+ /**
+ * Opens the event dialog for editing for a single occurrence of the event
+ * box located at the specified index.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates which event box to select.
+ *
+ * @returns {Promise<EditItemAtResult>}
+ */
+ async editEventOccurrenceAt(win, index) {
+ return this.#helper.editEventOccurrenceAt(win, 1, index);
+ }
+
+ /**
+ * Opens the event dialog for editing all occurrences of the event box
+ * located at the specified index.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates which event box to select.
+ *
+ * @returns {Promise<EditItemAtResult>}
+ */
+ async editEventOccurrencesAt(win, index) {
+ return this.#helper.editEventOccurrencesAt(win, 1, index);
+ }
+}
+
+/**
+ * Helper class for testing the week view of the calendar.
+ */
+class CalendarWeekViewTestUtils {
+ constructor(rootSelector = "#week-view") {
+ this.rootSelector = rootSelector;
+ }
+
+ /**
+ * Provides the column container element for the day specified.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7.
+ *
+ * @throws If the day parameter is out of range.
+ * @returns {HTMLElement} - The column container element.
+ */
+ getColumnContainer(win, day) {
+ if (day < 1 || day > 7) {
+ throw new Error(
+ `Invalid parameter to #getColumnContainer(): expected day=1-7, got day=${day}.`
+ );
+ }
+
+ let containers = win.document.documentElement.querySelectorAll(
+ `${this.rootSelector} .day-column-container`
+ );
+ return containers[day - 1];
+ }
+
+ /**
+ * Provides the element containing the formatted date for the day specified.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7.
+ *
+ * @throws If the day parameter is out of range.
+ * @returns {HTMLElement} - The column heading container element.
+ */
+ getColumnHeading(win, day) {
+ let container = this.getColumnContainer(win, day);
+ return container.querySelector(".day-column-heading");
+ }
+
+ /**
+ * Provides the calendar-event-column for the day specified.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7
+ *
+ * @throws - If the day parameter is out of range.
+ * @returns {MozCalendarEventColumn} - The column.
+ */
+ getEventColumn(win, day) {
+ let container = this.getColumnContainer(win, day);
+ return container.querySelector("calendar-event-column");
+ }
+
+ /**
+ * Provides the calendar-event-box elements for the day specified.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7.
+ *
+ * @returns {MozCalendarEventBox[]} - The event boxes.
+ */
+ getEventBoxes(win, day) {
+ let container = this.getColumnContainer(win, day);
+ return container.querySelectorAll(".multiday-events-list calendar-event-box");
+ }
+
+ /**
+ * Provides the calendar-event-box at "index" located in the event column for
+ * the specified day.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which event box to select.
+ *
+ * @returns {MozCalendarEventBox|undefined} - The event box, if it exists.
+ */
+ getEventBoxAt(win, day, index) {
+ return this.getEventBoxes(win, day)[index - 1];
+ }
+
+ /**
+ * Provides the .multiday-hour-box element for the specified hour. This
+ * element can be double clicked to create a new event at that hour.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Day of the week, between 1-7.
+ * @param {number} hour - Must be between 0-23.
+ *
+ * @throws If the day or hour are out of range.
+ * @returns {XULElement} - The hour box.
+ */
+ getHourBoxAt(win, day, hour) {
+ let container = this.getColumnContainer(win, day);
+ return container.querySelectorAll(".multiday-hour-box")[hour];
+ }
+
+ /**
+ * Provides the all-day header, which can be double clicked to create a new
+ * all-day event for the specified day.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Day of the week, between 1-7.
+ *
+ * @throws If the day is out of range.
+ * @returns {CalendarHeaderContainer} - The all-day header.
+ */
+ getAllDayHeader(win, day) {
+ let container = this.getColumnContainer(win, day);
+ return container.querySelector("calendar-header-container");
+ }
+
+ /**
+ * Provides the all-day calendar-editable-item located at "index" for the
+ * specified day.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Day of the week, between 1-7.
+ * @param {number} index - Indicates which item to select (starting from 1).
+ *
+ * @throws If the day or index are out of range.
+ * @returns {MozCalendarEditableItem|undefined} - The all-day item, if it
+ * exists.
+ */
+ getAllDayItemAt(win, day, index) {
+ let allDayHeader = this.getAllDayHeader(win, day);
+ return allDayHeader.querySelectorAll("calendar-editable-item")[index - 1];
+ }
+
+ /**
+ * Waits for the calendar-event-box at "index", located in the event column
+ * for the day specified to appear.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Day of the week, between 1-7.
+ * @param {number} index - Indicates which event box to select.
+ *
+ * @returns {MozCalendarEventBox} - The event box.
+ */
+ async waitForEventBoxAt(win, day, index) {
+ return TestUtils.waitForCondition(
+ () => this.getEventBoxAt(win, day, index),
+ `calendar-event-box at day=${day}, index=${index} did not appear in time`
+ );
+ }
+
+ /**
+ * Waits until the calendar-event-box at "index", located in the event column
+ * for the day specified disappears.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Day of the week, between 1-7.
+ * @param {number} index - Indicates which event box to select.
+ */
+ async waitForNoEventBoxAt(win, day, index) {
+ await TestUtils.waitForCondition(
+ () => !this.getEventBoxAt(win, day, index),
+ `calendar-event-box at day=${day}, index=${index} still present`
+ );
+ }
+
+ /**
+ * Waits for the all-day calendar-editable-item at "index", located in the
+ * event column for the day specified to appear.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Day of the week, between 1-7.
+ * @param {number} index - Indicates which item to select (starting from 1).
+ *
+ * @returns {Promise<MozCalendarEditableItem>} - The all-day item.
+ */
+ async waitForAllDayItemAt(win, day, index) {
+ return TestUtils.waitForCondition(
+ () => this.getAllDayItemAt(win, day, index),
+ `All-day calendar-editable-item at day=${day}, index=${index} did not appear in time`
+ );
+ }
+
+ /**
+ * Opens the event dialog for viewing for the event box located at the
+ * specified parameters.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which event to select.
+ *
+ * @returns {Promise<Window>} - The summary event dialog window.
+ */
+ async viewEventAt(win, day, index) {
+ let item = await this.waitForEventBoxAt(win, day, index);
+ return CalendarTestUtils.viewItem(win, item);
+ }
+
+ /**
+ * Opens the event dialog for editing for the event box located at the
+ * specified parameters.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which event to select.
+ *
+ * @returns {Promise<EditItemAtResult>}
+ */
+ async editEventAt(win, day, index) {
+ let item = await this.waitForEventBoxAt(win, day, index);
+ return CalendarTestUtils.editItem(win, item);
+ }
+
+ /**
+ * Opens the event dialog for editing for a single occurrence of the event
+ * box located at the specified parameters.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which event box to select.
+ *
+ * @returns {Promise<EditItemAtResult>}
+ */
+ async editEventOccurrenceAt(win, day, index) {
+ let item = await this.waitForEventBoxAt(win, day, index);
+ return CalendarTestUtils.editItemOccurrence(win, item);
+ }
+
+ /**
+ * Opens the event dialog for editing all occurrences of the event box
+ * located at the specified parameters.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which event box to select.
+ *
+ * @returns {Promise<EditItemAtResult>}
+ */
+ async editEventOccurrencesAt(win, day, index) {
+ let item = await this.waitForEventBoxAt(win, day, index);
+ return CalendarTestUtils.editItemOccurrences(win, item);
+ }
+}
+
+/**
+ * Helper class for testing the multiweek and month views of the calendar.
+ */
+class CalendarMonthViewTestUtils {
+ /**
+ * @param {string} rootSelector
+ */
+ constructor(rootSelector) {
+ this.rootSelector = rootSelector;
+ }
+
+ /**
+ * Provides the calendar-month-day-box element located at the specified day,
+ * week combination.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} week - Must be between 1-6. The cap may be as low as 1
+ * depending on the user preference calendar.weeks.inview.
+ * @param {number} day - Must be between 1-7.
+ *
+ * @throws If the day or week parameters are out of range.
+ * @returns {MozCalendarMonthDayBox}
+ */
+ getDayBox(win, week, day) {
+ if (!(week >= 1 && week <= 6 && day >= 1 && day <= 7)) {
+ throw new Error(
+ `Invalid parameters to getDayBox(): ` +
+ `expected week=1-6, day=1-7, got week=${week}, day=${day},`
+ );
+ }
+
+ return win.document.documentElement.querySelector(
+ `${this.rootSelector} .monthbody > tr:nth-of-type(${week}) >
+ td:nth-of-type(${day}) > calendar-month-day-box`
+ );
+ }
+
+ /**
+ * Get the calendar-month-day-box-item located in the specified day box, at
+ * the target index.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} week - Must be between 1-6.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which item to select.
+ *
+ * @throws If the index, day or week parameters are out of range.
+ * @returns {MozCalendarMonthDayBoxItem}
+ */
+ getItemAt(win, week, day, index) {
+ if (!(index >= 1)) {
+ throw new Error(`Invalid parameters to getItemAt(): expected index>=1, got index=${index}.`);
+ }
+
+ let dayBox = this.getDayBox(win, week, day);
+ return dayBox.querySelector(`li:nth-of-type(${index}) calendar-month-day-box-item`);
+ }
+
+ /**
+ * Waits for the calendar-month-day-box-item at "index", located in the
+ * specified week,day combination to appear.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} week - Must be between 1-6.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which item to select.
+ *
+ * @returns {MozCalendarMonthDayBoxItem}
+ */
+ async waitForItemAt(win, week, day, index) {
+ return TestUtils.waitForCondition(
+ () => this.getItemAt(win, week, day, index),
+ `calendar-month-day-box-item at week=${week}, day=${day}, index=${index} did not appear in time`
+ );
+ }
+
+ /**
+ * Waits for the calendar-month-day-box-item at "index", located in the
+ * specified week,day combination to disappear.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} week - Must be between 1-6.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates the item that should no longer be present.
+ */
+ async waitForNoItemAt(win, week, day, index) {
+ await TestUtils.waitForCondition(
+ () => !this.getItemAt(win, week, day, index),
+ `calendar-month-day-box-item at week=${week}, day=${day}, index=${index} still present`
+ );
+ }
+
+ /**
+ * Opens the event dialog for viewing for the item located at the specified
+ * parameters.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} week - Must be between 1-6.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which item to select.
+ *
+ * @returns {Window} - The summary event dialog window.
+ */
+ async viewItemAt(win, week, day, index) {
+ let item = await this.waitForItemAt(win, week, day, index);
+ return CalendarTestUtils.viewItem(win, item);
+ }
+
+ /**
+ * Opens the event dialog for editing for the item located at the specified
+ * parameters.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} week - Must be between 1-6.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which item to select.
+ *
+ * @returns {EditItemAtResult}
+ */
+ async editItemAt(win, week, day, index) {
+ let item = await this.waitForItemAt(win, week, day, index);
+ return CalendarTestUtils.editItem(win, item);
+ }
+
+ /**
+ * Opens the event dialog for editing for a single occurrence of the item
+ * located at the specified parameters.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} week - Must be between 1-6.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which item to select.
+ *
+ * @returns {EditItemAtResult}
+ */
+ async editItemOccurrenceAt(win, week, day, index) {
+ let item = await this.waitForItemAt(win, week, day, index);
+ return CalendarTestUtils.editItemOccurrence(win, item);
+ }
+
+ /**
+ * Opens the event dialog for editing all occurrences of the item
+ * located at the specified parameters.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} week - Must be between 1-6.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which item to select.
+ *
+ * @returns {EditItemAtResult}
+ */
+ async editItemOccurrencesAt(win, week, day, index) {
+ let item = await this.waitForItemAt(win, week, day, index);
+ return CalendarTestUtils.editItemOccurrences(win, item);
+ }
+}
+
+/**
+ * Non-mozmill calendar helper utility.
+ */
+const CalendarTestUtils = {
+ /**
+ * Helper methods for item editing.
+ */
+ items: {
+ cancelItemDialog,
+ saveAndCloseItemDialog,
+ setData,
+ },
+
+ /**
+ * Helpers specific to the day view.
+ */
+ dayView: new CalendarDayViewTestUtils(),
+
+ /**
+ * Helpers specific to the week view.
+ */
+ weekView: new CalendarWeekViewTestUtils(),
+
+ /**
+ * Helpers specific to the multiweek view.
+ */
+ multiweekView: new CalendarMonthViewTestUtils("#multiweek-view"),
+
+ /**
+ * Helpers specific to the month view.
+ */
+ monthView: new CalendarMonthViewTestUtils("#month-view"),
+
+ /**
+ * Dedent the template string tagged with this function to make indented data
+ * easier to read. Usage:
+ *
+ * let data = dedent`
+ * This is indented data it will be unindented so that the first line has
+ * no leading spaces and the second is indented by two spaces.
+ * `;
+ *
+ * @param strings The string fragments from the template string
+ * @param ...values The interpolated values
+ * @returns The interpolated, dedented string
+ */
+ dedent(strings, ...values) {
+ let parts = [];
+ // Perform variable interpolation
+ let minIndent = Infinity;
+ for (let [i, string] of strings.entries()) {
+ let innerparts = string.split("\n");
+ if (i == 0) {
+ innerparts.shift();
+ }
+ if (i == strings.length - 1) {
+ innerparts.pop();
+ }
+ for (let [j, ip] of innerparts.entries()) {
+ let match = ip.match(/^(\s*)\S*/);
+ if (j != 0) {
+ minIndent = Math.min(minIndent, match[1].length);
+ }
+ }
+ parts.push(innerparts);
+ }
+
+ return parts
+ .map((part, i) => {
+ return (
+ part
+ .map((line, j) => {
+ return j == 0 && i > 0 ? line : line.substr(minIndent);
+ })
+ .join("\n") + (i < values.length ? values[i] : "")
+ );
+ })
+ .join("");
+ },
+
+ /**
+ * Creates and registers a new calendar with the calendar manager. The
+ * created calendar will be set as the default calendar.
+ *
+ * @param {string} - name
+ * @param {string} - type
+ *
+ * @returns {calICalendar}
+ */
+ createCalendar(name = "Test", type = "storage") {
+ let calendar = cal.manager.createCalendar(type, Services.io.newURI(`moz-${type}-calendar://`));
+ calendar.name = name;
+ calendar.setProperty("calendar-main-default", true);
+ cal.manager.registerCalendar(calendar);
+ return calendar;
+ },
+
+ /**
+ * Convenience method for removing a calendar using its proxy.
+ *
+ * @param {calICalendar} calendar - A calendar to remove.
+ */
+ removeCalendar(calendar) {
+ cal.manager.unregisterCalendar(calendar);
+ },
+
+ /**
+ * Ensures the calendar tab is open
+ *
+ * @param {Window} win
+ */
+ async openCalendarTab(win) {
+ let tabmail = win.document.getElementById("tabmail");
+ let calendarMode = tabmail.tabModes.calendar;
+
+ if (calendarMode.tabs.length == 1) {
+ tabmail.selectedTab = calendarMode.tabs[0];
+ } else {
+ let calendarTabButton = win.document.getElementById("calendarButton");
+ EventUtils.synthesizeMouseAtCenter(calendarTabButton, { clickCount: 1 }, win);
+ }
+
+ Assert.equal(calendarMode.tabs.length, 1, "calendar tab is open");
+ Assert.equal(tabmail.selectedTab, calendarMode.tabs[0], "calendar tab is selected");
+
+ await new Promise(resolve => win.setTimeout(resolve));
+ },
+
+ /**
+ * Make sure the current view has finished loading.
+ *
+ * @param {Window} win
+ */
+ async ensureViewLoaded(win) {
+ await win.currentView().ready;
+ },
+
+ /**
+ * Ensures the calendar view is in the specified mode.
+ *
+ * @param {Window} win
+ * @param {string} viewName
+ */
+ async setCalendarView(win, viewName) {
+ await CalendarTestUtils.openCalendarTab(win);
+ await CalendarTestUtils.ensureViewLoaded(win);
+
+ let viewTabButton = win.document.querySelector(
+ `.calview-toggle-item[aria-controls="${viewName}-view"]`
+ );
+ EventUtils.synthesizeMouseAtCenter(viewTabButton, { clickCount: 1 }, win);
+ Assert.equal(win.currentView().id, `${viewName}-view`);
+
+ await CalendarTestUtils.ensureViewLoaded(win);
+ },
+
+ /**
+ * Step forward in the calendar view.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} n - Number of times to move the view forward.
+ */
+ async calendarViewForward(win, n) {
+ let viewForwardButton = win.document.getElementById("nextViewButton");
+ for (let i = 0; i < n; i++) {
+ await clickAndWait(win, viewForwardButton);
+ await CalendarTestUtils.ensureViewLoaded(win);
+ }
+ },
+
+ /**
+ * Step backward in the calendar view.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} n - Number of times to move the view backward.
+ */
+ async calendarViewBackward(win, n) {
+ let viewBackwardButton = win.document.getElementById("previousViewButton");
+ for (let i = 0; i < n; i++) {
+ await clickAndWait(win, viewBackwardButton);
+ await CalendarTestUtils.ensureViewLoaded(win);
+ }
+ },
+
+ /**
+ * Ensures the calendar tab is not open.
+ *
+ * @param {Window} win
+ */
+ async closeCalendarTab(win) {
+ let tabmail = win.document.getElementById("tabmail");
+ let calendarMode = tabmail.tabModes.calendar;
+
+ if (calendarMode.tabs.length == 1) {
+ tabmail.closeTab(calendarMode.tabs[0]);
+ }
+
+ Assert.equal(calendarMode.tabs.length, 0, "calendar tab is not open");
+
+ await new Promise(resolve => win.setTimeout(resolve));
+ },
+
+ /**
+ * Opens the event dialog for viewing by clicking on the provided event item.
+ *
+ * @param {Window} win - The window containing the calendar.
+ * @param {MozCalendarEditableItem} item - An event box item that can be
+ * clicked on to open the dialog.
+ *
+ * @returns {Promise<Window>}
+ */
+ async viewItem(win, item) {
+ if (Services.focus.activeWindow != win) {
+ await BrowserTestUtils.waitForEvent(win, "focus");
+ }
+
+ let promise = this.waitForEventDialog("view");
+ EventUtils.synthesizeMouseAtCenter(item, { clickCount: 2 }, win);
+ return promise;
+ },
+
+ async _editNewItem(win, target, type) {
+ let dialogPromise = CalendarTestUtils.waitForEventDialog("edit");
+
+ if (target) {
+ this.scrollViewToTarget(target, true);
+ EventUtils.synthesizeMouse(target, 1, 1, { clickCount: 2 }, win);
+ } else {
+ let buttonId = `sidePanelNew${type[0].toUpperCase()}${type.slice(1).toLowerCase()}`;
+ EventUtils.synthesizeMouseAtCenter(win.document.getElementById(buttonId), {}, win);
+ }
+
+ let dialogWindow = await dialogPromise;
+ let iframe = dialogWindow.document.querySelector("#calendar-item-panel-iframe");
+ await new Promise(resolve => iframe.contentWindow.setTimeout(resolve));
+ Assert.report(false, undefined, undefined, `New ${type} dialog opened`);
+ return {
+ dialogWindow,
+ dialogDocument: dialogWindow.document,
+ iframeWindow: iframe.contentWindow,
+ iframeDocument: iframe.contentDocument,
+ };
+ },
+
+ /**
+ * Opens the dialog for editing a new event. An optional day/week view
+ * hour box or multiweek/month view calendar-month-day-box can be specified
+ * to simulate creation of the event at that target.
+ *
+ * @param {Window} win - The window containing the calendar.
+ * @param {XULElement?} target - The <spacer> or <calendar-month-day-box>
+ * to click on, if not specified, the new event
+ * button is used.
+ */
+ async editNewEvent(win, target) {
+ return this._editNewItem(win, target, "event");
+ },
+
+ /**
+ * Opens the dialog for editing a new task.
+ *
+ * @param {Promise<Window>} win - The window containing the task tree.
+ */
+ async editNewTask(win) {
+ return this._editNewItem(win, null, "task");
+ },
+
+ async _editItem(win, item, selector) {
+ let summaryWin = await this.viewItem(win, item);
+ let promise = this.waitForEventDialog("edit");
+ let button = summaryWin.document.querySelector(selector);
+ button.click();
+
+ let dialogWindow = await promise;
+ let iframe = dialogWindow.document.querySelector("#calendar-item-panel-iframe");
+ return {
+ dialogWindow,
+ dialogDocument: dialogWindow.document,
+ iframeWindow: iframe.contentWindow,
+ iframeDocument: iframe.contentDocument,
+ };
+ },
+
+ /**
+ * Opens the event dialog for editing by clicking on the provided event item.
+ *
+ * @param {Window} win - The window containing the calendar.
+ * @param {MozCalendarEditableItem} item - An event box item that can be
+ * clicked on to open the dialog.
+ *
+ * @returns {Promise<EditItemAtResult>}
+ */
+ async editItem(win, item) {
+ return this._editItem(win, item, "#calendar-summary-dialog-edit-button");
+ },
+
+ /**
+ * Opens the event dialog for editing a single occurrence of a repeating event
+ * by clicking on the provided event item.
+ *
+ * @param {Window} win - The window containing the calendar.
+ * @param {MozCalendarEditableItem} item - An event box item that can be
+ * clicked on to open the dialog.
+ *
+ * @returns {Window}
+ */
+ async editItemOccurrence(win, item) {
+ return this._editItem(win, item, "#edit-button-context-menu-this-occurrence");
+ },
+
+ /**
+ * Opens the event dialog for editing all occurrences of a repeating event
+ * by clicking on the provided event box.
+ *
+ * @param {Window} win - The window containing the calendar.
+ * @param {MozCalendarEditableItem} item - An event box item that can be
+ * clicked on to open the dialog.
+ *
+ * @returns {Window}
+ */
+ async editItemOccurrences(win, item) {
+ return this._editItem(win, item, "#edit-button-context-menu-all-occurrences");
+ },
+
+ /**
+ * This produces a Promise for waiting on an event dialog to open.
+ * The mode parameter can be specified to indicate which of the dialogs to
+ * wait for.
+ *
+ * @param {string} [mode="view"] Determines which dialog we are waiting on,
+ * can be "view" for the summary or "edit" for the editing one.
+ *
+ * @returns {Promise<Window>}
+ */
+ waitForEventDialog(mode = "view") {
+ let uri =
+ mode === "edit"
+ ? "chrome://calendar/content/calendar-event-dialog.xhtml"
+ : "chrome://calendar/content/calendar-summary-dialog.xhtml";
+
+ return BrowserTestUtils.domWindowOpened(null, async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+
+ if (win.document.documentURI != uri) {
+ return false;
+ }
+
+ Assert.report(false, undefined, undefined, "Event dialog opened");
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == win,
+ "event dialog active"
+ );
+
+ if (mode === "edit") {
+ let iframe = win.document.getElementById("calendar-item-panel-iframe");
+ await TestUtils.waitForCondition(
+ () => iframe.contentWindow?.onLoad?.hasLoaded,
+ "waiting for iframe to be loaded"
+ );
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == iframe.contentWindow,
+ "waiting for iframe to be focused"
+ );
+ }
+ return true;
+ });
+ },
+
+ /**
+ * Go to a specific date using the minimonth.
+ *
+ * @param {Window} win - Main window
+ * @param {number} year - Four-digit year
+ * @param {number} month - 1-based index of a month
+ * @param {number} day - 1-based index of a day
+ */
+ async goToDate(win, year, month, day) {
+ let miniMonth = win.document.getElementById("calMinimonth");
+
+ let activeYear = miniMonth.querySelector(".minimonth-year-name").getAttribute("value");
+
+ let activeMonth = miniMonth.querySelector(".minimonth-month-name").getAttribute("monthIndex");
+
+ async function doScroll(name, difference, sleepTime) {
+ if (difference === 0) {
+ return;
+ }
+ let query = `.${name}s-${difference > 0 ? "back" : "forward"}-button`;
+ let scrollArrow = await TestUtils.waitForCondition(
+ () => miniMonth.querySelector(query),
+ `Query for scroll: ${query}`
+ );
+
+ for (let i = 0; i < Math.abs(difference); i++) {
+ EventUtils.synthesizeMouseAtCenter(scrollArrow, {}, win);
+ await new Promise(resolve => win.setTimeout(resolve, sleepTime));
+ }
+ }
+
+ await doScroll("year", activeYear - year, 10);
+ await doScroll("month", activeMonth - (month - 1), 25);
+
+ function getMiniMonthDay(week, day) {
+ return miniMonth.querySelector(
+ `.minimonth-cal-box > tr.minimonth-row-body:nth-of-type(${week + 1}) > ` +
+ `td.minimonth-day:nth-of-type(${day})`
+ );
+ }
+
+ let positionOfFirst = 7 - getMiniMonthDay(1, 7).textContent;
+ let weekDay = ((positionOfFirst + day - 1) % 7) + 1;
+ let week = Math.floor((positionOfFirst + day - 1) / 7) + 1;
+
+ // Pick day.
+ EventUtils.synthesizeMouseAtCenter(getMiniMonthDay(week, weekDay), {}, win);
+ await CalendarTestUtils.ensureViewLoaded(win);
+ },
+
+ /**
+ * Go to today.
+ *
+ * @param {Window} win - Main window
+ */
+ async goToToday(win) {
+ EventUtils.synthesizeMouseAtCenter(this.getNavBarTodayButton(win), {}, win);
+ await CalendarTestUtils.ensureViewLoaded(win);
+ },
+
+ /**
+ * Assert whether the given event box's edges are visually draggable (and
+ * hence, editable) at its edges or not.
+ *
+ * @param {MozCalendarEventBox} eventBox - The event box to test.
+ * @param {boolean} startDraggable - Whether we expect the start edge to be
+ * draggable.
+ * @param {boolean} endDraggable - Whether we expect the end edge to be
+ * draggable.
+ * @param {string} message - A message for assertions.
+ */
+ async assertEventBoxDraggable(eventBox, startDraggable, endDraggable, message) {
+ this.scrollViewToTarget(eventBox, true);
+ // Hover to see if the drag gripbars appear.
+ let enterPromise = BrowserTestUtils.waitForEvent(eventBox, "mouseenter");
+ // Hover over start.
+ EventUtils.synthesizeMouse(eventBox, 8, 8, { type: "mouseover" }, eventBox.ownerGlobal);
+ await enterPromise;
+ Assert.equal(
+ BrowserTestUtils.is_visible(eventBox.startGripbar),
+ startDraggable,
+ `Start gripbar should be ${startDraggable ? "visible" : "hidden"} on hover: ${message}`
+ );
+ Assert.equal(
+ BrowserTestUtils.is_visible(eventBox.endGripbar),
+ endDraggable,
+ `End gripbar should be ${endDraggable ? "visible" : "hidden"} on hover: ${message}`
+ );
+ },
+
+ /**
+ * Scroll the calendar view to show the given target.
+ *
+ * @param {Element} target - The target to scroll to. A descendent of a
+ * calendar view.
+ * @param {boolean} alignStart - Whether to scroll the inline and block start
+ * edges of the target into view, else scrolls the end edges into view.
+ */
+ scrollViewToTarget(target, alignStart) {
+ let multidayView = target.closest("calendar-day-view, calendar-week-view");
+ if (multidayView) {
+ // Multiday view has sticky headers, so scrollIntoView doesn't actually
+ // scroll far enough.
+ let scrollRect = multidayView.getScrollAreaRect();
+ let targetRect = target.getBoundingClientRect();
+ // We want to move the view by the difference between the starting/ending
+ // edge of the view and the starting/ending edge of the target.
+ let yDiff = alignStart
+ ? targetRect.top - scrollRect.top
+ : targetRect.bottom - scrollRect.bottom;
+ // In left-to-right, starting edge is the left edge. Otherwise, it is the
+ // right edge.
+ let xDiff =
+ alignStart == (target.ownerDocument.dir == "ltr")
+ ? targetRect.left - scrollRect.left
+ : targetRect.right - scrollRect.right;
+ multidayView.grid.scrollBy(xDiff, yDiff);
+ } else {
+ target.scrollIntoView(alignStart);
+ }
+ },
+
+ /**
+ * Save the current calendar views' UI states to be restored later.
+ *
+ * This is used with restoreCalendarViewsState to reset the view back to its
+ * initial loaded state after a test, so that later tests in the same group
+ * will receive the calendar view as if it was first opened after launching.
+ *
+ * @param {Window} win - The window that contains the calendar views.
+ *
+ * @returns {object} - An opaque object with data to pass to
+ * restoreCalendarViewsState.
+ */
+ saveCalendarViewsState(win) {
+ return {
+ multidayViewsData: ["day", "week"].map(viewName => {
+ // Save the scroll state since test utilities may change the scroll
+ // position, and this is currently not reset on re-opening the tab.
+ let view = win.document.getElementById(`${viewName}-view`);
+ return { view, viewName, scrollMinute: view.scrollMinute };
+ }),
+ };
+ },
+
+ /**
+ * Clean up the calendar views after a test by restoring their UI to the saved
+ * state, and close the calendar tab.
+ *
+ * @param {Window} win - The window that contains the calendar views.
+ * @param {object} data - The data returned by saveCalendarViewsState.
+ */
+ async restoreCalendarViewsState(win, data) {
+ for (let { view, viewName, scrollMinute } of data.multidayViewsData) {
+ await this.setCalendarView(win, viewName);
+ // The scrollMinute is rounded to the nearest integer.
+ // As is the scroll pixels.
+ // When we scrollToMinute, the scroll position is rounded to the nearest
+ // integer, as is the subsequent scroll minute. So calling
+ // scrollToMinute(min)
+ // will set
+ // scrollMinute = round(round(min * P) / P)
+ // where P is the pixelsPerMinute of the view. Thus
+ // scrollMinute = min +- round(0.5 / P)
+ let roundingError = Math.round(0.5 / view.pixelsPerMinute);
+ view.scrollToMinute(scrollMinute);
+ await TestUtils.waitForCondition(
+ () => Math.abs(view.scrollMinute - scrollMinute) <= roundingError,
+ "Waiting for scroll minute to restore"
+ );
+ }
+ await CalendarTestUtils.closeCalendarTab(win);
+ },
+
+ /**
+ * Get the Today button from the navigation bar.
+ *
+ * @param {Window} win - The window which contains the calendar.
+ *
+ * @returns {HTMLElement} - The today button.
+ */
+ getNavBarTodayButton(win) {
+ return win.document.getElementById("todayViewButton");
+ },
+
+ /**
+ * Get the label element containing a human-readable description of the
+ * displayed interval.
+ *
+ * @param {Window} win - The window which contains the calendar.
+ *
+ * @returns {HTMLLabelElement} - The interval description label.
+ */
+ getNavBarIntervalDescription(win) {
+ return win.document.getElementById("intervalDescription");
+ },
+
+ /**
+ * Get the label element containing an indication of which week or weeks are
+ * displayed.
+ *
+ * @param {Window} win - The window which contains the calendar.
+ *
+ * @returns {HTMLLabelElement} - The calendar week label.
+ */
+ getNavBarCalendarWeekBox(win) {
+ return win.document.getElementById("calendarWeek");
+ },
+};
diff --git a/comm/calendar/test/CalendarUtils.jsm b/comm/calendar/test/CalendarUtils.jsm
new file mode 100644
index 0000000000..708a65ad4e
--- /dev/null
+++ b/comm/calendar/test/CalendarUtils.jsm
@@ -0,0 +1,87 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = [
+ "SHORT_SLEEP",
+ "MID_SLEEP",
+ "TIMEOUT_MODAL_DIALOG",
+ "handleDeleteOccurrencePrompt",
+ "execEventDialogCallback",
+ "checkMonthAlarmIcon",
+ "closeAllEventDialogs",
+];
+
+var { Assert } = ChromeUtils.importESModule("resource://testing-common/Assert.sys.mjs");
+var { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+var EventUtils = ChromeUtils.import("resource://testing-common/mozmill/EventUtils.jsm");
+var { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs");
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "CalendarTestUtils",
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+var SHORT_SLEEP = 100;
+var MID_SLEEP = 500;
+var TIMEOUT_MODAL_DIALOG = 30000;
+var EVENT_DIALOG_NAME = "Calendar:EventDialog";
+
+/**
+ * Delete one or all occurrences using the prompt.
+ *
+ * @param {Window} window - Main window.
+ * @param {Element} element - Element which will open the dialog.
+ * @param {boolean} selectParent - true if all occurrences should be deleted.
+ */
+async function handleDeleteOccurrencePrompt(window, element, selectParent) {
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ "chrome://calendar/content/calendar-occurrence-prompt.xhtml",
+ {
+ callback(dialogWindow) {
+ let buttonId;
+ if (selectParent) {
+ buttonId = "accept-parent-button";
+ } else {
+ buttonId = "accept-occurrence-button";
+ }
+ let acceptButton = dialogWindow.document.getElementById(buttonId);
+ EventUtils.synthesizeMouseAtCenter(acceptButton, {}, dialogWindow);
+ },
+ }
+ );
+
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await dialogPromise;
+}
+
+async function execEventDialogCallback(callback) {
+ let eventWindow = Services.wm.getMostRecentWindow(EVENT_DIALOG_NAME);
+
+ if (!eventWindow) {
+ eventWindow = await lazy.CalendarTestUtils.waitForEventDialog("edit");
+ }
+
+ let iframe = eventWindow.document.getElementById("calendar-item-panel-iframe");
+ await TestUtils.waitForCondition(() => iframe.contentWindow.onLoad?.hasLoaded);
+
+ await callback(eventWindow, iframe.contentWindow);
+}
+
+/**
+ * Checks if Alarm-Icon is shown on a given Event-Box.
+ *
+ * @param {Window} window - Main window.
+ * @param {number} week - Week to check between 1-6.
+ * @param {number} day - Day to check between 1-7.
+ */
+function checkMonthAlarmIcon(window, week, day) {
+ let dayBox = lazy.CalendarTestUtils.monthView.getItemAt(window, week, day, 1);
+ Assert.ok(dayBox.querySelector(".alarm-icons-box > .reminder-icon"));
+}
diff --git a/comm/calendar/test/ICSServer.jsm b/comm/calendar/test/ICSServer.jsm
new file mode 100644
index 0000000000..c0f120ec2a
--- /dev/null
+++ b/comm/calendar/test/ICSServer.jsm
@@ -0,0 +1,153 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["ICSServer"];
+
+const { Assert } = ChromeUtils.importESModule("resource://testing-common/Assert.sys.mjs");
+const { CommonUtils } = ChromeUtils.importESModule("resource://services-common/utils.sys.mjs");
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+var ICSServer = {
+ server: null,
+ isOpen: false,
+
+ ics: "",
+ etag: "",
+ open(username, password) {
+ this.server = new HttpServer();
+ this.server.start(-1);
+ this.isOpen = true;
+
+ this.username = username;
+ this.password = password;
+ this.server.registerPathHandler("/ping", this.ping);
+ this.server.registerPathHandler(this.path, this.handleICS.bind(this));
+
+ this.reset();
+ },
+
+ reset() {
+ this.ics = "";
+ this.etag = "";
+ },
+
+ close() {
+ if (!this.isOpen) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve =>
+ this.server.stop({
+ onStopped: () => {
+ this.isOpen = false;
+ resolve();
+ },
+ })
+ );
+ },
+
+ get origin() {
+ return `http://localhost:${this.server.identity.primaryPort}`;
+ },
+
+ get path() {
+ return "/test.ics";
+ },
+
+ get url() {
+ return `${this.origin}${this.path}`;
+ },
+
+ get altPath() {
+ return "/addressbooks/me/default/";
+ },
+
+ get altURL() {
+ return `${this.origin}${this.altPath}`;
+ },
+
+ checkAuth(request, response) {
+ if (!this.username || !this.password) {
+ return true;
+ }
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ let value = request.getHeader("Authorization");
+ if (!value.startsWith("Basic ")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ let [username, password] = atob(value.substring(6)).split(":");
+ if (username != this.username || password != this.password) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ return true;
+ },
+
+ ping(request, response) {
+ response.setStatusLine("1.1", 200, "OK");
+ response.setHeader("Content-Type", "text/plain");
+ response.write("pong");
+ },
+
+ handleICS(request, response) {
+ if (!this.checkAuth(request, response)) {
+ return;
+ }
+
+ switch (request.method) {
+ case "HEAD":
+ this.headICS(request, response);
+ return;
+ case "GET":
+ this.getICS(request, response);
+ return;
+ case "PUT":
+ this.putICS(request, response);
+ return;
+ }
+
+ Assert.report(true, undefined, undefined, "Should not have reached here");
+ response.setStatusLine("1.1", 405, "Method Not Allowed");
+ response.setHeader("Content-Type", "text/plain");
+ response.write(`Method not allowed: ${request.method}`);
+ },
+
+ headICS(request, response) {
+ response.setStatusLine("1.1", 200, "OK");
+ response.setHeader("Content-Type", "text/calendar");
+ response.setHeader("ETag", this.etag);
+ },
+
+ getICS(request, response) {
+ this.headICS(request, response);
+ response.write(this.ics);
+ },
+
+ async putICS(request, response) {
+ response.processAsync();
+
+ await this.putICSInternal(CommonUtils.readBytesFromInputStream(request.bodyInputStream));
+
+ response.setStatusLine("1.1", 204, "No Content");
+ response.setHeader("ETag", this.etag);
+
+ response.finish();
+ },
+
+ async putICSInternal(ics) {
+ this.ics = ics;
+
+ let hash = await crypto.subtle.digest("sha-1", new TextEncoder().encode(this.ics));
+ this.etag = Array.from(new Uint8Array(hash), c => c.toString(16).padStart(2, "0")).join("");
+ },
+};
diff --git a/comm/calendar/test/ItemEditingHelpers.jsm b/comm/calendar/test/ItemEditingHelpers.jsm
new file mode 100644
index 0000000000..812f5008cb
--- /dev/null
+++ b/comm/calendar/test/ItemEditingHelpers.jsm
@@ -0,0 +1,681 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = [
+ "cancelItemDialog",
+ "formatDate",
+ "formatTime",
+ "menulistSelect",
+ "saveAndCloseItemDialog",
+ "setData",
+];
+
+var { Assert } = ChromeUtils.importESModule("resource://testing-common/Assert.sys.mjs");
+var { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs");
+var { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+var { sendString, synthesizeKey, synthesizeMouseAtCenter } = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalDateTime } = ChromeUtils.import("resource:///modules/CalDateTime.jsm");
+var { setTimeout } = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");
+
+function sleep(window, time = 0) {
+ return new Promise(resolve => window.setTimeout(resolve, time));
+}
+
+var dateFormatter = new Services.intl.DateTimeFormat(undefined, { dateStyle: "short" });
+var dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "short",
+ timeZone: "UTC",
+});
+var timeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ timeStyle: "short",
+ timeZone: "UTC",
+});
+
+/**
+ * Formats a date for input in a datepicker. Don't use cal.dtz.formatter methods
+ * for this as they use the application locale but datepicker uses the OS locale.
+ *
+ * @param {calIDateTime} date
+ * @returns {string}
+ */
+function formatDate(date) {
+ if (date.isDate) {
+ return dateFormatter.format(cal.dtz.dateTimeToJsDate(date));
+ }
+
+ return dateTimeFormatter.format(cal.dtz.dateTimeToJsDate(date));
+}
+
+/**
+ * Formats a time for input in a timepicker. Don't use cal.dtz.formatter methods
+ * for this as they use the application locale but timepicker uses the OS locale.
+ *
+ * @param {calIDateTime} time
+ * @returns {string}
+ */
+function formatTime(time) {
+ return timeFormatter.format(cal.dtz.dateTimeToJsDate(time));
+}
+
+/**
+ * @callback dialogCallback
+ * @param {Window} - The calendar-event-dialog-recurrence.xhtml dialog.
+ */
+
+/**
+ * Helper function to enter event/task dialog data.
+ *
+ * @param {Window} dialogWindow - Item dialog outer window.
+ * @param {Window} iframeWindow - Item dialog inner iframe.
+ * @param {object} data
+ * @param {string} data.title - Item title.
+ * @param {string} data.location - Item location.
+ * @param {string} data.description - Item description.
+ * @param {string[]} data.categories - Category names to set - leave empty to clear.
+ * @param {string} data.calendar - ID of the calendar the item should be in.
+ * @param {boolean} data.allday
+ * @param {calIDateTime} data.startdate
+ * @param {calIDateTime} data.starttime
+ * @param {calIDateTime} data.enddate
+ * @param {calIDateTime} data.endtime
+ * @param {boolean} data.timezonedisplay - false for hidden, true for shown.
+ * @param {string} data.timezone - String identifying the timezone.
+ * @param {string|dialogCallback} data.repeat - Recurrence value, one of
+ * none/daily/weekly/every.weekday/bi.weekly/monthly/yearly or a callback function to set a
+ * custom value.
+ * @param {calIDateTime} data.repeatuntil
+ * @param {string} data.reminder -
+ * none/0minutes/5minutes/15minutes/30minutes/1hour/2hours/12hours/1day/2days/1week
+ * (Custom is not supported.)
+ * @param {string} data.priority - none/low/normal/high
+ * @param {string} data.privacy - public/confidential/private
+ * @param {string} data.status - none/tentative/confirmed/canceled for events
+ * none/needs-action/in-process/completed/cancelled for tasks
+ * @param {calIDateTime} data.completed - Completion date (tasks only)
+ * @param {string} data.percent - Percentage complete (tasks only)
+ * @param {string} data.freebusy - free/busy
+ * @param {string} data.attachment.add - URL to add
+ * @param {string} data.attachment.remove - Label of url to remove. (without http://)
+ * @param {string} data.attendees.add - Email of attendees to add, comma separated.
+ * @param {string} data.attendees.remove - Email of attendees to remove, comma separated.
+ */
+async function setData(dialogWindow, iframeWindow, data) {
+ function replaceText(input, text) {
+ synthesizeMouseAtCenter(input, {}, iframeWindow);
+ synthesizeKey("a", { accelKey: true }, iframeWindow);
+ sendString(text, iframeWindow);
+ }
+
+ let dialogDocument = dialogWindow.document;
+ let iframeDocument = iframeWindow.document;
+
+ let isEvent = iframeWindow.calendarItem.isEvent();
+ let startPicker = iframeDocument.getElementById(isEvent ? "event-starttime" : "todo-entrydate");
+ let endPicker = iframeDocument.getElementById(isEvent ? "event-endtime" : "todo-duedate");
+
+ let startdateInput = startPicker._datepicker._inputField;
+ let enddateInput = endPicker._datepicker._inputField;
+ let starttimeInput = startPicker._timepicker._inputField;
+ let endtimeInput = endPicker._timepicker._inputField;
+ let completeddateInput = iframeDocument.getElementById("completed-date-picker")._inputField;
+ let untilDateInput = iframeDocument.getElementById("repeat-until-datepicker")._inputField;
+
+ // Wait for input elements' values to be populated.
+ await sleep(iframeWindow, 500);
+
+ // title
+ if (data.title !== undefined) {
+ let titleInput = iframeDocument.getElementById("item-title");
+ replaceText(titleInput, data.title);
+ }
+
+ // location
+ if (data.location !== undefined) {
+ let locationInput = iframeDocument.getElementById("item-location");
+ replaceText(locationInput, data.location);
+ }
+
+ // categories
+ if (data.categories !== undefined) {
+ await setCategories(iframeWindow, data.categories);
+ await sleep(iframeWindow);
+ }
+
+ // calendar
+ if (data.calendar !== undefined) {
+ await menulistSelect(iframeDocument.getElementById("item-calendar"), data.calendar);
+ await sleep(iframeWindow);
+ }
+
+ // all-day
+ if (data.allday !== undefined && isEvent) {
+ let checkbox = iframeDocument.getElementById("event-all-day");
+ if (checkbox.checked != data.allday) {
+ synthesizeMouseAtCenter(checkbox, {}, iframeWindow);
+ }
+ }
+
+ // timezonedisplay
+ if (data.timezonedisplay !== undefined) {
+ let menuitem = dialogDocument.getElementById("options-timezones-menuitem");
+ if (menuitem.getAttribute("checked") != data.timezonedisplay) {
+ synthesizeMouseAtCenter(menuitem, {}, iframeWindow);
+ }
+ }
+
+ // timezone
+ if (data.timezone !== undefined) {
+ await setTimezone(dialogWindow, iframeWindow, data.timezone);
+ }
+
+ // startdate
+ if (
+ data.startdate !== undefined &&
+ (data.startdate instanceof CalDateTime || data.startdate instanceof Ci.calIDateTime)
+ ) {
+ let startdate = formatDate(data.startdate);
+
+ if (!isEvent) {
+ let checkbox = iframeDocument.getElementById("todo-has-entrydate");
+ if (!checkbox.checked) {
+ synthesizeMouseAtCenter(checkbox, {}, iframeWindow);
+ }
+ }
+ replaceText(startdateInput, startdate);
+ }
+
+ // starttime
+ if (
+ data.starttime !== undefined &&
+ (data.starttime instanceof CalDateTime || data.starttime instanceof Ci.calIDateTime)
+ ) {
+ let starttime = formatTime(data.starttime);
+ replaceText(starttimeInput, starttime);
+ await sleep(iframeWindow);
+ }
+
+ // enddate
+ if (
+ data.enddate !== undefined &&
+ (data.enddate instanceof CalDateTime || data.enddate instanceof Ci.calIDateTime)
+ ) {
+ let enddate = formatDate(data.enddate);
+ if (!isEvent) {
+ let checkbox = iframeDocument.getElementById("todo-has-duedate");
+ if (!checkbox.checked) {
+ synthesizeMouseAtCenter(checkbox, {}, iframeWindow);
+ }
+ }
+ replaceText(enddateInput, enddate);
+ }
+
+ // endtime
+ if (
+ data.endtime !== undefined &&
+ (data.endtime instanceof CalDateTime || data.endtime instanceof Ci.calIDateTime)
+ ) {
+ let endtime = formatTime(data.endtime);
+ replaceText(endtimeInput, endtime);
+ }
+
+ // recurrence
+ if (data.repeat !== undefined) {
+ if (typeof data.repeat == "function") {
+ let repeatWindowPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ "chrome://calendar/content/calendar-event-dialog-recurrence.xhtml",
+ {
+ async callback(recurrenceWindow) {
+ Assert.report(false, undefined, undefined, "Recurrence dialog opened");
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == recurrenceWindow,
+ "recurrence dialog active"
+ );
+
+ await new Promise(resolve => recurrenceWindow.setTimeout(resolve, 500));
+ await data.repeat(recurrenceWindow);
+ },
+ }
+ );
+ await Promise.all([
+ menulistSelect(iframeDocument.getElementById("item-repeat"), "custom"),
+ repeatWindowPromise,
+ ]);
+ Assert.report(false, undefined, undefined, "Recurrence dialog closed");
+ } else {
+ await menulistSelect(iframeDocument.getElementById("item-repeat"), data.repeat);
+ }
+ }
+ if (
+ data.repeatuntil !== undefined &&
+ (data.repeatuntil instanceof CalDateTime || data.repeatuntil instanceof Ci.calIDateTime)
+ ) {
+ // Only fill in date, when the Datepicker is visible.
+ if (!iframeDocument.getElementById("repeat-untilDate").hidden) {
+ let untildate = formatDate(data.repeatuntil);
+ replaceText(untilDateInput, untildate);
+ }
+ }
+
+ // reminder
+ if (data.reminder !== undefined) {
+ await setReminderMenulist(iframeWindow, data.reminder);
+ }
+
+ // priority
+ if (data.priority !== undefined) {
+ dialogDocument.getElementById(`options-priority-${data.priority}-label`).click();
+ }
+
+ // privacy
+ if (data.privacy !== undefined) {
+ let button = dialogDocument.getElementById("button-privacy");
+ let shownPromise = BrowserTestUtils.waitForEvent(button, "popupshown");
+ synthesizeMouseAtCenter(button, {}, dialogWindow);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(button, "popuphidden");
+ synthesizeMouseAtCenter(
+ dialogDocument.getElementById(`event-privacy-${data.privacy}-menuitem`),
+ {},
+ dialogWindow
+ );
+ await hiddenPromise;
+ await sleep(iframeWindow);
+ }
+
+ // status
+ if (data.status !== undefined) {
+ if (isEvent) {
+ dialogDocument.getElementById(`options-status-${data.status}-menuitem`).click();
+ } else {
+ await menulistSelect(iframeDocument.getElementById("todo-status"), data.status.toUpperCase());
+ }
+ }
+
+ let currentStatus = iframeDocument.getElementById("todo-status").value;
+
+ // completed on
+ if (
+ data.completed !== undefined &&
+ (data.completed instanceof CalDateTime || data.completed instanceof Ci.calIDateTime) &&
+ !isEvent
+ ) {
+ let completeddate = formatDate(data.completed);
+ if (currentStatus == "COMPLETED") {
+ replaceText(completeddateInput, completeddate);
+ }
+ }
+
+ // percent complete
+ if (
+ data.percent !== undefined &&
+ (currentStatus == "NEEDS-ACTION" ||
+ currentStatus == "IN-PROCESS" ||
+ currentStatus == "COMPLETED")
+ ) {
+ let percentCompleteInput = iframeDocument.getElementById("percent-complete-textbox");
+ replaceText(percentCompleteInput, data.percent);
+ }
+
+ // free/busy
+ if (data.freebusy !== undefined) {
+ dialogDocument.getElementById(`options-freebusy-${data.freebusy}-menuitem`).click();
+ }
+
+ // description
+ if (data.description !== undefined) {
+ synthesizeMouseAtCenter(
+ iframeDocument.getElementById("event-grid-tab-description"),
+ {},
+ iframeWindow
+ );
+ let descField = iframeDocument.getElementById("item-description");
+ replaceText(descField, data.description);
+ }
+
+ // attachment
+ if (data.attachment !== undefined) {
+ if (data.attachment.add !== undefined) {
+ await handleAddingAttachment(dialogWindow, data.attachment.add);
+ }
+ if (data.attachment.remove !== undefined) {
+ synthesizeMouseAtCenter(
+ iframeDocument.getElementById("event-grid-tab-attachments"),
+ {},
+ iframeWindow
+ );
+ let attachmentBox = iframeDocument.getElementById("attachment-link");
+ let attachments = attachmentBox.children;
+ for (let attachment of attachments) {
+ if (attachment.tooltipText.includes(data.attachment.remove)) {
+ synthesizeMouseAtCenter(attachment, {}, iframeWindow);
+ synthesizeKey("VK_DELETE", {}, dialogWindow);
+ }
+ }
+ }
+ }
+
+ // attendees
+ if (data.attendees !== undefined) {
+ // Display attendees Tab.
+ synthesizeMouseAtCenter(
+ iframeDocument.getElementById("event-grid-tab-attendees"),
+ {},
+ iframeWindow
+ );
+ // Make sure no notifications are sent, since handling this dialog is
+ // not working when deleting a parent of a recurring event.
+ let attendeeCheckbox = iframeDocument.getElementById("notify-attendees-checkbox");
+ if (!attendeeCheckbox.disabled && attendeeCheckbox.checked) {
+ synthesizeMouseAtCenter(attendeeCheckbox, {}, iframeWindow);
+ }
+
+ // add
+ if (data.attendees.add !== undefined) {
+ await addAttendees(dialogWindow, iframeWindow, data.attendees.add);
+ }
+ // delete
+ if (data.attendees.remove !== undefined) {
+ await deleteAttendees(iframeWindow, data.attendees.remove);
+ }
+ }
+
+ await sleep(iframeWindow);
+}
+
+/**
+ * Closes an event dialog window, saving the event.
+ *
+ * @param {Window} dialogWindow
+ */
+async function saveAndCloseItemDialog(dialogWindow) {
+ let dialogClosing = BrowserTestUtils.domWindowClosed(dialogWindow);
+ synthesizeMouseAtCenter(
+ dialogWindow.document.getElementById("button-saveandclose"),
+ {},
+ dialogWindow
+ );
+ await dialogClosing;
+ Assert.report(false, undefined, undefined, "Item dialog closed");
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+/**
+ * Closes an event dialog window, discarding any changes.
+ *
+ * @param {Window} dialogWindow
+ */
+function cancelItemDialog(dialogWindow) {
+ synthesizeKey("VK_ESCAPE", {}, dialogWindow);
+}
+
+/**
+ * Select an item in the reminder menulist.
+ * Custom reminders are not supported.
+ *
+ * @param {Window} iframeWindow - The event dialog iframe.
+ * @param {string} id - Identifying string of menuitem id.
+ */
+async function setReminderMenulist(iframeWindow, id) {
+ let iframeDocument = iframeWindow.document;
+ let menulist = iframeDocument.querySelector(".item-alarm");
+ let menuitem = iframeDocument.getElementById(`reminder-${id}-menuitem`);
+
+ Assert.ok(menulist, `<menulist id=${menulist.id}> exists`);
+ Assert.ok(menuitem, `<menuitem id=${id}> exists`);
+
+ menulist.focus();
+
+ synthesizeMouseAtCenter(menulist, {}, iframeWindow);
+ await BrowserTestUtils.waitForEvent(menulist, "popupshown");
+ synthesizeMouseAtCenter(menuitem, {}, iframeWindow);
+ await BrowserTestUtils.waitForEvent(menulist, "popuphidden");
+ await sleep(iframeWindow);
+}
+
+/**
+ * Set the categories in the event-dialog menulist-panel.
+ *
+ * @param {Window} iframeWindow - The event dialog iframe.
+ * @param {string[]} categories - Category names to set - leave empty to clear.
+ */
+async function setCategories(iframeWindow, categories) {
+ let iframeDocument = iframeWindow.document;
+ let menulist = iframeDocument.getElementById("item-categories");
+ let menupopup = iframeDocument.getElementById("item-categories-popup");
+
+ synthesizeMouseAtCenter(menulist, {}, iframeWindow);
+ await BrowserTestUtils.waitForEvent(menupopup, "popupshown");
+
+ // Iterate over categories and check if needed.
+ for (let item of menupopup.children) {
+ if (categories.includes(item.label)) {
+ item.setAttribute("checked", "true");
+ } else {
+ item.removeAttribute("checked");
+ }
+ }
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menupopup, "popuphidden");
+ menupopup.hidePopup();
+ await hiddenPromise;
+}
+
+/**
+ * Add an URL attachment.
+ *
+ * @param {Window} dialogWindow - The event dialog.
+ * @param {string} url - URL to be added.
+ */
+async function handleAddingAttachment(dialogWindow, url) {
+ let dialogDocument = dialogWindow.document;
+ let attachButton = dialogDocument.querySelector("#button-url");
+ let menu = dialogDocument.querySelector("#button-attach-menupopup");
+ let menuShowing = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ synthesizeMouseAtCenter(attachButton, {}, dialogWindow);
+ await menuShowing;
+
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog(undefined, undefined, {
+ async callback(attachmentWindow) {
+ Assert.report(false, undefined, undefined, "Attachment dialog opened");
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == attachmentWindow,
+ "attachment dialog active"
+ );
+
+ let attachmentDocument = attachmentWindow.document;
+ attachmentDocument.getElementById("loginTextbox").value = url;
+ attachmentDocument.querySelector("dialog").getButton("accept").click();
+ },
+ });
+ synthesizeMouseAtCenter(dialogDocument.querySelector("#button-attach-url"), {}, dialogWindow);
+ await dialogPromise;
+ Assert.report(false, undefined, undefined, "Attachment dialog closed");
+ await sleep(dialogWindow);
+}
+
+/**
+ * Add attendees to the event.
+ *
+ * @param {Window} dialogWindow - The event dialog.
+ * @param {Window} iframeWindow - The event dialog iframe.
+ * @param {string} attendeesString - Comma separated list of email-addresses to add.
+ */
+async function addAttendees(dialogWindow, iframeWindow, attendeesString) {
+ let dialogDocument = dialogWindow.document;
+
+ let attendees = attendeesString.split(",");
+ for (let attendee of attendees) {
+ let calAttendee = iframeWindow.attendees.find(aAtt => aAtt.id == `mailto:${attendee}`);
+ // Only add if not already present.
+ if (!calAttendee) {
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ "chrome://calendar/content/calendar-event-dialog-attendees.xhtml",
+ {
+ async callback(attendeesWindow) {
+ Assert.report(false, undefined, undefined, "Attendees dialog opened");
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == attendeesWindow,
+ "attendees dialog active"
+ );
+
+ let attendeesDocument = attendeesWindow.document;
+ Assert.equal(attendeesDocument.activeElement.localName, "input");
+ Assert.equal(
+ attendeesDocument.activeElement.value,
+ "",
+ "active input value should be empty"
+ );
+ sendString(attendee, attendeesWindow);
+ Assert.report(false, undefined, undefined, `Sent attendee ${attendee}`);
+ // Windows needs the focus() call here.
+ attendeesDocument.querySelector("dialog").getButton("accept").focus();
+ synthesizeMouseAtCenter(
+ attendeesDocument.querySelector("dialog").getButton("accept"),
+ {},
+ attendeesWindow
+ );
+ },
+ }
+ );
+ synthesizeMouseAtCenter(dialogDocument.getElementById("button-attendees"), {}, dialogWindow);
+ await dialogPromise;
+ Assert.report(false, undefined, undefined, "Attendees dialog closed");
+ await sleep(iframeWindow);
+ }
+ }
+}
+
+/**
+ * Delete attendees from the event.
+ *
+ * @param {Window} iframeWindow - The event dialog iframe.
+ * @param {string} attendeesString - Comma separated list of email-addresses to delete.
+ */
+async function deleteAttendees(iframeWindow, attendeesString) {
+ let iframeDocument = iframeWindow.document;
+ let menupopup = iframeDocument.getElementById("attendee-popup");
+
+ // Now delete the attendees.
+ let attendees = attendeesString.split(",");
+ for (let attendee of attendees) {
+ let attendeeToDelete = iframeDocument.querySelector(
+ `.attendee-list [attendeeid="mailto:${attendee}"]`
+ );
+ if (attendeeToDelete) {
+ attendeeToDelete.focus();
+ synthesizeMouseAtCenter(attendeeToDelete, { type: "contextmenu" }, iframeWindow);
+ await BrowserTestUtils.waitForEvent(menupopup, "popupshown");
+ menupopup.activateItem(
+ iframeDocument.getElementById("attendee-popup-removeattendee-menuitem")
+ );
+ await BrowserTestUtils.waitForEvent(menupopup, "popuphidden");
+ }
+ }
+ await sleep(iframeWindow);
+}
+
+/**
+ * Set the timezone for the item
+ *
+ * @param {Window} dialogWindow - The event dialog.
+ * @param {Window} iframeWindow - The event dialog iframe.
+ * @param {string} timezone - String identifying the timezone.
+ */
+async function setTimezone(dialogWindow, iframeWindow, timezone) {
+ let dialogDocument = dialogWindow.document;
+ let iframeDocument = iframeWindow.document;
+
+ let menuitem = dialogDocument.getElementById("options-timezones-menuitem");
+ let label = iframeDocument.getElementById("timezone-starttime");
+ let menupopup = iframeDocument.getElementById("timezone-popup");
+ let customMenuitem = iframeDocument.getElementById("timezone-custom-menuitem");
+
+ if (!BrowserTestUtils.is_visible(label)) {
+ menuitem.click();
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(label),
+ "Timezone label should become visible"
+ );
+ }
+
+ await TestUtils.waitForCondition(() => !label.disabled, "Tiemzone label should become enabled");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menupopup, "popupshown");
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ "chrome://calendar/content/calendar-event-dialog-timezone.xhtml",
+ {
+ async callback(timezoneWindow) {
+ Assert.report(false, undefined, undefined, "Timezone dialog opened");
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == timezoneWindow,
+ "timezone dialog active"
+ );
+
+ let timezoneDocument = timezoneWindow.document;
+ let timezoneMenulist = timezoneDocument.getElementById("timezone-menulist");
+ let timezoneMenuitem = timezoneMenulist.querySelector(`[value="${timezone}"]`);
+
+ let popupshown = BrowserTestUtils.waitForEvent(timezoneMenulist, "popupshown");
+ synthesizeMouseAtCenter(timezoneMenulist, {}, timezoneWindow);
+ await popupshown;
+
+ timezoneMenuitem.scrollIntoView();
+
+ let popuphidden = BrowserTestUtils.waitForEvent(timezoneMenulist, "popuphidden");
+ synthesizeMouseAtCenter(timezoneMenuitem, {}, timezoneWindow);
+ await popuphidden;
+
+ synthesizeMouseAtCenter(
+ timezoneDocument.querySelector("dialog").getButton("accept"),
+ {},
+ timezoneWindow
+ );
+ },
+ }
+ );
+
+ synthesizeMouseAtCenter(label, {}, iframeWindow);
+ await shownPromise;
+
+ synthesizeMouseAtCenter(customMenuitem, {}, iframeWindow);
+ await dialogPromise;
+ Assert.report(false, undefined, undefined, "Timezone dialog closed");
+
+ await new Promise(resolve => iframeWindow.setTimeout(resolve, 500));
+}
+
+/**
+ * Selects an item from a menulist.
+ *
+ * @param {Element} menulist
+ * @param {string} value
+ */
+async function menulistSelect(menulist, value) {
+ let win = menulist.ownerGlobal;
+ Assert.ok(menulist, `<menulist id=${menulist.id}> exists`);
+ let menuitem = menulist.querySelector(`menupopup > menuitem[value='${value}']`);
+ Assert.ok(menuitem, `<menuitem value=${value}> exists`);
+
+ menulist.focus();
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menulist, "popupshown");
+ synthesizeMouseAtCenter(menulist, {}, win);
+ await shownPromise;
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menulist, "popuphidden");
+ synthesizeMouseAtCenter(menuitem, {}, win);
+ await hiddenPromise;
+
+ await new Promise(resolve => win.setTimeout(resolve));
+ Assert.equal(menulist.value, value);
+}
diff --git a/comm/calendar/test/browser/browser.ini b/comm/calendar/test/browser/browser.ini
new file mode 100644
index 0000000000..5eb780310b
--- /dev/null
+++ b/comm/calendar/test/browser/browser.ini
@@ -0,0 +1,39 @@
+[default]
+head = head.js
+prefs =
+ calendar.debug.log=true
+ calendar.item.promptDelete=false
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.oauth.loglevel=Debug
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ signon.rememberSignons=true
+subsuite = thunderbird
+support-files = data/**
+
+[browser_basicFunctionality.js]
+[browser_calDAV_discovery.js]
+[browser_calDAV_oAuth.js]
+tags = oauth
+[browser_calendarList.js]
+[browser_calendarTelemetry.js]
+[browser_calendarUnifinder.js]
+[browser_dragEventItem.js]
+[browser_eventDisplay_dayView.js]
+[browser_eventDisplay_multiWeekView.js]
+[browser_eventDisplay_weekView.js]
+[browser_eventUndoRedo.js]
+[browser_import.js]
+[browser_localICS.js]
+[browser_taskDelete.js]
+[browser_taskUndoRedo.js]
+[browser_tabs.js]
+[browser_taskDisplay.js]
+[browser_todayPane.js]
+[browser_todayPane_dragAndDrop.js]
+[browser_todayPane_visibility.js]
diff --git a/comm/calendar/test/browser/browser_basicFunctionality.js b/comm/calendar/test/browser/browser_basicFunctionality.js
new file mode 100644
index 0000000000..c8f0fd68b7
--- /dev/null
+++ b/comm/calendar/test/browser/browser_basicFunctionality.js
@@ -0,0 +1,78 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals createCalendarUsingDialog */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+add_task(async function testBasicFunctionality() {
+ const calendarName = "Mochitest";
+
+ registerCleanupFunction(() => {
+ for (let calendar of cal.manager.getCalendars()) {
+ if (calendar.name == calendarName) {
+ cal.manager.removeCalendar(calendar);
+ }
+ }
+ Services.focus.focusedWindow = window;
+ });
+
+ Services.focus.focusedWindow = window;
+
+ // Create test calendar.
+ await createCalendarUsingDialog(calendarName);
+
+ // Check for minimonth, every month has a day 1.
+ Assert.ok(
+ document.querySelector("#calMinimonth .minimonth-cal-box td[aria-label='1']"),
+ "day 1 exists in the minimonth"
+ );
+
+ // Check for calendar list.
+ Assert.ok(document.querySelector("#calendar-list-pane"), "calendar list pane exists");
+ Assert.ok(document.querySelector("#calendar-list"), "calendar list exists");
+
+ // Check for event search.
+ Assert.ok(document.querySelector("#bottom-events-box"), "event search box exists");
+
+ // There should be search field.
+ Assert.ok(document.querySelector("#unifinder-search-field"), "unifinded search field exists");
+
+ // Make sure the week view is the default selected view.
+ Assert.ok(
+ document
+ .querySelector(`.calview-toggle-item[aria-selected="true"]`)
+ .getAttribute("aria-controls") == "week-view",
+ "week-view toggle is the current default"
+ );
+
+ let dayViewButton = document.querySelector("#calTabDay");
+ dayViewButton.click();
+ Assert.ok(dayViewButton.getAttribute("aria-selected"), "day view button is selected");
+ await CalendarTestUtils.ensureViewLoaded(window);
+
+ // Day view should have 09:00 box.
+ let someTime = cal.createDateTime();
+ someTime.resetTo(someTime.year, someTime.month, someTime.day, 9, 0, 0, someTime.timezone);
+ let label = cal.dtz.formatter.formatTime(someTime);
+ let labelEl = document.querySelectorAll("#day-view .multiday-timebar .multiday-hour-box")[9];
+ Assert.ok(labelEl, "9th hour box should exist");
+ Assert.equal(labelEl.textContent, label, "9th hour box should show the correct time");
+ Assert.ok(CalendarTestUtils.dayView.getHourBoxAt(window, 9), "09:00 box exists");
+
+ // Open tasks view.
+ document.querySelector("#tasksButton").click();
+
+ // Should be possible to filter today's tasks.
+ Assert.ok(document.querySelector("#opt_today_filter"), "show today radio button exists");
+
+ // Check for task add button.
+ Assert.ok(document.querySelector("#calendar-add-task-button"), "task add button exists");
+
+ // Check for filtered tasks list.
+ Assert.ok(
+ document.querySelector("#calendar-task-tree .calendar-task-treechildren"),
+ "filtered tasks list exists"
+ );
+});
diff --git a/comm/calendar/test/browser/browser_calDAV_discovery.js b/comm/calendar/test/browser/browser_calDAV_discovery.js
new file mode 100644
index 0000000000..217bf76f55
--- /dev/null
+++ b/comm/calendar/test/browser/browser_calDAV_discovery.js
@@ -0,0 +1,241 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalDAVServer } = ChromeUtils.import("resource://testing-common/calendar/CalDAVServer.jsm");
+var { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm");
+
+async function openWizard(...args) {
+ await CalendarTestUtils.openCalendarTab(window);
+ let wizardPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ "chrome://calendar/content/calendar-creation.xhtml",
+ {
+ callback: wizardWindow => handleWizard(wizardWindow, ...args),
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ document.querySelector("#newCalendarSidebarButton"),
+ {},
+ window
+ );
+ return wizardPromise;
+}
+
+async function handleWizard(wizardWindow, { username, url, password, expectedCalendars }) {
+ let wizardDocument = wizardWindow.document;
+ let acceptButton = wizardDocument.querySelector("dialog").getButton("accept");
+ let cancelButton = wizardDocument.querySelector("dialog").getButton("cancel");
+
+ // Select calendar type.
+
+ EventUtils.synthesizeMouseAtCenter(
+ wizardDocument.querySelector(`radio[value="network"]`),
+ {},
+ wizardWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(acceptButton, {}, wizardWindow);
+
+ // Network calendar settings.
+
+ Assert.ok(acceptButton.disabled);
+ Assert.equal(wizardDocument.activeElement.id, "network-username-input");
+ if (username) {
+ EventUtils.sendString(username, wizardWindow);
+ }
+
+ if (username?.includes("@")) {
+ Assert.equal(
+ wizardDocument.getElementById("network-location-input").placeholder,
+ username.replace(/^.*@/, "")
+ );
+ }
+
+ EventUtils.synthesizeKey("VK_TAB", {}, wizardWindow);
+ Assert.equal(wizardDocument.activeElement.id, "network-location-input");
+ if (url) {
+ EventUtils.sendString(url, wizardWindow);
+ }
+
+ Assert.ok(!acceptButton.disabled);
+
+ let promptPromise = handlePasswordPrompt(password);
+ EventUtils.synthesizeKey("VK_RETURN", {}, wizardWindow);
+ await promptPromise;
+
+ // Select calendars.
+
+ let list = wizardDocument.getElementById("network-calendar-list");
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(list),
+ "waiting for calendar list to appear",
+ 200,
+ 100
+ );
+
+ Assert.equal(list.childElementCount, expectedCalendars.length);
+ for (let i = 0; i < expectedCalendars.length; i++) {
+ let item = list.children[i];
+
+ Assert.equal(item.calendar.uri.spec, expectedCalendars[i].uri);
+ Assert.equal(
+ item.querySelector(".calendar-color").style.backgroundColor,
+ expectedCalendars[i].color
+ );
+ Assert.equal(item.querySelector(".calendar-name").value, expectedCalendars[i].name);
+
+ if (expectedCalendars[i].hasOwnProperty("readOnly")) {
+ Assert.equal(
+ item.calendar.readOnly,
+ expectedCalendars[i].readOnly,
+ `calendar read-only property is ${expectedCalendars[i].readOnly}`
+ );
+ }
+ }
+ EventUtils.synthesizeMouseAtCenter(cancelButton, {}, wizardWindow);
+}
+
+async function handlePasswordPrompt(password) {
+ return BrowserTestUtils.promiseAlertDialog(null, undefined, {
+ async callback(prompt) {
+ await new Promise(resolve => prompt.setTimeout(resolve));
+
+ prompt.document.getElementById("password1Textbox").value = password;
+
+ let checkbox = prompt.document.getElementById("checkbox");
+ Assert.greater(checkbox.getBoundingClientRect().width, 0);
+ Assert.ok(checkbox.checked);
+
+ prompt.document.querySelector("dialog").getButton("accept").click();
+ },
+ });
+}
+
+/**
+ * Test that we correctly use DNS discovery. This uses the mochitest server
+ * (files in the data directory) instead of CalDAVServer because the latter
+ * can't speak HTTPS, and we only do DNS discovery for HTTPS.
+ */
+add_task(async function testDNS() {
+ var _srv = DNS.srv;
+ var _txt = DNS.txt;
+ DNS.srv = function (name) {
+ Assert.equal(name, "_caldavs._tcp.dnstest.invalid");
+ return [{ prio: 0, weight: 0, host: "example.org", port: 443 }];
+ };
+ DNS.txt = function (name) {
+ Assert.equal(name, "_caldavs._tcp.dnstest.invalid");
+ return [{ data: "path=/browser/comm/calendar/test/browser/data/dns.sjs" }];
+ };
+
+ await openWizard({
+ username: "carol@dnstest.invalid",
+ password: "carol",
+ expectedCalendars: [
+ {
+ uri: "https://example.org/browser/comm/calendar/test/browser/data/calendar.sjs",
+ name: "You found me!",
+ color: "rgb(0, 128, 0)",
+ },
+ {
+ uri: "https://example.org/browser/comm/calendar/test/browser/data/calendar2.sjs",
+ name: "RΓΆda dagar",
+ color: "rgb(255, 0, 0)",
+ },
+ ],
+ });
+
+ DNS.srv = _srv;
+ DNS.txt = _txt;
+});
+
+/**
+ * Test that the magic URL /.well-known/caldav works.
+ */
+add_task(async function testWellKnown() {
+ CalDAVServer.open("alice", "alice");
+
+ await openWizard({
+ username: "alice",
+ url: CalDAVServer.origin,
+ password: "alice",
+ expectedCalendars: [
+ {
+ uri: CalDAVServer.url,
+ name: "CalDAV Test",
+ color: "rgb(255, 128, 0)",
+ },
+ ],
+ });
+
+ CalDAVServer.close();
+});
+
+/**
+ * Tests calendars with only the "read" "current-user-privilege-set" are
+ * flagged read-only.
+ */
+add_task(async function testCalendarWithOnlyReadPriv() {
+ CalDAVServer.open("alice", "alice");
+ CalDAVServer.privileges = "<d:privilege><d:read/></d:privilege>";
+ await openWizard({
+ username: "alice",
+ url: CalDAVServer.origin,
+ password: "alice",
+ expectedCalendars: [
+ {
+ uri: CalDAVServer.url,
+ name: "CalDAV Test",
+ color: "rgb(255, 128, 0)",
+ readOnly: true,
+ },
+ ],
+ });
+ CalDAVServer.close();
+});
+
+/**
+ * Tests calendars that return none of the expected values for "current-user-privilege-set"
+ * are flagged read-only.
+ */
+add_task(async function testCalendarWithoutPrivs() {
+ CalDAVServer.open("alice", "alice");
+ CalDAVServer.privileges = "";
+ await openWizard({
+ username: "alice",
+ url: CalDAVServer.origin,
+ password: "alice",
+ expectedCalendars: [
+ {
+ uri: CalDAVServer.url,
+ name: "CalDAV Test",
+ color: "rgb(255, 128, 0)",
+ readOnly: true,
+ },
+ ],
+ });
+ CalDAVServer.close();
+});
+
+/**
+ * Tests calendars that return status 404 for "current-user-privilege-set" are
+ * not flagged read-only.
+ */
+add_task(async function testCalendarWithNoPrivSupport() {
+ CalDAVServer.open("alice", "alice");
+ CalDAVServer.privileges = null;
+ await openWizard({
+ username: "alice",
+ url: CalDAVServer.origin,
+ password: "alice",
+ expectedCalendars: [
+ {
+ uri: CalDAVServer.url,
+ name: "CalDAV Test",
+ color: "rgb(255, 128, 0)",
+ readOnly: false,
+ },
+ ],
+ });
+ CalDAVServer.close();
+});
diff --git a/comm/calendar/test/browser/browser_calDAV_oAuth.js b/comm/calendar/test/browser/browser_calDAV_oAuth.js
new file mode 100644
index 0000000000..4d9c733076
--- /dev/null
+++ b/comm/calendar/test/browser/browser_calDAV_oAuth.js
@@ -0,0 +1,201 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Creates calendars in various configurations (current and legacy) and performs
+// requests in each of them to prove that OAuth2 authentication is working as expected.
+
+var { CalDavCalendar } = ChromeUtils.import("resource:///modules/CalDavCalendar.jsm");
+var { CalDavGenericRequest } = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm");
+
+var LoginInfo = Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1",
+ Ci.nsILoginInfo,
+ "init"
+);
+
+// Ideal login info. This is what would be saved if you created a new calendar.
+const ORIGIN = "oauth://mochi.test";
+const SCOPE = "test_scope";
+const USERNAME = "bob@test.invalid";
+const VALID_TOKEN = "bobs_refresh_token";
+
+/**
+ * Set a string pref for the given calendar.
+ *
+ * @param {string} calendarId
+ * @param {string} key
+ * @param {string} value
+ */
+function setPref(calendarId, key, value) {
+ Services.prefs.setStringPref(`calendar.registry.${calendarId}.${key}`, value);
+}
+
+/**
+ * Clear any existing saved logins and add the given ones.
+ *
+ * @param {string[][]} - Zero or more arrays consisting of origin, realm, username, and password.
+ */
+function setLogins(...logins) {
+ Services.logins.removeAllLogins();
+ for (let [origin, realm, username, password] of logins) {
+ Services.logins.addLogin(new LoginInfo(origin, null, realm, username, password, "", ""));
+ }
+}
+
+/**
+ * Create a calendar with the given id, perform a request, and check that the correct
+ * authorisation header was used. If the user is required to re-authenticate with the provider,
+ * check that the new token is stored in the right place.
+ *
+ * @param {string} calendarId - ID of the new calendar
+ * @param {string} [newTokenUsername] - If given, re-authentication must happen and the new token
+ * stored with this user name.
+ */
+async function subtest(calendarId, newTokenUsername) {
+ let calendar = new CalDavCalendar();
+ calendar.id = calendarId;
+
+ let request = new CalDavGenericRequest(
+ calendar.wrappedJSObject.session,
+ calendar,
+ "GET",
+ Services.io.newURI(
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/auth_headers.sjs"
+ )
+ );
+ let response = await request.commit();
+ let headers = JSON.parse(response.text);
+
+ if (newTokenUsername) {
+ Assert.equal(headers.authorization, "Bearer new_access_token");
+
+ let logins = Services.logins
+ .findLogins(ORIGIN, null, SCOPE)
+ .filter(l => l.username == newTokenUsername);
+ Assert.equal(logins.length, 1);
+ Assert.equal(logins[0].username, newTokenUsername);
+ Assert.equal(logins[0].password, "new_refresh_token");
+ } else {
+ Assert.equal(headers.authorization, "Bearer bobs_access_token");
+ }
+
+ Services.logins.removeAllLogins();
+}
+
+// Test making a request when there is no matching token stored.
+
+/** No token stored, no username or session ID set. */
+add_task(function testCalendarOAuth_id_none() {
+ let calendarId = "testCalendarOAuth_id_none";
+ return subtest(calendarId, calendarId);
+});
+
+/** No token stored, session ID set. */
+add_task(function testCalendarOAuth_sessionId_none() {
+ let calendarId = "testCalendarOAuth_sessionId_none";
+ setPref(calendarId, "sessionId", "test_session");
+ return subtest(calendarId, "test_session");
+});
+
+/** No token stored, username set. */
+add_task(function testCalendarOAuth_username_none() {
+ let calendarId = "testCalendarOAuth_username_none";
+ setPref(calendarId, "username", USERNAME);
+ return subtest(calendarId, USERNAME);
+});
+
+// Test making a request when there IS a matching token, but the server rejects it.
+// Currently a new token is not requested on failure.
+
+/** Expired token stored with calendar ID. */
+add_task(function testCalendarOAuth_id_expired() {
+ let calendarId = "testCalendarOAuth_id_expired";
+ setLogins([`oauth:${calendarId}`, "Google CalDAV v2", calendarId, "expired_token"]);
+ return subtest(calendarId, calendarId);
+}).skip(); // Broken.
+
+/** Expired token stored with session ID. */
+add_task(function testCalendarOAuth_sessionId_expired() {
+ let calendarId = "testCalendarOAuth_sessionId_expired";
+ setPref(calendarId, "sessionId", "test_session");
+ setLogins(["oauth:test_session", "Google CalDAV v2", "test_session", "expired_token"]);
+ return subtest(calendarId, "test_session");
+}).skip(); // Broken.
+
+/** Expired token stored with calendar ID, username set. */
+add_task(function testCalendarOAuth_username_expired() {
+ let calendarId = "testCalendarOAuth_username_expired";
+ setPref(calendarId, "username", USERNAME);
+ setLogins([`oauth:${calendarId}`, "Google CalDAV v2", calendarId, "expired_token"]);
+ return subtest(calendarId, USERNAME);
+}).skip(); // Broken.
+
+// Test making a request with a valid token, using Lightning's client ID and secret.
+
+/** Valid token stored with calendar ID. */
+add_task(function testCalendarOAuth_id_valid() {
+ let calendarId = "testCalendarOAuth_id_valid";
+ setLogins([`oauth:${calendarId}`, "Google CalDAV v2", calendarId, VALID_TOKEN]);
+ return subtest(calendarId);
+});
+
+/** Valid token stored with session ID. */
+add_task(function testCalendarOAuth_sessionId_valid() {
+ let calendarId = "testCalendarOAuth_sessionId_valid";
+ setPref(calendarId, "sessionId", "test_session");
+ setLogins(["oauth:test_session", "Google CalDAV v2", "test_session", VALID_TOKEN]);
+ return subtest(calendarId);
+});
+
+/** Valid token stored with calendar ID, username set. */
+add_task(function testCalendarOAuth_username_valid() {
+ let calendarId = "testCalendarOAuth_username_valid";
+ setPref(calendarId, "username", USERNAME);
+ setLogins([`oauth:${calendarId}`, "Google CalDAV v2", calendarId, VALID_TOKEN]);
+ return subtest(calendarId, USERNAME);
+});
+
+// Test making a request with a valid token, using Thunderbird's client ID and secret.
+
+/** Valid token stored with calendar ID. */
+add_task(function testCalendarOAuthTB_id_valid() {
+ let calendarId = "testCalendarOAuthTB_id_valid";
+ setLogins([ORIGIN, SCOPE, calendarId, VALID_TOKEN]);
+ return subtest(calendarId);
+});
+
+/** Valid token stored with session ID. */
+add_task(function testCalendarOAuthTB_sessionId_valid() {
+ let calendarId = "testCalendarOAuthTB_sessionId_valid";
+ setPref(calendarId, "sessionId", "test_session");
+ setLogins([ORIGIN, SCOPE, "test_session", VALID_TOKEN]);
+ return subtest(calendarId);
+});
+
+/** Valid token stored with calendar ID, username set. */
+add_task(function testCalendarOAuthTB_username_valid() {
+ let calendarId = "testCalendarOAuthTB_username_valid";
+ setPref(calendarId, "username", USERNAME);
+ setLogins([ORIGIN, SCOPE, calendarId, VALID_TOKEN]);
+ return subtest(calendarId, USERNAME);
+});
+
+/** Valid token stored with username, exact scope. */
+add_task(function testCalendarOAuthTB_username_validSingle() {
+ let calendarId = "testCalendarOAuthTB_username_validSingle";
+ setPref(calendarId, "username", USERNAME);
+ setLogins(
+ [ORIGIN, SCOPE, USERNAME, VALID_TOKEN],
+ [ORIGIN, "other_scope", USERNAME, "other_refresh_token"]
+ );
+ return subtest(calendarId);
+});
+
+/** Valid token stored with username, many scopes. */
+add_task(function testCalendarOAuthTB_username_validMultiple() {
+ let calendarId = "testCalendarOAuthTB_username_validMultiple";
+ setPref(calendarId, "username", USERNAME);
+ setLogins([ORIGIN, "scope test_scope other_scope", USERNAME, VALID_TOKEN]);
+ return subtest(calendarId);
+});
diff --git a/comm/calendar/test/browser/browser_calendarList.js b/comm/calendar/test/browser/browser_calendarList.js
new file mode 100644
index 0000000000..b85ef5a56e
--- /dev/null
+++ b/comm/calendar/test/browser/browser_calendarList.js
@@ -0,0 +1,341 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+async function calendarListContextMenu(target, menuItem) {
+ await new Promise(r => setTimeout(r));
+ window.focus();
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == window,
+ "waiting for window to be focused"
+ );
+
+ // The test frequently times out if we don't wait here. Unknown why.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+
+ let contextMenu = document.getElementById("list-calendars-context-menu");
+ let shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(target, { type: "contextmenu" });
+ await shownPromise;
+
+ if (menuItem) {
+ let hiddenPromise = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ contextMenu.activateItem(document.getElementById(menuItem));
+ await hiddenPromise;
+ }
+}
+
+async function withMockPromptService(response, callback) {
+ let realPrompt = Services.prompt;
+ Services.prompt = {
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ confirmEx: (unused1, unused2, text) => {
+ info(text);
+ return response;
+ },
+ };
+ await callback();
+ Services.prompt = realPrompt;
+}
+
+add_task(async () => {
+ function checkProperties(index, expected) {
+ let calendarList = document.getElementById("calendar-list");
+ let item = calendarList.rows[index];
+ let colorImage = item.querySelector(".calendar-color");
+ for (let [key, expectedValue] of Object.entries(expected)) {
+ switch (key) {
+ case "id":
+ Assert.equal(item.getAttribute("calendar-id"), expectedValue);
+ break;
+ case "disabled":
+ Assert.equal(item.querySelector(".calendar-displayed").hidden, expectedValue);
+ break;
+ case "displayed":
+ Assert.equal(item.querySelector(".calendar-displayed").checked, expectedValue);
+ break;
+ case "color":
+ if (item.hasAttribute("calendar-disabled")) {
+ Assert.equal(getComputedStyle(colorImage).backgroundColor, "rgba(0, 0, 0, 0)");
+ } else {
+ Assert.equal(getComputedStyle(colorImage).backgroundColor, expectedValue);
+ }
+ break;
+ case "name":
+ Assert.equal(item.querySelector(".calendar-name").textContent, expectedValue);
+ break;
+ }
+ }
+ }
+
+ function checkDisplayed(...expected) {
+ let calendarList = document.getElementById("calendar-list");
+ Assert.greater(calendarList.rowCount, Math.max(...expected));
+ for (let i = 0; i < calendarList.rowCount; i++) {
+ Assert.equal(
+ calendarList.rows[i].querySelector(".calendar-displayed").checked,
+ expected.includes(i)
+ );
+ }
+ }
+
+ function checkSortOrder(...expected) {
+ let orderPref = Services.prefs.getStringPref("calendar.list.sortOrder", "wrong");
+ Assert.notEqual(orderPref, "wrong", "sort order pref has a value");
+ let order = orderPref.split(" ");
+ Assert.equal(order.length, expected.length, "sort order length");
+ for (let i = 0; i < expected.length; i++) {
+ Assert.equal(order[i], calendars[expected[i]].id, "sort order ids");
+ }
+ }
+
+ let calendarList = document.getElementById("calendar-list");
+ let contextMenu = document.getElementById("list-calendars-context-menu");
+ let composite = cal.view.getCompositeCalendar(window);
+
+ await CalendarTestUtils.openCalendarTab(window);
+
+ // Check the default calendar.
+ let calendars = cal.manager.getCalendars();
+ Assert.equal(calendars.length, 1);
+ Assert.equal(calendarList.rowCount, 1);
+ checkProperties(0, {
+ color: "rgb(168, 194, 225)",
+ name: "Home",
+ });
+ checkSortOrder(0);
+
+ // Test adding calendars.
+
+ // Open and then cancel the 'create calendar' dialog, just to prove that the
+ // context menu works.
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ "cancel",
+ "chrome://calendar/content/calendar-creation.xhtml"
+ );
+ calendarListContextMenu(calendarList, "list-calendars-context-new");
+ await dialogPromise;
+
+ // Add some new calendars, check their properties.
+ for (let i = 1; i <= 3; i++) {
+ calendars[i] = CalendarTestUtils.createCalendar(`Mochitest ${i}`, "memory");
+ }
+
+ Assert.equal(cal.manager.getCalendars().length, 4);
+ Assert.equal(calendarList.rowCount, 4);
+
+ for (let i = 1; i <= 3; i++) {
+ checkProperties(i, {
+ id: calendars[i].id,
+ displayed: true,
+ color: "rgb(168, 194, 225)",
+ name: `Mochitest ${i}`,
+ });
+ }
+ checkSortOrder(0, 1, 2, 3);
+
+ // Test the context menu.
+
+ await new Promise(resolve => setTimeout(resolve));
+ EventUtils.synthesizeMouseAtCenter(calendarList.rows[1], {});
+ await new Promise(resolve => setTimeout(resolve));
+ await calendarListContextMenu(calendarList.rows[1]);
+ await new Promise(resolve => setTimeout(resolve));
+ Assert.equal(
+ document.getElementById("list-calendars-context-togglevisible").label,
+ "Hide Mochitest 1"
+ );
+ Assert.equal(
+ document.getElementById("list-calendars-context-showonly").label,
+ "Show Only Mochitest 1"
+ );
+ Assert.ok(
+ document.getElementById("list-calendar-context-reload").hidden,
+ "Local calendar should have reload menu showing"
+ );
+ contextMenu.hidePopup();
+
+ Assert.equal(document.activeElement, calendarList);
+ Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[1]);
+
+ // Test show/hide.
+ // TODO: Check events on calendars are hidden/shown.
+
+ EventUtils.synthesizeMouseAtCenter(calendarList.rows[2].querySelector(".calendar-displayed"), {});
+ Assert.equal(document.activeElement, calendarList);
+ Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[2]);
+ Assert.equal(composite.getCalendarById(calendars[2].id), null);
+ checkDisplayed(0, 1, 3);
+
+ composite.removeCalendar(calendars[1]);
+ checkDisplayed(0, 3);
+
+ await calendarListContextMenu(calendarList.rows[3], "list-calendars-context-togglevisible");
+ checkDisplayed(0);
+
+ EventUtils.synthesizeMouseAtCenter(calendarList.rows[2].querySelector(".calendar-displayed"), {});
+ Assert.equal(composite.getCalendarById(calendars[2].id), calendars[2]);
+ Assert.equal(document.activeElement, calendarList);
+ Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[2]);
+ checkDisplayed(0, 2);
+
+ composite.addCalendar(calendars[1]);
+ checkDisplayed(0, 1, 2);
+
+ await calendarListContextMenu(calendarList.rows[3], "list-calendars-context-togglevisible");
+ checkDisplayed(0, 1, 2, 3);
+
+ await calendarListContextMenu(calendarList.rows[1], "list-calendars-context-showonly");
+ checkDisplayed(1);
+
+ await calendarListContextMenu(calendarList, "list-calendars-context-showall");
+ checkDisplayed(0, 1, 2, 3);
+
+ // Test editing calendars.
+
+ dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-properties-dialog.xhtml",
+ {
+ callback(win) {
+ let doc = win.document;
+ let nameElement = doc.getElementById("calendar-name");
+ let colorElement = doc.getElementById("calendar-color");
+ Assert.equal(nameElement.value, "Mochitest 1");
+ Assert.equal(colorElement.value, "#a8c2e1");
+ nameElement.value = "A New Calendar!";
+ colorElement.value = "#009900";
+ doc.querySelector("dialog").getButton("accept").click();
+ },
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(calendarList.rows[1], { clickCount: 2 });
+ await dialogPromise;
+
+ Assert.equal(document.activeElement, calendarList);
+ Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[1]);
+ checkProperties(1, {
+ color: "rgb(0, 153, 0)",
+ name: "A New Calendar!",
+ });
+
+ dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-properties-dialog.xhtml",
+ {
+ callback(win) {
+ let doc = win.document;
+ let nameElement = doc.getElementById("calendar-name");
+ let colorElement = doc.getElementById("calendar-color");
+ Assert.equal(nameElement.value, "A New Calendar!");
+ Assert.equal(colorElement.value, "#009900");
+ nameElement.value = "Mochitest 1";
+ doc.querySelector("dialog").getButton("accept").click();
+ },
+ }
+ );
+ calendarListContextMenu(calendarList.rows[1], "list-calendars-context-edit");
+ await dialogPromise;
+
+ Assert.equal(document.activeElement, calendarList);
+ Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[1]);
+ checkProperties(1, {
+ color: "rgb(0, 153, 0)",
+ name: "Mochitest 1",
+ });
+
+ dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-properties-dialog.xhtml",
+ {
+ callback(win) {
+ let doc = win.document;
+ Assert.equal(doc.getElementById("calendar-name").value, "Mochitest 3");
+ let enabledElement = doc.getElementById("calendar-enabled-checkbox");
+ Assert.ok(enabledElement.checked);
+ enabledElement.checked = false;
+ doc.querySelector("dialog").getButton("accept").click();
+ },
+ }
+ );
+ // We're clicking on an item that wasn't the selected one. Selection should be updated.
+ calendarListContextMenu(calendarList.rows[3], "list-calendars-context-edit");
+ await dialogPromise;
+
+ Assert.equal(document.activeElement, calendarList);
+ Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[3]);
+ checkProperties(3, { disabled: true });
+
+ calendars[3].setProperty("disabled", false);
+ checkProperties(3, { disabled: false });
+
+ // Test reordering calendars.
+
+ let dragSession = Cc["@mozilla.org/widget/dragservice;1"].getService(Ci.nsIDragService);
+ dragSession.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_MOVE);
+
+ await new Promise(resolve => window.setTimeout(resolve));
+
+ let [result, dataTransfer] = EventUtils.synthesizeDragOver(
+ calendarList.rows[3],
+ calendarList.rows[0],
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ {
+ screenY: calendarList.rows[0].getBoundingClientRect().top + 1,
+ }
+ );
+ await new Promise(resolve => setTimeout(resolve));
+
+ EventUtils.synthesizeDropAfterDragOver(result, dataTransfer, calendarList.rows[0]);
+ EventUtils.sendDragEvent({ type: "dragend" }, calendarList.rows[0]);
+ dragSession.endDragSession(true);
+ await new Promise(resolve => setTimeout(resolve));
+
+ checkSortOrder(3, 0, 1, 2);
+
+ Assert.equal(document.activeElement, calendarList);
+ Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[0]);
+
+ // Test deleting calendars.
+
+ // Delete a calendar by unregistering it.
+ CalendarTestUtils.removeCalendar(calendars[3]);
+ Assert.equal(cal.manager.getCalendars().length, 3);
+ Assert.equal(calendarList.rowCount, 3);
+ checkSortOrder(0, 1, 2);
+
+ // Start to remove a calendar. Cancel the prompt.
+ EventUtils.synthesizeMouseAtCenter(calendarList.rows[1], {});
+ await withMockPromptService(1, () => {
+ EventUtils.synthesizeKey("VK_DELETE");
+ });
+ Assert.equal(cal.manager.getCalendars().length, 3, "three calendars left in the manager");
+ Assert.equal(calendarList.rowCount, 3, "three calendars left in the list");
+ checkSortOrder(0, 1, 2);
+
+ // Remove a calendar with the keyboard.
+ await withMockPromptService(0, () => {
+ EventUtils.synthesizeKey("VK_DELETE");
+ });
+ Assert.equal(cal.manager.getCalendars().length, 2, "two calendars left in the manager");
+ Assert.equal(calendarList.rowCount, 2, "two calendars left in the list");
+ checkSortOrder(0, 2);
+
+ // Remove a calendar with the context menu.
+ await withMockPromptService(0, async () => {
+ EventUtils.synthesizeMouseAtCenter(calendarList.rows[1], {});
+ await calendarListContextMenu(calendarList.rows[1], "list-calendars-context-delete");
+ });
+
+ Assert.equal(cal.manager.getCalendars().length, 1, "one calendar left in the manager");
+ Assert.equal(calendarList.rowCount, 1, "one calendar left in the list");
+ checkSortOrder(0);
+
+ Assert.equal(composite.defaultCalendar.id, calendars[0].id, "default calendar id check");
+ Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[0]);
+ await CalendarTestUtils.closeCalendarTab(window);
+});
diff --git a/comm/calendar/test/browser/browser_calendarTelemetry.js b/comm/calendar/test/browser/browser_calendarTelemetry.js
new file mode 100644
index 0000000000..cf2dff7a55
--- /dev/null
+++ b/comm/calendar/test/browser/browser_calendarTelemetry.js
@@ -0,0 +1,119 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test telemetry related to calendar.
+ */
+
+let { MailTelemetryForTests } = ChromeUtils.import("resource:///modules/MailGlue.jsm");
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+/**
+ * Check that we're counting calendars and read only calendars.
+ */
+add_task(async function testCalendarCount() {
+ Services.telemetry.clearScalars();
+
+ let calendars = cal.manager.getCalendars();
+ let homeCal = calendars.find(cal => cal.name == "Home");
+ let readOnly = homeCal.readOnly;
+ homeCal.readOnly = true;
+
+ for (let i = 1; i <= 3; i++) {
+ calendars[i] = CalendarTestUtils.createCalendar(`Mochitest ${i}`, "memory");
+ if (i === 1 || i === 3) {
+ calendars[i].readOnly = true;
+ }
+ }
+
+ await MailTelemetryForTests.reportCalendars();
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.equal(
+ scalars["tb.calendar.calendar_count"].memory,
+ 3,
+ "Count of calendars must be correct."
+ );
+ Assert.equal(
+ scalars["tb.calendar.read_only_calendar_count"].memory,
+ 2,
+ "Count of readonly calendars must be correct."
+ );
+
+ Assert.ok(
+ !scalars["tb.calendar.calendar_count"].storage,
+ "'Home' calendar not included in count while disabled"
+ );
+
+ Assert.ok(
+ !scalars["tb.calendar.read_only_calendar_count"].storage,
+ "'Home' calendar not included in read-only count while disabled"
+ );
+
+ for (let i = 1; i <= 3; i++) {
+ CalendarTestUtils.removeCalendar(calendars[i]);
+ }
+ homeCal.readOnly = readOnly;
+});
+
+/**
+ * Ensure the "Home" calendar is not ignored if it has been used.
+ */
+add_task(async function testHomeCalendar() {
+ let calendar = cal.manager.getCalendars().find(cal => cal.name == "Home");
+ let readOnly = calendar.readOnly;
+ let disabled = calendar.getProperty("disabled");
+
+ // Test when enabled with no events.
+ calendar.setProperty("disabled", false);
+ calendar.readOnly = true;
+ Services.telemetry.clearScalars();
+ await MailTelemetryForTests.reportCalendars();
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.ok(!scalars["tb.calendar.calendar_count"], "'Home' calendar not counted when unused");
+ Assert.ok(
+ !scalars["tb.calendar.read_only_calendar_count"],
+ "'Home' calendar not included in readonly count when unused"
+ );
+
+ // Now test with an event added to the calendar.
+ calendar.readOnly = false;
+
+ let event = new CalEvent();
+ event.id = "bacd";
+ event.title = "Test";
+ event.startDate = cal.dtz.now();
+ event = await calendar.addItem(event);
+
+ calendar.readOnly = true;
+
+ await TestUtils.waitForCondition(async () => {
+ let result = await calendar.getItem("bacd");
+ return result;
+ }, "item added to calendar");
+
+ Services.telemetry.clearScalars();
+ await MailTelemetryForTests.reportCalendars();
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.equal(
+ scalars["tb.calendar.calendar_count"].storage,
+ 1,
+ "'Home' calendar counted when there are items"
+ );
+ Assert.equal(
+ scalars["tb.calendar.read_only_calendar_count"].storage,
+ 1,
+ "'Home' calendar included in read-only count when used"
+ );
+
+ calendar.readOnly = false;
+ await calendar.deleteItem(event);
+ calendar.readOnly = readOnly;
+ calendar.setProperty("disabled", disabled);
+});
diff --git a/comm/calendar/test/browser/browser_calendarUnifinder.js b/comm/calendar/test/browser/browser_calendarUnifinder.js
new file mode 100644
index 0000000000..7020b70f71
--- /dev/null
+++ b/comm/calendar/test/browser/browser_calendarUnifinder.js
@@ -0,0 +1,76 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+});
+
+/**
+ * Tests clicking on events opens in the summary dialog for both
+ * non-recurring and recurring events.
+ */
+add_task(async function testOpenEvent() {
+ let uri = Services.io.newURI("moz-memory-calendar://");
+ let calendar = cal.manager.createCalendar("memory", uri);
+
+ calendar.name = "Unifinder Test";
+ cal.manager.registerCalendar(calendar);
+ registerCleanupFunction(() => cal.manager.removeCalendar(calendar));
+
+ let now = cal.dtz.now();
+
+ let noRepeatEvent = new CalEvent();
+ noRepeatEvent.id = "no repeat event";
+ noRepeatEvent.title = "No Repeat Event";
+ noRepeatEvent.startDate = now;
+ noRepeatEvent.endDate = noRepeatEvent.startDate.clone();
+ noRepeatEvent.endDate.hour++;
+
+ let repeatEvent = new CalEvent();
+ repeatEvent.id = "repeated event";
+ repeatEvent.title = "Repeat Event";
+ repeatEvent.startDate = now;
+ repeatEvent.endDate = noRepeatEvent.startDate.clone();
+ repeatEvent.endDate.hour++;
+ repeatEvent.recurrenceInfo = new CalRecurrenceInfo(repeatEvent);
+ repeatEvent.recurrenceInfo.appendRecurrenceItem(
+ cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=30")
+ );
+
+ await CalendarTestUtils.openCalendarTab(window);
+
+ if (window.isUnifinderHidden()) {
+ window.toggleUnifinder();
+
+ await BrowserTestUtils.waitForCondition(
+ () => window.isUnifinderHidden(),
+ "calendar unifinder is open"
+ );
+ }
+
+ for (let event of [noRepeatEvent, repeatEvent]) {
+ await calendar.addItem(event);
+
+ let dialogWindowPromise = CalendarTestUtils.waitForEventDialog();
+ let tree = document.querySelector("#unifinder-search-results-tree");
+ mailTestUtils.treeClick(EventUtils, window, tree, 0, 1, { clickCount: 2 });
+
+ let dialogWindow = await dialogWindowPromise;
+ let docUri = dialogWindow.document.documentURI;
+ Assert.ok(
+ docUri === "chrome://calendar/content/calendar-summary-dialog.xhtml",
+ "event summary dialog did show"
+ );
+
+ await BrowserTestUtils.closeWindow(dialogWindow);
+ await calendar.deleteItem(event);
+ }
+});
diff --git a/comm/calendar/test/browser/browser_dragEventItem.js b/comm/calendar/test/browser/browser_dragEventItem.js
new file mode 100644
index 0000000000..204e429fdd
--- /dev/null
+++ b/comm/calendar/test/browser/browser_dragEventItem.js
@@ -0,0 +1,414 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test dragging of events in the various calendar views.
+ */
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+const calendar = CalendarTestUtils.createCalendar("Drag Test", "memory");
+// Set a low number of hours to reduce pixel -> minute rounding errors.
+Services.prefs.setIntPref("calendar.view.visiblehours", 3);
+
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ Services.prefs.clearUserPref("calendar.view.visiblehours");
+ // Reset the spaces toolbar to its default visible state.
+ window.gSpacesToolbar.toggleToolbar(false);
+});
+
+/**
+ * Ensures that the window is maximised after switching dates.
+ *
+ * @param {calIDateTime} date - A date to navigate the view to.
+ */
+async function resetView(date, view) {
+ window.goToDate(date);
+
+ if (window.windowState != window.STATE_MAXIMIZED) {
+ // The multi-day views adjust scrolling dynamically when they detect a
+ // resize. Hook into the resize event and scroll after the adjustment.
+ let resizePromise = BrowserTestUtils.waitForEvent(window, "resize");
+ window.maximize();
+ await resizePromise;
+ }
+}
+
+/**
+ * End the dragging of the event at the specified location.
+ *
+ * @param {number} day - The day to drop into.
+ * @param {number} hour - The starting hour to drop to.
+ * @param {number} topOffset - An offset to apply to the mouse position.
+ */
+function endDrag(day, hour, topOffset) {
+ let view = window.currentView();
+ let hourElement;
+ if (view.id == "day-view") {
+ hourElement = CalendarTestUtils.dayView.getHourBoxAt(window, hour);
+ } else {
+ hourElement = CalendarTestUtils.weekView.getHourBoxAt(window, day, hour);
+ }
+ // We scroll to align the *end* of the hour element so we can avoid triggering
+ // the auto-scroll when we synthesize mousemove below.
+ // FIXME: Use and test auto scroll by holding mouseover at the view edges.
+ CalendarTestUtils.scrollViewToTarget(hourElement, false);
+
+ let hourRect = hourElement.getBoundingClientRect();
+
+ // We drop the event with some offset from the starting edge of the desired
+ // hourElement.
+ // NOTE: This may mean that the drop point may not be above the hourElement.
+ // NOTE: We assume that the drop point is however still above the view.
+ // Currently event "move" events get cancelled if the pointer leaves the view.
+ let top = Math.round(hourRect.top + topOffset);
+ let left = Math.round(hourRect.left + hourRect.width / 2);
+
+ EventUtils.synthesizeMouseAtPoint(left, top, { type: "mousemove", shiftKey: true }, window);
+ EventUtils.synthesizeMouseAtPoint(left, top, { type: "mouseup", shiftKey: true }, window);
+}
+
+/**
+ * Simulates the dragging of an event box in a multi-day view to another
+ * column, horizontally.
+ *
+ * @param {MozCalendarEventBox} eventBox - The event to start moving.
+ * @param {number} day - The day to drop into.
+ * @param {number} hour - The starting hour to drop to.
+ */
+function simulateDragToColumn(eventBox, day, hour) {
+ // Scroll to align to the top of the view.
+ CalendarTestUtils.scrollViewToTarget(eventBox, true);
+
+ let sourceRect = eventBox.getBoundingClientRect();
+ // Start dragging from the center of the event box to avoid the gripbars.
+ // NOTE: We assume that the eventBox's center is in view.
+ let leftOffset = sourceRect.width / 2;
+ // We round the mouse position to try and reduce rounding errors when
+ // scrolling the view.
+ let sourceTop = Math.round(sourceRect.top + sourceRect.height / 2);
+ let sourceLeft = sourceRect.left + leftOffset;
+ // Keep track of the exact offset.
+ let topOffset = sourceTop - sourceRect.top;
+
+ EventUtils.synthesizeMouseAtPoint(
+ sourceLeft,
+ sourceTop,
+ // Hold shift to avoid snapping.
+ { type: "mousedown", shiftKey: true },
+ window
+ );
+ EventUtils.synthesizeMouseAtPoint(
+ // We assume the location of the mouseout event does not matter, just as
+ // long as the event box receives it.
+ sourceLeft,
+ sourceTop,
+ { type: "mouseout", shiftKey: true },
+ window
+ );
+
+ // End drag with the same offset from the starting edge.
+ endDrag(day, hour, topOffset);
+}
+
+/**
+ * Simulates the dragging of an event box via one of the gripbars.
+ *
+ * @param {MozCalendarEventBox} eventBox - The event to resize.
+ * @param {"start"|"end"} - The side to grab.
+ * @param {number} day - The day to move into.
+ * @param {number} hour - The hour to move to.
+ */
+function simulateGripbarDrag(eventBox, side, day, hour) {
+ // Scroll the edge of the box into view.
+ CalendarTestUtils.scrollViewToTarget(eventBox, side == "start");
+
+ let gripbar = side == "start" ? eventBox.startGripbar : eventBox.endGripbar;
+
+ let sourceRect = gripbar.getBoundingClientRect();
+ let sourceTop = sourceRect.top + sourceRect.height / 2;
+ let sourceLeft = sourceRect.left + sourceRect.width / 2;
+
+ // Hover to make the gripbar visible.
+ EventUtils.synthesizeMouseAtPoint(sourceLeft, sourceTop, { type: "mouseover" }, window);
+ EventUtils.synthesizeMouseAtPoint(
+ sourceLeft,
+ sourceTop,
+ // Hold shift to avoid snapping.
+ { type: "mousedown", shiftKey: true },
+ window
+ );
+
+ // End the drag at the start of the hour.
+ endDrag(day, hour, 0);
+}
+
+/**
+ * Tests dragging an event item updates the event in the month view.
+ */
+add_task(async function testMonthViewDragEventItem() {
+ let event = new CalEvent();
+ event.id = "1";
+ event.title = "Month View Event";
+ event.startDate = cal.createDateTime("20210316T000000Z");
+ event.endDate = cal.createDateTime("20210316T110000Z");
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ await calendar.addItem(event);
+ await resetView(event.startDate);
+
+ // Hide the spaces toolbar since it interferes with the calendar
+ window.gSpacesToolbar.toggleToolbar(true);
+
+ let eventItem = await CalendarTestUtils.monthView.waitForItemAt(window, 3, 3, 1);
+ let dayBox = await CalendarTestUtils.monthView.getDayBox(window, 3, 2);
+ let dragSession = Cc["@mozilla.org/widget/dragservice;1"].getService(Ci.nsIDragService);
+ dragSession.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_MOVE);
+
+ let [result, dataTransfer] = EventUtils.synthesizeDragOver(
+ eventItem,
+ dayBox,
+ undefined,
+ undefined,
+ eventItem.ownerGlobal,
+ dayBox.ownerGlobal
+ );
+ EventUtils.synthesizeDropAfterDragOver(result, dataTransfer, dayBox);
+ dragSession.endDragSession(true);
+
+ Assert.ok(
+ !CalendarTestUtils.monthView.getItemAt(window, 3, 3, 1),
+ "item removed from initial date"
+ );
+
+ eventItem = await CalendarTestUtils.monthView.waitForItemAt(window, 3, 2, 1);
+ Assert.ok(eventItem, "item moved to new date");
+
+ let { id, title, startDate, endDate } = eventItem.occurrence;
+ Assert.equal(id, event.id, "id is correct");
+ Assert.equal(title, event.title, "title is correct");
+ Assert.equal(startDate.icalString, "20210315T000000Z", "startDate is correct");
+ Assert.equal(endDate.icalString, "20210315T110000Z", "endDate is correct");
+ await calendar.deleteItem(eventItem.occurrence);
+});
+
+/**
+ * Tests dragging an event item updates the event in the multiweek view.
+ */
+add_task(async function testMultiWeekViewDragEventItem() {
+ let event = new CalEvent();
+ event.id = "2";
+ event.title = "Multiweek View Event";
+ event.startDate = cal.createDateTime("20210316T000000Z");
+ event.endDate = cal.createDateTime("20210316T110000Z");
+
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await calendar.addItem(event);
+ await resetView(event.startDate);
+
+ let eventItem = await CalendarTestUtils.multiweekView.waitForItemAt(window, 1, 3, 1);
+ let dayBox = await CalendarTestUtils.multiweekView.getDayBox(window, 1, 2);
+ let dragSession = Cc["@mozilla.org/widget/dragservice;1"].getService(Ci.nsIDragService);
+ dragSession.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_MOVE);
+
+ let [result, dataTransfer] = EventUtils.synthesizeDragOver(
+ eventItem,
+ dayBox,
+ undefined,
+ undefined,
+ eventItem.ownerGlobal,
+ dayBox.ownerGlobal
+ );
+ EventUtils.synthesizeDropAfterDragOver(result, dataTransfer, dayBox);
+ dragSession.endDragSession(true);
+
+ Assert.ok(
+ !CalendarTestUtils.multiweekView.getItemAt(window, 1, 3, 1),
+ "item removed from initial date"
+ );
+
+ eventItem = await CalendarTestUtils.multiweekView.waitForItemAt(window, 1, 2, 1);
+ Assert.ok(eventItem, "item moved to new date");
+
+ let { id, title, startDate, endDate } = eventItem.occurrence;
+ Assert.equal(id, event.id, "id is correct");
+ Assert.equal(title, event.title, "title is correct");
+ Assert.equal(startDate.icalString, "20210315T000000Z", "startDate is correct");
+ Assert.equal(endDate.icalString, "20210315T110000Z", "endDate is correct");
+ await calendar.deleteItem(eventItem.occurrence);
+});
+
+/**
+ * Tests dragging an event box to the previous day updates the event in the
+ * week view.
+ */
+add_task(async function testWeekViewDragEventBoxToPreviousDay() {
+ let event = new CalEvent();
+ event.id = "3";
+ event.title = "Week View Previous Day";
+ event.startDate = cal.createDateTime("20210316T020000Z");
+ event.endDate = cal.createDateTime("20210316T030000Z");
+
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await calendar.addItem(event);
+ await resetView(event.startDate);
+
+ let eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1);
+ simulateDragToColumn(eventBox, 2, 2);
+
+ eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 2, 1);
+ await TestUtils.waitForCondition(
+ () => !CalendarTestUtils.weekView.getEventBoxAt(window, 3, 1),
+ "Old position is empty"
+ );
+
+ let { id, title, startDate, endDate } = eventBox.occurrence;
+ Assert.equal(id, event.id, "id is correct");
+ Assert.equal(title, event.title, "title is correct");
+ Assert.equal(startDate.icalString, "20210315T020000Z", "startDate is correct");
+ Assert.equal(endDate.icalString, "20210315T030000Z", "endDate is correct");
+ await calendar.deleteItem(eventBox.occurrence);
+});
+
+/**
+ * Tests dragging an event box to the following day updates the event in the
+ * week view.
+ */
+add_task(async function testWeekViewDragEventBoxToFollowingDay() {
+ let event = new CalEvent();
+ event.id = "4";
+ event.title = "Week View Following Day";
+ event.startDate = cal.createDateTime("20210316T020000Z");
+ event.endDate = cal.createDateTime("20210316T030000Z");
+
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await calendar.addItem(event);
+ await resetView(event.startDate);
+
+ let eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1);
+ simulateDragToColumn(eventBox, 4, 2);
+
+ eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 4, 1);
+ await TestUtils.waitForCondition(
+ () => !CalendarTestUtils.weekView.getEventBoxAt(window, 3, 1),
+ "Old position is empty"
+ );
+
+ let { id, title, startDate, endDate } = eventBox.occurrence;
+ Assert.equal(id, event.id, "id is correct");
+ Assert.equal(title, event.title, "title is correct");
+ Assert.equal(startDate.icalString, "20210317T020000Z", "startDate is correct");
+ Assert.equal(endDate.icalString, "20210317T030000Z", "endDate is correct");
+ await calendar.deleteItem(eventBox.occurrence);
+});
+
+/**
+ * Tests dragging the top of an event box updates the start time in the week
+ * view.
+ */
+add_task(async function testWeekViewDragEventBoxStartTime() {
+ let event = new CalEvent();
+ event.id = "5";
+ event.title = "Week View Start";
+ event.startDate = cal.createDateTime("20210316T020000Z");
+ event.endDate = cal.createDateTime("20210316T030000Z");
+
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await calendar.addItem(event);
+ await resetView(event.startDate);
+
+ let eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1);
+ simulateGripbarDrag(eventBox, "start", 3, 1);
+ eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1);
+
+ let { id, title, startDate, endDate } = eventBox.occurrence;
+ Assert.equal(id, event.id, "id is correct");
+ Assert.equal(title, event.title, "title is correct");
+ Assert.equal(startDate.icalString, "20210316T010000Z", "startDate was changed");
+ Assert.equal(endDate.icalString, "20210316T030000Z", "endDate did not change");
+ await calendar.deleteItem(eventBox.occurrence);
+});
+
+/**
+ * Tests dragging the end of an event box changes the time in the week view.
+ */
+add_task(async function testWeekViewDragEventBoxEndTime() {
+ let event = new CalEvent();
+ event.id = "6";
+ event.title = "Week View End";
+ event.startDate = cal.createDateTime("20210316T020000Z");
+ event.endDate = cal.createDateTime("20210316T030000Z");
+
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await calendar.addItem(event);
+ await resetView(event.startDate);
+
+ let eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1);
+ simulateGripbarDrag(eventBox, "end", 3, 6);
+ eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1);
+
+ let { id, title, startDate, endDate } = eventBox.occurrence;
+ Assert.equal(id, event.id, "id is correct");
+ Assert.equal(title, event.title, "title is correct");
+ Assert.equal(startDate.icalString, "20210316T020000Z", "startDate did not change");
+ Assert.equal(endDate.icalString, "20210316T060000Z", "endDate was changed");
+ await calendar.deleteItem(eventBox.occurrence);
+});
+
+/**
+ * Tests dragging the top of an event box changes the start time in the day view.
+ */
+add_task(async function testDayViewDragEventBoxStartTime() {
+ let event = new CalEvent();
+ event.id = "7";
+ event.title = "Day View Start";
+ event.startDate = cal.createDateTime("20210316T020000Z");
+ event.endDate = cal.createDateTime("20210316T030000Z");
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await calendar.addItem(event);
+ await resetView(event.startDate);
+
+ let eventBox = await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1);
+ simulateGripbarDrag(eventBox, "start", 1, 1);
+ eventBox = await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1);
+
+ let { id, title, startDate, endDate } = eventBox.occurrence;
+ Assert.equal(id, event.id, "id is correct");
+ Assert.equal(title, event.title, "title is correct");
+ Assert.equal(startDate.icalString, "20210316T010000Z", "startDate was changed");
+ Assert.equal(endDate.icalString, "20210316T030000Z", "endDate did not change");
+ await calendar.deleteItem(eventBox.occurrence);
+});
+
+/**
+ * Tests dragging the bottom of an event box changes the end time in the day
+ * view.
+ */
+add_task(async function testDayViewDragEventBoxEndTime() {
+ let event = new CalEvent();
+ event.id = "8";
+ event.title = "Day View End";
+ event.startDate = cal.createDateTime("20210316T020000Z");
+ event.endDate = cal.createDateTime("20210316T030000Z");
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await calendar.addItem(event);
+ await resetView(event.startDate);
+
+ let eventBox = await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1);
+ simulateGripbarDrag(eventBox, "end", 1, 4);
+ eventBox = await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1);
+
+ let { id, title, startDate, endDate } = eventBox.occurrence;
+ Assert.equal(id, event.id, "id is correct");
+ Assert.equal(title, event.title, "title is correct");
+ Assert.equal(startDate.icalString, "20210316T020000Z", "startDate did not change");
+ Assert.equal(endDate.icalString, "20210316T040000Z", "endDate was changed");
+ await calendar.deleteItem(eventBox.occurrence);
+});
diff --git a/comm/calendar/test/browser/browser_eventDisplay_dayView.js b/comm/calendar/test/browser/browser_eventDisplay_dayView.js
new file mode 100644
index 0000000000..5f0941cac4
--- /dev/null
+++ b/comm/calendar/test/browser/browser_eventDisplay_dayView.js
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+var calendar = CalendarTestUtils.createCalendar();
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+/**
+ * Create an event item in the calendar.
+ *
+ * @param {string} name - The name of the event.
+ * @param {string} start - The date time string for the start of the event.
+ * @param {string} end - The date time string for the end of the event.
+ *
+ * @returns {CalEvent} - The created event.
+ */
+async function createEvent(name, start, end) {
+ let event = new CalEvent();
+ event.title = name;
+ event.startDate = cal.createDateTime(start);
+ event.endDate = cal.createDateTime(end);
+ return calendar.addItem(event);
+}
+
+/**
+ * Assert that there is an event shown on the given date in the day-view.
+ *
+ * @param {object} date - The date to move to.
+ * @param {number} date.day - The day.
+ * @param {number} date.week - The week.
+ * @param {number} date.year - The year.
+ * @param {object} expect - Details about the expected event.
+ * @param {string} expect.name - The event name.
+ * @param {boolean} expect.startInView - Whether the event starts within the
+ * view on the given date.
+ * @param {boolean} expect.endInView - Whether the event ends within the view
+ * on the given date.
+ * @param {string} message - A message to use in assertions.
+ */
+async function assertDayEvent(date, expect, message) {
+ await CalendarTestUtils.goToDate(window, date.year, date.month, date.day);
+ let element = await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1);
+ Assert.equal(
+ element.querySelector(".event-name-label").textContent,
+ expect.name,
+ `Event name should match: ${message}`
+ );
+ await CalendarTestUtils.assertEventBoxDraggable(
+ element,
+ expect.startInView,
+ expect.endInView,
+ message
+ );
+}
+
+/**
+ * Test an event that occurs within one day, in the day view.
+ */
+add_task(async function testInsideDayView() {
+ let event = await createEvent("Test Event", "20190403T123400", "20190403T234500");
+ await CalendarTestUtils.setCalendarView(window, "day");
+ Assert.equal(
+ document.querySelectorAll("#day-view calendar-event-column").length,
+ 1,
+ "1 day column in the day view"
+ );
+
+ // This event is fully within this view.
+ await assertDayEvent(
+ { day: 3, month: 4, year: 2019 },
+ { name: "Test Event", startInView: true, endInView: true },
+ "Single day event"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that starts and ends at midnight, in the day view.
+ */
+add_task(async function testMidnightDayView() {
+ let event = await createEvent("Test Event", "20190403T000000", "20190404T000000");
+ await CalendarTestUtils.setCalendarView(window, "day");
+
+ // This event is fully within this view.
+ await assertDayEvent(
+ { day: 3, month: 4, year: 2019 },
+ { name: "Test Event", startInView: true, endInView: true },
+ "Single midnight event"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that spans multiple days, in the day view.
+ */
+add_task(async function testOutsideDayView() {
+ let event = await createEvent("Test Event", "20190402T123400", "20190404T234500");
+ await CalendarTestUtils.setCalendarView(window, "day");
+
+ // Go to the start of the event. The end of the event is beyond the current view.
+ await assertDayEvent(
+ { day: 2, month: 4, year: 2019 },
+ { name: "Test Event", startInView: true, endInView: false },
+ "First day"
+ );
+
+ // Go to the middle of the event. Both ends of the event are beyond the current view.
+ await assertDayEvent(
+ { day: 3, month: 4, year: 2019 },
+ { name: "Test Event", startInView: false, endInView: false },
+ "Middle day"
+ );
+
+ // Go to the end of the event. The start of the event is beyond the current view.
+ await assertDayEvent(
+ { day: 4, month: 4, year: 2019 },
+ { name: "Test Event", startInView: false, endInView: true },
+ "Last day"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
diff --git a/comm/calendar/test/browser/browser_eventDisplay_multiWeekView.js b/comm/calendar/test/browser/browser_eventDisplay_multiWeekView.js
new file mode 100644
index 0000000000..91ddeec6ac
--- /dev/null
+++ b/comm/calendar/test/browser/browser_eventDisplay_multiWeekView.js
@@ -0,0 +1,275 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+var calendar = CalendarTestUtils.createCalendar();
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+/**
+ * Create an event item in the calendar.
+ *
+ * @param {string} name - The name of the event.
+ * @param {string} start - The date time string for the start of the event.
+ * @param {string} end - The date time string for the end of the event.
+ *
+ * @returns {Promise<CalEvent>} - The created event.
+ */
+function createEvent(name, start, end) {
+ let event = new CalEvent();
+ event.title = name;
+ event.startDate = cal.createDateTime(start);
+ event.endDate = cal.createDateTime(end);
+ return calendar.addItem(event);
+}
+
+/**
+ * Assert that there is a an event in the multiweek or month view between the
+ * expected range, and no events on the other days.
+ *
+ * @param {"multiweek"|"month"} viewName - The view to test.
+ * @param {number} numWeeks - The number of weeks shown in the view.
+ * @param {object} date - The date to move to.
+ * @param {number} date.day - The day.
+ * @param {number} date.week - The week.
+ * @param {number} date.year - The year.
+ * @param {object} expect - Details about the expected event.
+ * @param {string} expect.name - The event name.
+ * @param {number} expect.start - The day that the event should start in the
+ * week. Between 1 and 7.
+ * @param {number} expect.end - The day that the event should end in the week.
+ * @param {boolean} expect.startInView - Whether the event starts within the
+ * view on the given date.
+ * @param {boolean} expect.endInView - Whether the event ends within the view
+ * on the given date.
+ * @param {string} message - A message to use in assertions.
+ */
+async function assertMultiweekEvents(viewName, numWeeks, date, expect, message) {
+ await CalendarTestUtils.goToDate(window, date.year, date.month, date.day);
+ let view =
+ viewName == "multiweek" ? CalendarTestUtils.multiweekView : CalendarTestUtils.monthView;
+
+ // start = (startWeek - 1) * 7 + startDay
+ let startWeek = Math.floor((expect.start - 1) / 7) + 1;
+ let startDay = ((expect.start - 1) % 7) + 1;
+ let endWeek = Math.floor((expect.end - 1) / 7) + 1;
+ let endDay = ((expect.end - 1) % 7) + 1;
+ for (let week = startWeek; week <= endWeek; week++) {
+ let start = week == startWeek ? startDay : 1;
+ let end = week == endWeek ? endDay : 7;
+ for (let day = start; day <= end; day++) {
+ let element = await view.waitForItemAt(window, week, day, 1);
+ Assert.equal(
+ element.querySelector(".event-name-label").textContent,
+ expect.name,
+ `Week ${week}, day ${day} event name should match: ${message}`
+ );
+ let multidayIcon = element.querySelector(".item-type-icon");
+ if (startDay == endDay && week == startWeek && day == startDay) {
+ Assert.equal(multidayIcon.src, "", `Week ${week}, day ${day} icon has no source`);
+ } else if (expect.startInView && week == startWeek && day == startDay) {
+ Assert.equal(
+ multidayIcon.src,
+ "chrome://calendar/skin/shared/event-start.svg",
+ `Week ${week}, day ${day} icon src shows event start: ${message}`
+ );
+ } else if (expect.endInView && week == endWeek && day == endDay) {
+ Assert.equal(
+ multidayIcon.src,
+ "chrome://calendar/skin/shared/event-end.svg",
+ `Week ${week}, day ${day} icon src shows event end: ${message}`
+ );
+ } else {
+ Assert.equal(
+ multidayIcon.src,
+ "chrome://calendar/skin/shared/event-continue.svg",
+ `Week ${week}, day ${day} icon src shows event continue: ${message}`
+ );
+ }
+ }
+ }
+ Assert.equal(
+ numWeeks,
+ document.querySelectorAll(`#${viewName}-view .monthbody tr:not([hidden])`).length,
+ `Should show ${numWeeks} weeks in the view: ${message}`
+ );
+ // Test no events loaded on the other days.
+ for (let week = 1; week <= numWeeks; week++) {
+ for (let day = 1; day <= 7; day++) {
+ if (
+ (week > startWeek && week < endWeek) ||
+ (week == startWeek && day >= startDay) ||
+ (week == endWeek && day <= endDay)
+ ) {
+ continue;
+ }
+ Assert.ok(
+ !view.getItemAt(window, week, day, 1),
+ `Should be no events on day ${day}: ${message}`
+ );
+ }
+ }
+}
+
+/**
+ * Test an event that occurs fully within the multi-week view.
+ */
+add_task(async function testInsideMultiweekView() {
+ let event = await createEvent("Test Event", "20190402T123400", "20190419T234500");
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ Assert.equal(
+ document.querySelectorAll("#multiweek-view tr:not([hidden]) calendar-month-day-box").length,
+ 28,
+ "28 days in the multiweek view"
+ );
+
+ await assertMultiweekEvents(
+ "multiweek",
+ 4,
+ { day: 1, month: 4, year: 2019 },
+ { name: "Test Event", start: 3, end: 20, startInView: true, endInView: true },
+ "3 week event in multiweek view"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that starts and ends at midnight, in the multi-week view.
+ */
+add_task(async function testMidnightMultiweekView() {
+ // Event spans one day.
+ let event = await createEvent("Test Event", "20190402T000000", "20190403T000000");
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+
+ await assertMultiweekEvents(
+ "multiweek",
+ 4,
+ { day: 1, month: 4, year: 2019 },
+ { name: "Test Event", start: 3, end: 3, startInView: true, endInView: true },
+ "one day midnight event in multiweek"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that starts or ends outside the multi-week view.
+ */
+add_task(async function testOutsideMultiweekView() {
+ let event = await createEvent("Test Event", "20190402T123400", "20190507T234500");
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+
+ await assertMultiweekEvents(
+ "multiweek",
+ 4,
+ { day: 11, month: 3, year: 2019 },
+ { name: "Test Event", start: 24, end: 28, startInView: true, endInView: false },
+ "First block in multiweek"
+ );
+
+ await assertMultiweekEvents(
+ "multiweek",
+ 4,
+ { day: 8, month: 4, year: 2019 },
+ { name: "Test Event", start: 1, end: 28, startInView: false, endInView: false },
+ "Middle block in multiweek"
+ );
+
+ await assertMultiweekEvents(
+ "multiweek",
+ 4,
+ { day: 29, month: 4, year: 2019 },
+ { name: "Test Event", start: 1, end: 10, startInView: false, endInView: true },
+ "End block in multiweek"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that occurs within one month, in the month view.
+ */
+add_task(async function testInsideMonthView() {
+ let event = await createEvent("Test Event", "20190702T123400", "20190719T234500");
+ await CalendarTestUtils.setCalendarView(window, "month");
+ Assert.equal(
+ document.querySelectorAll("#month-view tr:not([hidden]) calendar-month-day-box").length,
+ 35,
+ "35 days in the month view"
+ );
+
+ await assertMultiweekEvents(
+ "month",
+ 5,
+ { day: 1, month: 7, year: 2019 },
+ { name: "Test Event", start: 3, end: 20, startInView: true, endInView: true },
+ "Event in single month"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that starts and ends at midnight, in the month view.
+ */
+add_task(async function testMidnightMonthView() {
+ // Event spans three days.
+ let event = await createEvent("Test Event", "20190702T000000", "20190705T000000");
+ await CalendarTestUtils.setCalendarView(window, "month");
+
+ await assertMultiweekEvents(
+ "month",
+ 5,
+ { day: 1, month: 7, year: 2019 },
+ { name: "Test Event", start: 3, end: 5, startInView: true, endInView: true },
+ "3 day midnight event in single month"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that spans multiple months, in the month view.
+ */
+add_task(async function testOutsideMonthView() {
+ let event = await createEvent("Test Event", "20190320T123400", "20190507T234500");
+ await CalendarTestUtils.setCalendarView(window, "month");
+
+ await assertMultiweekEvents(
+ "month",
+ 6,
+ { day: 1, month: 3, year: 2019 },
+ { name: "Test Event", start: 25, end: 42, startInView: true, endInView: false },
+ "First month"
+ );
+
+ await assertMultiweekEvents(
+ "month",
+ 5,
+ { day: 1, month: 4, year: 2019 },
+ { name: "Test Event", start: 1, end: 35, startInView: false, endInView: false },
+ "Middle month"
+ );
+
+ await assertMultiweekEvents(
+ "month",
+ 5,
+ { day: 1, month: 5, year: 2019 },
+ { name: "Test Event", start: 1, end: 10, startInView: false, endInView: true },
+ "End month"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
diff --git a/comm/calendar/test/browser/browser_eventDisplay_weekView.js b/comm/calendar/test/browser/browser_eventDisplay_weekView.js
new file mode 100644
index 0000000000..806105c29a
--- /dev/null
+++ b/comm/calendar/test/browser/browser_eventDisplay_weekView.js
@@ -0,0 +1,151 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+var calendar = CalendarTestUtils.createCalendar();
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+/**
+ * Create an event item in the calendar.
+ *
+ * @param {string} name - The name of the event.
+ * @param {string} start - The date time string for the start of the event.
+ * @param {string} end - The date time string for the end of the event.
+ *
+ * @returns {CalEvent} - The created event.
+ */
+async function createEvent(name, start, end) {
+ let event = new CalEvent();
+ event.title = name;
+ event.startDate = cal.createDateTime(start);
+ event.endDate = cal.createDateTime(end);
+ return calendar.addItem(event);
+}
+
+/**
+ * Assert that there is a an event in the week-view between the expected range,
+ * and no events on the other days.
+ *
+ * @param {object} date - The date to move to.
+ * @param {number} date.day - The day.
+ * @param {number} date.week - The week.
+ * @param {number} date.year - The year.
+ * @param {object} expect - Details about the expected event.
+ * @param {string} expect.name - The event name.
+ * @param {number} expect.start - The day that the event should start in the
+ * week. Between 1 and 7.
+ * @param {number} expect.end - The day that the event should end in the week.
+ * @param {boolean} expect.startInView - Whether the event starts within the
+ * view on the given date.
+ * @param {boolean} expect.endInView - Whether the event ends within the view
+ * on the given date.
+ * @param {string} message - A message to use in assertions.
+ */
+async function assertWeekEvents(date, expect, message) {
+ await CalendarTestUtils.goToDate(window, date.year, date.month, date.day);
+ // First test for expected events since these can take a short while to load,
+ // and we don't want to test for the absence of an event before they show.
+ for (let day = expect.start; day <= expect.end; day++) {
+ let element = await CalendarTestUtils.weekView.waitForEventBoxAt(window, day, 1);
+ Assert.equal(
+ element.querySelector(".event-name-label").textContent,
+ expect.name,
+ `Day ${day} event name should match: ${message}`
+ );
+ let icon = element.querySelector(".item-recurrence-icon");
+ Assert.equal(icon.src, "");
+ Assert.ok(icon.hidden);
+ await CalendarTestUtils.assertEventBoxDraggable(
+ element,
+ expect.startInView && day == expect.start,
+ expect.endInView && day == expect.end,
+ `Day ${day}: ${message}`
+ );
+ }
+ // Test no events loaded on the other days.
+ for (let day = 1; day <= 7; day++) {
+ if (day >= expect.start && day <= expect.end) {
+ continue;
+ }
+ Assert.equal(
+ CalendarTestUtils.weekView.getEventBoxes(window, day).length,
+ 0,
+ `Should be no events on day ${day}: ${message}`
+ );
+ }
+}
+
+/**
+ * Test an event that occurs within one week, in the week view.
+ */
+add_task(async function testInsideWeekView() {
+ let event = await createEvent("Test Event", "20190101T123400", "20190103T234500");
+ await CalendarTestUtils.setCalendarView(window, "week");
+ Assert.equal(
+ document.querySelectorAll("#week-view calendar-event-column").length,
+ 7,
+ "7 day columns in the week view"
+ );
+
+ await assertWeekEvents(
+ { day: 1, month: 1, year: 2019 },
+ { name: "Test Event", start: 3, end: 5, startInView: true, endInView: true },
+ "Single week event"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that starts and ends at midnight, in the week view.
+ */
+add_task(async function testMidnightWeekView() {
+ // Spans three days.
+ let event = await createEvent("Test Event", "20190101T000000", "20190104T000000");
+ await CalendarTestUtils.setCalendarView(window, "week");
+
+ // Midnight-to-midnight event only spans one day even though the end time
+ // matches the starting time of the next day (midnight).
+ await assertWeekEvents(
+ { day: 1, month: 1, year: 2019 },
+ { name: "Test Event", start: 3, end: 5, startInView: true, endInView: true },
+ "Midnight week event"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that spans multiple weeks, in the week view.
+ */
+add_task(async function testOutsideWeekView() {
+ let event = await createEvent("Test Event", "20190402T123400", "20190418T234500");
+ await CalendarTestUtils.setCalendarView(window, "week");
+
+ await assertWeekEvents(
+ { day: 3, month: 4, year: 2019 },
+ { name: "Test Event", start: 3, end: 7, startInView: true, endInView: false },
+ "First week"
+ );
+ await assertWeekEvents(
+ { day: 10, month: 4, year: 2019 },
+ { name: "Test Event", start: 1, end: 7, startInView: false, endInView: false },
+ "Middle week"
+ );
+ await assertWeekEvents(
+ { day: 17, month: 4, year: 2019 },
+ { name: "Test Event", start: 1, end: 5, startInView: false, endInView: true },
+ "Last week"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
diff --git a/comm/calendar/test/browser/browser_eventUndoRedo.js b/comm/calendar/test/browser/browser_eventUndoRedo.js
new file mode 100644
index 0000000000..34ea8d0523
--- /dev/null
+++ b/comm/calendar/test/browser/browser_eventUndoRedo.js
@@ -0,0 +1,260 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Tests for ensuring the undo/redo options are enabled properly when
+ * manipulating events.
+ */
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalTransactionManager: "resource:///modules/CalTransactionManager.jsm",
+});
+
+const calendar = CalendarTestUtils.createCalendar("Undo Redo Test");
+const calTransManager = CalTransactionManager.getInstance();
+
+/**
+ * Checks the value of the "disabled" property for items in either the "Edit"
+ * menu bar or the app menu. Display of the relevant menu is triggered first so
+ * the UI code can update the respective items.
+ *
+ * @param {XULElement} element - The menu item we want to check, if its id begins
+ * with "menu" then we assume it is in the menu
+ * bar, if "appmenu" then the app menu.
+ */
+async function isDisabled(element) {
+ let targetMenu = document.getElementById("menu_EditPopup");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(targetMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("menu_Edit"), {});
+ await shownPromise;
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(targetMenu, "popuphidden");
+ let status = element.disabled;
+ targetMenu.hidePopup();
+ await hiddenPromise;
+ return status;
+}
+
+async function clickItem(element) {
+ let targetMenu = document.getElementById("menu_EditPopup");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(targetMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("menu_Edit"), {});
+ await shownPromise;
+
+ targetMenu.activateItem(element);
+}
+
+/**
+ * Removes CalTransaction items from the CalTransactionManager stacks so other
+ * tests are unhindered.
+ */
+function clearTransactions() {
+ calTransManager.undoStack = [];
+ calTransManager.redoStack = [];
+}
+
+/**
+ * Test the undo/redo functionality for event creation.
+ *
+ * @param {string} undoId - The id of the "undo" menu item.
+ * @param {string} redoId - The id of the "redo" menu item.
+ */
+async function testAddUndoRedoEvent(undoId, redoId) {
+ let undo = document.getElementById(undoId);
+ let redo = document.getElementById(redoId);
+ Assert.ok(await isDisabled(undo), `#${undoId} is disabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ let newBtn = document.getElementById("sidePanelNewEvent");
+ let windowOpened = CalendarTestUtils.waitForEventDialog("edit");
+ EventUtils.synthesizeMouseAtCenter(newBtn, {});
+
+ let win = await windowOpened;
+ let iframeWin = win.document.getElementById("calendar-item-panel-iframe").contentWindow;
+ await CalendarTestUtils.items.setData(win, iframeWin, { title: "A New Event" });
+ await CalendarTestUtils.items.saveAndCloseItemDialog(win);
+
+ let eventItem;
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return eventItem;
+ }, "event not created in time");
+
+ Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ // Test undo.
+ await clickItem(undo);
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return !eventItem;
+ }, "undo did not remove item in time");
+
+ Assert.ok(!eventItem, `#${undoId} reverses item creation`);
+
+ // Test redo.
+ await clickItem(redo);
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return eventItem;
+ }, `${redoId} did not re-create item in time`);
+ Assert.ok(eventItem, `#${redoId} redos item creation`);
+
+ await calendar.deleteItem(eventItem.item);
+ clearTransactions();
+}
+
+/**
+ * Test the undo/redo functionality for event modification.
+ *
+ * @param {string} undoId - The id of the "undo" menu item.
+ * @param {string} redoId - The id of the "redo" menu item.
+ */
+async function testModifyUndoRedoEvent(undoId, redoId) {
+ let undo = document.getElementById(undoId);
+ let redo = document.getElementById(redoId);
+ Assert.ok(await isDisabled(undo), `#${undoId} is disabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ let event = new CalEvent();
+ event.title = "Modifiable Event";
+ event.startDate = cal.dtz.now();
+ await calendar.addItem(event);
+ window.goToDate(event.startDate);
+
+ let eventItem;
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return eventItem;
+ }, "event not created in time");
+
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editItem(window, eventItem);
+ await CalendarTestUtils.items.setData(dialogWindow, iframeWindow, {
+ title: "Modified Event",
+ });
+ await CalendarTestUtils.items.saveAndCloseItemDialog(dialogWindow);
+
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return eventItem && eventItem.item.title == "Modified Event";
+ }, "event not modified in time");
+
+ Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ // Test undo.
+ await clickItem(undo);
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return eventItem && eventItem.item.title == "Modifiable Event";
+ }, `#${undoId} did not un-modify event in time`);
+
+ Assert.equal(eventItem.item.title, "Modifiable Event", `#${undoId} reverses item modification`);
+
+ // Test redo.
+ await clickItem(redo);
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return eventItem && eventItem.item.title == "Modified Event";
+ }, `${redoId} did not re-modify item in time`);
+
+ Assert.equal(eventItem.item.title, "Modified Event", `#${redoId} redos item modification`);
+
+ clearTransactions();
+ await calendar.deleteItem(eventItem.item);
+}
+
+/**
+ * Test the undo/redo functionality for event deletion.
+ *
+ * @param {string} undoId - The id of the "undo" menu item.
+ * @param {string} redoId - The id of the "redo" menu item.
+ */
+async function testDeleteUndoRedo(undoId, redoId) {
+ let undo = document.getElementById(undoId);
+ let redo = document.getElementById(redoId);
+ Assert.ok(await isDisabled(undo), `#${undoId} is disabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ let event = new CalEvent();
+ event.title = "Deletable Event";
+ event.startDate = cal.dtz.now();
+ await calendar.addItem(event);
+ window.goToDate(event.startDate);
+
+ let eventItem;
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return eventItem;
+ }, "event not created in time");
+
+ EventUtils.synthesizeMouseAtCenter(eventItem, {});
+ EventUtils.synthesizeKey("VK_DELETE");
+
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return !eventItem;
+ }, "event not deleted in time");
+
+ Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ // Test undo.
+ await clickItem(undo);
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return eventItem;
+ }, `#${undoId} did not add event in time`);
+ Assert.ok(eventItem, `#${undoId} reverses item deletion`);
+
+ // Test redo.
+ await clickItem(redo);
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return !eventItem;
+ }, "redo did not delete item in time");
+
+ Assert.ok(!eventItem, `#${redoId} redos item deletion`);
+ clearTransactions();
+}
+
+/**
+ * Ensure the menu bar is visible and navigate the calendar view to today.
+ */
+add_setup(async function () {
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ clearTransactions();
+ document.getElementById("toolbar-menubar").setAttribute("autohide", null);
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.dtz.now());
+});
+
+/**
+ * Tests the menu bar's undo/redo after adding an event.
+ */
+add_task(async function testMenuBarAddEventUndoRedo() {
+ return testAddUndoRedoEvent("menu_undo", "menu_redo");
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
+
+/**
+ * Tests the menu bar's undo/redo after modifying an event.
+ */
+add_task(async function testMenuBarModifyEventUndoRedo() {
+ return testModifyUndoRedoEvent("menu_undo", "menu_redo");
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
+
+/**
+ * Tests the menu bar's undo/redo after deleting an event.
+ */
+add_task(async function testMenuBarDeleteEventUndoRedo() {
+ return testDeleteUndoRedo("menu_undo", "menu_redo");
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
diff --git a/comm/calendar/test/browser/browser_import.js b/comm/calendar/test/browser/browser_import.js
new file mode 100644
index 0000000000..86e605802b
--- /dev/null
+++ b/comm/calendar/test/browser/browser_import.js
@@ -0,0 +1,285 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This tests importing an ICS file. Rather than using the UI to trigger the
+// import, loadEventsFromFile is called directly.
+
+/* globals loadEventsFromFile */
+
+const { MockFilePicker } = ChromeUtils.importESModule(
+ "resource://testing-common/MockFilePicker.sys.mjs"
+);
+const ChromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIChromeRegistry);
+
+add_task(async () => {
+ await CalendarTestUtils.setCalendarView(window, "month");
+ await CalendarTestUtils.goToDate(window, 2019, 1, 1);
+
+ let chromeUrl = Services.io.newURI(getRootDirectory(gTestPath) + "data/import.ics");
+ let fileUrl = ChromeRegistry.convertChromeURL(chromeUrl);
+ let file = fileUrl.QueryInterface(Ci.nsIFileURL).file;
+
+ MockFilePicker.init(window);
+ MockFilePicker.setFiles([file]);
+ MockFilePicker.returnValue = MockFilePicker.returnCancel;
+
+ let calendar = CalendarTestUtils.createCalendar();
+
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ MockFilePicker.cleanup();
+ });
+
+ let cancelReturn = await loadEventsFromFile();
+ ok(!cancelReturn, "loadEventsFromFile returns false on cancel");
+
+ // Prepare to test the import dialog.
+ MockFilePicker.returnValue = MockFilePicker.returnOK;
+
+ let dialogWindowPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-ics-file-dialog.xhtml",
+ {
+ async callback(dialogWindow) {
+ let doc = dialogWindow.document;
+ let dialogElement = doc.querySelector("dialog");
+
+ let optionsPane = doc.getElementById("calendar-ics-file-dialog-options-pane");
+ let progressPane = doc.getElementById("calendar-ics-file-dialog-progress-pane");
+ let resultPane = doc.getElementById("calendar-ics-file-dialog-result-pane");
+
+ ok(!optionsPane.hidden);
+ ok(progressPane.hidden);
+ ok(resultPane.hidden);
+
+ // Check the initial import dialog state.
+ let displayedPath = doc.querySelector("#calendar-ics-file-dialog-file-path").value;
+ let pathFragment = "browser/comm/calendar/test/browser/data/import.ics";
+ if (Services.appinfo.OS == "WINNT") {
+ pathFragment = pathFragment.replace(/\//g, "\\");
+ }
+ is(
+ displayedPath.substring(displayedPath.length - pathFragment.length),
+ pathFragment,
+ "the displayed ics file path is correct"
+ );
+
+ let calendarMenu = doc.querySelector("#calendar-ics-file-dialog-calendar-menu");
+ // 0 is the Home calendar.
+ calendarMenu.selectedIndex = 1;
+ let calendarMenuItems = calendarMenu.querySelectorAll("menuitem");
+ is(calendarMenu.value, "Test", "correct calendar name is selected");
+ Assert.equal(calendarMenuItems.length, 1, "exactly one calendar is in the calendars menu");
+ is(calendarMenuItems[0].selected, true, "calendar menu item is selected");
+
+ let items;
+ await TestUtils.waitForCondition(() => {
+ items = doc.querySelectorAll(".calendar-ics-file-dialog-item-frame");
+ return items.length == 4;
+ }, "four calendar items are displayed");
+ is(
+ items[0].querySelector(".item-title").textContent,
+ "Event One",
+ "event 1 title should be correct"
+ );
+ is(
+ items[1].querySelector(".item-title").textContent,
+ "Event Two",
+ "event 2 title should be correct"
+ );
+ is(
+ items[2].querySelector(".item-title").textContent,
+ "Event Three",
+ "event 3 title should be correct"
+ );
+ is(
+ items[3].querySelector(".item-title").textContent,
+ "Event Four",
+ "event 4 title should be correct"
+ );
+ is(
+ items[0].querySelector(".item-date-row-start-date").textContent,
+ cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T150000")),
+ "event 1 start date should be correct"
+ );
+ is(
+ items[0].querySelector(".item-date-row-end-date").textContent,
+ cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T160000")),
+ "event 1 end date should be correct"
+ );
+ is(
+ items[1].querySelector(".item-date-row-start-date").textContent,
+ cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T160000")),
+ "event 2 start date should be correct"
+ );
+ is(
+ items[1].querySelector(".item-date-row-end-date").textContent,
+ cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T170000")),
+ "event 2 end date should be correct"
+ );
+ is(
+ items[2].querySelector(".item-date-row-start-date").textContent,
+ cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T170000")),
+ "event 3 start date should be correct"
+ );
+ is(
+ items[2].querySelector(".item-date-row-end-date").textContent,
+ cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T180000")),
+ "event 3 end date should be correct"
+ );
+ is(
+ items[3].querySelector(".item-date-row-start-date").textContent,
+ cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T180000")),
+ "event 4 start date should be correct"
+ );
+ is(
+ items[3].querySelector(".item-date-row-end-date").textContent,
+ cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T190000")),
+ "event 4 end date should be correct"
+ );
+
+ function check_displayed_titles(expectedTitles) {
+ let items = doc.querySelectorAll(
+ ".calendar-ics-file-dialog-item-frame:not([hidden]) > calendar-item-summary"
+ );
+ Assert.deepEqual(
+ [...items].map(summary => summary.item.title),
+ expectedTitles
+ );
+ }
+
+ let filterInput = doc.getElementById("calendar-ics-file-dialog-search-input");
+ async function check_filter(filterText, expectedTitles) {
+ let commandPromise = BrowserTestUtils.waitForEvent(filterInput, "command");
+
+ EventUtils.synthesizeMouseAtCenter(filterInput, {}, dialogWindow);
+ if (filterText) {
+ EventUtils.synthesizeKey("a", { accelKey: true }, dialogWindow);
+ EventUtils.sendString(filterText, dialogWindow);
+ } else {
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, dialogWindow);
+ }
+
+ await commandPromise;
+
+ check_displayed_titles(expectedTitles);
+ }
+
+ await check_filter("event", ["Event One", "Event Two", "Event Three", "Event Four"]);
+ await check_filter("four", ["Event Four"]);
+ await check_filter("ONE", ["Event One"]);
+ await check_filter(`"event t"`, ["Event Two", "Event Three"]);
+ await check_filter("", ["Event One", "Event Two", "Event Three", "Event Four"]);
+
+ async function check_sort(order, expectedTitles) {
+ let sortButton = doc.getElementById("calendar-ics-file-dialog-sort-button");
+ let shownPromise = BrowserTestUtils.waitForEvent(sortButton, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(sortButton, {}, dialogWindow);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(sortButton, "popuphidden");
+ EventUtils.synthesizeMouseAtCenter(
+ doc.getElementById(`calendar-ics-file-dialog-sort-${order}`),
+ {},
+ dialogWindow
+ );
+ await hiddenPromise;
+
+ let items = doc.querySelectorAll("calendar-item-summary");
+ is(items.length, 4, "four calendar items are displayed");
+ Assert.deepEqual(
+ [...items].map(summary => summary.item.title),
+ expectedTitles
+ );
+ }
+
+ await check_sort("title-ascending", [
+ "Event Four",
+ "Event One",
+ "Event Three",
+ "Event Two",
+ ]);
+ await check_sort("start-descending", [
+ "Event Four",
+ "Event Three",
+ "Event Two",
+ "Event One",
+ ]);
+ await check_sort("title-descending", [
+ "Event Two",
+ "Event Three",
+ "Event One",
+ "Event Four",
+ ]);
+ await check_sort("start-ascending", [
+ "Event One",
+ "Event Two",
+ "Event Three",
+ "Event Four",
+ ]);
+
+ items = doc.querySelectorAll(".calendar-ics-file-dialog-item-frame");
+
+ // Import just the first item, and check that the correct number of items remains.
+ let firstItemImportButton = items[0].querySelector(
+ ".calendar-ics-file-dialog-item-import-button"
+ );
+ EventUtils.synthesizeMouseAtCenter(firstItemImportButton, { clickCount: 1 }, dialogWindow);
+
+ await TestUtils.waitForCondition(() => {
+ let remainingItems = doc.querySelectorAll(".calendar-ics-file-dialog-item-frame");
+ return remainingItems.length == 3;
+ }, "three items remain after importing the first item");
+ check_displayed_titles(["Event Two", "Event Three", "Event Four"]);
+
+ // Filter and import the shown items.
+ await check_filter("four", ["Event Four"]);
+
+ dialogElement.getButton("accept").click();
+ ok(optionsPane.hidden);
+ ok(!progressPane.hidden);
+ ok(resultPane.hidden);
+
+ await TestUtils.waitForCondition(() => !optionsPane.hidden);
+ ok(progressPane.hidden);
+ ok(resultPane.hidden);
+
+ is(filterInput.value, "");
+ check_displayed_titles(["Event Two", "Event Three"]);
+
+ // Click the accept button to import the remaining items.
+ dialogElement.getButton("accept").click();
+ ok(optionsPane.hidden);
+ ok(!progressPane.hidden);
+ ok(resultPane.hidden);
+
+ await TestUtils.waitForCondition(() => !resultPane.hidden);
+ ok(optionsPane.hidden);
+ ok(progressPane.hidden);
+
+ let messageElement = doc.querySelector("#calendar-ics-file-dialog-result-message");
+ is(messageElement.textContent, "Import complete.", "import success message appeared");
+
+ dialogElement.getButton("accept").click();
+ },
+ }
+ );
+
+ await loadEventsFromFile();
+ await dialogWindowPromise;
+
+ // Check that the items were actually successfully imported.
+ let result = await calendar.getItemsAsArray(
+ Ci.calICalendar.ITEM_FILTER_ALL_ITEMS,
+ 0,
+ cal.createDateTime("20190101T000000"),
+ cal.createDateTime("20190102T000000")
+ );
+ is(result.length, 4, "all items that were imported were in fact imported");
+
+ await CalendarTestUtils.monthView.waitForItemAt(window, 1, 3, 4);
+
+ for (let item of result) {
+ await calendar.deleteItem(item);
+ }
+});
diff --git a/comm/calendar/test/browser/browser_localICS.js b/comm/calendar/test/browser/browser_localICS.js
new file mode 100644
index 0000000000..43e0299937
--- /dev/null
+++ b/comm/calendar/test/browser/browser_localICS.js
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals createCalendarUsingDialog */
+
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const HOUR = 8;
+
+// Unique name needed as deleting a calendar only unsubscribes from it and
+// if same file were used on next testrun then previously created event
+// would show up.
+var calendarName = String(Date.now());
+var calendarFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
+calendarFile.append(calendarName + ".ics");
+
+add_task(async function testLocalICS() {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await createCalendarUsingDialog(calendarName, { network: {} });
+
+ // Create new event.
+ let box = CalendarTestUtils.dayView.getHourBoxAt(window, HOUR);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, box);
+ await setData(dialogWindow, iframeWindow, { title: calendarName, calendar: calendarName });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Assert presence in view.
+ await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1);
+
+ // Verify in file.
+ let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ let cstream = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance(
+ Ci.nsIConverterInputStream
+ );
+
+ // Wait a moment until file is written.
+ await TestUtils.waitForCondition(() => calendarFile.exists());
+
+ // Read the calendar file and check for the summary.
+ fstream.init(calendarFile, -1, 0, 0);
+ cstream.init(fstream, "UTF-8", 0, 0);
+
+ let str = {};
+ cstream.readString(-1, str);
+ cstream.close();
+
+ Assert.ok(str.value.includes("SUMMARY:" + calendarName));
+});
+
+registerCleanupFunction(() => {
+ for (let calendar of cal.manager.getCalendars()) {
+ if (calendar.name == calendarName) {
+ cal.manager.removeCalendar(calendar);
+ }
+ }
+});
diff --git a/comm/calendar/test/browser/browser_tabs.js b/comm/calendar/test/browser/browser_tabs.js
new file mode 100644
index 0000000000..950b98a68c
--- /dev/null
+++ b/comm/calendar/test/browser/browser_tabs.js
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async () => {
+ // Test the calendar tab opens and closes.
+ await CalendarTestUtils.openCalendarTab(window);
+ await CalendarTestUtils.closeCalendarTab(window);
+
+ // Test the tasks tab opens and closes.
+ await openTasksTab();
+ await closeTasksTab();
+
+ // Test the calendar and tasks tabs at the same time.
+ await CalendarTestUtils.openCalendarTab(window);
+ await openTasksTab();
+ await CalendarTestUtils.closeCalendarTab(window);
+ await closeTasksTab();
+
+ // Test calendar view selection.
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await CalendarTestUtils.setCalendarView(window, "month");
+ await CalendarTestUtils.closeCalendarTab(window);
+});
diff --git a/comm/calendar/test/browser/browser_taskDelete.js b/comm/calendar/test/browser/browser_taskDelete.js
new file mode 100644
index 0000000000..dcbf7ab057
--- /dev/null
+++ b/comm/calendar/test/browser/browser_taskDelete.js
@@ -0,0 +1,185 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for deleting tasks in the task view.
+ */
+const { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+let calendar = CalendarTestUtils.createCalendar("Task Delete Test", "memory");
+registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar));
+
+/**
+ * Test ensures its possible to delete a task in the task view. Creates two task
+ * and deletes one.
+ */
+add_task(async function testTaskDeletion() {
+ let task1 = new CalTodo();
+ task1.id = "1";
+ task1.title = "Task 1";
+ task1.entryDate = cal.createDateTime("20210126T000001Z");
+
+ let task2 = new CalTodo();
+ task2.id = "2";
+ task2.title = "Task 2";
+ task2.entryDate = cal.createDateTime("20210127T000001Z");
+
+ await calendar.addItem(task1);
+ await calendar.addItem(task2);
+ await openTasksTab();
+
+ let tree = document.querySelector("#calendar-task-tree");
+ let radio = document.querySelector("#opt_next7days_filter");
+ let waitForRefresh = BrowserTestUtils.waitForEvent(tree, "refresh");
+ EventUtils.synthesizeMouseAtCenter(radio, {});
+ tree.refresh();
+
+ await waitForRefresh;
+ Assert.equal(tree.view.rowCount, 2, "2 tasks are displayed");
+
+ mailTestUtils.treeClick(EventUtils, window, tree, 0, 1, { clickCount: 1 });
+ EventUtils.synthesizeKey("VK_DELETE");
+
+ // Try and trigger a reflow
+ tree.getBoundingClientRect();
+ tree.invalidate();
+ await new Promise(r => setTimeout(r));
+
+ await TestUtils.waitForCondition(() => {
+ tree = document.querySelector("#calendar-task-tree");
+ return tree.view.rowCount == 1;
+ }, `task view displays ${tree.view.rowCount} tasks instead of 1`);
+
+ let result = await calendar.getItem(task1.id);
+ Assert.ok(!result, "first task was deleted successfully");
+
+ result = await calendar.getItem(task2.id);
+ Assert.ok(result, "second task was not deleted");
+ await calendar.deleteItem(task2);
+ await closeTasksTab();
+});
+
+/**
+ * Test ensures it is possible to delete a recurring task from the task view.
+ * See bug 1688708.
+ */
+add_task(async function testRecurringTaskDeletion() {
+ let repeatTask = new CalTodo();
+ repeatTask.id = "1";
+ repeatTask.title = "Repeating Task";
+ repeatTask.entryDate = cal.createDateTime("20210125T000001Z");
+ repeatTask.recurrenceInfo = new CalRecurrenceInfo(repeatTask);
+ repeatTask.recurrenceInfo.appendRecurrenceItem(
+ cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3")
+ );
+
+ let nonRepeatTask = new CalTodo();
+ nonRepeatTask.id = "2";
+ nonRepeatTask.title = "Non-Repeating Task";
+ nonRepeatTask.entryDate = cal.createDateTime("20210126T000001Z");
+
+ repeatTask = await calendar.addItem(repeatTask);
+ nonRepeatTask = await calendar.addItem(nonRepeatTask);
+
+ await openTasksTab();
+
+ let tree = document.querySelector("#calendar-task-tree");
+ let radio = document.querySelector("#opt_next7days_filter");
+ let waitForRefresh = BrowserTestUtils.waitForEvent(tree, "refresh");
+ EventUtils.synthesizeMouseAtCenter(radio, {});
+ tree.refresh();
+
+ await waitForRefresh;
+ Assert.equal(tree.view.rowCount, 4, "4 tasks are displayed");
+
+ // Delete a single occurrence.
+ let handleSingleDelete = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-occurrence-prompt.xhtml",
+ {
+ async callback(win) {
+ let dialog = win.document.querySelector("dialog");
+ let button = dialog.querySelector("#accept-occurrence-button");
+ EventUtils.synthesizeMouseAtCenter(button, {}, win);
+ },
+ }
+ );
+ mailTestUtils.treeClick(EventUtils, window, tree, 1, 1, { clickCount: 1 });
+ EventUtils.synthesizeKey("VK_DELETE");
+ await handleSingleDelete;
+
+ // Try and trigger a reflow
+ tree.getBoundingClientRect();
+ tree.invalidate();
+ await new Promise(r => setTimeout(r));
+
+ await TestUtils.waitForCondition(() => {
+ tree = document.querySelector("#calendar-task-tree");
+ return tree.view.rowCount == 3;
+ }, `task view displays ${tree.view.rowCount} tasks instead of 3`);
+
+ repeatTask = await calendar.getItem(repeatTask.id);
+
+ Assert.equal(
+ repeatTask.recurrenceInfo.getOccurrences(
+ cal.createDateTime("20210126T000001Z"),
+ cal.createDateTime("20210126T000001Z"),
+ 10
+ ).length,
+ 0,
+ "a single occurrence was deleted successfully"
+ );
+
+ Assert.equal(
+ repeatTask.recurrenceInfo.getOccurrences(
+ repeatTask.entryDate,
+ cal.createDateTime("20210131T000001Z"),
+ 10
+ ).length,
+ 2,
+ "other occurrences were not removed"
+ );
+
+ // Delete all occurrences
+ let handleAllDelete = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-occurrence-prompt.xhtml",
+ {
+ async callback(win) {
+ let dialog = win.document.querySelector("dialog");
+ let button = dialog.querySelector("#accept-parent-button");
+ EventUtils.synthesizeMouseAtCenter(button, {}, win);
+ },
+ }
+ );
+
+ mailTestUtils.treeClick(EventUtils, window, tree, 1, 1, { clickCount: 1 });
+ EventUtils.synthesizeKey("VK_DELETE");
+ await handleAllDelete;
+
+ // Try and trigger a reflow
+ tree.getBoundingClientRect();
+ tree.invalidate();
+ await new Promise(r => setTimeout(r));
+
+ await TestUtils.waitForCondition(() => {
+ tree = document.querySelector("#calendar-task-tree");
+ return tree.view.rowCount == 1;
+ }, `task view displays ${tree.view.rowCount} tasks instead of 1`);
+
+ repeatTask = await calendar.getItem(repeatTask.id);
+ Assert.ok(!repeatTask, "all occurrences were removed");
+
+ let result = await calendar.getItem(nonRepeatTask.id);
+ Assert.ok(result, "non-recurring task was not deleted");
+ await closeTasksTab();
+});
diff --git a/comm/calendar/test/browser/browser_taskDisplay.js b/comm/calendar/test/browser/browser_taskDisplay.js
new file mode 100644
index 0000000000..1ccd0dac3f
--- /dev/null
+++ b/comm/calendar/test/browser/browser_taskDisplay.js
@@ -0,0 +1,274 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+var calendar = CalendarTestUtils.createCalendar();
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+let tree = document.getElementById("calendar-task-tree");
+
+add_task(async () => {
+ async function createTask(title, attributes = {}) {
+ let task = new CalTodo();
+ task.title = title;
+ for (let [key, value] of Object.entries(attributes)) {
+ task[key] = value;
+ }
+ return calendar.addItem(task);
+ }
+
+ function treeRefresh() {
+ return BrowserTestUtils.waitForEvent(tree, "refresh");
+ }
+
+ async function setFilterGroup(name) {
+ info(`Setting filter to ${name}`);
+ let radio = document.getElementById(`opt_${name}_filter`);
+ EventUtils.synthesizeMouseAtCenter(radio, {});
+ await treeRefresh();
+ Assert.equal(
+ document.getElementById("calendar-task-tree").getAttribute("filterValue"),
+ radio.value,
+ "Filter group changed"
+ );
+ }
+
+ async function setFilterText(text) {
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("task-text-filter-field"), {});
+ EventUtils.sendString(text);
+ Assert.equal(document.getElementById("task-text-filter-field").value, text, "Filter text set");
+ await treeRefresh();
+ }
+
+ async function clearFilterText() {
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("task-text-filter-field"), {});
+ EventUtils.synthesizeKey("VK_ESCAPE");
+ Assert.equal(
+ document.getElementById("task-text-filter-field").value,
+ "",
+ "Filter text cleared"
+ );
+ await treeRefresh();
+ }
+
+ async function checkVisibleTasks(...expectedTasks) {
+ function toPrettyString(task) {
+ if (task.recurrenceId) {
+ return `${task.title}#${task.recurrenceId}`;
+ }
+ return task.title;
+ }
+ tree.getBoundingClientRect(); // Try and trigger a reflow...
+ tree.invalidate();
+
+ // It seems that under certain conditions notifyOperationComplete() is
+ // called in CalStorageCalender.getItems before all the results have been
+ // retrieved. This results in the "refresh" event being fired prematurely in
+ // calendar-task-tree. After some investigation, the cause of this seems to
+ // be related to multiple calls of executeAsync() in CalStorageItemModel.
+ // getAdditionalDataForItemMap() not finishing before notifyOperationComplete()
+ // is called despite being awaited on.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 500));
+
+ let actualTasks = [];
+ for (let i = 0; i < tree.view.rowCount; i++) {
+ actualTasks.push(tree.getTaskAtRow(i));
+ }
+ info("Expected: " + expectedTasks.map(toPrettyString).join(", "));
+ info("Actual: " + actualTasks.map(toPrettyString).join(", "));
+
+ Assert.equal(tree.view.rowCount, expectedTasks.length, "Correct number of tasks");
+ await new Promise(r => setTimeout(r));
+
+ // Although the order of expectedTasks matches the observed behaviour when
+ // this test was written, order is NOT checked here. The order of the list
+ // is not well defined (particularly when changing the filter text).
+ for (let aTask of actualTasks) {
+ Assert.ok(
+ expectedTasks.some(eTask => eTask.hasSameIds(aTask)),
+ toPrettyString(aTask)
+ );
+ }
+ }
+
+ let today = cal.dtz.now();
+ today.hour = today.minute = today.second = 0;
+ let yesterday = today.clone();
+ yesterday.addDuration(cal.createDuration("-P1D"));
+ let tomorrow = today.clone();
+ tomorrow.addDuration(cal.createDuration("P1D"));
+ let later = today.clone();
+ later.addDuration(cal.createDuration("P2W"));
+
+ let tasks = {
+ incomplete: await createTask("Incomplete"),
+ started30: await createTask("30% started", { percentComplete: 30 }),
+ started60: await createTask("60% started", { percentComplete: 60 }),
+ complete: await createTask("Complete", { isCompleted: true }),
+ overdue: await createTask("Overdue", { dueDate: yesterday }),
+ startsToday: await createTask("Starts today", { entryDate: today }),
+ startsTomorrow: await createTask("Starts tomorrow", { entryDate: tomorrow }),
+ startsLater: await createTask("Starts later", { entryDate: later }),
+ };
+
+ let repeatingTask = new CalTodo();
+ repeatingTask.title = "Repeating";
+ repeatingTask.entryDate = yesterday;
+ repeatingTask.recurrenceInfo = new CalRecurrenceInfo(repeatingTask);
+ repeatingTask.recurrenceInfo.appendRecurrenceItem(
+ cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3")
+ );
+
+ let firstOccurrence = repeatingTask.recurrenceInfo.getOccurrenceFor(yesterday);
+ firstOccurrence.isCompleted = true;
+ firstOccurrence.completedDate = yesterday;
+ repeatingTask.recurrenceInfo.modifyException(firstOccurrence, true);
+
+ repeatingTask = await calendar.addItem(repeatingTask);
+
+ let occurrences = repeatingTask.recurrenceInfo.getOccurrences(yesterday, later, 10);
+ Assert.equal(occurrences.length, 3);
+
+ await openTasksTab();
+
+ await setFilterGroup("all");
+ await checkVisibleTasks(
+ tasks.incomplete,
+ tasks.started30,
+ tasks.started60,
+ tasks.complete,
+ tasks.overdue,
+ tasks.startsToday,
+ tasks.startsTomorrow,
+ tasks.startsLater,
+ repeatingTask
+ );
+
+ await setFilterGroup("open");
+ await checkVisibleTasks(
+ tasks.incomplete,
+ tasks.started30,
+ tasks.started60,
+ tasks.overdue,
+ tasks.startsToday,
+ tasks.startsTomorrow,
+ tasks.startsLater,
+ occurrences[1],
+ occurrences[2]
+ );
+
+ await setFilterGroup("completed");
+ await checkVisibleTasks(tasks.complete, occurrences[0]);
+
+ await setFilterGroup("overdue");
+ await checkVisibleTasks(tasks.overdue);
+
+ await setFilterGroup("notstarted");
+ await checkVisibleTasks(tasks.overdue, tasks.incomplete, tasks.startsToday, occurrences[1]);
+
+ await setFilterGroup("next7days");
+ await checkVisibleTasks(
+ tasks.overdue,
+ tasks.incomplete,
+ tasks.startsToday,
+ tasks.started30,
+ tasks.started60,
+ tasks.complete,
+ tasks.startsTomorrow,
+ occurrences[1],
+ occurrences[2]
+ );
+
+ await setFilterGroup("today");
+ await checkVisibleTasks(
+ tasks.overdue,
+ tasks.incomplete,
+ tasks.startsToday,
+ tasks.started30,
+ tasks.started60,
+ tasks.complete,
+ occurrences[1]
+ );
+
+ await setFilterGroup("throughcurrent");
+ await checkVisibleTasks(
+ tasks.overdue,
+ tasks.incomplete,
+ tasks.startsToday,
+ tasks.started30,
+ tasks.started60,
+ tasks.complete,
+ occurrences[1]
+ );
+
+ await setFilterText("No matches");
+ await checkVisibleTasks();
+
+ await clearFilterText();
+ await checkVisibleTasks(
+ tasks.incomplete,
+ tasks.started30,
+ tasks.started60,
+ tasks.complete,
+ tasks.overdue,
+ tasks.startsToday,
+ occurrences[1]
+ );
+
+ await setFilterText("StArTeD");
+ await checkVisibleTasks(tasks.started30, tasks.started60);
+
+ await setFilterGroup("today");
+ Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD");
+ await checkVisibleTasks(tasks.started30, tasks.started60);
+
+ await setFilterGroup("next7days");
+ Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD");
+ await checkVisibleTasks(tasks.started30, tasks.started60);
+
+ await setFilterGroup("notstarted");
+ Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD");
+ await checkVisibleTasks();
+
+ await setFilterGroup("overdue");
+ Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD");
+ await checkVisibleTasks();
+
+ await setFilterGroup("completed");
+ Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD");
+ await checkVisibleTasks();
+
+ await setFilterGroup("open");
+ Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD");
+ await checkVisibleTasks(tasks.started30, tasks.started60);
+
+ await setFilterGroup("all");
+ Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD");
+ await checkVisibleTasks(tasks.started30, tasks.started60);
+
+ await clearFilterText();
+ await checkVisibleTasks(
+ tasks.started30,
+ tasks.started60,
+ tasks.incomplete,
+ tasks.complete,
+ tasks.overdue,
+ tasks.startsToday,
+ tasks.startsTomorrow,
+ tasks.startsLater,
+ repeatingTask
+ );
+
+ for (let task of Object.values(tasks)) {
+ await calendar.deleteItem(task);
+ }
+ await setFilterGroup("throughcurrent");
+});
diff --git a/comm/calendar/test/browser/browser_taskUndoRedo.js b/comm/calendar/test/browser/browser_taskUndoRedo.js
new file mode 100644
index 0000000000..09cf9a8de2
--- /dev/null
+++ b/comm/calendar/test/browser/browser_taskUndoRedo.js
@@ -0,0 +1,244 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Tests for ensuring the undo/redo options are enabled properly when
+ * manipulating tasks.
+ */
+
+var { mailTestUtils } = ChromeUtils.import("resource://testing-common/mailnews/MailTestUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalTodo: "resource:///modules/CalTodo.jsm",
+ CalTransactionManager: "resource:///modules/CalTransactionManager.jsm",
+});
+
+const calendar = CalendarTestUtils.createCalendar("Undo Redo Test", "memory");
+const calTransManager = CalTransactionManager.getInstance();
+
+/**
+ * Checks the value of the "disabled" property for items in either the "Edit"
+ * menu bar or the app menu. Display of the relevant menu is triggered first so
+ * the UI code can update the respective items.
+ *
+ * @param {XULElement} element - The menu item we want to check, if its id begins
+ * with "menu" then we assume it is in the menu
+ * bar, if "appmenu" then the app menu.
+ */
+async function isDisabled(element) {
+ let targetMenu = document.getElementById("menu_EditPopup");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(targetMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("menu_Edit"), {});
+ await shownPromise;
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(targetMenu, "popuphidden");
+ let status = element.disabled;
+ EventUtils.synthesizeKey("VK_ESCAPE");
+ await hiddenPromise;
+ return status;
+}
+
+/**
+ * Removes CalTransaction items from the CalTransactionManager stacks so other
+ * tests are unhindered.
+ */
+function clearTransactions() {
+ calTransManager.undoStack = [];
+ calTransManager.redoStack = [];
+}
+
+/**
+ * Test the undo/redo functionality for task creation.
+ *
+ * @param {string} undoId - The id of the "undo" menu item.
+ * @param {string} redoId - The id of the "redo" menu item.
+ */
+async function taskAddUndoRedoTask(undoId, redoId) {
+ let undo = document.getElementById(undoId);
+ let redo = document.getElementById(redoId);
+ Assert.ok(await isDisabled(undo), `#${undoId} is disabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ let newBtn = document.getElementById("sidePanelNewTask");
+ let windowPromise = CalendarTestUtils.waitForEventDialog("edit");
+ EventUtils.synthesizeMouseAtCenter(newBtn, {});
+
+ let win = await windowPromise;
+ let iframeWin = win.document.getElementById("calendar-item-panel-iframe").contentWindow;
+ await CalendarTestUtils.items.setData(win, iframeWin, { title: "New Task" });
+ await CalendarTestUtils.items.saveAndCloseItemDialog(win);
+
+ let tree = document.querySelector("#calendar-task-tree");
+ let refreshPromise = BrowserTestUtils.waitForEvent(tree, "refresh");
+ tree.refresh();
+ await refreshPromise;
+
+ Assert.equal(tree.view.rowCount, 1);
+ Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ // Test undo.
+ undo.doCommand();
+ await TestUtils.waitForCondition(
+ () => tree.view.rowCount == 0,
+ `${undoId} did not remove task in time`
+ );
+ Assert.equal(tree.view.rowCount, 0, `#${undoId} reverses task creation`);
+
+ // Test redo.
+ redo.doCommand();
+ await TestUtils.waitForCondition(
+ () => tree.view.rowCount == 1,
+ `${redoId} did not re-create task in time`
+ );
+
+ let task = tree.getTaskAtRow(0);
+ Assert.equal(task.title, "New Task", `#${redoId} redos task creation`);
+ await calendar.deleteItem(task);
+ clearTransactions();
+}
+
+/**
+ * Test the undo/redo functionality for task modification.
+ *
+ * @param {string} undoId - The id of the "undo" menu item.
+ * @param {string} redoId - The id of the "redo" menu item.
+ */
+async function testModifyUndoRedoTask(undoId, redoId) {
+ let undo = document.getElementById(undoId);
+ let redo = document.getElementById(redoId);
+ Assert.ok(await isDisabled(undo), `#${undoId} is disabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ let task = new CalTodo();
+ task.title = "Modifiable Task";
+ task.entryDate = cal.dtz.now();
+ await calendar.addItem(task);
+
+ let tree = document.querySelector("#calendar-task-tree");
+ let refreshPromise = BrowserTestUtils.waitForEvent(tree, "refresh");
+ tree.refresh();
+ await refreshPromise;
+
+ let windowPromise = CalendarTestUtils.waitForEventDialog("edit");
+ mailTestUtils.treeClick(EventUtils, window, tree, 0, 1, { clickCount: 2 });
+
+ let win = await windowPromise;
+ let iframeWin = win.document.getElementById("calendar-item-panel-iframe").contentWindow;
+ await CalendarTestUtils.items.setData(win, iframeWin, { title: "Modified Task" });
+ await CalendarTestUtils.items.saveAndCloseItemDialog(win);
+
+ Assert.equal(tree.getTaskAtRow(0).title, "Modified Task");
+ Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ // Test undo.
+ undo.doCommand();
+ refreshPromise = BrowserTestUtils.waitForEvent(tree, "refresh");
+ tree.refresh();
+ await refreshPromise;
+ Assert.equal(
+ tree.getTaskAtRow(0).title,
+ "Modifiable Task",
+ `#${undoId} reverses task modification`
+ );
+
+ // Test redo.
+ redo.doCommand();
+ refreshPromise = BrowserTestUtils.waitForEvent(tree, "refresh");
+ tree.refresh();
+ await refreshPromise;
+ Assert.equal(tree.getTaskAtRow(0).title, "Modified Task", `#${redoId} redos task modification`);
+
+ clearTransactions();
+ await calendar.deleteItem(tree.getTaskAtRow(0));
+}
+
+/**
+ * Test the undo/redo functionality for task deletion.
+ *
+ * @param {string} undoId - The id of the "undo" menu item.
+ * @param {string} redoId - The id of the "redo" menu item.
+ */
+async function testDeleteUndoRedoTask(undoId, redoId) {
+ let undo = document.getElementById(undoId);
+ let redo = document.getElementById(redoId);
+ Assert.ok(await isDisabled(undo), `#${undoId} is disabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ let task = new CalTodo();
+ task.title = "Deletable Task";
+ task.startDate = cal.dtz.now();
+ task.entryDate = cal.dtz.now();
+ await calendar.addItem(task);
+
+ let tree = document.querySelector("#calendar-task-tree");
+ let refreshPromise = BrowserTestUtils.waitForEvent(tree, "refresh");
+ tree.refresh();
+ await refreshPromise;
+ Assert.equal(tree.view.rowCount, 1);
+
+ mailTestUtils.treeClick(EventUtils, window, tree, 0, 1, { clickCount: 1 });
+ EventUtils.synthesizeKey("VK_DELETE");
+ await TestUtils.waitForCondition(() => tree.view.rowCount == 0, "task was not removed in time");
+
+ Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ // Test undo.
+ undo.doCommand();
+ tree.refresh();
+ await TestUtils.waitForCondition(
+ () => tree.view.rowCount == 1,
+ "undo did not restore task in time"
+ );
+ Assert.equal(tree.getTaskAtRow(0).title, "Deletable Task", `#${undoId} reverses item deletion`);
+
+ // Test redo.
+ redo.doCommand();
+ await TestUtils.waitForCondition(
+ () => tree.view.rowCount == 0,
+ `#${redoId} redo did not delete item in time`
+ );
+ Assert.ok(!tree.getTaskAtRow(0), `#${redoId} redos item deletion`);
+
+ clearTransactions();
+}
+
+/**
+ * Ensure the menu bar is visible and navigate to the task view.
+ */
+add_setup(async function () {
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ clearTransactions();
+ document.getElementById("toolbar-menubar").setAttribute("autohide", null);
+ await openTasksTab();
+});
+
+/**
+ * Tests the menu bar's undo/redo after adding an event.
+ */
+add_task(async function testMenuBarAddTaskUndoRedo() {
+ return taskAddUndoRedoTask("menu_undo", "menu_redo");
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
+
+/**
+ * Tests the menu bar's undo/redo after modifying an event.
+ */
+add_task(async function testMenuBarModifyTaskUndoRedo() {
+ return testModifyUndoRedoTask("menu_undo", "menu_redo");
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
+
+/**
+ * Tests the menu bar's undo/redo after deleting an event.
+ */
+add_task(async function testMenuBarDeleteTaskUndoRedo() {
+ return testDeleteUndoRedoTask("menu_undo", "menu_redo");
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
diff --git a/comm/calendar/test/browser/browser_todayPane.js b/comm/calendar/test/browser/browser_todayPane.js
new file mode 100644
index 0000000000..8ad9141815
--- /dev/null
+++ b/comm/calendar/test/browser/browser_todayPane.js
@@ -0,0 +1,820 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals TodayPane */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { formatDate, formatTime } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalDateTime: "resource:///modules/CalDateTime.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+});
+
+let calendar = CalendarTestUtils.createCalendar();
+Services.prefs.setIntPref("calendar.agenda.days", 7);
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ Services.prefs.clearUserPref("calendar.agenda.days");
+});
+
+let today = cal.dtz.now();
+let startHour = today.hour;
+today.hour = today.minute = today.second = 0;
+
+let todayPanePanel = document.getElementById("today-pane-panel");
+let todayPaneStatusButton = document.getElementById("calendar-status-todaypane-button");
+
+// Go to mail tab.
+selectFolderTab();
+
+// Verify today pane open.
+if (todayPanePanel.hasAttribute("collapsed")) {
+ EventUtils.synthesizeMouseAtCenter(todayPaneStatusButton, {});
+}
+Assert.ok(!todayPanePanel.hasAttribute("collapsed"), "Today Pane is open");
+
+// Verify today pane's date.
+Assert.equal(document.getElementById("datevalue-label").value, today.day, "Today Pane shows today");
+
+async function addEvent(title, relativeStart, relativeEnd, isAllDay) {
+ let event = new CalEvent();
+ event.id = cal.getUUID();
+ event.title = title;
+ event.startDate = today.clone();
+ event.startDate.addDuration(cal.createDuration(relativeStart));
+ event.startDate.isDate = isAllDay;
+ event.endDate = today.clone();
+ event.endDate.addDuration(cal.createDuration(relativeEnd));
+ event.endDate.isDate = isAllDay;
+ return calendar.addItem(event);
+}
+
+function checkEvent(row, { dateHeader, time, title, relative, overlap, classes = [] }) {
+ let dateHeaderElement = row.querySelector(".agenda-date-header");
+ if (dateHeader) {
+ Assert.ok(BrowserTestUtils.is_visible(dateHeaderElement), "date header is visible");
+ if (dateHeader instanceof CalDateTime || dateHeader instanceof Ci.calIDateTime) {
+ dateHeader = cal.dtz.formatter.formatDateLongWithoutYear(dateHeader);
+ }
+ Assert.equal(dateHeaderElement.textContent, dateHeader, "date header has correct value");
+ } else {
+ Assert.ok(BrowserTestUtils.is_hidden(dateHeaderElement), "date header is hidden");
+ }
+
+ let calendarElement = row.querySelector(".agenda-listitem-calendar");
+ let timeElement = row.querySelector(".agenda-listitem-time");
+ if (time) {
+ Assert.ok(BrowserTestUtils.is_visible(calendarElement), "calendar is visible");
+ Assert.ok(BrowserTestUtils.is_visible(timeElement), "time is visible");
+ if (time instanceof CalDateTime || time instanceof Ci.calIDateTime) {
+ time = cal.dtz.formatter.formatTime(time);
+ }
+ Assert.equal(timeElement.textContent, time, "time has correct value");
+ } else if (time === "") {
+ Assert.ok(BrowserTestUtils.is_visible(calendarElement), "calendar is visible");
+ Assert.ok(BrowserTestUtils.is_hidden(timeElement), "time is hidden");
+ } else {
+ Assert.ok(BrowserTestUtils.is_hidden(calendarElement), "calendar is hidden");
+ Assert.ok(BrowserTestUtils.is_hidden(timeElement), "time is hidden");
+ }
+
+ let titleElement = row.querySelector(".agenda-listitem-title");
+ Assert.ok(BrowserTestUtils.is_visible(titleElement), "title is visible");
+ Assert.equal(titleElement.textContent, title, "title has correct value");
+
+ let relativeElement = row.querySelector(".agenda-listitem-relative");
+ if (Array.isArray(relative)) {
+ Assert.ok(BrowserTestUtils.is_visible(relativeElement), "relative time is visible");
+ Assert.report(
+ !relative.includes(relativeElement.textContent),
+ relative,
+ relativeElement.textContent,
+ "relative time is correct",
+ "includes"
+ );
+ } else if (relative !== undefined) {
+ Assert.ok(BrowserTestUtils.is_hidden(relativeElement), "relative time is hidden");
+ }
+
+ let overlapElement = row.querySelector(".agenda-listitem-overlap");
+ if (overlap) {
+ Assert.ok(BrowserTestUtils.is_visible(overlapElement), "overlap is visible");
+ Assert.equal(
+ overlapElement.src,
+ `chrome://messenger/skin/icons/new/event-${overlap}.svg`,
+ "overlap has correct image"
+ );
+ Assert.equal(
+ overlapElement.dataset.l10nId,
+ `calendar-editable-item-multiday-event-icon-${overlap}`,
+ "overlap has correct alt text"
+ );
+ } else {
+ Assert.ok(BrowserTestUtils.is_hidden(overlapElement), "overlap is hidden");
+ }
+
+ for (let className of classes) {
+ Assert.ok(row.classList.contains(className), `row has ${className} class`);
+ }
+}
+
+function checkEvents(...expectedEvents) {
+ Assert.equal(TodayPane.agenda.rowCount, expectedEvents.length, "expected number of rows shown");
+ for (let i = 0; i < expectedEvents.length; i++) {
+ Assert.ok(TodayPane.agenda.rows[i].getAttribute("is"), "agenda-listitem");
+ checkEvent(TodayPane.agenda.rows[i], expectedEvents[i]);
+ }
+}
+
+add_task(async function testBasicAllDay() {
+ let todaysEvent = await addEvent("Today's Event", "P0D", "P1D", true);
+ checkEvents({ dateHeader: "Today", title: "Today's Event" });
+
+ let tomorrowsEvent = await addEvent("Tomorrow's Event", "P1D", "P2D", true);
+ checkEvents(
+ { dateHeader: "Today", title: "Today's Event" },
+ { dateHeader: "Tomorrow", title: "Tomorrow's Event" }
+ );
+
+ let events = [];
+ for (let i = 2; i < 7; i++) {
+ events.push(await addEvent(`Event ${i + 1}`, `P${i}D`, `P${i + 1}D`, true));
+ checkEvents(
+ { dateHeader: "Today", title: "Today's Event" },
+ { dateHeader: "Tomorrow", title: "Tomorrow's Event" },
+ ...events.map(e => {
+ return { dateHeader: e.startDate, title: e.title };
+ })
+ );
+ }
+
+ await calendar.deleteItem(todaysEvent);
+ checkEvents(
+ { dateHeader: "Tomorrow", title: "Tomorrow's Event" },
+ ...events.map(e => {
+ return { dateHeader: e.startDate, title: e.title };
+ })
+ );
+ await calendar.deleteItem(tomorrowsEvent);
+ checkEvents(
+ ...events.map(e => {
+ return { dateHeader: e.startDate, title: e.title };
+ })
+ );
+
+ while (events.length) {
+ await calendar.deleteItem(events.shift());
+ checkEvents(
+ ...events.map(e => {
+ return { dateHeader: e.startDate, title: e.title };
+ })
+ );
+ }
+});
+
+add_task(async function testBasic() {
+ let time = today.clone();
+ time.hour = 23;
+
+ let todaysEvent = await addEvent("Today's Event", "P0DT23H", "P1D");
+ checkEvents({ dateHeader: "Today", time, title: "Today's Event" });
+
+ let tomorrowsEvent = await addEvent("Tomorrow's Event", "P1DT23H", "P2D");
+ checkEvents(
+ { dateHeader: "Today", time, title: "Today's Event" },
+ { dateHeader: "Tomorrow", time, title: "Tomorrow's Event" }
+ );
+
+ let events = [];
+ for (let i = 2; i < 7; i++) {
+ events.push(await addEvent(`Event ${i + 1}`, `P${i}DT23H`, `P${i + 1}D`));
+ checkEvents(
+ { dateHeader: "Today", time, title: "Today's Event" },
+ { dateHeader: "Tomorrow", time, title: "Tomorrow's Event" },
+ ...events.map(e => {
+ return { dateHeader: e.startDate, time, title: e.title };
+ })
+ );
+ }
+
+ await calendar.deleteItem(todaysEvent);
+ checkEvents(
+ { dateHeader: "Tomorrow", time, title: "Tomorrow's Event" },
+ ...events.map(e => {
+ return { dateHeader: e.startDate, time, title: e.title };
+ })
+ );
+ await calendar.deleteItem(tomorrowsEvent);
+ checkEvents(
+ ...events.map(e => {
+ return { dateHeader: e.startDate, time, title: e.title };
+ })
+ );
+
+ while (events.length) {
+ await calendar.deleteItem(events.shift());
+ checkEvents(
+ ...events.map(e => {
+ return { dateHeader: e.startDate, time, title: e.title };
+ })
+ );
+ }
+});
+
+/**
+ * Adds and removes events in a different order from which they occur.
+ * This checks that the events are inserted in the right place, and that the
+ * date header is shown/hidden appropriately.
+ */
+add_task(async function testSortOrder() {
+ let afternoonEvent = await addEvent("Afternoon Event", "P1DT13H", "P1DT17H");
+ checkEvents({
+ dateHeader: "Tomorrow",
+ time: afternoonEvent.startDate,
+ title: "Afternoon Event",
+ });
+
+ let morningEvent = await addEvent("Morning Event", "P1DT8H", "P1DT12H");
+ checkEvents(
+ { dateHeader: "Tomorrow", time: morningEvent.startDate, title: "Morning Event" },
+ { time: afternoonEvent.startDate, title: "Afternoon Event" }
+ );
+
+ let allDayEvent = await addEvent("All Day Event", "P1D", "P2D", true);
+ checkEvents(
+ { dateHeader: "Tomorrow", title: "All Day Event" },
+ { time: morningEvent.startDate, title: "Morning Event" },
+ { time: afternoonEvent.startDate, title: "Afternoon Event" }
+ );
+
+ let eveningEvent = await addEvent("Evening Event", "P1DT18H", "P1DT22H");
+ checkEvents(
+ { dateHeader: "Tomorrow", title: "All Day Event" },
+ { time: morningEvent.startDate, title: "Morning Event" },
+ { time: afternoonEvent.startDate, title: "Afternoon Event" },
+ { time: eveningEvent.startDate, title: "Evening Event" }
+ );
+
+ await calendar.deleteItem(afternoonEvent);
+ checkEvents(
+ { dateHeader: "Tomorrow", title: "All Day Event" },
+ { time: morningEvent.startDate, title: "Morning Event" },
+ { time: eveningEvent.startDate, title: "Evening Event" }
+ );
+
+ await calendar.deleteItem(morningEvent);
+ checkEvents(
+ { dateHeader: "Tomorrow", title: "All Day Event" },
+ { time: eveningEvent.startDate, title: "Evening Event" }
+ );
+
+ await calendar.deleteItem(allDayEvent);
+ checkEvents({
+ dateHeader: "Tomorrow",
+ time: eveningEvent.startDate,
+ title: "Evening Event",
+ });
+
+ await calendar.deleteItem(eveningEvent);
+ checkEvents();
+});
+
+/**
+ * Check events that begin and end on different days inside the date range.
+ * All-day events are still sorted ahead of non-all-day events.
+ */
+add_task(async function testOverlapInside() {
+ let allDayEvent = await addEvent("All Day Event", "P0D", "P2D", true);
+ checkEvents(
+ { dateHeader: "Today", title: "All Day Event", overlap: "start" },
+ { dateHeader: "Tomorrow", title: "All Day Event", overlap: "end" }
+ );
+
+ let timedEvent = await addEvent("Timed Event", "P1H", "P1D23H");
+ checkEvents(
+ { dateHeader: "Today", title: "All Day Event", overlap: "start" },
+ { time: timedEvent.startDate, title: "Timed Event", overlap: "start" },
+ { dateHeader: "Tomorrow", title: "All Day Event", overlap: "end" },
+ { time: timedEvent.endDate, title: "Timed Event", overlap: "end" }
+ );
+
+ await calendar.deleteItem(allDayEvent);
+ await calendar.deleteItem(timedEvent);
+});
+
+/**
+ * Check events that begin and end on different days and that end at midnight.
+ * The list item for the end of the event should be the last one on the day
+ * before the end midnight, and its time label should display "24:00".
+ */
+add_task(async function testOverlapEndAtMidnight() {
+ // Start with an event that begins outside the displayed dates.
+
+ let timedEvent = await addEvent("Timed Event", "-P1D", "P1D");
+ // Ends an hour before `timedEvent` to prove the ordering is correct.
+ let duringEvent = await addEvent("During Event", "P22H", "P23H");
+ // Starts at the same time as `timedEvent` ends to prove the ordering is correct.
+ let nextEvent = await addEvent("Next Event", "P1D", "P2D", true);
+
+ checkEvents(
+ { dateHeader: "Today", time: duringEvent.startDate, title: "During Event" },
+ {
+ // Should show "24:00" as the time and end today.
+ time: cal.dtz.formatter.formatTime(timedEvent.endDate, true),
+ title: "Timed Event",
+ overlap: "end",
+ },
+ { dateHeader: "Tomorrow", title: "Next Event" }
+ );
+
+ // Move the event fully into the displayed range.
+
+ let timedClone = timedEvent.clone();
+ timedClone.startDate.day += 2;
+ timedClone.endDate.day += 2;
+ await calendar.modifyItem(timedClone, timedEvent);
+
+ let duringClone = duringEvent.clone();
+ duringClone.startDate.day += 2;
+ duringClone.endDate.day += 2;
+ await calendar.modifyItem(duringClone, duringEvent);
+
+ let nextClone = nextEvent.clone();
+ nextClone.startDate.day += 2;
+ nextClone.endDate.day += 2;
+ await calendar.modifyItem(nextClone, nextEvent);
+
+ let realEndDate = today.clone();
+ realEndDate.day += 2;
+ checkEvents(
+ {
+ dateHeader: "Tomorrow",
+ time: timedClone.startDate,
+ title: "Timed Event",
+ overlap: "start",
+ },
+ { dateHeader: realEndDate, time: duringClone.startDate, title: "During Event" },
+ {
+ // Should show "24:00" as the time and end on the day after tomorrow.
+ time: cal.dtz.formatter.formatTime(timedClone.endDate, true),
+ title: "Timed Event",
+ overlap: "end",
+ },
+ { dateHeader: nextClone.startDate, title: "Next Event" }
+ );
+
+ await calendar.deleteItem(timedClone);
+ await calendar.deleteItem(duringClone);
+ await calendar.deleteItem(nextClone);
+});
+
+/**
+ * Check events that begin and/or end outside the date range. Events that have
+ * already started are listed as "Today", but still sorted by start time.
+ * All-day events are still sorted ahead of non-all-day events.
+ */
+add_task(async function testOverlapOutside() {
+ let before = await addEvent("Starts Before", "-P1D", "P1D", true);
+ checkEvents({ dateHeader: "Today", title: "Starts Before", overlap: "end" });
+
+ let after = await addEvent("Ends After", "P0D", "P9D", true);
+ checkEvents(
+ { dateHeader: "Today", title: "Starts Before", overlap: "end" },
+ { title: "Ends After", overlap: "start" }
+ );
+
+ let both = await addEvent("Beyond Start and End", "-P2D", "P9D", true);
+ checkEvents(
+ { dateHeader: "Today", title: "Beyond Start and End", overlap: "continue" },
+ { title: "Starts Before", overlap: "end" },
+ { title: "Ends After", overlap: "start" }
+ );
+
+ // Change `before` to begin earlier than `both`. They should swap places.
+
+ let startClone = before.clone();
+ startClone.startDate.day -= 2;
+ await calendar.modifyItem(startClone, before);
+ checkEvents(
+ { dateHeader: "Today", title: "Starts Before", overlap: "end" },
+ { title: "Beyond Start and End", overlap: "continue" },
+ { title: "Ends After", overlap: "start" }
+ );
+
+ let beforeWithTime = await addEvent("Starts Before with time", "-PT5H", "PT15H");
+ checkEvents(
+ { dateHeader: "Today", title: "Starts Before", overlap: "end" },
+ { title: "Beyond Start and End", overlap: "continue" },
+ { title: "Ends After", overlap: "start" },
+ // This is the end of the event so the end time is used.
+ { time: beforeWithTime.endDate, title: "Starts Before with time", overlap: "end" }
+ );
+
+ let afterWithTime = await addEvent("Ends After with time", "PT6H", "P8DT12H");
+ checkEvents(
+ { dateHeader: "Today", title: "Starts Before", overlap: "end" },
+ { title: "Beyond Start and End", overlap: "continue" },
+ { title: "Ends After", overlap: "start" },
+ { time: afterWithTime.startDate, title: "Ends After with time", overlap: "start" },
+ // This is the end of the event so the end time is used.
+ { time: beforeWithTime.endDate, title: "Starts Before with time", overlap: "end" }
+ );
+
+ let bothWithTime = await addEvent("Beyond Start and End with time", "-P2DT10H", "P9DT1H");
+ checkEvents(
+ { dateHeader: "Today", title: "Starts Before", overlap: "end" },
+ { title: "Beyond Start and End", overlap: "continue" },
+ { title: "Ends After", overlap: "start" },
+ { time: "", title: "Beyond Start and End with time", overlap: "continue" },
+ { time: afterWithTime.startDate, title: "Ends After with time", overlap: "start" },
+ // This is the end of the event so the end time is used.
+ { time: beforeWithTime.endDate, title: "Starts Before with time", overlap: "end" }
+ );
+
+ await calendar.deleteItem(before);
+ await calendar.deleteItem(after);
+ await calendar.deleteItem(both);
+ await calendar.deleteItem(beforeWithTime);
+ await calendar.deleteItem(afterWithTime);
+ await calendar.deleteItem(bothWithTime);
+});
+
+/**
+ * Checks that events that happened earlier today are marked as in the past,
+ * and events happening now are marked as such.
+ *
+ * This test may fail if run within a minute either side of midnight.
+ *
+ * It would be nice to test that as time passes events are changed
+ * appropriately, but that means waiting around for minutes and probably won't
+ * be very reliable, so we don't do that.
+ */
+add_task(async function testActive() {
+ let now = cal.dtz.now();
+
+ let pastEvent = await addEvent("Past Event", "PT0M", "PT1M");
+ let presentEvent = await addEvent("Present Event", `PT${now.hour}H`, `PT${now.hour + 1}H`);
+ let futureEvent = await addEvent("Future Event", "PT23H59M", "PT24H");
+ checkEvents(
+ { dateHeader: "Today", time: pastEvent.startDate, title: "Past Event" },
+ { time: presentEvent.startDate, title: "Present Event" },
+ { time: futureEvent.startDate, title: "Future Event" }
+ );
+
+ let [pastRow, presentRow, futureRow] = TodayPane.agenda.rows;
+ Assert.ok(pastRow.classList.contains("agenda-listitem-past"), "past event is marked past");
+ Assert.ok(!pastRow.classList.contains("agenda-listitem-now"), "past event is not marked now");
+ Assert.ok(
+ !presentRow.classList.contains("agenda-listitem-past"),
+ "present event is not marked past"
+ );
+ Assert.ok(presentRow.classList.contains("agenda-listitem-now"), "present event is marked now");
+ Assert.ok(
+ !futureRow.classList.contains("agenda-listitem-past"),
+ "future event is not marked past"
+ );
+ Assert.ok(!futureRow.classList.contains("agenda-listitem-now"), "future event is not marked now");
+
+ await calendar.deleteItem(pastEvent);
+ await calendar.deleteItem(presentEvent);
+ await calendar.deleteItem(futureEvent);
+});
+
+/**
+ * Checks events in different time zones are displayed correctly.
+ */
+add_task(async function testOtherTimeZones() {
+ // Johannesburg is UTC+2.
+ let johannesburg = cal.timezoneService.getTimezone("Africa/Johannesburg");
+ // Panama is UTC-5.
+ let panama = cal.timezoneService.getTimezone("America/Panama");
+
+ // All-day events are displayed on the day of the event, the time zone is ignored.
+
+ let allDayEvent = new CalEvent();
+ allDayEvent.id = cal.getUUID();
+ allDayEvent.title = "All-day event in Johannesburg";
+ allDayEvent.startDate = cal.createDateTime();
+ allDayEvent.startDate.resetTo(today.year, today.month, today.day + 1, 0, 0, 0, johannesburg);
+ allDayEvent.startDate.isDate = true;
+ allDayEvent.endDate = cal.createDateTime();
+ allDayEvent.endDate.resetTo(today.year, today.month, today.day + 2, 0, 0, 0, johannesburg);
+ allDayEvent.endDate.isDate = true;
+ allDayEvent = await calendar.addItem(allDayEvent);
+
+ checkEvents({
+ dateHeader: "Tomorrow",
+ title: "All-day event in Johannesburg",
+ });
+
+ await calendar.deleteItem(allDayEvent);
+
+ // The event time must be displayed in the local time zone, and the event must be sorted correctly.
+
+ let beforeEvent = await addEvent("Before", "P1DT5H", "P1DT6H");
+ let afterEvent = await addEvent("After", "P1DT7H", "P1DT8H");
+
+ let timedEvent = new CalEvent();
+ timedEvent.id = cal.getUUID();
+ timedEvent.title = "Morning in Johannesburg";
+ timedEvent.startDate = cal.createDateTime();
+ timedEvent.startDate.resetTo(today.year, today.month, today.day + 1, 8, 0, 0, johannesburg);
+ timedEvent.endDate = cal.createDateTime();
+ timedEvent.endDate.resetTo(today.year, today.month, today.day + 1, 12, 0, 0, johannesburg);
+ timedEvent = await calendar.addItem(timedEvent);
+
+ checkEvents(
+ {
+ dateHeader: "Tomorrow",
+ time: beforeEvent.startDate,
+ title: "Before",
+ },
+ {
+ time: cal.dtz.formatter.formatTime(cal.createDateTime("20000101T060000Z")), // The date used here is irrelevant.
+ title: "Morning in Johannesburg",
+ },
+ {
+ time: afterEvent.startDate,
+ title: "After",
+ }
+ );
+ Assert.stringContains(
+ TodayPane.agenda.rows[1].querySelector(".agenda-listitem-time").getAttribute("datetime"),
+ "T08:00:00+02:00"
+ );
+
+ await calendar.deleteItem(beforeEvent);
+ await calendar.deleteItem(afterEvent);
+ await calendar.deleteItem(timedEvent);
+
+ // Events that cross midnight in the local time zone (but not in the event time zone)
+ // must have a start row and an end row.
+
+ let overnightEvent = new CalEvent();
+ overnightEvent.id = cal.getUUID();
+ overnightEvent.title = "Evening in Panama";
+ overnightEvent.startDate = cal.createDateTime();
+ overnightEvent.startDate.resetTo(today.year, today.month, today.day, 17, 0, 0, panama);
+ overnightEvent.endDate = cal.createDateTime();
+ overnightEvent.endDate.resetTo(today.year, today.month, today.day, 23, 0, 0, panama);
+ overnightEvent = await calendar.addItem(overnightEvent);
+
+ checkEvents(
+ {
+ dateHeader: "Today",
+ time: cal.dtz.formatter.formatTime(cal.createDateTime("20000101T220000Z")), // The date used here is irrelevant.
+ title: "Evening in Panama",
+ overlap: "start",
+ },
+ {
+ dateHeader: "Tomorrow",
+ time: cal.dtz.formatter.formatTime(cal.createDateTime("20000101T040000Z")), // The date used here is irrelevant.
+ title: "Evening in Panama",
+ overlap: "end",
+ }
+ );
+ Assert.stringContains(
+ TodayPane.agenda.rows[0].querySelector(".agenda-listitem-time").getAttribute("datetime"),
+ "T17:00:00-05:00"
+ );
+ Assert.stringContains(
+ TodayPane.agenda.rows[1].querySelector(".agenda-listitem-time").getAttribute("datetime"),
+ "T23:00:00-05:00"
+ );
+
+ await calendar.deleteItem(overnightEvent);
+});
+
+/**
+ * Checks events in different time zones are displayed correctly.
+ */
+add_task(async function testRelativeTime() {
+ let formatter = new Intl.RelativeTimeFormat(undefined, { style: "short" });
+ let now = cal.dtz.now();
+ now.second = 0;
+ info(`The time is now ${now}`);
+
+ let testData = [
+ {
+ name: "two hours ago",
+ start: "-PT1H55M",
+ expected: {
+ classes: ["agenda-listitem-past"],
+ },
+ minHour: 2,
+ },
+ {
+ name: "one hour ago",
+ start: "-PT1H5M",
+ expected: {
+ classes: ["agenda-listitem-past"],
+ },
+ minHour: 2,
+ },
+ {
+ name: "23 minutes ago",
+ start: "-PT23M",
+ expected: {
+ classes: ["agenda-listitem-past"],
+ },
+ minHour: 1,
+ },
+ {
+ name: "now",
+ start: "-PT5M",
+ expected: {
+ relative: ["now"],
+ classes: ["agenda-listitem-now"],
+ },
+ minHour: 1,
+ maxHour: 22,
+ },
+ {
+ name: "19 minutes ahead",
+ start: "PT19M",
+ expected: {
+ relative: [formatter.format(19, "minute"), formatter.format(18, "minute")],
+ },
+ maxHour: 22,
+ },
+ {
+ name: "one hour ahead",
+ start: "PT1H25M",
+ expected: {
+ relative: [formatter.format(85, "minute"), formatter.format(84, "minute")],
+ },
+ maxHour: 21,
+ },
+ {
+ name: "one and half hours ahead",
+ start: "PT1H35M",
+ expected: {
+ relative: [formatter.format(2, "hour")],
+ },
+ maxHour: 21,
+ },
+ {
+ name: "two hours ahead",
+ start: "PT1H49M",
+ expected: {
+ relative: [formatter.format(2, "hour")],
+ },
+ maxHour: 21,
+ },
+ ];
+
+ let events = [];
+ let expectedEvents = [];
+ for (let { name, start, expected, minHour, maxHour } of testData) {
+ if (minHour && now.hour < minHour) {
+ info(`Skipping ${name} because it's too early.`);
+ continue;
+ }
+ if (maxHour && now.hour > maxHour) {
+ info(`Skipping ${name} because it's too late.`);
+ continue;
+ }
+
+ let event = new CalEvent();
+ event.id = cal.getUUID();
+ event.title = name;
+ event.startDate = now.clone();
+ event.startDate.addDuration(cal.createDuration(start));
+ event.endDate = event.startDate.clone();
+ event.endDate.addDuration(cal.createDuration("PT10M"));
+ events.push(await calendar.addItem(event));
+
+ expectedEvents.push({ ...expected, title: name, time: event.startDate });
+ }
+
+ expectedEvents[0].dateHeader = "Today";
+ checkEvents(...expectedEvents);
+
+ for (let event of events) {
+ await calendar.deleteItem(event);
+ }
+});
+
+/**
+ * Tests the today pane opens events in the summary dialog for both
+ * non-recurring and recurring events.
+ */
+add_task(async function testOpenEvent() {
+ let noRepeatEvent = new CalEvent();
+ noRepeatEvent.id = "no repeat event";
+ noRepeatEvent.title = "No Repeat Event";
+ noRepeatEvent.startDate = today.clone();
+ noRepeatEvent.startDate.hour = startHour;
+ noRepeatEvent.endDate = noRepeatEvent.startDate.clone();
+ noRepeatEvent.endDate.hour++;
+
+ let repeatEvent = new CalEvent();
+ repeatEvent.id = "repeated event";
+ repeatEvent.title = "Repeated Event";
+ repeatEvent.startDate = today.clone();
+ repeatEvent.startDate.hour = startHour;
+ repeatEvent.endDate = noRepeatEvent.startDate.clone();
+ repeatEvent.endDate.hour++;
+ repeatEvent.recurrenceInfo = new CalRecurrenceInfo(repeatEvent);
+ repeatEvent.recurrenceInfo.appendRecurrenceItem(
+ cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=5")
+ );
+
+ for (let event of [noRepeatEvent, repeatEvent]) {
+ let addedEvent = await calendar.addItem(event);
+
+ if (event == noRepeatEvent) {
+ Assert.equal(TodayPane.agenda.rowCount, 1);
+ } else {
+ Assert.equal(TodayPane.agenda.rowCount, 5);
+ }
+ Assert.equal(
+ TodayPane.agenda.rows[0].querySelector(".agenda-listitem-title").textContent,
+ event.title,
+ "event title is correct"
+ );
+
+ let dialogWindowPromise = CalendarTestUtils.waitForEventDialog();
+ EventUtils.synthesizeMouseAtCenter(TodayPane.agenda.rows[0], { clickCount: 2 });
+
+ let dialogWindow = await dialogWindowPromise;
+ let docUri = dialogWindow.document.documentURI;
+ Assert.ok(
+ docUri === "chrome://calendar/content/calendar-summary-dialog.xhtml",
+ "event summary dialog shown"
+ );
+
+ await BrowserTestUtils.closeWindow(dialogWindow);
+ await calendar.deleteItem(addedEvent);
+ }
+});
+
+/**
+ * Tests that the "New Event" button begins creating an event on the date
+ * selected in the Today Pane.
+ */
+add_task(async function testNewEvent() {
+ async function checkEventDialogDate() {
+ let dialogWindowPromise = CalendarTestUtils.waitForEventDialog("edit");
+ EventUtils.synthesizeMouseAtCenter(newEventButton, {}, window);
+ await dialogWindowPromise.then(async function (dialogWindow) {
+ let iframe = dialogWindow.document.querySelector("#calendar-item-panel-iframe");
+ let iframeDocument = iframe.contentDocument;
+
+ let startDate = iframeDocument.getElementById("event-starttime");
+ Assert.equal(
+ startDate._datepicker._inputField.value,
+ formatDate(expectedDate),
+ "date should match the expected date"
+ );
+ Assert.equal(
+ startDate._timepicker._inputField.value,
+ formatTime(expectedDate),
+ "time should be the next hour after now"
+ );
+
+ await BrowserTestUtils.closeWindow(dialogWindow);
+ });
+ }
+
+ let newEventButton = document.getElementById("todaypane-new-event-button");
+
+ // Check today with the "day" view.
+
+ TodayPane.displayMiniSection("miniday");
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("today-button"), {}, window);
+
+ let expectedDate = cal.dtz.now();
+ expectedDate.hour++;
+ expectedDate.minute = 0;
+
+ await checkEventDialogDate();
+
+ // Check tomorrow with the "day" view.
+
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("next-day-button"), {}, window);
+ expectedDate.day++;
+
+ await checkEventDialogDate();
+
+ // Check today with the "month" view;
+
+ TodayPane.displayMiniSection("minimonth");
+ let minimonth = document.getElementById("today-minimonth");
+ minimonth.value = new Date();
+ expectedDate.day--;
+
+ await checkEventDialogDate();
+
+ // Check a date in the past with the "month" view;
+
+ minimonth.value = new Date(Date.UTC(2018, 8, 1));
+ expectedDate.resetTo(2018, 8, 1, expectedDate.hour, 0, 0, cal.dtz.UTC);
+
+ await checkEventDialogDate();
+}).__skipMe = new Date().getUTCHours() == 23;
diff --git a/comm/calendar/test/browser/browser_todayPane_dragAndDrop.js b/comm/calendar/test/browser/browser_todayPane_dragAndDrop.js
new file mode 100644
index 0000000000..bac91ed60d
--- /dev/null
+++ b/comm/calendar/test/browser/browser_todayPane_dragAndDrop.js
@@ -0,0 +1,262 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for drag and drop on the today pane.
+ */
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+const {
+ add_message_to_folder,
+ be_in_folder,
+ create_folder,
+ create_message,
+ inboxFolder,
+ select_click_row,
+} = ChromeUtils.import("resource://testing-common/mozmill/FolderDisplayHelpers.jsm");
+const { SyntheticPartLeaf } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+const calendar = CalendarTestUtils.createCalendar("Mochitest", "memory");
+registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar));
+
+/**
+ * Ensures the today pane is visible for each test.
+ */
+async function ensureTodayPane() {
+ let todayPane = document.querySelector("#today-pane-panel");
+ if (!todayPane.isVisible()) {
+ todayPane.setVisible(true, true, true);
+ }
+
+ await TestUtils.waitForCondition(() => todayPane.isVisible(), "today pane not visible in time");
+}
+
+/**
+ * Tests dropping a message from the message pane on to the today pane brings
+ * up the new event dialog.
+ */
+add_task(async function testDropMozMessage() {
+ let folder = await create_folder("Mochitest");
+ let subject = "The Grand Event";
+ let body = "Parking is available.";
+ await be_in_folder(folder);
+ await add_message_to_folder([folder], create_message({ subject, body: { body } }));
+ select_click_row(0);
+
+ let about3PaneTab = document.getElementById("tabmail").currentTabInfo;
+ let msg = about3PaneTab.message;
+ let msgStr = about3PaneTab.folder.getUriForMsg(msg);
+ let msgUrl = MailServices.messageServiceFromURI(msgStr).getUrlForUri(msgStr);
+
+ // Setup a DataTransfer to mimic what ThreadPaneOnDragStart sends.
+ let dataTransfer = new DataTransfer();
+ dataTransfer.mozSetDataAt("text/x-moz-message", msgStr, 0);
+ dataTransfer.mozSetDataAt("text/x-moz-url", msgUrl.spec, 0);
+ dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise-url",
+ msgUrl.spec + "?fileName=" + encodeURIComponent("message.eml"),
+ 0
+ );
+ dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise",
+ new window.messageFlavorDataProvider(),
+ 0
+ );
+
+ let promise = CalendarTestUtils.waitForEventDialog("edit");
+ await ensureTodayPane();
+ document.querySelector("#agenda").dispatchEvent(new DragEvent("drop", { dataTransfer }));
+
+ let eventWindow = await promise;
+ let iframe = eventWindow.document.querySelector("#calendar-item-panel-iframe");
+ let iframeDoc = iframe.contentDocument;
+
+ Assert.equal(
+ iframeDoc.querySelector("#item-title").value,
+ subject,
+ "the message subject was used as the event title"
+ );
+ Assert.equal(
+ iframeDoc.querySelector("#item-description").contentDocument.body.innerText,
+ body,
+ "the message body was used as the event description"
+ );
+
+ await BrowserTestUtils.closeWindow(eventWindow);
+ await be_in_folder(inboxFolder);
+ folder.deleteSelf(null);
+});
+
+/**
+ * Tests dropping an entry from the address book adds the address as an attendee
+ * to a new event when dropped on the today pane.
+ */
+add_task(async function testMozAddressDrop() {
+ let vcard = CalendarTestUtils.dedent`
+ BEGIN:VCARD
+ VERSION:4.0
+ EMAIL;PREF=1:person@example.com
+ FN:Some Person
+ N:Some;Person;;;
+ UID:d5f9113d-5ede-4a5c-ba8e-0f2345369993
+ END:VCARD
+ `;
+
+ let address = "Some Person <person@example.com>";
+
+ // Setup a DataTransfer to mimic what the address book sends.
+ let dataTransfer = new DataTransfer();
+ dataTransfer.setData("moz/abcard", "0");
+ dataTransfer.setData("text/x-moz-address", address);
+ dataTransfer.setData("text/plain", address);
+ dataTransfer.setData("text/vcard", decodeURIComponent(vcard));
+ dataTransfer.setData("application/x-moz-file-promise-dest-filename", "person.vcf");
+ dataTransfer.setData("application/x-moz-file-promise-url", "data:text/vcard," + vcard);
+ dataTransfer.setData("application/x-moz-file-promise", window.abFlavorDataProvider);
+
+ let promise = CalendarTestUtils.waitForEventDialog("edit");
+ await ensureTodayPane();
+ document.querySelector("#agenda").dispatchEvent(new DragEvent("drop", { dataTransfer }));
+
+ let eventWindow = await promise;
+ let iframe = eventWindow.document.querySelector("#calendar-item-panel-iframe");
+ let iframeWin = iframe.cotnentWindow;
+ let iframeDoc = iframe.contentDocument;
+
+ // Verify the address was added as an attendee.
+ EventUtils.synthesizeMouseAtCenter(
+ iframeDoc.querySelector("#event-grid-tab-attendees"),
+ {},
+ iframeWin
+ );
+
+ let box = iframeDoc.querySelector('[attendeeid="mailto:person@example.com"]');
+ Assert.ok(box, "address included as an attendee to the new event");
+ await BrowserTestUtils.closeWindow(eventWindow);
+});
+
+/**
+ * Tests dropping plain text that is actually ics data format is picked up by
+ * the today pane.
+ */
+add_task(async function testPlainTextICSDrop() {
+ let event = CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ SUMMARY:An Event
+ DESCRIPTION:Parking is not available.
+ DTSTART:20210325T110000Z
+ DTEND:20210325T120000Z
+ UID:916bd967-35ac-40f6-8cd5-487739c9d245
+ END:VEVENT
+ END:VCALENDAR
+ `;
+
+ // Setup a DataTransfer to mimic what the address book sends.
+ let dataTransfer = new DataTransfer();
+ dataTransfer.setData("text/plain", event);
+
+ let promise = CalendarTestUtils.waitForEventDialog("edit");
+ await ensureTodayPane();
+ document.querySelector("#agenda").dispatchEvent(new DragEvent("drop", { dataTransfer }));
+
+ let eventWindow = await promise;
+ let iframe = eventWindow.document.querySelector("#calendar-item-panel-iframe");
+ let iframeDoc = iframe.contentDocument;
+ Assert.equal(iframeDoc.querySelector("#item-title").value, "An Event");
+
+ let startTime = iframeDoc.querySelector("#event-starttime");
+ Assert.equal(
+ startTime._datepicker._inputBoxValue,
+ cal.dtz.formatter.formatDateShort(cal.createDateTime("20210325T110000Z"))
+ );
+
+ let endTime = iframeDoc.querySelector("#event-endtime");
+ Assert.equal(
+ endTime._datepicker._inputBoxValue,
+ cal.dtz.formatter.formatDateShort(cal.createDateTime("20210325T120000Z"))
+ );
+
+ Assert.equal(
+ iframeDoc.querySelector("#item-description").contentDocument.body.innerText,
+ "Parking is not available."
+ );
+ await BrowserTestUtils.closeWindow(eventWindow);
+});
+
+/**
+ * Tests dropping a file with an ics extension on the today pane is parsed as an
+ * ics file.
+ */
+add_task(async function testICSFileDrop() {
+ let file = await File.createFromFileName(getTestFilePath("data/event.ics"));
+ let dataTransfer = new DataTransfer();
+ dataTransfer.items.add(file);
+
+ let promise = CalendarTestUtils.waitForEventDialog("edit");
+ await ensureTodayPane();
+
+ // For some reason, dataTransfer.items.add() results in a mozItemCount of 2
+ // instead of one. Call onExternalDrop directly to get around that.
+ window.calendarCalendarButtonDNDObserver.onExternalDrop(dataTransfer);
+
+ let eventWindow = await promise;
+ let iframe = eventWindow.document.querySelector("#calendar-item-panel-iframe");
+ let iframeDoc = iframe.contentDocument;
+
+ Assert.equal(iframeDoc.querySelector("#item-title").value, "An Event");
+
+ let startTime = iframeDoc.querySelector("#event-starttime");
+ Assert.equal(
+ startTime._datepicker._inputBoxValue,
+ cal.dtz.formatter.formatDateShort(cal.createDateTime("20210325T110000Z"))
+ );
+
+ let endTime = iframeDoc.querySelector("#event-endtime");
+ Assert.equal(
+ endTime._datepicker._inputBoxValue,
+ cal.dtz.formatter.formatDateShort(cal.createDateTime("20210325T120000Z"))
+ );
+
+ Assert.equal(
+ iframeDoc.querySelector("#item-description").contentDocument.body.innerText,
+ "Parking is not available."
+ );
+ await BrowserTestUtils.closeWindow(eventWindow);
+});
+
+/**
+ * Tests dropping any other file on the today pane ends up as an attachment
+ * to a new event.
+ */
+add_task(async function testOtherFileDrop() {
+ let file = await File.createFromNsIFile(
+ new FileUtils.File(getTestFilePath("data/attachment.png"))
+ );
+ let dataTransfer = new DataTransfer();
+ dataTransfer.setData("image/png", file);
+ dataTransfer.items.add(file);
+
+ let promise = CalendarTestUtils.waitForEventDialog("edit");
+ await ensureTodayPane();
+ document.querySelector("#agenda").dispatchEvent(new DragEvent("drop", { dataTransfer }));
+
+ let eventWindow = await promise;
+ let iframe = eventWindow.document.querySelector("#calendar-item-panel-iframe");
+ let iframeWin = iframe.contentWindow;
+ let iframeDoc = iframe.contentDocument;
+
+ EventUtils.synthesizeMouseAtCenter(
+ iframeDoc.querySelector("#event-grid-tab-attachments"),
+ {},
+ iframeWin
+ );
+
+ let listBox = iframeDoc.querySelector("#attachment-link");
+ let listItem = listBox.itemChildren[0];
+ Assert.equal(listItem.querySelector("label").value, "attachment.png");
+ await BrowserTestUtils.closeWindow(eventWindow);
+});
diff --git a/comm/calendar/test/browser/browser_todayPane_visibility.js b/comm/calendar/test/browser/browser_todayPane_visibility.js
new file mode 100644
index 0000000000..d2176218ed
--- /dev/null
+++ b/comm/calendar/test/browser/browser_todayPane_visibility.js
@@ -0,0 +1,167 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals openAddonsTab, openChatTab, openNewCalendarEventTab,
+ * openNewCalendarTaskTab, openPreferencesTab, openTasksTab,
+ * selectCalendarEventTab, selectCalendarTaskTab, selectFolderTab,
+ * toAddressBook */
+
+// Test that today pane is visible/collapsed correctly for various tab types.
+// In all cases today pane should not be visible in preferences or addons tab.
+// Also test that the today pane button is visible/hidden for various tab types.
+add_task(async () => {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ const todayPane = document.getElementById("today-pane-panel");
+ const todayPaneButton = document.getElementById("calendar-status-todaypane-button");
+
+ let eventTabPanelId, taskTabPanelId;
+
+ async function clickTodayPaneButton() {
+ // The today pane button will be hidden for certain tabs (e.g. preferences), and then
+ // the user won't be able to click it, so we shouldn't be able to here either.
+ if (BrowserTestUtils.is_visible(todayPaneButton)) {
+ EventUtils.synthesizeMouseAtCenter(todayPaneButton, { clickCount: 1 });
+ }
+ await new Promise(resolve => setTimeout(resolve));
+ }
+
+ /**
+ * Tests whether the today pane is only open in certain tabs.
+ *
+ * @param {string[]} tabsWhereVisible - Array of tab mode names for tabs where
+ * the today pane should be visible.
+ */
+ async function checkTodayPaneVisibility(tabsWhereVisible) {
+ function check(tabModeName) {
+ let shouldBeVisible = tabsWhereVisible.includes(tabModeName);
+ is(
+ BrowserTestUtils.is_visible(todayPane),
+ shouldBeVisible,
+ `today pane is ${shouldBeVisible ? "visible" : "collapsed"} in ${tabModeName} tab`
+ );
+ }
+
+ await selectFolderTab();
+ check("folder");
+ await CalendarTestUtils.openCalendarTab(window);
+ check("calendar");
+ await openTasksTab();
+ check("tasks");
+ await openChatTab();
+ check("chat");
+ await selectCalendarEventTab(eventTabPanelId);
+ check("calendarEvent");
+ await selectCalendarTaskTab(taskTabPanelId);
+ check("calendarTask");
+ await toAddressBook();
+ check("addressBookTab");
+ await openPreferencesTab();
+ check("preferencesTab");
+ await openAddonsTab();
+ check("contentTab");
+ }
+
+ // Show today pane in folder (mail) tab, but not in other tabs.
+ await selectFolderTab();
+ if (!BrowserTestUtils.is_visible(todayPane)) {
+ await clickTodayPaneButton();
+ }
+ await CalendarTestUtils.openCalendarTab(window);
+ if (BrowserTestUtils.is_visible(todayPane)) {
+ await clickTodayPaneButton();
+ }
+ await openTasksTab();
+ if (BrowserTestUtils.is_visible(todayPane)) {
+ await clickTodayPaneButton();
+ }
+ await openChatTab();
+ if (BrowserTestUtils.is_visible(todayPane)) {
+ await clickTodayPaneButton();
+ }
+ eventTabPanelId = await openNewCalendarEventTab();
+ if (BrowserTestUtils.is_visible(todayPane)) {
+ await clickTodayPaneButton();
+ }
+ taskTabPanelId = await openNewCalendarTaskTab();
+ if (BrowserTestUtils.is_visible(todayPane)) {
+ await clickTodayPaneButton();
+ }
+
+ await checkTodayPaneVisibility(["folder"]);
+
+ // Show today pane in calendar tab, but not in other tabs.
+ // Hide it in folder tab.
+ await selectFolderTab();
+ await clickTodayPaneButton();
+ // Show it in calendar tab.
+ await CalendarTestUtils.openCalendarTab(window);
+ await clickTodayPaneButton();
+
+ await checkTodayPaneVisibility(["calendar"]);
+
+ // Show today pane in tasks tab, but not in other tabs.
+ // Hide it in calendar tab.
+ await CalendarTestUtils.openCalendarTab(window);
+ await clickTodayPaneButton();
+ // Show it in tasks tab.
+ await openTasksTab();
+ await clickTodayPaneButton();
+
+ await checkTodayPaneVisibility(["tasks"]);
+
+ // Show today pane in chat tab, but not in other tabs.
+ // Hide it in tasks tab.
+ await openTasksTab();
+ await clickTodayPaneButton();
+ // Show it in chat tab.
+ await openChatTab();
+ await clickTodayPaneButton();
+
+ await checkTodayPaneVisibility(["chat"]);
+
+ // Show today pane in calendar event tab, but not in other tabs.
+ // Hide it in chat tab.
+ await openChatTab();
+ await clickTodayPaneButton();
+ // Show it in calendar event tab.
+ await selectCalendarEventTab(eventTabPanelId);
+ await clickTodayPaneButton();
+
+ await checkTodayPaneVisibility(["calendarEvent"]);
+
+ // Show today pane in calendar task tab, but not in other tabs.
+ // Hide it in calendar event tab.
+ await selectCalendarEventTab(eventTabPanelId);
+ await clickTodayPaneButton();
+ // Show it in calendar task tab.
+ await selectCalendarTaskTab(taskTabPanelId);
+ await clickTodayPaneButton();
+
+ await checkTodayPaneVisibility(["calendarTask"]);
+
+ // Check the visibility of the today pane button.
+ const button = document.getElementById("calendar-status-todaypane-button");
+ await selectFolderTab();
+ ok(BrowserTestUtils.is_visible(button), "today pane button is visible in folder tab");
+ await CalendarTestUtils.openCalendarTab(window);
+ ok(BrowserTestUtils.is_visible(button), "today pane button is visible in calendar tab");
+ await openTasksTab();
+ ok(BrowserTestUtils.is_visible(button), "today pane button is visible in tasks tab");
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(button), "today pane button is visible in chat tab");
+ await selectCalendarEventTab(eventTabPanelId);
+ ok(BrowserTestUtils.is_visible(button), "today pane button is visible in event tab");
+ await selectCalendarTaskTab(taskTabPanelId);
+ ok(BrowserTestUtils.is_visible(button), "today pane button is visible in task tab");
+ await toAddressBook();
+ is(BrowserTestUtils.is_visible(button), false, "today pane button is hidden in address book tab");
+ await openPreferencesTab();
+ is(BrowserTestUtils.is_visible(button), false, "today pane button is hidden in preferences tab");
+ await openAddonsTab();
+ is(BrowserTestUtils.is_visible(button), false, "today pane button is hidden in addons tab");
+});
diff --git a/comm/calendar/test/browser/contextMenu/browser.ini b/comm/calendar/test/browser/contextMenu/browser.ini
new file mode 100644
index 0000000000..b6590e849c
--- /dev/null
+++ b/comm/calendar/test/browser/contextMenu/browser.ini
@@ -0,0 +1,14 @@
+[default]
+prefs =
+ calendar.item.promptDelete=false
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+
+[browser_edit.js]
diff --git a/comm/calendar/test/browser/contextMenu/browser_edit.js b/comm/calendar/test/browser/contextMenu/browser_edit.js
new file mode 100644
index 0000000000..672055709f
--- /dev/null
+++ b/comm/calendar/test/browser/contextMenu/browser_edit.js
@@ -0,0 +1,187 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+/**
+ * Grabs a calendar-month-day-box-item from the view using an attribute CSS
+ * selector. Only works when the calendar is in month view.
+ */
+async function getDayBoxItem(attrSelector) {
+ let itemBox;
+ await TestUtils.waitForCondition(() => {
+ itemBox = document.querySelector(
+ `calendar-month-day-box[${attrSelector}] calendar-month-day-box-item`
+ );
+ return itemBox != null;
+ }, "calendar item did not appear in time");
+ return itemBox;
+}
+
+/**
+ * Switches to the view to the calendar.
+ */
+add_setup(function () {
+ return CalendarTestUtils.setCalendarView(window, "month");
+});
+
+/**
+ * Tests the "Edit" menu item is available and opens up the event dialog.
+ */
+add_task(async function testEditEditableItem() {
+ let calendar = CalendarTestUtils.createCalendar("Editable", "memory");
+ registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar));
+
+ let title = "Editable Event";
+ let event = new CalEvent();
+ event.title = title;
+ event.startDate = cal.createDateTime("20200101T000001Z");
+
+ await calendar.addItem(event);
+ window.goToDate(event.startDate);
+
+ let menu = document.querySelector("#calendar-item-context-menu");
+ let editMenu = document.querySelector("#calendar-item-context-menu-modify-menuitem");
+ let popupPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+
+ EventUtils.synthesizeMouseAtCenter(await getDayBoxItem('day="1"'), { type: "contextmenu" });
+ await popupPromise;
+ Assert.ok(!editMenu.disabled, 'context menu "Edit" item is not disabled for editable event');
+
+ let editDialogPromise = BrowserTestUtils.domWindowOpened(null, async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+
+ let doc = win.document;
+ Assert.ok(
+ doc.documentURI == "chrome://calendar/content/calendar-event-dialog.xhtml",
+ "editing event dialog opened"
+ );
+
+ let iframe = doc.querySelector("#calendar-item-panel-iframe");
+ await BrowserTestUtils.waitForEvent(iframe.contentWindow, "load");
+
+ let iframeDoc = iframe.contentDocument;
+ Assert.ok(
+ (iframeDoc.querySelector("#item-title").value = title),
+ 'context menu "Edit" item opens the editing dialog'
+ );
+ doc.querySelector("dialog").acceptDialog();
+ return true;
+ });
+
+ menu.activateItem(editMenu);
+ await editDialogPromise;
+});
+
+/**
+ * Tests that the "Edit" menu item is disabled for events we are not allowed to
+ * modify.
+ */
+add_task(async function testEditNonEditableItem() {
+ let calendar = CalendarTestUtils.createCalendar("Non-Editable", "memory");
+ registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar));
+
+ let event = new CalEvent();
+ let acl = {
+ QueryInterface: ChromeUtils.generateQI(["calIItemACLEntry"]),
+ userCanModify: false,
+ userCanRespond: true,
+ userCanViewAll: true,
+ userCanViewDateAndTime: true,
+ calendarEntry: {
+ hasAccessControl: true,
+ userIsOwner: false,
+ },
+ };
+ event.title = "Read Only Event";
+ event.startDate = cal.createDateTime("20200102T000001Z");
+ event.mACLEntry = acl;
+
+ await calendar.addItem(event);
+ window.goToDate(event.startDate);
+
+ let menu = document.querySelector("#calendar-item-context-menu");
+ let editMenu = document.querySelector("#calendar-item-context-menu-modify-menuitem");
+ let popupPromise = BrowserTestUtils.waitForEvent(menu, "popupshowing");
+
+ EventUtils.synthesizeMouseAtCenter(await getDayBoxItem('day="2"'), { type: "contextmenu" });
+ await popupPromise;
+ Assert.ok(editMenu.disabled, 'context menu "Edit" item is disabled for non-editable event');
+ menu.hidePopup();
+});
+
+/**
+ * Tests that the "Edit" menu item is disabled when the event is an invitation.
+ */
+add_task(async function testInvitation() {
+ let calendar = CalendarTestUtils.createCalendar("Invitation", "memory");
+ calendar.setProperty("organizerId", "mailto:attendee@example.com");
+ registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar));
+
+ let icalString = CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ CREATED:20200103T152601Z
+ DTSTAMP:20200103T192729Z
+ UID:x131e
+ SUMMARY:Invitation
+ ORGANIZER;CN=Org:mailto:organizer@example.com
+ ATTENDEE;RSVP=TRUE;CN=attendee@example.com;PARTSTAT=NEEDS-ACTION;CUTY
+ PE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;X-NUM-GUESTS=0:mailto:attendee@example.com
+ DTSTART:20200103T153000Z
+ DTEND:20200103T163000Z
+ DESCRIPTION:Just a Test
+ SEQUENCE:0
+ TRANSP:OPAQUE
+ END:VEVENT
+ `;
+
+ let invitation = new CalEvent(icalString);
+ await calendar.addItem(invitation);
+ window.goToDate(invitation.startDate);
+
+ let menu = document.querySelector("#calendar-item-context-menu");
+ let editMenu = document.querySelector("#calendar-item-context-menu-modify-menuitem");
+ let popupPromise = BrowserTestUtils.waitForEvent(menu, "popupshowing");
+
+ EventUtils.synthesizeMouseAtCenter(await getDayBoxItem('day="3"'), { type: "contextmenu" });
+ await popupPromise;
+ Assert.ok(editMenu.disabled, 'context menu "Edit" item is disabled for invitations');
+ menu.hidePopup();
+});
+
+/**
+ * Tests that the "Edit" menu item is disabled when the calendar is read-only.
+ */
+add_task(async function testCalendarReadOnly() {
+ let calendar = CalendarTestUtils.createCalendar("ReadOnly", "memory");
+ registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar));
+
+ let event = new CalEvent();
+ event.title = "ReadOnly Event";
+ event.startDate = cal.createDateTime("20200104T000001Z");
+
+ await calendar.addItem(event);
+ calendar.setProperty("readOnly", true);
+ window.goToDate(event.startDate);
+
+ let menu = document.querySelector("#calendar-item-context-menu");
+ let editMenu = document.querySelector("#calendar-item-context-menu-modify-menuitem");
+ let popupPromise = BrowserTestUtils.waitForEvent(menu, "popupshowing");
+
+ EventUtils.synthesizeMouseAtCenter(await getDayBoxItem('day="4"'), { type: "contextmenu" });
+ await popupPromise;
+ Assert.ok(editMenu.disabled, 'context menu "Edit" item is disabled when calendar is read-only');
+ menu.hidePopup();
+});
+
+registerCleanupFunction(() => {
+ return CalendarTestUtils.closeCalendarTab(window);
+});
diff --git a/comm/calendar/test/browser/data/attachment.png b/comm/calendar/test/browser/data/attachment.png
new file mode 100644
index 0000000000..30caecab7b
--- /dev/null
+++ b/comm/calendar/test/browser/data/attachment.png
Binary files differ
diff --git a/comm/calendar/test/browser/data/calendars.sjs b/comm/calendar/test/browser/data/calendars.sjs
new file mode 100644
index 0000000000..f1175f1903
--- /dev/null
+++ b/comm/calendar/test/browser/data/calendars.sjs
@@ -0,0 +1,126 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+Cu.importGlobalProperties(["TextEncoder"]);
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml; charset=utf-8", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <resourcetype/>
+ // <displayname/>
+ // <current-user-privilege-set/>
+ // <calendar-color/>
+ // </prop>
+ // </propfind>
+
+ let res = `<multistatus xmlns="DAV:"
+ xmlns:A="http://apple.com/ns/ical/"
+ xmlns:C="urn:ietf:params:xml:ns:caldav"
+ xmlns:CS="http://calendarserver.org/ns/">
+ <response>
+ <href>/browser/comm/calendar/test/browser/data/calendars.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ </resourcetype>
+ <displayname>Things found by DNS</displayname>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <current-user-privilege-set/>
+ <A:calendar-color/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ <response>
+ <href>/browser/comm/calendar/test/browser/data/calendar.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ <C:calendar/>
+ <CS:shared/>
+ </resourcetype>
+ <displayname>You found me!</displayname>
+ <A:calendar-color>#008000</A:calendar-color>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <current-user-privilege-set/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ <response>
+ <href>/browser/comm/calendar/test/browser/data/calendar2.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ <C:calendar/>
+ <CS:shared/>
+ </resourcetype>
+ <displayname>RΓΆda dagar</displayname>
+ <A:calendar-color>#ff0000</A:calendar-color>
+ <current-user-privilege-set>
+ <privilege>
+ <read/>
+ </privilege>
+ <privilege>
+ <C:read-free-busy/>
+ </privilege>
+ <privilege>
+ <read-current-user-privilege-set/>
+ </privilege>
+ <privilege>
+ <write/>
+ </privilege>
+ <privilege>
+ <write-content/>
+ </privilege>
+ <privilege>
+ <write-properties/>
+ </privilege>
+ <privilege>
+ <bind/>
+ </privilege>
+ <privilege>
+ <unbind/>
+ </privilege>
+ </current-user-privilege-set>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <current-user-privilege-set/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ </multistatus>`;
+
+ let bytes = new TextEncoder().encode(res);
+ let str = "";
+ for (let i = 0; i < bytes.length; i += 65536) {
+ str += String.fromCharCode.apply(null, bytes.subarray(i, i + 65536));
+ }
+ response.write(str);
+}
diff --git a/comm/calendar/test/browser/data/dns.sjs b/comm/calendar/test/browser/data/dns.sjs
new file mode 100644
index 0000000000..85b8777233
--- /dev/null
+++ b/comm/calendar/test/browser/data/dns.sjs
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <resourcetype/>
+ // <owner/>
+ // <displayname/>
+ // <current-user-principal/>
+ // <current-user-privilege-set/>
+ // <calendar-color/>
+ // <calendar-home-set/>
+ // </prop>
+ // </propfind>
+
+ response.write(`<multistatus xmlns="DAV:"
+ xmlns:A="http://apple.com/ns/ical/"
+ xmlns:C="urn:ietf:params:xml:ns:caldav">
+ <response>
+ <href>/browser/comm/calendar/test/browser/data/dns.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ </resourcetype>
+ <current-user-principal>
+ <href>/browser/comm/calendar/test/browser/data/principal.sjs</href>
+ </current-user-principal>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <owner/>
+ <displayname/>
+ <current-user-privilege-set/>
+ <A:calendar-color/>
+ <C:calendar-home-set/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ </multistatus>`);
+}
diff --git a/comm/calendar/test/browser/data/event.ics b/comm/calendar/test/browser/data/event.ics
new file mode 100644
index 0000000000..3ee7fd4495
--- /dev/null
+++ b/comm/calendar/test/browser/data/event.ics
@@ -0,0 +1,10 @@
+BEGIN:VCALENDAR
+PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:An Event
+DESCRIPTION:Parking is not available.
+DTSTART:20210325T110000Z
+DTEND:20210325T120000Z
+END:VEVENT
+END:VCALENDAR
diff --git a/comm/calendar/test/browser/data/import.ics b/comm/calendar/test/browser/data/import.ics
new file mode 100644
index 0000000000..b6e7a965d7
--- /dev/null
+++ b/comm/calendar/test/browser/data/import.ics
@@ -0,0 +1,24 @@
+BEGIN:VCALENDAR
+PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:Event One
+DTSTART:20190101T150000
+DTEND:20190101T160000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Four
+DTSTART:20190101T180000
+DTEND:20190101T190000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Three
+DTSTART:20190101T170000
+DTEND:20190101T180000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Two
+DTSTART:20190101T160000
+DTEND:20190101T170000
+END:VEVENT
+END:VCALENDAR
diff --git a/comm/calendar/test/browser/data/principal.sjs b/comm/calendar/test/browser/data/principal.sjs
new file mode 100644
index 0000000000..4cebd9660e
--- /dev/null
+++ b/comm/calendar/test/browser/data/principal.sjs
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <resourcetype/>
+ // <calendar-home-set/>
+ // </prop>
+ // </propfind>
+
+ response.write(`<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
+ <response>
+ <href>/browser/comm/calendar/test/browser/data/principal.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <principal/>
+ </resourcetype>
+ <C:calendar-home-set>
+ <href>/browser/comm/calendar/test/browser/data/calendars.sjs</href>
+ </C:calendar-home-set>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ </response>
+ </multistatus>`);
+}
diff --git a/comm/calendar/test/browser/eventDialog/browser.ini b/comm/calendar/test/browser/eventDialog/browser.ini
new file mode 100644
index 0000000000..85f569c0cc
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser.ini
@@ -0,0 +1,27 @@
+[default]
+head = head.js
+prefs =
+ calendar.item.promptDelete=false
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = data/**
+
+[browser_alarmDialog.js]
+[browser_attachMenu.js]
+[browser_attendeesDialog.js]
+[browser_attendeesDialogAdd.js]
+[browser_attendeesDialogNoEdit.js]
+[browser_attendeesDialogRemove.js]
+[browser_attendeesDialogUpdate.js]
+[browser_eventDialog.js]
+[browser_eventDialogDescriptionEditor.js]
+[browser_eventDialogEditButton.js]
+[browser_eventDialogModificationPrompt.js]
+[browser_utf8.js]
diff --git a/comm/calendar/test/browser/eventDialog/browser_alarmDialog.js b/comm/calendar/test/browser/eventDialog/browser_alarmDialog.js
new file mode 100644
index 0000000000..0d6a07a3c4
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_alarmDialog.js
@@ -0,0 +1,88 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { dayView } = CalendarTestUtils;
+
+add_task(async function testAlarmDialog() {
+ let now = new Date();
+
+ const TITLE = "Event";
+
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(
+ window,
+ now.getUTCFullYear(),
+ now.getUTCMonth() + 1,
+ now.getUTCDate()
+ );
+ await CalendarTestUtils.calendarViewForward(window, 1);
+
+ let allDayHeader = dayView.getAllDayHeader(window);
+ Assert.ok(allDayHeader);
+ EventUtils.synthesizeMouseAtCenter(allDayHeader, {}, window);
+
+ // Create a new all-day event tomorrow.
+
+ // Prepare to dismiss the alarm.
+ let alarmPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-alarm-dialog.xhtml",
+ {
+ async callback(alarmWindow) {
+ await new Promise(resolve => alarmWindow.setTimeout(resolve, 500));
+
+ let dismissButton = alarmWindow.document.getElementById("alarm-dismiss-all-button");
+ EventUtils.synthesizeMouseAtCenter(dismissButton, {}, alarmWindow);
+ },
+ }
+ );
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window);
+ await setData(dialogWindow, iframeWindow, {
+ allday: true,
+ reminder: "1day",
+ title: TITLE,
+ });
+
+ await saveAndCloseItemDialog(dialogWindow);
+ await alarmPromise;
+
+ // Change the reminder duration, this resets the alarm.
+ let eventBox = await dayView.waitForAllDayItemAt(window, 1);
+
+ // Prepare to snooze the alarm.
+ alarmPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-alarm-dialog.xhtml",
+ {
+ async callback(alarmWindow) {
+ await new Promise(resolve => alarmWindow.setTimeout(resolve, 500));
+
+ let snoozeAllButton = alarmWindow.document.getElementById("alarm-snooze-all-button");
+ let popup = alarmWindow.document.querySelector("#alarm-snooze-all-popup");
+ let menuitems = alarmWindow.document.querySelectorAll("#alarm-snooze-all-popup > menuitem");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(snoozeAllButton, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(snoozeAllButton, {}, alarmWindow);
+ await shownPromise;
+ popup.activateItem(menuitems[5]);
+ },
+ }
+ );
+
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editItem(window, eventBox));
+ await setData(dialogWindow, iframeWindow, { reminder: "2days", title: TITLE });
+ await saveAndCloseItemDialog(dialogWindow);
+ await alarmPromise;
+
+ Assert.ok(true, "Test ran to completion");
+});
diff --git a/comm/calendar/test/browser/eventDialog/browser_attachMenu.js b/comm/calendar/test/browser/eventDialog/browser_attachMenu.js
new file mode 100644
index 0000000000..2a0b2afc4c
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_attachMenu.js
@@ -0,0 +1,266 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the attach menu in the event dialog window.
+ */
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { cloudFileAccounts } = ChromeUtils.import("resource:///modules/cloudFileAccounts.jsm");
+const { MockFilePicker } = ChromeUtils.importESModule(
+ "resource://testing-common/MockFilePicker.sys.mjs"
+);
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+// Remove the save prompt observer that head.js added. It's causing trouble here.
+Services.ww.unregisterNotification(savePromptObserver);
+
+let calendar = CalendarTestUtils.createCalendar("Attachments");
+registerCleanupFunction(() => {
+ cal.manager.unregisterCalendar(calendar);
+ MockFilePicker.cleanup();
+});
+
+async function getEventBox(selector) {
+ let itemBox;
+ await TestUtils.waitForCondition(() => {
+ itemBox = document.querySelector(selector);
+ return itemBox != null;
+ }, "calendar item did not appear in time");
+ return itemBox;
+}
+
+async function openEventFromBox(eventBox) {
+ if (Services.focus.activeWindow != window) {
+ await BrowserTestUtils.waitForEvent(window, "focus");
+ }
+ let promise = CalendarTestUtils.waitForEventDialog();
+ EventUtils.synthesizeMouseAtCenter(eventBox, { clickCount: 2 });
+ return promise;
+}
+
+/**
+ * Tests using the "Website" menu item attaches a link to the event.
+ */
+add_task(async function testAttachWebPage() {
+ let startDate = cal.createDateTime("20200101T000001Z");
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(startDate);
+
+ let { dialogWindow, iframeWindow, dialogDocument, iframeDocument } =
+ await CalendarTestUtils.editNewEvent(window);
+
+ await setData(dialogWindow, iframeWindow, {
+ title: "Web Link Event",
+ startDate,
+ });
+
+ // Attach the url.
+ let attachButton = dialogWindow.document.querySelector("#button-url");
+ Assert.ok(attachButton, "attach menu button found");
+
+ let menu = dialogDocument.querySelector("#button-attach-menupopup");
+ let menuShowing = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(attachButton, {}, dialogWindow);
+ await menuShowing;
+
+ let url = "https://thunderbird.net/";
+ let urlPrompt = BrowserTestUtils.promiseAlertDialogOpen(
+ "",
+ "chrome://global/content/commonDialog.xhtml",
+ {
+ async callback(win) {
+ win.document.querySelector("#loginTextbox").value = url;
+ EventUtils.synthesizeKey("VK_RETURN", {}, win);
+ },
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.querySelector("#button-attach-url"),
+ {},
+ dialogWindow
+ );
+ await urlPrompt;
+
+ // Now check that the url shows in the attachments list.
+ EventUtils.synthesizeMouseAtCenter(
+ iframeDocument.querySelector("#event-grid-tab-attachments"),
+ {},
+ iframeWindow
+ );
+
+ let listBox = iframeDocument.querySelector("#attachment-link");
+ await BrowserTestUtils.waitForCondition(
+ () => listBox.itemChildren.length == 1,
+ "attachment list did not show in time"
+ );
+
+ Assert.equal(listBox.itemChildren[0].tooltipText, url, "url included in attachments list");
+
+ // Save the new event.
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Open the event to verify the attachment is shown in the summary dialog.
+ let summaryWin = await openEventFromBox(await getEventBox("calendar-month-day-box-item"));
+ let label = summaryWin.document.querySelector(`label[value="${url}"]`);
+ Assert.ok(label, "attachment label found on calendar summary dialog");
+ await BrowserTestUtils.closeWindow(summaryWin);
+
+ // Clean up.
+ let eventBox = await getEventBox("calendar-month-day-box-item");
+ eventBox.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {});
+});
+
+/**
+ * Tests selecting a provider from the attach menu works.
+ */
+add_task(async function testAttachProvider() {
+ let fileUrl = "https://path/to/mock/file.pdf";
+ let iconURL = "chrome://messenger/content/extension.svg";
+ let provider = {
+ type: "Mochitest",
+ displayName: "Mochitest",
+ iconURL,
+ initAccount(accountKey) {
+ return {
+ accountKey,
+ type: "Mochitest",
+ get displayName() {
+ return Services.prefs.getCharPref(
+ `mail.cloud_files.accounts.${this.accountKey}.displayName`,
+ "Mochitest Account"
+ );
+ },
+ iconURL,
+ configured: true,
+ managementURL: "",
+ uploadFile(window, aFile) {
+ return new Promise(resolve =>
+ setTimeout(() =>
+ resolve({
+ id: 1,
+ path: aFile.path,
+ size: aFile.fileSize,
+ url: fileUrl,
+ // The uploadFile() function should return serviceIcon, serviceName
+ // and serviceUrl - either default or user defined values specified
+ // by the onFileUpload event. The item-edit dialog uses only the
+ // serviceIcon.
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ })
+ )
+ );
+ },
+ };
+ },
+ };
+
+ cloudFileAccounts.registerProvider("Mochitest", provider);
+ cloudFileAccounts.createAccount("Mochitest");
+ registerCleanupFunction(() => {
+ cloudFileAccounts.unregisterProvider("Mochitest");
+ });
+
+ let file = new FileUtils.File(getTestFilePath("data/guests.txt"));
+ MockFilePicker.init(window);
+ MockFilePicker.setFiles([file]);
+ MockFilePicker.returnValue = MockFilePicker.returnOk;
+
+ let startDate = cal.createDateTime("20200201T000001Z");
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(startDate);
+
+ let { dialogWindow, iframeWindow, dialogDocument, iframeDocument } =
+ await CalendarTestUtils.editNewEvent(window);
+
+ await setData(dialogWindow, iframeWindow, {
+ title: "Provider Attachment Event",
+ startDate,
+ });
+
+ let attachButton = dialogDocument.querySelector("#button-url");
+ Assert.ok(attachButton, "attach menu button found");
+
+ let menu = dialogDocument.querySelector("#button-attach-menupopup");
+ let menuItem;
+
+ await BrowserTestUtils.waitForCondition(() => {
+ menuItem = menu.querySelector("menuitem[label='File using Mochitest Account']");
+ return menuItem;
+ });
+
+ Assert.ok(menuItem, "custom provider menuitem found");
+ Assert.equal(menuItem.image, iconURL, "provider image src is provider image");
+
+ // Click on the "Attach" menu.
+ let menuShowing = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(attachButton, {}, dialogWindow);
+ await menuShowing;
+
+ // Click on the menuitem to attach a file using our provider.
+ let menuHidden = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ EventUtils.synthesizeMouseAtCenter(menuItem, {}, dialogWindow);
+ await menuHidden;
+
+ // Check if the file dialog was "shown". MockFilePicker.open() is asynchronous
+ // but does not return a promise.
+ await BrowserTestUtils.waitForCondition(
+ () => MockFilePicker.shown,
+ "file picker was not shown in time"
+ );
+
+ // Click on the attachments tab of the event dialog.
+ EventUtils.synthesizeMouseAtCenter(
+ iframeDocument.querySelector("#event-grid-tab-attachments"),
+ {},
+ iframeWindow
+ );
+
+ // Wait until the file we attached appears.
+ let listBox = iframeDocument.querySelector("#attachment-link");
+ await BrowserTestUtils.waitForCondition(
+ () => listBox.itemChildren.length == 1,
+ "attachment list did not show in time"
+ );
+
+ let listItem = listBox.itemChildren[0];
+
+ // XXX: This property is set after an async operation. Unfortunately, that
+ // operation is not awaited on in its surrounding code so the assertion
+ // after this will occasionally fail if this is not done.
+ await BrowserTestUtils.waitForCondition(
+ () => listItem.attachCloudFileUpload,
+ "attachCloudFileUpload property not set on attachment listitem in time."
+ );
+
+ Assert.equal(listItem.attachCloudFileUpload.url, fileUrl, "upload attached to event");
+
+ let listItemImage = listItem.querySelector("img");
+ Assert.equal(
+ listItemImage.src,
+ "chrome://messenger/skin/icons/globe.svg",
+ "attachment image is provider image"
+ );
+
+ // Save the new event.
+ dialogDocument.querySelector("#button-saveandclose").click();
+
+ // Open it and verify the attachment is shown.
+ let summaryWin = await openEventFromBox(await getEventBox("calendar-month-day-box-item"));
+ let label = summaryWin.document.querySelector(`label[value="${fileUrl}"]`);
+ Assert.ok(label, "attachment label found on calendar summary dialog");
+ await BrowserTestUtils.closeWindow(summaryWin);
+
+ if (Services.focus.activeWindow != window) {
+ await BrowserTestUtils.waitForEvent(window, "focus");
+ }
+
+ // Clean up.
+ let eventBox = await getEventBox("calendar-month-day-box-item");
+ eventBox.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {});
+});
diff --git a/comm/calendar/test/browser/eventDialog/browser_attendeesDialog.js b/comm/calendar/test/browser/eventDialog/browser_attendeesDialog.js
new file mode 100644
index 0000000000..f6e73f3957
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_attendeesDialog.js
@@ -0,0 +1,462 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals createEventWithDialog, openAttendeesWindow, closeAttendeesWindow */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+add_task(async () => {
+ let calendar = CalendarTestUtils.createCalendar("Mochitest", "memory");
+ calendar.name = "Mochitest";
+ calendar.setProperty("organizerId", "mailto:mochitest@example.com");
+
+ cal.freeBusyService.addProvider(freeBusyProvider);
+
+ let book = MailServices.ab.getDirectoryFromId(
+ MailServices.ab.newAddressBook("Mochitest", null, 101)
+ );
+ let contacts = {};
+ for (let name of ["Charlie", "Juliet", "Mike", "Oscar", "Romeo", "Victor"]) {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(Ci.nsIAbCard);
+ card.firstName = name;
+ card.lastName = "Mochitest";
+ card.displayName = `${name} Mochitest`;
+ card.primaryEmail = `${name.toLowerCase()}@example.com`;
+ contacts[name.toUpperCase()] = book.addCard(card);
+ }
+ let list = Cc["@mozilla.org/addressbook/directoryproperty;1"].createInstance(Ci.nsIAbDirectory);
+ list.isMailList = true;
+ list.dirName = "The Boys";
+ list = book.addMailList(list);
+ list.addCard(contacts.MIKE);
+ list.addCard(contacts.OSCAR);
+ list.addCard(contacts.ROMEO);
+ list.addCard(contacts.VICTOR);
+
+ let today = new Date();
+ let times = {
+ ONE: new Date(
+ Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 1, 13, 0, 0)
+ ),
+ TWO_THIRTY: new Date(
+ Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 1, 14, 30, 0)
+ ),
+ THREE_THIRTY: new Date(
+ Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 1, 15, 30, 0)
+ ),
+ FOUR: new Date(
+ Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 1, 16, 0, 0)
+ ),
+ };
+
+ registerCleanupFunction(async () => {
+ CalendarTestUtils.removeCalendar(calendar);
+ cal.freeBusyService.removeProvider(freeBusyProvider);
+ MailServices.ab.deleteAddressBook(book.URI);
+ });
+
+ let eventWindow = await openEventWindow(calendar);
+ let eventDocument = eventWindow.document;
+ let iframeDocument = eventDocument.getElementById("calendar-item-panel-iframe").contentDocument;
+
+ let eventStartTime = iframeDocument.getElementById("event-starttime");
+ eventStartTime.value = times.ONE;
+ let eventEndTime = iframeDocument.getElementById("event-endtime");
+ eventEndTime.value = times.THREE_THIRTY;
+
+ async function checkAttendeesInAttendeesDialog(attendeesDocument, expectedAttendees) {
+ let attendeesList = attendeesDocument.getElementById("attendee-list");
+ await TestUtils.waitForCondition(
+ () => attendeesList.childElementCount == expectedAttendees.length + 1,
+ "empty attendee input should have been added"
+ );
+
+ function getInputValueFromAttendeeRow(row) {
+ const input = row.querySelector("input");
+ return input.value;
+ }
+
+ Assert.deepEqual(
+ Array.from(attendeesList.children, getInputValueFromAttendeeRow),
+ [...expectedAttendees, ""],
+ "attendees list matches what was expected"
+ );
+ Assert.equal(
+ attendeesDocument.activeElement,
+ attendeesList.children[expectedAttendees.length].querySelector("input"),
+ "empty attendee input should have focus"
+ );
+ }
+
+ async function checkFreeBusy(row, count) {
+ Assert.equal(row._freeBusyDiv.querySelectorAll(".pending").length, 1);
+ Assert.equal(row._freeBusyDiv.querySelectorAll(".busy").length, 0);
+ let responsePromise = BrowserTestUtils.waitForEvent(row, "freebusy-update-finished");
+ freeBusyProvider.sendNextResponse();
+ await responsePromise;
+ Assert.equal(row._freeBusyDiv.querySelectorAll(".pending").length, 0);
+ Assert.equal(row._freeBusyDiv.querySelectorAll(".busy").length, count);
+ }
+
+ {
+ info("Opening for the first time");
+ let attendeesWindow = await openAttendeesWindow(eventWindow);
+ let attendeesDocument = attendeesWindow.document;
+ let attendeesList = attendeesDocument.getElementById("attendee-list");
+
+ Assert.equal(attendeesWindow.arguments[0].calendar, calendar);
+ Assert.equal(attendeesWindow.arguments[0].organizer, null);
+ Assert.equal(calendar.getProperty("organizerId"), "mailto:mochitest@example.com");
+ Assert.deepEqual(attendeesWindow.arguments[0].attendees, []);
+
+ await new Promise(resolve => attendeesWindow.setTimeout(resolve));
+
+ let attendeesStartTime = attendeesDocument.getElementById("event-starttime");
+ let attendeesEndTime = attendeesDocument.getElementById("event-endtime");
+ Assert.equal(attendeesStartTime.value.toISOString(), times.ONE.toISOString());
+ Assert.equal(attendeesEndTime.value.toISOString(), times.THREE_THIRTY.toISOString());
+
+ attendeesStartTime.value = times.TWO_THIRTY;
+ attendeesEndTime.value = times.FOUR;
+
+ // Check free/busy of organizer.
+
+ await checkAttendeesInAttendeesDialog(attendeesDocument, ["mochitest@example.com"]);
+
+ let organizer = attendeesList.firstElementChild;
+ await checkFreeBusy(organizer, 5);
+
+ // Add attendee.
+
+ EventUtils.sendString("test@example.com", attendeesWindow);
+ EventUtils.synthesizeKey("VK_TAB", {}, attendeesWindow);
+
+ await checkAttendeesInAttendeesDialog(attendeesDocument, [
+ "mochitest@example.com",
+ "test@example.com",
+ ]);
+ await checkFreeBusy(attendeesList.children[1], 0);
+
+ // Add another attendee, from the address book.
+
+ let input = attendeesDocument.activeElement;
+ EventUtils.sendString("julie", attendeesWindow);
+ await new Promise(resolve => attendeesWindow.setTimeout(resolve, 1000));
+ Assert.equal(input.value, "juliet Mochitest <juliet@example.com>");
+ Assert.ok(input.popupElement.popupOpen);
+ Assert.equal(input.popupElement.richlistbox.childElementCount, 1);
+ Assert.equal(input.popupElement._currentIndex, 1);
+ EventUtils.synthesizeKey("VK_DOWN", {}, attendeesWindow);
+ Assert.equal(input.popupElement._currentIndex, 1);
+ EventUtils.synthesizeKey("VK_TAB", {}, attendeesWindow);
+
+ await checkAttendeesInAttendeesDialog(attendeesDocument, [
+ "mochitest@example.com",
+ "test@example.com",
+ "Juliet Mochitest <juliet@example.com>",
+ ]);
+ await checkFreeBusy(attendeesList.children[2], 1);
+
+ // Add a mailing list which should expand.
+
+ input = attendeesDocument.activeElement;
+ EventUtils.sendString("boys", attendeesWindow);
+ await new Promise(resolve => attendeesWindow.setTimeout(resolve, 1000));
+ Assert.equal(input.value, "boys >> The Boys <The Boys>");
+ Assert.ok(input.popupElement.popupOpen);
+ Assert.equal(input.popupElement.richlistbox.childElementCount, 1);
+ Assert.equal(input.popupElement._currentIndex, 1);
+ EventUtils.synthesizeKey("VK_DOWN", {}, attendeesWindow);
+ Assert.equal(input.popupElement._currentIndex, 1);
+ EventUtils.synthesizeKey("VK_TAB", {}, attendeesWindow);
+
+ await checkAttendeesInAttendeesDialog(attendeesDocument, [
+ "mochitest@example.com",
+ "test@example.com",
+ "Juliet Mochitest <juliet@example.com>",
+ "Mike Mochitest <mike@example.com>",
+ "Oscar Mochitest <oscar@example.com>",
+ "Romeo Mochitest <romeo@example.com>",
+ "Victor Mochitest <victor@example.com>",
+ ]);
+ await checkFreeBusy(attendeesList.children[3], 0);
+ await checkFreeBusy(attendeesList.children[4], 0);
+ await checkFreeBusy(attendeesList.children[5], 1);
+ await checkFreeBusy(attendeesList.children[6], 0);
+
+ await closeAttendeesWindow(attendeesWindow);
+ await new Promise(resolve => eventWindow.setTimeout(resolve));
+ }
+
+ Assert.equal(eventStartTime.value.toISOString(), times.TWO_THIRTY.toISOString());
+ Assert.equal(eventEndTime.value.toISOString(), times.FOUR.toISOString());
+
+ function checkAttendeesInEventDialog(organizer, expectedAttendees) {
+ Assert.equal(iframeDocument.getElementById("item-organizer-row").textContent, organizer);
+
+ let attendeeItems = iframeDocument.querySelectorAll(".attendee-list .attendee-label");
+ Assert.equal(attendeeItems.length, expectedAttendees.length);
+ for (let i = 0; i < expectedAttendees.length; i++) {
+ Assert.equal(attendeeItems[i].getAttribute("attendeeid"), expectedAttendees[i]);
+ }
+ }
+
+ checkAttendeesInEventDialog("mochitest@example.com", [
+ "mailto:mochitest@example.com",
+ "mailto:test@example.com",
+ "mailto:juliet@example.com",
+ "mailto:mike@example.com",
+ "mailto:oscar@example.com",
+ "mailto:romeo@example.com",
+ "mailto:victor@example.com",
+ ]);
+
+ {
+ info("Opening for a second time");
+ let attendeesWindow = await openAttendeesWindow(eventWindow);
+ let attendeesDocument = attendeesWindow.document;
+ let attendeesList = attendeesDocument.getElementById("attendee-list");
+
+ let attendeesStartTime = attendeesDocument.getElementById("event-starttime");
+ let attendeesEndTime = attendeesDocument.getElementById("event-endtime");
+ Assert.equal(attendeesStartTime.value.toISOString(), times.TWO_THIRTY.toISOString());
+ Assert.equal(attendeesEndTime.value.toISOString(), times.FOUR.toISOString());
+
+ await checkAttendeesInAttendeesDialog(attendeesDocument, [
+ "mochitest@example.com",
+ "test@example.com",
+ "Juliet Mochitest <juliet@example.com>",
+ "Mike Mochitest <mike@example.com>",
+ "Oscar Mochitest <oscar@example.com>",
+ "Romeo Mochitest <romeo@example.com>",
+ "Victor Mochitest <victor@example.com>",
+ ]);
+
+ await checkFreeBusy(attendeesList.children[0], 5);
+ await checkFreeBusy(attendeesList.children[1], 0);
+ await checkFreeBusy(attendeesList.children[2], 1);
+ await checkFreeBusy(attendeesList.children[3], 0);
+ await checkFreeBusy(attendeesList.children[4], 0);
+ await checkFreeBusy(attendeesList.children[5], 1);
+ await checkFreeBusy(attendeesList.children[6], 0);
+
+ await closeAttendeesWindow(attendeesWindow);
+ await new Promise(resolve => eventWindow.setTimeout(resolve));
+ }
+
+ Assert.equal(eventStartTime.value.toISOString(), times.TWO_THIRTY.toISOString());
+ Assert.equal(eventEndTime.value.toISOString(), times.FOUR.toISOString());
+
+ checkAttendeesInEventDialog("mochitest@example.com", [
+ "mailto:mochitest@example.com",
+ "mailto:test@example.com",
+ "mailto:juliet@example.com",
+ "mailto:mike@example.com",
+ "mailto:oscar@example.com",
+ "mailto:romeo@example.com",
+ "mailto:victor@example.com",
+ ]);
+
+ iframeDocument.getElementById("notify-attendees-checkbox").checked = false;
+ await closeEventWindow(eventWindow);
+});
+
+add_task(async () => {
+ let calendar = CalendarTestUtils.createCalendar("Mochitest", "memory");
+ calendar.setProperty("organizerId", "mailto:mochitest@example.com");
+
+ registerCleanupFunction(async () => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ let defaults = {
+ displayTimezone: true,
+ attendees: [],
+ organizer: null,
+ calendar,
+ onOk: () => {},
+ };
+
+ async function testDays(startTime, endTime, expectedFirst, expectedLast) {
+ let attendeesWindow = await openAttendeesWindow({ ...defaults, startTime, endTime });
+ let attendeesDocument = attendeesWindow.document;
+
+ let days = attendeesDocument.querySelectorAll("calendar-day");
+ Assert.equal(days.length, 16);
+ Assert.equal(days[0].date.icalString, expectedFirst);
+ Assert.equal(days[15].date.icalString, expectedLast);
+
+ await closeAttendeesWindow(attendeesWindow);
+ }
+
+ // With the management of the reduced days or not, the format of the dates is different according to the cases.
+ // In case of a reduced day, the day format will include the start hour of the day (defined by calendar.view.daystarthour).
+ // In the case of a full day, we keep the behavior similar to before.
+
+ //Full day tests
+ await testDays(
+ cal.createDateTime("20100403T020000"),
+ cal.createDateTime("20100403T030000"),
+ "20100403",
+ "20100418"
+ );
+ for (let i = -2; i < 0; i++) {
+ await testDays(
+ fromToday({ days: i, hours: 2 }),
+ fromToday({ days: i, hours: 3 }),
+ fromToday({ days: i }).icalString.substring(0, 8),
+ fromToday({ days: i + 15 }).icalString.substring(0, 8)
+ );
+ }
+ for (let i = 0; i < 3; i++) {
+ await testDays(
+ fromToday({ days: i, hours: 2 }),
+ fromToday({ days: i, hours: 3 }),
+ fromToday({ days: 0 }).icalString.substring(0, 8),
+ fromToday({ days: 15 }).icalString.substring(0, 8)
+ );
+ }
+ for (let i = 3; i < 5; i++) {
+ await testDays(
+ fromToday({ days: i, hours: 2 }),
+ fromToday({ days: i, hours: 3 }),
+ fromToday({ days: i - 2 }).icalString.substring(0, 8),
+ fromToday({ days: i + 13 }).icalString.substring(0, 8)
+ );
+ }
+ await testDays(
+ cal.createDateTime("20300403T020000"),
+ cal.createDateTime("20300403T030000"),
+ "20300401",
+ "20300416"
+ );
+
+ // Reduced day tests
+ let dayStartHour = Services.prefs.getIntPref("calendar.view.daystarthour", 8).toString();
+ if (dayStartHour.length == 1) {
+ dayStartHour = "0" + dayStartHour;
+ }
+
+ await testDays(
+ cal.createDateTime("20100403T120000"),
+ cal.createDateTime("20100403T130000"),
+ "20100403T" + dayStartHour + "0000Z",
+ "20100418T" + dayStartHour + "0000Z"
+ );
+ for (let i = -2; i < 0; i++) {
+ await testDays(
+ fromToday({ days: i, hours: 12 }),
+ fromToday({ days: i, hours: 13 }),
+ fromToday({ days: i }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z",
+ fromToday({ days: i + 15 }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z"
+ );
+ }
+ for (let i = 0; i < 3; i++) {
+ await testDays(
+ fromToday({ days: i, hours: 12 }),
+ fromToday({ days: i, hours: 13 }),
+ fromToday({ days: 0 }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z",
+ fromToday({ days: 15 }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z"
+ );
+ }
+ for (let i = 3; i < 5; i++) {
+ await testDays(
+ fromToday({ days: i, hours: 12 }),
+ fromToday({ days: i, hours: 13 }),
+ fromToday({ days: i - 2 }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z",
+ fromToday({ days: i + 13 }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z"
+ );
+ }
+ await testDays(
+ cal.createDateTime("20300403T120000"),
+ cal.createDateTime("20300403T130000"),
+ "20300401T" + dayStartHour + "0000Z",
+ "20300416T" + dayStartHour + "0000Z"
+ );
+});
+
+function openEventWindow(calendar) {
+ let eventWindowPromise = BrowserTestUtils.domWindowOpened(null, async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+
+ let doc = win.document;
+ if (doc.documentURI == "chrome://calendar/content/calendar-event-dialog.xhtml") {
+ let iframe = doc.getElementById("calendar-item-panel-iframe");
+ await BrowserTestUtils.waitForEvent(iframe.contentWindow, "load");
+ return true;
+ }
+ return false;
+ });
+ createEventWithDialog(calendar, null, null, "Event");
+ return eventWindowPromise;
+}
+
+async function closeEventWindow(eventWindow) {
+ let eventWindowPromise = BrowserTestUtils.domWindowClosed(eventWindow);
+ eventWindow.document.getElementById("button-saveandclose").click();
+ await eventWindowPromise;
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+function fromToday({ days = 0, hours = 0 }) {
+ if (!fromToday.today) {
+ fromToday.today = cal.dtz.now();
+ fromToday.today.hour = fromToday.today.minute = fromToday.today.second = 0;
+ }
+
+ let duration = cal.createDuration();
+ duration.days = days;
+ duration.hours = hours;
+
+ let value = fromToday.today.clone();
+ value.addDuration(duration);
+ return value;
+}
+
+var freeBusyProvider = {
+ pendingRequests: [],
+ sendNextResponse() {
+ let next = this.pendingRequests.shift();
+ if (next) {
+ next();
+ }
+ },
+ getFreeBusyIntervals(aCalId, aStart, aEnd, aTypes, aListener) {
+ this.pendingRequests.push(() => {
+ info(`Sending free/busy response for ${aCalId}`);
+ if (aCalId in this.data) {
+ aListener.onResult(
+ null,
+ this.data[aCalId].map(([startDuration, duration]) => {
+ let start = fromToday(startDuration);
+
+ let end = start.clone();
+ end.addDuration(cal.createDuration(duration));
+
+ return new cal.provider.FreeBusyInterval(
+ aCalId,
+ Ci.calIFreeBusyInterval.BUSY,
+ start,
+ end
+ );
+ })
+ );
+ } else {
+ aListener.onResult(null, []);
+ }
+ });
+ },
+ data: {
+ "mailto:mochitest@example.com": [
+ [{ days: 1, hours: 4 }, "PT3H"],
+ [{ days: 1, hours: 8 }, "PT3H"],
+ [{ days: 1, hours: 12 }, "PT3H"],
+ [{ days: 1, hours: 16 }, "PT3H"],
+ [{ days: 2, hours: 4 }, "PT3H"],
+ ],
+ "mailto:juliet@example.com": [["P1DT9H", "PT8H"]],
+ "mailto:romeo@example.com": [["P1DT14H", "PT5H"]],
+ },
+};
diff --git a/comm/calendar/test/browser/eventDialog/browser_attendeesDialogAdd.js b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogAdd.js
new file mode 100644
index 0000000000..c1f2778118
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogAdd.js
@@ -0,0 +1,248 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals openAttendeesWindow, closeAttendeesWindow, findAndEditMatchingRow */
+
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+
+add_setup(async function () {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ CalendarTestUtils.goToDate(window, 2023, 2, 18);
+});
+
+add_task(async function testAddAttendeeToEventWithNone() {
+ const calendar = CalendarTestUtils.createCalendar();
+ calendar.setProperty("organizerId", "mailto:foo@example.com");
+ calendar.setProperty("organizerCN", "Foo Fooson");
+
+ // Create an event which currently has no attendees or organizer.
+ const event = await calendar.addItem(
+ new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ SUMMARY:An event
+ DTSTART:20230218T100000Z
+ DTEND:20230218T110000Z
+ END:VEVENT
+ `)
+ );
+
+ // Remember event details so we can refetch it after editing.
+ const eventId = event.id;
+ const eventModified = event.lastModifiedTime;
+
+ // Sanity check.
+ Assert.equal(event.organizer, null, "event should not have an organizer");
+ Assert.equal(event.getAttendees().length, 0, "event should not have any attendees");
+
+ // Open our event for editing.
+ const { dialogWindow: eventWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1);
+ const attendeesWindow = await openAttendeesWindow(eventWindow);
+
+ // Set text in the empty row to create a new attendee.
+ findAndEditMatchingRow(
+ attendeesWindow,
+ "bar@example.com",
+ "there should an empty input",
+ value => value === ""
+ );
+
+ // Save and close the event.
+ await closeAttendeesWindow(attendeesWindow);
+ await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow);
+
+ await TestUtils.waitForCondition(async () => {
+ const item = await calendar.getItem(eventId);
+ return item.lastModifiedTime != eventModified;
+ });
+
+ const editedEvent = await calendar.getItem(eventId);
+
+ // Verify that the organizer was set on the event.
+ const organizer = editedEvent.organizer;
+ Assert.ok(organizer, "there should be an organizer for the event after editing");
+ Assert.equal(
+ organizer.id,
+ "mailto:foo@example.com",
+ "organizer ID should match calendar property"
+ );
+ Assert.equal(organizer.commonName, "Foo Fooson", "organizer name should match calendar property");
+
+ const attendees = editedEvent.getAttendees();
+ Assert.equal(attendees.length, 2, "there should be two attendees of the event after editing");
+
+ // Verify that the organizer was added as an attendee.
+ const fooFooson = attendees.find(attendee => attendee.id == "mailto:foo@example.com");
+ Assert.ok(fooFooson, "the organizer should have been added as an attendee");
+ Assert.equal(fooFooson.commonName, "Foo Fooson", "attendee name should match organizer's");
+ Assert.equal(
+ fooFooson.participationStatus,
+ "ACCEPTED",
+ "organizer attendee should have automatically accepted"
+ );
+ Assert.equal(fooFooson.role, "REQ-PARTICIPANT", "organizer attendee should be required");
+
+ // Verify that the attendee we added to the list is represented on the event.
+ const barBarrington = attendees.find(attendee => attendee.id == "mailto:bar@example.com");
+ Assert.ok(barBarrington, "an attendee should have the address bar@example.com");
+ Assert.equal(barBarrington.commonName, null, "new attendee name should not be set");
+ Assert.equal(
+ barBarrington.participationStatus,
+ "NEEDS-ACTION",
+ "new attendee should have default participation status"
+ );
+ Assert.equal(barBarrington.role, "REQ-PARTICIPANT", "new attendee should have default role");
+
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+add_task(async function testAddAttendeeToEventWithoutOrganizerAsAttendee() {
+ const calendar = CalendarTestUtils.createCalendar();
+ calendar.setProperty("organizerId", "mailto:foo@example.com");
+ calendar.setProperty("organizerCN", "Foo Fooson");
+
+ // Create an event which has an organizer and attendees, but no attendee
+ // matching the organizer.
+ const event = await calendar.addItem(
+ new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ SUMMARY:An event
+ DTSTART:20230218T100000Z
+ DTEND:20230218T110000Z
+ ORGANIZER;CN="Foo Fooson":mailto:foo@example.com
+ ATTENDEE;CN="Bar Barrington";PARTSTAT=DECLINED;ROLE=CHAIR:mailto:bar@examp
+ le.com
+ ATTENDEE;CN="Baz Luhrmann";PARTSTAT=NEEDS-ACTION;ROLE=OPT-PARTICIPANT;RSV
+ P=TRUE:mailto:baz@example.com
+ END:VEVENT
+ `)
+ );
+
+ // Remember event details so we can refetch it after editing.
+ const eventId = event.id;
+ const eventModified = event.lastModifiedTime;
+
+ // Sanity check. Note that order of attendees is not significant and thus not
+ // guaranteed.
+ const organizer = event.organizer;
+ Assert.ok(organizer, "the organizer should be set");
+ Assert.equal(organizer.id, "mailto:foo@example.com", "organizer ID should match");
+ Assert.equal(organizer.commonName, "Foo Fooson", "organizer name should match");
+
+ const attendees = event.getAttendees();
+ Assert.equal(attendees.length, 2, "there should be two attendees of the event");
+
+ const fooFooson = attendees.find(attendee => attendee.id == "mailto:foo@example.com");
+ Assert.ok(!fooFooson, "there should be no attendee matching the organizer");
+
+ const barBarrington = attendees.find(attendee => attendee.id == "mailto:bar@example.com");
+ Assert.ok(barBarrington, "an attendee should have the address bar@example.com");
+ Assert.equal(barBarrington.commonName, "Bar Barrington", "attendee name should match");
+ Assert.equal(barBarrington.participationStatus, "DECLINED", "attendee should have declined");
+ Assert.equal(barBarrington.role, "CHAIR", "attendee should be the meeting chair");
+
+ const bazLuhrmann = attendees.find(attendee => attendee.id == "mailto:baz@example.com");
+ Assert.ok(bazLuhrmann, "an attendee should have the address baz@example.com");
+ Assert.equal(bazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match");
+ Assert.equal(
+ bazLuhrmann.participationStatus,
+ "NEEDS-ACTION",
+ "attendee should not have responded yet"
+ );
+ Assert.equal(bazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional");
+ Assert.equal(bazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP");
+
+ // Open our event for editing.
+ const { dialogWindow: eventWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1);
+ const attendeesWindow = await openAttendeesWindow(eventWindow);
+
+ // Verify that we don't display an attendee for the organizer if there is no
+ // attendee on the event for them.
+ const attendeeList = attendeesWindow.document.getElementById("attendee-list");
+ const attendeeInput = Array.from(attendeeList.children)
+ .map(child => child.querySelector("input"))
+ .find(input => {
+ return input ? input.value.includes("foo@example.com") : false;
+ });
+ Assert.ok(!attendeeInput, "there should be no row in the dialog for the organizer");
+
+ // Set text in the empty row to create a new attendee.
+ findAndEditMatchingRow(
+ attendeesWindow,
+ "Jim James <jim@example.com>",
+ "there should an empty input",
+ value => value === ""
+ );
+
+ // Save and close the event.
+ await closeAttendeesWindow(attendeesWindow);
+ await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow);
+
+ await TestUtils.waitForCondition(async () => {
+ const item = await calendar.getItem(eventId);
+ return item.lastModifiedTime != eventModified;
+ });
+
+ const editedEvent = await calendar.getItem(eventId);
+
+ // Verify that the organizer hasn't changed.
+ const editedOrganizer = editedEvent.organizer;
+ Assert.ok(editedOrganizer, "the organizer should still be set on the event after editing");
+ Assert.equal(
+ editedOrganizer.id,
+ "mailto:foo@example.com",
+ "organizer ID should not have changed"
+ );
+ Assert.equal(editedOrganizer.commonName, "Foo Fooson", "organizer name should not have changed");
+
+ const editedAttendees = editedEvent.getAttendees();
+ Assert.equal(
+ editedAttendees.length,
+ 3,
+ "there should be three attendees of the event after editing"
+ );
+
+ // Verify that no attendee matching the organizer was added.
+ const editedFooFooson = editedAttendees.find(attendee => attendee.id == "mailto:foo@example.com");
+ Assert.ok(!editedFooFooson, "there should still be no attendee matching the organizer");
+
+ // Verify that a new attendee was added.
+ const jimJames = editedAttendees.find(attendee => attendee.id == "mailto:jim@example.com");
+ Assert.ok(jimJames, "an attendee should have the address jim@example.com");
+ Assert.equal(jimJames.commonName, "Jim James", "new attendee name should be set");
+ Assert.equal(
+ jimJames.participationStatus,
+ "NEEDS-ACTION",
+ "new attendee should have default participation status"
+ );
+ Assert.equal(jimJames.role, "REQ-PARTICIPANT", "new attendee should have default role");
+
+ // Verify that the original first attendee's properties remain untouched.
+ const editedBarBarrington = editedAttendees.find(
+ attendee => attendee.id == "mailto:bar@example.com"
+ );
+ Assert.ok(editedBarBarrington, "an attendee should have the address bar@example.com");
+ Assert.equal(editedBarBarrington.commonName, "Bar Barrington", "attendee name should match");
+ Assert.equal(
+ editedBarBarrington.participationStatus,
+ "DECLINED",
+ "attendee should have declined"
+ );
+ Assert.equal(editedBarBarrington.role, "CHAIR", "attendee should be the meeting chair");
+
+ // Verify that the original second attendee's properties remain untouched.
+ const editedBazLuhrmann = editedAttendees.find(
+ attendee => attendee.id == "mailto:baz@example.com"
+ );
+ Assert.ok(editedBazLuhrmann, "an attendee should have the address baz@example.com");
+ Assert.equal(editedBazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match");
+ Assert.equal(
+ editedBazLuhrmann.participationStatus,
+ "NEEDS-ACTION",
+ "attendee should not have responded yet"
+ );
+ Assert.equal(editedBazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional");
+ Assert.equal(editedBazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP");
+
+ CalendarTestUtils.removeCalendar(calendar);
+});
diff --git a/comm/calendar/test/browser/eventDialog/browser_attendeesDialogNoEdit.js b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogNoEdit.js
new file mode 100644
index 0000000000..a103173790
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogNoEdit.js
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals openAttendeesWindow, closeAttendeesWindow, findAndFocusMatchingRow */
+
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+
+add_setup(async function () {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ CalendarTestUtils.goToDate(window, 2023, 2, 18);
+});
+
+add_task(async function testBackingOutWithNoAttendees() {
+ const calendar = CalendarTestUtils.createCalendar();
+ calendar.setProperty("organizerId", "mailto:foo@example.com");
+ calendar.setProperty("organizerCN", "Foo Fooson");
+
+ // Create an event which currently has no attendees or organizer.
+ const event = await calendar.addItem(
+ new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ SUMMARY:An event
+ DTSTART:20230218T100000Z
+ DTEND:20230218T110000Z
+ END:VEVENT
+ `)
+ );
+
+ // Remember event details so we can refetch it after editing.
+ const eventId = event.id;
+ const eventModified = event.lastModifiedTime;
+
+ // Sanity check.
+ Assert.equal(event.organizer, null, "event should not have an organizer");
+ Assert.equal(event.getAttendees().length, 0, "event should not have any attendees");
+
+ // Open our event for editing.
+ const { dialogWindow: eventWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1);
+ const attendeesWindow = await openAttendeesWindow(eventWindow);
+
+ findAndFocusMatchingRow(attendeesWindow, "there should be a row matching the organizer", value =>
+ value.includes(calendar.getProperty("organizerCN"))
+ );
+
+ // We changed our mind. Save and close the event.
+ await closeAttendeesWindow(attendeesWindow);
+ await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow);
+
+ // The event is still counted as modified even with no changes. If this
+ // changes in the future, we'll just need to wait a reasonable time and fetch
+ // the event again.
+ await TestUtils.waitForCondition(async () => {
+ const item = await calendar.getItem(eventId);
+ return item.lastModifiedTime != eventModified;
+ });
+
+ const editedEvent = await calendar.getItem(eventId);
+
+ // Verify that the organizer was set on the event.
+ const organizer = editedEvent.organizer;
+ Assert.ok(!organizer, "there should still be no organizer for the event");
+
+ const attendees = editedEvent.getAttendees();
+ Assert.equal(attendees.length, 0, "there should still be no attendees of the event");
+
+ CalendarTestUtils.removeCalendar(calendar);
+});
diff --git a/comm/calendar/test/browser/eventDialog/browser_attendeesDialogRemove.js b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogRemove.js
new file mode 100644
index 0000000000..7ad5a3cf68
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogRemove.js
@@ -0,0 +1,147 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals openAttendeesWindow, closeAttendeesWindow, findAndEditMatchingRow */
+
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+
+add_setup(async function () {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ CalendarTestUtils.goToDate(window, 2023, 2, 18);
+});
+
+add_task(async function testRemoveOrganizerAttendee() {
+ const calendar = CalendarTestUtils.createCalendar();
+ calendar.setProperty("organizerId", "mailto:jim@example.com");
+ calendar.setProperty("organizerCN", "Jim James");
+
+ // Create an event with several attendees, including one matching the current
+ // organizer.
+ const event = await calendar.addItem(
+ new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ SUMMARY:An event
+ DTSTART:20230218T100000Z
+ DTEND:20230218T110000Z
+ ORGANIZER;CN="Foo Fooson":mailto:foo@example.com
+ ATTENDEE;CN="Foo Fooson";PARTSTAT=TENTATIVE;ROLE=REQ-PARTICIPANT:mailto:f
+ oo@example.com
+ ATTENDEE;CN="Bar Barrington";PARTSTAT=DECLINED;ROLE=CHAIR:mailto:bar@exam
+ ple.com
+ ATTENDEE;CN="Baz Luhrmann";PARTSTAT=NEEDS-ACTION;ROLE=OPT-PARTICIPANT;RSV
+ P=TRUE:mailto:baz@example.com
+ END:VEVENT
+ `)
+ );
+
+ // Remember event details so we can refetch it after editing.
+ const eventId = event.id;
+ const eventModified = event.lastModifiedTime;
+
+ // Sanity check. Note that order of attendees is not significant and thus not
+ // guaranteed.
+ const organizer = event.organizer;
+ Assert.ok(organizer, "the organizer should be set");
+ Assert.equal(organizer.id, "mailto:foo@example.com", "organizer ID should match");
+ Assert.equal(organizer.commonName, "Foo Fooson", "organizer name should match");
+
+ const attendees = event.getAttendees();
+ Assert.equal(attendees.length, 3, "there should be three attendees of the event");
+
+ const fooFooson = attendees.find(attendee => attendee.id == "mailto:foo@example.com");
+ Assert.ok(fooFooson, "an attendee should match the organizer");
+ Assert.equal(fooFooson.commonName, "Foo Fooson", "attendee name should match");
+ Assert.equal(fooFooson.participationStatus, "TENTATIVE", "attendee should be marked tentative");
+ Assert.equal(fooFooson.role, "REQ-PARTICIPANT", "attendee should be required");
+
+ const barBarrington = attendees.find(attendee => attendee.id == "mailto:bar@example.com");
+ Assert.ok(barBarrington, "an attendee should have the address bar@example.com");
+ Assert.equal(barBarrington.commonName, "Bar Barrington", "attendee name should match");
+ Assert.equal(barBarrington.participationStatus, "DECLINED", "attendee should have declined");
+ Assert.equal(barBarrington.role, "CHAIR", "attendee should be the meeting chair");
+
+ const bazLuhrmann = attendees.find(attendee => attendee.id == "mailto:baz@example.com");
+ Assert.ok(bazLuhrmann, "an attendee should have the address baz@example.com");
+ Assert.equal(bazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match");
+ Assert.equal(
+ bazLuhrmann.participationStatus,
+ "NEEDS-ACTION",
+ "attendee should not have responded yet"
+ );
+ Assert.equal(bazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional");
+ Assert.equal(bazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP");
+
+ // Open our event for editing.
+ const { dialogWindow: eventWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1);
+ const attendeesWindow = await openAttendeesWindow(eventWindow);
+
+ // Empty the row matching the organizer's attendee.
+ findAndEditMatchingRow(
+ attendeesWindow,
+ "",
+ "there should an input for attendee matching the organizer",
+ value => value.includes("foo@example.com")
+ );
+
+ // Save and close the event.
+ await closeAttendeesWindow(attendeesWindow);
+ await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow);
+
+ await TestUtils.waitForCondition(async () => {
+ const item = await calendar.getItem(eventId);
+ return item.lastModifiedTime != eventModified;
+ });
+
+ const editedEvent = await calendar.getItem(eventId);
+
+ // Verify that the organizer hasn't changed.
+ const editedOrganizer = editedEvent.organizer;
+ Assert.ok(editedOrganizer, "the organizer should still be set on the event after editing");
+ Assert.equal(
+ editedOrganizer.id,
+ "mailto:foo@example.com",
+ "organizer ID should not have changed"
+ );
+ Assert.equal(editedOrganizer.commonName, "Foo Fooson", "organizer name should not have changed");
+
+ const editedAttendees = editedEvent.getAttendees();
+ Assert.equal(
+ editedAttendees.length,
+ 2,
+ "there should be two attendees of the event after editing"
+ );
+
+ // Verify that the attendee matching the organizer was removed.
+ const editedFooFooson = editedAttendees.find(attendee => attendee.id == "mailto:foo@example.com");
+ Assert.ok(!editedFooFooson, "there should be no attendee matching the organizer after editing");
+
+ // Verify that the second attendee's properties remain untouched.
+ const editedBarBarrington = editedAttendees.find(
+ attendee => attendee.id == "mailto:bar@example.com"
+ );
+ Assert.ok(editedBarBarrington, "an attendee should have the address bar@example.com");
+ Assert.equal(editedBarBarrington.commonName, "Bar Barrington", "attendee name should match");
+ Assert.equal(
+ editedBarBarrington.participationStatus,
+ "DECLINED",
+ "attendee should have declined"
+ );
+ Assert.equal(editedBarBarrington.role, "CHAIR", "attendee should be the meeting chair");
+
+ // Verify that the final attendee's properties remain untouched.
+ const editedBazLuhrmann = editedAttendees.find(
+ attendee => attendee.id == "mailto:baz@example.com"
+ );
+ Assert.ok(editedBazLuhrmann, "an attendee should have the address baz@example.com");
+ Assert.equal(editedBazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match");
+ Assert.equal(
+ editedBazLuhrmann.participationStatus,
+ "NEEDS-ACTION",
+ "attendee should not have responded yet"
+ );
+ Assert.equal(editedBazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional");
+ Assert.equal(editedBazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP");
+
+ CalendarTestUtils.removeCalendar(calendar);
+});
diff --git a/comm/calendar/test/browser/eventDialog/browser_attendeesDialogUpdate.js b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogUpdate.js
new file mode 100644
index 0000000000..b4e30344d0
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogUpdate.js
@@ -0,0 +1,140 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals openAttendeesWindow, closeAttendeesWindow, findAndEditMatchingRow */
+
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+
+add_setup(async function () {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ CalendarTestUtils.goToDate(window, 2023, 2, 18);
+});
+
+add_task(async function testUpdateAttendee() {
+ const calendar = CalendarTestUtils.createCalendar();
+ calendar.setProperty("organizerId", "mailto:foo@example.com");
+
+ // Create an event with several attendees, all of which should have some
+ // non-default properties which aren't covered in the attendees dialog to
+ // ensure that we aren't throwing properties away when we close the dialog.
+ const event = await calendar.addItem(
+ new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ SUMMARY:An event
+ DTSTART:20230218T100000Z
+ DTEND:20230218T110000Z
+ ORGANIZER;CN="Foo Fooson":mailto:foo@example.com
+ ATTENDEE;CN="Foo Fooson";PARTSTAT=TENTATIVE;ROLE=REQ-PARTICIPANT:mailto:f
+ oo@example.com
+ ATTENDEE;CN="Bar Barington";PARTSTAT=DECLINED;ROLE=CHAIR:mailto:bar@examp
+ le.com
+ ATTENDEE;CN="Baz Luhrmann";PARTSTAT=NEEDS-ACTION;ROLE=OPT-PARTICIPANT;RSV
+ P=TRUE:mailto:baz@example.com
+ END:VEVENT
+ `)
+ );
+
+ // Remember event details so we can refetch it after editing.
+ const eventId = event.id;
+ const eventModified = event.lastModifiedTime;
+
+ // Sanity check. Note that order of attendees is not significant and thus not
+ // guaranteed.
+ const attendees = event.getAttendees();
+ Assert.equal(attendees.length, 3, "there should be three attendees of the event");
+
+ const fooFooson = attendees.find(attendee => attendee.id == "mailto:foo@example.com");
+ Assert.ok(fooFooson, "an attendee should have the address foo@example.com");
+ Assert.equal(fooFooson.commonName, "Foo Fooson", "attendee name should match");
+ Assert.equal(fooFooson.participationStatus, "TENTATIVE", "attendee should be marked tentative");
+ Assert.equal(fooFooson.role, "REQ-PARTICIPANT", "attendee should be required");
+
+ const barBarrington = attendees.find(attendee => attendee.id == "mailto:bar@example.com");
+ Assert.ok(barBarrington, "an attendee should have the address bar@example.com");
+ Assert.equal(barBarrington.commonName, "Bar Barington", "attendee name should match");
+ Assert.equal(barBarrington.participationStatus, "DECLINED", "attendee should have declined");
+ Assert.equal(barBarrington.role, "CHAIR", "attendee should be the meeting chair");
+
+ const bazLuhrmann = attendees.find(attendee => attendee.id == "mailto:baz@example.com");
+ Assert.ok(bazLuhrmann, "an attendee should have the address baz@example.com");
+ Assert.equal(bazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match");
+ Assert.equal(
+ bazLuhrmann.participationStatus,
+ "NEEDS-ACTION",
+ "attendee should not have responded yet"
+ );
+ Assert.equal(bazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional");
+ Assert.equal(bazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP");
+
+ // Open our event for editing.
+ const { dialogWindow: eventWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1);
+ const attendeesWindow = await openAttendeesWindow(eventWindow);
+
+ // Edit the second attendee to correct their name.
+ findAndEditMatchingRow(
+ attendeesWindow,
+ "Bar Barrington <bar@example.com>",
+ "there should an input containing the provided email",
+ value => value.includes("bar@example.com")
+ );
+
+ // Save and close the event.
+ await closeAttendeesWindow(attendeesWindow);
+ await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow);
+
+ await TestUtils.waitForCondition(async () => {
+ const item = await calendar.getItem(eventId);
+ return item.lastModifiedTime != eventModified;
+ });
+
+ const editedEvent = await calendar.getItem(eventId);
+ const editedAttendees = editedEvent.getAttendees();
+ Assert.equal(
+ editedAttendees.length,
+ 3,
+ "there should be three attendees of the event after editing"
+ );
+
+ // Verify that the first attendee's properties have not been overwritten or
+ // lost.
+ const editedFooFooson = editedAttendees.find(attendee => attendee.id == "mailto:foo@example.com");
+ Assert.ok(editedFooFooson, "an attendee should have the address foo@example.com");
+ Assert.equal(editedFooFooson.commonName, "Foo Fooson", "attendee name should match");
+ Assert.equal(
+ editedFooFooson.participationStatus,
+ "TENTATIVE",
+ "attendee should be marked tentative"
+ );
+ Assert.equal(editedFooFooson.role, "REQ-PARTICIPANT", "attendee should be required");
+
+ // Verify that the second attendee's name has been changed and all other
+ // fields remain untouched.
+ const editedBarBarrington = editedAttendees.find(
+ attendee => attendee.id == "mailto:bar@example.com"
+ );
+ Assert.ok(editedBarBarrington, "an attendee should have the address bar@example.com");
+ Assert.equal(editedBarBarrington.commonName, "Bar Barrington", "attendee name should match");
+ Assert.equal(
+ editedBarBarrington.participationStatus,
+ "DECLINED",
+ "attendee should have declined"
+ );
+ Assert.equal(editedBarBarrington.role, "CHAIR", "attendee should be the meeting chair");
+
+ // Verify that the final attendee's properties remain untouched.
+ const editedBazLuhrmann = editedAttendees.find(
+ attendee => attendee.id == "mailto:baz@example.com"
+ );
+ Assert.ok(editedBazLuhrmann, "an attendee should have the address baz@example.com");
+ Assert.equal(editedBazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match");
+ Assert.equal(
+ editedBazLuhrmann.participationStatus,
+ "NEEDS-ACTION",
+ "attendee should not have responded yet"
+ );
+ Assert.equal(editedBazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional");
+ Assert.equal(editedBazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP");
+
+ CalendarTestUtils.removeCalendar(calendar);
+});
diff --git a/comm/calendar/test/browser/eventDialog/browser_eventDialog.js b/comm/calendar/test/browser/eventDialog/browser_eventDialog.js
new file mode 100644
index 0000000000..44d75d7169
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_eventDialog.js
@@ -0,0 +1,399 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { TIMEOUT_MODAL_DIALOG, checkMonthAlarmIcon, handleDeleteOccurrencePrompt } =
+ ChromeUtils.import("resource://testing-common/calendar/CalendarUtils.jsm");
+var { cancelItemDialog, formatTime, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+const EVENTTITLE = "Event";
+const EVENTLOCATION = "Location";
+const EVENTDESCRIPTION = "Event Description";
+const EVENTATTENDEE = "foo@example.com";
+const EVENTURL = "https://mozilla.org/";
+const EVENT_ORGANIZER_EMAIL = "pillow@example.com";
+var firstDay;
+
+var { dayView, monthView } = CalendarTestUtils;
+
+let calendar = CalendarTestUtils.createCalendar();
+// This is done so that calItemBase#isInvitation returns true.
+calendar.setProperty("organizerId", `mailto:${EVENT_ORGANIZER_EMAIL}`);
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+add_task(async function testEventDialog() {
+ let now = new Date();
+
+ // Since from other tests we may be elsewhere, make sure we start today.
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(
+ window,
+ now.getUTCFullYear(),
+ now.getUTCMonth() + 1,
+ now.getUTCDate()
+ );
+ await CalendarTestUtils.calendarViewBackward(window, 1);
+
+ // Open month view.
+ await CalendarTestUtils.setCalendarView(window, "month");
+ firstDay = window.currentView().startDay;
+ dump(`First day in view is: ${firstDay.year}-${firstDay.month + 1}-${firstDay.day}\n`);
+
+ // Setup start- & endTime.
+ // Next full hour except last hour of the day.
+ let hour = now.getUTCHours();
+ let startHour = hour == 23 ? hour : (hour + 1) % 24;
+
+ let nextHour = cal.dtz.now();
+ nextHour.resetTo(firstDay.year, firstDay.month, firstDay.day, startHour, 0, 0, cal.dtz.UTC);
+ let startTime = formatTime(nextHour);
+ nextHour.resetTo(
+ firstDay.year,
+ firstDay.month,
+ firstDay.day,
+ (startHour + 1) % 24,
+ 0,
+ 0,
+ cal.dtz.UTC
+ );
+ let endTime = formatTime(nextHour);
+
+ // Create new event on first day in view.
+ EventUtils.synthesizeMouseAtCenter(monthView.getDayBox(window, 1, 1), {}, window);
+
+ let { dialogWindow, iframeWindow, dialogDocument, iframeDocument } =
+ await CalendarTestUtils.editNewEvent(window);
+
+ // First check all standard-values are set correctly.
+ let startPicker = iframeDocument.getElementById("event-starttime");
+ Assert.equal(startPicker._timepicker._inputField.value, startTime);
+
+ // Check selected calendar.
+ Assert.equal(iframeDocument.getElementById("item-calendar").value, "Test");
+
+ // Check standard title.
+ let defTitle = cal.l10n.getAnyString("calendar", "calendar", "newEvent");
+ Assert.equal(iframeDocument.getElementById("item-title").placeholder, defTitle);
+
+ // Prepare category.
+ let categories = cal.l10n.getAnyString("calendar", "categories", "categories2");
+ // Pick 4th value in a comma-separated list.
+ let category = categories.split(",")[4];
+ // Calculate date to repeat until.
+ let untildate = firstDay.clone();
+ untildate.addDuration(cal.createDuration("P20D"));
+
+ // Fill in the rest of the values.
+ await setData(dialogWindow, iframeWindow, {
+ title: EVENTTITLE,
+ location: EVENTLOCATION,
+ description: EVENTDESCRIPTION,
+ categories: [category],
+ repeat: "daily",
+ repeatuntil: untildate,
+ reminder: "5minutes",
+ privacy: "private",
+ attachment: { add: EVENTURL },
+ attendees: { add: EVENTATTENDEE },
+ });
+
+ // Verify attendee added.
+ EventUtils.synthesizeMouseAtCenter(
+ iframeDocument.getElementById("event-grid-tab-attendees"),
+ {},
+ dialogWindow
+ );
+
+ let attendeesTab = iframeDocument.getElementById("event-grid-tabpanel-attendees");
+ let attendeeNameElements = attendeesTab.querySelectorAll(".attendee-list .attendee-name");
+ Assert.equal(attendeeNameElements.length, 2, "there should be two attendees after save");
+ Assert.equal(attendeeNameElements[0].textContent, EVENT_ORGANIZER_EMAIL);
+ Assert.equal(attendeeNameElements[1].textContent, EVENTATTENDEE);
+ Assert.ok(!iframeDocument.getElementById("notify-attendees-checkbox").checked);
+
+ // Verify private label visible.
+ await TestUtils.waitForCondition(
+ () => !dialogDocument.getElementById("status-privacy-private-box").hasAttribute("collapsed")
+ );
+ dialogDocument.getElementById("event-privacy-menupopup").hidePopup();
+
+ // Add attachment and verify added.
+ EventUtils.synthesizeMouseAtCenter(
+ iframeDocument.getElementById("event-grid-tab-attachments"),
+ {},
+ iframeWindow
+ );
+
+ let attachmentsTab = iframeDocument.getElementById("event-grid-tabpanel-attachments");
+ Assert.equal(attachmentsTab.querySelectorAll("richlistitem").length, 1);
+
+ let alarmPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ "chrome://calendar/content/calendar-alarm-dialog.xhtml",
+ {
+ callback(alarmWindow) {
+ let dismissAllButton = alarmWindow.document.getElementById("alarm-dismiss-all-button");
+ EventUtils.synthesizeMouseAtCenter(dismissAllButton, {}, alarmWindow);
+ },
+ }
+ );
+
+ // save
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Catch and dismiss alarm.
+ await alarmPromise;
+
+ // Verify event and alarm icon visible until endDate (3 full rows) and check tooltip.
+ for (let row = 1; row <= 3; row++) {
+ for (let col = 1; col <= 7; col++) {
+ await monthView.waitForItemAt(window, row, col, 1);
+ checkMonthAlarmIcon(window, row, col);
+ checkTooltip(row, col, startTime, endTime);
+ }
+ }
+ Assert.ok(!monthView.getItemAt(window, 4, 1, 1));
+
+ // Delete and verify deleted 6th col in row 1.
+ EventUtils.synthesizeMouseAtCenter(monthView.getItemAt(window, 1, 6, 1), {}, window);
+ let elemToDelete = document.getElementById("month-view");
+ await handleDeleteOccurrencePrompt(window, elemToDelete, false);
+
+ await monthView.waitForNoItemAt(window, 1, 6, 1);
+
+ // Verify all others still exist.
+ for (let col = 1; col <= 5; col++) {
+ Assert.ok(monthView.getItemAt(window, 1, col, 1));
+ }
+ Assert.ok(monthView.getItemAt(window, 1, 7, 1));
+
+ for (let row = 2; row <= 3; row++) {
+ for (let col = 1; col <= 7; col++) {
+ Assert.ok(monthView.getItemAt(window, row, col, 1));
+ }
+ }
+
+ // Delete series by deleting last item in row 1 and confirming to delete all.
+ EventUtils.synthesizeMouseAtCenter(monthView.getItemAt(window, 1, 7, 1), {}, window);
+ elemToDelete = document.getElementById("month-view");
+ await handleDeleteOccurrencePrompt(window, elemToDelete, true);
+
+ // Verify all deleted.
+ await monthView.waitForNoItemAt(window, 1, 5, 1);
+ await monthView.waitForNoItemAt(window, 1, 6, 1);
+ await monthView.waitForNoItemAt(window, 1, 7, 1);
+
+ for (let row = 2; row <= 3; row++) {
+ for (let col = 1; col <= 7; col++) {
+ await monthView.waitForNoItemAt(window, row, col, 1);
+ }
+ }
+});
+
+add_task(async function testOpenExistingEventDialog() {
+ let now = new Date();
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(
+ window,
+ now.getUTCFullYear(),
+ now.getUTCMonth() + 1,
+ now.getUTCDate()
+ );
+
+ let createBox = dayView.getHourBoxAt(window, 8);
+
+ // Create a new event.
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createBox);
+ await setData(dialogWindow, iframeWindow, {
+ title: EVENTTITLE,
+ location: EVENTLOCATION,
+ description: EVENTDESCRIPTION,
+ });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ let eventBox = await dayView.waitForEventBoxAt(window, 1);
+
+ // Open the event in the summary dialog, it will fail if otherwise.
+ let eventWin = await CalendarTestUtils.viewItem(window, eventBox);
+ Assert.equal(
+ eventWin.document.querySelector("calendar-item-summary .item-title").textContent,
+ EVENTTITLE
+ );
+ Assert.equal(
+ eventWin.document.querySelector("calendar-item-summary .item-location").textContent,
+ EVENTLOCATION
+ );
+ Assert.equal(
+ eventWin.document.querySelector("calendar-item-summary .item-description").contentDocument.body
+ .innerText,
+ EVENTDESCRIPTION
+ );
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWin);
+
+ eventBox.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await dayView.waitForNoEventBoxAt(window, 1);
+});
+
+add_task(async function testEventReminderDisplay() {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2020, 1, 1);
+
+ let createBox = dayView.getHourBoxAt(window, 8);
+
+ // Create an event without a reminder.
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createBox);
+ await setData(dialogWindow, iframeWindow, {
+ title: EVENTTITLE,
+ location: EVENTLOCATION,
+ description: EVENTDESCRIPTION,
+ });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ let eventBox = await dayView.waitForEventBoxAt(window, 1);
+
+ let eventWindow = await CalendarTestUtils.viewItem(window, eventBox);
+ let doc = eventWindow.document;
+ let row = doc.querySelector(".reminder-row");
+ Assert.ok(row.hidden, "reminder dropdown is not displayed");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWindow);
+
+ await CalendarTestUtils.goToDate(window, 2020, 2, 1);
+ createBox = dayView.getHourBoxAt(window, 8);
+
+ // Create an event with a reminder.
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createBox));
+ await setData(dialogWindow, iframeWindow, {
+ title: EVENTTITLE,
+ location: EVENTLOCATION,
+ description: EVENTDESCRIPTION,
+ reminder: "1week",
+ });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ eventBox = await dayView.waitForEventBoxAt(window, 1);
+ eventWindow = await CalendarTestUtils.viewItem(window, eventBox);
+ doc = eventWindow.document;
+ row = doc.querySelector(".reminder-row");
+
+ Assert.ok(
+ row.textContent.includes("7 days before"),
+ "the details are shown when a reminder is set"
+ );
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWindow);
+
+ // Create an invitation.
+ let icalString =
+ "BEGIN:VCALENDAR\r\n" +
+ "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN\r\n" +
+ "VERSION:2.0\r\n" +
+ "BEGIN:VEVENT\r\n" +
+ "CREATED:20200301T152601Z\r\n" +
+ "DTSTAMP:20200301T192729Z\r\n" +
+ "UID:x137e\r\n" +
+ "SUMMARY:Nap Time\r\n" +
+ "ORGANIZER;CN=Papa Bois:mailto:papabois@example.com\r\n" +
+ "ATTENDEE;RSVP=TRUE;CN=pillow@example.com;PARTSTAT=NEEDS-ACTION;CUTY\r\n" +
+ " PE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;X-NUM-GUESTS=0:mailto:pillow@example.com\r\n" +
+ "DTSTART:20200301T153000Z\r\n" +
+ "DTEND:20200301T163000Z\r\n" +
+ "DESCRIPTION:Slumber In Lumber\r\n" +
+ "SEQUENCE:0\r\n" +
+ "TRANSP:OPAQUE\r\n" +
+ "BEGIN:VALARM\r\n" +
+ "TRIGGER:-PT30M\r\n" +
+ "REPEAT:2\r\n" +
+ "DURATION:PT15M\r\n" +
+ "ACTION:DISPLAY\r\n" +
+ "END:VALARM\r\n" +
+ "END:VEVENT\r\n" +
+ "END:VCALENDAR\r\n";
+
+ let calendarEvent = await calendar.addItem(new CalEvent(icalString));
+ await CalendarTestUtils.goToDate(window, 2020, 3, 1);
+ eventBox = await dayView.waitForEventBoxAt(window, 1);
+
+ eventWindow = await CalendarTestUtils.viewItem(window, eventBox);
+ doc = eventWindow.document;
+ row = doc.querySelector(".reminder-row");
+
+ Assert.ok(!row.hidden, "reminder row is displayed");
+ Assert.ok(row.querySelector("menulist") != null, "reminder dropdown is available");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWindow);
+
+ // Delete directly, as using the UI causes a prompt to appear.
+ calendar.deleteItem(calendarEvent);
+ await dayView.waitForNoEventBoxAt(window, 1);
+});
+
+/**
+ * Test that using CTRL+Enter does not result in two events being created.
+ * This only happens in the dialog window. See bug 1668478.
+ */
+add_task(async function testCtrlEnterShortcut() {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2020, 9, 1);
+
+ let createBox = dayView.getHourBoxAt(window, 8);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createBox);
+ await setData(dialogWindow, iframeWindow, {
+ title: EVENTTITLE,
+ location: EVENTLOCATION,
+ description: EVENTDESCRIPTION,
+ });
+ EventUtils.synthesizeKey("VK_RETURN", { ctrlKey: true }, dialogWindow);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+
+ // Give the event boxes enough time to appear before checking for duplicates.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ let events = document.querySelectorAll("calendar-month-day-box-item");
+ Assert.equal(events.length, 1, "event was created once");
+
+ if (Services.focus.activeWindow != window) {
+ await BrowserTestUtils.waitForEvent(window, "focus");
+ }
+
+ events[0].focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+});
+
+function checkTooltip(row, col, startTime, endTime) {
+ let item = monthView.getItemAt(window, row, col, 1);
+
+ let toolTipNode = document.getElementById("itemTooltip");
+ toolTipNode.ownerGlobal.onMouseOverItem({ currentTarget: item });
+
+ function getDescription(index) {
+ return toolTipNode.querySelector(
+ `.tooltipHeaderTable > tr:nth-of-type(${index}) > .tooltipHeaderDescription`
+ ).textContent;
+ }
+
+ // Check title.
+ Assert.equal(getDescription(1), EVENTTITLE);
+
+ // Check date and time.
+ let dateTime = getDescription(3);
+
+ let currDate = firstDay.clone();
+ currDate.addDuration(cal.createDuration(`P${7 * (row - 1) + (col - 1)}D`));
+ let startDate = cal.dtz.formatter.formatDate(currDate);
+
+ Assert.ok(dateTime.includes(`${startDate} ${startTime} – `));
+
+ // This could be on the next day if it is 00:00.
+ Assert.ok(dateTime.endsWith(endTime));
+}
diff --git a/comm/calendar/test/browser/eventDialog/browser_eventDialogDescriptionEditor.js b/comm/calendar/test/browser/eventDialog/browser_eventDialogDescriptionEditor.js
new file mode 100644
index 0000000000..d838330e73
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_eventDialogDescriptionEditor.js
@@ -0,0 +1,154 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+add_setup(async function () {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ CalendarTestUtils.goToDate(window, 2023, 2, 18);
+});
+
+add_task(async function testPastePreformattedWithLinebreak() {
+ const calendar = CalendarTestUtils.createCalendar();
+
+ // Create an event which currently has no description.
+ const event = await calendar.addItem(
+ new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ SUMMARY:An event
+ DTSTART:20230218T100000Z
+ DTEND:20230218T110000Z
+ END:VEVENT
+ `)
+ );
+
+ // Remember event details so we can refetch it after editing.
+ const eventId = event.id;
+ const eventModified = event.lastModifiedTime;
+
+ // Sanity check.
+ Assert.equal(event.descriptionHTML, null, "event should not have an HTML description");
+ Assert.equal(event.descriptionText, null, "event should not have a text description");
+
+ // Open our event for editing.
+ const { dialogWindow: eventWindow, iframeDocument } = await CalendarTestUtils.dayView.editEventAt(
+ window,
+ 1
+ );
+
+ const editor = iframeDocument.getElementById("item-description");
+ editor.focus();
+
+ const expectedHTML =
+ "<pre><code>This event is one which includes\nan explicit linebreak inside a pre tag.</code></pre>";
+
+ // Create a paste which includes HTML data, which the editor will recognize as
+ // HTML and paste with formatting by default.
+ const stringData = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString);
+ stringData.data = expectedHTML;
+
+ const transferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(Ci.nsITransferable);
+ transferable.init(null);
+ transferable.addDataFlavor("text/html");
+ transferable.setTransferData("text/html", stringData);
+ Services.clipboard.setData(transferable, null, Ci.nsIClipboard.kGlobalClipboard);
+
+ // Paste.
+ EventUtils.synthesizeKey("v", { accelKey: true }, eventWindow);
+
+ await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow);
+
+ await TestUtils.waitForCondition(async () => {
+ const item = await calendar.getItem(eventId);
+ return item.lastModifiedTime != eventModified;
+ });
+
+ const editedEvent = await calendar.getItem(eventId);
+
+ // Verify that the description has been set appropriately. There should be no
+ // change to the HTML, which is preformatted, and the text description should
+ // include a linebreak in the same place as the HTML.
+ Assert.equal(editedEvent.descriptionHTML, expectedHTML, "HTML description should match input");
+ Assert.equal(
+ editedEvent.descriptionText,
+ "This event is one which includes\nan explicit linebreak inside a pre tag.",
+ "text description should include linebreak"
+ );
+
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+add_task(async function testTypeLongTextWithLinebreaks() {
+ const calendar = CalendarTestUtils.createCalendar();
+
+ // Create an event which currently has no description.
+ const event = await calendar.addItem(
+ new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ SUMMARY:An event
+ DTSTART:20230218T100000Z
+ DTEND:20230218T110000Z
+ END:VEVENT
+ `)
+ );
+
+ // Remember event details so we can refetch it after editing.
+ const eventId = event.id;
+ const eventModified = event.lastModifiedTime;
+
+ // Sanity check.
+ Assert.equal(event.descriptionHTML, null, "event should not have an HTML description");
+ Assert.equal(event.descriptionText, null, "event should not have a text description");
+
+ // Open our event for editing.
+ const {
+ dialogWindow: eventWindow,
+ iframeDocument,
+ iframeWindow,
+ } = await CalendarTestUtils.dayView.editEventAt(window, 1);
+
+ const editor = iframeDocument.getElementById("item-description");
+ editor.focus();
+
+ // Insert text with several long lines and explicit linebreaks.
+ const firstLine =
+ "This event is pretty much just plain text, albeit it has some pretty long lines so that we can ensure that we don't accidentally wrap it during conversion.";
+ EventUtils.sendString(firstLine, iframeWindow);
+ EventUtils.sendKey("RETURN", iframeWindow);
+
+ const secondLine = "This line follows immediately after a linebreak.";
+ EventUtils.sendString(secondLine, iframeWindow);
+ EventUtils.sendKey("RETURN", iframeWindow);
+ EventUtils.sendKey("RETURN", iframeWindow);
+
+ const thirdLine =
+ "And one after a couple more linebreaks, for good measure. It might as well be a fairly long string as well, just so we're certain.";
+ EventUtils.sendString(thirdLine, iframeWindow);
+
+ await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow);
+
+ await TestUtils.waitForCondition(async () => {
+ const item = await calendar.getItem(eventId);
+ return item.lastModifiedTime != eventModified;
+ });
+
+ const editedEvent = await calendar.getItem(eventId);
+
+ // Verify that the description has been set appropriately. The HTML should
+ // match the input and use <br> as a linebreak, while the text should not be
+ // wrapped and should use \n as a linebreak.
+ Assert.equal(
+ editedEvent.descriptionHTML,
+ `${firstLine}<br>${secondLine}<br><br>${thirdLine}`,
+ "HTML description should match input with <br> for linebreaks"
+ );
+ Assert.equal(
+ editedEvent.descriptionText,
+ `${firstLine}\n${secondLine}\n\n${thirdLine}`,
+ "text description should match input with linebreaks"
+ );
+
+ CalendarTestUtils.removeCalendar(calendar);
+});
diff --git a/comm/calendar/test/browser/eventDialog/browser_eventDialogEditButton.js b/comm/calendar/test/browser/eventDialog/browser_eventDialogEditButton.js
new file mode 100644
index 0000000000..b7730444b2
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_eventDialogEditButton.js
@@ -0,0 +1,223 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the edit button displayed in the calendar summary dialog.
+ */
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+});
+
+const calendar = CalendarTestUtils.createCalendar("Edit Button Test", "storage");
+
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+function createNonRecurringEvent() {
+ let event = new CalEvent();
+ event.title = "Non-Recurring Event";
+ event.startDate = cal.createDateTime("20191201T000001Z");
+ return event;
+}
+
+function createRecurringEvent() {
+ let event = new CalEvent();
+ event.title = "Recurring Event";
+ event.startDate = cal.createDateTime("20200101T000001Z");
+ event.recurrenceInfo = new CalRecurrenceInfo(event);
+ event.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=30"));
+ return event;
+}
+
+/**
+ * Test the correct edit button is shown for a non-recurring event.
+ */
+add_task(async function testNonRecurringEvent() {
+ let event = await calendar.addItem(createNonRecurringEvent());
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(event.startDate);
+
+ let eventWindow = await CalendarTestUtils.monthView.viewItemAt(window, 1, 1, 1);
+ let editMenuButton = eventWindow.document.querySelector(
+ "#calendar-summary-dialog-edit-menu-button"
+ );
+
+ Assert.ok(
+ !BrowserTestUtils.is_visible(editMenuButton),
+ "edit dropdown is not visible for non-recurring event"
+ );
+
+ let editButton = eventWindow.document.querySelector("#calendar-summary-dialog-edit-button");
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(editButton),
+ "edit button is visible for non-recurring event"
+ );
+ await CalendarTestUtils.items.cancelItemDialog(eventWindow);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test the edit button for a non-recurring event actual edits the event.
+ */
+add_task(async function testEditNonRecurringEvent() {
+ let event = await calendar.addItem(createNonRecurringEvent());
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(event.startDate);
+
+ let modificationPromise = new Promise(resolve => {
+ calendar.wrappedJSObject.addObserver({
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+ onModifyItem(aNewItem, aOldItem) {
+ calendar.wrappedJSObject.removeObserver(this);
+ resolve();
+ },
+ });
+ });
+
+ let { dialogWindow, iframeDocument } = await CalendarTestUtils.monthView.editItemAt(
+ window,
+ 1,
+ 1,
+ 1
+ );
+
+ let newTitle = "Edited Non-Recurring Event";
+ iframeDocument.querySelector("#item-title").value = newTitle;
+
+ await CalendarTestUtils.items.saveAndCloseItemDialog(dialogWindow);
+ await modificationPromise;
+
+ let viewWindow = await CalendarTestUtils.monthView.viewItemAt(window, 1, 1, 1);
+ let actualTitle = viewWindow.document.querySelector(
+ "#calendar-item-summary .item-title"
+ ).textContent;
+
+ Assert.equal(actualTitle, newTitle, "edit non-recurring event successful");
+ await CalendarTestUtils.items.cancelItemDialog(viewWindow);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Tests the dropdown menu is displayed for a recurring event.
+ */
+add_task(async function testRecurringEvent() {
+ let event = await calendar.addItem(createRecurringEvent());
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(event.startDate);
+
+ let viewWindow = await CalendarTestUtils.monthView.viewItemAt(window, 1, 6, 1);
+
+ Assert.ok(
+ !BrowserTestUtils.is_visible(
+ viewWindow.document.querySelector("#calendar-summary-dialog-edit-button")
+ ),
+ "non-recurring edit button is not visible for recurring event"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(
+ viewWindow.document.querySelector("#calendar-summary-dialog-edit-menu-button")
+ ),
+ "edit dropdown is visible for recurring event"
+ );
+
+ await CalendarTestUtils.items.cancelItemDialog(viewWindow);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Tests the dropdown menu allows a single occurrence of a repeating event
+ * to be edited.
+ */
+add_task(async function testEditThisOccurrence() {
+ let event = createRecurringEvent();
+ event = await calendar.addItem(event);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(event.startDate);
+
+ let modificationPromise = new Promise(resolve => {
+ calendar.wrappedJSObject.addObserver({
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+ onModifyItem(aNewItem, aOldItem) {
+ calendar.wrappedJSObject.removeObserver(this);
+ resolve();
+ },
+ });
+ });
+
+ let { dialogWindow, iframeDocument } = await CalendarTestUtils.monthView.editItemOccurrenceAt(
+ window,
+ 1,
+ 6,
+ 1
+ );
+
+ let originalTitle = event.title;
+ let newTitle = "Edited This Occurrence";
+
+ iframeDocument.querySelector("#item-title").value = newTitle;
+ await CalendarTestUtils.items.saveAndCloseItemDialog(dialogWindow);
+
+ await modificationPromise;
+
+ let changedBox = await CalendarTestUtils.monthView.waitForItemAt(window, 1, 6, 1);
+ let eventBoxes = document.querySelectorAll("calendar-month-day-box-item");
+
+ for (let box of eventBoxes) {
+ if (box !== changedBox) {
+ Assert.equal(
+ box.item.title,
+ originalTitle,
+ '"Edit this occurrence" did not edit other occurrences'
+ );
+ } else {
+ Assert.equal(box.item.title, newTitle, '"Edit this occurrence only" edited this occurrence.');
+ }
+ }
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Tests the dropdown menu allows all occurrences of a recurring event to be
+ * edited.
+ */
+add_task(async function testEditAllOccurrences() {
+ let event = await calendar.addItem(createRecurringEvent());
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(event.startDate);
+
+ // Setup an observer so we can wait for the event boxes to be updated.
+ let boxesRefreshed = false;
+ let observer = new MutationObserver(() => (boxesRefreshed = true));
+ observer.observe(document.querySelector("#month-view"), {
+ childList: true,
+ subtree: true,
+ });
+
+ let { dialogWindow, iframeDocument } = await CalendarTestUtils.monthView.editItemOccurrencesAt(
+ window,
+ 1,
+ 6,
+ 1
+ );
+
+ let newTitle = "Edited All Occurrences";
+
+ iframeDocument.querySelector("#item-title").value = newTitle;
+ await CalendarTestUtils.items.saveAndCloseItemDialog(dialogWindow);
+ await TestUtils.waitForCondition(() => boxesRefreshed, "event boxes did not refresh in time");
+
+ let eventBoxes = document.querySelectorAll("calendar-month-day-box-item");
+ for (let box of eventBoxes) {
+ Assert.equal(box.item.title, newTitle, '"Edit all occurrences" edited each occurrence');
+ }
+ await calendar.deleteItem(event);
+});
diff --git a/comm/calendar/test/browser/eventDialog/browser_eventDialogModificationPrompt.js b/comm/calendar/test/browser/eventDialog/browser_eventDialogModificationPrompt.js
new file mode 100644
index 0000000000..b0f3282b24
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_eventDialogModificationPrompt.js
@@ -0,0 +1,160 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+requestLongerTimeout(2);
+
+var { cancelItemDialog, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { data, newlines } = setupData();
+
+var { dayView } = CalendarTestUtils;
+
+let calendar = CalendarTestUtils.createCalendar();
+// This is done so that calItemBase#isInvitation returns true.
+calendar.setProperty("organizerId", "mailto:pillow@example.com");
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+// Test that closing an event dialog with no changes does not prompt for save.
+add_task(async function testEventDialogModificationPrompt() {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ let createbox = dayView.getHourBoxAt(window, 8);
+
+ // Create new event.
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createbox);
+ let categories = cal.l10n.getAnyString("calendar", "categories", "categories2").split(",");
+ data[0].categories.push(categories[0]);
+ data[1].categories.push(categories[1], categories[2]);
+
+ // Enter first set of data.
+ await setData(dialogWindow, iframeWindow, data[0]);
+ await saveAndCloseItemDialog(dialogWindow);
+
+ let eventbox = await dayView.waitForEventBoxAt(window, 1);
+
+ // Open, but change nothing.
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editItem(window, eventbox));
+ // Escape the event window, there should be no prompt to save event.
+ cancelItemDialog(dialogWindow);
+ // Wait to see if the prompt appears.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ eventbox = await dayView.waitForEventBoxAt(window, 1);
+ // Open, change all values then revert the changes.
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editItem(window, eventbox));
+ // Change all values.
+ await setData(dialogWindow, iframeWindow, data[1]);
+
+ // Edit all values back to original.
+ await setData(dialogWindow, iframeWindow, data[0]);
+
+ // Escape the event window, there should be no prompt to save event.
+ cancelItemDialog(dialogWindow);
+ // Wait to see if the prompt appears.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ // Delete event.
+ document.getElementById("day-view").focus();
+ if (window.currentView().getSelectedItems().length == 0) {
+ EventUtils.synthesizeMouseAtCenter(eventbox, {}, window);
+ }
+ Assert.equal(eventbox.isEditing, false, "event is not being edited");
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await dayView.waitForNoEventBoxAt(window, 1);
+});
+
+add_task(async function testDescriptionWhitespace() {
+ for (let i = 0; i < newlines.length; i++) {
+ // test set i
+ let createbox = dayView.getHourBoxAt(window, 8);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createbox);
+ await setData(dialogWindow, iframeWindow, newlines[i]);
+ await saveAndCloseItemDialog(dialogWindow);
+
+ let eventbox = await dayView.waitForEventBoxAt(window, 1);
+
+ // Open and close.
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editItem(window, eventbox));
+ await setData(dialogWindow, iframeWindow, newlines[i]);
+ cancelItemDialog(dialogWindow);
+ // Wait to see if the prompt appears.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ // Delete it.
+ document.getElementById("day-view").focus();
+ if (window.currentView().getSelectedItems().length == 0) {
+ EventUtils.synthesizeMouseAtCenter(eventbox, {}, window);
+ }
+ Assert.equal(eventbox.isEditing, false, "event is not being edited");
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await dayView.waitForNoEventBoxAt(window, 1);
+ }
+});
+
+function setupData() {
+ let date1 = cal.createDateTime("20090101T080000Z");
+ let date2 = cal.createDateTime("20090102T090000Z");
+ let date3 = cal.createDateTime("20090103T100000Z");
+ return {
+ data: [
+ {
+ title: "title1",
+ location: "location1",
+ description: "description1",
+ categories: [],
+ allday: false,
+ startdate: date1,
+ starttime: date1,
+ enddate: date2,
+ endtime: date2,
+ repeat: "none",
+ reminder: "none",
+ priority: "normal",
+ privacy: "public",
+ status: "confirmed",
+ freebusy: "busy",
+ timezonedisplay: true,
+ attachment: { add: "https://mozilla.org" },
+ attendees: { add: "foo@bar.de,foo@bar.com" },
+ },
+ {
+ title: "title2",
+ location: "location2",
+ description: "description2",
+ categories: [],
+ allday: true,
+ startdate: date2,
+ starttime: date2,
+ enddate: date3,
+ endtime: date3,
+ repeat: "daily",
+ reminder: "5minutes",
+ priority: "high",
+ privacy: "private",
+ status: "tentative",
+ freebusy: "free",
+ timezonedisplay: false,
+ attachment: { remove: "mozilla.org" },
+ attendees: { remove: "foo@bar.de,foo@bar.com" },
+ },
+ ],
+ newlines: [
+ { title: "title", description: " test spaces " },
+ { title: "title", description: "\ntest newline\n" },
+ { title: "title", description: "\rtest \\r\r" },
+ { title: "title", description: "\r\ntest \\r\\n\r\n" },
+ { title: "title", description: "\ttest \\t\t" },
+ ],
+ };
+}
diff --git a/comm/calendar/test/browser/eventDialog/browser_utf8.js b/comm/calendar/test/browser/eventDialog/browser_utf8.js
new file mode 100644
index 0000000000..5e9ff82d19
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_utf8.js
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cancelItemDialog, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var UTF8STRING = " πŸ’£ πŸ’₯ ☣ ";
+
+add_task(async function testUTF8() {
+ let calendar = CalendarTestUtils.createCalendar();
+ Services.prefs.setStringPref("calendar.categories.names", UTF8STRING);
+
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ Services.prefs.clearUserPref("calendar.categories.names");
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+
+ // Create new event.
+ let eventBox = CalendarTestUtils.dayView.getHourBoxAt(window, 8);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ // Fill in name, location, description.
+ await setData(dialogWindow, iframeWindow, {
+ title: UTF8STRING,
+ location: UTF8STRING,
+ description: UTF8STRING,
+ categories: [UTF8STRING],
+ });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // open
+ let { dialogWindow: dlgWindow, iframeDocument } = await CalendarTestUtils.dayView.editEventAt(
+ window,
+ 1
+ );
+ // Check values.
+ Assert.equal(iframeDocument.getElementById("item-title").value, UTF8STRING);
+ Assert.equal(iframeDocument.getElementById("item-location").value, UTF8STRING);
+ // The trailing spaces confuse innerText, so we'll do this longhand
+ let editorEl = iframeDocument.getElementById("item-description");
+ let editor = editorEl.getEditor(editorEl.contentWindow);
+ let description = editor.outputToString("text/plain", 0);
+ // The HTML editor makes the first character a NBSP instead of a space.
+ Assert.equal(description.replaceAll("\xA0", " "), UTF8STRING);
+ Assert.ok(
+ iframeDocument
+ .getElementById("item-categories")
+ .querySelector(`menuitem[label="${UTF8STRING}"][checked]`)
+ );
+
+ // Escape the event window.
+ cancelItemDialog(dlgWindow);
+});
diff --git a/comm/calendar/test/browser/eventDialog/data/guests.txt b/comm/calendar/test/browser/eventDialog/data/guests.txt
new file mode 100644
index 0000000000..e2959cf71e
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/data/guests.txt
@@ -0,0 +1,2 @@
+Nobody
+No one
diff --git a/comm/calendar/test/browser/eventDialog/head.js b/comm/calendar/test/browser/eventDialog/head.js
new file mode 100644
index 0000000000..0646cd709c
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/head.js
@@ -0,0 +1,97 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+// If the "do you want to save the event?" prompt appears, the test failed.
+// Listen for all windows opening, and if one is the save prompt, fail.
+var savePromptObserver = {
+ async observe(win, topic) {
+ if (topic == "domwindowopened") {
+ await BrowserTestUtils.waitForEvent(win, "load");
+ // Make sure this is a prompt window.
+ if (win.location.href == "chrome://global/content/commonDialog.xhtml") {
+ let doc = win.document;
+ // Adding attachments also shows a prompt, but we can tell which one
+ // this is by checking whether the textbox is visible.
+ if (doc.querySelector("#loginContainer").hasAttribute("hidden")) {
+ Assert.report(true, undefined, undefined, "Unexpected save prompt appeared");
+ doc.querySelector("dialog").getButton("cancel").click();
+ }
+ }
+ }
+ },
+};
+Services.ww.registerNotification(savePromptObserver);
+
+const calendarViewsInitialState = CalendarTestUtils.saveCalendarViewsState(window);
+
+registerCleanupFunction(async () => {
+ Services.ww.unregisterNotification(savePromptObserver);
+ await CalendarTestUtils.restoreCalendarViewsState(window, calendarViewsInitialState);
+});
+
+function openAttendeesWindow(eventWindowOrArgs) {
+ let attendeesWindowPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ null,
+ "chrome://calendar/content/calendar-event-dialog-attendees.xhtml",
+ {
+ async callback(win) {
+ await new Promise(resolve => win.setTimeout(resolve));
+ },
+ }
+ );
+
+ if (Window.isInstance(eventWindowOrArgs)) {
+ EventUtils.synthesizeMouseAtCenter(
+ eventWindowOrArgs.document.getElementById("button-attendees"),
+ {},
+ eventWindowOrArgs
+ );
+ } else {
+ openDialog(
+ "chrome://calendar/content/calendar-event-dialog-attendees.xhtml",
+ "_blank",
+ "chrome,titlebar,resizable",
+ eventWindowOrArgs
+ );
+ }
+ return attendeesWindowPromise;
+}
+
+async function closeAttendeesWindow(attendeesWindow, buttonAction = "accept") {
+ let closedPromise = BrowserTestUtils.domWindowClosed(attendeesWindow);
+ let dialog = attendeesWindow.document.querySelector("dialog");
+ dialog.getButton(buttonAction).click();
+ await closedPromise;
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+function findAndFocusMatchingRow(attendeesWindow, message, matchFunction) {
+ // Get the attendee row for which the input matches.
+ const attendeeList = attendeesWindow.document.getElementById("attendee-list");
+ const attendeeInput = Array.from(attendeeList.children)
+ .map(child => child.querySelector("input"))
+ .find(input => {
+ return input ? matchFunction(input.value) : false;
+ });
+ Assert.ok(attendeeInput, message);
+
+ attendeeInput.focus();
+
+ return attendeeInput;
+}
+
+function findAndEditMatchingRow(attendeesWindow, newValue, message, matchFunction) {
+ // Get the attendee row we wish to edit.
+ const attendeeInput = findAndFocusMatchingRow(attendeesWindow, message, matchFunction);
+
+ // Set the new value of the row. We set the input value directly due to issues
+ // experienced trying to use simulated keystrokes.
+ attendeeInput.value = newValue;
+ EventUtils.synthesizeKey("VK_RETURN", {}, attendeesWindow);
+}
diff --git a/comm/calendar/test/browser/head.js b/comm/calendar/test/browser/head.js
new file mode 100644
index 0000000000..f76cc85754
--- /dev/null
+++ b/comm/calendar/test/browser/head.js
@@ -0,0 +1,374 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../base/content/calendar-views-utils.js */
+
+/* globals openOptionsDialog, openAddonsMgr */
+
+const { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+async function openTasksTab() {
+ let tabmail = document.getElementById("tabmail");
+ let tasksMode = tabmail.tabModes.tasks;
+
+ if (tasksMode.tabs.length == 1) {
+ tabmail.selectedTab = tasksMode.tabs[0];
+ } else {
+ let tasksTabButton = document.getElementById("tasksButton");
+ EventUtils.synthesizeMouseAtCenter(tasksTabButton, { clickCount: 1 });
+ }
+
+ is(tasksMode.tabs.length, 1, "tasks tab is open");
+ is(tabmail.selectedTab, tasksMode.tabs[0], "tasks tab is selected");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+async function closeTasksTab() {
+ let tabmail = document.getElementById("tabmail");
+ let tasksMode = tabmail.tabModes.tasks;
+
+ if (tasksMode.tabs.length == 1) {
+ tabmail.closeTab(tasksMode.tabs[0]);
+ }
+
+ is(tasksMode.tabs.length, 0, "tasks tab is not open");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+/**
+ * Currently there's always a folder tab open, hence "select" not "open".
+ */
+async function selectFolderTab() {
+ const tabmail = document.getElementById("tabmail");
+ const folderMode = tabmail.tabModes.mail3PaneTab;
+
+ tabmail.selectedTab = folderMode.tabs[0];
+
+ is(folderMode.tabs.length > 0, true, "at least one folder tab is open");
+ is(tabmail.selectedTab, folderMode.tabs[0], "a folder tab is selected");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+async function openChatTab() {
+ let tabmail = document.getElementById("tabmail");
+ let chatMode = tabmail.tabModes.chat;
+
+ if (chatMode.tabs.length == 1) {
+ tabmail.selectedTab = chatMode.tabs[0];
+ } else {
+ window.showChatTab();
+ }
+
+ is(chatMode.tabs.length, 1, "chat tab is open");
+ is(tabmail.selectedTab, chatMode.tabs[0], "chat tab is selected");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+async function closeChatTab() {
+ let tabmail = document.getElementById("tabmail");
+ let chatMode = tabmail.tabModes.chat;
+
+ if (chatMode.tabs.length == 1) {
+ tabmail.closeTab(chatMode.tabs[0]);
+ }
+
+ is(chatMode.tabs.length, 0, "chat tab is not open");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+/**
+ * Opens a new calendar event or task tab.
+ *
+ * @param {string} tabMode - Mode of the new tab, either `calendarEvent` or `calendarTask`.
+ * @returns {string} - The id of the new tab's panel element.
+ */
+async function _openNewCalendarItemTab(tabMode) {
+ let tabmail = document.getElementById("tabmail");
+ let itemTabs = tabmail.tabModes[tabMode].tabs;
+ let previousTabCount = itemTabs.length;
+
+ Services.prefs.setBoolPref("calendar.item.editInTab", true);
+ let buttonId = "sidePanelNewEvent";
+ if (tabMode == "calendarTask") {
+ await openTasksTab();
+ buttonId = "sidePanelNewTask";
+ } else {
+ await CalendarTestUtils.openCalendarTab(window);
+ }
+
+ let newItemButton = document.getElementById(buttonId);
+ EventUtils.synthesizeMouseAtCenter(newItemButton, { clickCount: 1 });
+
+ let newTab = itemTabs[itemTabs.length - 1];
+
+ is(itemTabs.length, previousTabCount + 1, `new ${tabMode} tab is open`);
+ is(tabmail.selectedTab, newTab, `new ${tabMode} tab is selected`);
+
+ await BrowserTestUtils.browserLoaded(newTab.iframe);
+ await new Promise(resolve => setTimeout(resolve));
+ return newTab.panel.id;
+}
+
+let openNewCalendarEventTab = _openNewCalendarItemTab.bind(null, "calendarEvent");
+let openNewCalendarTaskTab = _openNewCalendarItemTab.bind(null, "calendarTask");
+
+/**
+ * Selects an existing (open) calendar event or task tab.
+ *
+ * @param {string} tabMode - The tab mode, either `calendarEvent` or `calendarTask`.
+ * @param {string} panelId - The id of the tab's panel element.
+ */
+async function _selectCalendarItemTab(tabMode, panelId) {
+ let tabmail = document.getElementById("tabmail");
+ let itemTabs = tabmail.tabModes[tabMode].tabs;
+ let tabToSelect = itemTabs.find(tab => tab.panel.id == panelId);
+
+ ok(tabToSelect, `${tabMode} tab is open`);
+
+ tabmail.selectedTab = tabToSelect;
+
+ is(tabmail.selectedTab, tabToSelect, `${tabMode} tab is selected`);
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+let selectCalendarEventTab = _selectCalendarItemTab.bind(null, "calendarEvent");
+let selectCalendarTaskTab = _selectCalendarItemTab.bind(null, "calendarTask");
+
+/**
+ * Closes a calendar event or task tab.
+ *
+ * @param {string} tabMode - The tab mode, either `calendarEvent` or `calendarTask`.
+ * @param {string} panelId - The id of the panel of the tab to close.
+ */
+async function _closeCalendarItemTab(tabMode, panelId) {
+ let tabmail = document.getElementById("tabmail");
+ let itemTabs = tabmail.tabModes[tabMode].tabs;
+ let previousTabCount = itemTabs.length;
+ let itemTab = itemTabs.find(tab => tab.panel.id == panelId);
+
+ if (itemTab) {
+ // Tab does not immediately close, so wait for it.
+ let tabClosedPromise = new Promise(resolve => {
+ itemTab.tabNode.addEventListener("TabClose", resolve, { once: true });
+ });
+ tabmail.closeTab(itemTab);
+ await tabClosedPromise;
+ }
+
+ is(itemTabs.length, previousTabCount - 1, `${tabMode} tab was closed`);
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+let closeCalendarEventTab = _closeCalendarItemTab.bind(null, "calendarEvent");
+let closeCalendarTaskTab = _closeCalendarItemTab.bind(null, "calendarTask");
+
+async function openPreferencesTab() {
+ const tabmail = document.getElementById("tabmail");
+ const prefsMode = tabmail.tabModes.preferencesTab;
+
+ if (prefsMode.tabs.length == 1) {
+ tabmail.selectedTab = prefsMode.tabs[0];
+ } else {
+ openOptionsDialog();
+ }
+
+ is(prefsMode.tabs.length, 1, "preferences tab is open");
+ is(tabmail.selectedTab, prefsMode.tabs[0], "preferences tab is selected");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+async function closeAddressBookTab() {
+ let tabmail = document.getElementById("tabmail");
+ let abMode = tabmail.tabModes.addressBookTab;
+
+ if (abMode.tabs.length == 1) {
+ tabmail.closeTab(abMode.tabs[0]);
+ }
+
+ is(abMode.tabs.length, 0, "address book tab is not open");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+async function closePreferencesTab() {
+ let tabmail = document.getElementById("tabmail");
+ let prefsMode = tabmail.tabModes.preferencesTab;
+
+ if (prefsMode.tabs.length == 1) {
+ tabmail.closeTab(prefsMode.tabs[0]);
+ }
+
+ is(prefsMode.tabs.length, 0, "preferences tab is not open");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+async function openAddonsTab() {
+ const tabmail = document.getElementById("tabmail");
+ const contentMode = tabmail.tabModes.contentTab;
+
+ if (contentMode.tabs.length == 1) {
+ tabmail.selectedTab = contentMode.tabs[0];
+ } else {
+ openAddonsMgr("addons://list/extension");
+ }
+
+ is(contentMode.tabs.length, 1, "addons tab is open");
+ is(tabmail.selectedTab, contentMode.tabs[0], "addons tab is selected");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+async function closeAddonsTab() {
+ let tabmail = document.getElementById("tabmail");
+ let contentMode = tabmail.tabModes.contentTab;
+
+ if (contentMode.tabs.length == 1) {
+ tabmail.closeTab(contentMode.tabs[0]);
+ }
+
+ is(contentMode.tabs.length, 0, "addons tab is not open");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+/**
+ * Create a calendar using the "Create New Calendar" dialog.
+ *
+ * @param {string} name - Name for the new calendar.
+ * @param {object} [data] - Data to enter into the dialog.
+ * @param {boolean} [data.showReminders] - False to disable reminders.
+ * @param {string} [data.email] - An email address.
+ * @param {object} [data.network] - Data for network calendars.
+ * @param {string} [data.network.location] - A URI (leave undefined for local ICS file).
+ * @param {boolean} [data.network.offline] - False to disable the cache.
+ */
+async function createCalendarUsingDialog(name, data = {}) {
+ /**
+ * Callback function to interact with the dialog.
+ *
+ * @param {nsIDOMWindow} win - The dialog window.
+ */
+ async function useDialog(win) {
+ let doc = win.document;
+ let dialogElement = doc.querySelector("dialog");
+ let acceptButton = dialogElement.getButton("accept");
+
+ if (data.network) {
+ // Choose network calendar type.
+ doc.querySelector("#calendar-type [value='network']").click();
+ acceptButton.click();
+
+ // Enter a location.
+ if (data.network.location == undefined) {
+ let calendarFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ calendarFile.append(name + ".ics");
+ let fileURI = Services.io.newFileURI(calendarFile);
+ data.network.location = fileURI.prePath + fileURI.pathQueryRef;
+ }
+ EventUtils.synthesizeMouseAtCenter(doc.querySelector("#network-location-input"), {}, win);
+ EventUtils.sendString(data.network.location, win);
+
+ // Choose offline support.
+ if (data.network.offline == undefined) {
+ data.network.offline = true;
+ }
+ let offlineCheckbox = doc.querySelector("#network-cache-checkbox");
+ if (!offlineCheckbox.checked) {
+ EventUtils.synthesizeMouseAtCenter(offlineCheckbox, {}, win);
+ }
+ acceptButton.click();
+
+ // Set up an observer to wait for calendar(s) to be found, before
+ // clicking the accept button to subscribe to the calendar(s).
+ let observer = new MutationObserver(mutationList => {
+ mutationList.forEach(async mutation => {
+ if (mutation.type === "childList") {
+ acceptButton.click();
+ }
+ });
+ });
+ observer.observe(doc.querySelector("#network-calendar-list"), { childList: true });
+ } else {
+ // Choose local calendar type.
+ doc.querySelector("#calendar-type [value='local']").click();
+ acceptButton.click();
+
+ // Set calendar name.
+ // Setting the value does not activate the accept button on all platforms,
+ // so we need to type something in case the field is empty.
+ let nameInput = doc.querySelector("#local-calendar-name-input");
+ if (nameInput.value == "") {
+ EventUtils.synthesizeMouseAtCenter(nameInput, {}, win);
+ EventUtils.sendString(name, win);
+ }
+
+ // Set reminder option.
+ if (data.showReminders == undefined) {
+ data.showReminders = true;
+ }
+ let localFireAlarmsCheckbox = doc.querySelector("#local-fire-alarms-checkbox");
+ if (localFireAlarmsCheckbox.checked != data.showReminders) {
+ EventUtils.synthesizeMouseAtCenter(localFireAlarmsCheckbox, {}, win);
+ }
+
+ // Set email account.
+ if (data.email == undefined) {
+ data.email = "none";
+ }
+ let emailIdentityMenulist = doc.querySelector("#email-identity-menulist");
+ EventUtils.synthesizeMouseAtCenter(emailIdentityMenulist, {}, win);
+ emailIdentityMenulist.querySelector("menuitem[value='none']").click();
+
+ // Create the calendar.
+ acceptButton.click();
+ }
+ }
+
+ let dialogWindowPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-creation.xhtml",
+ { callback: useDialog }
+ );
+ // Open the "create new calendar" dialog.
+ CalendarTestUtils.openCalendarTab(window);
+ // This double-click must be inside the calendar list but below the list items.
+ EventUtils.synthesizeMouseAtCenter(document.querySelector("#calendar-list"), { clickCount: 2 });
+ return dialogWindowPromise;
+}
+
+const calendarViewsInitialState = CalendarTestUtils.saveCalendarViewsState(window);
+
+registerCleanupFunction(async () => {
+ await CalendarTestUtils.restoreCalendarViewsState(window, calendarViewsInitialState);
+ await closeTasksTab();
+ await closeChatTab();
+ await closeAddressBookTab();
+ await closePreferencesTab();
+ await closeAddonsTab();
+
+ // Close any event or task tabs that are open.
+ let tabmail = document.getElementById("tabmail");
+ let eventTabPanelIds = tabmail.tabModes.calendarEvent.tabs.map(tab => tab.panel.id);
+ let taskTabPanelIds = tabmail.tabModes.calendarTask.tabs.map(tab => tab.panel.id);
+ for (let id of eventTabPanelIds) {
+ await closeCalendarEventTab(id);
+ }
+ for (let id of taskTabPanelIds) {
+ await closeCalendarTaskTab(id);
+ }
+ Services.prefs.setBoolPref("calendar.item.editInTab", false);
+
+ Assert.equal(tabmail.tabInfo.length, 1, "all tabs closed");
+});
diff --git a/comm/calendar/test/browser/invitations/browser.ini b/comm/calendar/test/browser/invitations/browser.ini
new file mode 100644
index 0000000000..7c7aa6af46
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser.ini
@@ -0,0 +1,31 @@
+[default]
+head = ../head.js head.js
+prefs =
+ calendar.item.promptDelete=false
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = data/**
+
+[browser_attachedPublishEvent.js]
+[browser_icsAttachment.js]
+skip-if = os == 'win'
+[browser_identityPrompt.js]
+[browser_imipBar.js]
+[browser_imipBarCancel.js]
+[browser_imipBarEmail.js]
+[browser_imipBarExceptionCancel.js]
+[browser_imipBarExceptionOnly.js]
+[browser_imipBarExceptions.js]
+[browser_imipBarRepeat.js]
+[browser_imipBarRepeatCancel.js]
+[browser_imipBarRepeatUpdates.js]
+[browser_imipBarUpdates.js]
+[browser_invitationDisplayNew.js]
+[browser_unsupportedFreq.js]
diff --git a/comm/calendar/test/browser/invitations/browser_attachedPublishEvent.js b/comm/calendar/test/browser/invitations/browser_attachedPublishEvent.js
new file mode 100644
index 0000000000..af121a8032
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_attachedPublishEvent.js
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test that attached events - NOT invites - works properly.
+ * These are attached VCALENDARs that have METHOD:PUBLISH.
+ */
+"use strict";
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var gCalendar;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ let receiverAcct = MailServices.accounts.createAccount();
+ receiverAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ let receiverIdentity = MailServices.accounts.createIdentity();
+ receiverIdentity.email = "john.doe@example.com";
+ receiverAcct.addIdentity(receiverIdentity);
+ gCalendar = CalendarTestUtils.createCalendar("EventTestCal");
+
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(gCalendar);
+ MailServices.accounts.removeAccount(receiverAcct, true);
+ });
+});
+
+/**
+ * Test that opening a message containing an event with iTIP method "PUBLISH"
+ * shows the correct UI.
+ * The party crashing dialog should not show.
+ */
+add_task(async function test_event_from_eml() {
+ let file = new FileUtils.File(getTestFilePath("data/message-non-invite.eml"));
+
+ let win = await openMessageFromFile(file);
+ let aboutMessage = win.document.getElementById("messageBrowser").contentWindow;
+ let imipBar = aboutMessage.document.getElementById("imip-bar");
+
+ await TestUtils.waitForCondition(() => !imipBar.collapsed);
+ info("Ok, iMIP bar is showing");
+
+ let imipAddButton = aboutMessage.document.getElementById("imipAddButton");
+ Assert.ok(!imipAddButton.hidden, "Add button should show");
+
+ EventUtils.synthesizeMouseAtCenter(imipAddButton, {}, aboutMessage);
+
+ // Make sure the event got added, without showing the party crashing dialog.
+ await TestUtils.waitForCondition(async () => {
+ let event = await gCalendar.getItem("1e5fd4e6-bc52-439c-ac76-40da54f57c77@secure.example.com");
+ return event;
+ });
+
+ await TestUtils.waitForCondition(() => imipAddButton.hidden, "Add button should hide");
+
+ let imipDetailsButton = aboutMessage.document.getElementById("imipDetailsButton");
+ Assert.ok(!imipDetailsButton.hidden, "Details button should show");
+
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/comm/calendar/test/browser/invitations/browser_icsAttachment.js b/comm/calendar/test/browser/invitations/browser_icsAttachment.js
new file mode 100644
index 0000000000..11bde9144d
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_icsAttachment.js
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test TB can be set as default calendar app.
+ */
+
+/**
+ * Set TB as default calendar app.
+ */
+add_setup(function () {
+ let shellSvc = Cc["@mozilla.org/mail/shell-service;1"].getService(Ci.nsIShellService);
+ shellSvc.setDefaultClient(false, shellSvc.CALENDAR);
+ ok(shellSvc.isDefaultClient(false, shellSvc.CALENDAR), "setDefaultClient works");
+});
+
+/**
+ * Test when opening an ics attachment, TB should be shown as an option.
+ */
+add_task(async function test_ics_attachment() {
+ let file = new FileUtils.File(getTestFilePath("data/message-containing-event.eml"));
+ let msgWindow = await openMessageFromFile(file);
+ let aboutMessage = msgWindow.document.getElementById("messageBrowser").contentWindow;
+ let promise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ {
+ async callback(dialogWindow) {
+ ok(true, "unknownContentType dialog opened");
+ let dialogElement = dialogWindow.document.querySelector("dialog");
+ let acceptButton = dialogElement.getButton("accept");
+ return new Promise(resolve => {
+ let observer = new MutationObserver(mutationList => {
+ mutationList.forEach(async mutation => {
+ if (mutation.attributeName == "disabled" && !acceptButton.disabled) {
+ is(acceptButton.disabled, false, "Accept button enabled");
+ if (AppConstants.platform != "macosx") {
+ let bundle = Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+ let name = bundle.GetStringFromName("brandShortName");
+ // macOS requires extra step in Finder to set TB as default calendar app.
+ ok(
+ dialogWindow.document.getElementById("openHandler").label.includes(name),
+ `${name} is the default calendar app`
+ );
+ }
+
+ // Should really click acceptButton and test
+ // calender-ics-file-dialog is opened. But on local, a new TB
+ // instance is started and this test will fail.
+ dialogElement.getButton("cancel").click();
+ resolve();
+ }
+ });
+ });
+ observer.observe(acceptButton, { attributes: true });
+ });
+ },
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("attachmentName"),
+ {},
+ aboutMessage
+ );
+ await promise;
+
+ await BrowserTestUtils.closeWindow(msgWindow);
+});
diff --git a/comm/calendar/test/browser/invitations/browser_identityPrompt.js b/comm/calendar/test/browser/invitations/browser_identityPrompt.js
new file mode 100644
index 0000000000..e2d6fe3115
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_identityPrompt.js
@@ -0,0 +1,144 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the calender-itip-identity dialog.
+ */
+
+"use strict";
+
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let receiverAcct;
+let receiverIdentity;
+let gInbox;
+let calendar;
+
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ MailServices.accounts.removeIncomingServer(receiverAcct.incomingServer, true);
+ MailServices.accounts.removeAccount(receiverAcct);
+});
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ if (MailServices.accounts.accounts.length == 0) {
+ MailServices.accounts.createLocalMailAccount();
+ }
+
+ let rootFolder = MailServices.accounts.localFoldersServer.rootFolder;
+ if (!rootFolder.containsChildNamed("Inbox")) {
+ rootFolder.createSubfolder("Inbox", null);
+ }
+ gInbox = rootFolder.getChildNamed("Inbox");
+
+ receiverAcct = MailServices.accounts.createAccount();
+ receiverAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ receiverIdentity = MailServices.accounts.createIdentity();
+ receiverIdentity.email = "receiver@example.com";
+ receiverAcct.addIdentity(receiverIdentity);
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+
+ let copyListener = new PromiseTestUtils.PromiseCopyListener();
+ MailServices.copy.copyFileMessage(
+ new FileUtils.File(getTestFilePath("data/meet-meeting-invite.eml")),
+ gInbox,
+ null,
+ false,
+ 0,
+ "",
+ copyListener,
+ null
+ );
+ await copyListener.promise;
+});
+
+/**
+ * Tests that the identity prompt shows when accepting an invitation to an
+ * event with an identity no calendar is configured to use.
+ */
+add_task(async function testInvitationIdentityPrompt() {
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.displayFolder(gInbox.URI);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-itip-identity-dialog.xhtml",
+ {
+ async callback(win) {
+ // Select the identity we want to use.
+ let menulist = win.document.getElementById("identity-menu");
+ for (let i = 0; i < menulist.itemCount; i++) {
+ let target = menulist.getItemAtIndex(i);
+ if (target.value == receiverIdentity.fullAddress) {
+ menulist.selectedIndex = i;
+ }
+ }
+
+ win.document.querySelector("dialog").getButton("accept").click();
+ },
+ }
+ );
+
+ // Override this function to intercept the attempt to send the email out.
+ let sendItemsArgs = [];
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => ({
+ scheme: "mailto",
+ type: "email",
+ sendItems(receipientArray, item, sender) {
+ sendItemsArgs = [receipientArray, item, sender];
+ return true;
+ },
+ });
+
+ let aboutMessage = tabmail.currentAboutMessage;
+ let acceptButton = aboutMessage.document.getElementById("imipAcceptButton");
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(acceptButton),
+ "waiting for accept button to become visible"
+ );
+ EventUtils.synthesizeMouseAtCenter(acceptButton, {}, aboutMessage);
+ await dialogPromise;
+
+ let event;
+ await TestUtils.waitForCondition(async () => {
+ event = await calendar.getItem("65m17hsdolmotv3kvmrtg40ont@google.com");
+ return event && sendItemsArgs.length;
+ });
+
+ // Restore this function.
+ cal.itip.getImipTransport = getImipTransport;
+
+ let id = `mailto:${receiverIdentity.email}`;
+ Assert.ok(event, "event was added to the calendar successfully");
+ Assert.ok(event.getAttendeeById(id), "selected identity was added to the attendee list");
+ Assert.equal(
+ event.getProperty("X-MOZ-INVITED-ATTENDEE"),
+ id,
+ "X-MOZ-INVITED-ATTENDEE is set to the selected identity"
+ );
+
+ let [recipientArray, , sender] = sendItemsArgs;
+ Assert.equal(recipientArray.length, 1, "one recipient for the reply");
+ Assert.equal(recipientArray[0].id, "mailto:example@gmail.com", "recipient is event organizer");
+ Assert.equal(sender.id, id, "sender is the identity selected");
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBar.js b/comm/calendar/test/browser/invitations/browser_imipBar.js
new file mode 100644
index 0000000000..c9a21a6d2b
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBar.js
@@ -0,0 +1,199 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for receiving event invitations via the imip-bar.
+ */
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalItipDefaultEmailTransport } = ChromeUtils.import(
+ "resource:///modules/CalItipEmailTransport.jsm"
+);
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests accepting an invitation and sending a response.
+ */
+add_task(async function testAcceptWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ await clickAction(win, "imipAcceptButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "ACCEPTED",
+ },
+ event
+ );
+
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting an invitation and sending a response.
+ */
+add_task(async function testTentativeWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ await clickAction(win, "imipTentativeButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "TENTATIVE",
+ },
+ event
+ );
+
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining an invitation and sending a response.
+ */
+add_task(async function testDeclineWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ await clickAction(win, "imipDeclineButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "DECLINED",
+ },
+ event
+ );
+
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests accepting an invitation without sending a response.
+ */
+add_task(async function testAcceptWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ await clickMenuAction(win, "imipAcceptButton", "imipAcceptButton_AcceptDontSend");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "ACCEPTED",
+ noReply: true,
+ },
+ event
+ );
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting an invitation without sending a response.
+ */
+add_task(async function testTentativeWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ await clickMenuAction(win, "imipTentativeButton", "imipTentativeButton_TentativeDontSend");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "TENTATIVE",
+ noReply: true,
+ },
+ event
+ );
+
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining an invitation without sending a response.
+ */
+add_task(async function testDeclineWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ await clickMenuAction(win, "imipDeclineButton", "imipDeclineButton_DeclineDontSend");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "DECLINED",
+ noReply: true,
+ },
+ event
+ );
+
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarCancel.js b/comm/calendar/test/browser/invitations/browser_imipBarCancel.js
new file mode 100644
index 0000000000..3cde7d4656
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarCancel.js
@@ -0,0 +1,129 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for processing cancellations via the imip-bar.
+ */
+
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests accepting a cancellation to an already accepted event.
+ */
+add_task(async function testCancelAccepted() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipAcceptButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ transport,
+ calendar,
+ event,
+ });
+});
+
+/**
+ * Tests accepting a cancellation to tentatively accepted event.
+ */
+add_task(async function testCancelTentative() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipTentativeButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ transport,
+ calendar,
+ event,
+ });
+});
+
+/**
+ * Tests accepting a cancellation to an already declined event.
+ */
+add_task(async function testCancelDeclined() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipDeclineButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ transport,
+ calendar,
+ event,
+ });
+});
+
+/**
+ * Tests the handling of a cancellation when the event was not processed
+ * previously.
+ */
+add_task(async function testUnprocessedCancel() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/cancel-single-event.eml"));
+ let win = await openImipMessage(invite);
+
+ // There should be no buttons present because there is no action to take.
+ // Note: the imip-bar message "This message contains an event that has already been processed" is
+ // misleading.
+ for (let button of [...win.document.querySelectorAll("#imip-view-toolbar > toolbarbutton")]) {
+ Assert.ok(button.hidden, `${button.id} is hidden`);
+ }
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarEmail.js b/comm/calendar/test/browser/invitations/browser_imipBarEmail.js
new file mode 100644
index 0000000000..a3816b65dd
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarEmail.js
@@ -0,0 +1,168 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test that the IMIP bar behaves properly for eml files with invites.
+ */
+
+/* eslint-disable @microsoft/sdl/no-insecure-url */
+
+function getFileFromChromeURL(leafName) {
+ let ChromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIChromeRegistry);
+
+ let url = Services.io.newURI(getRootDirectory(gTestPath) + leafName);
+ info(url.spec);
+ let fileURL = ChromeRegistry.convertChromeURL(url).QueryInterface(Ci.nsIFileURL);
+ return fileURL.file;
+}
+
+/**
+ * Test that when opening a message containing a Teams meeting invite
+ * works as it should.
+ */
+add_task(async function test_event_from_eml() {
+ let file = getFileFromChromeURL("data/teams-meeting-invite.eml");
+
+ let msgWindow = await openMessageFromFile(file);
+ let aboutMessage = msgWindow.document.getElementById("messageBrowser").contentWindow;
+
+ await TestUtils.waitForCondition(
+ () => !aboutMessage.document.getElementById("imip-bar").collapsed
+ );
+ info("Ok, iMIP bar is showing");
+
+ // The contentDocument has both the imipHTMLDetails HTML part generated by us,
+ // and the regular HTML part generated by the sender (the server).
+ let links = [
+ ...msgWindow.content.document.getElementById("imipHTMLDetails").querySelectorAll("a"),
+ ];
+
+ Assert.equal(links.length, 3, "The 3 links should show");
+
+ // Check the links and their text
+ Assert.equal(
+ links[0].href,
+ "https://teams.microsoft.com/l/meetup-join/19%3ameeting_MGU5NmI2ZGYtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy%40thread.v2/0?context=%7b%22Tid%22%3a%222fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c%22%2c%22Oid%22%3a%2214464d09-ceb8-458c-a61c-717f1e5c66c5%22%7d",
+ "link0 href"
+ );
+ Assert.equal(
+ links[0].textContent,
+ "<https://teams.microsoft.com/l/meetup-join/19%3ameeting_MGU5NmI2ZGYtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy%40thread.v2/0?context=%7b%22Tid%22%3a%222fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c%22%2c%22Oid%22%3a%2214464d09-ceb8-458c-a61c-717f1e5c66c5%22%7d>",
+ "link0 textContent"
+ );
+
+ Assert.equal(links[1].href, "https://aka.ms/JoinTeamsMeeting", "link1 href");
+ Assert.equal(links[1].textContent, "<https://aka.ms/JoinTeamsMeeting>", "link1 textContent");
+
+ Assert.equal(
+ links[2].href,
+ "https://teams.microsoft.com/meetingOptions/?organizerId=14464d09-ceb8-458c-a61c-717f1e5c66c5&tenantId=2fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c&threadId=19_meeting_MGU5NmI2ZGYtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy@thread.v2&messageId=0&language=fi-FI",
+ "link2 href"
+ );
+ Assert.equal(
+ links[2].textContent,
+ "<https://teams.microsoft.com/meetingOptions/?organizerId=14464d09-ceb8-458c-a61c-717f1e5c66c5&tenantId=2fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c&threadId=19_meeting_MGU5NmI2ZGYtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy@thread.v2&messageId=0&language=fi-FI>",
+ "link2 textContent"
+ );
+
+ await BrowserTestUtils.closeWindow(msgWindow);
+
+ Assert.ok(true, "test_event_from_eml test ran to completion");
+});
+
+/**
+ * Test that when opening a message containing a Meet meeting invite
+ * works as it should.
+ */
+add_task(async function test_event_from_eml() {
+ let file = getFileFromChromeURL("data/meet-meeting-invite.eml");
+
+ let msgWindow = await openMessageFromFile(file);
+ let aboutMessage = msgWindow.document.getElementById("messageBrowser").contentWindow;
+
+ await TestUtils.waitForCondition(
+ () => !aboutMessage.document.getElementById("imip-bar").collapsed
+ );
+ info("Ok, iMIP bar is showing");
+
+ // The contentDocument has both the imipHTMLDetails HTML part generated by us,
+ // and the regular HTML part generated by the sender (the server).
+ let links = [
+ ...msgWindow.content.document.getElementById("imipHTMLDetails").querySelectorAll("a"),
+ ];
+
+ Assert.equal(links.length, 4, "The 4 links should show");
+
+ // Check the links and their text
+ Assert.equal(links[0].href, "mailto:foo@example.com", "link0 href");
+ Assert.equal(links[0].textContent, "<foo@example.com>", "link0 textContent");
+
+ Assert.equal(links[1].href, "http://example.com/?foo=bar", "link1 href");
+ Assert.equal(links[1].textContent, "http://example.com?foo=bar", "link1 textContent");
+
+ Assert.equal(links[2].href, "https://meet.google.com/pyb-ndcu-hhc", "link1 href");
+ Assert.equal(links[2].textContent, "https://meet.google.com/pyb-ndcu-hhc", "link1 textContent");
+
+ Assert.equal(
+ links[3].href,
+ "https://calendar.google.com/calendar/event?action=VIEW&eid=NjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQGh1dC5maQ&tok=MjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmNjYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&ctz=Europe%2FHelsinki&hl=sv&es=1",
+ "link2 href"
+ );
+ Assert.equal(
+ links[3].textContent,
+ "https://calendar.google.com/calendar/event?action=VIEW&eid=NjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQGh1dC5maQ&tok=MjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmNjYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&ctz=Europe%2FHelsinki&hl=sv&es=1",
+ "link2 textContent"
+ );
+
+ await BrowserTestUtils.closeWindow(msgWindow);
+
+ Assert.ok(true, "test_event_from_eml test ran to completion");
+});
+
+/**
+ * Test that when opening a message containing an outlook invite with "empty"
+ * content works as it should.
+ */
+add_task(async function test_outlook_event_from_eml() {
+ let file = getFileFromChromeURL("data/outlook-test-invite.eml");
+
+ let msgWindow = await openMessageFromFile(file);
+ let aboutMessage = msgWindow.document.getElementById("messageBrowser").contentWindow;
+
+ await TestUtils.waitForCondition(
+ () => !aboutMessage.document.getElementById("imip-bar").collapsed
+ );
+ info("Ok, iMIP bar is showing");
+
+ let details = msgWindow.content.document.getElementById("imipHTMLDetails");
+
+ Assert.equal(
+ details.getAttribute("open"),
+ "open",
+ "Details should be expanded when the message doesn't include good details"
+ );
+
+ await BrowserTestUtils.closeWindow(msgWindow);
+
+ Assert.ok(true, "test_outlook_event_from_eml test ran to completion");
+});
+
+/**
+ * Test that when opening a message containing an event, the IMIP bar shows.
+ */
+add_task(async function test_event_from_eml() {
+ let file = getFileFromChromeURL("data/message-containing-event.eml");
+
+ let msgWindow = await openMessageFromFile(file);
+ let aboutMessage = msgWindow.document.getElementById("messageBrowser").contentWindow;
+
+ await TestUtils.waitForCondition(
+ () => !aboutMessage.document.getElementById("imip-bar").collapsed
+ );
+ info("Ok, iMIP bar is showing");
+
+ await BrowserTestUtils.closeWindow(msgWindow);
+
+ Assert.ok(true, "test_event_from_eml test ran to completion");
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarExceptionCancel.js b/comm/calendar/test/browser/invitations/browser_imipBarExceptionCancel.js
new file mode 100644
index 0000000000..7800e742ca
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarExceptionCancel.js
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for processing cancellations to recurring event exceptions.
+ */
+
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ requestLongerTimeout(3);
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests cancelling an exception works.
+ */
+add_task(async function testCancelException() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ await doCancelExceptionTest({
+ calendar,
+ transport,
+ identity,
+ partStat,
+ recurrenceId: "20220317T110000Z",
+ isRecurring: true,
+ });
+ }
+});
+
+/**
+ * Tests cancelling an event with only an exception processed works.
+ */
+add_task(async function testCancelExceptionOnly() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ let win = await openImipMessage(
+ new FileUtils.File(getTestFilePath("data/exception-major.eml"))
+ );
+ await clickAction(win, actionIds.single.button[partStat]);
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 5, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ calendar,
+ event,
+ transport,
+ identity,
+ });
+ }
+});
+
+/**
+ * Tests processing a cancellation for a recurring event works when only an
+ * exception was processed previously.
+ */
+add_task(async function testCancelSeriesWithExceptionOnly() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ let win = await openImipMessage(
+ new FileUtils.File(getTestFilePath("data/exception-major.eml"))
+ );
+ await clickMenuAction(
+ win,
+ actionIds.single.button[partStat],
+ actionIds.single.noReply[partStat]
+ );
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 5, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+
+ let cancel = new FileUtils.File(getTestFilePath("data/cancel-repeat-event.eml"));
+ let cancelWin = await openImipMessage(cancel);
+ let aboutMessage = cancelWin.document.getElementById("messageBrowser").contentWindow;
+
+ let deleteButton = aboutMessage.document.getElementById("imipDeleteButton");
+ Assert.ok(!deleteButton.hidden, `#${deleteButton.id} button shown`);
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, aboutMessage);
+ await BrowserTestUtils.closeWindow(cancelWin);
+ await CalendarTestUtils.monthView.waitForNoItemAt(window, 3, 5, 1);
+ Assert.ok(!(await calendar.getItem(event.id)), "event was deleted");
+
+ Assert.equal(
+ transport.sentItems.length,
+ 0,
+ "itip subsystem did not attempt to send a response"
+ );
+ Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem");
+ }
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarExceptionOnly.js b/comm/calendar/test/browser/invitations/browser_imipBarExceptionOnly.js
new file mode 100644
index 0000000000..88ad0b3c41
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarExceptionOnly.js
@@ -0,0 +1,262 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for receiving an invitation exception but the original event was not
+ * processed first.
+ */
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ requestLongerTimeout(5);
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests accepting a minor exception and sending a response.
+ */
+add_task(async function testMinorAcceptWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml")));
+ await clickAction(win, "imipAcceptButton");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "ACCEPTED",
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting a minor exception and sending a response.
+ */
+add_task(async function testMinorTentativeWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml")));
+ await clickAction(win, "imipTentativeButton");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "TENTATIVE",
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining a minor exception and sending a response.
+ */
+add_task(async function testMinorDeclineWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml")));
+ await clickAction(win, "imipDeclineButton");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "DECLINED",
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests accepting a minor exception without sending a response.
+ */
+add_task(async function testMinorAcceptWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml")));
+ await clickMenuAction(win, "imipAcceptButton", "imipAcceptButton_AcceptDontSend");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "ACCEPTED",
+ noReply: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting a minor exception without sending a response.
+ */
+add_task(async function testMinorTentativeWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml")));
+ await clickMenuAction(win, "imipTentativeButton", "imipTentativeButton_TentativeDontSend");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "TENTATIVE",
+ noReply: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining a minor exception without sending a response.
+ */
+add_task(async function testMinorDeclineWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml")));
+ await clickMenuAction(win, "imipDeclineButton", "imipDeclineButton_DeclineDontSend");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "DECLINED",
+ noReply: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests accepting a major exception and sending a response.
+ */
+add_task(async function testMajorAcceptWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml")));
+ await clickAction(win, "imipAcceptButton");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "ACCEPTED",
+ isMajor: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting a major exception and sending a response.
+ */
+add_task(async function testMajorTentativeWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml")));
+ await clickAction(win, "imipTentativeButton");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "TENTATIVE",
+ isMajor: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining a major exception and sending a response.
+ */
+add_task(async function testMajorDeclineWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml")));
+ await clickAction(win, "imipDeclineButton");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "DECLINED",
+ isMajor: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests accepting a major exception without sending a response.
+ */
+add_task(async function testMajorAcceptWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml")));
+ await clickMenuAction(win, "imipAcceptButton", "imipAcceptButton_AcceptDontSend");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "ACCEPTED",
+ noReply: true,
+ isMajor: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting a major exception without sending a response.
+ */
+add_task(async function testMajorTentativeWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml")));
+ await clickMenuAction(win, "imipTentativeButton", "imipTentativeButton_TentativeDontSend");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "TENTATIVE",
+ noReply: true,
+ isMajor: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining a major exception without sending a response.
+ */
+add_task(async function testMajorDeclineWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml")));
+ await clickMenuAction(win, "imipDeclineButton", "imipDeclineButton_DeclineDontSend");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "DECLINED",
+ noReply: true,
+ isMajor: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarExceptions.js b/comm/calendar/test/browser/invitations/browser_imipBarExceptions.js
new file mode 100644
index 0000000000..2cdf18ed59
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarExceptions.js
@@ -0,0 +1,288 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for handling exceptions to recurring event invitations via the imip-bar.
+ */
+
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ requestLongerTimeout(5);
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests a minor update exception to an already accepted recurring event.
+ */
+add_task(async function testMinorUpdateExceptionToAccepted() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipAcceptRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorExceptionTest({
+ transport,
+ calendar,
+ partStat: "ACCEPTED",
+ });
+});
+
+/**
+ * Tests a minor update exception to an already tentatively accepted recurring
+ * event.
+ */
+add_task(async function testMinorUpdateExceptionToTentative() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipTentativeRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorExceptionTest({
+ transport,
+ calendar,
+ partStat: "TENTATIVE",
+ });
+});
+
+/**
+ * Tests a minor update exception to an already declined recurring declined
+ * event.
+ */
+add_task(async function testMinorUpdateExceptionToDeclined() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipDeclineRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorExceptionTest({
+ transport,
+ calendar,
+ partStat: "DECLINED",
+ });
+});
+
+/**
+ * Tests a major update exception to an already accepted event.
+ */
+add_task(async function testMajorExceptionToAcceptedWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipAcceptRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorExceptionTest({
+ transport,
+ identity,
+ calendar,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update exception to an already tentatively accepted event.
+ */
+add_task(async function testMajorExceptionToTentativeWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipTentativeRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorExceptionTest({
+ transport,
+ identity,
+ calendar,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update exception to an already declined event.
+ */
+add_task(async function testMajorExceptionToDeclinedWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipDeclineRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorExceptionTest({
+ transport,
+ identity,
+ calendar,
+ isRecurring: true,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update exception to an already accepted event without sending
+ * a reply.
+ */
+add_task(async function testMajorExecptionToAcceptedWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickMenuAction(
+ win,
+ "imipAcceptRecurrencesButton",
+ "imipAcceptRecurrencesButton_AcceptDontSend"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorExceptionTest({
+ transport,
+ calendar,
+ isRecurring: true,
+ partStat,
+ noReply: true,
+ });
+ }
+});
+
+/**
+ * Tests a major update exception to an already tentatively accepted event
+ * without sending a reply.
+ */
+add_task(async function testMajorUpdateToTentativeWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickMenuAction(
+ win,
+ "imipTentativeRecurrencesButton",
+ "imipTentativeRecurrencesButton_TentativeDontSend"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorExceptionTest({
+ transport,
+ calendar,
+ isRecurring: true,
+ partStat,
+ noReply: true,
+ });
+ }
+});
+
+/**
+ * Tests a major update exception to a declined event without sending a reply.
+ */
+add_task(async function testMajorUpdateToDeclinedWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickMenuAction(
+ win,
+ "imipDeclineRecurrencesButton",
+ "imipDeclineRecurrencesButton_DeclineDontSend"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorExceptionTest({
+ transport,
+ calendar,
+ isRecurring: true,
+ partStat,
+ noReply: true,
+ });
+ }
+});
+
+/**
+ * Tests a major update exception to an event where the participation status
+ * is still "NEEDS-ACTION". Here we want to ensure action is only taken on the
+ * target exception date and not the other dates.
+ */
+add_task(async function testMajorUpdateToNeedsAction() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+
+ // Extract the event from the .eml file and manually add it to the calendar.
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let srcText = await IOUtils.readUTF8(invite.path);
+ let ics = srcText.match(
+ /--00000000000080f3da05db4aef59[\S\s]+--00000000000080f3da05db4aef59/g
+ )[0];
+ ics = ics.split("--00000000000080f3da05db4aef59").join("");
+ ics = ics.replaceAll(/Content-(Type|Transfer-Encoding)?: .*/g, "");
+
+ let event = new CalEvent(ics);
+
+ // This will not be set because we manually added the event.
+ event.setProperty("x-moz-received-dtstamp", "20220316T191602Z");
+
+ await calendar.addItem(event);
+ await CalendarTestUtils.monthView.waitForItemAt(window, 3, 5, 1).item;
+ await doMajorExceptionTest({
+ transport,
+ identity,
+ calendar,
+ isRecurring: true,
+ partStat,
+ });
+ }
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarRepeat.js b/comm/calendar/test/browser/invitations/browser_imipBarRepeat.js
new file mode 100644
index 0000000000..c14ff2c0a5
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarRepeat.js
@@ -0,0 +1,218 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for receiving recurring event invitations via the imip-bar.
+ */
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests accepting an invitation to a recurring event and sending a response.
+ */
+add_task(async function testAcceptRecurringWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipAcceptRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ isRecurring: true,
+ partStat: "ACCEPTED",
+ },
+ event
+ );
+
+ await calendar.deleteItem(event.parentItem);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting an invitation to a recurring event and sending a
+ * response.
+ */
+add_task(async function testTentativeRecurringWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipTentativeRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ isRecurring: true,
+ partStat: "TENTATIVE",
+ },
+ event
+ );
+
+ await calendar.deleteItem(event.parentItem);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining an invitation to a recurring event and sending a response.
+ */
+add_task(async function testDeclineRecurringWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipDeclineRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ isRecurring: true,
+ partStat: "DECLINED",
+ },
+ event
+ );
+
+ await calendar.deleteItem(event.parentItem);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests accepting an invitation to a recurring event without sending a response.
+ */
+add_task(async function testAcceptRecurringWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickMenuAction(
+ win,
+ "imipAcceptRecurrencesButton",
+ "imipAcceptRecurrencesButton_AcceptDontSend"
+ );
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ isRecurring: true,
+ partStat: "ACCEPTED",
+ noReply: true,
+ },
+ event
+ );
+
+ await calendar.deleteItem(event.parentItem);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting an invitation to a recurring event without sending
+ * a response.
+ */
+add_task(async function testTentativeRecurringWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickMenuAction(
+ win,
+ "imipTentativeRecurrencesButton",
+ "imipTentativeRecurrencesButton_TentativeDontSend"
+ );
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ isRecurring: true,
+ partStat: "TENTATIVE",
+ noReply: true,
+ },
+ event
+ );
+
+ await calendar.deleteItem(event.parentItem);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining an invitation to a recurring event without sending a response.
+ */
+add_task(async function testDeclineRecurrencesWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickMenuAction(
+ win,
+ "imipDeclineRecurrencesButton",
+ "imipDeclineRecurrencesButton_DeclineDontSend"
+ );
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ isRecurring: true,
+ partStat: "DECLINED",
+ noReply: true,
+ },
+ event
+ );
+
+ await calendar.deleteItem(event.parentItem);
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarRepeatCancel.js b/comm/calendar/test/browser/invitations/browser_imipBarRepeatCancel.js
new file mode 100644
index 0000000000..1ab50cc739
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarRepeatCancel.js
@@ -0,0 +1,186 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for processing cancellations to recurring invitations via the imip-bar.
+ */
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ requestLongerTimeout(5);
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests accepting a cancellation to an already accepted recurring event.
+ */
+add_task(async function testCancelAcceptedRecurring() {
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipAcceptRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ calendar,
+ event,
+ transport,
+ isRecurring: true,
+ });
+});
+
+/**
+ * Tests accepting a cancellation to an already tentatively accepted event.
+ */
+add_task(async function testCancelTentativeRecurring() {
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipTentativeRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ calendar,
+ event,
+ transport,
+ identity,
+ isRecurring: true,
+ });
+});
+
+/**
+ * Tests accepting a cancellation to an already declined recurring event.
+ */
+add_task(async function testCancelDeclinedRecurring() {
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipDeclineRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ calendar,
+ event,
+ transport,
+ identity,
+ isRecurring: true,
+ });
+});
+
+/**
+ * Tests accepting a cancellation to a single occurrence of an already accepted
+ * recurring event.
+ */
+add_task(async function testCancelAcceptedOccurrence() {
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipAcceptRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ calendar,
+ event,
+ transport,
+ isRecurring: true,
+ recurrenceId: "20220317T110000Z",
+ });
+ await calendar.deleteItem(event.parentItem);
+});
+
+/**
+ * Tests accepting a cancellation to a single occurrence of an already tentatively
+ * accepted event.
+ */
+add_task(async function testCancelTentativeOccurrence() {
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipTentativeRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ calendar,
+ event,
+ transport,
+ identity,
+ isRecurring: true,
+ recurrenceId: "20220317T110000Z",
+ });
+ await calendar.deleteItem(event.parentItem);
+});
+
+/**
+ * Tests accepting a cancellation to a single occurrence of an already declined
+ * recurring event.
+ */
+add_task(async function testCancelDeclinedOccurrence() {
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipDeclineRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ calendar,
+ event,
+ transport,
+ identity,
+ isRecurring: true,
+ recurrenceId: "20220317T110000Z",
+ });
+ await calendar.deleteItem(event.parentItem);
+});
+
+/**
+ * Tests the handling of a cancellation when the event was not processed
+ * previously.
+ */
+add_task(async function testUnprocessedCancel() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/cancel-repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ for (let button of [...win.document.querySelectorAll("#imip-view-toolbar > toolbarbutton")]) {
+ Assert.ok(button.hidden, `${button.id} is hidden`);
+ }
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarRepeatUpdates.js b/comm/calendar/test/browser/invitations/browser_imipBarRepeatUpdates.js
new file mode 100644
index 0000000000..7f0d16f627
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarRepeatUpdates.js
@@ -0,0 +1,247 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for receiving minor and major updates to recurring event invitations
+ * via the imip-bar.
+ */
+
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ requestLongerTimeout(5);
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests a minor update to an already accepted event.
+ */
+add_task(async function testMinorUpdateToAccepted() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipAcceptRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorUpdateTest({
+ transport,
+ calendar,
+ isRecurring: true,
+ partStat: "ACCEPTED",
+ });
+});
+
+/**
+ * Tests a minor update to an already tentatively accepted event.
+ */
+add_task(async function testMinorUpdateToTentative() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipTentativeRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorUpdateTest({
+ transport,
+ calendar,
+ isRecurring: true,
+ partStat: "TENTATIVE",
+ });
+});
+
+/**
+ * Tests a minor update to an already declined event.
+ */
+add_task(async function testMinorUpdateToDeclined() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipDeclineRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorUpdateTest({ transport, calendar, isRecurring: true, invite, partStat: "DECLINED" });
+});
+
+/**
+ * Tests a major update to an already accepted event.
+ */
+add_task(async function testMajorUpdateToAcceptedWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipAcceptRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ identity,
+ calendar,
+ isRecurring: true,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already tentatively accepted event.
+ */
+add_task(async function testMajorUpdateToTentativeWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipTentativeRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ identity,
+ calendar,
+ isRecurring: true,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already declined event.
+ */
+add_task(async function testMajorUpdateToDeclinedWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipDeclineRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ identity,
+ calendar,
+ isRecurring: true,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already accepted event without replying to the
+ * update.
+ */
+add_task(async function testMajorUpdateToAcceptedWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickMenuAction(
+ win,
+ "imipAcceptRecurrencesButton",
+ "imipAcceptRecurrencesButton_AcceptDontSend"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ calendar,
+ isRecurring: true,
+ partStat,
+ noReply: true,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already tentatively accepted event without replying
+ * to the update.
+ */
+add_task(async function testMajorUpdateToTentativeWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickMenuAction(
+ win,
+ "imipTentativeRecurrencesButton",
+ "imipTentativeRecurrencesButton_TentativeDontSend"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ calendar,
+ isRecurring: true,
+ partStat,
+ noReply: true,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already declined event.
+ */
+add_task(async function testMajorUpdateToDeclinedWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickMenuAction(
+ win,
+ "imipDeclineRecurrencesButton",
+ "imipDeclineRecurrencesButton_DeclineDontSend"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ calendar,
+ isRecurring: true,
+ partStat,
+ noReply: true,
+ });
+ }
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarUpdates.js b/comm/calendar/test/browser/invitations/browser_imipBarUpdates.js
new file mode 100644
index 0000000000..d0f5018e89
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarUpdates.js
@@ -0,0 +1,223 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for receiving minor and major updates to invitations via the imip-bar.
+ */
+
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ requestLongerTimeout(5);
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests a minor update to an already accepted event.
+ */
+add_task(async function testMinorUpdateToAccepted() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipAcceptButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorUpdateTest({
+ transport,
+ calendar,
+ partStat: "ACCEPTED",
+ });
+});
+
+/**
+ * Tests a minor update to an already tentatively accepted event.
+ */
+add_task(async function testMinorUpdateToTentative() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipTentativeButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorUpdateTest({ transport, calendar, invite, partStat: "TENTATIVE" });
+});
+
+/**
+ * Tests a minor update to an already declined event.
+ */
+add_task(async function testMinorUpdateToDeclined() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipDeclineButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorUpdateTest({ transport, calendar, invite, partStat: "DECLINED" });
+});
+
+/**
+ * Tests a major update to an already accepted event.
+ */
+add_task(async function testMajorUpdateToAcceptedWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipAcceptButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ identity,
+ calendar,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already tentatively accepted event.
+ */
+add_task(async function testMajorUpdateToTentativeWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipTentativeButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ identity,
+ calendar,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already declined event.
+ */
+add_task(async function testMajorUpdateToDeclinedWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipDeclineButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ identity,
+ calendar,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already accepted event without replying to the
+ * update.
+ */
+add_task(async function testMajorUpdateToAcceptedWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipAcceptButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ calendar,
+ partStat,
+ noReply: true,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already tentatively accepted event without replying
+ * to the update.
+ */
+add_task(async function testMajorUpdateToTentativeWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipTentativeButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ calendar,
+ partStat,
+ noReply: true,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already declined event.
+ */
+add_task(async function testMajorUpdateToDeclinedWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipDeclineButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ calendar,
+ partStat,
+ noReply: true,
+ });
+ }
+});
diff --git a/comm/calendar/test/browser/invitations/browser_invitationDisplayNew.js b/comm/calendar/test/browser/invitations/browser_invitationDisplayNew.js
new file mode 100644
index 0000000000..a7b3f833de
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_invitationDisplayNew.js
@@ -0,0 +1,257 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the invitation panel display with new events.
+ */
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalItipDefaultEmailTransport } = ChromeUtils.import(
+ "resource:///modules/CalItipEmailTransport.jsm"
+);
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ Services.prefs.setBoolPref("calendar.itip.newInvitationDisplay", true);
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ Services.prefs.setBoolPref("calendar.itip.newInvitationDisplay", false);
+ });
+});
+
+/**
+ * Tests the invitation panel shows the correct data when loaded with a new
+ * invitation.
+ */
+add_task(async function testShowPanelData() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ let panel = win.document
+ .getElementById("messageBrowser")
+ .contentDocument.querySelector("calendar-invitation-panel");
+
+ if (panel.ownerDocument.hasPendingL10nMutations) {
+ await BrowserTestUtils.waitForEvent(panel.ownerDocument, "L10nMutationsFinished");
+ }
+
+ let notification = panel.shadowRoot.querySelector("notification-message");
+ compareShownPanelValues(notification.shadowRoot, {
+ ".notification-message": "You have been invited to this event.",
+ ".notification-button-container > button": "More",
+ });
+
+ compareShownPanelValues(panel.shadowRoot, {
+ "#title": "Single Event",
+ "#location": "Somewhere",
+ "#partStatTotal": "3 participants",
+ '[data-l10n-id="calendar-invitation-panel-partstat-accepted"]': "1 yes",
+ '[data-l10n-id="calendar-invitation-panel-partstat-needs-action"]': "2 pending",
+ "#attendees li:nth-of-type(1)": "Sender <sender@example.com>",
+ "#attendees li:nth-of-type(2)": "Receiver <receiver@example.com>",
+ "#attendees li:nth-of-type(3)": "Other <other@example.com>",
+ "#description": "An event invitation.",
+ });
+
+ Assert.ok(!panel.shadowRoot.querySelector("#actionButtons").hidden, "action buttons shown");
+ for (let indicator of [
+ ...panel.shadowRoot.querySelectorAll("calendar-invitation-change-indicator"),
+ ]) {
+ Assert.ok(indicator.hidden, `${indicator.id} is hidden`);
+ }
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests accepting an invitation and sending a response.
+ */
+add_task(async function testAcceptWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ let panel = win.document
+ .getElementById("messageBrowser")
+ .contentDocument.querySelector("calendar-invitation-panel");
+
+ await clickPanelAction(panel, "acceptButton");
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "ACCEPTED",
+ },
+ event
+ );
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting an invitation and sending a response.
+ */
+add_task(async function testTentativeWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ let panel = win.document
+ .getElementById("messageBrowser")
+ .contentDocument.querySelector("calendar-invitation-panel");
+
+ await clickPanelAction(panel, "tentativeButton");
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "TENTATIVE",
+ },
+ event
+ );
+
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining an invitation and sending a response.
+ */
+add_task(async function testDeclineWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ let panel = win.document
+ .getElementById("messageBrowser")
+ .contentDocument.querySelector("calendar-invitation-panel");
+
+ await clickPanelAction(panel, "declineButton");
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "DECLINED",
+ },
+ event
+ );
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests accepting an invitation without sending a response.
+ */
+add_task(async function testAcceptWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ let panel = win.document
+ .getElementById("messageBrowser")
+ .contentDocument.querySelector("calendar-invitation-panel");
+
+ await clickPanelAction(panel, "acceptButton", false);
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "ACCEPTED",
+ noSend: true,
+ },
+ event
+ );
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting an invitation without sending a response.
+ */
+add_task(async function testTentativeWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ let panel = win.document
+ .getElementById("messageBrowser")
+ .contentDocument.querySelector("calendar-invitation-panel");
+
+ await clickPanelAction(panel, "tentativeButton", false);
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "TENTATIVE",
+ noSend: true,
+ },
+ event
+ );
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining an invitation without sending a response.
+ */
+add_task(async function testDeclineWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ let panel = win.document
+ .getElementById("messageBrowser")
+ .contentDocument.querySelector("calendar-invitation-panel");
+
+ await clickPanelAction(panel, "declineButton", false);
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "DECLINED",
+ noSend: true,
+ },
+ event
+ );
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/comm/calendar/test/browser/invitations/browser_unsupportedFreq.js b/comm/calendar/test/browser/invitations/browser_unsupportedFreq.js
new file mode 100644
index 0000000000..2d05ed66dc
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_unsupportedFreq.js
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for ensuring the application does not hang after processing an
+ * unsupported FREQ value.
+ */
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let calendar;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Runs the test using the provided FREQ value.
+ *
+ * @param {string} freq Either "SECONDLY" or "MINUTELY"
+ */
+async function doFreqTest(freq) {
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let srcText = await IOUtils.readUTF8(invite.path);
+ let tmpFile = FileTestUtils.getTempFile(`${freq}.eml`);
+
+ srcText = srcText.replace(/RRULE:.*/g, `RRULE:FREQ=${freq}`);
+ srcText = srcText.replace(/UID:.*/g, `UID:${freq}`);
+ await IOUtils.writeUTF8(tmpFile.path, srcText);
+
+ let win = await openImipMessage(tmpFile);
+ await clickMenuAction(
+ win,
+ "imipAcceptRecurrencesButton",
+ "imipAcceptRecurrencesButton_AcceptDontSend"
+ );
+
+ // Give the view time to refresh and create any occurrences.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 5000));
+ await BrowserTestUtils.closeWindow(win);
+
+ let dayBoxItems = document.querySelectorAll("calendar-month-day-box-item");
+ Assert.equal(dayBoxItems.length, 1, "only one occurrence displayed");
+
+ let [dayBox] = dayBoxItems;
+ let { item } = dayBox;
+ Assert.equal(item.title, "Repeat Event");
+ Assert.equal(item.startDate.icalString, "20220316T110000Z");
+
+ let summaryDialog = await CalendarTestUtils.viewItem(window, dayBox);
+ Assert.equal(
+ summaryDialog.document.querySelector(".repeat-details").textContent,
+ "Repeat details unknown",
+ "repeat details not shown"
+ );
+
+ await BrowserTestUtils.closeWindow(summaryDialog);
+ await calendar.deleteItem(item.parentItem);
+ await TestUtils.waitForCondition(
+ () => document.querySelectorAll("calendar-month-day-box-item").length == 0
+ );
+}
+
+/**
+ * Tests accepting an invitation using the FREQ=SECONDLY value does not render
+ * the application unusable.
+ */
+add_task(async function testSecondly() {
+ return doFreqTest("SECONDLY");
+});
+
+/**
+ * Tests accepting an invitation using the FREQ=MINUTELY value does not render
+ * the application unusable.
+ */
+add_task(async function testMinutely() {
+ return doFreqTest("MINUTELY");
+});
diff --git a/comm/calendar/test/browser/invitations/data/cancel-repeat-event.eml b/comm/calendar/test/browser/invitations/data/cancel-repeat-event.eml
new file mode 100644
index 0000000000..03f298525b
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/cancel-repeat-event.eml
@@ -0,0 +1,49 @@
+MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Invitation: Repeat Event @ Daily from 2pm to 3pm 3 times (AST) (receiver@example.com)
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=CANCEL
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:CANCEL
+BEGIN:VEVENT
+DTSTART:20220316T110000Z
+DTEND:20220316T113000Z
+RRULE:FREQ=DAILY;WKST=SU;COUNT=3;INTERVAL=1
+DTSTAMP:20220316T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Somewhere
+SEQUENCE:1
+STATUS:CANCELLED
+SUMMARY:Repeat Event
+DESCRIPTION:An event invitation.
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/cancel-single-event.eml b/comm/calendar/test/browser/invitations/data/cancel-single-event.eml
new file mode 100644
index 0000000000..afb4edb99d
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/cancel-single-event.eml
@@ -0,0 +1,78 @@
+MIME-Version: 1.0
+Content-Transfer-Encoding: binary
+Content-Type: multipart/mixed; boundary="_----------=_1647458162153312762582"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+To: receiver@example.com
+Subject: Cancellation: Single Event @ Wed, Mar 16 2022 11:00 AST
+From: Sender <sender@example.com>
+
+This is a multi-part message in MIME format.
+
+--_----------=_1647458162153312762582
+MIME-Version: 1.0
+Content-Transfer-Encoding: binary
+Content-Type: multipart/alternative; boundary="_----------=_1647458162153312762583"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+This is a multi-part message in MIME format.
+
+--_----------=_1647458162153312762583
+MIME-Version: 1.0
+Content-Disposition: inline
+Content-Length: 227
+Content-Transfer-Encoding: binary
+Content-Type: text/plain; charset="utf-8"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+Single Event
+
+When:
+ Wed, Mar 16 2022
+ 11:00 - 12:00 AST
+Where:
+ Somewhere
+
+--_----------=_1647458162153312762583
+MIME-Version: 1.0
+Content-Disposition: inline
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/calendar; charset="utf-8"; method=CANCEL
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+BEGIN:VCALENDAR
+VERSION:2.0
+METHOD:CANCEL
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:02e79b96
+SEQUENCE:1
+DTSTAMP:20220317T191602Z
+CREATED:20220316T191532Z
+DTSTART:20220316T110000Z
+DTEND:20220316T113000Z
+DURATION:PT1H
+PRIORITY:0
+SUMMARY:Single Event
+DESCRIPTION:An event invitation.
+LOCATION:Somewhere
+STATUS:CANCELLED
+TRANSP:OPAQUE
+CLASS:PUBLIC
+ORGANIZER;CN=3DSender;
+ EMAIL=3Dsender@example.com:mailto:sender@example.com
+ATTENDEE;CN=3DSender;
+ EMAIL=3Dsender@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DACCEPTED:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=3Dreceiver@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DNEEDS-ACTION:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DNEEDS-ACTION:mailto:other@example.com
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--_----------=_1647458162153312762583--
diff --git a/comm/calendar/test/browser/invitations/data/exception-major.eml b/comm/calendar/test/browser/invitations/data/exception-major.eml
new file mode 100644
index 0000000000..07f48e64bd
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/exception-major.eml
@@ -0,0 +1,49 @@
+MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Exception Major
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20220317T050000Z
+DTEND:20220317T053000Z
+RECURRENCE-ID:20220317T110000Z
+DTSTAMP:20220316T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Somewhere
+SEQUENCE:2
+STATUS:CONFIRMED
+SUMMARY:Repeat Event
+DESCRIPTION:An event invitation.
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/exception-minor.eml b/comm/calendar/test/browser/invitations/data/exception-minor.eml
new file mode 100644
index 0000000000..7cc38d29d3
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/exception-minor.eml
@@ -0,0 +1,49 @@
+MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Exception Minor
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20220317T110000Z
+DTEND:20220317T113000Z
+RECURRENCE-ID:20220317T110000Z
+DTSTAMP:20220318T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Exception location
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Exception title
+DESCRIPTION:Exception description
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:Exception description.
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/meet-meeting-invite.eml b/comm/calendar/test/browser/invitations/data/meet-meeting-invite.eml
new file mode 100644
index 0000000000..8587cd803a
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/meet-meeting-invite.eml
@@ -0,0 +1,384 @@
+Sender: Google Kalender <calendar-notification@google.com>
+Message-ID: <0000000000008c6d7005be1c767c@google.com>
+Date: Mon, 22 Mar 2021 09:12:20 +0000
+Subject: Meet invite (HTML)
+From: example@gmail.com
+To: homer@example.com
+Content-Type: multipart/mixed; boundary="0000000000008c6d6205be1c767e"
+Return-Path: example@gmail.com
+MIME-Version: 1.0
+
+--0000000000008c6d6205be1c767e
+Content-Type: multipart/alternative; boundary="0000000000008c6d6005be1c767c"
+
+--0000000000008c6d6005be1c767c
+Content-Type: text/plain; charset="UTF-8"; format=flowed; delsp=yes
+Content-Transfer-Encoding: base64
+
+RHUgaGFyIGJsaXZpdCBpbmJqdWRlbiB0aWxsIGbDtmxqYW5kZSBow6RuZGVsc2UuCgpUaXRlbDog
+TWVlZWVldCBtZSBIVE1MClRoaXMgaXMgYSB0ZXN0LiBCb2xkLiBJdGFsaWMuJm5ic3A7V2lsbCBk
+aXNjdXNzIGFkZHJlc3MgZm9yIGVtYWlsICAKJmx0O2Zvb0BleGFtcGxlLmNvbSZndDsgYW5kIGh0
+dHA6Ly9leGFtcGxlLmNvbT9mb289YmFyLgpOw6RyOiBtw6VuIGRlbiAyMiBtYXJzIDIwMjEgMTE6
+MzBhbSDigJMgMTI6MzBwbSDDlnN0ZXVyb3BlaXNrIHRpZCAtIEhlbHNpbmdmb3JzCgpBbnNsdXRu
+aW5nc2luZm86IEFuc2x1dCB0aWxsIEdvb2dsZSBNZWV0Cmh0dHBzOi8vbWVldC5nb29nbGUuY29t
+L3B5Yi1uZGN1LWhoYz9ocz0yMjQKCkthbGVuZGVyOiBob21lckBleGFtcGxlLmNvbQpWZW06CiAg
+ICAgKiBleGFtcGxlQGdtYWlsLmNvbeKAkyBvcmdhbmlzYXTDtnIKICAgICAqIGhvbWVyQGV4YW1w
+bGUuY29tCgpJbmZvcm1hdGlvbiBvbSBow6RuZGVsc2VuOiAgCmh0dHBzOi8vY2FsZW5kYXIuZ29v
+Z2xlLmNvbS9jYWxlbmRhci9ldmVudD9hY3Rpb249VklFVyZlaWQ9TmpWdE1UZG9jMlJ2YkcxdmRI
+WXphM1p0Y25Sbk5EQnZiblFnYldGbmJuVnpMbTFsYkdsdVFHaDFkQzVtYVEmdG9rPU1qRWpZbVZ5
+ZEdGMGFHVmliM1JBWjIxaGFXd3VZMjl0WlRnMk5HRmpZbU5qWVdFMU1qVmxaV0ptWTJVelltUm1N
+REF5TldVME1Ea3pOREF4WmpSaFpnJmN0ej1FdXJvcGUlMkZIZWxzaW5raSZobD1zdiZlcz0wCgpJ
+bmJqdWRhbiBmcsOlbiBHb29nbGUgS2FsZW5kZXI6IGh0dHBzOi8vY2FsZW5kYXIuZ29vZ2xlLmNv
+bS9jYWxlbmRhci8KCkRldHRhIGUtcG9zdG1lZGRlbGFuZGUgaGFyIHNraWNrYXRzIHRpbGwga29u
+dG90IGhvbWVyQGV4YW1wbGUuY29tICAKZWZ0ZXJzb20gZHUgw6RyIGRlbHRhZ2FyZSB2aWQgZGVu
+bmEgaMOkbmRlbHNlLgoKT20gZHUgaW50ZSB2aWxsIGbDpSB1cHBkYXRlcmluZ2FyIG9tIGRlbm5h
+IGjDpG5kZWxzZSBpIGZyYW10aWRlbiBrYW4gZHUgdGFja2EgIApuZWogdGlsbCBkZW5uYSBow6Ru
+ZGVsc2UuIER1IGthbiDDpHZlbiByZWdpc3RyZXJhIGRpZyBmw7ZyIGF0dCBmw6UgZXR0ICAKR29v
+Z2xlLWtvbnRvIHDDpSBodHRwczovL2NhbGVuZGFyLmdvb2dsZS5jb20vY2FsZW5kYXIvIG9jaCBr
+b250cm9sbGVyYSAgCmF2aXNlcmluZ3NpbnN0w6RsbG5pbmdhcm5hIGbDtnIgaGVsYSBrYWxlbmRl
+cm4uCgpPbSBkdSB2aWRhcmViZWZvcmRyYXIgZGVuIGjDpHIgaW5ianVkYW4ga2FuIGRldCBnw7Zy
+YSBkZXQgbcO2amxpZ3QgZsO2ciBhbGxhICAKbW90dGFnYXJlIGF0dCBza2lja2EgZXR0IHN2YXIg
+dGlsbCBvcmdhbmlzYXTDtnJlbiBvY2ggbMOkZ2dhcyB0aWxsIHDDpSAgCmfDpHN0bGlzdGFuLCBi
+anVkYSBpbiBhbmRyYSBvYXZzZXR0IGRlcmFzIGVnZW4gaW5ianVkbmluZ3NzdGF0dXMgZWxsZXIg
+IAptb2RpZmllcmEgZGl0dCBPU0EuIEzDpHMgbWVyIHDDpSAgCmh0dHBzOi8vc3VwcG9ydC5nb29n
+bGUuY29tL2NhbGVuZGFyL2Fuc3dlci8zNzEzNSNmb3J3YXJkaW5nCg==
+--0000000000008c6d6005be1c767c
+Content-Type: text/html; charset="UTF-8"
+Content-Transfer-Encoding: quoted-printable
+
+<html>
+<head>
+<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Dutf-8">
+</head>
+<body>
+<span itemscope=3D"" itemtype=3D"http://schema.org/InformAction"><span styl=
+e=3D"display:none" itemprop=3D"about" itemscope=3D"" itemtype=3D"http://sch=
+ema.org/Person">
+<meta itemprop=3D"description" content=3D"Inbjudan fr=C3=A5n example@gm=
+ail.com">
+</span><span itemprop=3D"object" itemscope=3D"" itemtype=3D"http://schema.o=
+rg/Event">
+<div style=3D"">
+<table cellspacing=3D"0" cellpadding=3D"8" border=3D"0" summary=3D"" style=
+=3D"width:100%;font-family:Arial,Sans-serif;border:1px Solid #ccc;border-wi=
+dth:1px 2px 2px 1px;background-color:#fff;">
+<tbody>
+<tr>
+<td>
+<meta itemprop=3D"eventStatus" content=3D"http://schema.org/EventScheduled"=
+>
+<h4 style=3D"padding:6px 0;margin:0 0 4px 0;font-family:Arial,Sans-serif;fo=
+nt-size:13px;line-height:1.4;border:1px Solid #fff;background:#fff;color:#0=
+90;font-weight:normal">
+<strong>Du har blivit inbjuden till f=C3=B6ljande h=C3=A4ndelse.</strong></=
+h4>
+<div style=3D"padding:2px"><span itemprop=3D"publisher" itemscope=3D"" item=
+type=3D"http://schema.org/Organization">
+<meta itemprop=3D"name" content=3D"Google Calendar">
+</span>
+<meta itemprop=3D"eventId/googleCalendar" content=3D"65m17hsdolmotv3kvmrtg4=
+0ont">
+<h3 style=3D"padding:0 0 6px 0;margin:0;font-family:Arial,Sans-serif;font-s=
+ize:16px;font-weight:bold;color:#222">
+<span itemprop=3D"name">Meeeeet me HTML</span></h3>
+<table style=3D"display:inline-table" cellpadding=3D"0" cellspacing=3D"0" b=
+order=3D"0" summary=3D"Uppgifter om h=C3=A4ndelse">
+<tbody>
+<tr>
+<td style=3D"padding:0 1em 10px 0;font-family:Arial,Sans-serif;font-size:13=
+px;color:#888;white-space:nowrap;width:90px" valign=3D"top">
+<div><i style=3D"font-style:normal">N=C3=A4r</i></div>
+</td>
+<td style=3D"padding-bottom:10px;font-family:Arial,Sans-serif;font-size:13p=
+x;color:#222" valign=3D"top">
+<div style=3D"text-indent:-1px"><time itemprop=3D"startDate" datetime=3D"20=
+210322T093000Z"></time><time itemprop=3D"endDate" datetime=3D"20210322T1030=
+00Z"></time>m=C3=A5n den 22 mars 2021 11:30am =E2=80=93 12:30pm
+<span style=3D"color:#888">=C3=96steuropeisk tid - Helsingfors</span></div>
+</td>
+</tr>
+<tr>
+<td style=3D"padding:0 1em 4px 0;font-family:Arial,Sans-serif;font-size:13p=
+x;color:#888;white-space:nowrap;width:90px" valign=3D"top">
+<div><i style=3D"font-style:normal">Anslutningsinfo</i></div>
+</td>
+<td style=3D"padding-bottom:4px;font-family:Arial,Sans-serif;font-size:13px=
+;color:#222" valign=3D"top">
+<div style=3D"text-indent:-1px">Anslut till Google Meet</div>
+</td>
+</tr>
+<tr>
+<td style=3D"padding:0 1em 10px 0;font-family:Arial,Sans-serif;font-size:13=
+px;color:#888;white-space:nowrap;width:90px">
+</td>
+<td style=3D"padding-bottom:10px;font-family:Arial,Sans-serif;font-size:13p=
+x;color:#222" valign=3D"top">
+<div style=3D"text-indent:-1px">
+<div style=3D"text-indent:-1px"><span itemprop=3D"potentialaction" itemscop=
+e=3D"" itemtype=3D"http://schema.org/JoinAction"><span itemprop=3D"name" co=
+ntent=3D"meet.google.com/pyb-ndcu-hhc"><span itemprop=3D"target" itemscope=
+=3D"" itemtype=3D"http://schema.org/EntryPoint"><span itemprop=3D"url" cont=
+ent=3D"https://meet.google.com/pyb-ndcu-hhc?hs=3D224"><span itemprop=3D"htt=
+pMethod" content=3D"GET"><a href=3D"https://meet.google.com/pyb-ndcu-hhc?hs=
+=3D224" style=3D"color:#20c;white-space:nowrap" target=3D"_blank">meet.goog=
+le.com/pyb-ndcu-hhc</a></span></span></span></span></span>
+</div>
+</div>
+</td>
+</tr>
+<tr>
+<td style=3D"padding:0 1em 10px 0;font-family:Arial,Sans-serif;font-size:13=
+px;color:#888;white-space:nowrap;width:90px" valign=3D"top">
+<div><i style=3D"font-style:normal">Kalender</i></div>
+</td>
+<td style=3D"padding-bottom:10px;font-family:Arial,Sans-serif;font-size:13p=
+x;color:#222" valign=3D"top">
+<div style=3D"text-indent:-1px">homer@example.com</div>
+</td>
+</tr>
+<tr>
+<td style=3D"padding:0 1em 10px 0;font-family:Arial,Sans-serif;font-size:13=
+px;color:#888;white-space:nowrap;width:90px" valign=3D"top">
+<div><i style=3D"font-style:normal">Vem</i></div>
+</td>
+<td style=3D"padding-bottom:10px;font-family:Arial,Sans-serif;font-size:13p=
+x;color:#222" valign=3D"top">
+<table cellspacing=3D"0" cellpadding=3D"0">
+<tbody>
+<tr>
+<td style=3D"padding-right:10px;font-family:Arial,Sans-serif;font-size:13px=
+;color:#222;width:10px">
+<div style=3D"text-indent:-1px"><span style=3D"font-family:Courier New,mono=
+space">=E2=80=A2</span></div>
+</td>
+<td style=3D"padding-right:10px;font-family:Arial,Sans-serif;font-size:13px=
+;color:#222">
+<div style=3D"text-indent:-1px">
+<div>
+<div style=3D"margin:0 0 0.3em 0"><span itemprop=3D"attendee" itemscope=3D"=
+" itemtype=3D"http://schema.org/Person"><span itemprop=3D"name" class=3D"no=
+translate">example@gmail.com</span>
+<meta itemprop=3D"email" content=3D"example@gmail.com">
+</span><span itemprop=3D"organizer" itemscope=3D"" itemtype=3D"http://schem=
+a.org/Person">
+<meta itemprop=3D"name" content=3D"example@gmail.com">
+<meta itemprop=3D"email" content=3D"example@gmail.com">
+</span><span style=3D"font-size:11px;color:#888">=E2=80=93 organisat=C3=B6r=
+</span></div>
+</div>
+</div>
+</td>
+</tr>
+<tr>
+<td style=3D"padding-right:10px;font-family:Arial,Sans-serif;font-size:13px=
+;color:#222;width:10px">
+<div style=3D"text-indent:-1px"><span style=3D"font-family:Courier New,mono=
+space">=E2=80=A2</span></div>
+</td>
+<td style=3D"padding-right:10px;font-family:Arial,Sans-serif;font-size:13px=
+;color:#222">
+<div style=3D"text-indent:-1px">
+<div>
+<div style=3D"margin:0 0 0.3em 0"><span itemprop=3D"attendee" itemscope=3D"=
+" itemtype=3D"http://schema.org/Person"><span itemprop=3D"name" class=3D"no=
+translate">homer@example.com</span>
+<meta itemprop=3D"email" content=3D"homer@example.com">
+</span></div>
+</div>
+</div>
+</td>
+</tr>
+</tbody>
+</table>
+</td>
+</tr>
+</tbody>
+</table>
+<div style=3D"float:right;font-weight:bold;font-size:13px"><a href=3D"https=
+://calendar.google.com/calendar/event?action=3DVIEW&amp;eid=3DNjVtMTdoc2Rvb=
+G1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQGh1dC5maQ&amp;tok=3DMjEjYmVydGF0aGV=
+ib3RAZ21haWwuY29tZTg2NGFjYmNjYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&amp=
+;ctz=3DEurope%2FHelsinki&amp;hl=3Dsv&amp;es=3D0" style=3D"color:#20c;white-=
+space:nowrap" itemprop=3D"url">mer
+ information =C2=BB</a><br>
+</div>
+<div style=3D"padding-bottom:15px;font-family:Arial,Sans-serif;font-size:13=
+px;color:#222;white-space:pre-wrap!important;white-space:-moz-pre-wrap!impo=
+rtant;white-space:-pre-wrap!important;white-space:-o-pre-wrap!important;whi=
+te-space:pre;word-wrap:break-word">
+<span>This is a test. <b>Bold</b>. <i>Italic</i>. <br>
+<br>
+Will discuss address for email &lt;<a href=3D"mailto:foo@example.com" targe=
+t=3D"_blank">foo@example.com</a>&gt; and
+<a href=3D"https://www.google.com/url?q=3Dhttp%3A%2F%2Fexample.com%3Ffoo%3D=
+bar&amp;sa=3DD&amp;ust=3D1616836340813000&amp;usg=3DAOvVaw04gjO0O3Bf1tJs9vs=
+BMj3x" target=3D"_blank">
+http://example.com?foo=3Dbar</a>.</span>
+<meta itemprop=3D"description" content=3D"This is a test. Bold. Italic.&nbs=
+p;Will discuss address for email &lt;foo@example.com&gt; and http://example=
+.com?foo=3Dbar.">
+</div>
+</div>
+<p style=3D"color:#222;font-size:13px;margin:0"><span style=3D"color:#888">=
+Ska du delta (homer@example.com)?
+</span><wbr><strong><span itemprop=3D"potentialaction" itemscope=3D"" itemt=
+ype=3D"http://schema.org/RsvpAction">
+<meta itemprop=3D"attendance" content=3D"http://schema.org/RsvpAttendance/Y=
+es">
+<span itemprop=3D"handler" itemscope=3D"" itemtype=3D"http://schema.org/Htt=
+pActionHandler"><link itemprop=3D"method" href=3D"http://schema.org/HttpReq=
+uestMethod/GET"><a href=3D"https://calendar.google.com/calendar/event?actio=
+n=3DRESPOND&amp;eid=3DNjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQ=
+Gh1dC5maQ&amp;rst=3D1&amp;tok=3DMjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmN=
+jYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&amp;ctz=3DEurope%2FHelsinki&amp=
+;hl=3Dsv&amp;es=3D0" style=3D"color:#20c;white-space:nowrap" itemprop=3D"ur=
+l">Ja</a></span></span><span style=3D"margin:0 0.4em;font-weight:normal">
+ - </span><span itemprop=3D"potentialaction" itemscope=3D"" itemtype=3D"htt=
+p://schema.org/RsvpAction">
+<meta itemprop=3D"attendance" content=3D"http://schema.org/RsvpAttendance/M=
+aybe">
+<span itemprop=3D"handler" itemscope=3D"" itemtype=3D"http://schema.org/Htt=
+pActionHandler"><link itemprop=3D"method" href=3D"http://schema.org/HttpReq=
+uestMethod/GET"><a href=3D"https://calendar.google.com/calendar/event?actio=
+n=3DRESPOND&amp;eid=3DNjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQ=
+Gh1dC5maQ&amp;rst=3D3&amp;tok=3DMjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmN=
+jYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&amp;ctz=3DEurope%2FHelsinki&amp=
+;hl=3Dsv&amp;es=3D0" style=3D"color:#20c;white-space:nowrap" itemprop=3D"ur=
+l">Kanske</a></span></span><span style=3D"margin:0 0.4em;font-weight:normal=
+">
+ - </span><span itemprop=3D"potentialaction" itemscope=3D"" itemtype=3D"htt=
+p://schema.org/RsvpAction">
+<meta itemprop=3D"attendance" content=3D"http://schema.org/RsvpAttendance/N=
+o">
+<span itemprop=3D"handler" itemscope=3D"" itemtype=3D"http://schema.org/Htt=
+pActionHandler"><link itemprop=3D"method" href=3D"http://schema.org/HttpReq=
+uestMethod/GET"><a href=3D"https://calendar.google.com/calendar/event?actio=
+n=3DRESPOND&amp;eid=3DNjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQ=
+Gh1dC5maQ&amp;rst=3D2&amp;tok=3DMjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmN=
+jYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&amp;ctz=3DEurope%2FHelsinki&amp=
+;hl=3Dsv&amp;es=3D0" style=3D"color:#20c;white-space:nowrap" itemprop=3D"ur=
+l">Nej</a></span></span></strong>
+<wbr><a href=3D"https://calendar.google.com/calendar/event?action=3DVIEW&am=
+p;eid=3DNjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQGh1dC5maQ&amp;=
+tok=3DMjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmNjYWE1MjVlZWJmY2UzYmRmMDAyN=
+WU0MDkzNDAxZjRhZg&amp;ctz=3DEurope%2FHelsinki&amp;hl=3Dsv&amp;es=3D0" style=
+=3D"color:#20c;white-space:nowrap" itemprop=3D"url">fler
+ alternativ =C2=BB</a></p>
+</td>
+</tr>
+<tr>
+<td style=3D"background-color:#f6f6f6;color:#888;border-top:1px Solid #ccc;=
+font-family:Arial,Sans-serif;font-size:11px">
+<p>Inbjudan fr=C3=A5n <a href=3D"https://calendar.google.com/calendar/" tar=
+get=3D"_blank" style=3D"">
+Google Kalender</a></p>
+<p>Detta e-postmeddelande har skickats till kontot homer@example.com efte=
+rsom du =C3=A4r deltagare vid denna h=C3=A4ndelse.</p>
+<p>Om du inte vill f=C3=A5 uppdateringar om denna h=C3=A4ndelse i framtiden=
+ kan du tacka nej till denna h=C3=A4ndelse. Du kan =C3=A4ven registrera dig=
+ f=C3=B6r att f=C3=A5 ett Google-konto p=C3=A5 https://calendar.google.com/=
+calendar/ och kontrollera aviseringsinst=C3=A4llningarna f=C3=B6r hela kale=
+ndern.</p>
+<p>Om du vidarebefordrar den h=C3=A4r inbjudan kan det g=C3=B6ra det m=C3=
+=B6jligt f=C3=B6r alla mottagare att skicka ett svar till organisat=C3=B6re=
+n och l=C3=A4ggas till p=C3=A5 g=C3=A4stlistan, bjuda in andra oavsett dera=
+s egen inbjudningsstatus eller modifiera ditt OSA.
+<a href=3D"https://support.google.com/calendar/answer/37135#forwarding">L=
+=C3=A4s mer</a>.</p>
+</td>
+</tr>
+</tbody>
+</table>
+</div>
+</span></span>
+</body>
+</html>
+
+--0000000000008c6d6005be1c767c
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: quoted-printable
+
+BEGIN:VCALENDAR
+PRODID:-//Google Inc//Google Calendar 70.9054//EN
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20210322T093000Z
+DTEND:20210322T103000Z
+DTSTAMP:20210322T091220Z
+ORGANIZER;CN=3Dexample@gmail.com:mailto:example@gmail.com
+UID:65m17hsdolmotv3kvmrtg40ont@google.com
+ATTENDEE;CUTYPE=3DINDIVIDUAL;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DACCEPTED;RSV=
+P=3DTRUE
+ ;CN=3Dexample@gmail.com;X-NUM-GUESTS=3D0:mailto:example@gmail.com
+ATTENDEE;CUTYPE=3DINDIVIDUAL;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION=
+;RSVP=3D
+ TRUE;CN=3Dhomer@example.com;X-NUM-GUESTS=3D0:mailto:homer@example.com
+X-MICROSOFT-CDO-OWNERAPPTID:-410050292
+CREATED:20210322T091220Z
+DESCRIPTION:This is a test. <b>Bold</b>. <i>Italic</i>.&nbsp\;<br><br>Will=
+=20
+ discuss address for email &lt\;foo@example.com&gt\; and http://example.com=
+?
+ foo=3Dbar.\n\n-::~:~::~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:=
+~:~
+ :~:~:~:~:~:~:~:~::~:~::-\n=C3=84ndra inte det h=C3=A4r avsnittet i beskriv=
+ningen.\n\n
+ Den h=C3=A4r h=C3=A4ndelsen har ett videosamtal.\nG=C3=A5 med: https://mee=
+t.google.com/pyb
+ -ndcu-hhc\n\nVisa din h=C3=A4ndelse p=C3=A5 https://calendar.google.com/ca=
+lendar/even
+ t?action=3DVIEW&eid=3DNjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGlu=
+QGh1d
+ C5maQ&tok=3DMjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmNjYWE1MjVlZWJmY2UzYm=
+RmM
+ DAyNWU0MDkzNDAxZjRhZg&ctz=3DEurope%2FHelsinki&hl=3Dsv&es=3D1.\n-::~:~::~:~=
+:~:~:~:
+ ~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~::~:~::-
+LAST-MODIFIED:20210322T091220Z
+LOCATION:
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Meeeeet me HTML
+TRANSP:OPAQUE
+END:VEVENT
+END:VCALENDAR
+
+--0000000000008c6d6005be1c767c--
+
+--0000000000008c6d6205be1c767e
+Content-Type: application/ics; name="invite.ics"
+Content-Disposition: attachment; filename="invite.ics"
+Content-Transfer-Encoding: base64
+
+QkVHSU46VkNBTEVOREFSClBST0RJRDotLy9Hb29nbGUgSW5jLy9Hb29nbGUgQ2FsZW5kYXIgNzAu
+OTA1NC8vRU4KVkVSU0lPTjoyLjAKQ0FMU0NBTEU6R1JFR09SSUFOCk1FVEhPRDpSRVFVRVNUCkJF
+R0lOOlZFVkVOVApEVFNUQVJUOjIwMjEwMzIyVDA5MzAwMFoKRFRFTkQ6MjAyMTAzMjJUMTAzMDAw
+WgpEVFNUQU1QOjIwMjEwMzIyVDA5MTIyMFoKT1JHQU5JWkVSO0NOPWV4YW1wbGVAZ21haWwuY29t
+Om1haWx0bzpleGFtcGxlQGdtYWlsLmNvbQpVSUQ6NjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnRA
+Z29vZ2xlLmNvbQpBVFRFTkRFRTtDVVRZUEU9SU5ESVZJRFVBTDtST0xFPVJFUS1QQVJUSUNJUEFO
+VDtQQVJUU1RBVD1BQ0NFUFRFRDtSU1ZQPVRSVUUKIDtDTj1leGFtcGxlQGdtYWlsLmNvbTtYLU5V
+TS1HVUVTVFM9MDptYWlsdG86ZXhhbXBsZUBnbWFpbC5jb20KQVRURU5ERUU7Q1VUWVBFPUlORElW
+SURVQUw7Uk9MRT1SRVEtUEFSVElDSVBBTlQ7UEFSVFNUQVQ9TkVFRFMtQUNUSU9OO1JTVlA9CiBU
+UlVFO0NOPWhvbWVyQGV4YW1wbGUuY29tO1gtTlVNLUdVRVNUUz0wOm1haWx0bzpob21lckBleGFt
+cGxlLmNvbQpYLU1JQ1JPU09GVC1DRE8tT1dORVJBUFBUSUQ6LTQxMDA1MDI5MgpDUkVBVEVEOjIw
+MjEwMzIyVDA5MTIyMFoKREVTQ1JJUFRJT046VGhpcyBpcyBhIHRlc3QuIDxiPkJvbGQ8L2I+LiA8
+aT5JdGFsaWM8L2k+LiZuYnNwXDs8YnI+PGJyPldpbGwgCiBkaXNjdXNzIGFkZHJlc3MgZm9yIGVt
+YWlsICZsdFw7Zm9vQGV4YW1wbGUuY29tJmd0XDsgYW5kIGh0dHA6Ly9leGFtcGxlLmNvbT8KIGZv
+bz1iYXIuXG5cbi06On46fjo6fjp+On46fjp+On46fjp+On46fjp+On46fjp+On46fjp+On46fjp+
+On46fjp+On46fjp+On46fgogOn46fjp+On46fjp+On46fjo6fjp+OjotXG7DhG5kcmEgaW50ZSBk
+ZXQgaMOkciBhdnNuaXR0ZXQgaSBiZXNrcml2bmluZ2VuLlxuXG4KIERlbiBow6RyIGjDpG5kZWxz
+ZW4gaGFyIGV0dCB2aWRlb3NhbXRhbC5cbkfDpSBtZWQ6IGh0dHBzOi8vbWVldC5nb29nbGUuY29t
+L3B5YgogLW5kY3UtaGhjXG5cblZpc2EgZGluIGjDpG5kZWxzZSBww6UgaHR0cHM6Ly9jYWxlbmRh
+ci5nb29nbGUuY29tL2NhbGVuZGFyL2V2ZW4KIHQ/YWN0aW9uPVZJRVcmZWlkPU5qVnRNVGRvYzJS
+dmJHMXZkSFl6YTNadGNuUm5OREJ2Ym5RZ2JXRm5iblZ6TG0xbGJHbHVRR2gxZAogQzVtYVEmdG9r
+PU1qRWpZbVZ5ZEdGMGFHVmliM1JBWjIxaGFXd3VZMjl0WlRnMk5HRmpZbU5qWVdFMU1qVmxaV0pt
+WTJVelltUm1NCiBEQXlOV1UwTURrek5EQXhaalJoWmcmY3R6PUV1cm9wZSUyRkhlbHNpbmtpJmhs
+PXN2JmVzPTEuXG4tOjp+On46On46fjp+On46fjoKIH46fjp+On46fjp+On46fjp+On46fjp+On46
+fjp+On46fjp+On46fjp+On46fjp+On46fjp+On46fjp+On46On46fjo6LQpMQVNULU1PRElGSUVE
+OjIwMjEwMzIyVDA5MTIyMFoKTE9DQVRJT046ClNFUVVFTkNFOjAKU1RBVFVTOkNPTkZJUk1FRApT
+VU1NQVJZOk1lZWVlZXQgbWUgSFRNTApUUkFOU1A6T1BBUVVFCkVORDpWRVZFTlQKRU5EOlZDQUxF
+TkRBUgo=
+
+--0000000000008c6d6205be1c767e--
diff --git a/comm/calendar/test/browser/invitations/data/message-containing-event.eml b/comm/calendar/test/browser/invitations/data/message-containing-event.eml
new file mode 100644
index 0000000000..d27c2976db
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/message-containing-event.eml
@@ -0,0 +1,44 @@
+From: ExampleStore <noreply@example.com>
+Date: Wed, 24 Aug 2016 16:40:06 -0400
+Subject: ExampleStore - booking 01.09.2016 @ 09.25 - 09.50
+Content-Type: multipart/mixed;
+ boundary="_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35"
+To: <foo@example.com>
+Message-ID: <df0f52ae-d3dc-4b89-bc3e-67fc4f6e8552@example.com>
+MIME-Version: 1.0
+
+--_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35
+Content-Type: multipart/alternative;
+ boundary="_=ALT_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35"
+
+--_=ALT_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35
+Content-Type: text/plain; charset="UTF-8"
+
+Remember your booking @ 09.25
+
+--_=ALT_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35
+Content-Type: text/html; charset="UTF-8"
+
+<html>
+<body>
+<p>You have a booking for 9.25</p>
+</body>
+</html>
+
+--_=ALT_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35--
+
+--_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35
+Content-Type: TeXt/CaLeNdAr; method=PUBLISH; charset=UTF-8
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename="booking.ics"
+
+QkVHSU46VkNBTEVOREFSDQpWRVJTSU9OOjIuMA0KTUVUSE9EOlBVQkxJU0gNClBST0RJRDpleGFt
+cGxlLmNvbQ0KQkVHSU46VkVWRU5UDQpEVFNUQVJUOjIwMTYwOTAxVDA2MjUwMFoNCkRURU5EOjIw
+MTYwOTAxVDA2NTAwMFoNCkRUU1RBTVA6MjAxNjA4MjRUMjA0MDAwWg0KVUlEOjIwMTYwODI0VDIw
+NDAwMFotNTY4ODYwMjgwQGV4YW1wbGUuY29tDQpTVU1NQVJZOkhhaXJjdXQNCk9SR0FOSVpFUjpt
+YWlsdG86c29tZW9uZUBleGFtcGxlLmNvbQ0KREVTQ1JJUFRJT046SGFpcmN1dCtzdHlsaW5nDQpM
+T0NBVElPTjpTb21ld2hlcmUNClRSQU5TUDpPUEFRVUUNClNFUVVFTkNFOjANCkNMQVNTOlBVQkxJ
+Qw0KQkVHSU46VkFMQVJNDQpUUklHR0VSOi1QVDYwTQ0KQUNUSU9OOkFVRElPDQpERVNDUklQVElP
+TjpSZW1pbmRlcg0KRU5EOlZBTEFSTQ0KRU5EOlZFVkVOVA0KRU5EOlZDQUxFTkRBUg==
+
+--_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35--
diff --git a/comm/calendar/test/browser/invitations/data/message-non-invite.eml b/comm/calendar/test/browser/invitations/data/message-non-invite.eml
new file mode 100644
index 0000000000..cf391f445a
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/message-non-invite.eml
@@ -0,0 +1,115 @@
+Date: Sun, 28 Nov 2021 21:39:31 +0000
+From: Jane <noreply@example.com>
+To: <john.doe@example.com>
+Message-ID: <1074020157.32201638135571450.JavaMail.root@hki-example-prod-app-004>
+Subject: We're having a party - you're NOT invited
+Content-Type: multipart/mixed;
+ boundary="----=_Part_6440_2094089067.1638135571440"
+MIME-Version: 1.0
+
+------=_Part_6440_2094089067.1638135571440
+Content-Type: multipart/related;
+ boundary="----=_Part_6441_499243807.1638135571440"
+
+------=_Part_6441_499243807.1638135571440
+Content-Type: text/plain; charset="UTF-8"
+
+Hey, we're having a party! You're not invited ;)
+
+------=_Part_6441_499243807.1638135571440--
+
+------=_Part_6440_2094089067.1638135571440
+Content-Type: text/calendar; charset="utf-8"; name="event.ics"
+Content-Transfer-Encoding: quoted-printable
+Content-Disposition: attachment; filename="event.ics"
+
+BEGIN:VCALENDAR
+PRODID:-//EXAMPLE:COM//iCal4j 1.0.5.2//EN
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:PUBLISH
+BEGIN:VTIMEZONE
+TZID:Europe/Helsinki
+TZURL:http://tzurl.org/zoneinfo/Europe/Helsinki
+X-LIC-LOCATION:Europe/Helsinki
+BEGIN:DAYLIGHT
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0300
+TZNAME:EEST
+DTSTART:19830327T030000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D3;BYDAY=3D-1SU
+END:DAYLIGHT
+BEGIN:STANDARD
+TZOFFSETFROM:+0300
+TZOFFSETTO:+0200
+TZNAME:EET
+DTSTART:19961027T040000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D10;BYDAY=3D-1SU
+END:STANDARD
+BEGIN:STANDARD
+TZOFFSETFROM:+013952
+TZOFFSETTO:+013952
+TZNAME:HMT
+DTSTART:18780531T000000
+RDATE:18780531T000000
+END:STANDARD
+BEGIN:STANDARD
+TZOFFSETFROM:+013952
+TZOFFSETTO:+0200
+TZNAME:EET
+DTSTART:19210501T000000
+RDATE:19210501T000000
+END:STANDARD
+BEGIN:DAYLIGHT
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0300
+TZNAME:EEST
+DTSTART:19420403T000000
+RDATE:19420403T000000
+RDATE:19810329T020000
+RDATE:19820328T020000
+END:DAYLIGHT
+BEGIN:STANDARD
+TZOFFSETFROM:+0300
+TZOFFSETTO:+0200
+TZNAME:EET
+DTSTART:19421003T000000
+RDATE:19421003T000000
+RDATE:19810927T030000
+RDATE:19820926T030000
+RDATE:19830925T040000
+RDATE:19840930T040000
+RDATE:19850929T040000
+RDATE:19860928T040000
+RDATE:19870927T040000
+RDATE:19880925T040000
+RDATE:19890924T040000
+RDATE:19900930T040000
+RDATE:19910929T040000
+RDATE:19920927T040000
+RDATE:19930926T040000
+RDATE:19940925T040000
+RDATE:19950924T040000
+END:STANDARD
+BEGIN:STANDARD
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0200
+TZNAME:EET
+DTSTART:19830101T000000
+RDATE:19830101T000000
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTAMP:20211128T213931Z
+DTSTART;TZID=3DEurope/Helsinki:20211129T105500
+DTEND;TZID=3DEurope/Helsinki:20211129T110000
+SUMMARY:Party at John's house\, Helsinki
+ORGANIZER;CN=3DJANE:mailto:noreply@example.com
+UID:1e5fd4e6-bc52-439c-ac76-40da54f57c77@secure.example.com
+SEQUENCE:3
+STATUS:CONFIRMED
+LAST-MODIFIED:20211128T213931Z
+END:VEVENT
+END:VCALENDAR
+
+------=_Part_6440_2094089067.1638135571440--
diff --git a/comm/calendar/test/browser/invitations/data/outlook-test-invite.eml b/comm/calendar/test/browser/invitations/data/outlook-test-invite.eml
new file mode 100644
index 0000000000..de07a9b873
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/outlook-test-invite.eml
@@ -0,0 +1,102 @@
+From: Marge <marge@example.org>
+To: Homer <homer@example.org>
+Subject: Testaus
+Thread-Topic: Testaus
+Thread-Index: AdfdgAAehFDfsPyXTommZqRYgeMqiQAABo4Q
+Date: Fri, 19 Nov 2021 20:00:31 +0000
+Message-ID: <HE1P190MB0540579ABA18FCE320901B31E39C9@HE1P190MB0540.EURP190.PROD.OUTLOOK.COM>
+Accept-Language: en-US
+Content-Language: en-US
+Content-Type: multipart/alternative;
+ boundary="_000_HE1P190MB0540579ABA18FCE320901B31E39C9HE1P190MB0540EURP_"
+MIME-Version: 1.0
+
+--_000_HE1P190MB0540579ABA18FCE320901B31E39C9HE1P190MB0540EURP_
+Content-Type: text/plain; charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+
+
+--_000_HE1P190MB0540579ABA18FCE320901B31E39C9HE1P190MB0540EURP_
+Content-Type: text/html; charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+<html xmlns:v=3D"urn:schemas-microsoft-com:vml" xmlns:o=3D"urn:schemas-micr=
+osoft-com:office:office" xmlns:w=3D"urn:schemas-microsoft-com:office:word" =
+xmlns:m=3D"http://schemas.microsoft.com/office/2004/12/omml" xmlns=3D"http:=
+//www.w3.org/TR/REC-html40"><head>
+<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Diso-8859-=
+1">
+<meta name=3D"Generator" content=3D"Microsoft Word 15 (filtered medium)">
+<style><!--
+/* Font Definitions */
+@font-face
+ {font-family:"Cambria Math";
+ panose-1:2 4 5 3 5 4 6 3 2 4;}
+@font-face
+ {font-family:Calibri;
+ panose-1:2 15 5 2 2 2 4 3 2 4;}
+/* Style Definitions */
+p.MsoNormal, li.MsoNormal, div.MsoNormal
+ {margin:0cm;
+ font-size:11.0pt;
+ font-family:"Calibri",sans-serif;
+ mso-fareast-language:EN-US;}
+span.EmailStyle18
+ {mso-style-type:personal-compose;
+ font-family:"Calibri",sans-serif;
+ color:windowtext;}
+.MsoChpDefault
+ {mso-style-type:export-only;
+ font-size:10.0pt;}
+@page WordSection1
+ {size:612.0pt 792.0pt;
+ margin:70.85pt 2.0cm 70.85pt 2.0cm;}
+div.WordSection1
+ {page:WordSection1;}
+--></style><!--[if gte mso 9]><xml>
+<o:shapedefaults v:ext=3D"edit" spidmax=3D"1026" />
+</xml><![endif]--><!--[if gte mso 9]><xml>
+<o:shapelayout v:ext=3D"edit">
+<o:idmap v:ext=3D"edit" data=3D"1" />
+</o:shapelayout></xml><![endif]-->
+</head>
+<body lang=3D"FI" link=3D"#0563C1" vlink=3D"#954F72" style=3D"word-wrap:bre=
+ak-word">
+<div class=3D"WordSection1">
+<p class=3D"MsoNormal"><o:p>&nbsp;</o:p></p>
+</div>
+</body>
+</html>
+
+--_000_HE1P190MB0540579ABA18FCE320901B31E39C9HE1P190MB0540EURP_
+Content-Type: text/calendar; charset="utf-8"; method=REQUEST
+Content-Transfer-Encoding: base64
+
+QkVHSU46VkNBTEVOREFSCk1FVEhPRDpSRVFVRVNUClBST0RJRDpNaWNyb3NvZnQgRXhjaGFuZ2Ug
+U2VydmVyIDIwMTAKVkVSU0lPTjoyLjAKQkVHSU46VlRJTUVaT05FClRaSUQ6RkxFIFN0YW5kYXJk
+IFRpbWUKQkVHSU46U1RBTkRBUkQKRFRTVEFSVDoxNjAxMDEwMVQwNDAwMDAKVFpPRkZTRVRGUk9N
+OiswMzAwClRaT0ZGU0VUVE86KzAyMDAKUlJVTEU6RlJFUT1ZRUFSTFk7SU5URVJWQUw9MTtCWURB
+WT0tMVNVO0JZTU9OVEg9MTAKRU5EOlNUQU5EQVJECkJFR0lOOkRBWUxJR0hUCkRUU1RBUlQ6MTYw
+MTAxMDFUMDMwMDAwClRaT0ZGU0VURlJPTTorMDIwMApUWk9GRlNFVFRPOiswMzAwClJSVUxFOkZS
+RVE9WUVBUkxZO0lOVEVSVkFMPTE7QllEQVk9LTFTVTtCWU1PTlRIPTMKRU5EOkRBWUxJR0hUCkVO
+RDpWVElNRVpPTkUKQkVHSU46VkVWRU5UCk9SR0FOSVpFUjtDTj1NYXJnZTptYWlsdG86bWFyZ2VA
+ZXhhbXBsZS5vcmcKQVRURU5ERUU7Uk9MRT1SRVEtUEFSVElDSVBBTlQ7UEFSVFNUQVQ9TkVFRFMt
+QUNUSU9OO1JTVlA9VFJVRTtDTj1Ib20KIGVyOm1haWx0bzpob21lckBleGFtcGxlLm9yZwpERVND
+UklQVElPTjtMQU5HVUFHRT1lbi1VUzpcbgpVSUQ6MDMwMDAwMDA4MjAwRTAwMDc0QzVCNzEwMUE4
+MkUwMDgwMDAwMDAwMDYwQjYwOEQwOTBEREQ3MDEwMDAwMDAwMDAwMDAwMDAKIDAxMDAwMDAwMDRC
+RTBDRkZBNTRCQ0Y2NEU5NTZFMzQxNDMzNjJDM0MwClNVTU1BUlk7TEFOR1VBR0U9ZW4tVVM6VGVz
+dGF1cwpEVFNUQVJUO1RaSUQ9RkxFIFN0YW5kYXJkIFRpbWU6MjAyMTExMjdUMDkwMDAwCkRURU5E
+O1RaSUQ9RkxFIFN0YW5kYXJkIFRpbWU6MjAyMTExMjdUMDkzMDAwCkNMQVNTOlBVQkxJQwpQUklP
+UklUWTo1CkRUU1RBTVA6MjAyMTExMTlUMjAwMDI5WgpUUkFOU1A6T1BBUVVFClNUQVRVUzpDT05G
+SVJNRUQKU0VRVUVOQ0U6MApMT0NBVElPTjtMQU5HVUFHRT1lbi1VUzoKWC1NSUNST1NPRlQtQ0RP
+LUFQUFQtU0VRVUVOQ0U6MApYLU1JQ1JPU09GVC1DRE8tT1dORVJBUFBUSUQ6LTcyMDEyODAyNwpY
+LU1JQ1JPU09GVC1DRE8tQlVTWVNUQVRVUzpURU5UQVRJVkUKWC1NSUNST1NPRlQtQ0RPLUlOVEVO
+REVEU1RBVFVTOkJVU1kKWC1NSUNST1NPRlQtQ0RPLUFMTERBWUVWRU5UOkZBTFNFClgtTUlDUk9T
+T0ZULUNETy1JTVBPUlRBTkNFOjEKWC1NSUNST1NPRlQtQ0RPLUlOU1RUWVBFOjAKWC1NSUNST1NP
+RlQtRE9OT1RGT1JXQVJETUVFVElORzpGQUxTRQpYLU1JQ1JPU09GVC1ESVNBTExPVy1DT1VOVEVS
+OkZBTFNFClgtTUlDUk9TT0ZULUxPQ0FUSU9OUzpbXQpCRUdJTjpWQUxBUk0KREVTQ1JJUFRJT046
+UkVNSU5ERVIKVFJJR0dFUjtSRUxBVEVEPVNUQVJUOi1QVDE1TQpBQ1RJT046RElTUExBWQpFTkQ6
+VkFMQVJNCkVORDpWRVZFTlQKRU5EOlZDQUxFTkRBUgo=
+
+--_000_HE1P190MB0540579ABA18FCE320901B31E39C9HE1P190MB0540EURP_--
diff --git a/comm/calendar/test/browser/invitations/data/repeat-event.eml b/comm/calendar/test/browser/invitations/data/repeat-event.eml
new file mode 100644
index 0000000000..9247e6575b
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/repeat-event.eml
@@ -0,0 +1,49 @@
+MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Invitation: Repeat Event @ Daily from 2pm to 3pm 3 times (AST) (receiver@example.com)
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20220316T110000Z
+DTEND:20220316T113000Z
+RRULE:FREQ=DAILY;WKST=SU;COUNT=3;INTERVAL=1
+DTSTAMP:20220316T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Somewhere
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Repeat Event
+DESCRIPTION:An event invitation.
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/repeat-update-major.eml b/comm/calendar/test/browser/invitations/data/repeat-update-major.eml
new file mode 100644
index 0000000000..61fe9f5022
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/repeat-update-major.eml
@@ -0,0 +1,49 @@
+MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Repeat Update Major
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20220316T050000Z
+DTEND:20220316T053000Z
+RRULE:FREQ=DAILY;WKST=SU;COUNT=3;INTERVAL=1
+DTSTAMP:20220316T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Somewhere
+SEQUENCE:2
+STATUS:CONFIRMED
+SUMMARY:Repeat Event
+DESCRIPTION:An event invitation.
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/repeat-update-minor.eml b/comm/calendar/test/browser/invitations/data/repeat-update-minor.eml
new file mode 100644
index 0000000000..a6ad357553
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/repeat-update-minor.eml
@@ -0,0 +1,49 @@
+MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Invitation: Repeat Update Minor
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20220316T110000Z
+DTEND:20220316T113000Z
+RRULE:FREQ=DAILY;WKST=SU;COUNT=3;INTERVAL=1
+DTSTAMP:20220318T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Updated location
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Updated Event
+DESCRIPTION:Updated description.
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:Updated description.
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/single-event.eml b/comm/calendar/test/browser/invitations/data/single-event.eml
new file mode 100644
index 0000000000..14418c2c79
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/single-event.eml
@@ -0,0 +1,78 @@
+MIME-Version: 1.0
+Content-Transfer-Encoding: binary
+Content-Type: multipart/mixed; boundary="_----------=_1647458162153312762582"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+To: receiver@example.com
+Subject: Invitation: Single Event @ Wed, Mar 16 2022 11:00 AST
+From: Sender <sender@example.com>
+
+This is a multi-part message in MIME format.
+
+--_----------=_1647458162153312762582
+MIME-Version: 1.0
+Content-Transfer-Encoding: binary
+Content-Type: multipart/alternative; boundary="_----------=_1647458162153312762583"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+This is a multi-part message in MIME format.
+
+--_----------=_1647458162153312762583
+MIME-Version: 1.0
+Content-Disposition: inline
+Content-Length: 227
+Content-Transfer-Encoding: binary
+Content-Type: text/plain; charset="utf-8"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+Single Event
+
+When:
+ Wed, Mar 16 2022
+ 11:00 - 12:00 AST
+Where:
+ Somewhere
+
+--_----------=_1647458162153312762583
+MIME-Version: 1.0
+Content-Disposition: inline
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/calendar; charset="utf-8"; method="REQUEST"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+BEGIN:VCALENDAR
+VERSION:2.0
+METHOD:REQUEST
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:02e79b96
+SEQUENCE:0
+DTSTAMP:20220316T191602Z
+CREATED:20220316T191532Z
+DTSTART:20220316T110000Z
+DTEND:20220316T113000Z
+DURATION:PT1H
+PRIORITY:0
+SUMMARY:Single Event
+DESCRIPTION:An event invitation.
+LOCATION:Somewhere
+STATUS:CONFIRMED
+TRANSP:OPAQUE
+CLASS:PUBLIC
+ORGANIZER;CN=3DSender;
+ EMAIL=3Dsender@example.com:mailto:sender@example.com
+ATTENDEE;CN=3DSender;
+ EMAIL=3Dsender@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DACCEPTED;RSVP=3DFALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=3Dreceiver@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:other@example.com
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--_----------=_1647458162153312762583--
diff --git a/comm/calendar/test/browser/invitations/data/teams-meeting-invite.eml b/comm/calendar/test/browser/invitations/data/teams-meeting-invite.eml
new file mode 100644
index 0000000000..777486ec87
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/teams-meeting-invite.eml
@@ -0,0 +1,167 @@
+From: Marge <marge@example.com>
+To: bart@example.com, homer@example.com
+Subject: Teams meeting
+Thread-Topic: Teams meeting
+Thread-Index: AdbIy2RnFnEYrGmq80aB3RiaEcOS6w==
+Date: Wed, 2 Dec 2020 16:52:34 +0000
+Message-ID: <HE1PR0802MB228346BE1576FEAB8A7F32328EF30@HE1PR0802MB2283.eurprd08.prod.outlook.com>
+Accept-Language: fi-FI, en-US
+Content-Language: en-US
+X-MS-Has-Attach:
+X-MS-TNEF-Correlator:
+Content-Type: multipart/alternative;
+ boundary="_000_HE1PR0802MB228346BE1576FEAB8A7F32328EF30HE1PR0802MB2283_"
+X-Spam-Flag: No
+Return-Path: marge@example.com
+MIME-Version: 1.0
+
+--_000_HE1PR0802MB228346BE1576FEAB8A7F32328EF30HE1PR0802MB2283_
+Content-Type: text/plain; charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+
+
+
+___________________________________________________________________________=
+_____
+Microsoft Teams -kokous
+Liity tietokoneella tai mobiilisovelluksella
+Liity kokoukseen napsauttamalla t=E4t=E4<https://teams.microsoft.com/l/meet=
+up-join/19%3ameeting_MHU5NmI5ZGAtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy%40thr=
+ead.v2/0?context=3D%7b%33Tid%22%3a%222fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c%2=
+2%2c%22Oid%22%3a%2214464d09-ceb8-458c-a61c-717f1e5c66c5%22%7d>
+Lis=E4tietoja<https://aka.ms/JoinTeamsMeeting> | Kokousasetukset<https://te=
+ams.microsoft.com/meetingOptions/?organizerId=3D14464d09-ceb8-458c-a61c-717=
+f1e5c66c5&tenantId=3D2fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c&threadId=3D19_mee=
+ting_MHU5NmI5ZGAtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy@thread.v2&messageId=
+=3D0&language=3Dfi-FI>
+___________________________________________________________________________=
+_____
+
+--_000_HE1PR0802MB228346BE1576FEAB8A7F32328EF30HE1PR0802MB2283_
+Content-Type: text/html; charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+<html>
+<head>
+<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Diso-8859-=
+1">
+</head>
+<body>
+<div><br>
+<br>
+<br>
+<div style=3D"width:100%;height: 20px;"><span style=3D"white-space:nowrap;c=
+olor:#5F5F5F;opacity:.36;">________________________________________________=
+________________________________</span>
+</div>
+<div class=3D"me-email-text" style=3D"color:#252424;font-family:'Segoe UI',=
+'Helvetica Neue',Helvetica,Arial,sans-serif;">
+<div style=3D"margin-top: 24px; margin-bottom: 20px;"><span style=3D"font-s=
+ize: 24px; color:#252424">Microsoft Teams -kokous</span>
+</div>
+<div style=3D"margin-bottom: 20px;">
+<div style=3D"margin-top: 0px; margin-bottom: 0px; font-weight: bold"><span=
+ style=3D"font-size: 14px; color:#252424">Liity tietokoneella tai mobiiliso=
+velluksella</span>
+</div>
+<a class=3D"me-email-headline" style=3D"font-size: 14px;font-family:'Segoe =
+UI Semibold','Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif;text-de=
+coration: underline;color: #6264a7;" href=3D"https://teams.microsoft.com/l/=
+meetup-join/19%3ameeting_MHU5NmI5ZGAtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy%4=
+0thread.v2/0?context=3D%7b%33Tid%22%3a%222fd0c1c5-28e1-40c4-9f0d-a0363ca80a=
+3c%22%2c%22Oid%22%3a%2214464d09-ceb8-458c-a61c-717f1e5c66c5%22%7d" target=
+=3D"_blank" rel=3D"noreferrer noopener">Liity
+ kokoukseen napsauttamalla t=E4t=E4</a> </div>
+<div style=3D"margin-bottom: 24px;margin-top: 20px;"><a class=3D"me-email-l=
+ink" style=3D"font-size: 14px;text-decoration: underline;color: #6264a7;fon=
+t-family:'Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif;" target=3D=
+"_blank" href=3D"https://aka.ms/JoinTeamsMeeting" rel=3D"noreferrer noopene=
+r">Lis=E4tietoja</a>
+ | <a class=3D"me-email-link" style=3D"font-size: 14px;text-decoration: und=
+erline;color: #6264a7;font-family:'Segoe UI','Helvetica Neue',Helvetica,Ari=
+al,sans-serif;" target=3D"_blank" href=3D"https://teams.microsoft.com/meeti=
+ngOptions/?organizerId=3D14464d09-ceb8-458c-a61c-717f1e5c66c5&amp;tenantId=
+=3D2fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c&amp;threadId=3D19_meeting_MGU5NmI2Z=
+GYtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy@thread.v2&amp;messageId=3D0&amp;lan=
+guage=3Dfi-FI" rel=3D"noreferrer noopener">
+Kokousasetukset</a> </div>
+</div>
+<div style=3D"font-size: 14px; margin-bottom: 4px;font-family:'Segoe UI','H=
+elvetica Neue',Helvetica,Arial,sans-serif;">
+</div>
+<div style=3D"font-size: 12px;"></div>
+<div style=3D"width:100%;height: 20px;"><span style=3D"white-space:nowrap;c=
+olor:#5F5F5F;opacity:.36;">________________________________________________=
+________________________________</span>
+</div>
+</div>
+</body>
+</html>
+
+--_000_HE1PR0802MB228346BE1576FEAB8A7F32328EF30HE1PR0802MB2283_
+Content-Type: text/calendar; charset="utf-8"; method=REQUEST
+Content-Transfer-Encoding: base64
+
+QkVHSU46VkNBTEVOREFSCk1FVEhPRDpSRVFVRVNUClBST0RJRDpNaWNyb3NvZnQgRXhjaGFuZ2Ug
+U2VydmVyIDIwMTAKVkVSU0lPTjoyLjAKQkVHSU46VlRJTUVaT05FClRaSUQ6RkxFIFN0YW5kYXJk
+IFRpbWUKQkVHSU46U1RBTkRBUkQKRFRTVEFSVDoxNjAxMDEwMVQwNDAwMDAKVFpPRkZTRVRGUk9N
+OiswMzAwClRaT0ZGU0VUVE86KzAyMDAKUlJVTEU6RlJFUT1ZRUFSTFk7SU5URVJWQUw9MTtCWURB
+WT0tMVNVO0JZTU9OVEg9MTAKRU5EOlNUQU5EQVJECkJFR0lOOkRBWUxJR0hUCkRUU1RBUlQ6MTYw
+MTAxMDFUMDMwMDAwClRaT0ZGU0VURlJPTTorMDIwMApUWk9GRlNFVFRPOiswMzAwClJSVUxFOkZS
+RVE9WUVBUkxZO0lOVEVSVkFMPTE7QllEQVk9LTFTVTtCWU1PTlRIPTMKRU5EOkRBWUxJR0hUCkVO
+RDpWVElNRVpPTkUKQkVHSU46VkVWRU5UCk9SR0FOSVpFUjtDTj1NYXJnZTptYWlsdG86bWFyZ2VA
+ZXhhbXBsZS5jb20KQVRURU5ERUU7Uk9MRT1SRVEtUEFSVElDSVBBTlQ7UEFSVFNUQVQ9TkVFRFMt
+QUNUSU9OO1JTVlA9VFJVRTtDTj1iYXJ0QGUKIGV4YW1wbGUuY29tOm1haWx0bzpiYXJ0QGV4YW1w
+bGUuY29tCkFUVEVOREVFO1JPTEU9UkVRLVBBUlRJQ0lQQU5UO1BBUlRTVEFUPU5FRURTLUFDVElP
+TjtSU1ZQPVRSVUU7Q049aG9tZXJAZXgKIGFtcGxlLmNvbTptYWlsdG86aG9tZXJAZXhhbXBsZS5j
+b20KCkRFU0NSSVBUSU9OO0xBTkdVQUdFPWVuLVVTOlxuXG5cbl9fX19fX19fX19fX19fX19fX19f
+X19fX19fX19fX19fX19fX19fX19fXwogX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19f
+X19fX19cbk1pY3Jvc29mdCBUZWFtcyAta29rb3VzXG5MaWl0eSB0aWUKIHRva29uZWVsbGEgdGFp
+IG1vYmlpbGlzb3ZlbGx1a3NlbGxhXG5MaWl0eSBrb2tvdWtzZWVuIG5hcHNhdXR0YW1hbGxhIHTD
+pHQKIMOkPGh0dHBzOi8vdGVhbXMubWljcm9zb2Z0LmNvbS9sL21lZXR1cC1qb2luLzE5JTNhbWVl
+dGluZ19NR1U1Tm1JMlpHWXRPV1ptCiBPQzAwWTJabUxXSmxPVEl0TmpVeE5qQTVZalV5WVRZeSU0
+MHRocmVhZC52Mi8wP2NvbnRleHQ9JTdiJTIyVGlkJTIyJTNhJTIyMgogZmQwYzFjNS0yOGUxLTQw
+YzQtOWYwZC1hMDM2M2NhODBhM2MlMjIlMmMlMjJPaWQlMjIlM2ElMjIxNDQ2NGQwOS1jZWI4LTQ1
+OGMKIC1hNjFjLTcxN2YxZTVjNjZjNSUyMiU3ZD5cbkxpc8OkdGlldG9qYTxodHRwczovL2FrYS5t
+cy9Kb2luVGVhbXNNZWV0aW5nPiB8CiAgS29rb3VzYXNldHVrc2V0PGh0dHBzOi8vdGVhbXMubWlj
+cm9zb2Z0LmNvbS9tZWV0aW5nT3B0aW9ucy8/b3JnYW5pemVySWQ9MQogNDQ2NGQwOS1jZWI4LTQ1
+OGMtYTYxYy03MTdmMWU1YzY2YzUmdGVuYW50SWQ9MmZkMGMxYzUtMjhlMS00MGM0LTlmMGQtYTAz
+NjMKIGNhODBhM2MmdGhyZWFkSWQ9MTlfbWVldGluZ19NR1U1Tm1JMlpHWXRPV1ptT0MwMFkyWm1M
+V0psT1RJdE5qVXhOakE1WWpVeVlUCiBZeUB0aHJlYWQudjImbWVzc2FnZUlkPTAmbGFuZ3VhZ2U9
+ZmktRkk+XG5fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fXwogX19fX19fX19fX19fX19f
+X19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fXG4KVUlEOjA1NjAwMDAwODIwMEUwMDA3
+NEM1QjcxMDFBODJFMDA4MDAwMDAwMDAxQUY4NEM2NENCQzhENjAxMDAwMDAwMDAwMDAwMDAwCiAw
+MTAwMDAwMDA0MDNCNUFDMTBBMEVCNDQ0QTk0N0QyQjQ5OUE0Qjk4QwpTVU1NQVJZO0xBTkdVQUdF
+PWVuLVVTOlRlYW1zIG1lZXRpbmcKRFRTVEFSVDtUWklEPUZMRSBTdGFuZGFyZCBUaW1lOjIwMjAx
+MjAyVDE5MDAwMApEVEVORDtUWklEPUZMRSBTdGFuZGFyZCBUaW1lOjIwMjAxMjAyVDIxMDAwMApD
+TEFTUzpQVUJMSUMKUFJJT1JJVFk6NQpEVFNUQU1QOjIwMjAxMjAyVDE2NTEzNFoKVFJBTlNQOk9Q
+QVFVRQpTVEFUVVM6Q09ORklSTUVEClNFUVVFTkNFOjAKTE9DQVRJT047TEFOR1VBR0U9ZW4tVVM6
+ClgtTUlDUk9TT0ZULUNETy1BUFBULVNFUVVFTkNFOjAKWC1NSUNST1NPRlQtQ0RPLU9XTkVSQVBQ
+VElEOjIxMTg5MTUzNTQKWC1NSUNST1NPRlQtQ0RPLUJVU1lTVEFUVVM6VEVOVEFUSVZFClgtTUlD
+Uk9TT0ZULUNETy1JTlRFTkRFRFNUQVRVUzpCVVNZClgtTUlDUk9TT0ZULUNETy1BTExEQVlFVkVO
+VDpGQUxTRQpYLU1JQ1JPU09GVC1DRE8tSU1QT1JUQU5DRToxClgtTUlDUk9TT0ZULUNETy1JTlNU
+VFlQRTowClgtTUlDUk9TT0ZULVNLWVBFVEVBTVNNRUVUSU5HVVJMOmh0dHBzOi8vdGVhbXMubWlj
+cm9zb2Z0LmNvbS9sL21lZXR1cC1qb2luLwogMTklM2FtZWV0aW5nX01HVTVObUkyWkdZdE9XWm1P
+QzAwWTJabUxXSmxPVEl0TmpVeE5qQTVZalV5WVRZeSU0MHRocmVhZC52Mi8KIDA/Y29udGV4dD0l
+N2IlMjJUaWQlMjIlM2ElMjIyZmQwYzFjNS0xOGUxLTQwYzQtOWYwZC1hMDM2M2NhODBhM2MlMjIl
+MmMlMjJPCiBpZCUyMiUzYSUyMjE0NDY0ZDA5LWNlYjgtNDU4Yy1hNjFjLTcxN2YxZTVjNjZjNSUy
+MiU3ZApYLU1JQ1JPU09GVC1TQ0hFRFVMSU5HU0VSVklDRVVQREFURVVSTDpodHRwczovL3NjaGVk
+dWxlci50ZWFtcy5taWNyb3NvZnQuY28KIG0vdGVhbXMvMmZkMGMxYzUtMjhlMS00MGM0LTlmMGQt
+YWIzNjNjYTgwYTNjLzE0NDY0ZDA5LWNlYjgtNDU4Yy1hNjFjLTcxN2YxCiBlNWM2NmM1LzE5X21l
+ZXRpbmdfTUdVNU5tSTJaR1l0T1dabU9DMDBZMlptTFdKbE9USXROalV4TmpBNVlqVXlZVFl5QHRo
+cmVhZAogLnYyLzAKWC1NSUNST1NPRlQtU0tZUEVURUFNU1BST1BFUlRJRVM6eyJjaWQiOiIxOTpt
+ZWV0aW5nX01HVTVObUkyWkdZdE9XWm1PQzAwWTJaCiBtTFdKbE9USXROalV4TmpBNVlqVXlZVFl5
+QHRocmVhZC52MiJcLCJyaWQiOjBcLCJtaWQiOjBcLCJ1aWQiOm51bGxcLCJwcml2YQogdGUiOnRy
+dWVcLCJ0eXBlIjowfQpYLU1JQ1JPU09GVC1PTkxJTkVNRUVUSU5HQ09ORkxJTks6Y29uZjpzaXA6
+bWFyZ2VAZXhhbXBsZS5jb21cO2dydXVcO29wCiBhcXVlPWFwcDpjb25mOmZvY3VzOmlkOnRlYW1z
+OjI6MCExOTptZWV0aW5nX01HVTVNbUkyWkdZdE9XWm1PQzAwWTJabUxXSmxPVAogSXROalV4TmpB
+NVlqVXlZVFl5LXRocmVhZC52MiExNDQ2NGQwOWNlYjg0NThjYTYxYzcxN2YxZTVjNjZjNSEyZmQw
+YzFjNTI4ZTEKIDQwYzQ5ZjBkYTAzNjNjYTgwYTNjClgtTUlDUk9TT0ZULU9OTElORU1FRVRJTkdJ
+TkZPUk1BVElPTjp7Ik9ubGluZU1lZXRpbmdDaGFubmVsSWQiOm51bGxcLCJPbmxpbgogZU1lZXRp
+bmdQcm92aWRlciI6M30KWC1NSUNST1NPRlQtRE9OT1RGT1JXQVJETUVFVElORzpGQUxTRQpYLU1J
+Q1JPU09GVC1ESVNBTExPVy1DT1VOVEVSOkZBTFNFClgtTUlDUk9TT0ZULUxPQ0FUSU9OUzpbXQpC
+RUdJTjpWQUxBUk0KREVTQ1JJUFRJT046UkVNSU5ERVIKVFJJR0dFUjtSRUxBVEVEPVNUQVJUOi1Q
+VDE1TQpBQ1RJT046RElTUExBWQpFTkQ6VkFMQVJNCkVORDpWRVZFTlQKRU5EOlZDQUxFTkRBUgo=
+
+--_000_HE1PR0802MB228346BE1576FEAB8A7F32328EF30HE1PR0802MB2283_--
diff --git a/comm/calendar/test/browser/invitations/data/update-major.eml b/comm/calendar/test/browser/invitations/data/update-major.eml
new file mode 100644
index 0000000000..04754798b2
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/update-major.eml
@@ -0,0 +1,78 @@
+MIME-Version: 1.0
+Content-Transfer-Encoding: binary
+Content-Type: multipart/mixed; boundary="_----------=_1647458162153312762582"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+To: receiver@example.com
+Subject: Update Major
+From: Sender <sender@example.com>
+
+This is a multi-part message in MIME format.
+
+--_----------=_1647458162153312762582
+MIME-Version: 1.0
+Content-Transfer-Encoding: binary
+Content-Type: multipart/alternative; boundary="_----------=_1647458162153312762583"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+This is a multi-part message in MIME format.
+
+--_----------=_1647458162153312762583
+MIME-Version: 1.0
+Content-Disposition: inline
+Content-Length: 227
+Content-Transfer-Encoding: binary
+Content-Type: text/plain; charset="utf-8"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+Single Event
+
+When:
+ Wed, Mar 16 2022
+ 11:00 - 12:00 AST
+Where:
+ Somewhere
+
+--_----------=_1647458162153312762583
+MIME-Version: 1.0
+Content-Disposition: inline
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/calendar; charset="utf-8"; method="REQUEST"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+BEGIN:VCALENDAR
+VERSION:2.0
+METHOD:REQUEST
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:02e79b96
+SEQUENCE:2
+DTSTAMP:20220316T191602Z
+CREATED:20220316T191532Z
+DTSTART:20220316T050000Z
+DTEND:20220316T053000Z
+DURATION:PT1H
+PRIORITY:0
+SUMMARY:Single Event
+DESCRIPTION:An event invitation.
+LOCATION:Somewhere
+STATUS:CONFIRMED
+TRANSP:OPAQUE
+CLASS:PUBLIC
+ORGANIZER;CN=3DSender;
+ EMAIL=3Dsender@example.com:mailto:sender@example.com
+ATTENDEE;CN=3DSender;
+ EMAIL=3Dsender@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DACCEPTED;RSVP=3DFALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=3Dreceiver@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:other@example.com
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--_----------=_1647458162153312762583--
diff --git a/comm/calendar/test/browser/invitations/data/update-minor.eml b/comm/calendar/test/browser/invitations/data/update-minor.eml
new file mode 100644
index 0000000000..afeb8e9ba0
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/update-minor.eml
@@ -0,0 +1,78 @@
+MIME-Version: 1.0
+Content-Transfer-Encoding: binary
+Content-Type: multipart/mixed; boundary="_----------=_1647458162153312762582"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+To: receiver@example.com
+Subject: Update Minor
+From: Sender <sender@example.com>
+
+This is a multi-part message in MIME format.
+
+--_----------=_1647458162153312762582
+MIME-Version: 1.0
+Content-Transfer-Encoding: binary
+Content-Type: multipart/alternative; boundary="_----------=_1647458162153312762583"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+This is a multi-part message in MIME format.
+
+--_----------=_1647458162153312762583
+MIME-Version: 1.0
+Content-Disposition: inline
+Content-Length: 227
+Content-Transfer-Encoding: binary
+Content-Type: text/plain; charset="utf-8"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+Single Event
+
+When:
+ Wed, Mar 16 2022
+ 11:00 - 12:00 AST
+Where:
+ Somewhere
+
+--_----------=_1647458162153312762583
+MIME-Version: 1.0
+Content-Disposition: inline
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/calendar; charset="utf-8"; method="REQUEST"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+BEGIN:VCALENDAR
+VERSION:2.0
+METHOD:REQUEST
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:02e79b96
+SEQUENCE:0
+DTSTAMP:20220318T191602Z
+CREATED:20220316T191532Z
+DTSTART:20220316T110000Z
+DTEND:20220316T113000Z
+DURATION:PT1H
+PRIORITY:0
+SUMMARY:Updated Event
+DESCRIPTION:Updated description.
+LOCATION:Updated location
+STATUS:CONFIRMED
+TRANSP:OPAQUE
+CLASS:PUBLIC
+ORGANIZER;CN=3DSender;
+ EMAIL=3Dsender@example.com:mailto:sender@example.com
+ATTENDEE;CN=3DSender;
+ EMAIL=3Dsender@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DACCEPTED;RSVP=3DFALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=3Dreceiver@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:other@example.com
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:Updated description.
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--_----------=_1647458162153312762583--
diff --git a/comm/calendar/test/browser/invitations/head.js b/comm/calendar/test/browser/invitations/head.js
new file mode 100644
index 0000000000..24835c3021
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/head.js
@@ -0,0 +1,942 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Common functions for the imip-bar tests.
+ *
+ * Note that these tests are heavily tied to the .eml files found in the data
+ * folder.
+ */
+
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalItipDefaultEmailTransport } = ChromeUtils.import(
+ "resource:///modules/CalItipEmailTransport.jsm"
+);
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+registerCleanupFunction(async () => {
+ // Some tests that open new windows don't return focus to the main window
+ // in a way that satisfies mochitest, and the test times out.
+ Services.focus.focusedWindow = window;
+ document.body.focus();
+});
+
+class EmailTransport extends CalItipDefaultEmailTransport {
+ sentItems = [];
+
+ sentMsgs = [];
+
+ getMsgSend() {
+ let { sentMsgs } = this;
+ return {
+ sendMessageFile(
+ userIdentity,
+ accountKey,
+ composeFields,
+ messageFile,
+ deleteSendFileOnCompletion,
+ digest,
+ deliverMode,
+ msgToReplace,
+ listener,
+ statusFeedback,
+ smtpPassword
+ ) {
+ sentMsgs.push({
+ userIdentity,
+ accountKey,
+ composeFields,
+ messageFile,
+ deleteSendFileOnCompletion,
+ digest,
+ deliverMode,
+ msgToReplace,
+ listener,
+ statusFeedback,
+ smtpPassword,
+ });
+ },
+ };
+ }
+
+ sendItems(recipients, itipItem, fromAttendee) {
+ this.sentItems.push({ recipients, itipItem, fromAttendee });
+ return super.sendItems(recipients, itipItem, fromAttendee);
+ }
+
+ reset() {
+ this.sentItems = [];
+ this.sentMsgs = [];
+ }
+}
+
+async function openMessageFromFile(file) {
+ let fileURL = Services.io
+ .newFileURI(file)
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize();
+
+ let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ window.openDialog(
+ "chrome://messenger/content/messageWindow.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ fileURL
+ );
+ let win = await winPromise;
+ await BrowserTestUtils.waitForEvent(win, "MsgLoaded");
+ await TestUtils.waitForCondition(() => Services.focus.activeWindow == win);
+ return win;
+}
+
+/**
+ * Opens an iMIP message file and waits for the imip-bar to appear.
+ *
+ * @param {nsIFile} file
+ * @returns {Window}
+ */
+async function openImipMessage(file) {
+ let win = await openMessageFromFile(file);
+ let aboutMessage = win.document.getElementById("messageBrowser").contentWindow;
+ let imipBar = aboutMessage.document.getElementById("imip-bar");
+ await TestUtils.waitForCondition(() => !imipBar.collapsed, "imip-bar shown");
+
+ if (Services.prefs.getBoolPref("calendar.itip.newInvitationDisplay")) {
+ // CalInvitationDisplay.show() does some async activities before the panel is added.
+ await TestUtils.waitForCondition(
+ () =>
+ win.document
+ .getElementById("messageBrowser")
+ .contentDocument.querySelector("calendar-invitation-panel"),
+ "calendar-invitation-panel shown"
+ );
+ }
+ return win;
+}
+
+/**
+ * Clicks on one of the imip-bar action buttons.
+ *
+ * @param {Window} win
+ * @param {string} id
+ */
+async function clickAction(win, id) {
+ let aboutMessage = win.document.getElementById("messageBrowser").contentWindow;
+ let action = aboutMessage.document.getElementById(id);
+ await TestUtils.waitForCondition(() => !action.hidden, `button "#${id}" shown`);
+
+ EventUtils.synthesizeMouseAtCenter(action, {}, aboutMessage);
+ await TestUtils.waitForCondition(() => action.hidden, `button "#${id}" hidden`);
+}
+
+/**
+ * Clicks on one of the imip-bar actions from a dropdown menu.
+ *
+ * @param {Window} win The window the imip message is opened in.
+ * @param {string} buttonId The id of the <toolbarbutton> containing the menu.
+ * @param {string} actionId The id of the menu item to click.
+ */
+async function clickMenuAction(win, buttonId, actionId) {
+ let aboutMessage = win.document.getElementById("messageBrowser").contentWindow;
+ let actionButton = aboutMessage.document.getElementById(buttonId);
+ await TestUtils.waitForCondition(() => !actionButton.hidden, `"${buttonId}" shown`);
+
+ let actionMenu = actionButton.querySelector("menupopup");
+ let menuShown = BrowserTestUtils.waitForEvent(actionMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(actionButton.querySelector("dropmarker"), {}, aboutMessage);
+ await menuShown;
+ actionMenu.activateItem(aboutMessage.document.getElementById(actionId));
+ await TestUtils.waitForCondition(() => actionButton.hidden, `action menu "#${buttonId}" hidden`);
+}
+
+const unpromotedProps = ["location", "description", "sequence", "x-moz-received-dtstamp"];
+
+/**
+ * An object where the keys are paths/selectors and the values are the values
+ * we expect to encounter.
+ *
+ * @typedef {object} Comparable
+ */
+
+/**
+ * Compares the paths specified in the expected object against the provided
+ * actual object.
+ *
+ * @param {object} actual This is expected to be a calIEvent or calIAttendee but
+ * can also be an array of both etc.
+ * @param {Comparable} expected
+ */
+function compareProperties(actual, expected, prefix = "") {
+ Assert.equal(typeof actual, "object", `${prefix || "provided value"} is an object`);
+ for (let [key, value] of Object.entries(expected)) {
+ if (key.includes(".")) {
+ let keys = key.split(".");
+ let head = keys[0];
+ let tail = keys.slice(1).join(".");
+ compareProperties(actual[head], { [tail]: value }, [prefix, head].filter(k => k).join("."));
+ continue;
+ }
+
+ let path = [prefix, key].filter(k => k).join(".");
+ let actualValue = unpromotedProps.includes(key) ? actual.getProperty(key) : actual[key];
+ Assert.equal(actualValue, value, `property "${path}" is "${value}"`);
+ }
+}
+
+/**
+ * Compares the text contents of the selectors specified on the inviatation panel
+ * to the expected value for each.
+ *
+ * @param {ShadowRoot} root The invitation panel's ShadowRoot instance.
+ * @param {Comparable} expected
+ */
+function compareShownPanelValues(root, expected) {
+ for (let [key, value] of Object.entries(expected)) {
+ value = Array.isArray(value) ? value.join("") : value;
+ Assert.equal(
+ root.querySelector(key).textContent.trim(),
+ value,
+ `property "${key}" is "${value}"`
+ );
+ }
+}
+
+/**
+ * Clicks on one of the invitation panel action buttons.
+ *
+ * @param {Window} panel
+ * @param {string} id
+ * @param {boolean} sendResponse
+ */
+async function clickPanelAction(panel, id, sendResponse = true) {
+ let promise = BrowserTestUtils.promiseAlertDialogOpen(sendResponse ? "accept" : "cancel");
+ let button = panel.shadowRoot.getElementById(id);
+ EventUtils.synthesizeMouseAtCenter(button, {}, panel.ownerGlobal);
+ await promise;
+ await BrowserTestUtils.waitForEvent(panel.ownerGlobal, "onItipItemActionFinished");
+}
+
+/**
+ * Tests that an attempt to reply to the organizer of the event with the correct
+ * details occurred.
+ *
+ * @param {EmailTransport} transport
+ * @param {nsIdentity} identity
+ * @param {string} partStat
+ */
+async function doReplyTest(transport, identity, partStat) {
+ info("Verifying the attempt to send a response uses the correct data");
+ Assert.equal(transport.sentItems.length, 1, "itip subsystem attempted to send a response");
+ compareProperties(transport.sentItems[0], {
+ "recipients.0.id": "mailto:sender@example.com",
+ "itipItem.responseMethod": "REPLY",
+ "fromAttendee.id": "mailto:receiver@example.com",
+ "fromAttendee.participationStatus": partStat,
+ });
+
+ // The itipItem is used to generate the iTIP data in the message body.
+ info("Verifying the reply calItipItem attendee list");
+ let replyItem = transport.sentItems[0].itipItem.getItemList()[0];
+ let replyAttendees = replyItem.getAttendees();
+ Assert.equal(replyAttendees.length, 1, "reply has one attendee");
+ compareProperties(replyAttendees[0], {
+ id: "mailto:receiver@example.com",
+ participationStatus: partStat,
+ });
+
+ info("Verifying the call to the message subsystem");
+ Assert.equal(transport.sentMsgs.length, 1, "transport sent 1 message");
+ compareProperties(transport.sentMsgs[0], {
+ userIdentity: identity,
+ "composeFields.from": "receiver@example.com",
+ "composeFields.to": "Sender <sender@example.com>",
+ });
+ Assert.ok(transport.sentMsgs[0].messageFile.exists(), "message file was created");
+}
+
+/**
+ * @typedef {object} ImipBarActionTestConf
+ *
+ * @property {calICalendar} calendar The calendar used for the test.
+ * @property {calIItipTranport} transport The transport used for the test.
+ * @property {nsIIdentity} identity The identity expected to be used to
+ * send the reply.
+ * @property {boolean} isRecurring Indicates whether to treat the event as a
+ * recurring event or not.
+ * @property {string} partStat The participationStatus of the receiving user to
+ * expect.
+ * @property {boolean} noReply If true, do not expect an attempt to send a reply.
+ * @property {boolean} noSend If true, expect the reply attempt to stop after the
+ * user is prompted.
+ * @property {boolean} isMajor For update tests indicates if the changes expected
+ * are major or minor.
+ */
+
+/**
+ * Test the properties of an event created from the imip-bar and optionally, the
+ * attempt to send a reply.
+ *
+ * @param {ImipBarActionTestConf} conf
+ * @param {calIEvent|calIEvent[]} item
+ */
+async function doImipBarActionTest(conf, event) {
+ let { calendar, transport, identity, partStat, isRecurring, noReply, noSend } = conf;
+ let events = [event];
+ let startDates = ["20220316T110000Z"];
+ let endDates = ["20220316T113000Z"];
+
+ if (isRecurring) {
+ startDates = [...startDates, "20220317T110000Z", "20220318T110000Z"];
+ endDates = [...endDates, "20220317T113000Z", "20220318T113000Z"];
+ events = event.parentItem.recurrenceInfo.getOccurrences(
+ cal.createDateTime("19700101"),
+ cal.createDateTime("30000101"),
+ Infinity
+ );
+ Assert.equal(events.length, 3, "reccurring event has 3 occurrences");
+ }
+
+ info("Verifying relevant properties of each event occurrence");
+ for (let [index, occurrence] of events.entries()) {
+ compareProperties(occurrence, {
+ id: "02e79b96",
+ title: isRecurring ? "Repeat Event" : "Single Event",
+ "calendar.name": calendar.name,
+ ...(isRecurring ? { "recurrenceId.icalString": startDates[index] } : {}),
+ "startDate.icalString": startDates[index],
+ "endDate.icalString": endDates[index],
+ description: "An event invitation.",
+ location: "Somewhere",
+ sequence: "0",
+ "x-moz-received-dtstamp": "20220316T191602Z",
+ "organizer.id": "mailto:sender@example.com",
+ status: "CONFIRMED",
+ });
+
+ // Alarms should be ignored.
+ Assert.equal(
+ occurrence.getAlarms().length,
+ 0,
+ `${isRecurring ? "occurrence" : "event"} has no reminders`
+ );
+
+ info("Verifying attendee list and participation status");
+ let attendees = occurrence.getAttendees();
+ compareProperties(attendees, {
+ "0.id": "mailto:sender@example.com",
+ "0.participationStatus": "ACCEPTED",
+ "1.participationStatus": partStat,
+ "1.id": "mailto:receiver@example.com",
+ "2.id": "mailto:other@example.com",
+ "2.participationStatus": "NEEDS-ACTION",
+ });
+ }
+
+ if (noReply) {
+ Assert.equal(
+ transport.sentItems.length,
+ 0,
+ "itip subsystem did not attempt to send a response"
+ );
+ }
+ if (noReply || noSend) {
+ Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem");
+ return;
+ }
+ await doReplyTest(transport, identity, partStat);
+}
+
+/**
+ * Tests the recognition and application of a minor update to an existing event.
+ * An update is considered minor if the SEQUENCE property has not changed but
+ * the DTSTAMP has.
+ *
+ * @param {ImipBarActionTestConf} conf
+ */
+async function doMinorUpdateTest(conf) {
+ let { transport, calendar, partStat, isRecurring } = conf;
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ let prevEventIcs = event.icalString;
+
+ transport.reset();
+
+ let updatePath = isRecurring ? "data/repeat-update-minor.eml" : "data/update-minor.eml";
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath(updatePath)));
+ let aboutMessage = win.document.getElementById("messageBrowser").contentWindow;
+ let updateButton = aboutMessage.document.getElementById("imipUpdateButton");
+ Assert.ok(!updateButton.hidden, `#${updateButton.id} button shown`);
+ EventUtils.synthesizeMouseAtCenter(updateButton, {}, aboutMessage);
+
+ await TestUtils.waitForCondition(async () => {
+ event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ return event.icalString != prevEventIcs;
+ }, "event updated");
+
+ await BrowserTestUtils.closeWindow(win);
+
+ let events = [event];
+ let startDates = ["20220316T110000Z"];
+ let endDates = ["20220316T113000Z"];
+ if (isRecurring) {
+ startDates = [...startDates, "20220317T110000Z", "20220318T110000Z"];
+ endDates = [...endDates, "20220317T113000Z", "20220318T113000Z"];
+ events = event.recurrenceInfo.getOccurrences(
+ cal.createDateTime("19700101"),
+ cal.createDateTime("30000101"),
+ Infinity
+ );
+ Assert.equal(events.length, 3, "reccurring event has 3 occurrences");
+ }
+
+ info("Verifying relevant properties of each event occurrence");
+ for (let [index, occurrence] of events.entries()) {
+ compareProperties(occurrence, {
+ id: "02e79b96",
+ title: "Updated Event",
+ "calendar.name": calendar.name,
+ ...(isRecurring ? { "recurrenceId.icalString": startDates[index] } : {}),
+ "startDate.icalString": startDates[index],
+ "endDate.icalString": endDates[index],
+ description: "Updated description.",
+ location: "Updated location",
+ sequence: "0",
+ "x-moz-received-dtstamp": "20220318T191602Z",
+ "organizer.id": "mailto:sender@example.com",
+ status: "CONFIRMED",
+ });
+
+ // Note: It seems we do not keep the order of the attendees list for updates.
+ let attendees = occurrence.getAttendees();
+ compareProperties(attendees, {
+ "0.id": "mailto:sender@example.com",
+ "0.participationStatus": "ACCEPTED",
+ "1.id": "mailto:other@example.com",
+ "1.participationStatus": "NEEDS-ACTION",
+ "2.participationStatus": partStat,
+ "2.id": "mailto:receiver@example.com",
+ });
+ }
+
+ Assert.equal(transport.sentItems.length, 0, "itip subsystem did not attempt to send a response");
+ Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem");
+ await calendar.deleteItem(event);
+}
+
+const actionIds = {
+ single: {
+ button: {
+ ACCEPTED: "imipAcceptButton",
+ TENTATIVE: "imipTentativeButton",
+ DECLINED: "imipDeclineButton",
+ },
+ noReply: {
+ ACCEPTED: "imipAcceptButton_AcceptDontSend",
+ TENTATIVE: "imipTentativeButton_TentativeDontSend",
+ DECLINED: "imipDeclineButton_DeclineDontSend",
+ },
+ },
+ recurring: {
+ button: {
+ ACCEPTED: "imipAcceptRecurrencesButton",
+ TENTATIVE: "imipTentativeRecurrencesButton",
+ DECLINED: "imipDeclineRecurrencesButton",
+ },
+ noReply: {
+ ACCEPTED: "imipAcceptRecurrencesButton_AcceptDontSend",
+ TENTATIVE: "imipTentativeRecurrencesButton_TentativeDontSend",
+ DECLINED: "imipDeclineRecurrencesButton_DeclineDontSend",
+ },
+ },
+};
+
+/**
+ * Tests the recognition and application of a major update to an existing event.
+ * An update is considered major if the SEQUENCE property has changed. For major
+ * updates, the imip-bar prompts the user to re-confirm their attendance.
+ *
+ * @param {ImipBarActionTestConf} conf
+ */
+async function doMajorUpdateTest(conf) {
+ let { transport, identity, calendar, partStat, isRecurring, noReply } = conf;
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ let prevEventIcs = event.icalString;
+
+ transport.reset();
+
+ let updatePath = isRecurring ? "data/repeat-update-major.eml" : "data/update-major.eml";
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath(updatePath)));
+ let actions = isRecurring ? actionIds.recurring : actionIds.single;
+ if (noReply) {
+ let { button, noReply } = actions;
+ await clickMenuAction(win, button[partStat], noReply[partStat]);
+ } else {
+ await clickAction(win, actions.button[partStat]);
+ }
+
+ await TestUtils.waitForCondition(async () => {
+ event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ return event.icalString != prevEventIcs;
+ }, "event updated");
+
+ await BrowserTestUtils.closeWindow(win);
+
+ if (noReply) {
+ Assert.equal(
+ transport.sentItems.length,
+ 0,
+ "itip subsystem did not attempt to send a response"
+ );
+ Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem");
+ } else {
+ await doReplyTest(transport, identity, partStat);
+ }
+
+ let events = [event];
+ let startDates = ["20220316T050000Z"];
+ let endDates = ["20220316T053000Z"];
+ if (isRecurring) {
+ startDates = [...startDates, "20220317T050000Z", "20220318T050000Z"];
+ endDates = [...endDates, "20220317T053000Z", "20220318T053000Z"];
+ events = event.recurrenceInfo.getOccurrences(
+ cal.createDateTime("19700101"),
+ cal.createDateTime("30000101"),
+ Infinity
+ );
+ Assert.equal(events.length, 3, "reccurring event has 3 occurrences");
+ }
+
+ for (let [index, occurrence] of events.entries()) {
+ compareProperties(occurrence, {
+ id: "02e79b96",
+ title: isRecurring ? "Repeat Event" : "Single Event",
+ "calendar.name": calendar.name,
+ ...(isRecurring ? { "recurrenceId.icalString": startDates[index] } : {}),
+ "startDate.icalString": startDates[index],
+ "endDate.icalString": endDates[index],
+ description: "An event invitation.",
+ location: "Somewhere",
+ sequence: "2",
+ "x-moz-received-dtstamp": "20220316T191602Z",
+ "organizer.id": "mailto:sender@example.com",
+ status: "CONFIRMED",
+ });
+
+ let attendees = occurrence.getAttendees();
+ compareProperties(attendees, {
+ "0.id": "mailto:sender@example.com",
+ "0.participationStatus": "ACCEPTED",
+ "1.id": "mailto:other@example.com",
+ "1.participationStatus": "NEEDS-ACTION",
+ "2.participationStatus": partStat,
+ "2.id": "mailto:receiver@example.com",
+ });
+ }
+ await calendar.deleteItem(event);
+}
+
+/**
+ * Tests the recognition and application of a minor update exception to an
+ * existing recurring event.
+ *
+ * @param {ImipBarActionTestConf} conf
+ */
+async function doMinorExceptionTest(conf) {
+ let { transport, calendar, partStat } = conf;
+ let recurrenceId = cal.createDateTime("20220317T110000Z");
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ let originalProps = {
+ id: "02e79b96",
+ "recurrenceId.icalString": "20220317T110000Z",
+ title: event.title,
+ "calendar.name": calendar.name,
+ "startDate.icalString": event.startDate.icalString,
+ "endDate.icalString": event.endDate.icalString,
+ description: event.getProperty("DESCRIPTION"),
+ location: event.getProperty("LOCATION"),
+ sequence: "0",
+ "x-moz-received-dtstamp": event.getProperty("x-moz-received-dtstamp"),
+ "organizer.id": "mailto:sender@example.com",
+ status: "CONFIRMED",
+ };
+
+ Assert.ok(
+ !event.recurrenceInfo.getExceptionFor(recurrenceId),
+ `no exception exists for ${recurrenceId}`
+ );
+
+ transport.reset();
+
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml")));
+ let aboutMessage = win.document.getElementById("messageBrowser").contentWindow;
+ let updateButton = aboutMessage.document.getElementById("imipUpdateButton");
+ Assert.ok(!updateButton.hidden, `#${updateButton.id} button shown`);
+ EventUtils.synthesizeMouseAtCenter(updateButton, {}, aboutMessage);
+
+ let exception;
+ await TestUtils.waitForCondition(async () => {
+ event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ exception = event.recurrenceInfo.getExceptionFor(recurrenceId);
+ return exception;
+ }, "event exception applied");
+
+ await BrowserTestUtils.closeWindow(win);
+
+ Assert.equal(transport.sentItems.length, 0, "itip subsystem did not attempt to send a response");
+ Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem");
+
+ info("Verifying relevant properties of the exception");
+ compareProperties(exception, {
+ id: "02e79b96",
+ "recurrenceId.icalString": "20220317T110000Z",
+ title: "Exception title",
+ "calendar.name": calendar.name,
+ "startDate.icalString": "20220317T110000Z",
+ "endDate.icalString": "20220317T113000Z",
+ description: "Exception description",
+ location: "Exception location",
+ sequence: "0",
+ "x-moz-received-dtstamp": "20220318T191602Z",
+ "organizer.id": "mailto:sender@example.com",
+ status: "CONFIRMED",
+ });
+
+ compareProperties(exception.getAttendees(), {
+ "0.id": "mailto:sender@example.com",
+ "0.participationStatus": "ACCEPTED",
+ "1.id": "mailto:other@example.com",
+ "1.participationStatus": "NEEDS-ACTION",
+ "2.id": "mailto:receiver@example.com",
+ "2.participationStatus": partStat,
+ });
+
+ let occurrences = event.recurrenceInfo.getOccurrences(
+ cal.createDateTime("19700101"),
+ cal.createDateTime("30000101"),
+ Infinity
+ );
+ Assert.equal(occurrences.length, 3, "reccurring event still has 3 occurrences");
+
+ info("Verifying relevant properties of the other occurrences");
+
+ let startDates = ["20220316T110000Z", "20220317T110000Z", "20220318T110000Z"];
+ let endDates = ["20220316T113000Z", "20220317T113000Z", "20220318T113000Z"];
+ for (let [index, occurrence] of occurrences.entries()) {
+ if (occurrence.startDate.compare(recurrenceId) == 0) {
+ continue;
+ }
+ compareProperties(occurrence, {
+ ...originalProps,
+ "recurrenceId.icalString": startDates[index],
+ "startDate.icalString": startDates[index],
+ "endDate.icalString": endDates[index],
+ });
+
+ let attendees = occurrence.getAttendees();
+ compareProperties(attendees, {
+ "0.id": "mailto:sender@example.com",
+ "0.participationStatus": "ACCEPTED",
+ "1.id": "mailto:receiver@example.com",
+ "1.participationStatus": partStat,
+ "2.id": "mailto:other@example.com",
+ "2.participationStatus": "NEEDS-ACTION",
+ });
+ }
+
+ await calendar.deleteItem(event);
+}
+
+/**
+ * Tests the recognition and application of a major update exception to an
+ * existing recurring event.
+ *
+ * @param {ImipBarActionTestConf} conf
+ */
+async function doMajorExceptionTest(conf) {
+ let { transport, identity, calendar, partStat, noReply } = conf;
+ let recurrenceId = cal.createDateTime("20220317T110000Z");
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ let originalProps = {
+ id: "02e79b96",
+ "recurrenceId.icalString": "20220317T110000Z",
+ title: event.title,
+ "calendar.name": calendar.name,
+ "startDate.icalString": event.startDate.icalString,
+ "endDate.icalString": event.endDate.icalString,
+ description: event.getProperty("DESCRIPTION"),
+ location: event.getProperty("LOCATION"),
+ sequence: "0",
+ "x-moz-received-dtstamp": event.getProperty("x-moz-received-dtstamp"),
+ "organizer.id": "mailto:sender@example.com",
+ status: "CONFIRMED",
+ };
+ let originalPartStat = event
+ .getAttendees()
+ .find(att => att.id == "mailto:receiver@example.com").participationStatus;
+
+ Assert.ok(
+ !event.recurrenceInfo.getExceptionFor(recurrenceId),
+ `no exception exists for ${recurrenceId}`
+ );
+
+ transport.reset();
+
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml")));
+ if (noReply) {
+ let { button, noReply } = actionIds.single;
+ await clickMenuAction(win, button[partStat], noReply[partStat]);
+ } else {
+ await clickAction(win, actionIds.single.button[partStat]);
+ }
+
+ let exception;
+ await TestUtils.waitForCondition(async () => {
+ event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ exception = event.recurrenceInfo.getExceptionFor(recurrenceId);
+ return exception;
+ }, "event exception applied");
+
+ await BrowserTestUtils.closeWindow(win);
+
+ if (noReply) {
+ Assert.equal(
+ transport.sentItems.length,
+ 0,
+ "itip subsystem did not attempt to send a response"
+ );
+ Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem");
+ } else {
+ await doReplyTest(transport, identity, partStat);
+ }
+
+ info("Verifying relevant properties of the exception");
+
+ compareProperties(exception, {
+ ...originalProps,
+ "startDate.icalString": "20220317T050000Z",
+ "endDate.icalString": "20220317T053000Z",
+ sequence: "2",
+ });
+
+ compareProperties(exception.getAttendees(), {
+ "0.id": "mailto:sender@example.com",
+ "0.participationStatus": "ACCEPTED",
+ "1.id": "mailto:other@example.com",
+ "1.participationStatus": "NEEDS-ACTION",
+ "2.id": "mailto:receiver@example.com",
+ "2.participationStatus": partStat,
+ });
+
+ let occurrences = event.recurrenceInfo.getOccurrences(
+ cal.createDateTime("19700101"),
+ cal.createDateTime("30000101"),
+ Infinity
+ );
+ Assert.equal(occurrences.length, 3, "reccurring event still has 3 occurrences");
+
+ info("Verifying relevant properties of the other occurrences");
+
+ let startDates = ["20220316T110000Z", "20220317T110000Z", "20220318T110000Z"];
+ let endDates = ["20220316T113000Z", "20220317T113000Z", "20220318T113000Z"];
+ for (let [index, occurrence] of occurrences.entries()) {
+ if (occurrence.startDate.icalString == "20220317T050000Z") {
+ continue;
+ }
+ compareProperties(occurrence, {
+ ...originalProps,
+ "recurrenceId.icalString": startDates[index],
+ "startDate.icalString": startDates[index],
+ "endDate.icalString": endDates[index],
+ });
+
+ let attendees = occurrence.getAttendees();
+ compareProperties(attendees, {
+ "0.id": "mailto:sender@example.com",
+ "0.participationStatus": "ACCEPTED",
+ "1.id": "mailto:receiver@example.com",
+ "1.participationStatus": originalPartStat,
+ "2.id": "mailto:other@example.com",
+ "2.participationStatus": "NEEDS-ACTION",
+ });
+ }
+
+ await calendar.deleteItem(event);
+}
+
+/**
+ * Test the properties of an event created from a minor or major exception where
+ * we have not added the original event and optionally, the attempt to send a
+ * reply.
+ *
+ * @param {ImipBarActionTestConf} conf
+ */
+async function doExceptionOnlyTest(conf) {
+ let { calendar, transport, identity, partStat, noReply, isMajor } = conf;
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 5, 1)).item;
+
+ // Exceptions are still created as recurring events.
+ Assert.ok(event != event.parentItem, "event created is a recurring event");
+ let occurrences = event.parentItem.recurrenceInfo.getOccurrences(
+ cal.createDateTime("10000101"),
+ cal.createDateTime("30000101"),
+ Infinity
+ );
+ Assert.equal(occurrences.length, 1, "parent item only has one occurrence");
+ Assert.ok(occurrences[0] == event, "occurrence is the event exception");
+
+ info("Verifying relevant properties of the event");
+ compareProperties(event, {
+ id: "02e79b96",
+ title: isMajor ? event.title : "Exception title",
+ "calendar.name": calendar.name,
+ "recurrenceId.icalString": "20220317T110000Z",
+ "startDate.icalString": isMajor ? "20220317T050000Z" : "20220317T110000Z",
+ "endDate.icalString": isMajor ? "20220317T053000Z" : "20220317T113000Z",
+ description: isMajor ? event.getProperty("DESCRIPTION") : "Exception description",
+ location: isMajor ? event.getProperty("LOCATION") : "Exception location",
+ sequence: isMajor ? "2" : "0",
+ "x-moz-received-dtstamp": isMajor
+ ? event.getProperty("x-moz-received-dtstamp")
+ : "20220318T191602Z",
+ "organizer.id": "mailto:sender@example.com",
+ status: "CONFIRMED",
+ });
+
+ // Alarms should be ignored.
+ Assert.equal(event.getAlarms().length, 0, "event has no reminders");
+
+ info("Verifying attendee list and participation status");
+ let attendees = event.getAttendees();
+ compareProperties(attendees, {
+ "0.id": "mailto:sender@example.com",
+ "0.participationStatus": "ACCEPTED",
+ "1.participationStatus": partStat,
+ "1.id": "mailto:receiver@example.com",
+ "2.id": "mailto:other@example.com",
+ "2.participationStatus": "NEEDS-ACTION",
+ });
+
+ if (noReply) {
+ Assert.equal(
+ transport.sentItems.length,
+ 0,
+ "itip subsystem did not attempt to send a response"
+ );
+ Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem");
+ } else {
+ await doReplyTest(transport, identity, partStat);
+ }
+ await calendar.deleteItem(event.parentItem);
+}
+
+/**
+ * Tests the recognition and application of a cancellation to an existing event.
+ *
+ * @param {ImipBarActionTestConf} conf
+ */
+async function doCancelTest({ transport, calendar, isRecurring, event, recurrenceId }) {
+ transport.reset();
+
+ let eventId = event.id;
+ if (isRecurring) {
+ // wait for the other occurrences to appear.
+ await CalendarTestUtils.monthView.waitForItemAt(window, 3, 5, 1);
+ await CalendarTestUtils.monthView.waitForItemAt(window, 3, 6, 1);
+ }
+
+ let cancellationPath = isRecurring
+ ? "data/cancel-repeat-event.eml"
+ : "data/cancel-single-event.eml";
+
+ let cancelMsgFile = new FileUtils.File(getTestFilePath(cancellationPath));
+ if (recurrenceId) {
+ let srcTxt = await IOUtils.readUTF8(cancelMsgFile.path);
+ srcTxt = srcTxt.replaceAll(/RRULE:.+/g, `RECURRENCE-ID:${recurrenceId}`);
+ srcTxt = srcTxt.replaceAll(/SEQUENCE:.+/g, "SEQUENCE:3");
+ cancelMsgFile = FileTestUtils.getTempFile("cancel-occurrence.eml");
+ await IOUtils.writeUTF8(cancelMsgFile.path, srcTxt);
+ }
+
+ let win = await openImipMessage(cancelMsgFile);
+ let aboutMessage = win.document.getElementById("messageBrowser").contentWindow;
+ let deleteButton = aboutMessage.document.getElementById("imipDeleteButton");
+ Assert.ok(!deleteButton.hidden, `#${deleteButton.id} button shown`);
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, aboutMessage);
+
+ if (isRecurring && recurrenceId) {
+ // Expects a single occurrence to be cancelled.
+
+ let occurrences;
+ await TestUtils.waitForCondition(async () => {
+ let { parentItem } = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ occurrences = parentItem.recurrenceInfo.getOccurrences(
+ cal.createDateTime("19700101"),
+ cal.createDateTime("30000101"),
+ Infinity
+ );
+ return occurrences.length == 2;
+ }, "occurrence was deleted");
+
+ Assert.ok(
+ occurrences.every(occ => occ.recurrenceId && occ.recurrenceId != recurrenceId),
+ `occurrence "${recurrenceId}" removed`
+ );
+ Assert.ok(!!(await calendar.getItem(eventId)), "event was not deleted");
+ } else {
+ await CalendarTestUtils.monthView.waitForNoItemAt(window, 3, 4, 1);
+
+ if (isRecurring) {
+ await CalendarTestUtils.monthView.waitForNoItemAt(window, 3, 5, 1);
+ await CalendarTestUtils.monthView.waitForNoItemAt(window, 3, 6, 1);
+ }
+
+ await TestUtils.waitForCondition(async () => {
+ let result = await calendar.getItem(eventId);
+ return !result;
+ }, "event was deleted");
+ }
+
+ await BrowserTestUtils.closeWindow(win);
+ Assert.equal(transport.sentItems.length, 0, "itip subsystem did not attempt to send a response");
+ Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem");
+}
+
+/**
+ * Tests processing of cancellations to exceptions to recurring events.
+ *
+ * @param {ImipBarActionTestConf} conf
+ */
+async function doCancelExceptionTest(conf) {
+ let { partStat, recurrenceId, calendar } = conf;
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, actionIds.recurring.button[partStat]);
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ await BrowserTestUtils.closeWindow(win);
+
+ let update = new FileUtils.File(getTestFilePath("data/exception-major.eml"));
+ let updateWin = await openImipMessage(update);
+ await clickAction(updateWin, actionIds.single.button[partStat]);
+
+ let exception;
+ await TestUtils.waitForCondition(async () => {
+ event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ exception = event.recurrenceInfo.getExceptionFor(cal.createDateTime(recurrenceId));
+ return !!exception;
+ }, "exception applied");
+
+ await BrowserTestUtils.closeWindow(updateWin);
+ await doCancelTest({ ...conf, event });
+ await calendar.deleteItem(event);
+}
diff --git a/comm/calendar/test/browser/preferences/browser.ini b/comm/calendar/test/browser/preferences/browser.ini
new file mode 100644
index 0000000000..a06213b220
--- /dev/null
+++ b/comm/calendar/test/browser/preferences/browser.ini
@@ -0,0 +1,16 @@
+[default]
+head = head.js
+prefs =
+ calendar.item.promptDelete=false
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+
+[browser_alarmDefaultValue.js]
+[browser_categoryColors.js]
diff --git a/comm/calendar/test/browser/preferences/browser_alarmDefaultValue.js b/comm/calendar/test/browser/preferences/browser_alarmDefaultValue.js
new file mode 100644
index 0000000000..b1cde7cf11
--- /dev/null
+++ b/comm/calendar/test/browser/preferences/browser_alarmDefaultValue.js
@@ -0,0 +1,176 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test default alarm settings for events and tasks
+ */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+var { cancelItemDialog } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+const DEFVALUE = 43;
+
+add_task(async function testDefaultAlarms() {
+ let calendar = CalendarTestUtils.createCalendar("Mochitest", "memory");
+ calendar.setProperty("calendar-main-default", true);
+ registerCleanupFunction(async () => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ let localeUnitString = cal.l10n.getCalString("unitDays");
+ let unitString = PluralForm.get(DEFVALUE, localeUnitString).replace("#1", DEFVALUE);
+ let alarmString = (...args) => cal.l10n.getString("calendar-alarms", ...args);
+ let originStringEvent = alarmString("reminderCustomOriginBeginBeforeEvent");
+ let originStringTask = alarmString("reminderCustomOriginBeginBeforeTask");
+ let expectedEventReminder = alarmString("reminderCustomTitle", [unitString, originStringEvent]);
+ let expectedTaskReminder = alarmString("reminderCustomTitle", [unitString, originStringTask]);
+
+ // Configure the preferences.
+ let { prefsWindow, prefsDocument } = await openNewPrefsTab("paneCalendar", "defaultsnoozelength");
+ await handlePrefTab(prefsWindow, prefsDocument);
+
+ // Create New Event.
+ await CalendarTestUtils.openCalendarTab(window);
+
+ let { dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewEvent(window);
+
+ Assert.equal(iframeDocument.querySelector(".item-alarm").value, "custom");
+ let reminderDetails = iframeDocument.querySelector(".reminder-single-alarms-label");
+ Assert.equal(reminderDetails.value, expectedEventReminder);
+
+ let reminderDialogPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-event-dialog-reminder.xhtml",
+ { callback: handleReminderDialog }
+ );
+ EventUtils.synthesizeMouseAtCenter(reminderDetails, {}, iframeWindow);
+ await reminderDialogPromise;
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ cancelItemDialog(dialogWindow);
+ await promptPromise;
+
+ // Create New Task.
+ await openTasksTab();
+ ({ dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewTask(window));
+
+ Assert.equal(iframeDocument.querySelector(".item-alarm").value, "custom");
+ reminderDetails = iframeDocument.querySelector(".reminder-single-alarms-label");
+ Assert.equal(reminderDetails.value, expectedTaskReminder);
+
+ reminderDialogPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-event-dialog-reminder.xhtml",
+ { callback: handleReminderDialog }
+ );
+ EventUtils.synthesizeMouseAtCenter(reminderDetails, {}, iframeWindow);
+ await reminderDialogPromise;
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ cancelItemDialog(dialogWindow);
+ await promptPromise;
+});
+
+async function handlePrefTab(prefsWindow, prefsDocument) {
+ function menuList(id, value) {
+ let list = prefsDocument.getElementById(id);
+ list.scrollIntoView();
+ list.click();
+ list.querySelector(`menuitem[value="${value}"]`).click();
+ }
+ // Turn on alarms for events and tasks.
+ menuList("eventdefalarm", "1");
+ menuList("tododefalarm", "1");
+
+ // Selects "days" as a unit.
+ menuList("tododefalarmunit", "days");
+ menuList("eventdefalarmunit", "days");
+
+ function text(id, value) {
+ let input = prefsDocument.getElementById(id);
+ input.scrollIntoView();
+ EventUtils.synthesizeMouse(input, 5, 5, {}, prefsWindow);
+ Assert.equal(prefsDocument.activeElement, input);
+ EventUtils.synthesizeKey("a", { accelKey: true }, prefsWindow);
+ EventUtils.sendString(value, prefsWindow);
+ }
+ // Sets default alarm length for events to DEFVALUE.
+ text("eventdefalarmlen", DEFVALUE.toString());
+ text("tododefalarmlen", DEFVALUE.toString());
+
+ Assert.equal(Services.prefs.getIntPref("calendar.alarms.onforevents"), 1);
+ Assert.equal(Services.prefs.getIntPref("calendar.alarms.eventalarmlen"), DEFVALUE);
+ Assert.equal(Services.prefs.getStringPref("calendar.alarms.eventalarmunit"), "days");
+ Assert.equal(Services.prefs.getIntPref("calendar.alarms.onfortodos"), 1);
+ Assert.equal(Services.prefs.getIntPref("calendar.alarms.todoalarmlen"), DEFVALUE);
+ Assert.equal(Services.prefs.getStringPref("calendar.alarms.todoalarmunit"), "days");
+}
+
+async function handleReminderDialog(remindersWindow) {
+ await new Promise(remindersWindow.setTimeout);
+ let remindersDocument = remindersWindow.document;
+
+ let listbox = remindersDocument.getElementById("reminder-listbox");
+ Assert.equal(listbox.selectedCount, 1);
+ Assert.equal(listbox.selectedItem.reminder.offset.days, DEFVALUE);
+
+ EventUtils.synthesizeMouseAtCenter(
+ remindersDocument.getElementById("reminder-new-button"),
+ {},
+ remindersWindow
+ );
+ Assert.equal(listbox.itemCount, 2);
+ Assert.equal(listbox.selectedCount, 1);
+ Assert.equal(listbox.selectedItem.reminder.offset.days, DEFVALUE);
+
+ function text(id, value) {
+ let input = remindersDocument.getElementById(id);
+ EventUtils.synthesizeMouse(input, 5, 5, {}, remindersWindow);
+ Assert.equal(remindersDocument.activeElement, input);
+ EventUtils.synthesizeKey("a", { accelKey: true }, remindersWindow);
+ EventUtils.sendString(value, remindersWindow);
+ }
+ text("reminder-length", "20");
+ Assert.equal(listbox.selectedItem.reminder.offset.days, 20);
+
+ EventUtils.synthesizeMouseAtCenter(listbox, {}, remindersWindow);
+ EventUtils.synthesizeKey("VK_UP", {}, remindersWindow);
+ Assert.equal(listbox.selectedIndex, 0);
+
+ Assert.equal(listbox.selectedItem.reminder.offset.days, DEFVALUE);
+
+ remindersDocument.querySelector("dialog").getButton("accept").click();
+}
+
+async function openTasksTab() {
+ let tabmail = document.getElementById("tabmail");
+ let tasksMode = tabmail.tabModes.tasks;
+
+ if (tasksMode.tabs.length == 1) {
+ tabmail.selectedTab = tasksMode.tabs[0];
+ } else {
+ let tasksTabButton = document.getElementById("tasksButton");
+ EventUtils.synthesizeMouseAtCenter(tasksTabButton, { clickCount: 1 });
+ }
+
+ is(tasksMode.tabs.length, 1, "tasks tab is open");
+ is(tabmail.selectedTab, tasksMode.tabs[0], "tasks tab is selected");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("calendar.alarms.onforevents");
+ Services.prefs.clearUserPref("calendar.alarms.eventalarmlen");
+ Services.prefs.clearUserPref("calendar.alarms.eventalarmunit");
+ Services.prefs.clearUserPref("calendar.alarms.onfortodos");
+ Services.prefs.clearUserPref("calendar.alarms.todoalarmlen");
+ Services.prefs.clearUserPref("calendar.alarms.todoalarmunit");
+});
diff --git a/comm/calendar/test/browser/preferences/browser_categoryColors.js b/comm/calendar/test/browser/preferences/browser_categoryColors.js
new file mode 100644
index 0000000000..29ad21ce13
--- /dev/null
+++ b/comm/calendar/test/browser/preferences/browser_categoryColors.js
@@ -0,0 +1,90 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+add_task(async function testCategoryColors() {
+ let calendar = CalendarTestUtils.createCalendar("Mochitest", "memory");
+
+ registerCleanupFunction(async () => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ let { prefsWindow, prefsDocument } = await openNewPrefsTab("paneCalendar", "categorieslist");
+
+ let listBox = prefsDocument.getElementById("categorieslist");
+ Assert.equal(listBox.itemChildren.length, 22);
+
+ for (let item of listBox.itemChildren) {
+ info(`${item.firstElementChild.value}: ${item.lastElementChild.style.backgroundColor}`);
+ Assert.ok(item.lastElementChild.style.backgroundColor);
+ }
+
+ // Edit the name and colour of a built-in category.
+
+ let subDialogPromise = BrowserTestUtils.waitForEvent(
+ prefsWindow.gSubDialog._dialogStack,
+ "dialogopen"
+ );
+
+ EventUtils.synthesizeMouse(listBox, 5, 5, {}, prefsWindow);
+ Assert.equal(listBox.selectedIndex, 0);
+ EventUtils.synthesizeMouseAtCenter(prefsDocument.getElementById("editCButton"), {}, prefsWindow);
+
+ await subDialogPromise;
+
+ let subDialogBrowser = prefsWindow.gSubDialog._topDialog._frame;
+ let subDialogDocument = subDialogBrowser.contentDocument;
+ subDialogDocument.getElementById("categoryName").value = "ZZZ Mochitest";
+ subDialogDocument.getElementById("categoryColor").value = "#00CC00";
+ subDialogDocument.body.firstElementChild.getButton("accept").click();
+
+ let listItem = listBox.itemChildren[listBox.itemCount - 1];
+ Assert.equal(listBox.selectedItem, listItem);
+ Assert.equal(listItem.firstElementChild.value, "ZZZ Mochitest");
+ Assert.equal(listItem.lastElementChild.style.backgroundColor, "rgb(0, 204, 0)");
+ Assert.equal(Services.prefs.getCharPref("calendar.category.color.zzz_mochitest"), "#00cc00");
+
+ // Remove the colour of a built-in category.
+
+ subDialogPromise = BrowserTestUtils.waitForEvent(
+ prefsWindow.gSubDialog._dialogStack,
+ "dialogopen"
+ );
+
+ EventUtils.synthesizeMouse(listBox, 5, 5, {}, prefsWindow);
+ EventUtils.synthesizeKey("VK_HOME", {}, prefsWindow);
+ Assert.equal(listBox.selectedIndex, 0);
+ let itemName = listBox.itemChildren[0].firstElementChild.value;
+ EventUtils.synthesizeMouseAtCenter(prefsDocument.getElementById("editCButton"), {}, prefsWindow);
+
+ await subDialogPromise;
+
+ subDialogBrowser = prefsWindow.gSubDialog._topDialog._frame;
+ await new Promise(subDialogBrowser.contentWindow.setTimeout);
+ subDialogDocument = subDialogBrowser.contentDocument;
+ subDialogDocument.getElementById("useColor").checked = false;
+ subDialogDocument.body.firstElementChild.getButton("accept").click();
+
+ listItem = listBox.itemChildren[0];
+ Assert.equal(listBox.selectedItem, listItem);
+ Assert.equal(listItem.firstElementChild.value, itemName);
+ Assert.equal(listItem.lastElementChild.style.backgroundColor, "");
+ Assert.equal(Services.prefs.getCharPref(`calendar.category.color.${itemName.toLowerCase()}`), "");
+
+ // Remove the added category.
+
+ EventUtils.synthesizeMouse(listBox, 5, 5, {}, prefsWindow);
+ EventUtils.synthesizeKey("VK_END", {}, prefsWindow);
+ Assert.equal(listBox.selectedIndex, listBox.itemCount - 1);
+ EventUtils.synthesizeMouseAtCenter(
+ prefsDocument.getElementById("deleteCButton"),
+ {},
+ prefsWindow
+ );
+});
diff --git a/comm/calendar/test/browser/preferences/head.js b/comm/calendar/test/browser/preferences/head.js
new file mode 100644
index 0000000000..8d6d5d3ab5
--- /dev/null
+++ b/comm/calendar/test/browser/preferences/head.js
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals openPreferencesTab */
+
+async function openNewPrefsTab(paneID, scrollPaneTo, otherArgs) {
+ let tabmail = document.getElementById("tabmail");
+ let prefsTabMode = tabmail.tabModes.preferencesTab;
+
+ Assert.equal(prefsTabMode.tabs.length, 0, "Prefs tab is not open");
+
+ let prefsDocument = await new Promise(resolve => {
+ Services.obs.addObserver(function documentLoaded(subject) {
+ if (subject.URL.startsWith("about:preferences")) {
+ Services.obs.removeObserver(documentLoaded, "chrome-document-loaded");
+ resolve(subject);
+ }
+ }, "chrome-document-loaded");
+ openPreferencesTab(paneID, scrollPaneTo, otherArgs);
+ });
+ Assert.ok(prefsDocument.URL.startsWith("about:preferences"), "Prefs tab is open");
+
+ prefsDocument = prefsTabMode.tabs[0].browser.contentDocument;
+ let prefsWindow = prefsDocument.ownerGlobal;
+ prefsWindow.resizeTo(screen.availWidth, screen.availHeight);
+ if (paneID) {
+ await new Promise(resolve => prefsWindow.setTimeout(resolve));
+ Assert.equal(prefsWindow.gLastCategory.category, paneID, `Selected pane is ${paneID}`);
+ } else {
+ // If we don't wait here for other scripts to run, they
+ // could be in a bad state if our test closes the tab.
+ await new Promise(resolve => prefsWindow.setTimeout(resolve));
+ }
+
+ registerCleanupOnce();
+
+ await new Promise(resolve => prefsWindow.setTimeout(resolve));
+ if (scrollPaneTo) {
+ Assert.greater(
+ prefsDocument.getElementById("preferencesContainer").scrollTop,
+ 0,
+ "Prefs page did scroll when it was supposed to"
+ );
+ }
+ return { prefsDocument, prefsWindow };
+}
+
+function registerCleanupOnce() {
+ if (registerCleanupOnce.alreadyRegistered) {
+ return;
+ }
+ registerCleanupFunction(closePrefsTab);
+ registerCleanupOnce.alreadyRegistered = true;
+}
+
+async function closePrefsTab() {
+ info("Closing prefs tab");
+ let tabmail = document.getElementById("tabmail");
+ let prefsTab = tabmail.tabModes.preferencesTab.tabs[0];
+ if (prefsTab) {
+ tabmail.closeTab(prefsTab);
+ }
+}
diff --git a/comm/calendar/test/browser/providers/browser.ini b/comm/calendar/test/browser/providers/browser.ini
new file mode 100644
index 0000000000..84d6133696
--- /dev/null
+++ b/comm/calendar/test/browser/providers/browser.ini
@@ -0,0 +1,21 @@
+[default]
+head = head.js
+prefs =
+ calendar.item.promptDelete=false
+ calendar.debug.log=true
+ calendar.debug.log.verbose=true
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+
+[browser_caldavCalendar_cached.js]
+[browser_caldavCalendar_uncached.js]
+[browser_icsCalendar_cached.js]
+[browser_icsCalendar_uncached.js]
+[browser_storageCalendar.js]
diff --git a/comm/calendar/test/browser/providers/browser_caldavCalendar_cached.js b/comm/calendar/test/browser/providers/browser_caldavCalendar_cached.js
new file mode 100644
index 0000000000..5b725e4d54
--- /dev/null
+++ b/comm/calendar/test/browser/providers/browser_caldavCalendar_cached.js
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalDAVServer } = ChromeUtils.import("resource://testing-common/calendar/CalDAVServer.jsm");
+
+CalDAVServer.open("bob", "bob");
+if (!Services.logins.findLogins(CalDAVServer.origin, null, "test").length) {
+ // Save a username and password to the login manager.
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ loginInfo.init(CalDAVServer.origin, null, "test", "bob", "bob", "", "");
+ Services.logins.addLogin(loginInfo);
+}
+
+let calendar;
+add_setup(async function () {
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ calendar = createCalendar("caldav", CalDAVServer.url, true);
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ registerCleanupFunction(async () => {
+ // This test has issues cleaning up, and it breaks all the subsequent tests.
+ await new Promise(r => setTimeout(r, 1000)); // eslint-disable-line mozilla/no-arbitrary-setTimeout
+ await CalDAVServer.close();
+ Services.logins.removeAllLogins();
+ removeCalendar(calendar);
+ });
+});
+
+async function promiseIdle() {
+ await TestUtils.waitForCondition(() => !calendar.wrappedJSObject.mPendingSync);
+ await fetch(`${CalDAVServer.origin}/ping`);
+}
+
+add_task(async function testAlarms() {
+ calendarObserver._batchRequired = true;
+ await runTestAlarms(calendar);
+
+ // Be sure the calendar has finished deleting the event.
+ await promiseIdle();
+});
+
+add_task(async function testSyncChanges() {
+ await syncChangesTest.setUp();
+
+ await CalDAVServer.putItemInternal(
+ "ad0850e5-8020-4599-86a4-86c90af4e2cd.ics",
+ syncChangesTest.part1Item
+ );
+ await syncChangesTest.runPart1();
+
+ await CalDAVServer.putItemInternal(
+ "ad0850e5-8020-4599-86a4-86c90af4e2cd.ics",
+ syncChangesTest.part2Item
+ );
+ await syncChangesTest.runPart2();
+
+ CalDAVServer.deleteItemInternal("ad0850e5-8020-4599-86a4-86c90af4e2cd.ics");
+ await syncChangesTest.runPart3();
+
+ // Be sure the calendar has finished all requests.
+ await promiseIdle();
+});
diff --git a/comm/calendar/test/browser/providers/browser_caldavCalendar_uncached.js b/comm/calendar/test/browser/providers/browser_caldavCalendar_uncached.js
new file mode 100644
index 0000000000..7489ae4e09
--- /dev/null
+++ b/comm/calendar/test/browser/providers/browser_caldavCalendar_uncached.js
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalDAVServer } = ChromeUtils.import("resource://testing-common/calendar/CalDAVServer.jsm");
+
+CalDAVServer.open("bob", "bob");
+if (!Services.logins.findLogins(CalDAVServer.origin, null, "test").length) {
+ // Save a username and password to the login manager.
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ loginInfo.init(CalDAVServer.origin, null, "test", "bob", "bob", "", "");
+ Services.logins.addLogin(loginInfo);
+}
+
+let calendar;
+add_setup(async function () {
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ calendar = createCalendar("caldav", CalDAVServer.url, false);
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ registerCleanupFunction(async () => {
+ await CalDAVServer.close();
+ Services.logins.removeAllLogins();
+ removeCalendar(calendar);
+ });
+});
+
+async function promiseIdle() {
+ await fetch(`${CalDAVServer.origin}/ping`);
+}
+
+add_task(async function testAlarms() {
+ calendarObserver._batchRequired = true;
+ await runTestAlarms(calendar);
+
+ // Be sure the calendar has finished deleting the event.
+ await promiseIdle();
+});
+
+add_task(async function testSyncChanges() {
+ await syncChangesTest.setUp();
+
+ await CalDAVServer.putItemInternal(
+ "ad0850e5-8020-4599-86a4-86c90af4e2cd.ics",
+ syncChangesTest.part1Item
+ );
+ await syncChangesTest.runPart1();
+
+ await CalDAVServer.putItemInternal(
+ "ad0850e5-8020-4599-86a4-86c90af4e2cd.ics",
+ syncChangesTest.part2Item
+ );
+ await syncChangesTest.runPart2();
+
+ CalDAVServer.deleteItemInternal("ad0850e5-8020-4599-86a4-86c90af4e2cd.ics");
+ await syncChangesTest.runPart3();
+
+ // Be sure the calendar has finished all requests.
+ await promiseIdle();
+});
diff --git a/comm/calendar/test/browser/providers/browser_icsCalendar_cached.js b/comm/calendar/test/browser/providers/browser_icsCalendar_cached.js
new file mode 100644
index 0000000000..ba788be5b9
--- /dev/null
+++ b/comm/calendar/test/browser/providers/browser_icsCalendar_cached.js
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ICSServer } = ChromeUtils.import("resource://testing-common/calendar/ICSServer.jsm");
+
+ICSServer.open("bob", "bob");
+if (!Services.logins.findLogins(ICSServer.origin, null, "test").length) {
+ // Save a username and password to the login manager.
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ loginInfo.init(ICSServer.origin, null, "test", "bob", "bob", "", "");
+ Services.logins.addLogin(loginInfo);
+}
+
+let calendar;
+add_setup(async function () {
+ // TODO: item notifications from a cached ICS calendar occur outside of batches.
+ // This isn't fatal but it shouldn't happen. Side-effects include alarms firing
+ // twice - once from onAddItem then again at onLoad.
+ //
+ // Remove the next line when this is fixed.
+ calendarObserver._batchRequired = false;
+
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ calendar = createCalendar("ics", ICSServer.url, true);
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ registerCleanupFunction(async () => {
+ await ICSServer.close();
+ Services.logins.removeAllLogins();
+ removeCalendar(calendar);
+ });
+});
+
+async function promiseIdle() {
+ await TestUtils.waitForCondition(
+ () =>
+ calendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject._queue.length == 0 &&
+ calendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject._isLocked === false
+ );
+ await fetch(`${ICSServer.origin}/ping`);
+}
+
+add_task(async function testAlarms() {
+ // Remove the next line when fixed.
+ calendarObserver._batchRequired = false;
+ await runTestAlarms(calendar);
+
+ // Be sure the calendar has finished deleting the event.
+ await promiseIdle();
+}).skip(); // Broken.
+
+add_task(async function testSyncChanges() {
+ await syncChangesTest.setUp();
+
+ await ICSServer.putICSInternal(syncChangesTest.part1Item);
+ await syncChangesTest.runPart1();
+
+ await ICSServer.putICSInternal(syncChangesTest.part2Item);
+ await syncChangesTest.runPart2();
+
+ await ICSServer.putICSInternal(
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ END:VCALENDAR
+ `
+ );
+ await syncChangesTest.runPart3();
+
+ // Be sure the calendar has finished deleting the event.
+ await promiseIdle();
+});
diff --git a/comm/calendar/test/browser/providers/browser_icsCalendar_uncached.js b/comm/calendar/test/browser/providers/browser_icsCalendar_uncached.js
new file mode 100644
index 0000000000..ef25408dce
--- /dev/null
+++ b/comm/calendar/test/browser/providers/browser_icsCalendar_uncached.js
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ICSServer } = ChromeUtils.import("resource://testing-common/calendar/ICSServer.jsm");
+
+ICSServer.open("bob", "bob");
+if (!Services.logins.findLogins(ICSServer.origin, null, "test").length) {
+ // Save a username and password to the login manager.
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ loginInfo.init(ICSServer.origin, null, "test", "bob", "bob", "", "");
+ Services.logins.addLogin(loginInfo);
+}
+
+let calendar;
+add_setup(async function () {
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ calendar = createCalendar("ics", ICSServer.url, false);
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ registerCleanupFunction(async () => {
+ await ICSServer.close();
+ Services.logins.removeAllLogins();
+ removeCalendar(calendar);
+ });
+});
+
+async function promiseIdle() {
+ await TestUtils.waitForCondition(
+ () =>
+ calendar.wrappedJSObject._queue.length == 0 && calendar.wrappedJSObject._isLocked === false
+ );
+ await fetch(`${ICSServer.origin}/ping`);
+}
+
+add_task(async function testAlarms() {
+ calendarObserver._batchRequired = true;
+ await runTestAlarms(calendar);
+
+ // Be sure the calendar has finished deleting the event.
+ await promiseIdle();
+});
+
+add_task(async function testSyncChanges() {
+ await syncChangesTest.setUp();
+
+ await ICSServer.putICSInternal(syncChangesTest.part1Item);
+ await syncChangesTest.runPart1();
+
+ await ICSServer.putICSInternal(syncChangesTest.part2Item);
+ await syncChangesTest.runPart2();
+
+ await ICSServer.putICSInternal(
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ END:VCALENDAR
+ `
+ );
+ await syncChangesTest.runPart3();
+
+ // Be sure the calendar has finished all requests.
+ await promiseIdle();
+});
diff --git a/comm/calendar/test/browser/providers/browser_storageCalendar.js b/comm/calendar/test/browser/providers/browser_storageCalendar.js
new file mode 100644
index 0000000000..1a9eb6a30c
--- /dev/null
+++ b/comm/calendar/test/browser/providers/browser_storageCalendar.js
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let calendar = createCalendar("storage", "moz-storage-calendar://");
+registerCleanupFunction(() => {
+ removeCalendar(calendar);
+});
+
+add_task(function testAlarms() {
+ calendarObserver._batchRequired = false;
+ return runTestAlarms(calendar);
+});
diff --git a/comm/calendar/test/browser/providers/head.js b/comm/calendar/test/browser/providers/head.js
new file mode 100644
index 0000000000..bf58302131
--- /dev/null
+++ b/comm/calendar/test/browser/providers/head.js
@@ -0,0 +1,402 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+SimpleTest.requestCompleteLog();
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+var { handleDeleteOccurrencePrompt } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+let calendarObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+
+ /* calIObserver */
+
+ _batchCount: 0,
+ _batchRequired: true,
+ onStartBatch(calendar) {
+ info(`onStartBatch ${calendar?.id} ${++this._batchCount}`);
+ Assert.equal(
+ calendar,
+ this._expectedCalendar,
+ "onStartBatch should occur on the expected calendar"
+ );
+ },
+ onEndBatch(calendar) {
+ info(`onEndBatch ${calendar?.id} ${this._batchCount--}`);
+ Assert.equal(
+ calendar,
+ this._expectedCalendar,
+ "onEndBatch should occur on the expected calendar"
+ );
+ },
+ onLoad(calendar) {
+ info(`onLoad ${calendar.id}`);
+ Assert.equal(calendar, this._expectedCalendar, "onLoad should occur on the expected calendar");
+ if (this._onLoadPromise) {
+ this._onLoadPromise.resolve();
+ }
+ },
+ onAddItem(item) {
+ info(`onAddItem ${item.calendar.id} ${item.id}`);
+ if (this._batchRequired) {
+ Assert.equal(this._batchCount, 1, "onAddItem must occur in a batch");
+ }
+ },
+ onModifyItem(newItem, oldItem) {
+ info(`onModifyItem ${newItem.calendar.id} ${newItem.id}`);
+ if (this._batchRequired) {
+ Assert.equal(this._batchCount, 1, "onModifyItem must occur in a batch");
+ }
+ },
+ onDeleteItem(deletedItem) {
+ info(`onDeleteItem ${deletedItem.calendar.id} ${deletedItem.id}`);
+ },
+ onError(calendar, errNo, message) {},
+ onPropertyChanged(calendar, name, value, oldValue) {},
+ onPropertyDeleting(calendar, name) {},
+};
+
+/**
+ * Create and register a calendar.
+ *
+ * @param {string} type - The calendar provider to use.
+ * @param {string} url - URL of the server.
+ * @param {boolean} useCache - Should this calendar have offline storage?
+ * @returns {calICalendar}
+ */
+function createCalendar(type, url, useCache) {
+ let calendar = cal.manager.createCalendar(type, Services.io.newURI(url));
+ calendar.name = type + (useCache ? " with cache" : " without cache");
+ calendar.id = cal.getUUID();
+ calendar.setProperty("cache.enabled", useCache);
+ calendar.setProperty("calendar-main-default", true);
+
+ cal.manager.registerCalendar(calendar);
+ calendar = cal.manager.getCalendarById(calendar.id);
+ calendarObserver._expectedCalendar = calendar;
+ calendar.addObserver(calendarObserver);
+
+ info(`Created calendar ${calendar.id}`);
+ return calendar;
+}
+
+/**
+ * Unregister a calendar.
+ *
+ * @param {calICalendar} calendar
+ */
+function removeCalendar(calendar) {
+ calendar.removeObserver(calendarObserver);
+ cal.manager.removeCalendar(calendar);
+}
+
+let alarmService = Cc["@mozilla.org/calendar/alarm-service;1"].getService(Ci.calIAlarmService);
+
+let alarmObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calIAlarmServiceObserver"]),
+
+ /* calIAlarmServiceObserver */
+
+ _alarmCount: 0,
+ onAlarm(item, alarm) {
+ info("onAlarm");
+ this._alarmCount++;
+ },
+ onRemoveAlarmsByItem(item) {},
+ onRemoveAlarmsByCalendar(calendar) {},
+ onAlarmsLoaded(calendar) {},
+};
+alarmService.addObserver(alarmObserver);
+registerCleanupFunction(async () => {
+ alarmService.removeObserver(alarmObserver);
+});
+
+/**
+ * Tests the creation, firing, dismissal, modification and deletion of an event with an alarm.
+ * Also checks that the number of events in the unifinder is correct at each stage.
+ *
+ * Passing this test requires the active calendar to fire notifications in the correct sequence.
+ */
+async function runTestAlarms() {
+ let today = cal.dtz.now();
+ let start = today.clone();
+ start.day++;
+ start.hour = start.minute = start.second = 0;
+ let end = start.clone();
+ end.hour++;
+ let repeatUntil = start.clone();
+ repeatUntil.day += 15;
+
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await CalendarTestUtils.goToToday(window);
+ Assert.equal(window.unifinderTreeView.rowCount, 0, "unifinder event count");
+
+ alarmObserver._alarmCount = 0;
+
+ let alarmDialogPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ "chrome://calendar/content/calendar-alarm-dialog.xhtml",
+ {
+ async callback(alarmWindow) {
+ info("Alarm dialog opened");
+ let alarmDocument = alarmWindow.document;
+
+ let list = alarmDocument.getElementById("alarm-richlist");
+ let items = list.querySelectorAll(`richlistitem[is="calendar-alarm-widget-richlistitem"]`);
+ await TestUtils.waitForCondition(() => items.length);
+ Assert.equal(items.length, 1);
+
+ await new Promise(resolve => alarmWindow.setTimeout(resolve, 500));
+
+ let dismissButton = alarmDocument.querySelector("#alarm-dismiss-all-button");
+ EventUtils.synthesizeMouseAtCenter(dismissButton, {}, alarmWindow);
+ },
+ }
+ );
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window);
+ await setData(dialogWindow, iframeWindow, {
+ title: "test event",
+ startdate: start,
+ starttime: start,
+ enddate: end,
+ endtime: end,
+ reminder: "2days",
+ repeat: "weekly",
+ });
+
+ await saveAndCloseItemDialog(dialogWindow);
+ await alarmDialogPromise;
+ info("Alarm dialog closed");
+
+ await new Promise(r => setTimeout(r, 2000));
+ Assert.equal(window.unifinderTreeView.rowCount, 1, "there should be one event in the unifinder");
+
+ Assert.equal(
+ [...Services.wm.getEnumerator("Calendar:AlarmWindow")].length,
+ 0,
+ "alarm dialog did not reappear"
+ );
+ Assert.equal(alarmObserver._alarmCount, 1, "only one alarm");
+ alarmObserver._alarmCount = 0;
+
+ let eventBox = await CalendarTestUtils.multiweekView.waitForItemAt(
+ window,
+ start.weekday == 0 ? 2 : 1, // Sunday's event is next week.
+ start.weekday + 1,
+ 1
+ );
+ Assert.ok(!!eventBox.item.parentItem.alarmLastAck);
+
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editItemOccurrences(window, eventBox));
+ await setData(dialogWindow, iframeWindow, {
+ title: "modified test event",
+ repeat: "weekly",
+ repeatuntil: repeatUntil,
+ });
+
+ await saveAndCloseItemDialog(dialogWindow);
+
+ Assert.equal(window.unifinderTreeView.rowCount, 1, "there should be one event in the unifinder");
+
+ Services.focus.focusedWindow = window;
+
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ Assert.equal(
+ [...Services.wm.getEnumerator("Calendar:AlarmWindow")].length,
+ 0,
+ "alarm dialog should not reappear"
+ );
+ Assert.equal(alarmObserver._alarmCount, 0, "there should not be any remaining alarms");
+ alarmObserver._alarmCount = 0;
+
+ eventBox = await CalendarTestUtils.multiweekView.waitForItemAt(
+ window,
+ start.weekday == 0 ? 2 : 1, // Sunday's event is next week.
+ start.weekday + 1,
+ 1
+ );
+ Assert.ok(!!eventBox.item.parentItem.alarmLastAck);
+
+ EventUtils.synthesizeMouseAtCenter(eventBox, {}, window);
+ eventBox.focus();
+ window.calendarController.onSelectionChanged({ detail: window.currentView().getSelectedItems() });
+ await handleDeleteOccurrencePrompt(window, window.currentView(), true);
+
+ await CalendarTestUtils.multiweekView.waitForNoItemAt(
+ window,
+ start.weekday == 0 ? 2 : 1, // Sunday's event is next week.
+ start.weekday + 1,
+ 1
+ );
+ Assert.equal(window.unifinderTreeView.rowCount, 0, "there should be no events in the unifinder");
+}
+
+const syncItem1Name = "holy cow, a new item!";
+const syncItem2Name = "a changed item";
+
+let syncChangesTest = {
+ async setUp() {
+ await CalendarTestUtils.openCalendarTab(window);
+
+ if (document.getElementById("today-pane-panel").collapsed) {
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("calendar-status-todaypane-button"),
+ {}
+ );
+ }
+
+ if (document.getElementById("agenda-panel").collapsed) {
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("today-pane-cycler-next"), {});
+ }
+ },
+
+ get part1Item() {
+ let today = cal.dtz.now();
+ let start = today.clone();
+ start.day += 9 - start.weekday;
+ start.hour = 13;
+ start.minute = start.second = 0;
+ let end = start.clone();
+ end.hour++;
+
+ return CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:ad0850e5-8020-4599-86a4-86c90af4e2cd
+ SUMMARY:${syncItem1Name}
+ DTSTART:${start.icalString}
+ DTEND:${end.icalString}
+ END:VEVENT
+ END:VCALENDAR
+ `;
+ },
+
+ async runPart1() {
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await CalendarTestUtils.goToToday(window);
+
+ // Sanity check that we have not already synchronized and that there is no
+ // existing item.
+ Assert.ok(
+ !CalendarTestUtils.multiweekView.getItemAt(window, 2, 3, 1),
+ "there should be no existing item in the calendar"
+ );
+
+ // Synchronize.
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("refreshCalendar"), {});
+
+ // Verify that the item we added appears in the calendar view.
+ let item = await CalendarTestUtils.multiweekView.waitForItemAt(window, 2, 3, 1);
+ Assert.equal(item.item.title, syncItem1Name, "view should include newly-added item");
+
+ // Verify that the today pane updates and shows the item we added.
+ await TestUtils.waitForCondition(() => window.TodayPane.agenda.rowCount == 1);
+ Assert.equal(
+ getTodayPaneItemTitle(0),
+ syncItem1Name,
+ "today pane should include newly-added item"
+ );
+ Assert.ok(
+ !window.TodayPane.agenda.rows[0].nextElementSibling,
+ "there should be no additional items in the today pane"
+ );
+ },
+
+ get part2Item() {
+ let today = cal.dtz.now();
+ let start = today.clone();
+ start.day += 10 - start.weekday;
+ start.hour = 9;
+ start.minute = start.second = 0;
+ let end = start.clone();
+ end.hour++;
+
+ return CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:ad0850e5-8020-4599-86a4-86c90af4e2cd
+ SUMMARY:${syncItem2Name}
+ DTSTART:${start.icalString}
+ DTEND:${end.icalString}
+ END:VEVENT
+ END:VCALENDAR
+ `;
+ },
+
+ async runPart2() {
+ // Sanity check that we have not already synchronized and that there is no
+ // existing item.
+ Assert.ok(
+ !CalendarTestUtils.multiweekView.getItemAt(window, 2, 4, 1),
+ "there should be no existing item on the specified day"
+ );
+
+ // Synchronize.
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("refreshCalendar"), {});
+
+ // Verify that the item has updated in the calendar view.
+ await CalendarTestUtils.multiweekView.waitForNoItemAt(window, 2, 3, 1);
+ let item = await CalendarTestUtils.multiweekView.waitForItemAt(window, 2, 4, 1);
+ Assert.equal(item.item.title, syncItem2Name, "view should show updated item");
+
+ // Verify that the today pane updates and shows the updated item.
+ await TestUtils.waitForCondition(
+ () => window.TodayPane.agenda.rowCount == 1 && getTodayPaneItemTitle(0) != syncItem1Name
+ );
+ Assert.equal(getTodayPaneItemTitle(0), syncItem2Name, "today pane should show updated item");
+ Assert.ok(
+ !window.TodayPane.agenda.rows[0].nextElementSibling,
+ "there should be no additional items in the today pane"
+ );
+ },
+
+ async runPart3() {
+ // Synchronize via the calendar context menu.
+ await calendarListContextMenu(
+ document.querySelector("#calendar-list > li:nth-child(2)"),
+ "list-calendar-context-reload"
+ );
+
+ // Verify that the item is removed from the calendar view.
+ await CalendarTestUtils.multiweekView.waitForNoItemAt(window, 2, 3, 1);
+ await CalendarTestUtils.multiweekView.waitForNoItemAt(window, 2, 4, 1);
+
+ // Verify that the item is removed from the today pane.
+ await TestUtils.waitForCondition(() => window.TodayPane.agenda.rowCount == 0);
+ },
+};
+
+function getTodayPaneItemTitle(idx) {
+ const row = window.TodayPane.agenda.rows[idx];
+ return row.querySelector(".agenda-listitem-title").textContent;
+}
+
+async function calendarListContextMenu(target, menuItem) {
+ await new Promise(r => setTimeout(r));
+ window.focus();
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == window,
+ "waiting for window to be focused"
+ );
+
+ let contextMenu = document.getElementById("list-calendars-context-menu");
+ let shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(target, { type: "contextmenu" });
+ await shownPromise;
+
+ if (menuItem) {
+ let hiddenPromise = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ contextMenu.activateItem(document.getElementById(menuItem));
+ await hiddenPromise;
+ }
+}
diff --git a/comm/calendar/test/browser/recurrence/browser.ini b/comm/calendar/test/browser/recurrence/browser.ini
new file mode 100644
index 0000000000..633ed27dde
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser.ini
@@ -0,0 +1,23 @@
+[default]
+dupe-manifest =
+prefs =
+ calendar.item.promptDelete=false
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+tags = recurrence
+
+[browser_annual.js]
+[browser_biweekly.js]
+[browser_daily.js]
+[browser_lastDayOfMonth.js]
+[browser_recurrenceNavigation.js]
+[browser_weeklyN.js]
+[browser_weeklyUntil.js]
+[browser_weeklyWithException.js]
diff --git a/comm/calendar/test/browser/recurrence/browser_annual.js b/comm/calendar/test/browser/recurrence/browser_annual.js
new file mode 100644
index 0000000000..54c7198f05
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_annual.js
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { handleDeleteOccurrencePrompt } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { dayView, weekView, multiweekView, monthView } = CalendarTestUtils;
+
+const STARTYEAR = 1950;
+const EPOCH = 1970;
+
+add_task(async function testAnnualRecurrence() {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, STARTYEAR, 1, 1);
+
+ // Create yearly recurring all-day event.
+ let eventBox = dayView.getAllDayHeader(window);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ await setData(dialogWindow, iframeWindow, { title: "Event", repeat: "yearly" });
+ await saveAndCloseItemDialog(dialogWindow);
+ await TestUtils.waitForCondition(
+ () => CalendarTestUtils.dayView.getAllDayItemAt(window, 1),
+ "recurring all-day event created"
+ );
+
+ let checkYears = [STARTYEAR, STARTYEAR + 1, EPOCH - 1, EPOCH, EPOCH + 1];
+ for (let year of checkYears) {
+ await CalendarTestUtils.goToDate(window, year, 1, 1);
+ let date = new Date(Date.UTC(year, 0, 1));
+ let column = date.getUTCDay() + 1;
+
+ // day view
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await dayView.waitForAllDayItemAt(window, 1);
+
+ // week view
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await weekView.waitForAllDayItemAt(window, column, 1);
+
+ // multiweek view
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await multiweekView.waitForItemAt(window, 1, column, 1);
+
+ // month view
+ await CalendarTestUtils.setCalendarView(window, "month");
+ await monthView.waitForItemAt(window, 1, column, 1);
+ }
+
+ // Delete event.
+ await CalendarTestUtils.goToDate(window, checkYears[0], 1, 1);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ const box = await dayView.waitForAllDayItemAt(window, 1);
+ EventUtils.synthesizeMouseAtCenter(box, {}, window);
+ await handleDeleteOccurrencePrompt(window, box, true);
+ await TestUtils.waitForCondition(() => !dayView.getAllDayItemAt(window, 1), "No all-day events");
+
+ Assert.ok(true, "Test ran to completion");
+});
diff --git a/comm/calendar/test/browser/recurrence/browser_biweekly.js b/comm/calendar/test/browser/recurrence/browser_biweekly.js
new file mode 100644
index 0000000000..6889822375
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_biweekly.js
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { handleDeleteOccurrencePrompt } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { dayView, weekView, multiweekView, monthView } = CalendarTestUtils;
+
+const HOUR = 8;
+
+add_task(async function testBiweeklyRecurrence() {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 31);
+
+ // Create biweekly event.
+ let eventBox = dayView.getHourBoxAt(window, HOUR);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ await setData(dialogWindow, iframeWindow, { title: "Event", repeat: "bi.weekly" });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check day view.
+ await CalendarTestUtils.setCalendarView(window, "day");
+ for (let i = 0; i < 4; i++) {
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 14);
+ }
+
+ // Check week view.
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 31);
+
+ for (let i = 0; i < 4; i++) {
+ await weekView.waitForEventBoxAt(window, 7, 1);
+ await CalendarTestUtils.calendarViewForward(window, 2);
+ }
+
+ // Check multiweek view.
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 31);
+
+ // Always two occurrences in view, 1st and 3rd or 2nd and 4th week.
+ for (let i = 0; i < 5; i++) {
+ await multiweekView.waitForItemAt(window, (i % 2) + 1, 7, 1);
+ Assert.ok(multiweekView.getItemAt(window, (i % 2) + 3, 7, 1));
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ }
+
+ // Check month view.
+ await CalendarTestUtils.setCalendarView(window, "month");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 31);
+
+ // January
+ await monthView.waitForItemAt(window, 5, 7, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+
+ // February
+ await monthView.waitForItemAt(window, 2, 7, 1);
+ Assert.ok(monthView.getItemAt(window, 4, 7, 1));
+ await CalendarTestUtils.calendarViewForward(window, 1);
+
+ // March
+ await monthView.waitForItemAt(window, 2, 7, 1);
+
+ let box = monthView.getItemAt(window, 4, 7, 1);
+ Assert.ok(box);
+
+ // Delete event.
+ EventUtils.synthesizeMouseAtCenter(box, {}, window);
+ await handleDeleteOccurrencePrompt(window, box, true);
+
+ await monthView.waitForNoItemAt(window, 4, 7, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
diff --git a/comm/calendar/test/browser/recurrence/browser_daily.js b/comm/calendar/test/browser/recurrence/browser_daily.js
new file mode 100644
index 0000000000..42ffc4c8db
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_daily.js
@@ -0,0 +1,162 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { handleDeleteOccurrencePrompt } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var {
+ calendarViewBackward,
+ calendarViewForward,
+ setCalendarView,
+ dayView,
+ weekView,
+ multiweekView,
+ monthView,
+} = CalendarTestUtils;
+
+const HOUR = 8;
+const TITLE = "Event";
+
+add_task(async function testDailyRecurrence() {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ // Create daily event.
+ let eventBox = dayView.getHourBoxAt(window, HOUR);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ await setData(dialogWindow, iframeWindow, {
+ title: TITLE,
+ repeat: "daily",
+ repeatuntil: cal.createDateTime("20090320T000000Z"),
+ });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check day view for 7 days.
+ for (let day = 1; day <= 7; day++) {
+ await dayView.waitForEventBoxAt(window, 1);
+ await calendarViewForward(window, 1);
+ }
+
+ // Check week view for 2 weeks.
+ await setCalendarView(window, "week");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ for (let day = 5; day <= 7; day++) {
+ await weekView.waitForEventBoxAt(window, day, 1);
+ }
+
+ await calendarViewForward(window, 1);
+
+ for (let day = 1; day <= 7; day++) {
+ await weekView.waitForEventBoxAt(window, day, 1);
+ }
+
+ // Check multiweek view for 4 weeks.
+ await setCalendarView(window, "multiweek");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ for (let day = 5; day <= 7; day++) {
+ await multiweekView.waitForItemAt(window, 1, day, 1);
+ }
+
+ for (let week = 2; week <= 4; week++) {
+ for (let day = 1; day <= 7; day++) {
+ await multiweekView.waitForItemAt(window, week, day, 1);
+ }
+ }
+ // Check month view for all 5 weeks.
+ await setCalendarView(window, "month");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ for (let day = 5; day <= 7; day++) {
+ await monthView.waitForItemAt(window, 1, day, 1);
+ }
+
+ for (let week = 2; week <= 5; week++) {
+ for (let day = 1; day <= 7; day++) {
+ await monthView.waitForItemAt(window, week, day, 1);
+ }
+ }
+
+ // Delete 3rd January occurrence.
+ let saturday = await monthView.waitForItemAt(window, 1, 7, 1);
+ EventUtils.synthesizeMouseAtCenter(saturday, {}, window);
+ await handleDeleteOccurrencePrompt(window, saturday, false);
+
+ // Verify in all views.
+ await monthView.waitForNoItemAt(window, 1, 7, 1);
+
+ await setCalendarView(window, "multiweek");
+ Assert.ok(!multiweekView.getItemAt(window, 1, 7, 1));
+
+ await setCalendarView(window, "week");
+ Assert.ok(!weekView.getEventBoxAt(window, 7, 1));
+
+ await setCalendarView(window, "day");
+ Assert.ok(!dayView.getEventBoxAt(window, 1));
+
+ // Go to previous day to edit event to occur only on weekdays.
+ await calendarViewBackward(window, 1);
+
+ ({ dialogWindow, iframeWindow } = await dayView.editEventOccurrencesAt(window, 1));
+ await setData(dialogWindow, iframeWindow, { repeat: "every.weekday" });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check day view for 7 days.
+ let dates = [
+ [2009, 1, 3],
+ [2009, 1, 4],
+ ];
+ for (let [y, m, d] of dates) {
+ await CalendarTestUtils.goToDate(window, y, m, d);
+ Assert.ok(!dayView.getEventBoxAt(window, 1));
+ }
+
+ // Check week view for 2 weeks.
+ await setCalendarView(window, "week");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ for (let i = 0; i <= 1; i++) {
+ await weekView.waitForNoEventBoxAt(window, 1, 1);
+ Assert.ok(!weekView.getEventBoxAt(window, 7, 1));
+ await calendarViewForward(window, 1);
+ }
+
+ // Check multiweek view for 4 weeks.
+ await setCalendarView(window, "multiweek");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ for (let i = 1; i <= 4; i++) {
+ await multiweekView.waitForNoItemAt(window, i, 1, 1);
+ Assert.ok(!multiweekView.getItemAt(window, i, 7, 1));
+ }
+
+ // Check month view for all 5 weeks.
+ await setCalendarView(window, "month");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ for (let i = 1; i <= 5; i++) {
+ await monthView.waitForNoItemAt(window, i, 1, 1);
+ Assert.ok(!monthView.getItemAt(window, i, 7, 1));
+ }
+
+ // Delete event.
+ let day = monthView.getItemAt(window, 1, 5, 1);
+ EventUtils.synthesizeMouseAtCenter(day, {}, window);
+ await handleDeleteOccurrencePrompt(window, day, true);
+ await monthView.waitForNoItemAt(window, 1, 5, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
diff --git a/comm/calendar/test/browser/recurrence/browser_lastDayOfMonth.js b/comm/calendar/test/browser/recurrence/browser_lastDayOfMonth.js
new file mode 100644
index 0000000000..bc7e01556a
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_lastDayOfMonth.js
@@ -0,0 +1,112 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { handleDeleteOccurrencePrompt } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+
+var { menulistSelect } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { setCalendarView, dayView, weekView, multiweekView, monthView } = CalendarTestUtils;
+
+const HOUR = 8;
+
+add_task(async function testLastDayOfMonthRecurrence() {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2008, 1, 31); // Start with a leap year.
+
+ // Create monthly recurring event.
+ let eventBox = dayView.getHourBoxAt(window, HOUR);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ await setData(dialogWindow, iframeWindow, { title: "Event", repeat: setRecurrence });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // data tuple: [year, month, day, row in month view]
+ // note: Month starts here with 1 for January.
+ let checkingData = [
+ [2008, 1, 31, 5],
+ [2008, 2, 29, 5],
+ [2008, 3, 31, 6],
+ [2008, 4, 30, 5],
+ [2008, 5, 31, 5],
+ [2008, 6, 30, 5],
+ [2008, 7, 31, 5],
+ [2008, 8, 31, 6],
+ [2008, 9, 30, 5],
+ [2008, 10, 31, 5],
+ [2008, 11, 30, 6],
+ [2008, 12, 31, 5],
+ [2009, 1, 31, 5],
+ [2009, 2, 28, 4],
+ [2009, 3, 31, 5],
+ ];
+ // Check all dates.
+ for (let [y, m, d, correctRow] of checkingData) {
+ let date = new Date(Date.UTC(y, m - 1, d));
+ let column = date.getUTCDay() + 1;
+
+ await CalendarTestUtils.goToDate(window, y, m, d);
+
+ // day view
+ await setCalendarView(window, "day");
+ await dayView.waitForEventBoxAt(window, 1);
+
+ // week view
+ await setCalendarView(window, "week");
+ await weekView.waitForEventBoxAt(window, column, 1);
+
+ // multiweek view
+ await setCalendarView(window, "multiweek");
+ await multiweekView.waitForItemAt(window, 1, column, 1);
+
+ // month view
+ await setCalendarView(window, "month");
+ await monthView.waitForItemAt(window, correctRow, column, 1);
+ }
+
+ // Delete event.
+ await CalendarTestUtils.goToDate(
+ window,
+ checkingData[0][0],
+ checkingData[0][1],
+ checkingData[0][2]
+ );
+ await setCalendarView(window, "day");
+ let box = await dayView.waitForEventBoxAt(window, 1);
+ EventUtils.synthesizeMouseAtCenter(box, {}, window);
+ await handleDeleteOccurrencePrompt(window, box, true);
+ await dayView.waitForNoEventBoxAt(window, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
+
+async function setRecurrence(recurrenceWindow) {
+ let recurrenceDocument = recurrenceWindow.document;
+ // monthly
+ await menulistSelect(recurrenceDocument.getElementById("period-list"), "2");
+
+ // last day of month
+ EventUtils.synthesizeMouseAtCenter(
+ recurrenceDocument.getElementById("montly-period-relative-date-radio"),
+ {},
+ recurrenceWindow
+ );
+ await menulistSelect(recurrenceDocument.getElementById("monthly-ordinal"), "-1");
+ await menulistSelect(recurrenceDocument.getElementById("monthly-weekday"), "-1");
+
+ let button = recurrenceDocument.querySelector("dialog").getButton("accept");
+ button.scrollIntoView();
+ // Close dialog.
+ EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow);
+}
diff --git a/comm/calendar/test/browser/recurrence/browser_recurrenceNavigation.js b/comm/calendar/test/browser/recurrence/browser_recurrenceNavigation.js
new file mode 100644
index 0000000000..8dfe7287ce
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_recurrenceNavigation.js
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const calendar = CalendarTestUtils.createCalendar("Minimonths", "memory");
+
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+add_task(async function testRecurrenceNavigation() {
+ await CalendarTestUtils.setCalendarView(window, "month");
+
+ let eventDate = cal.createDateTime("20200201T000001Z");
+ window.goToDate(eventDate);
+
+ let newEventBtn = document.querySelector("#sidePanelNewEvent");
+ let getEventWin = CalendarTestUtils.waitForEventDialog("edit");
+ EventUtils.synthesizeMouseAtCenter(newEventBtn, {});
+
+ let eventWin = await getEventWin;
+ let iframe = eventWin.document.querySelector("iframe");
+
+ let getRepeatWin = BrowserTestUtils.promiseAlertDialogOpen(
+ "",
+ "chrome://calendar/content/calendar-event-dialog-recurrence.xhtml",
+ {
+ async callback(win) {
+ let container = await TestUtils.waitForCondition(() => {
+ return win.document.querySelector("#recurrencePreviewContainer");
+ }, `The recurrence container exists`);
+
+ let initialMonth = await TestUtils.waitForCondition(() => {
+ return container.querySelector(`calendar-minimonth[month="1"][year="2020"]`);
+ }, `Initial month exists`);
+ Assert.ok(!initialMonth.hidden, `Initial month is visible on load`);
+
+ let nextButton = container.querySelector("#recurrenceNext");
+ Assert.ok(nextButton, `Next button exists`);
+ nextButton.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(nextButton, {}, win);
+
+ let nextMonth = container.querySelector(`calendar-minimonth[month="2"][year="2020"]`);
+ Assert.ok(nextMonth, `Next month exists`);
+ Assert.ok(!nextMonth.hidden, `Next month is visible`);
+
+ let previousButton = container.querySelector("#recurrencePrevious");
+ Assert.ok(previousButton, `Previous button exists`);
+ previousButton.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(previousButton, {}, win);
+ Assert.ok(!initialMonth.hidden, `Previous month is visible after using previous button`);
+
+ // Check that future dates display
+ nextButton.scrollIntoView();
+ for (let index = 0; index < 5; index++) {
+ EventUtils.synthesizeMouseAtCenter(nextButton, {}, win);
+ }
+
+ let futureMonth = await TestUtils.waitForCondition(() => {
+ return container.querySelector(`calendar-minimonth[month="6"][year="2020"]`);
+ }, `Future month exist`);
+ Assert.ok(!futureMonth.hidden, `Future month is visible after using next button`);
+
+ // Ensure the number of minimonths shown is the amount we expect.
+ let defaultMinimonthCount = "3";
+ let actualVisibleMinimonthCount = container.querySelectorAll(
+ `calendar-minimonth:not([hidden])`
+ ).length;
+ Assert.equal(
+ defaultMinimonthCount,
+ actualVisibleMinimonthCount,
+ `Default minimonth visible count matches actual: ${actualVisibleMinimonthCount}`
+ );
+
+ // Go back 5 times; we should go back to the initial month.
+ for (let index = 0; index < 5; index++) {
+ EventUtils.synthesizeMouseAtCenter(previousButton, {}, win);
+ }
+ Assert.ok(!initialMonth.hidden, `Initial month is visible`);
+
+ // Close window at end of tests for this item
+ await BrowserTestUtils.closeWindow(win);
+ },
+ }
+ );
+
+ let repeatMenu = iframe.contentDocument.querySelector("#item-repeat");
+ repeatMenu.value = "custom";
+ repeatMenu.doCommand();
+ await getRepeatWin;
+
+ await BrowserTestUtils.closeWindow(eventWin);
+});
+
+add_task(async function testRecurrenceCreationOfMonths() {
+ await CalendarTestUtils.setCalendarView(window, "month");
+
+ let eventDate = cal.createDateTime("20200101T000001Z");
+ window.goToDate(eventDate);
+
+ let newEventBtn = document.querySelector("#sidePanelNewEvent");
+ let getEventWin = CalendarTestUtils.waitForEventDialog("edit");
+ EventUtils.synthesizeMouseAtCenter(newEventBtn, {});
+
+ let eventWin = await getEventWin;
+ let iframe = eventWin.document.querySelector("iframe");
+
+ let getRepeatWin = BrowserTestUtils.promiseAlertDialogOpen(
+ "",
+ "chrome://calendar/content/calendar-event-dialog-recurrence.xhtml",
+ {
+ async callback(win) {
+ let container = win.document.querySelector("#recurrencePreviewContainer");
+ let nextButton = container.querySelector("#recurrenceNext");
+ nextButton.scrollIntoView();
+ for (let index = 0; index < 10; index++) {
+ EventUtils.synthesizeMouseAtCenter(nextButton, {}, win);
+ }
+
+ let futureMonth = container.querySelector(`calendar-minimonth[month="10"][year="2020"]`);
+ Assert.ok(futureMonth, `Dynamically created future month exists`);
+ Assert.ok(!futureMonth.hidden, `Dynamically created future month is visible`);
+
+ // Close window at end of tests for this item
+ await BrowserTestUtils.closeWindow(win);
+ },
+ }
+ );
+
+ let repeatMenu = iframe.contentDocument.querySelector("#item-repeat");
+ repeatMenu.value = "custom";
+ repeatMenu.doCommand();
+ await getRepeatWin;
+
+ await BrowserTestUtils.closeWindow(eventWin);
+});
diff --git a/comm/calendar/test/browser/recurrence/browser_rotated.ini b/comm/calendar/test/browser/recurrence/browser_rotated.ini
new file mode 100644
index 0000000000..2385ed9324
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_rotated.ini
@@ -0,0 +1,24 @@
+[default]
+head = head.js
+dupe-manifest =
+prefs =
+ calendar.item.promptDelete=false
+ calendar.test.rotateViews=true
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+tags = recurrence-rotated
+
+[browser_annual.js]
+[browser_biweekly.js]
+[browser_daily.js]
+[browser_lastDayOfMonth.js]
+[browser_weeklyN.js]
+[browser_weeklyUntil.js]
+[browser_weeklyWithException.js]
diff --git a/comm/calendar/test/browser/recurrence/browser_weeklyN.js b/comm/calendar/test/browser/recurrence/browser_weeklyN.js
new file mode 100644
index 0000000000..e32ab470f1
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_weeklyN.js
@@ -0,0 +1,268 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { handleDeleteOccurrencePrompt } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+
+var { menulistSelect, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { dayView, weekView, multiweekView, monthView } = CalendarTestUtils;
+
+const HOUR = 8;
+
+/*
+ * This test is intended to verify that events recurring on a weekly basis are
+ * correctly created and displayed. The event should recur on multiple days in
+ * the week, skip days, and be limited to a certain number of recurrences in
+ * order to verify that these parameters are respected. Deletion should delete
+ * all event occurrences when appropriate.
+ */
+add_task(async function testWeeklyNRecurrence() {
+ async function setRecurrence(recurrenceWindow) {
+ let recurrenceDocument = recurrenceWindow.document;
+
+ // Select weekly recurrence
+ await menulistSelect(recurrenceDocument.getElementById("period-list"), "1");
+
+ let monLabel = cal.l10n.getDateFmtString("day.2.Mmm");
+ let tueLabel = cal.l10n.getDateFmtString("day.3.Mmm");
+ let wedLabel = cal.l10n.getDateFmtString("day.4.Mmm");
+ let friLabel = cal.l10n.getDateFmtString("day.6.Mmm");
+ let satLabel = cal.l10n.getDateFmtString("day.7.Mmm");
+
+ let dayPicker = recurrenceDocument.getElementById("daypicker-weekday");
+
+ // Selected date is a Monday, so it should already be selected
+ Assert.ok(
+ dayPicker.querySelector(`[label="${monLabel}"]`).checked,
+ "Monday should already be selected"
+ );
+
+ // Select Tuesday, Wednesday, Friday, and Saturday as additional days for
+ // event occurrences
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${tueLabel}"]`),
+ {},
+ recurrenceWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${wedLabel}"]`),
+ {},
+ recurrenceWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${friLabel}"]`),
+ {},
+ recurrenceWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${satLabel}"]`),
+ {},
+ recurrenceWindow
+ );
+
+ // Create a total of four events
+ EventUtils.synthesizeMouseAtCenter(
+ recurrenceDocument.getElementById("recurrence-range-for"),
+ {},
+ recurrenceWindow
+ );
+ recurrenceDocument.getElementById("repeat-ntimes-count").value = "4";
+
+ let button = recurrenceDocument.querySelector("dialog").getButton("accept");
+ button.scrollIntoView();
+ // Close dialog
+ EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow);
+ }
+
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5);
+
+ // Create event recurring on a weekly basis
+ let eventBox = dayView.getHourBoxAt(window, HOUR);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ await setData(dialogWindow, iframeWindow, { title: "Event", repeat: setRecurrence });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Verify in the day view that events were created for Monday through Wednesday
+ for (let i = 0; i < 3; i++) {
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ }
+
+ // No event should have been created on Thursday because it was not selected
+ await dayView.waitForNoEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+
+ // An event should have been created for Friday because it was selected
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+
+ // No event should have been created on Saturday due to four event limit
+ await dayView.waitForNoEventBoxAt(window, 1);
+
+ // Validate event creation and lack of Saturday event in week view
+ await CalendarTestUtils.setCalendarView(window, "week");
+
+ for (let i = 2; i < 5; i++) {
+ await weekView.waitForEventBoxAt(window, i, 1);
+ }
+
+ // No event Thursday or Saturday, event on Friday
+ await weekView.waitForNoEventBoxAt(window, 5, 1);
+ await weekView.waitForEventBoxAt(window, 6, 1);
+ await weekView.waitForNoEventBoxAt(window, 7, 1);
+
+ // Validate event creation and lack of Saturday event in multiweek view
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+
+ for (let i = 2; i < 5; i++) {
+ await multiweekView.waitForItemAt(window, 1, i, 1);
+ }
+
+ // No event Thursday or Saturday, event on Friday
+ await multiweekView.waitForNoItemAt(window, 1, 5, 1);
+ await multiweekView.waitForItemAt(window, 1, 6, 1);
+ await multiweekView.waitForNoItemAt(window, 1, 7, 1);
+
+ // Validate event creation and lack of Saturday event in month view
+ await CalendarTestUtils.setCalendarView(window, "month");
+
+ for (let i = 2; i < 5; i++) {
+ // This should be the second week in the month
+ await monthView.waitForItemAt(window, 2, i, 1);
+ }
+
+ // No event Thursday or Saturday, event on Friday
+ await monthView.waitForNoItemAt(window, 2, 5, 1);
+ await monthView.waitForItemAt(window, 2, 6, 1);
+ await monthView.waitForNoItemAt(window, 2, 7, 1);
+
+ // Delete event
+ let box = await monthView.waitForItemAt(window, 2, 2, 1);
+ EventUtils.synthesizeMouseAtCenter(box, {}, window);
+ await handleDeleteOccurrencePrompt(window, box, true);
+
+ // All occurrences should have been deleted
+ for (let i = 2; i < 5; i++) {
+ await monthView.waitForNoItemAt(window, 2, i, 1);
+ }
+
+ await monthView.waitForNoItemAt(window, 2, 6, 1);
+});
+
+/*
+ * This test is intended to catch instances in which we aren't correctly setting
+ * the week start value of recurrences. For example, if the user has set their
+ * week to start on Saturday, then creates a recurring event running every other
+ * Saturday, Sunday, and Monday, they expect to see events on the initial
+ * Saturday, Sunday, Monday, skip a week, repeat. However, week start defaults
+ * to Monday, so if it is not correctly set, they would see events on the
+ * initial Saturday and Sunday, nothing on Monday, but an event on the following
+ * Monday.
+ */
+add_task(async function testRecurrenceAcrossWeekStart() {
+ // Sanity check that we're not testing against a default value
+ const initialWeekStart = Services.prefs.getIntPref("calendar.week.start", 0);
+ Assert.notEqual(initialWeekStart, 6, "week start should not be Saturday");
+
+ // Set week start to Saturday
+ Services.prefs.setIntPref("calendar.week.start", 6);
+ registerCleanupFunction(() => {
+ Services.prefs.setIntPref("calendar.week.start", initialWeekStart);
+ });
+
+ async function setRecurrence(recurrenceWindow) {
+ let recurrenceDocument = recurrenceWindow.document;
+
+ // Select weekly recurrence
+ await menulistSelect(recurrenceDocument.getElementById("period-list"), "1");
+
+ // Recur every two weeks
+ recurrenceDocument.getElementById("weekly-weeks").value = "2";
+
+ let satLabel = cal.l10n.getDateFmtString("day.7.Mmm");
+ let sunLabel = cal.l10n.getDateFmtString("day.1.Mmm");
+ let monLabel = cal.l10n.getDateFmtString("day.2.Mmm");
+
+ let dayPicker = recurrenceDocument.getElementById("daypicker-weekday");
+
+ // Selected date is a Saturday, so it should already be selected
+ Assert.ok(
+ dayPicker.querySelector(`[label="${satLabel}"]`).checked,
+ "Saturday should already be checked"
+ );
+
+ // Select Sunday and Monday as additional days for event occurrences
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${sunLabel}"]`),
+ {},
+ recurrenceWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${monLabel}"]`),
+ {},
+ recurrenceWindow
+ );
+
+ // Create a total of six events
+ EventUtils.synthesizeMouseAtCenter(
+ recurrenceDocument.getElementById("recurrence-range-for"),
+ {},
+ recurrenceWindow
+ );
+ recurrenceDocument.getElementById("repeat-ntimes-count").value = "6";
+
+ let button = recurrenceDocument.querySelector("dialog").getButton("accept");
+ button.scrollIntoView();
+ // Close dialog
+ EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow);
+ }
+
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2022, 10, 15);
+
+ // Create event recurring every other week
+ let eventBox = dayView.getHourBoxAt(window, HOUR);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ await setData(dialogWindow, iframeWindow, { title: "Event", repeat: setRecurrence });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Open week view
+ await CalendarTestUtils.setCalendarView(window, "week");
+
+ // Verify events created on Saturday, Sunday, Monday of first week
+ for (let i = 1; i < 4; i++) {
+ await weekView.waitForEventBoxAt(window, i, 1);
+ }
+
+ // Verify no events created on Saturday, Sunday, Monday of second week
+ await CalendarTestUtils.calendarViewForward(window, 1);
+
+ for (let i = 1; i < 4; i++) {
+ await weekView.waitForNoEventBoxAt(window, i, 1);
+ }
+
+ // Verify events created on Saturday, Sunday, Monday of third week
+ await CalendarTestUtils.calendarViewForward(window, 1);
+
+ for (let i = 1; i < 4; i++) {
+ await weekView.waitForEventBoxAt(window, i, 1);
+ }
+});
diff --git a/comm/calendar/test/browser/recurrence/browser_weeklyUntil.js b/comm/calendar/test/browser/recurrence/browser_weeklyUntil.js
new file mode 100644
index 0000000000..c9780e9428
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_weeklyUntil.js
@@ -0,0 +1,175 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { handleDeleteOccurrencePrompt } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+
+var { formatDate, menulistSelect, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { dayView, weekView, multiweekView, monthView } = CalendarTestUtils;
+
+const ENDDATE = cal.createDateTime("20090126T000000Z"); // Last Monday in month.
+const HOUR = 8;
+
+add_task(async function testWeeklyUntilRecurrence() {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5); // Monday
+
+ // Create weekly recurring event.
+ let eventBox = dayView.getHourBoxAt(window, HOUR);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ await setData(dialogWindow, iframeWindow, { title: "Event", repeat: setRecurrence });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check day view.
+ for (let week = 0; week < 3; week++) {
+ // Monday
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 2);
+
+ // Wednesday
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 2);
+
+ // Friday
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 3);
+ }
+
+ // Monday, last occurrence
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 2);
+
+ // Wednesday
+ await dayView.waitForNoEventBoxAt(window, 1);
+
+ // Check week view.
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5);
+ for (let week = 0; week < 3; week++) {
+ // Monday
+ await weekView.waitForEventBoxAt(window, 2, 1);
+
+ // Wednesday
+ await weekView.waitForEventBoxAt(window, 4, 1);
+
+ // Friday
+ await weekView.waitForEventBoxAt(window, 6, 1);
+
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ }
+
+ // Monday, last occurrence
+ await weekView.waitForEventBoxAt(window, 2, 1);
+ // Wednesday
+ await weekView.waitForNoEventBoxAt(window, 4, 1);
+
+ // Check multiweek view.
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5);
+ for (let week = 1; week < 4; week++) {
+ // Monday
+ await multiweekView.waitForItemAt(window, week, 2, 1);
+ // Wednesday
+ await multiweekView.waitForItemAt(window, week, 4, 1);
+ // Friday
+ await multiweekView.waitForItemAt(window, week, 6, 1);
+ }
+
+ // Monday, last occurrence
+ await multiweekView.waitForItemAt(window, 4, 2, 1);
+
+ // Wednesday
+ await multiweekView.waitForNoItemAt(window, 4, 4, 1);
+
+ // Check month view.
+ await CalendarTestUtils.setCalendarView(window, "month");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5);
+ // starts on week 2 in month-view
+ for (let week = 2; week < 5; week++) {
+ // Monday
+ await monthView.waitForItemAt(window, week, 2, 1);
+ // Wednesday
+ await monthView.waitForItemAt(window, week, 4, 1);
+ // Friday
+ await monthView.waitForItemAt(window, week, 6, 1);
+ }
+
+ // Monday, last occurrence
+ await monthView.waitForItemAt(window, 5, 2, 1);
+
+ // Wednesday
+ await monthView.waitForNoItemAt(window, 5, 4, 1);
+
+ // Delete event.
+ let box = monthView.getItemAt(window, 2, 2, 1);
+ EventUtils.synthesizeMouseAtCenter(box, {}, window);
+ await handleDeleteOccurrencePrompt(window, box, true);
+ await monthView.waitForNoItemAt(window, 2, 2, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
+
+async function setRecurrence(recurrenceWindow) {
+ let recurrenceDocument = recurrenceWindow.document;
+
+ // weekly
+ await menulistSelect(recurrenceDocument.getElementById("period-list"), "1");
+
+ let mon = cal.l10n.getDateFmtString("day.2.Mmm");
+ let wed = cal.l10n.getDateFmtString("day.4.Mmm");
+ let fri = cal.l10n.getDateFmtString("day.6.Mmm");
+
+ let dayPicker = recurrenceDocument.getElementById("daypicker-weekday");
+
+ // Starting from Monday so it should be checked.
+ Assert.ok(dayPicker.querySelector(`[label="${mon}"]`).checked, "mon checked");
+ // Check Wednesday and Friday too.
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${wed}"]`),
+ {},
+ recurrenceWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${fri}"]`),
+ {},
+ recurrenceWindow
+ );
+
+ // Set until date.
+ EventUtils.synthesizeMouseAtCenter(
+ recurrenceDocument.getElementById("recurrence-range-until"),
+ {},
+ recurrenceWindow
+ );
+
+ // Delete previous date.
+ let untilInput = recurrenceDocument.getElementById("repeat-until-date");
+ untilInput.focus();
+ EventUtils.synthesizeKey("a", { accelKey: true }, recurrenceWindow);
+ untilInput.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, recurrenceWindow);
+
+ let endDateString = formatDate(ENDDATE);
+ EventUtils.sendString(endDateString, recurrenceWindow);
+
+ // Move focus to ensure the date is selected.
+ untilInput.focus();
+ EventUtils.synthesizeKey("VK_TAB", {}, recurrenceWindow);
+
+ let button = recurrenceDocument.querySelector("dialog").getButton("accept");
+ button.scrollIntoView();
+ // Close dialog.
+ EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow);
+}
diff --git a/comm/calendar/test/browser/recurrence/browser_weeklyWithException.js b/comm/calendar/test/browser/recurrence/browser_weeklyWithException.js
new file mode 100644
index 0000000000..fbe007ea45
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_weeklyWithException.js
@@ -0,0 +1,264 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { handleDeleteOccurrencePrompt } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+
+var { menulistSelect, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { dayView, weekView, multiweekView, monthView } = CalendarTestUtils;
+
+const HOUR = 8;
+const STARTDATE = cal.createDateTime("20090106T000000Z");
+const TITLE = "Event";
+
+add_task(async function testWeeklyWithExceptionRecurrence() {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5);
+
+ // Create weekly recurring event.
+ let eventBox = dayView.getHourBoxAt(window, HOUR);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ await setData(dialogWindow, iframeWindow, { title: TITLE, repeat: setRecurrence });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ let eventItem = await dayView.waitForEventBoxAt(window, 1);
+ let icon = eventItem.querySelector(".item-recurrence-icon");
+ Assert.equal(icon.src, "chrome://messenger/skin/icons/new/recurrence.svg");
+ Assert.ok(!icon.hidden);
+
+ // Move 5th January occurrence to 6th January.
+ ({ dialogWindow, iframeWindow } = await dayView.editEventOccurrenceAt(window, 1));
+ await setData(dialogWindow, iframeWindow, {
+ title: TITLE,
+ startdate: STARTDATE,
+ enddate: STARTDATE,
+ });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ await CalendarTestUtils.goToDate(window, 2009, 1, 6);
+ eventItem = await dayView.waitForEventBoxAt(window, 1);
+ icon = eventItem.querySelector(".item-recurrence-icon");
+ Assert.equal(icon.src, "chrome://messenger/skin/icons/new/recurrence-exception.svg");
+
+ // Change recurrence rule.
+ await CalendarTestUtils.goToDate(window, 2009, 1, 7);
+ ({ dialogWindow, iframeWindow } = await dayView.editEventOccurrencesAt(window, 1));
+ await setData(dialogWindow, iframeWindow, {
+ title: "Event",
+ repeat: changeRecurrence,
+ });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check two weeks.
+ // day view
+ await CalendarTestUtils.setCalendarView(window, "day");
+
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5);
+ await dayView.waitForNoEventBoxAt(window, 1);
+
+ await CalendarTestUtils.calendarViewForward(window, 1);
+
+ // Assert exactly two.
+ Assert.ok(!!(await dayView.waitForEventBoxAt(window, 1)));
+ Assert.ok(!!(await dayView.waitForEventBoxAt(window, 2)));
+
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForNoEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForNoEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForNoEventBoxAt(window, 1);
+
+ // next week
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForNoEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForNoEventBoxAt(window, 1);
+
+ // week view
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5);
+
+ // Assert exactly two on Tuesday.
+ Assert.ok(!!(await weekView.waitForEventBoxAt(window, 3, 1)));
+ Assert.ok(!!(await weekView.waitForEventBoxAt(window, 3, 2)));
+
+ // Wait for the last occurrence because this appears last.
+ eventItem = await weekView.waitForEventBoxAt(window, 6, 1);
+ icon = eventItem.querySelector(".item-recurrence-icon");
+ Assert.equal(icon.src, "chrome://messenger/skin/icons/new/recurrence.svg");
+ Assert.ok(!icon.hidden);
+
+ Assert.ok(!weekView.getEventBoxAt(window, 1, 1));
+ Assert.ok(!weekView.getEventBoxAt(window, 2, 1));
+ Assert.ok(!!weekView.getEventBoxAt(window, 4, 1));
+ Assert.ok(!weekView.getEventBoxAt(window, 5, 1));
+ Assert.ok(!weekView.getEventBoxAt(window, 7, 1));
+
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await weekView.waitForEventBoxAt(window, 6, 1);
+ Assert.ok(!weekView.getEventBoxAt(window, 1, 1));
+ Assert.ok(!!weekView.getEventBoxAt(window, 2, 1));
+ Assert.ok(!!weekView.getEventBoxAt(window, 3, 1));
+ Assert.ok(!!weekView.getEventBoxAt(window, 4, 1));
+ Assert.ok(!weekView.getEventBoxAt(window, 5, 1));
+ Assert.ok(!weekView.getEventBoxAt(window, 7, 1));
+
+ // multiweek view
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5);
+ // Wait for the first items, then check the ones not to be present.
+ // Assert exactly two.
+ await multiweekView.waitForItemAt(window, 1, 3, 1, 1);
+ Assert.ok(multiweekView.getItemAt(window, 1, 3, 2, 1));
+ Assert.ok(!multiweekView.getItemAt(window, 1, 3, 3, 1));
+ // Then check no item on the 5th.
+ Assert.ok(!multiweekView.getItemAt(window, 1, 2, 1));
+ Assert.ok(multiweekView.getItemAt(window, 1, 4, 1));
+ Assert.ok(!multiweekView.getItemAt(window, 1, 5, 1));
+ Assert.ok(multiweekView.getItemAt(window, 1, 6, 1));
+ Assert.ok(!multiweekView.getItemAt(window, 1, 7, 1));
+
+ Assert.ok(!multiweekView.getItemAt(window, 2, 1, 1));
+ Assert.ok(multiweekView.getItemAt(window, 2, 2, 1));
+ Assert.ok(multiweekView.getItemAt(window, 2, 3, 1));
+ Assert.ok(multiweekView.getItemAt(window, 2, 4, 1));
+ Assert.ok(!multiweekView.getItemAt(window, 2, 5, 1));
+ Assert.ok(multiweekView.getItemAt(window, 2, 6, 1));
+ Assert.ok(!multiweekView.getItemAt(window, 2, 7, 1));
+
+ eventItem = multiweekView.getItemAt(window, 2, 4, 1);
+ icon = eventItem.querySelector(".item-recurrence-icon");
+ Assert.equal(icon.src, "chrome://messenger/skin/icons/new/recurrence.svg");
+ Assert.ok(!icon.hidden);
+
+ // month view
+ await CalendarTestUtils.setCalendarView(window, "month");
+ // Wait for the first items, then check the ones not to be present.
+ // Assert exactly two.
+ // start on the second week
+ await monthView.waitForItemAt(window, 2, 3, 1);
+ Assert.ok(monthView.getItemAt(window, 2, 3, 2));
+ Assert.ok(!monthView.getItemAt(window, 2, 3, 3));
+ // Then check no item on the 5th.
+ Assert.ok(!monthView.getItemAt(window, 2, 2, 1));
+ Assert.ok(monthView.getItemAt(window, 2, 4, 1));
+ Assert.ok(!monthView.getItemAt(window, 2, 5, 1));
+ Assert.ok(monthView.getItemAt(window, 2, 6, 1));
+ Assert.ok(!monthView.getItemAt(window, 2, 7, 1));
+
+ Assert.ok(!monthView.getItemAt(window, 3, 1, 1));
+ Assert.ok(monthView.getItemAt(window, 3, 2, 1));
+ Assert.ok(monthView.getItemAt(window, 3, 3, 1));
+ Assert.ok(monthView.getItemAt(window, 3, 4, 1));
+ Assert.ok(!monthView.getItemAt(window, 3, 5, 1));
+ Assert.ok(monthView.getItemAt(window, 3, 6, 1));
+ Assert.ok(!monthView.getItemAt(window, 3, 7, 1));
+
+ eventItem = monthView.getItemAt(window, 3, 6, 1);
+ icon = eventItem.querySelector(".item-recurrence-icon");
+ Assert.equal(icon.src, "chrome://messenger/skin/icons/new/recurrence.svg");
+ Assert.ok(!icon.hidden);
+
+ // Delete event.
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 12);
+ eventBox = await dayView.waitForEventBoxAt(window, 1);
+ EventUtils.synthesizeMouseAtCenter(eventBox, {}, window);
+ await handleDeleteOccurrencePrompt(window, eventBox, true);
+ await dayView.waitForNoEventBoxAt(window, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
+
+async function setRecurrence(recurrenceWindow) {
+ let recurrenceDocument = recurrenceWindow.document;
+
+ // weekly
+ await menulistSelect(recurrenceDocument.getElementById("period-list"), "1");
+
+ let mon = cal.l10n.getDateFmtString("day.2.Mmm");
+ let wed = cal.l10n.getDateFmtString("day.4.Mmm");
+ let fri = cal.l10n.getDateFmtString("day.6.Mmm");
+
+ let dayPicker = recurrenceDocument.getElementById("daypicker-weekday");
+
+ // Starting from Monday so it should be checked.
+ Assert.ok(dayPicker.querySelector(`[label="${mon}"]`).checked, "mon checked");
+
+ // Check Wednesday and Friday too.
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${wed}"]`),
+ {},
+ recurrenceWindow
+ );
+ Assert.ok(dayPicker.querySelector(`[label="${wed}"]`).checked, "wed checked");
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${fri}"]`),
+ {},
+ recurrenceWindow
+ );
+ Assert.ok(dayPicker.querySelector(`[label="${fri}"]`).checked, "fri checked");
+
+ let button = recurrenceDocument.querySelector("dialog").getButton("accept");
+ button.scrollIntoView();
+ // Close dialog.
+ EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow);
+}
+
+async function changeRecurrence(recurrenceWindow) {
+ let recurrenceDocument = recurrenceWindow.document;
+
+ // weekly
+ await menulistSelect(recurrenceDocument.getElementById("period-list"), "1");
+
+ let mon = cal.l10n.getDateFmtString("day.2.Mmm");
+ let tue = cal.l10n.getDateFmtString("day.3.Mmm");
+ let wed = cal.l10n.getDateFmtString("day.4.Mmm");
+ let fri = cal.l10n.getDateFmtString("day.6.Mmm");
+
+ let dayPicker = recurrenceDocument.getElementById("daypicker-weekday");
+
+ // Check old rule.
+ // Starting from Monday so it should be checked.
+ Assert.ok(dayPicker.querySelector(`[label="${mon}"]`).checked, "mon checked");
+ Assert.ok(dayPicker.querySelector(`[label="${wed}"]`).checked, "wed checked");
+ Assert.ok(dayPicker.querySelector(`[label="${fri}"]`).checked, "fri checked");
+
+ // Check Tuesday.
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${tue}"]`),
+ {},
+ recurrenceWindow
+ );
+ Assert.ok(dayPicker.querySelector(`[label="${tue}"]`).checked, "tue checked");
+
+ let button = recurrenceDocument.querySelector("dialog").getButton("accept");
+ button.scrollIntoView();
+ // Close dialog.
+ EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow);
+}
diff --git a/comm/calendar/test/browser/recurrence/head.js b/comm/calendar/test/browser/recurrence/head.js
new file mode 100644
index 0000000000..efcee250b6
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/head.js
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// The tests in this folder frequently take too long. Give them more time.
+requestLongerTimeout(2);
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+/* globals toggleOrientation */
+
+let isRotated =
+ document.getElementById("calendar_toggle_orientation_command").getAttribute("checked") == "true";
+let shouldBeRotated = Services.prefs.getBoolPref("calendar.test.rotateViews", false);
+
+if (isRotated != shouldBeRotated) {
+ toggleOrientation();
+}
+
+const calendarViewsInitialState = CalendarTestUtils.saveCalendarViewsState(window);
+
+registerCleanupFunction(async () => {
+ await CalendarTestUtils.restoreCalendarViewsState(window, calendarViewsInitialState);
+});
diff --git a/comm/calendar/test/browser/timezones/browser.ini b/comm/calendar/test/browser/timezones/browser.ini
new file mode 100644
index 0000000000..2fe2a0b4ea
--- /dev/null
+++ b/comm/calendar/test/browser/timezones/browser.ini
@@ -0,0 +1,17 @@
+[default]
+prefs =
+ calendar.item.promptDelete=false
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = data/**
+
+[browser_minimonth.js]
+[browser_timezones.js]
+skip-if = debug # Takes way too long, bug 1746973.
diff --git a/comm/calendar/test/browser/timezones/browser_minimonth.js b/comm/calendar/test/browser/timezones/browser_minimonth.js
new file mode 100644
index 0000000000..eba6bb2485
--- /dev/null
+++ b/comm/calendar/test/browser/timezones/browser_minimonth.js
@@ -0,0 +1,215 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests the minimonth widget in a range of time zones. It will fail if the
+ * widget loses time zone awareness.
+ */
+
+/* eslint-disable no-restricted-syntax */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+add_setup(async function () {
+ await CalendarTestUtils.openCalendarTab(window);
+});
+
+registerCleanupFunction(async function () {
+ await CalendarTestUtils.closeCalendarTab(window);
+ Services.prefs.setStringPref("calendar.timezone.local", "UTC");
+});
+
+async function subtest() {
+ let zone = cal.dtz.defaultTimezone;
+ info(`Running test in ${zone.tzid}`);
+
+ // Set the minimonth to display August 2016.
+ let minimonth = document.getElementById("calMinimonth");
+ minimonth.showMonth(new Date(2016, 7, 15));
+
+ Assert.deepEqual(
+ [...minimonth.dayBoxes.keys()],
+ [
+ "2016-07-31",
+ "2016-08-01",
+ "2016-08-02",
+ "2016-08-03",
+ "2016-08-04",
+ "2016-08-05",
+ "2016-08-06",
+ "2016-08-07",
+ "2016-08-08",
+ "2016-08-09",
+ "2016-08-10",
+ "2016-08-11",
+ "2016-08-12",
+ "2016-08-13",
+ "2016-08-14",
+ "2016-08-15",
+ "2016-08-16",
+ "2016-08-17",
+ "2016-08-18",
+ "2016-08-19",
+ "2016-08-20",
+ "2016-08-21",
+ "2016-08-22",
+ "2016-08-23",
+ "2016-08-24",
+ "2016-08-25",
+ "2016-08-26",
+ "2016-08-27",
+ "2016-08-28",
+ "2016-08-29",
+ "2016-08-30",
+ "2016-08-31",
+ "2016-09-01",
+ "2016-09-02",
+ "2016-09-03",
+ "2016-09-04",
+ "2016-09-05",
+ "2016-09-06",
+ "2016-09-07",
+ "2016-09-08",
+ "2016-09-09",
+ "2016-09-10",
+ ],
+ "day boxes are stored with the correct keys"
+ );
+
+ function check(date, row, column) {
+ if (date instanceof Date) {
+ info(date);
+ } else {
+ info(`${date} ${date.timezone.tzid}`);
+ }
+ if (row && column) {
+ Assert.equal(minimonth.getBoxForDate(date), minimonth.mCalBox.rows[row].cells[column]);
+ } else {
+ Assert.equal(minimonth.getBoxForDate(date), null);
+ }
+ }
+
+ let dateWithZone = cal.createDateTime();
+
+ // Dates without timezones or the local timezone.
+
+ // All of these represent the 1st of August.
+ check(new Date(2016, 7, 1), 1, 2);
+ check(new Date(2016, 7, 1, 9, 0, 0), 1, 2);
+ check(new Date(2016, 7, 1, 22, 0, 0), 1, 2);
+
+ check(cal.createDateTime("20160801"), 1, 2);
+ check(cal.createDateTime("20160801T030000"), 1, 2);
+ check(cal.createDateTime("20160801T210000"), 1, 2);
+
+ dateWithZone.resetTo(2016, 7, 1, 3, 0, 0, zone);
+ check(dateWithZone, 1, 2);
+ dateWithZone.resetTo(2016, 7, 1, 21, 0, 0, zone);
+ check(dateWithZone, 1, 2);
+
+ // All of these represent the 31st of August.
+ check(new Date(2016, 7, 31), 5, 4);
+ check(new Date(2016, 7, 31, 9, 0, 0), 5, 4);
+ check(new Date(2016, 7, 31, 22, 0, 0), 5, 4);
+
+ check(cal.createDateTime("20160831"), 5, 4);
+ check(cal.createDateTime("20160831T030000"), 5, 4);
+ check(cal.createDateTime("20160831T210000"), 5, 4);
+
+ dateWithZone.resetTo(2016, 7, 31, 3, 0, 0, zone);
+ check(dateWithZone, 5, 4);
+ dateWithZone.resetTo(2016, 7, 31, 21, 0, 0, zone);
+ check(dateWithZone, 5, 4);
+
+ // August a year earlier shouldn't be displayed.
+ check(new Date(2015, 7, 15));
+ check(cal.createDateTime("20150815"));
+ dateWithZone.resetTo(2015, 7, 15, 0, 0, 0, zone);
+ check(dateWithZone);
+
+ // The Saturday of the previous week shouldn't be displayed.
+ check(new Date(2016, 6, 30));
+ check(cal.createDateTime("20160730"));
+ dateWithZone.resetTo(2016, 6, 30, 0, 0, 0, zone);
+ check(dateWithZone);
+
+ // The Sunday of the next week shouldn't be displayed.
+ check(new Date(2016, 8, 11));
+ check(cal.createDateTime("20160911"));
+ dateWithZone.resetTo(2016, 8, 11, 0, 0, 0, zone);
+ check(dateWithZone);
+
+ // August a year later shouldn't be displayed.
+ check(new Date(2017, 7, 15));
+ check(cal.createDateTime("20170815"));
+ dateWithZone.resetTo(2017, 7, 15, 0, 0, 0, zone);
+ check(dateWithZone);
+
+ // UTC dates.
+
+ check(cal.createDateTime("20160801T030000Z"), 1, zone.tzid == "America/Vancouver" ? 1 : 2);
+ check(cal.createDateTime("20160801T210000Z"), 1, zone.tzid == "Pacific/Auckland" ? 3 : 2);
+
+ check(cal.createDateTime("20160831T030000Z"), 5, zone.tzid == "America/Vancouver" ? 3 : 4);
+ check(cal.createDateTime("20160831T210000Z"), 5, zone.tzid == "Pacific/Auckland" ? 5 : 4);
+
+ // Dates in different zones.
+
+ let auckland = cal.timezoneService.getTimezone("Pacific/Auckland");
+ let vancouver = cal.timezoneService.getTimezone("America/Vancouver");
+
+ // Early in Auckland is the previous day everywhere else.
+ dateWithZone.resetTo(2016, 7, 15, 3, 0, 0, auckland);
+ check(dateWithZone, 3, zone.tzid == "Pacific/Auckland" ? 2 : 1);
+
+ // Late in Auckland is the same day everywhere.
+ dateWithZone.resetTo(2016, 7, 15, 21, 0, 0, auckland);
+ check(dateWithZone, 3, 2);
+
+ // Early in Vancouver is the same day everywhere.
+ dateWithZone.resetTo(2016, 7, 15, 3, 0, 0, vancouver);
+ check(dateWithZone, 3, 2);
+
+ // Late in Vancouver is the next day everywhere else.
+ dateWithZone.resetTo(2016, 7, 15, 21, 0, 0, vancouver);
+ check(dateWithZone, 3, zone.tzid == "America/Vancouver" ? 2 : 3);
+
+ // Reset the minimonth to a different month.
+ minimonth.showMonth(new Date(2016, 9, 15));
+}
+
+/**
+ * Run the test at UTC+12.
+ */
+add_task(async function auckland() {
+ Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Auckland");
+ await subtest();
+});
+
+/**
+ * Run the test at UTC+2.
+ */
+add_task(async function berlin() {
+ Services.prefs.setStringPref("calendar.timezone.local", "Europe/Berlin");
+ await subtest();
+});
+
+/**
+ * Run the test at UTC.
+ */
+add_task(async function utc() {
+ Services.prefs.setStringPref("calendar.timezone.local", "UTC");
+ await subtest();
+});
+
+/**
+ * Run the test at UTC-7.
+ */
+add_task(async function vancouver() {
+ Services.prefs.setStringPref("calendar.timezone.local", "America/Vancouver");
+ await subtest();
+});
diff --git a/comm/calendar/test/browser/timezones/browser_timezones.js b/comm/calendar/test/browser/timezones/browser_timezones.js
new file mode 100644
index 0000000000..41ce97027a
--- /dev/null
+++ b/comm/calendar/test/browser/timezones/browser_timezones.js
@@ -0,0 +1,867 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+requestLongerTimeout(3);
+
+var { findEventsInNode } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var DATES = [
+ [2009, 1, 1],
+ [2009, 4, 2],
+ [2009, 4, 16],
+ [2009, 4, 30],
+ [2009, 7, 2],
+ [2009, 10, 15],
+ [2009, 10, 29],
+ [2009, 11, 5],
+];
+
+var TIMEZONES = [
+ "America/St_Johns",
+ "America/Caracas", // standard time UTC-4:30 from 2007 to 2016
+ "America/Phoenix",
+ "America/Los_Angeles",
+ "America/Buenos_Aires", // standard time UTC-3, DST UTC-4 from October 2008 to March 2009
+ "Europe/Paris",
+ "Asia/Katmandu",
+ "Australia/Adelaide",
+];
+
+const calendarViewsInitialState = CalendarTestUtils.saveCalendarViewsState(window);
+
+add_setup(async () => {
+ registerCleanupFunction(async () => {
+ await CalendarTestUtils.restoreCalendarViewsState(window, calendarViewsInitialState);
+ Services.prefs.setStringPref("calendar.timezone.local", "UTC");
+ });
+
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ // Create weekly recurring events in all TIMEZONES.
+ let times = [
+ [4, 30],
+ [5, 0],
+ [3, 0],
+ [3, 0],
+ [9, 0],
+ [14, 0],
+ [19, 45],
+ [1, 30],
+ ];
+ let time = cal.createDateTime();
+ for (let i = 0; i < TIMEZONES.length; i++) {
+ let eventBox = CalendarTestUtils.dayView.getHourBoxAt(window, i + 11);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ time.hour = times[i][0];
+ time.minute = times[i][1];
+
+ // Set event data.
+ await setData(dialogWindow, iframeWindow, {
+ title: TIMEZONES[i],
+ repeat: "weekly",
+ repeatuntil: cal.createDateTime("20091231T000000Z"),
+ starttime: time,
+ timezone: TIMEZONES[i],
+ });
+
+ await saveAndCloseItemDialog(dialogWindow);
+ }
+});
+
+add_task(async function testTimezones3_checkStJohns() {
+ Services.prefs.setStringPref("calendar.timezone.local", "America/St_Johns");
+ let times = [
+ [
+ [4, 30],
+ [6, 0],
+ [6, 30],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [10, 30],
+ [11, 30],
+ ],
+ [
+ [4, 30],
+ [7, 0],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [9, 30],
+ [11, 30],
+ [12, 30],
+ ],
+ [
+ [4, 30],
+ [7, 0],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [9, 30],
+ [11, 30],
+ [13, 30],
+ ],
+ [
+ [4, 30],
+ [7, 0],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [9, 30],
+ [11, 30],
+ [13, 30],
+ ],
+ [
+ [4, 30],
+ [7, 0],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [9, 30],
+ [11, 30],
+ [13, 30],
+ ],
+ [
+ [4, 30],
+ [7, 0],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [9, 30],
+ [11, 30],
+ [12, 30],
+ ],
+ [
+ [4, 30],
+ [7, 0],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [10, 30],
+ [11, 30],
+ [12, 30],
+ ],
+ [
+ [4, 30],
+ [6, 0],
+ [6, 30],
+ [7, 30],
+ [8, 30],
+ [9, 30],
+ [10, 30],
+ [11, 30],
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ await verify(DATES, TIMEZONES, times);
+});
+
+add_task(async function testTimezones4_checkCaracas() {
+ Services.prefs.setStringPref("calendar.timezone.local", "America/Caracas");
+ let times = [
+ [
+ [3, 30],
+ [5, 0],
+ [5, 30],
+ [6, 30],
+ [6, 30],
+ [8, 30],
+ [9, 30],
+ [10, 30],
+ ],
+ [
+ [2, 30],
+ [5, 0],
+ [5, 30],
+ [5, 30],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [10, 30],
+ ],
+ [
+ [2, 30],
+ [5, 0],
+ [5, 30],
+ [5, 30],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [11, 30],
+ ],
+ [
+ [2, 30],
+ [5, 0],
+ [5, 30],
+ [5, 30],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [11, 30],
+ ],
+ [
+ [2, 30],
+ [5, 0],
+ [5, 30],
+ [5, 30],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [11, 30],
+ ],
+ [
+ [2, 30],
+ [5, 0],
+ [5, 30],
+ [5, 30],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [10, 30],
+ ],
+ [
+ [2, 30],
+ [5, 0],
+ [5, 30],
+ [5, 30],
+ [7, 30],
+ [8, 30],
+ [9, 30],
+ [10, 30],
+ ],
+ [
+ [3, 30],
+ [5, 0],
+ [5, 30],
+ [6, 30],
+ [7, 30],
+ [8, 30],
+ [9, 30],
+ [10, 30],
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ await verify(DATES, TIMEZONES, times);
+});
+
+add_task(async function testTimezones5_checkPhoenix() {
+ Services.prefs.setStringPref("calendar.timezone.local", "America/Phoenix");
+ let times = [
+ [
+ [1, 0],
+ [2, 30],
+ [3, 0],
+ [4, 0],
+ [4, 0],
+ [6, 0],
+ [7, 0],
+ [8, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [8, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [9, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [9, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [9, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [8, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [6, 0],
+ [7, 0],
+ [8, 0],
+ ],
+ [
+ [1, 0],
+ [2, 30],
+ [3, 0],
+ [4, 0],
+ [5, 0],
+ [6, 0],
+ [7, 0],
+ [8, 0],
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ await verify(DATES, TIMEZONES, times);
+});
+
+add_task(async function testTimezones6_checkLosAngeles() {
+ Services.prefs.setStringPref("calendar.timezone.local", "America/Los_Angeles");
+ let times = [
+ [
+ [0, 0],
+ [1, 30],
+ [2, 0],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [6, 0],
+ [7, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [8, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [9, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [9, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [9, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [8, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [6, 0],
+ [7, 0],
+ [8, 0],
+ ],
+ [
+ [0, 0],
+ [1, 30],
+ [2, 0],
+ [3, 0],
+ [4, 0],
+ [5, 0],
+ [6, 0],
+ [7, 0],
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ await verify(DATES, TIMEZONES, times);
+});
+
+add_task(async function testTimezones7_checkBuenosAires() {
+ Services.prefs.setStringPref("calendar.timezone.local", "America/Argentina/Buenos_Aires");
+ let times = [
+ [
+ [6, 0],
+ [7, 30],
+ [8, 0],
+ [9, 0],
+ [9, 0],
+ [11, 0],
+ [12, 0],
+ [13, 0],
+ ],
+ [
+ [4, 0],
+ [6, 30],
+ [7, 0],
+ [7, 0],
+ [9, 0],
+ [9, 0],
+ [11, 0],
+ [12, 0],
+ ],
+ [
+ [4, 0],
+ [6, 30],
+ [7, 0],
+ [7, 0],
+ [9, 0],
+ [9, 0],
+ [11, 0],
+ [13, 0],
+ ],
+ [
+ [4, 0],
+ [6, 30],
+ [7, 0],
+ [7, 0],
+ [9, 0],
+ [9, 0],
+ [11, 0],
+ [13, 0],
+ ],
+ [
+ [4, 0],
+ [6, 30],
+ [7, 0],
+ [7, 0],
+ [9, 0],
+ [9, 0],
+ [11, 0],
+ [13, 0],
+ ],
+ [
+ [4, 0],
+ [6, 30],
+ [7, 0],
+ [7, 0],
+ [9, 0],
+ [9, 0],
+ [11, 0],
+ [12, 0],
+ ],
+ [
+ [4, 0],
+ [6, 30],
+ [7, 0],
+ [7, 0],
+ [9, 0],
+ [10, 0],
+ [11, 0],
+ [12, 0],
+ ],
+ [
+ [5, 0],
+ [6, 30],
+ [7, 0],
+ [8, 0],
+ [9, 0],
+ [10, 0],
+ [11, 0],
+ [12, 0],
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ await verify(DATES, TIMEZONES, times);
+});
+
+add_task(async function testTimezones8_checkParis() {
+ Services.prefs.setStringPref("calendar.timezone.local", "Europe/Paris");
+ let times = [
+ [
+ [9, 0],
+ [10, 30],
+ [11, 0],
+ [12, 0],
+ [12, 0],
+ [14, 0],
+ [15, 0],
+ [16, 0],
+ ],
+ [
+ [9, 0],
+ [11, 30],
+ [12, 0],
+ [12, 0],
+ [14, 0],
+ [14, 0],
+ [16, 0],
+ [17, 0],
+ ],
+ [
+ [9, 0],
+ [11, 30],
+ [12, 0],
+ [12, 0],
+ [14, 0],
+ [14, 0],
+ [16, 0],
+ [18, 0],
+ ],
+ [
+ [9, 0],
+ [11, 30],
+ [12, 0],
+ [12, 0],
+ [14, 0],
+ [14, 0],
+ [16, 0],
+ [18, 0],
+ ],
+ [
+ [9, 0],
+ [11, 30],
+ [12, 0],
+ [12, 0],
+ [14, 0],
+ [14, 0],
+ [16, 0],
+ [18, 0],
+ ],
+ [
+ [9, 0],
+ [11, 30],
+ [12, 0],
+ [12, 0],
+ [14, 0],
+ [14, 0],
+ [16, 0],
+ [17, 0],
+ ],
+ [
+ [8, 0],
+ [10, 30],
+ [11, 0],
+ [11, 0],
+ [13, 0],
+ [14, 0],
+ [15, 0],
+ [16, 0],
+ ],
+ [
+ [9, 0],
+ [10, 30],
+ [11, 0],
+ [12, 0],
+ [13, 0],
+ [14, 0],
+ [15, 0],
+ [16, 0],
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ await verify(DATES, TIMEZONES, times);
+});
+
+add_task(async function testTimezones9_checkKathmandu() {
+ Services.prefs.setStringPref("calendar.timezone.local", "Asia/Kathmandu");
+ let times = [
+ [
+ [13, 45],
+ [15, 15],
+ [15, 45],
+ [16, 45],
+ [16, 45],
+ [18, 45],
+ [19, 45],
+ [20, 45],
+ ],
+ [
+ [12, 45],
+ [15, 15],
+ [15, 45],
+ [15, 45],
+ [17, 45],
+ [17, 45],
+ [19, 45],
+ [20, 45],
+ ],
+ [
+ [12, 45],
+ [15, 15],
+ [15, 45],
+ [15, 45],
+ [17, 45],
+ [17, 45],
+ [19, 45],
+ [21, 45],
+ ],
+ [
+ [12, 45],
+ [15, 15],
+ [15, 45],
+ [15, 45],
+ [17, 45],
+ [17, 45],
+ [19, 45],
+ [21, 45],
+ ],
+ [
+ [12, 45],
+ [15, 15],
+ [15, 45],
+ [15, 45],
+ [17, 45],
+ [17, 45],
+ [19, 45],
+ [21, 45],
+ ],
+ [
+ [12, 45],
+ [15, 15],
+ [15, 45],
+ [15, 45],
+ [17, 45],
+ [17, 45],
+ [19, 45],
+ [20, 45],
+ ],
+ [
+ [12, 45],
+ [15, 15],
+ [15, 45],
+ [15, 45],
+ [17, 45],
+ [18, 45],
+ [19, 45],
+ [20, 45],
+ ],
+ [
+ [13, 45],
+ [15, 15],
+ [15, 45],
+ [16, 45],
+ [17, 45],
+ [18, 45],
+ [19, 45],
+ [20, 45],
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ await verify(DATES, TIMEZONES, times);
+});
+
+add_task(async function testTimezones10_checkAdelaide() {
+ Services.prefs.setStringPref("calendar.timezone.local", "Australia/Adelaide");
+ let times = [
+ [
+ [18, 30],
+ [20, 0],
+ [20, 30],
+ [21, 30],
+ [21, 30],
+ [23, 30],
+ [0, 30, +1],
+ [1, 30, +1],
+ ],
+ [
+ [17, 30],
+ [20, 0],
+ [20, 30],
+ [20, 30],
+ [22, 30],
+ [22, 30],
+ [0, 30, +1],
+ [1, 30, +1],
+ ],
+ [
+ [16, 30],
+ [19, 0],
+ [19, 30],
+ [19, 30],
+ [21, 30],
+ [21, 30],
+ [23, 30],
+ [1, 30, +1],
+ ],
+ [
+ [16, 30],
+ [19, 0],
+ [19, 30],
+ [19, 30],
+ [21, 30],
+ [21, 30],
+ [23, 30],
+ [1, 30, +1],
+ ],
+ [
+ [16, 30],
+ [19, 0],
+ [19, 30],
+ [19, 30],
+ [21, 30],
+ [21, 30],
+ [23, 30],
+ [1, 30, +1],
+ ],
+ [
+ [17, 30],
+ [20, 0],
+ [20, 30],
+ [20, 30],
+ [22, 30],
+ [22, 30],
+ [0, 30, +1],
+ [1, 30, +1],
+ ],
+ [
+ [17, 30],
+ [20, 0],
+ [20, 30],
+ [20, 30],
+ [22, 30],
+ [23, 30],
+ [0, 30, +1],
+ [1, 30, +1],
+ ],
+ [
+ [18, 30],
+ [20, 0],
+ [20, 30],
+ [21, 30],
+ [22, 30],
+ [23, 30],
+ [0, 30, +1],
+ [1, 30, +1],
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ await verify(DATES, TIMEZONES, times);
+});
+
+async function verify(dates, timezones, times) {
+ function* datetimes() {
+ for (let idx = 0; idx < dates.length; idx++) {
+ yield [dates[idx][0], dates[idx][1], dates[idx][2], times[idx]];
+ }
+ }
+ let allowedDifference = 3;
+
+ for (let [selectedYear, selectedMonth, selectedDay, selectedTime] of datetimes()) {
+ info(`Verifying on day ${selectedDay}, month ${selectedMonth}, year ${selectedYear}`);
+ await CalendarTestUtils.goToDate(window, selectedYear, selectedMonth, selectedDay);
+
+ // Find event with timezone tz.
+ for (let tzIdx = 0; tzIdx < timezones.length; tzIdx++) {
+ let [hour, minutes, day] = selectedTime[tzIdx];
+ info(
+ `Verifying at ${hour} hours, ${minutes} minutes (offset: ${day || "none"}) ` +
+ `in timezone "${timezones[tzIdx]}"`
+ );
+
+ // following day
+ if (day == 1) {
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ } else if (day == -1) {
+ await CalendarTestUtils.calendarViewBackward(window, 1);
+ }
+
+ let hourRect = CalendarTestUtils.dayView.getHourBoxAt(window, hour).getBoundingClientRect();
+ let timeY = hourRect.y + hourRect.height * (minutes / 60);
+
+ // Wait for at least one event box to exist.
+ await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1);
+
+ let eventPositions = Array.from(CalendarTestUtils.dayView.getEventBoxes(window))
+ .filter(node => node.mOccurrence.title == timezones[tzIdx])
+ .map(node => node.getBoundingClientRect().y);
+
+ dump(`Looking for event at ${timeY}: found ${eventPositions.join(", ")}\n`);
+
+ if (day == 1) {
+ await CalendarTestUtils.calendarViewBackward(window, 1);
+ } else if (day == -1) {
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ }
+
+ Assert.ok(
+ eventPositions.some(pos => Math.abs(timeY - pos) < allowedDifference),
+ `There should be an event box that starts at ${hour} hours, ${minutes} minutes`
+ );
+ }
+ }
+}
diff --git a/comm/calendar/test/browser/views/browser.ini b/comm/calendar/test/browser/views/browser.ini
new file mode 100644
index 0000000000..0aae8af9d0
--- /dev/null
+++ b/comm/calendar/test/browser/views/browser.ini
@@ -0,0 +1,32 @@
+[default]
+head = head.js
+prefs =
+ calendar.item.promptDelete=false
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ # Default start of the week Thursday to make sure calendar isn't relying on
+ # built-in assumptions of week start.
+ calendar.week.start=4
+ # Default Sunday to "not a weekend" and Wednesday to "weekend" to make sure
+ # calendar isn't relying on built-in assumptions of work days.
+ calendar.week.d0sundaysoff=false
+ calendar.week.d3wednesdaysoff=true
+ # Default work hours to be from 3:00 to 12:00 to make sure calendar isn't
+ # relying on built-in assumptions of work hours.
+ calendar.view.daystarthour=3
+ calendar.view.dayendhour=12
+ calendar.view.visiblehours=3
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+
+[browser_dayView.js]
+[browser_monthView.js]
+[browser_multiweekView.js]
+[browser_propertyChanges.js]
+[browser_taskView.js]
+[browser_viewSwitch.js]
+[browser_weekView.js]
diff --git a/comm/calendar/test/browser/views/browser_dayView.js b/comm/calendar/test/browser/views/browser_dayView.js
new file mode 100644
index 0000000000..ba68a85eea
--- /dev/null
+++ b/comm/calendar/test/browser/views/browser_dayView.js
@@ -0,0 +1,185 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { formatDate, formatTime, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const TITLE1 = "Day View Event";
+const TITLE2 = "Day View Event Changed";
+const DESC = "Day View Event Description";
+
+add_setup(async function () {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+});
+
+add_task(async function testDayView() {
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ let dayView = document.getElementById("day-view");
+ // Verify date in view.
+ await TestUtils.waitForCondition(
+ () => dayView.dayColumns[0]?.date.icalString == "20090101",
+ "Inspecting the date"
+ );
+
+ // Create event at 8 AM.
+ let eventBox = CalendarTestUtils.dayView.getHourBoxAt(window, 8);
+ let { dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewEvent(
+ window,
+ eventBox
+ );
+
+ // Check that the start time is correct.
+ let someDate = cal.createDateTime();
+ someDate.resetTo(2009, 0, 1, 8, 0, 0, cal.dtz.UTC);
+
+ let startPicker = iframeDocument.getElementById("event-starttime");
+ Assert.equal(startPicker._datepicker._inputField.value, formatDate(someDate));
+ Assert.equal(startPicker._timepicker._inputField.value, formatTime(someDate));
+
+ // Fill in title, description and calendar.
+ await setData(dialogWindow, iframeWindow, {
+ title: TITLE1,
+ description: DESC,
+ calendar: "Test",
+ });
+
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // If it was created successfully, it can be opened.
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1));
+ await setData(dialogWindow, iframeWindow, { title: TITLE2 });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check if name was saved.
+ await TestUtils.waitForCondition(() => {
+ eventBox = CalendarTestUtils.dayView.getEventBoxAt(window, 1);
+ if (!eventBox) {
+ return false;
+ }
+
+ let eventName = eventBox.querySelector(".event-name-label");
+ return eventName.textContent == TITLE2;
+ }, "event was modified in the view");
+
+ // Delete event
+ EventUtils.synthesizeMouseAtCenter(eventBox, {}, window);
+ eventBox.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await CalendarTestUtils.dayView.waitForNoEventBoxAt(window, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
+
+add_task(async function testDayViewDateLabel() {
+ await CalendarTestUtils.goToDate(window, 2022, 4, 13);
+
+ let heading = CalendarTestUtils.dayView.getColumnHeading(window);
+ let labelSpan = heading.querySelector("span:not([hidden])");
+
+ Assert.equal(
+ labelSpan.textContent,
+ "Wednesday Apr 13",
+ "the date label should contain the displayed date in a human-readable string"
+ );
+});
+
+add_task(async function testDayViewCurrentDayHighlight() {
+ // Sanity check that this date (which should be in the past) is not today's
+ // date.
+ let today = new Date();
+ Assert.ok(today.getUTCFullYear() != 2022 || today.getUTCMonth() != 3 || today.getUTCDate() != 13);
+
+ // When displaying days which are not the current day, there should be no
+ // highlight.
+ await CalendarTestUtils.goToDate(window, 2022, 4, 13);
+
+ let container = CalendarTestUtils.dayView.getColumnContainer(window);
+ Assert.ok(
+ !container.classList.contains("day-column-today"),
+ "the displayed date should not be highlighted if it is not the current day"
+ );
+
+ // When displaying the current day, it should be highlighted.
+ await CalendarTestUtils.goToToday(window);
+
+ container = CalendarTestUtils.dayView.getColumnContainer(window);
+ Assert.ok(
+ container.classList.contains("day-column-today"),
+ "the displayed date should be highlighted if it is the current day"
+ );
+});
+
+add_task(async function testDayViewWorkDayHighlight() {
+ // The test configuration sets Sunday to be a work day, so it should not have
+ // the weekend background.
+ await CalendarTestUtils.goToDate(window, 2022, 4, 10);
+
+ let container = CalendarTestUtils.dayView.getColumnContainer(window);
+ Assert.ok(
+ !container.classList.contains("day-column-weekend"),
+ "the displayed date should not be highlighted if it is a work day"
+ );
+
+ await CalendarTestUtils.goToDate(window, 2022, 4, 13);
+
+ container = CalendarTestUtils.dayView.getColumnContainer(window);
+ Assert.ok(
+ container.classList.contains("day-column-weekend"),
+ "the displayed date should be highlighted if it is not a work day"
+ );
+});
+
+add_task(async function testDayViewNavbar() {
+ await CalendarTestUtils.goToDate(window, 2022, 4, 13);
+
+ let intervalDescription = CalendarTestUtils.getNavBarIntervalDescription(window);
+ Assert.equal(
+ intervalDescription.textContent,
+ "Wednesday, April 13, 2022",
+ "interval description should contain a description of the displayed date"
+ );
+
+ // Note that the value 14 here tests calculation of the calendar week based on
+ // the starting day of the week; if the calculation built in an assumption of
+ // Sunday or Monday as the starting day of the week, we would get 15 here.
+ let calendarWeek = CalendarTestUtils.getNavBarCalendarWeekBox(window);
+ Assert.equal(
+ calendarWeek.textContent,
+ "CW: 14",
+ "calendar week label should contain an indicator of which week contains displayed date"
+ );
+});
+
+add_task(async function testDayViewTodayButton() {
+ // Though this code is cribbed from the CalendarTestUtils, it should be
+ // duplicated in case the utility implementation changes.
+ let todayButton = CalendarTestUtils.getNavBarTodayButton(window);
+
+ EventUtils.synthesizeMouseAtCenter(todayButton, {}, window);
+ await CalendarTestUtils.ensureViewLoaded(window);
+
+ let displayedDate = CalendarTestUtils.dayView.getEventColumn(window).date;
+
+ let today = new Date();
+ Assert.equal(
+ displayedDate.year,
+ today.getUTCFullYear(),
+ "year of displayed date should be this year"
+ );
+ Assert.equal(
+ displayedDate.month,
+ today.getUTCMonth(),
+ "month of displayed date should be this month"
+ );
+ Assert.equal(displayedDate.day, today.getUTCDate(), "day of displayed date should be today");
+});
diff --git a/comm/calendar/test/browser/views/browser_monthView.js b/comm/calendar/test/browser/views/browser_monthView.js
new file mode 100644
index 0000000000..f3a385a3f5
--- /dev/null
+++ b/comm/calendar/test/browser/views/browser_monthView.js
@@ -0,0 +1,86 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { formatDate, formatTime, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const TITLE1 = "Month View Event";
+const TITLE2 = "Month View Event Changed";
+const DESC = "Month View Event Description";
+
+add_task(async function testMonthView() {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ // Verify date.
+ await TestUtils.waitForCondition(() => {
+ let dateLabel = document.querySelector(
+ '#month-view td[selected="true"] > calendar-month-day-box'
+ );
+ return dateLabel && dateLabel.mDate.icalString == "20090101";
+ }, "Inspecting the date");
+
+ // Create event.
+ // Thursday of 2009-01-05 should be the selected box in the first row with default settings.
+ let hour = new Date().getUTCHours(); // Remember time at click.
+ let eventBox = CalendarTestUtils.monthView.getDayBox(window, 1, 5);
+ let { dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewEvent(
+ window,
+ eventBox
+ );
+
+ // Check that the start time is correct.
+ // Next full hour except last hour hour of the day.
+ let nextHour = hour == 23 ? hour : (hour + 1) % 24;
+ let someDate = cal.dtz.now();
+ someDate.resetTo(2009, 0, 5, nextHour, 0, 0, cal.dtz.UTC);
+
+ let startPicker = iframeDocument.getElementById("event-starttime");
+ Assert.equal(startPicker._datepicker._inputField.value, formatDate(someDate));
+ Assert.equal(startPicker._timepicker._inputField.value, formatTime(someDate));
+
+ // Fill in title, description and calendar.
+ await setData(dialogWindow, iframeWindow, {
+ title: TITLE1,
+ description: DESC,
+ calendar: "Test",
+ });
+
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // If it was created successfully, it can be opened.
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.monthView.editItemAt(window, 1, 5, 1));
+ // Change title and save changes.
+ await setData(dialogWindow, iframeWindow, { title: TITLE2 });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check if name was saved.
+ let eventName;
+ await TestUtils.waitForCondition(() => {
+ eventBox = CalendarTestUtils.monthView.getItemAt(window, 1, 5, 1);
+ if (!eventBox) {
+ return false;
+ }
+ eventName = eventBox.querySelector(".event-name-label").textContent;
+ return eventName == TITLE2;
+ }, "event name did not update in time");
+
+ Assert.equal(eventName, TITLE2);
+
+ // Delete event.
+ EventUtils.synthesizeMouseAtCenter(eventBox, {}, window);
+ eventBox.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await CalendarTestUtils.monthView.waitForNoItemAt(window, 1, 5, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
diff --git a/comm/calendar/test/browser/views/browser_multiweekView.js b/comm/calendar/test/browser/views/browser_multiweekView.js
new file mode 100644
index 0000000000..feb8fcd3ec
--- /dev/null
+++ b/comm/calendar/test/browser/views/browser_multiweekView.js
@@ -0,0 +1,88 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { formatDate, formatTime, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const TITLE1 = "Multiweek View Event";
+const TITLE2 = "Multiweek View Event Changed";
+const DESC = "Multiweek View Event Description";
+
+add_task(async function () {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ // Verify date.
+ await TestUtils.waitForCondition(() => {
+ let dateLabel = document.querySelector(
+ '#multiweek-view td[selected="true"] > calendar-month-day-box'
+ );
+ return dateLabel && dateLabel.mDate.icalString == "20090101";
+ }, "Inspecting the date");
+
+ // Create event.
+ // Thursday of 2009-01-05 should be the selected box in the first row with default settings.
+ let hour = new Date().getUTCHours(); // Remember time at click.
+ let eventBox = CalendarTestUtils.multiweekView.getDayBox(window, 1, 5);
+ let { dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewEvent(
+ window,
+ eventBox
+ );
+
+ // Check that the start time is correct.
+ // Next full hour except last hour hour of the day.
+ let nextHour = hour == 23 ? hour : (hour + 1) % 24;
+ let someDate = cal.dtz.now();
+ someDate.resetTo(2009, 0, 5, nextHour, 0, 0, cal.dtz.UTC);
+
+ let startPicker = iframeDocument.getElementById("event-starttime");
+ Assert.equal(startPicker._datepicker._inputField.value, formatDate(someDate));
+ Assert.equal(startPicker._timepicker._inputField.value, formatTime(someDate));
+
+ // Fill in title, description and calendar.
+ await setData(dialogWindow, iframeWindow, {
+ title: TITLE1,
+ description: DESC,
+ calendar: "Test",
+ });
+
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // If it was created successfully, it can be opened.
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.multiweekView.editItemAt(
+ window,
+ 1,
+ 5,
+ 1
+ ));
+ // Change title and save changes.
+ await setData(dialogWindow, iframeWindow, { title: TITLE2 });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check if name was saved.
+ await TestUtils.waitForCondition(() => {
+ eventBox = CalendarTestUtils.multiweekView.getItemAt(window, 1, 5, 1);
+ if (eventBox === null) {
+ return false;
+ }
+ let eventName = eventBox.querySelector(".event-name-label");
+ return eventName && eventName.textContent == TITLE2;
+ }, "Wait for the new title");
+
+ // Delete event.
+ EventUtils.synthesizeMouseAtCenter(eventBox, {}, window);
+ eventBox.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await CalendarTestUtils.multiweekView.waitForNoItemAt(window, 1, 5, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
diff --git a/comm/calendar/test/browser/views/browser_propertyChanges.js b/comm/calendar/test/browser/views/browser_propertyChanges.js
new file mode 100644
index 0000000000..79848a0e73
--- /dev/null
+++ b/comm/calendar/test/browser/views/browser_propertyChanges.js
@@ -0,0 +1,248 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/** Tests that changes in a calendar's properties are reflected in the current view. */
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+
+let composite = cal.view.getCompositeCalendar(window);
+
+// This is the calendar we're going to change the properties of.
+let thisCalendar = CalendarTestUtils.createCalendar("This Calendar", "memory");
+thisCalendar.setProperty("color", "#ffee22");
+
+// This calendar isn't going to change, and we'll check it doesn't.
+let notThisCalendar = CalendarTestUtils.createCalendar("Not This Calendar", "memory");
+notThisCalendar.setProperty("color", "#dd3333");
+
+add_setup(async function () {
+ let { dedent } = CalendarTestUtils;
+ await thisCalendar.addItem(
+ new CalEvent(dedent`
+ BEGIN:VEVENT
+ SUMMARY:This Event 1
+ DTSTART;VALUE=DATE:20160205
+ DTEND;VALUE=DATE:20160206
+ END:VEVENT
+ `)
+ );
+ await thisCalendar.addItem(
+ new CalEvent(dedent`
+ BEGIN:VEVENT
+ SUMMARY:This Event 2
+ DTSTART:20160205T130000Z
+ DTEND:20160205T150000Z
+ END:VEVENT
+ `)
+ );
+ await thisCalendar.addItem(
+ new CalEvent(dedent`
+ BEGIN:VEVENT
+ SUMMARY:This Event 3
+ DTSTART;VALUE=DATE:20160208
+ DTEND;VALUE=DATE:20160209
+ RRULE:FREQ=DAILY;INTERVAL=2;COUNT=3
+ END:VEVENT
+ `)
+ );
+
+ await notThisCalendar.addItem(
+ new CalEvent(dedent`
+ BEGIN:VEVENT
+ SUMMARY:Not This Event 1
+ DTSTART;VALUE=DATE:20160205
+ DTEND;VALUE=DATE:20160207
+ END:VEVENT
+ `)
+ );
+ await notThisCalendar.addItem(
+ new CalEvent(dedent`
+ BEGIN:VEVENT
+ SUMMARY:Not This Event 2
+ DTSTART:20160205T140000Z
+ DTEND:20160205T170000Z
+ END:VEVENT
+ `)
+ );
+});
+
+/**
+ * Assert whether the given event box is draggable (editable).
+ *
+ * @param {MozCalendarEventBox} eventBox - The event box to test.
+ * @param {boolean} draggable - Whether we expect it to be draggable.
+ * @param {string} message - A message for assertions.
+ */
+async function assertCanDrag(eventBox, draggable, message) {
+ // Hover to see if the drag gripbars appear.
+ let enterPromise = BrowserTestUtils.waitForEvent(eventBox, "mouseenter");
+ EventUtils.synthesizeMouseAtCenter(eventBox, { type: "mouseover" }, window);
+ await enterPromise;
+ Assert.equal(
+ BrowserTestUtils.is_visible(eventBox.startGripbar),
+ draggable,
+ `Start gripbar should be ${draggable ? "visible" : "hidden"} on hover: ${message}`
+ );
+ Assert.equal(
+ BrowserTestUtils.is_visible(eventBox.endGripbar),
+ draggable,
+ `End gripbar should be ${draggable ? "visible" : "hidden"} on hover: ${message}`
+ );
+}
+
+/**
+ * Assert whether the given event element is editable.
+ *
+ * @param {Element} eventElement - The event element to test.
+ * @param {boolean} editable - Whether we expect it to be editable.
+ * @param {string} message - A message for assertions.
+ */
+async function assertEditable(eventElement, editable, message) {
+ // FIXME: Have more ways to test if an event is editable (e.g. test the
+ // context menu)
+ if (eventElement.matches("calendar-event-box")) {
+ await CalendarTestUtils.assertEventBoxDraggable(eventElement, editable, editable, message);
+ }
+}
+
+async function subTest(viewName, boxSelector, thisBoxCount, notThisBoxCount) {
+ async function makeChangeWithReload(changeFunction) {
+ await changeFunction();
+ await CalendarTestUtils.ensureViewLoaded(window);
+ }
+
+ async function checkBoxItems(expectedCount, checkFunction) {
+ await TestUtils.waitForCondition(
+ () => view.querySelectorAll(boxSelector).length == expectedCount,
+ "waiting for the correct number of boxes to be displayed"
+ );
+ let boxItems = view.querySelectorAll(boxSelector);
+
+ if (!checkFunction) {
+ return;
+ }
+
+ for (let boxItem of boxItems) {
+ // TODO: why is it named `item` in some places and `occurrence` elsewhere?
+ let isThisCalendar =
+ (boxItem.item && boxItem.item.calendar == thisCalendar) ||
+ boxItem.occurrence.calendar == thisCalendar;
+ await checkFunction(boxItem, isThisCalendar);
+ }
+ }
+
+ let view = document.getElementById(`${viewName}-view`);
+
+ await CalendarTestUtils.setCalendarView(window, viewName);
+ await CalendarTestUtils.goToDate(window, 2016, 2, 5);
+
+ info("Check initial state.");
+
+ await checkBoxItems(thisBoxCount + notThisBoxCount, async (boxItem, isThisCalendar) => {
+ let style = getComputedStyle(boxItem);
+
+ if (isThisCalendar) {
+ Assert.equal(style.backgroundColor, "rgb(255, 238, 34)", "item background correct");
+ Assert.equal(style.color, "rgb(34, 34, 34)", "item foreground correct");
+ } else {
+ Assert.equal(
+ style.backgroundColor,
+ "rgb(221, 51, 51)",
+ "item background correct (not target calendar)"
+ );
+ Assert.equal(
+ style.color,
+ "rgb(255, 255, 255)",
+ "item foreground correct (not target calendar)"
+ );
+ }
+ await assertEditable(boxItem, true, "Initial event");
+ });
+
+ info("Change color.");
+
+ thisCalendar.setProperty("color", "#16a765");
+ await checkBoxItems(thisBoxCount + notThisBoxCount, async (boxItem, isThisCalendar) => {
+ let style = getComputedStyle(boxItem);
+
+ if (isThisCalendar) {
+ Assert.equal(style.backgroundColor, "rgb(22, 167, 101)", "item background correct");
+ Assert.equal(style.color, "rgb(255, 255, 255)", "item foreground correct");
+ } else {
+ Assert.equal(
+ style.backgroundColor,
+ "rgb(221, 51, 51)",
+ "item background correct (not target calendar)"
+ );
+ Assert.equal(
+ style.color,
+ "rgb(255, 255, 255)",
+ "item foreground correct (not target calendar)"
+ );
+ }
+ });
+
+ info("Reset color.");
+ thisCalendar.setProperty("color", "#ffee22");
+
+ info("Disable.");
+
+ thisCalendar.setProperty("disabled", true);
+ await checkBoxItems(notThisBoxCount);
+
+ info("Enable.");
+
+ await makeChangeWithReload(() => thisCalendar.setProperty("disabled", false));
+ await checkBoxItems(thisBoxCount + notThisBoxCount);
+
+ info("Hide.");
+
+ composite.removeCalendar(thisCalendar);
+ await checkBoxItems(notThisBoxCount);
+
+ info("Show.");
+
+ await makeChangeWithReload(() => composite.addCalendar(thisCalendar));
+ await checkBoxItems(thisBoxCount + notThisBoxCount);
+
+ info("Set read-only.");
+
+ await makeChangeWithReload(() => thisCalendar.setProperty("readOnly", true));
+ await checkBoxItems(thisBoxCount + notThisBoxCount, async (boxItem, isThisCalendar) => {
+ if (isThisCalendar) {
+ await assertEditable(boxItem, false, "In readonly calendar");
+ } else {
+ await assertEditable(boxItem, true, "In non-readonly calendar");
+ }
+ });
+
+ info("Clear read-only.");
+
+ await makeChangeWithReload(() => thisCalendar.setProperty("readOnly", false));
+ await checkBoxItems(thisBoxCount + notThisBoxCount, async boxItem => {
+ await assertEditable(boxItem, true, "In non-readonly calendar after clearing");
+ });
+}
+
+add_task(async function testMonthView() {
+ await subTest("month", "calendar-month-day-box-item", 5, 3);
+});
+
+add_task(async function testMultiWeekView() {
+ await subTest("multiweek", "calendar-month-day-box-item", 5, 3);
+});
+
+add_task(async function testWeekView() {
+ await subTest("week", "calendar-editable-item, .multiday-events-list calendar-event-box", 4, 3);
+});
+
+add_task(async function testDayView() {
+ await subTest("day", "calendar-editable-item, .multiday-events-list calendar-event-box", 2, 2);
+});
+
+registerCleanupFunction(async () => {
+ CalendarTestUtils.removeCalendar(thisCalendar);
+ CalendarTestUtils.removeCalendar(notThisCalendar);
+});
diff --git a/comm/calendar/test/browser/views/browser_taskView.js b/comm/calendar/test/browser/views/browser_taskView.js
new file mode 100644
index 0000000000..c049c9668f
--- /dev/null
+++ b/comm/calendar/test/browser/views/browser_taskView.js
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MID_SLEEP, execEventDialogCallback } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+const TITLE = "Task";
+const DESCRIPTION = "1. Do A\n2. Do B";
+const PERCENTCOMPLETE = "50";
+
+add_task(async function () {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ // Open task view.
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("tasksButton"), {}, window);
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, MID_SLEEP));
+
+ // Make sure that testing calendar is selected.
+ let calList = document.querySelector(`#calendar-list > [calendar-id="${calendar.id}"]`);
+ Assert.ok(calList);
+ EventUtils.synthesizeMouseAtCenter(calList, {}, window);
+
+ let taskTreeNode = document.getElementById("calendar-task-tree");
+ Assert.equal(taskTreeNode.mTaskArray.length, 0);
+
+ // Add task.
+ let taskInput = document.getElementById("view-task-edit-field");
+ taskInput.focus();
+ EventUtils.sendString(TITLE, window);
+ EventUtils.synthesizeKey("VK_RETURN", {}, window);
+
+ // Verify added.
+ await TestUtils.waitForCondition(
+ () => taskTreeNode.mTaskArray.length == 1,
+ "Added Task did not appear"
+ );
+
+ // Last added task is automatically selected so verify detail window data.
+ Assert.equal(document.getElementById("calendar-task-details-title").textContent, TITLE);
+
+ // Open added task
+ // Double-click on completion checkbox is ignored as opening action, so don't
+ // click at immediate left where the checkbox is located.
+ let eventWindowPromise = CalendarTestUtils.waitForEventDialog("edit");
+ let treeChildren = document.querySelector("#calendar-task-tree .calendar-task-treechildren");
+ Assert.ok(treeChildren);
+ EventUtils.synthesizeMouse(treeChildren, 50, 0, { clickCount: 2 }, window);
+
+ await eventWindowPromise;
+ await execEventDialogCallback(async (taskWindow, iframeWindow) => {
+ // Verify calendar.
+ Assert.equal(iframeWindow.document.getElementById("item-calendar").value, "Test");
+
+ await setData(taskWindow, iframeWindow, {
+ status: "needs-action",
+ percent: PERCENTCOMPLETE,
+ description: DESCRIPTION,
+ });
+
+ await saveAndCloseItemDialog(taskWindow);
+ });
+
+ Assert.less(taskTreeNode.mTaskArray.length, 2, "Should not have added task");
+ Assert.greater(taskTreeNode.mTaskArray.length, 0, "Should not have removed task");
+
+ // Verify description and status in details pane.
+ await TestUtils.waitForCondition(() => {
+ let desc = document.getElementById("calendar-task-details-description");
+ return desc && desc.contentDocument.body.innerText == DESCRIPTION;
+ }, "Calendar task description");
+ Assert.equal(document.getElementById("calendar-task-details-status").textContent, "Needs Action");
+
+ // This is a hack.
+ taskTreeNode.getTaskAtRow(0).calendar.setProperty("capabilities.priority.supported", true);
+
+ // Set high priority and verify it in detail pane.
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("task-actions-priority"), {}, window);
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, MID_SLEEP));
+
+ let priorityMenu = document.querySelector(
+ "#task-actions-priority-menupopup > .priority-1-menuitem"
+ );
+ Assert.ok(priorityMenu);
+ EventUtils.synthesizeMouseAtCenter(priorityMenu, {}, window);
+ await TestUtils.waitForCondition(
+ () => !document.getElementById("calendar-task-details-priority-high").hidden,
+ "#calendar-task-details-priority-high did not show"
+ );
+
+ // Verify that tooltip shows status, priority and percent complete.
+ let toolTipNode = document.getElementById("taskTreeTooltip");
+ toolTipNode.ownerGlobal.showToolTip(toolTipNode, taskTreeNode.getTaskAtRow(0));
+
+ function getTooltipDescription(index) {
+ return toolTipNode.querySelector(
+ `.tooltipHeaderTable > tr:nth-of-type(${index}) > .tooltipHeaderDescription`
+ ).textContent;
+ }
+
+ // Name
+ Assert.equal(getTooltipDescription(1), TITLE);
+ // Calendar
+ Assert.equal(getTooltipDescription(2), "Test");
+ // Priority
+ Assert.equal(getTooltipDescription(3), "High");
+ // Status
+ Assert.equal(getTooltipDescription(4), "Needs Action");
+ // Complete
+ Assert.equal(getTooltipDescription(5), PERCENTCOMPLETE + "%");
+
+ // Mark completed, verify.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("task-actions-markcompleted"),
+ {},
+ window
+ );
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, MID_SLEEP));
+
+ toolTipNode.ownerGlobal.showToolTip(toolTipNode, taskTreeNode.getTaskAtRow(0));
+ Assert.equal(getTooltipDescription(4), "Completed");
+
+ // Delete task and verify.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("calendar-delete-task-button"),
+ {},
+ window
+ );
+ await TestUtils.waitForCondition(
+ () => taskTreeNode.mTaskArray.length == 0,
+ "Task did not delete"
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tabmail.currentTabInfo);
+
+ Assert.ok(true, "Test ran to completion");
+});
diff --git a/comm/calendar/test/browser/views/browser_viewSwitch.js b/comm/calendar/test/browser/views/browser_viewSwitch.js
new file mode 100644
index 0000000000..e730f3d797
--- /dev/null
+++ b/comm/calendar/test/browser/views/browser_viewSwitch.js
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests that the time indicator is restarted and scroll position is restored
+ * when switching tabs or views.
+ */
+
+/**
+ * Wait until the view's timebar shows the given number of visible hours.
+ *
+ * @param {CalendarMultidayBaseView} view - The calendar view.
+ * @param {number} numHours - The expected number of visible hours.
+ *
+ * @returns {Promise} - Promise that resolves when the timebar has numHours
+ * visible hours.
+ */
+function waitForVisibleHours(view, numHours) {
+ // The timebar is the only scrollable child in its column (the others are
+ // sticky), so the difference between the scroll area's scrollTopMax and the
+ // timebar's clientHeight should give us the visible height.
+ return TestUtils.waitForCondition(() => {
+ let timebarHeight = view.timebar.clientHeight;
+ let visiblePx = timebarHeight - view.grid.scrollTopMax;
+ let expectPx = (numHours / 24) * timebarHeight;
+ // Allow up to 3px difference to accommodate accumulated integer rounding
+ // errors (e.g. clientHeight is a rounded integer, whilst client rectangles
+ // and expectPx are floating).
+ return Math.abs(visiblePx - expectPx) < 3;
+ }, `${view.id} should have ${numHours} hours visible`);
+}
+
+/**
+ * Wait until the view's timebar's first visible hour is the given hour.
+ *
+ * @param {CalendarMultidayBaseView} view - The calendar view.
+ * @param {number} hour - The expected first visible hour.
+ *
+ * @returns {Promise} - Promise that resolves when the timebar has the given
+ * first visible hour.
+ */
+function waitForFirstVisibleHour(view, hour) {
+ return TestUtils.waitForCondition(() => {
+ let expectPx = (hour / 24) * view.timebar.clientHeight;
+ let actualPx = view.grid.scrollTop;
+ return Math.abs(actualPx - expectPx) < 3;
+ }, `${view.id} first visible hour should be ${hour}`);
+}
+
+/**
+ * Perform a scroll on the view by one hour.
+ *
+ * @param {CalendarMultidayBaseView} view - The calendar view to scroll.
+ * @param {boolean} scrollDown - Whether to scroll down, otherwise scrolls up.
+ */
+async function doScroll(view, scrollDown) {
+ let scrollPromise = BrowserTestUtils.waitForEvent(view.grid, "scroll");
+ let viewRect = view.getBoundingClientRect();
+ EventUtils.synthesizeWheel(
+ view.grid,
+ viewRect.width / 2,
+ viewRect.height / 2,
+ { deltaY: scrollDown ? 1 : -1, deltaMode: WheelEvent.DOM_DELTA_LINE },
+ window
+ );
+ await scrollPromise;
+}
+
+add_task(async function () {
+ let expectedVisibleHours = 3;
+ let expectedStartHour = 3;
+
+ let tabmail = document.getElementById("tabmail");
+ Assert.equal(tabmail.tabInfo.length, 1);
+
+ Assert.equal(Services.prefs.getIntPref("calendar.view.daystarthour"), expectedStartHour);
+ Assert.equal(Services.prefs.getIntPref("calendar.view.dayendhour"), 12);
+ Assert.equal(Services.prefs.getIntPref("calendar.view.visiblehours"), expectedVisibleHours);
+
+ // Open the day view, check the display matches the prefs.
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+
+ let dayView = document.getElementById("day-view");
+
+ await waitForFirstVisibleHour(dayView, expectedStartHour);
+ await waitForVisibleHours(dayView, expectedVisibleHours);
+
+ // Scroll down 3 hours. We'll check this scroll position later.
+ await doScroll(dayView, true);
+ await waitForFirstVisibleHour(dayView, expectedStartHour + 1);
+
+ await doScroll(dayView, true);
+ await doScroll(dayView, true);
+ await waitForFirstVisibleHour(dayView, expectedStartHour + 3);
+ await waitForVisibleHours(dayView, expectedVisibleHours);
+
+ // Open the week view, check the display matches the prefs.
+
+ await CalendarTestUtils.setCalendarView(window, "week");
+
+ let weekView = document.getElementById("week-view");
+
+ await waitForFirstVisibleHour(weekView, expectedStartHour);
+ await waitForVisibleHours(weekView, expectedVisibleHours);
+
+ // Scroll up 1 hour.
+ await doScroll(weekView, false);
+ await waitForFirstVisibleHour(weekView, expectedStartHour - 1);
+ await waitForVisibleHours(weekView, expectedVisibleHours);
+
+ // Go back to the day view, check the timer and scroll position.
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+
+ await waitForFirstVisibleHour(dayView, expectedStartHour + 3);
+ await waitForVisibleHours(dayView, expectedVisibleHours);
+
+ // Switch away from the calendar tab.
+
+ tabmail.switchToTab(0);
+
+ // Switch back to the calendar tab. Check scroll position.
+
+ tabmail.switchToTab(1);
+ Assert.equal(window.currentView().id, "day-view");
+
+ await waitForFirstVisibleHour(dayView, expectedStartHour + 3);
+ await waitForVisibleHours(dayView, expectedVisibleHours);
+
+ // Go back to the week view. Check scroll position.
+
+ await CalendarTestUtils.setCalendarView(window, "week");
+
+ await waitForFirstVisibleHour(weekView, expectedStartHour - 1);
+ await waitForVisibleHours(weekView, expectedVisibleHours);
+});
diff --git a/comm/calendar/test/browser/views/browser_weekView.js b/comm/calendar/test/browser/views/browser_weekView.js
new file mode 100644
index 0000000000..0835da2f23
--- /dev/null
+++ b/comm/calendar/test/browser/views/browser_weekView.js
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { formatDate, formatTime, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var TITLE1 = "Week View Event";
+var TITLE2 = "Week View Event Changed";
+var DESC = "Week View Event Description";
+
+add_task(async function testWeekView() {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ // Verify date.
+ await TestUtils.waitForCondition(() => {
+ let dateLabel = document.querySelector("#week-view .day-column-selected calendar-event-column");
+ return dateLabel?.date.icalString == "20090101";
+ }, "Date is selected");
+
+ // Create event at 8 AM.
+ // Thursday of 2009-01-05 is 4th with default settings.
+ let eventBox = CalendarTestUtils.weekView.getHourBoxAt(window, 5, 8);
+ let { dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewEvent(
+ window,
+ eventBox
+ );
+
+ // Check that the start time is correct.
+ let someDate = cal.createDateTime();
+ someDate.resetTo(2009, 0, 5, 8, 0, 0, cal.dtz.UTC);
+
+ let startPicker = iframeDocument.getElementById("event-starttime");
+ Assert.equal(startPicker._datepicker._inputField.value, formatDate(someDate));
+ Assert.equal(startPicker._timepicker._inputField.value, formatTime(someDate));
+
+ // Fill in title, description and calendar.
+ await setData(dialogWindow, iframeWindow, {
+ title: TITLE1,
+ description: DESC,
+ calendar: "Test",
+ });
+
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // If it was created successfully, it can be opened.
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.weekView.editEventAt(window, 5, 1));
+ // Change title and save changes.
+ await setData(dialogWindow, iframeWindow, { title: TITLE2 });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check if name was saved.
+ let eventName;
+ await TestUtils.waitForCondition(() => {
+ eventBox = CalendarTestUtils.weekView.getEventBoxAt(window, 5, 1);
+ if (!eventBox) {
+ return false;
+ }
+ eventName = eventBox.querySelector(".event-name-label").textContent;
+ return eventName == TITLE2;
+ }, "event name did not update in time");
+
+ Assert.equal(eventName, TITLE2);
+
+ // Delete event.
+ EventUtils.synthesizeMouseAtCenter(eventBox, {}, window);
+ eventBox.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await CalendarTestUtils.weekView.waitForNoEventBoxAt(window, 5, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
diff --git a/comm/calendar/test/browser/views/head.js b/comm/calendar/test/browser/views/head.js
new file mode 100644
index 0000000000..c0f924d9b5
--- /dev/null
+++ b/comm/calendar/test/browser/views/head.js
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+const calendarViewsInitialState = CalendarTestUtils.saveCalendarViewsState(window);
+
+registerCleanupFunction(async () => {
+ await CalendarTestUtils.restoreCalendarViewsState(window, calendarViewsInitialState);
+});
diff --git a/comm/calendar/test/moz.build b/comm/calendar/test/moz.build
new file mode 100644
index 0000000000..8c6dc6b9db
--- /dev/null
+++ b/comm/calendar/test/moz.build
@@ -0,0 +1,30 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+BROWSER_CHROME_MANIFESTS += [
+ "browser/browser.ini",
+ "browser/contextMenu/browser.ini",
+ "browser/eventDialog/browser.ini",
+ "browser/invitations/browser.ini",
+ "browser/preferences/browser.ini",
+ "browser/providers/browser.ini",
+ "browser/recurrence/browser.ini",
+ "browser/recurrence/browser_rotated.ini",
+ "browser/timezones/browser.ini",
+ "browser/views/browser.ini",
+]
+
+TESTING_JS_MODULES.calendar += [
+ "CalDAVServer.jsm",
+ "CalendarTestUtils.jsm",
+ "CalendarUtils.jsm",
+ "ICSServer.jsm",
+ "ItemEditingHelpers.jsm",
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "unit/providers/xpcshell.ini",
+ "unit/xpcshell.ini",
+]
diff --git a/comm/calendar/test/unit/data/bug1790339.sql b/comm/calendar/test/unit/data/bug1790339.sql
new file mode 100644
index 0000000000..ad464de995
--- /dev/null
+++ b/comm/calendar/test/unit/data/bug1790339.sql
@@ -0,0 +1,194 @@
+-- Data for test_bug1790339.js. There's two events here, one saved with folded ICAL strings
+-- (as libical would do) and one with unfolded ICAL strings (as ical.js does).
+--
+-- This file contains significant white-space and is deliberately saved with Windows line-endings.
+
+CREATE TABLE cal_calendar_schema_version (version INTEGER);
+CREATE TABLE cal_attendees (
+ item_id TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ cal_id TEXT,
+ icalString TEXT);
+CREATE TABLE cal_recurrence (item_id TEXT, cal_id TEXT, icalString TEXT);
+CREATE TABLE cal_properties (
+ item_id TEXT,
+ key TEXT,
+ value BLOB,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ cal_id TEXT);
+CREATE TABLE cal_events (
+ cal_id TEXT,
+ id TEXT,
+ time_created INTEGER,
+ last_modified INTEGER,
+ title TEXT,
+ priority INTEGER,
+ privacy TEXT,
+ ical_status TEXT,
+ flags INTEGER,
+ event_start INTEGER,
+ event_end INTEGER,
+ event_stamp INTEGER,
+ event_start_tz TEXT,
+ event_end_tz TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ alarm_last_ack INTEGER,
+ offline_journal INTEGER);
+CREATE TABLE cal_todos (
+ cal_id TEXT,
+ id TEXT,
+ time_created INTEGER,
+ last_modified INTEGER,
+ title TEXT,
+ priority INTEGER,
+ privacy TEXT,
+ ical_status TEXT,
+ flags INTEGER,
+ todo_entry INTEGER,
+ todo_due INTEGER,
+ todo_completed INTEGER,
+ todo_complete INTEGER,
+ todo_entry_tz TEXT,
+ todo_due_tz TEXT,
+ todo_completed_tz TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ alarm_last_ack INTEGER,
+ todo_stamp INTEGER,
+ offline_journal INTEGER);
+CREATE TABLE cal_tz_version (version TEXT);
+CREATE TABLE cal_metadata (cal_id TEXT, item_id TEXT, value BLOB);
+CREATE TABLE cal_alarms (
+ cal_id TEXT,
+ item_id TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ icalString TEXT);
+CREATE TABLE cal_relations (
+ cal_id TEXT,
+ item_id TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ icalString TEXT);
+CREATE TABLE cal_attachments (
+ item_id TEXT,
+ cal_id TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ icalString TEXT);
+CREATE TABLE cal_parameters (
+ cal_id TEXT,
+ item_id TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ key1 TEXT,
+ key2 TEXT,
+ value BLOB);
+
+INSERT INTO cal_calendar_schema_version VALUES (23);
+
+INSERT INTO cal_events (
+ cal_id,
+ id,
+ time_created,
+ last_modified,
+ title,
+ flags,
+ event_start,
+ event_end,
+ event_stamp,
+ event_start_tz,
+ event_end_tz
+) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 1663028606000000,
+ 1663030277000000,
+ 'test',
+ 86,
+ 1663032600000000,
+ 1663037100000000,
+ 1663030277000000,
+ 'Pacific/Auckland',
+ 'Pacific/Auckland'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 1663028606000000,
+ 1663030277000000,
+ 'test',
+ 86,
+ 1663032600000000,
+ 1663037100000000,
+ 1663030277000000,
+ 'Pacific/Auckland',
+ 'Pacific/Auckland'
+);
+
+INSERT INTO cal_attachments (cal_id, item_id, icalString) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 'ATTACH:https://ftp.mozilla.org/pub/thunderbird/nightly/latest-comm-central
+ /thunderbird-106.0a1.en-US.linux-x86_64.tar.bz2'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 'ATTACH:https://ftp.mozilla.org/pub/thunderbird/nightly/latest-comm-central/thunderbird-106.0a1.en-US.linux-x86_64.tar.bz2'
+);
+
+INSERT INTO cal_attendees (cal_id, item_id, icalString) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 'ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CN=Test Person:mailto:
+ test@example.com'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 'ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CN=Test Person:mailto:test@example.com'
+);
+
+INSERT INTO cal_recurrence (cal_id, item_id, icalString) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 'RRULE:FREQ=WEEKLY;UNTIL=20220913T013000Z;INTERVAL=22;BYDAY=MO,TU,WE,TH,FR,
+ SA,SU'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 'RRULE:FREQ=WEEKLY;UNTIL=20220913T013000Z;INTERVAL=22;BYDAY=MO,TU,WE,TH,FR,SA,SU'
+);
+
+INSERT INTO cal_relations (cal_id, item_id, icalString) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 'RELATED-TO;RELTYPE=SIBLING:19960401-080045-4000F192713@
+ example.com'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 'RELATED-TO;RELTYPE=SIBLING:19960401-080045-4000F192713@example.com'
+);
+
+INSERT INTO cal_alarms (cal_id, item_id, icalString) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 'BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-PT5M
+DESCRIPTION:Make sure you don''t miss this very very important event. It''s
+ essential that you don''t forget.
+END:VALARM
+'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 'BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-PT5M
+DESCRIPTION:Make sure you don''t miss this very very important event. It''s essential that you don''t forget.
+END:VALARM
+'
+);
diff --git a/comm/calendar/test/unit/data/import.ics b/comm/calendar/test/unit/data/import.ics
new file mode 100644
index 0000000000..b6e7a965d7
--- /dev/null
+++ b/comm/calendar/test/unit/data/import.ics
@@ -0,0 +1,24 @@
+BEGIN:VCALENDAR
+PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:Event One
+DTSTART:20190101T150000
+DTEND:20190101T160000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Four
+DTSTART:20190101T180000
+DTEND:20190101T190000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Three
+DTSTART:20190101T170000
+DTEND:20190101T180000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Two
+DTSTART:20190101T160000
+DTEND:20190101T170000
+END:VEVENT
+END:VCALENDAR
diff --git a/comm/calendar/test/unit/head.js b/comm/calendar/test/unit/head.js
new file mode 100644
index 0000000000..2b7b9c99fa
--- /dev/null
+++ b/comm/calendar/test/unit/head.js
@@ -0,0 +1,337 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs");
+var { FileUtils } = ChromeUtils.importESModule("resource://gre/modules/FileUtils.sys.mjs");
+
+var { updateAppInfo } = ChromeUtils.importESModule("resource://testing-common/AppInfo.sys.mjs");
+
+ChromeUtils.defineModuleGetter(this, "NetUtil", "resource://gre/modules/NetUtil.jsm");
+
+updateAppInfo();
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+function createDate(aYear, aMonth, aDay, aHasTime, aHour, aMinute, aSecond, aTimezone) {
+ let date = Cc["@mozilla.org/calendar/datetime;1"].createInstance(Ci.calIDateTime);
+ date.resetTo(
+ aYear,
+ aMonth,
+ aDay,
+ aHour || 0,
+ aMinute || 0,
+ aSecond || 0,
+ aTimezone || cal.dtz.UTC
+ );
+ date.isDate = !aHasTime;
+ return date;
+}
+
+function createEventFromIcalString(icalString) {
+ if (/^BEGIN:VCALENDAR/.test(icalString)) {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ parser.parseString(icalString);
+ let items = parser.getItems();
+ cal.ASSERT(items.length == 1);
+ return items[0].QueryInterface(Ci.calIEvent);
+ }
+ let event = Cc["@mozilla.org/calendar/event;1"].createInstance(Ci.calIEvent);
+ event.icalString = icalString;
+ return event;
+}
+
+function createTodoFromIcalString(icalString) {
+ let todo = Cc["@mozilla.org/calendar/todo;1"].createInstance(Ci.calITodo);
+ todo.icalString = icalString;
+ return todo;
+}
+
+function getMemoryCal() {
+ return Cc["@mozilla.org/calendar/calendar;1?type=memory"].createInstance(
+ Ci.calISyncWriteCalendar
+ );
+}
+
+function getStorageCal() {
+ // Whenever we get the storage calendar we need to request a profile,
+ // otherwise the cleanup functions will not run
+ do_get_profile();
+
+ // create URI
+ let db = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ db.append("test_storage.sqlite");
+ let uri = Services.io.newFileURI(db);
+
+ // Make sure timezone service is initialized
+ Cc["@mozilla.org/calendar/timezone-service;1"].getService(Ci.calIStartupService).startup(null);
+
+ // create storage calendar
+ let stor = Cc["@mozilla.org/calendar/calendar;1?type=storage"].createInstance(
+ Ci.calISyncWriteCalendar
+ );
+ stor.uri = uri;
+ stor.id = cal.getUUID();
+ return stor;
+}
+
+/**
+ * Return an item property as string.
+ *
+ * @param aItem
+ * @param string aProp possible item properties: start, end, duration,
+ * generation, title,
+ * id, calendar, creationDate, lastModifiedTime,
+ * stampTime, priority, privacy, status,
+ * alarmLastAck, recurrenceStartDate
+ * and any property that can be obtained using getProperty()
+ */
+function getProps(aItem, aProp) {
+ let value = null;
+ switch (aProp) {
+ case "start":
+ value = aItem.startDate || aItem.entryDate || null;
+ break;
+ case "end":
+ value = aItem.endDate || aItem.dueDate || null;
+ break;
+ case "duration":
+ value = aItem.duration || null;
+ break;
+ case "generation":
+ value = aItem.generation;
+ break;
+ case "title":
+ value = aItem.title;
+ break;
+ case "id":
+ value = aItem.id;
+ break;
+ case "calendar":
+ value = aItem.calendar.id;
+ break;
+ case "creationDate":
+ value = aItem.creationDate;
+ break;
+ case "lastModifiedTime":
+ value = aItem.lastModifiedTime;
+ break;
+ case "stampTime":
+ value = aItem.stampTime;
+ break;
+ case "priority":
+ value = aItem.priority;
+ break;
+ case "privacy":
+ value = aItem.privacy;
+ break;
+ case "status":
+ value = aItem.status;
+ break;
+ case "alarmLastAck":
+ value = aItem.alarmLastAck;
+ break;
+ case "recurrenceStartDate":
+ value = aItem.recurrenceStartDate;
+ break;
+ default:
+ value = aItem.getProperty(aProp);
+ }
+ if (value) {
+ return value.toString();
+ }
+ return null;
+}
+
+function compareItemsSpecific(aLeftItem, aRightItem, aPropArray) {
+ if (!aPropArray) {
+ // left out: "id", "calendar", "lastModifiedTime", "generation",
+ // "stampTime" as these are expected to change
+ aPropArray = [
+ "start",
+ "end",
+ "duration",
+ "title",
+ "priority",
+ "privacy",
+ "creationDate",
+ "status",
+ "alarmLastAck",
+ "recurrenceStartDate",
+ ];
+ }
+ if (aLeftItem instanceof Ci.calIEvent) {
+ aLeftItem.QueryInterface(Ci.calIEvent);
+ } else if (aLeftItem instanceof Ci.calITodo) {
+ aLeftItem.QueryInterface(Ci.calITodo);
+ }
+ for (let i = 0; i < aPropArray.length; i++) {
+ equal(getProps(aLeftItem, aPropArray[i]), getProps(aRightItem, aPropArray[i]));
+ }
+}
+
+/**
+ * Unfold ics lines by removing any \r\n or \n followed by a linear whitespace
+ * (space or htab).
+ *
+ * @param aLine The line to unfold
+ * @returns The unfolded line
+ */
+function ics_unfoldline(aLine) {
+ return aLine.replace(/\r?\n[ \t]/g, "");
+}
+
+/**
+ * Dedent the template string tagged with this function to make indented data
+ * easier to read. Usage:
+ *
+ * let data = dedent`
+ * This is indented data it will be unindented so that the first line has
+ * no leading spaces and the second is indented by two spaces.
+ * `;
+ *
+ * @param strings The string fragments from the template string
+ * @param ...values The interpolated values
+ * @returns The interpolated, dedented string
+ */
+function dedent(strings, ...values) {
+ let parts = [];
+ // Perform variable interpolation
+ let minIndent = Infinity;
+ for (let [i, string] of strings.entries()) {
+ let innerparts = string.split("\n");
+ if (i == 0) {
+ innerparts.shift();
+ }
+ if (i == strings.length - 1) {
+ innerparts.pop();
+ }
+ for (let [j, ip] of innerparts.entries()) {
+ let match = ip.match(/^(\s*)\S*/);
+ if (j != 0) {
+ minIndent = Math.min(minIndent, match[1].length);
+ }
+ }
+ parts.push(innerparts);
+ }
+
+ return parts
+ .map((part, i) => {
+ return (
+ part
+ .map((line, j) => {
+ return j == 0 && i > 0 ? line : line.substr(minIndent);
+ })
+ .join("\n") + (i < values.length ? values[i] : "")
+ );
+ })
+ .join("");
+}
+
+/**
+ * Read a JSON file and return the JS object
+ */
+function readJSONFile(aFile) {
+ let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(Ci.nsIFileInputStream);
+ try {
+ stream.init(aFile, FileUtils.MODE_RDONLY, FileUtils.PERMS_FILE, 0);
+ let bytes = NetUtil.readInputStream(stream, stream.available());
+ let data = JSON.parse(new TextDecoder().decode(bytes));
+ return data;
+ } catch (ex) {
+ dump("readJSONFile: Error reading JSON file: " + ex);
+ } finally {
+ stream.close();
+ }
+ return false;
+}
+
+function do_load_timezoneservice(callback) {
+ do_test_pending();
+ cal.timezoneService.startup({
+ onResult() {
+ do_test_finished();
+ callback();
+ },
+ });
+}
+
+function do_load_calmgr(callback) {
+ do_test_pending();
+ cal.manager.startup({
+ onResult() {
+ do_test_finished();
+ callback();
+ },
+ });
+}
+
+function do_calendar_startup(callback) {
+ let obs = {
+ observe() {
+ Services.obs.removeObserver(this, "calendar-startup-done");
+ do_test_finished();
+ executeSoon(callback);
+ },
+ };
+
+ let startupService = Cc["@mozilla.org/calendar/startup-service;1"].getService(
+ Ci.nsISupports
+ ).wrappedJSObject;
+
+ if (startupService.started) {
+ callback();
+ } else {
+ do_test_pending();
+ Services.obs.addObserver(obs, "calendar-startup-done");
+ if (this._profileInitialized) {
+ Services.obs.notifyObservers(null, "profile-after-change", "xpcshell-do-get-profile");
+ } else {
+ do_get_profile(true);
+ }
+ }
+}
+
+/**
+ * Monkey patch the function with the name x on obj and overwrite it with func.
+ * The first parameter of this function is the original function that can be
+ * called at any time.
+ *
+ * @param obj The object the function is on.
+ * @param name The string name of the function.
+ * @param func The function to monkey patch with.
+ */
+function monkeyPatch(obj, x, func) {
+ let old = obj[x];
+ obj[x] = function () {
+ let parent = old.bind(obj);
+ let args = Array.from(arguments);
+ args.unshift(parent);
+ try {
+ return func.apply(obj, args);
+ } catch (e) {
+ console.error(e);
+ throw e;
+ }
+ };
+}
+
+/**
+ * Asserts the properties of an actual extract parser result to what was
+ * expected.
+ *
+ * @param {object} actual - Mostly the actual output of parse().
+ * @param {object} expected - The expected output.
+ * @param {string} level - The variable name to refer to report on.
+ */
+function compareExtractResults(actual, expected, level = "") {
+ for (let [key, value] of Object.entries(expected)) {
+ let qualifiedKey = [level, Array.isArray(expected) ? `[${key}]` : `.${key}`].join("");
+ if (value && typeof value == "object") {
+ Assert.ok(actual[key], `${qualifiedKey} is not null`);
+ compareExtractResults(actual[key], value, qualifiedKey);
+ continue;
+ }
+ Assert.equal(actual[key], value, `${qualifiedKey} has value "${value}"`);
+ }
+}
diff --git a/comm/calendar/test/unit/providers/head.js b/comm/calendar/test/unit/providers/head.js
new file mode 100644
index 0000000000..3c995ab31a
--- /dev/null
+++ b/comm/calendar/test/unit/providers/head.js
@@ -0,0 +1,152 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+var { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+var { PromiseUtils } = ChromeUtils.importESModule("resource://gre/modules/PromiseUtils.sys.mjs");
+
+var { updateAppInfo } = ChromeUtils.importESModule("resource://testing-common/AppInfo.sys.mjs");
+updateAppInfo();
+
+// The tests in this directory each do the same thing, with slight variations as needed for each
+// calendar provider. The core of the test lives in this file and the tests call it when ready.
+
+do_get_profile();
+add_setup(async () => {
+ await new Promise(resolve => cal.manager.startup({ onResult: resolve }));
+ await new Promise(resolve => cal.timezoneService.startup({ onResult: resolve }));
+ cal.manager.addCalendarObserver(calendarObserver);
+});
+
+let calendarObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+
+ /* calIObserver */
+
+ _batchCount: 0,
+ _batchRequired: true,
+ onStartBatch(calendar) {
+ info(`onStartBatch ${calendar?.id} ${++this._batchCount}`);
+ Assert.equal(calendar, this._expectedCalendar);
+ Assert.equal(this._batchCount, 1, "onStartBatch must not occur in a batch");
+ },
+ onEndBatch(calendar) {
+ info(`onEndBatch ${calendar?.id} ${this._batchCount--}`);
+ Assert.equal(calendar, this._expectedCalendar);
+ Assert.equal(this._batchCount, 0, "onEndBatch must occur in a batch");
+ },
+ onLoad(calendar) {
+ info(`onLoad ${calendar.id}`);
+ Assert.equal(this._batchCount, 0, "onLoad must not occur in a batch");
+ Assert.equal(calendar, this._expectedCalendar);
+ if (this._onLoadPromise) {
+ this._onLoadPromise.resolve();
+ }
+ },
+ onAddItem(item) {
+ info(`onAddItem ${item.calendar.id} ${item.id}`);
+ if (this._batchRequired) {
+ Assert.equal(this._batchCount, 1, "onAddItem must occur in a batch");
+ }
+ if (this._onAddItemPromise) {
+ this._onAddItemPromise.resolve();
+ }
+ },
+ onModifyItem(newItem, oldItem) {
+ info(`onModifyItem ${newItem.calendar.id} ${newItem.id}`);
+ if (this._batchRequired) {
+ Assert.equal(this._batchCount, 1, "onModifyItem must occur in a batch");
+ }
+ if (this._onModifyItemPromise) {
+ this._onModifyItemPromise.resolve();
+ }
+ },
+ onDeleteItem(deletedItem) {
+ info(`onDeleteItem ${deletedItem.calendar.id} ${deletedItem.id}`);
+ if (this._onDeleteItemPromise) {
+ this._onDeleteItemPromise.resolve();
+ }
+ },
+ onError(calendar, errNo, message) {},
+ onPropertyChanged(calendar, name, value, oldValue) {},
+ onPropertyDeleting(calendar, name) {},
+};
+
+/**
+ * Create and register a calendar.
+ *
+ * @param {string} type - The calendar provider to use.
+ * @param {string} url - URL of the server.
+ * @param {boolean} useCache - Should this calendar have offline storage?
+ * @returns {calICalendar}
+ */
+function createCalendar(type, url, useCache) {
+ let calendar = cal.manager.createCalendar(type, Services.io.newURI(url));
+ calendar.name = type + (useCache ? " with cache" : " without cache");
+ calendar.id = cal.getUUID();
+ calendar.setProperty("cache.enabled", useCache);
+
+ cal.manager.registerCalendar(calendar);
+ calendar = cal.manager.getCalendarById(calendar.id);
+ calendarObserver._expectedCalendar = calendar;
+
+ info(`Created calendar ${calendar.id}`);
+ return calendar;
+}
+
+/**
+ * Creates an event and adds it to the given calendar.
+ *
+ * @param {calICalendar} calendar
+ * @returns {calIEvent}
+ */
+async function runAddItem(calendar) {
+ let event = new CalEvent();
+ event.id = "6b7dd6f6-d6f0-4e93-a953-bb5473c4c47a";
+ event.title = "New event";
+ event.startDate = cal.createDateTime("20200303T205500Z");
+ event.endDate = cal.createDateTime("20200303T210200Z");
+
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onModifyItemPromise = PromiseUtils.defer();
+ await calendar.addItem(event);
+ await Promise.any([
+ calendarObserver._onAddItemPromise.promise,
+ calendarObserver._onModifyItemPromise.promise,
+ ]);
+
+ return event;
+}
+
+/**
+ * Modifies the event from runAddItem.
+ *
+ * @param {calICalendar} calendar
+ */
+async function runModifyItem(calendar) {
+ let event = await calendar.getItem("6b7dd6f6-d6f0-4e93-a953-bb5473c4c47a");
+
+ let clone = event.clone();
+ clone.title = "Modified event";
+
+ calendarObserver._onModifyItemPromise = PromiseUtils.defer();
+ await calendar.modifyItem(clone, event);
+ await calendarObserver._onModifyItemPromise.promise;
+}
+
+/**
+ * Deletes the event from runAddItem.
+ *
+ * @param {calICalendar} calendar
+ */
+async function runDeleteItem(calendar) {
+ let event = await calendar.getItem("6b7dd6f6-d6f0-4e93-a953-bb5473c4c47a");
+
+ calendarObserver._onDeleteItemPromise = PromiseUtils.defer();
+ await calendar.deleteItem(event);
+ await calendarObserver._onDeleteItemPromise.promise;
+}
diff --git a/comm/calendar/test/unit/providers/test_caldavCalendar_cached.js b/comm/calendar/test/unit/providers/test_caldavCalendar_cached.js
new file mode 100644
index 0000000000..7a61973644
--- /dev/null
+++ b/comm/calendar/test/unit/providers/test_caldavCalendar_cached.js
@@ -0,0 +1,201 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalDAVServer } = ChromeUtils.import("resource://testing-common/calendar/CalDAVServer.jsm");
+
+add_setup(async function () {
+ CalDAVServer.open();
+ await CalDAVServer.putItemInternal(
+ "5a9fa76c-93f3-4ad8-9f00-9e52aedd2821.ics",
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:5a9fa76c-93f3-4ad8-9f00-9e52aedd2821
+ SUMMARY:exists before time
+ DTSTART:20210401T120000Z
+ DTEND:20210401T130000Z
+ END:VEVENT
+ END:VCALENDAR
+ `
+ );
+});
+registerCleanupFunction(() => CalDAVServer.close());
+
+add_task(async function () {
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ let calendar = createCalendar("caldav", CalDAVServer.url, true);
+ await calendarObserver._onAddItemPromise.promise;
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(await calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"));
+
+ info("creating the item");
+ calendarObserver._batchRequired = true;
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runAddItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("modifying the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runModifyItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("deleting the item");
+ await runDeleteItem(calendar);
+
+ cal.manager.unregisterCalendar(calendar);
+});
+
+/**
+ * Tests calendars that return status 404 for "current-user-privilege-set" are
+ * not flagged read-only.
+ */
+add_task(async function testCalendarWithNoPrivSupport() {
+ CalDAVServer.privileges = null;
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+
+ let calendar = createCalendar("caldav", CalDAVServer.url, true);
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(!calendar.readOnly, "calendar was not marked read-only");
+
+ cal.manager.unregisterCalendar(calendar);
+});
+
+/**
+ * Tests modifyItem() does not hang when the server reports no actual
+ * modifications were made.
+ */
+add_task(async function testModifyItemWithNoChanges() {
+ let event = new CalEvent();
+ let calendar = createCalendar("caldav", CalDAVServer.url, false);
+ event.id = "6f6dd7b6-0fbd-39e4-359a-a74c4c3745bb";
+ event.title = "A New Event";
+ event.startDate = cal.createDateTime("20200303T205500Z");
+ event.endDate = cal.createDateTime("20200303T210200Z");
+ await calendar.addItem(event);
+
+ let clone = event.clone();
+ clone.title = "A Modified Event";
+
+ let putItemInternal = CalDAVServer.putItemInternal;
+ CalDAVServer.putItemInternal = () => {};
+
+ let modifiedEvent = await calendar.modifyItem(clone, event);
+ CalDAVServer.putItemInternal = putItemInternal;
+
+ Assert.ok(modifiedEvent, "an event was returned");
+ Assert.equal(modifiedEvent.title, event.title, "the un-modified event is returned");
+
+ await calendar.deleteItem(modifiedEvent);
+ cal.manager.unregisterCalendar(calendar);
+});
+
+/**
+ * Tests that an error response from the server when syncing doesn't delete
+ * items from the local calendar.
+ */
+add_task(async function testSyncError1() {
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ let calendar = createCalendar("caldav", CalDAVServer.url, true);
+ await calendarObserver._onAddItemPromise.promise;
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(
+ await calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"),
+ "item should exist when first connected"
+ );
+
+ info("syncing with rate limit error");
+ CalDAVServer.throwRateLimitErrors = true;
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ calendar.refresh();
+ await calendarObserver._onLoadPromise.promise;
+ CalDAVServer.throwRateLimitErrors = false;
+ info("sync with rate limit error complete");
+
+ Assert.equal(
+ calendar.getProperty("currentStatus"),
+ Cr.NS_OK,
+ "calendar should not be in an error state"
+ );
+ Assert.equal(calendar.getProperty("disabled"), null, "calendar should not be disabled");
+ Assert.ok(
+ await calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"),
+ "item should still exist after error response"
+ );
+
+ info("syncing without rate limit error");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ calendar.refresh();
+ await calendarObserver._onLoadPromise.promise;
+ info("sync without rate limit error complete");
+
+ Assert.ok(
+ await calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"),
+ "item should still exist after successful sync"
+ );
+
+ cal.manager.unregisterCalendar(calendar);
+});
+
+/**
+ * Tests that multiple pages of item responses from the server when syncing
+ * doesn't result in items being deleted from the local calendar.
+ *
+ * The server has a page size of 3, although this test should pass regardless
+ * of the page size.
+ */
+add_task(async function testSyncError2() {
+ // Add some items to the server so multiple requests are required to get
+ // them all. There's already one item on the server.
+ for (let i = 0; i < 3; i++) {
+ await CalDAVServer.putItemInternal(
+ `fake-uid-${i}.ics`,
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:fake-uid-${i}
+ SUMMARY:event ${i}
+ DTSTART:20210401T120000Z
+ DTEND:20210401T130000Z
+ END:VEVENT
+ END:VCALENDAR
+ `
+ );
+ }
+
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ let calendar = createCalendar("caldav", CalDAVServer.url, true);
+ await calendarObserver._onAddItemPromise.promise;
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ let items = await calendar.getItemsAsArray(Ci.calICalendar.ITEM_FILTER_TYPE_ALL, 0, null, null);
+ Assert.equal(items.length, 4, "all items added to calendar when first connected");
+
+ info("forced syncing with multiple pages");
+ calendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject.mWebdavSyncToken = null;
+ calendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject.saveCalendarProperties();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ calendar.refresh();
+ await calendarObserver._onLoadPromise.promise;
+ info("forced sync with multiple pages complete");
+
+ items = await calendar.getItemsAsArray(Ci.calICalendar.ITEM_FILTER_TYPE_ALL, 0, null, null);
+ Assert.equal(items.length, 4, "all items still in calendar after forced refresh");
+
+ cal.manager.unregisterCalendar(calendar);
+
+ // Delete the added items.
+ for (let i = 0; i < 3; i++) {
+ CalDAVServer.deleteItemInternal(`fake-uid-${i}.ics`);
+ }
+});
diff --git a/comm/calendar/test/unit/providers/test_caldavCalendar_uncached.js b/comm/calendar/test/unit/providers/test_caldavCalendar_uncached.js
new file mode 100644
index 0000000000..025a1ba871
--- /dev/null
+++ b/comm/calendar/test/unit/providers/test_caldavCalendar_uncached.js
@@ -0,0 +1,96 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalDAVServer } = ChromeUtils.import("resource://testing-common/calendar/CalDAVServer.jsm");
+
+add_setup(async function () {
+ CalDAVServer.open();
+ await CalDAVServer.putItemInternal(
+ "5a9fa76c-93f3-4ad8-9f00-9e52aedd2821.ics",
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:5a9fa76c-93f3-4ad8-9f00-9e52aedd2821
+ SUMMARY:exists before time
+ DTSTART:20210401T120000Z
+ DTEND:20210401T130000Z
+ END:VEVENT
+ END:VCALENDAR
+ `
+ );
+});
+registerCleanupFunction(() => CalDAVServer.close());
+
+add_task(async function () {
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ let calendar = createCalendar("caldav", CalDAVServer.url, false);
+ await calendarObserver._onAddItemPromise.promise;
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(await calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"));
+
+ info("creating the item");
+ calendarObserver._batchRequired = true;
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runAddItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("modifying the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runModifyItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("deleting the item");
+ await runDeleteItem(calendar);
+
+ cal.manager.unregisterCalendar(calendar);
+});
+
+/**
+ * Tests calendars that return status 404 for "current-user-privilege-set" are
+ * not flagged read-only.
+ */
+add_task(async function testCalendarWithNoPrivSupport() {
+ CalDAVServer.privileges = null;
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+
+ let calendar = createCalendar("caldav", CalDAVServer.url, false);
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(!calendar.readOnly, "calendar was not marked read-only");
+
+ cal.manager.unregisterCalendar(calendar);
+});
+
+/**
+ * Tests modifyItem() does not hang when the server reports no actual
+ * modifications were made.
+ */
+add_task(async function testModifyItemWithNoChanges() {
+ let event = new CalEvent();
+ let calendar = createCalendar("caldav", CalDAVServer.url, false);
+ event.id = "6f6dd7b6-0fbd-39e4-359a-a74c4c3745bb";
+ event.title = "A New Event";
+ event.startDate = cal.createDateTime("20200303T205500Z");
+ event.endDate = cal.createDateTime("20200303T210200Z");
+ await calendar.addItem(event);
+
+ let clone = event.clone();
+ clone.title = "A Modified Event";
+
+ let putItemInternal = CalDAVServer.putItemInternal;
+ CalDAVServer.putItemInternal = () => {};
+
+ let modifiedEvent = await calendar.modifyItem(clone, event);
+ CalDAVServer.putItemInternal = putItemInternal;
+
+ Assert.ok(modifiedEvent, "an event was returned");
+ Assert.equal(modifiedEvent.title, event.title, "the un-modified event is returned");
+
+ await calendar.deleteItem(modifiedEvent);
+ cal.manager.unregisterCalendar(calendar);
+});
diff --git a/comm/calendar/test/unit/providers/test_icsCalendar_cached.js b/comm/calendar/test/unit/providers/test_icsCalendar_cached.js
new file mode 100644
index 0000000000..9bc1b127da
--- /dev/null
+++ b/comm/calendar/test/unit/providers/test_icsCalendar_cached.js
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ICSServer } = ChromeUtils.import("resource://testing-common/calendar/ICSServer.jsm");
+
+ICSServer.open();
+ICSServer.putICSInternal(
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:5a9fa76c-93f3-4ad8-9f00-9e52aedd2821
+ SUMMARY:exists before time
+ DTSTART:20210401T120000Z
+ DTEND:20210401T130000Z
+ END:VEVENT
+ END:VCALENDAR
+ `
+);
+registerCleanupFunction(() => ICSServer.close());
+
+add_task(async function () {
+ // TODO: item notifications from a cached ICS calendar occur outside of batches.
+ // This isn't fatal but it shouldn't happen. Side-effects include alarms firing
+ // twice - once from onAddItem then again at onLoad.
+ //
+ // Remove the next line when this is fixed.
+ calendarObserver._batchRequired = false;
+
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ let calendar = createCalendar("ics", ICSServer.url, true);
+ await calendarObserver._onAddItemPromise.promise;
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(await calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"));
+
+ info("creating the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runAddItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("modifying the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runModifyItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("deleting the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runDeleteItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+});
diff --git a/comm/calendar/test/unit/providers/test_icsCalendar_uncached.js b/comm/calendar/test/unit/providers/test_icsCalendar_uncached.js
new file mode 100644
index 0000000000..db5db6e99f
--- /dev/null
+++ b/comm/calendar/test/unit/providers/test_icsCalendar_uncached.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ICSServer } = ChromeUtils.import("resource://testing-common/calendar/ICSServer.jsm");
+
+ICSServer.open();
+ICSServer.putICSInternal(
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:5a9fa76c-93f3-4ad8-9f00-9e52aedd2821
+ SUMMARY:exists before time
+ DTSTART:20210401T120000Z
+ DTEND:20210401T130000Z
+ END:VEVENT
+ END:VCALENDAR
+ `
+);
+registerCleanupFunction(() => ICSServer.close());
+
+add_task(async function () {
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ let calendar = createCalendar("ics", ICSServer.url, false);
+ await calendarObserver._onAddItemPromise.promise;
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(await calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"));
+
+ info("creating the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runAddItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("modifying the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runModifyItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("deleting the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runDeleteItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+});
diff --git a/comm/calendar/test/unit/providers/test_storageCalendar.js b/comm/calendar/test/unit/providers/test_storageCalendar.js
new file mode 100644
index 0000000000..778f9f9251
--- /dev/null
+++ b/comm/calendar/test/unit/providers/test_storageCalendar.js
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function () {
+ let calendar = createCalendar("storage", "moz-storage-calendar://");
+
+ info("creating the item");
+ calendarObserver._batchRequired = false;
+ await runAddItem(calendar);
+
+ info("modifying the item");
+ await runModifyItem(calendar);
+
+ info("deleting the item");
+ await runDeleteItem(calendar);
+});
diff --git a/comm/calendar/test/unit/providers/xpcshell.ini b/comm/calendar/test/unit/providers/xpcshell.ini
new file mode 100644
index 0000000000..272fef7c78
--- /dev/null
+++ b/comm/calendar/test/unit/providers/xpcshell.ini
@@ -0,0 +1,11 @@
+[default]
+head = head.js
+prefs =
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+
+[test_caldavCalendar_cached.js]
+[test_caldavCalendar_uncached.js]
+[test_icsCalendar_cached.js]
+[test_icsCalendar_uncached.js]
+[test_storageCalendar.js]
diff --git a/comm/calendar/test/unit/test_CalendarFileImporter.js b/comm/calendar/test/unit/test_CalendarFileImporter.js
new file mode 100644
index 0000000000..358a82942d
--- /dev/null
+++ b/comm/calendar/test/unit/test_CalendarFileImporter.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+var { CalendarFileImporter } = ChromeUtils.import("resource:///modules/CalendarFileImporter.jsm");
+
+/**
+ * Test CalendarFileImporter can import ics file correctly.
+ */
+async function test_importIcsFile() {
+ let importer = new CalendarFileImporter();
+
+ // Parse items from ics file should work.
+ let items = await importer.parseIcsFile(do_get_file("data/import.ics"));
+ equal(items.length, 4);
+
+ // Create a temporary calendar.
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ // Put items to the temporary calendar should work.
+ await importer.startImport(items, calendar);
+ let result = await calendar.getItemsAsArray(
+ Ci.calICalendar.ITEM_FILTER_ALL_ITEMS,
+ 0,
+ cal.createDateTime("20190101T000000"),
+ cal.createDateTime("20190102T000000")
+ );
+ equal(result.length, 4);
+}
+
+function run_test() {
+ do_get_profile();
+
+ add_test(() => {
+ do_calendar_startup(async () => {
+ await test_importIcsFile();
+ run_next_test();
+ });
+ });
+}
diff --git a/comm/calendar/test/unit/test_alarm.js b/comm/calendar/test/unit/test_alarm.js
new file mode 100644
index 0000000000..14b2ce76bd
--- /dev/null
+++ b/comm/calendar/test/unit/test_alarm.js
@@ -0,0 +1,674 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+function run_tests() {
+ test_initial_creation();
+
+ test_display_alarm();
+ test_email_alarm();
+ test_audio_alarm();
+ test_custom_alarm();
+ test_repeat();
+ test_xprop();
+
+ test_dates();
+
+ test_clone();
+ test_immutable();
+ test_serialize();
+ test_strings();
+}
+
+function run_test() {
+ do_calendar_startup(run_tests);
+}
+
+function test_initial_creation() {
+ dump("Testing initial creation...");
+ let alarm = new CalAlarm();
+
+ let passed;
+ try {
+ // eslint-disable-next-line no-unused-expressions
+ alarm.icalString;
+ passed = false;
+ } catch (e) {
+ passed = true;
+ }
+ if (!passed) {
+ do_throw("Fresh calIAlarm should not produce a valid icalString");
+ }
+ dump("Done\n");
+}
+
+function test_display_alarm() {
+ dump("Testing DISPLAY alarms...");
+ let alarm = new CalAlarm();
+ // Set ACTION to DISPLAY, make sure this was not rejected
+ alarm.action = "DISPLAY";
+ equal(alarm.action, "DISPLAY");
+
+ // Set a Description, REQUIRED for ACTION:DISPLAY
+ alarm.description = "test";
+ equal(alarm.description, "test");
+
+ // SUMMARY is not valid for ACTION:DISPLAY
+ alarm.summary = "test";
+ equal(alarm.summary, null);
+
+ // No attendees allowed
+ let attendee = new CalAttendee();
+ attendee.id = "mailto:horst";
+
+ throws(() => {
+ // DISPLAY alarm should not be able to save attendees
+ alarm.addAttendee(attendee);
+ }, /Alarm type AUDIO\/DISPLAY may not have attendees/);
+
+ throws(() => {
+ // DISPLAY alarm should not be able to save attachment
+ alarm.addAttachment(new CalAttachment());
+ }, /Alarm type DISPLAY may not have attachments/);
+
+ dump("Done\n");
+}
+
+function test_email_alarm() {
+ dump("Testing EMAIL alarms...");
+ let alarm = new CalAlarm();
+ // Set ACTION to DISPLAY, make sure this was not rejected
+ alarm.action = "EMAIL";
+ equal(alarm.action, "EMAIL");
+
+ // Set a Description, REQUIRED for ACTION:EMAIL
+ alarm.description = "description";
+ equal(alarm.description, "description");
+
+ // Set a Summary, REQUIRED for ACTION:EMAIL
+ alarm.summary = "summary";
+ equal(alarm.summary, "summary");
+
+ // Set an offset of some sort
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ alarm.offset = cal.createDuration();
+
+ // Check for at least one attendee
+ let attendee1 = new CalAttendee();
+ attendee1.id = "mailto:horst";
+ let attendee2 = new CalAttendee();
+ attendee2.id = "mailto:gustav";
+
+ equal(alarm.getAttendees().length, 0);
+ alarm.addAttendee(attendee1);
+ equal(alarm.getAttendees().length, 1);
+ alarm.addAttendee(attendee2);
+ equal(alarm.getAttendees().length, 2);
+ alarm.addAttendee(attendee1);
+ let addedAttendees = alarm.getAttendees();
+ equal(addedAttendees.length, 2);
+ equal(addedAttendees[0].wrappedJSObject, attendee2);
+ equal(addedAttendees[1].wrappedJSObject, attendee1);
+
+ ok(!!alarm.icalComponent.serializeToICS().match(/mailto:horst/));
+ ok(!!alarm.icalComponent.serializeToICS().match(/mailto:gustav/));
+
+ alarm.deleteAttendee(attendee1);
+ equal(alarm.getAttendees().length, 1);
+
+ alarm.clearAttendees();
+ equal(alarm.getAttendees().length, 0);
+
+ // Make sure attendees are correctly folded/imported
+ alarm.icalString = dedent`
+ BEGIN:VALARM
+ ACTION:EMAIL
+ TRIGGER;VALUE=DURATION:-PT5M
+ ATTENDEE:mailto:test@example.com
+ ATTENDEE:mailto:test@example.com
+ ATTENDEE:mailto:test2@example.com
+ END:VALARM
+ `;
+ equal(alarm.icalString.match(/ATTENDEE:mailto:test@example.com/g).length, 1);
+ equal(alarm.icalString.match(/ATTENDEE:mailto:test2@example.com/g).length, 1);
+
+ // TODO test attachments
+ dump("Done\n");
+}
+
+function test_audio_alarm() {
+ dump("Testing AUDIO alarms...");
+ let alarm = new CalAlarm();
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE;
+ alarm.alarmDate = cal.createDateTime();
+ // Set ACTION to AUDIO, make sure this was not rejected
+ alarm.action = "AUDIO";
+ equal(alarm.action, "AUDIO");
+
+ // No Description for ACTION:AUDIO
+ alarm.description = "description";
+ equal(alarm.description, null);
+
+ // No Summary, for ACTION:AUDIO
+ alarm.summary = "summary";
+ equal(alarm.summary, null);
+
+ // No attendees allowed
+ let attendee = new CalAttendee();
+ attendee.id = "mailto:horst";
+
+ try {
+ alarm.addAttendee(attendee);
+ do_throw("AUDIO alarm should not be able to save attendees");
+ } catch (e) {
+ // TODO looks like this test is disabled. Why?
+ }
+
+ // Test attachments
+ let sound = new CalAttachment();
+ sound.uri = Services.io.newURI("file:///sound.wav");
+ let sound2 = new CalAttachment();
+ sound2.uri = Services.io.newURI("file:///sound2.wav");
+
+ // Adding an attachment should work
+ alarm.addAttachment(sound);
+ let addedAttachments = alarm.getAttachments();
+ equal(addedAttachments.length, 1);
+ equal(addedAttachments[0].wrappedJSObject, sound);
+ ok(alarm.icalString.includes("ATTACH:file:///sound.wav"));
+
+ // Adding twice shouldn't change anything
+ alarm.addAttachment(sound);
+ addedAttachments = alarm.getAttachments();
+ equal(addedAttachments.length, 1);
+ equal(addedAttachments[0].wrappedJSObject, sound);
+
+ try {
+ alarm.addAttachment(sound2);
+ do_throw("Adding a second attachment should fail for type AUDIO");
+ } catch (e) {
+ // TODO looks like this test is disabled. Why?
+ }
+
+ // Deleting should work
+ alarm.deleteAttachment(sound);
+ addedAttachments = alarm.getAttachments();
+ equal(addedAttachments.length, 0);
+
+ // As well as clearing
+ alarm.addAttachment(sound);
+ alarm.clearAttachments();
+ addedAttachments = alarm.getAttachments();
+ equal(addedAttachments.length, 0);
+
+ // AUDIO alarms should only be allowing one attachment, and folding any with the same value
+ alarm.icalString = dedent`
+ BEGIN:VALARM
+ ACTION:AUDIO
+ TRIGGER;VALUE=DURATION:-PT5M
+ ATTACH:Basso
+ UID:28F8007B-FE56-442E-917C-1F4E48DD406A
+ X-APPLE-DEFAULT-ALARM:TRUE
+ ATTACH:Basso
+ END:VALARM
+ `;
+ equal(alarm.icalString.match(/ATTACH:Basso/g).length, 1);
+
+ dump("Done\n");
+}
+
+function test_custom_alarm() {
+ dump("Testing X-SMS (custom) alarms...");
+ let alarm = new CalAlarm();
+ // Set ACTION to a custom value, make sure this was not rejected
+ alarm.action = "X-SMS";
+ equal(alarm.action, "X-SMS");
+
+ // There is no restriction on DESCRIPTION for custom alarms
+ alarm.description = "description";
+ equal(alarm.description, "description");
+
+ // There is no restriction on SUMMARY for custom alarms
+ alarm.summary = "summary";
+ equal(alarm.summary, "summary");
+
+ // Test for attendees
+ let attendee1 = new CalAttendee();
+ attendee1.id = "mailto:horst";
+ let attendee2 = new CalAttendee();
+ attendee2.id = "mailto:gustav";
+
+ equal(alarm.getAttendees().length, 0);
+ alarm.addAttendee(attendee1);
+ equal(alarm.getAttendees().length, 1);
+ alarm.addAttendee(attendee2);
+ equal(alarm.getAttendees().length, 2);
+ alarm.addAttendee(attendee1);
+ equal(alarm.getAttendees().length, 2);
+
+ alarm.deleteAttendee(attendee1);
+ equal(alarm.getAttendees().length, 1);
+
+ alarm.clearAttendees();
+ equal(alarm.getAttendees().length, 0);
+
+ // Test for attachments
+ let attach1 = new CalAttachment();
+ attach1.uri = Services.io.newURI("file:///example.txt");
+ let attach2 = new CalAttachment();
+ attach2.uri = Services.io.newURI("file:///example2.txt");
+
+ alarm.addAttachment(attach1);
+ alarm.addAttachment(attach2);
+
+ let addedAttachments = alarm.getAttachments();
+ equal(addedAttachments.length, 2);
+ equal(addedAttachments[0].wrappedJSObject, attach1);
+ equal(addedAttachments[1].wrappedJSObject, attach2);
+
+ alarm.deleteAttachment(attach1);
+ addedAttachments = alarm.getAttachments();
+ equal(addedAttachments.length, 1);
+
+ alarm.clearAttachments();
+ addedAttachments = alarm.getAttachments();
+ equal(addedAttachments.length, 0);
+}
+
+// Check if any combination of REPEAT and DURATION work as expected.
+function test_repeat() {
+ dump("Testing REPEAT and DURATION properties...");
+ let alarm = new CalAlarm();
+
+ // Check initial value
+ equal(alarm.repeat, 0);
+ equal(alarm.repeatOffset, null);
+ equal(alarm.repeatDate, null);
+
+ // Should not be able to get REPEAT when DURATION is not set
+ alarm.repeat = 1;
+ equal(alarm.repeat, 0);
+
+ // Both REPEAT and DURATION should be accessible, when the two are set.
+ alarm.repeatOffset = cal.createDuration();
+ notEqual(alarm.repeatOffset, null);
+ notEqual(alarm.repeat, 0);
+
+ // Should not be able to get DURATION when REPEAT is not set
+ alarm.repeat = null;
+ equal(alarm.repeatOffset, null);
+
+ // Should be able to unset alarm DURATION attribute. (REPEAT already tested above)
+ try {
+ alarm.repeatOffset = null;
+ } catch (e) {
+ do_throw("Could not set repeatOffset attribute to null" + e);
+ }
+
+ // Check unset value
+ equal(alarm.repeat, 0);
+ equal(alarm.repeatOffset, null);
+
+ // Check repeatDate
+ alarm = new CalAlarm();
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE;
+ alarm.alarmDate = cal.createDateTime();
+ alarm.repeat = 1;
+ alarm.repeatOffset = cal.createDuration();
+ alarm.repeatOffset.inSeconds = 3600;
+
+ let date = alarm.alarmDate.clone();
+ date.second += 3600;
+ equal(alarm.repeatDate.icalString, date.icalString);
+
+ dump("Done\n");
+}
+
+function test_xprop() {
+ dump("Testing X-Props...");
+ let alarm = new CalAlarm();
+ alarm.setProperty("X-PROP", "X-VALUE");
+ ok(alarm.hasProperty("X-PROP"));
+ equal(alarm.getProperty("X-PROP"), "X-VALUE");
+ alarm.deleteProperty("X-PROP");
+ ok(!alarm.hasProperty("X-PROP"));
+ equal(alarm.getProperty("X-PROP"), null);
+
+ // also check X-MOZ-LASTACK prop
+ let date = cal.createDateTime();
+ alarm.setProperty("X-MOZ-LASTACK", date.icalString);
+ alarm.action = "DISPLAY";
+ alarm.description = "test";
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ alarm.offset = cal.createDuration("-PT5M");
+ ok(alarm.icalComponent.serializeToICS().includes(date.icalString));
+
+ alarm.deleteProperty("X-MOZ-LASTACK");
+ ok(!alarm.icalComponent.serializeToICS().includes(date.icalString));
+ dump("Done\n");
+}
+
+function test_dates() {
+ dump("Testing alarm dates...");
+ let passed;
+ // Initial value
+ let alarm = new CalAlarm();
+ equal(alarm.alarmDate, null);
+ equal(alarm.offset, null);
+
+ // Set an offset and check it
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ let offset = cal.createDuration("-PT5M");
+ alarm.offset = offset;
+ equal(alarm.alarmDate, null);
+ equal(alarm.offset, offset);
+ try {
+ alarm.alarmDate = cal.createDateTime();
+ passed = false;
+ } catch (e) {
+ passed = true;
+ }
+ if (!passed) {
+ do_throw("Setting alarmDate when alarm is relative should not succeed");
+ }
+
+ // Set an absolute time and check it
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE;
+ let alarmDate = createDate(2007, 0, 1, true, 2, 0, 0);
+ alarm.alarmDate = alarmDate;
+ equal(alarm.alarmDate.icalString, alarmDate.icalString);
+ equal(alarm.offset, null);
+ try {
+ alarm.offset = cal.createDuration();
+ passed = false;
+ } catch (e) {
+ passed = true;
+ }
+ if (!passed) {
+ do_throw("Setting offset when alarm is absolute should not succeed");
+ }
+
+ dump("Done\n");
+}
+
+var propMap = {
+ related: Ci.calIAlarm.ALARM_RELATED_START,
+ repeat: 1,
+ action: "X-TEST",
+ description: "description",
+ summary: "summary",
+ offset: cal.createDuration("PT4M"),
+ repeatOffset: cal.createDuration("PT1M"),
+};
+var clonePropMap = {
+ related: Ci.calIAlarm.ALARM_RELATED_END,
+ repeat: 2,
+ action: "X-CHANGED",
+ description: "description-changed",
+ summary: "summary-changed",
+ offset: cal.createDuration("PT5M"),
+ repeatOffset: cal.createDuration("PT2M"),
+};
+
+function test_immutable() {
+ dump("Testing immutable alarms...");
+ let alarm = new CalAlarm();
+ // Set up each attribute
+ for (let prop in propMap) {
+ alarm[prop] = propMap[prop];
+ }
+
+ // Set up some extra props
+ alarm.setProperty("X-FOO", "X-VAL");
+ alarm.setProperty("X-DATEPROP", cal.createDateTime());
+ alarm.addAttendee(new CalAttendee());
+
+ // Initial checks
+ ok(alarm.isMutable);
+ alarm.makeImmutable();
+ ok(!alarm.isMutable);
+ alarm.makeImmutable();
+ ok(!alarm.isMutable);
+
+ // Check each attribute
+ for (let prop in propMap) {
+ try {
+ alarm[prop] = propMap[prop];
+ } catch (e) {
+ equal(e.result, Cr.NS_ERROR_OBJECT_IS_IMMUTABLE);
+ continue;
+ }
+ do_throw("Attribute " + prop + " was writable while item was immutable");
+ }
+
+ // Functions
+ throws(() => {
+ alarm.setProperty("X-FOO", "changed");
+ }, /Can not modify immutable data container/);
+
+ throws(() => {
+ alarm.deleteProperty("X-FOO");
+ }, /Can not modify immutable data container/);
+
+ ok(!alarm.getProperty("X-DATEPROP").isMutable);
+
+ dump("Done\n");
+}
+
+function test_clone() {
+ dump("Testing cloning alarms...");
+ let alarm = new CalAlarm();
+ // Set up each attribute
+ for (let prop in propMap) {
+ alarm[prop] = propMap[prop];
+ }
+
+ // Set up some extra props
+ alarm.setProperty("X-FOO", "X-VAL");
+ alarm.setProperty("X-DATEPROP", cal.createDateTime());
+ alarm.addAttendee(new CalAttendee());
+
+ // Make a copy
+ let newAlarm = alarm.clone();
+ newAlarm.makeImmutable();
+ newAlarm = newAlarm.clone();
+ ok(newAlarm.isMutable);
+
+ // Check if item is still the same
+ // TODO This is not quite optimal, maybe someone can find a better way to do
+ // the comparisons.
+ for (let prop in propMap) {
+ if (prop == "item") {
+ equal(alarm.item.icalString, newAlarm.item.icalString);
+ } else {
+ try {
+ alarm[prop].QueryInterface(Ci.nsISupports);
+ equal(alarm[prop].icalString, newAlarm[prop].icalString);
+ } catch {
+ equal(alarm[prop], newAlarm[prop]);
+ }
+ }
+ }
+
+ // Check if changes on the cloned object do not affect the original object.
+ for (let prop in clonePropMap) {
+ newAlarm[prop] = clonePropMap[prop];
+ dump("Checking " + prop + "...");
+ notEqual(alarm[prop], newAlarm[prop]);
+ dump("OK!\n");
+ break;
+ }
+
+ // Check x props
+ alarm.setProperty("X-FOO", "BAR");
+ equal(alarm.getProperty("X-FOO"), "BAR");
+ let date = alarm.getProperty("X-DATEPROP");
+ equal(date.isMutable, true);
+
+ // Test xprop params
+ alarm.icalString =
+ "BEGIN:VALARM\n" +
+ "ACTION:DISPLAY\n" +
+ "TRIGGER:-PT15M\n" +
+ "X-FOO;X-PARAM=PARAMVAL:BAR\n" +
+ "DESCRIPTION:TEST\n" +
+ "END:VALARM";
+
+ newAlarm = alarm.clone();
+ equal(alarm.icalString, newAlarm.icalString);
+
+ dump("Done\n");
+}
+
+function test_serialize() {
+ // most checks done by other tests, these don't fit into categories
+ let alarm = new CalAlarm();
+
+ throws(
+ () => {
+ alarm.icalComponent = cal.icsService.createIcalComponent("BARF");
+ },
+ /0x80070057/,
+ "Invalid Argument"
+ );
+
+ function addProp(name, value) {
+ let prop = cal.icsService.createIcalProperty(name);
+ prop.value = value;
+ comp.addProperty(prop);
+ }
+ function addActionDisplay() {
+ addProp("ACTION", "DISPLAY");
+ }
+ function addActionEmail() {
+ addProp("ACTION", "EMAIL");
+ }
+ function addTrigger() {
+ addProp("TRIGGER", "-PT15M");
+ }
+ function addDescr() {
+ addProp("DESCRIPTION", "TEST");
+ }
+ function addDuration() {
+ addProp("DURATION", "-PT15M");
+ }
+ function addRepeat() {
+ addProp("REPEAT", "1");
+ }
+ function addAttendee() {
+ addProp("ATTENDEE", "mailto:horst");
+ }
+ function addAttachment() {
+ addProp("ATTACH", "data:yeah");
+ }
+
+ // All is there, should not throw
+ let comp = cal.icsService.createIcalComponent("VALARM");
+ addActionDisplay();
+ addTrigger();
+ addDescr();
+ addDuration();
+ addRepeat();
+ alarm.icalComponent = comp;
+ alarm.toString();
+
+ // Attachments and attendees
+ comp = cal.icsService.createIcalComponent("VALARM");
+ addActionEmail();
+ addTrigger();
+ addDescr();
+ addAttendee();
+ addAttachment();
+ alarm.icalComponent = comp;
+ alarm.toString();
+
+ // Missing action
+ throws(
+ () => {
+ comp = cal.icsService.createIcalComponent("VALARM");
+ addTrigger();
+ addDescr();
+ alarm.icalComponent = comp;
+ },
+ /Illegal value/,
+ "Invalid Argument"
+ );
+
+ // Missing trigger
+ throws(
+ () => {
+ comp = cal.icsService.createIcalComponent("VALARM");
+ addActionDisplay();
+ addDescr();
+ alarm.icalComponent = comp;
+ },
+ /Illegal value/,
+ "Invalid Argument"
+ );
+
+ // Missing duration with repeat
+ throws(
+ () => {
+ comp = cal.icsService.createIcalComponent("VALARM");
+ addActionDisplay();
+ addTrigger();
+ addDescr();
+ addRepeat();
+ alarm.icalComponent = comp;
+ },
+ /Illegal value/,
+ "Invalid Argument"
+ );
+
+ // Missing repeat with duration
+ throws(
+ () => {
+ comp = cal.icsService.createIcalComponent("VALARM");
+ addActionDisplay();
+ addTrigger();
+ addDescr();
+ addDuration();
+ alarm.icalComponent = comp;
+ },
+ /Illegal value/,
+ "Invalid Argument"
+ );
+}
+
+function test_strings() {
+ // Serializing the string shouldn't throw, but we don't really care about
+ // the string itself.
+ let alarm = new CalAlarm();
+ alarm.action = "DISPLAY";
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE;
+ alarm.alarmDate = cal.createDateTime();
+ alarm.toString();
+
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ alarm.offset = cal.createDuration();
+ alarm.toString();
+ alarm.toString(new CalTodo());
+
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_END;
+ alarm.offset = cal.createDuration();
+ alarm.toString();
+ alarm.toString(new CalTodo());
+
+ alarm.offset = cal.createDuration("P1D");
+ alarm.toString();
+
+ alarm.offset = cal.createDuration("PT1H");
+ alarm.toString();
+
+ alarm.offset = cal.createDuration("-PT1H");
+ alarm.toString();
+}
diff --git a/comm/calendar/test/unit/test_alarmservice.js b/comm/calendar/test/unit/test_alarmservice.js
new file mode 100644
index 0000000000..df61dc029b
--- /dev/null
+++ b/comm/calendar/test/unit/test_alarmservice.js
@@ -0,0 +1,606 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+var { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+});
+
+var EXPECT_NONE = 0;
+var EXPECT_FIRED = 1;
+var EXPECT_TIMER = 2;
+
+function do_check_xor(a, b, aMessage) {
+ return ok((a && !b) || (!a && b), aMessage);
+}
+
+var alarmObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calIAlarmServiceObserver"]),
+
+ service: null,
+ firedMap: {},
+ expectedMap: {},
+ pendingOps: {},
+
+ onAlarm(aItem, aAlarm) {
+ this.firedMap[aItem.hashId] = this.firedMap[aItem.hashId] || {};
+ this.firedMap[aItem.hashId][aAlarm.icalString] = true;
+ },
+
+ onNotification(item) {},
+
+ onRemoveAlarmsByItem(aItem) {
+ if (aItem.hashId in this.firedMap) {
+ delete this.firedMap[aItem.hashId];
+ }
+ },
+
+ onRemoveAlarmsByCalendar() {},
+
+ onAlarmsLoaded(aCalendar) {
+ this.checkLoadStatus();
+ if (aCalendar.id in this.pendingOps) {
+ this.pendingOps[aCalendar.id].call();
+ }
+ },
+
+ async doOnAlarmsLoaded(aCalendar) {
+ this.checkLoadStatus();
+ if (
+ aCalendar.id in this.service.mLoadedCalendars &&
+ this.service.mLoadedCalendars[aCalendar.id]
+ ) {
+ // the calendar's alarms have already been loaded
+ } else {
+ await new Promise(resolve => {
+ // the calendar hasn't been fully loaded yet, set as a pending operation
+ this.pendingOps[aCalendar.id] = resolve;
+ });
+ }
+ },
+
+ getTimer(aCalendarId, aItemId, aAlarmStr) {
+ return aCalendarId in this.service.mTimerMap &&
+ aItemId in this.service.mTimerMap[aCalendarId] &&
+ aAlarmStr in this.service.mTimerMap[aCalendarId][aItemId]
+ ? this.service.mTimerMap[aCalendarId][aItemId][aAlarmStr]
+ : null;
+ },
+
+ expectResult(aCalendar, aItem, aAlarm, aExpected) {
+ let expectedAndTitle = {
+ expected: aExpected,
+ title: aItem.title,
+ };
+ this.expectedMap[aCalendar.id] = this.expectedMap[aCalendar.id] || {};
+ this.expectedMap[aCalendar.id][aItem.hashId] =
+ this.expectedMap[aCalendar.id][aItem.hashId] || {};
+ this.expectedMap[aCalendar.id][aItem.hashId][aAlarm.icalString] = expectedAndTitle;
+ },
+
+ expectOccurrences(aCalendar, aItem, aAlarm, aExpectedArray) {
+ // we need to be earlier than the first occurrence
+ let date = aItem.startDate.clone();
+ date.second -= 1;
+
+ for (let expected of aExpectedArray) {
+ let occ = aItem.recurrenceInfo.getNextOccurrence(date);
+ occ.QueryInterface(Ci.calIEvent);
+ date = occ.startDate;
+ this.expectResult(aCalendar, occ, aAlarm, expected);
+ }
+ },
+
+ checkExpected(aMessage) {
+ for (let calId in this.expectedMap) {
+ for (let id in this.expectedMap[calId]) {
+ for (let icalString in this.expectedMap[calId][id]) {
+ let expectedAndTitle = this.expectedMap[calId][id][icalString];
+ // if no explicit message has been passed, take the item title
+ let message = typeof aMessage == "string" ? aMessage : expectedAndTitle.title;
+ // only alarms expected as fired should exist in our fired alarm map
+ do_check_xor(
+ expectedAndTitle.expected != EXPECT_FIRED,
+ id in this.firedMap && icalString in this.firedMap[id],
+ message + "; check fired"
+ );
+ // only alarms expected as timers should exist in the service's timer map
+ do_check_xor(
+ expectedAndTitle.expected != EXPECT_TIMER,
+ !!this.getTimer(calId, id, icalString),
+ message + "; check timer"
+ );
+ }
+ }
+ }
+ },
+
+ checkLoadStatus() {
+ for (let calId in this.service.mLoadedCalendars) {
+ if (!this.service.mLoadedCalendars[calId]) {
+ // at least one calendar hasn't finished loading alarms
+ ok(this.service.isLoading);
+ return;
+ }
+ }
+ ok(!this.service.isLoading);
+ },
+
+ clear() {
+ this.firedMap = {};
+ this.pendingOps = {};
+ this.expectedMap = {};
+ },
+};
+
+add_setup(async function () {
+ do_get_profile();
+ await new Promise(resolve =>
+ do_calendar_startup(() => {
+ alarmObserver.service = Cc["@mozilla.org/calendar/alarm-service;1"].getService(
+ Ci.calIAlarmService
+ ).wrappedJSObject;
+ ok(!alarmObserver.service.mStarted);
+ alarmObserver.service.startup(null);
+ ok(alarmObserver.service.mStarted);
+
+ // we need to replace the existing observers with our observer
+ for (let obs of alarmObserver.service.mObservers.values()) {
+ alarmObserver.service.removeObserver(obs);
+ }
+ alarmObserver.service.addObserver(alarmObserver);
+ resolve();
+ })
+ );
+});
+
+function createAlarmFromDuration(aOffset) {
+ let alarm = new CalAlarm();
+
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ alarm.offset = cal.createDuration(aOffset);
+
+ return alarm;
+}
+
+function createEventWithAlarm(aCalendar, aStart, aEnd, aOffset, aRRule) {
+ let alarm = null;
+ let item = new CalEvent();
+
+ item.id = cal.getUUID();
+ item.calendar = aCalendar;
+ item.startDate = aStart || cal.dtz.now();
+ item.endDate = aEnd || cal.dtz.now();
+ if (aOffset) {
+ alarm = createAlarmFromDuration(aOffset);
+ item.addAlarm(alarm);
+ }
+ if (aRRule) {
+ item.recurrenceInfo = new CalRecurrenceInfo(item);
+ item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule(aRRule));
+ }
+ return [item, alarm];
+}
+
+async function addTestItems(aCalendar) {
+ let item, alarm;
+
+ // alarm on an item starting more than a month in the past should not fire
+ let date = cal.dtz.now();
+ date.day -= 32;
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "P7D");
+ item.title = "addTestItems Test 1";
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_NONE);
+ await aCalendar.addItem(item);
+
+ // alarm 15 minutes ago should fire
+ date = cal.dtz.now();
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT15M");
+ item.title = "addTestItems Test 2";
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_FIRED);
+ await aCalendar.addItem(item);
+
+ // alarm within 6 hours should have a timer set
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "PT1H");
+ item.title = "addTestItems Test 3";
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_TIMER);
+ await aCalendar.addItem(item);
+
+ // alarm more than 6 hours in the future should not have a timer set
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "PT7H");
+ item.title = "addTestItems Test 4";
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_NONE);
+ await aCalendar.addItem(item);
+
+ // test multiple alarms on an item
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date);
+ item.title = "addTestItems Test 5";
+ const firedOffsets = [
+ ["-PT1H", EXPECT_FIRED],
+ ["-PT15M", EXPECT_FIRED],
+ ["PT1H", EXPECT_TIMER],
+ ["PT7H", EXPECT_NONE],
+ ["P7D", EXPECT_NONE],
+ ];
+
+ firedOffsets.forEach(([offset, expected]) => {
+ alarm = createAlarmFromDuration(offset);
+ item.addAlarm(alarm);
+ alarmObserver.expectResult(aCalendar, item, alarm, expected);
+ });
+ await aCalendar.addItem(item);
+
+ // Bug 1344068 - Alarm with lastAck on exception, should take parent lastAck.
+ // Alarm 15 minutes ago should fire.
+ date = cal.dtz.now();
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT15M", "RRULE:FREQ=DAILY;COUNT=1");
+ item.title = "addTestItems Test 6";
+
+ // Parent item is acknowledged before alarm, so it should fire.
+ let lastAck = item.startDate.clone();
+ lastAck.hour -= 1;
+ item.alarmLastAck = lastAck;
+
+ // Occurrence is acknowledged after alarm (start date), so if the alarm
+ // service wrongly uses the exception occurrence then we catch it.
+ let occ = item.recurrenceInfo.getOccurrenceFor(item.startDate);
+ occ.alarmLastAck = item.startDate.clone();
+ item.recurrenceInfo.modifyException(occ, true);
+
+ alarmObserver.expectOccurrences(aCalendar, item, alarm, [EXPECT_FIRED]);
+ await aCalendar.addItem(item);
+
+ // daily repeating event starting almost 2 full days ago. The alarms on the first 2 occurrences
+ // should fire, and a timer should be set for the next occurrence only
+ date = cal.dtz.now();
+ date.hour -= 47;
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT15M", "RRULE:FREQ=DAILY");
+ item.title = "addTestItems Test 7";
+ alarmObserver.expectOccurrences(aCalendar, item, alarm, [
+ EXPECT_FIRED,
+ EXPECT_FIRED,
+ EXPECT_TIMER,
+ EXPECT_NONE,
+ EXPECT_NONE,
+ ]);
+ await aCalendar.addItem(item);
+
+ // monthly repeating event starting 2 months and a day ago. The alarms on the first 2 occurrences
+ // should be ignored, the alarm on the next occurrence only should fire.
+ // Missing recurrences of the event in particular days of the year generate exceptions to the
+ // regular sequence of alarms.
+ date = cal.dtz.now();
+ let statusAlarmSequences = {
+ reg: [EXPECT_NONE, EXPECT_NONE, EXPECT_FIRED, EXPECT_NONE, EXPECT_NONE],
+ excep1: [EXPECT_NONE, EXPECT_FIRED, EXPECT_NONE, EXPECT_NONE, EXPECT_NONE],
+ excep2: [EXPECT_NONE, EXPECT_NONE, EXPECT_NONE, EXPECT_NONE, EXPECT_NONE],
+ };
+ let expected = [];
+ if (date.day == 1) {
+ // Exceptions for missing occurrences on months with 30 days when the event starts on 31st.
+ let sequence = [
+ "excep1",
+ "reg",
+ "excep2",
+ "excep1",
+ "reg",
+ "excep1",
+ "reg",
+ "excep1",
+ "reg",
+ "excep2",
+ "excep1",
+ "reg",
+ ][date.month];
+ expected = statusAlarmSequences[sequence];
+ } else if (date.day == 30 && (date.month == 2 || date.month == 3)) {
+ // Exceptions for missing occurrences or different start date caused by February.
+ let leapYear = date.endOfYear.yearday == 366;
+ expected = leapYear ? statusAlarmSequences.reg : statusAlarmSequences.excep1;
+ } else if (date.day == 31 && date.month == 2) {
+ // Exceptions for missing occurrences caused by February.
+ expected = statusAlarmSequences.excep1;
+ } else {
+ // Regular sequence of alarms expected for all the others days.
+ expected = statusAlarmSequences.reg;
+ }
+ date.month -= 2;
+ date.day -= 1;
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT15M", "RRULE:FREQ=MONTHLY");
+ item.title = "addTestItems Test 8";
+ alarmObserver.expectOccurrences(aCalendar, item, alarm, expected);
+ await aCalendar.addItem(item);
+}
+
+async function doModifyItemTest(aCalendar) {
+ let item, alarm;
+
+ // begin with item starting before the alarm date range
+ let date = cal.dtz.now();
+ date.day -= 32;
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "PT0S");
+ await aCalendar.addItem(item);
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_NONE);
+ alarmObserver.checkExpected("doModifyItemTest Test 1");
+
+ // move event into the fired range
+ let oldItem = item.clone();
+ date.day += 31;
+ item.startDate = date.clone();
+ item.generation++;
+ await aCalendar.modifyItem(item, oldItem);
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_FIRED);
+ alarmObserver.checkExpected("doModifyItemTest Test 2");
+
+ // move event into the timer range
+ oldItem = item.clone();
+ date.hour += 25;
+ item.startDate = date.clone();
+ item.generation++;
+ await aCalendar.modifyItem(item, oldItem);
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_TIMER);
+ alarmObserver.checkExpected("doModifyItemTest Test 3");
+
+ // move event past the timer range
+ oldItem = item.clone();
+ date.hour += 6;
+ item.startDate = date.clone();
+ item.generation++;
+ await aCalendar.modifyItem(item, oldItem);
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_NONE);
+ alarmObserver.checkExpected("doModifyItemTest Test 4");
+
+ // re-move the event in the timer range and verify that the timer
+ // doesn't change when the timezone changes to floating (bug 1300493).
+ oldItem = item.clone();
+ date.hour -= 6;
+ item.startDate = date.clone();
+ item.generation++;
+ await aCalendar.modifyItem(item, oldItem);
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_TIMER);
+ alarmObserver.checkExpected("doModifyItemTest Test 5");
+ let oldTimer = alarmObserver.getTimer(aCalendar.id, item.hashId, alarm.icalString);
+ oldItem = item.clone();
+ // change the timezone to floating
+ item.startDate.timezone = cal.dtz.floating;
+ item.generation++;
+ await aCalendar.modifyItem(item, oldItem);
+ // the alarm must still be timer and with the same value (apart from milliseconds)
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_TIMER);
+ alarmObserver.checkExpected("doModifyItemTest Test 5, floating timezone");
+ let newTimer = alarmObserver.getTimer(aCalendar.id, item.hashId, alarm.icalString);
+ ok(
+ newTimer.delay - oldTimer.delay <= 1000,
+ "doModifyItemTest Test 5, floating timezone; check timer value"
+ );
+}
+
+async function doDeleteItemTest(aCalendar) {
+ alarmObserver.clear();
+ let item, alarm;
+ let item2, alarm2;
+
+ // create a fired alarm and a timer
+ let date = cal.dtz.now();
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT5M");
+ [item2, alarm2] = createEventWithAlarm(aCalendar, date, date, "PT1H");
+ item.title = "doDeleteItemTest item Test 1";
+ item2.title = "doDeleteItemTest item2 Test 1";
+ await aCalendar.addItem(item);
+ await aCalendar.addItem(item2);
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_FIRED);
+ alarmObserver.expectResult(aCalendar, item2, alarm2, EXPECT_TIMER);
+ alarmObserver.checkExpected();
+
+ // item deletion should clear the fired alarm and timer
+ await aCalendar.deleteItem(item);
+ await aCalendar.deleteItem(item2);
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_NONE);
+ alarmObserver.expectResult(aCalendar, item2, alarm2, EXPECT_NONE);
+ alarmObserver.checkExpected("doDeleteItemTest, cleared fired alarm and timer");
+}
+
+async function doAcknowledgeTest(aCalendar) {
+ alarmObserver.clear();
+ let item, alarm;
+ let item2, alarm2;
+
+ // create the fired alarms
+ let date = cal.dtz.now();
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT5M");
+ [item2, alarm2] = createEventWithAlarm(aCalendar, date, date, "-PT5M");
+ item.title = "doAcknowledgeTest item Test 1";
+ item2.title = "doAcknowledgeTest item2 Test 1";
+ await aCalendar.addItem(item);
+ await aCalendar.addItem(item2);
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_FIRED);
+ alarmObserver.expectResult(aCalendar, item2, alarm2, EXPECT_FIRED);
+ alarmObserver.checkExpected();
+
+ // test snooze alarm
+ alarmObserver.service.snoozeAlarm(item, alarm, cal.createDuration("PT1H"));
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_TIMER);
+ alarmObserver.checkExpected("doAcknowledgeTest, test snooze alarm");
+
+ // the snoozed alarm timer delay should be close to an hour
+ let tmr = alarmObserver.getTimer(aCalendar.id, item.hashId, alarm.icalString);
+ ok(
+ Math.abs(tmr.delay - 3600000) <= 1000,
+ "doAcknowledgeTest, snoozed alarm timer delay close to an hour"
+ );
+
+ // test dismiss alarm
+ alarmObserver.service.dismissAlarm(item2, alarm2);
+ alarmObserver.expectResult(aCalendar, item2, alarm2, EXPECT_NONE);
+ alarmObserver.checkExpected("doAcknowledgeTest, test dismiss alarm");
+}
+
+async function doRunTest(aOnCalendarCreated) {
+ alarmObserver.clear();
+
+ let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ memory.id = cal.getUUID();
+
+ if (aOnCalendarCreated) {
+ await aOnCalendarCreated(memory);
+ }
+
+ cal.manager.registerCalendar(memory);
+ await alarmObserver.doOnAlarmsLoaded(memory);
+ return memory;
+}
+
+/**
+ * Test the initial alarm loading of a calendar with existing data.
+ */
+add_task(async function test_loadCalendar() {
+ await doRunTest(async memory => addTestItems(memory));
+ alarmObserver.checkExpected();
+});
+
+/**
+ * Test adding alarm data to a calendar already registered.
+ */
+add_task(async function test_addItems() {
+ let memory = await doRunTest();
+ await addTestItems(memory);
+ alarmObserver.checkExpected();
+});
+
+/**
+ * Test response to modification of alarm data.
+ */
+add_task(async function test_modifyItems() {
+ let memory = await doRunTest();
+ await doModifyItemTest(memory);
+ await doDeleteItemTest(memory);
+ await doAcknowledgeTest(memory);
+});
+
+/**
+ * Test an array of timers has expected delay values.
+ *
+ * @param {nsITimer[]} timers - An array of nsITimer.
+ * @param {number[]} expected - Expected delays in seconds.
+ */
+function matchTimers(timers, expected) {
+ let delays = timers.map(timer => timer.delay / 1000);
+ let matched = true;
+ for (let i = 0; i < delays.length; i++) {
+ if (Math.abs(delays[i] - expected[i]) > 2) {
+ matched = false;
+
+ break;
+ }
+ }
+ ok(matched, `Delays=${delays} should match Expected=${expected}`);
+}
+
+/**
+ * Test notification timers are set up correctly when add/modify/remove a
+ * calendar item.
+ */
+add_task(async function test_notificationTimers() {
+ let memory = await doRunTest();
+ // Add an item.
+ let date = cal.dtz.now();
+ date.hour += 1;
+ let item, oldItem;
+ [item] = createEventWithAlarm(memory, date, date, null);
+ await memory.addItem(item);
+ equal(
+ alarmObserver.service.mNotificationTimerMap[item.calendar.id],
+ undefined,
+ "should have no notification timer"
+ );
+
+ // Set the pref to have one notifiaction.
+ Services.prefs.setCharPref("calendar.notifications.times", "-PT1H");
+ oldItem = item.clone();
+ date.hour += 1;
+ item.startDate = date.clone();
+ item.generation++;
+ await memory.modifyItem(item, oldItem);
+ // Should have one notification timer
+ matchTimers(alarmObserver.service.mNotificationTimerMap[item.calendar.id][item.hashId], [3600]);
+
+ // Set the pref to have three notifiactions.
+ Services.prefs.setCharPref("calendar.notifications.times", "END:PT2M,PT0M,END:-PT30M,-PT5M");
+ oldItem = item.clone();
+ date.hour -= 1;
+ item.startDate = date.clone();
+ date.hour += 1;
+ item.endDate = date.clone();
+ item.generation++;
+ await memory.modifyItem(item, oldItem);
+ // Should have four notification timers.
+ matchTimers(alarmObserver.service.mNotificationTimerMap[item.calendar.id][item.hashId], [
+ 3300, // 55 minutes
+ 3600, // 60 minutes
+ 5400, // 90 minutes, which is 30 minutes before the end (END:-PT30M)
+ 7320, // 122 minutes, which is 2 minutes after the end (END:PT2M)
+ ]);
+
+ alarmObserver.service.removeFiredNotificationTimer(item);
+ // Should have three notification timers.
+ matchTimers(
+ alarmObserver.service.mNotificationTimerMap[item.calendar.id][item.hashId],
+ [3600, 5400, 7320]
+ );
+
+ await memory.deleteItem(item);
+ equal(
+ alarmObserver.service.mNotificationTimerMap[item.calendar.id],
+ undefined,
+ "notification timers should be removed"
+ );
+
+ Services.prefs.clearUserPref("calendar.notifications.times");
+});
+
+/**
+ * Test notification timers are set up correctly according to the calendar level
+ * notifications.times config.
+ */
+add_task(async function test_calendarLevelNotificationTimers() {
+ let loaded = false;
+ let item;
+ let memory = await doRunTest();
+
+ if (!loaded) {
+ loaded = true;
+ // Set the global pref to have one notifiaction.
+ Services.prefs.setCharPref("calendar.notifications.times", "-PT1H");
+
+ // Add an item.
+ let date = cal.dtz.now();
+ date.hour += 2;
+ [item] = createEventWithAlarm(memory, date, date, null);
+ await memory.addItem(item);
+
+ // Should have one notification timer.
+ matchTimers(alarmObserver.service.mNotificationTimerMap[item.calendar.id][item.hashId], [3600]);
+ // Set the calendar level pref to have two notification timers.
+ memory.setProperty("notifications.times", "-PT5M,PT0M");
+ }
+
+ await TestUtils.waitForCondition(
+ () => alarmObserver.service.mNotificationTimerMap[item.calendar.id]?.[item.hashId].length == 2
+ );
+ // Should have two notification timers
+ matchTimers(alarmObserver.service.mNotificationTimerMap[item.calendar.id][item.hashId], [
+ 6900, // 105 minutes
+ 7200, // 120 minutes
+ ]);
+
+ Services.prefs.clearUserPref("calendar.notifications.times");
+});
+
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("calendar.notifications.times");
+});
diff --git a/comm/calendar/test/unit/test_alarmutils.js b/comm/calendar/test/unit/test_alarmutils.js
new file mode 100644
index 0000000000..e51453367d
--- /dev/null
+++ b/comm/calendar/test/unit/test_alarmutils.js
@@ -0,0 +1,171 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+function run_test() {
+ do_calendar_startup(run_next_test);
+}
+
+add_task(async function test_setDefaultValues_events() {
+ let item, alarm;
+
+ Services.prefs.setIntPref("calendar.alarms.onforevents", 1);
+ Services.prefs.setStringPref("calendar.alarms.eventalarmunit", "hours");
+ Services.prefs.setIntPref("calendar.alarms.eventalarmlen", 60);
+ item = new CalEvent();
+ cal.alarms.setDefaultValues(item);
+ alarm = item.getAlarms()[0];
+ ok(alarm);
+ equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START);
+ equal(alarm.action, "DISPLAY");
+ equal(alarm.offset.icalString, "-P2DT12H");
+
+ Services.prefs.setIntPref("calendar.alarms.onforevents", 1);
+ Services.prefs.setStringPref("calendar.alarms.eventalarmunit", "yards");
+ Services.prefs.setIntPref("calendar.alarms.eventalarmlen", 20);
+ item = new CalEvent();
+ cal.alarms.setDefaultValues(item);
+ alarm = item.getAlarms()[0];
+ ok(alarm);
+ equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START);
+ equal(alarm.action, "DISPLAY");
+ equal(alarm.offset.icalString, "-PT20M");
+
+ Services.prefs.setIntPref("calendar.alarms.onforevents", 0);
+ item = new CalEvent();
+ cal.alarms.setDefaultValues(item);
+ equal(item.getAlarms().length, 0);
+
+ let mockCalendar = {
+ getProperty() {
+ return ["SHOUT"];
+ },
+ };
+
+ Services.prefs.setIntPref("calendar.alarms.onforevents", 1);
+ Services.prefs.setStringPref("calendar.alarms.eventalarmunit", "hours");
+ Services.prefs.setIntPref("calendar.alarms.eventalarmlen", 60);
+ item = new CalEvent();
+ item.calendar = mockCalendar;
+ cal.alarms.setDefaultValues(item);
+ alarm = item.getAlarms()[0];
+ ok(alarm);
+ equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START);
+ equal(alarm.action, "SHOUT");
+ equal(alarm.offset.icalString, "-P2DT12H");
+
+ Services.prefs.clearUserPref("calendar.alarms.onforevents");
+ Services.prefs.clearUserPref("calendar.alarms.eventalarmunit");
+ Services.prefs.clearUserPref("calendar.alarms.eventalarmlen");
+});
+
+add_task(async function test_setDefaultValues_tasks() {
+ let item, alarm;
+ let calnow = cal.dtz.now;
+ let nowDate = cal.createDateTime("20150815T120000");
+ cal.dtz.now = function () {
+ return nowDate;
+ };
+
+ Services.prefs.setIntPref("calendar.alarms.onfortodos", 1);
+ Services.prefs.setStringPref("calendar.alarms.todoalarmunit", "hours");
+ Services.prefs.setIntPref("calendar.alarms.todoalarmlen", 60);
+ item = new CalTodo();
+ equal(item.entryDate, null);
+ cal.alarms.setDefaultValues(item);
+ alarm = item.getAlarms()[0];
+ ok(alarm);
+ equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START);
+ equal(alarm.action, "DISPLAY");
+ equal(alarm.offset.icalString, "-P2DT12H");
+ equal(item.entryDate.icalString, nowDate.icalString);
+
+ Services.prefs.setIntPref("calendar.alarms.onfortodos", 1);
+ Services.prefs.setStringPref("calendar.alarms.todoalarmunit", "yards");
+ Services.prefs.setIntPref("calendar.alarms.todoalarmlen", 20);
+ item = new CalTodo();
+ cal.alarms.setDefaultValues(item);
+ alarm = item.getAlarms()[0];
+ ok(alarm);
+ equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START);
+ equal(alarm.action, "DISPLAY");
+ equal(alarm.offset.icalString, "-PT20M");
+
+ Services.prefs.setIntPref("calendar.alarms.onfortodos", 0);
+ item = new CalTodo();
+ cal.alarms.setDefaultValues(item);
+ equal(item.getAlarms().length, 0);
+
+ let mockCalendar = {
+ getProperty() {
+ return ["SHOUT"];
+ },
+ };
+
+ Services.prefs.setIntPref("calendar.alarms.onfortodos", 1);
+ Services.prefs.setStringPref("calendar.alarms.todoalarmunit", "hours");
+ Services.prefs.setIntPref("calendar.alarms.todoalarmlen", 60);
+ item = new CalTodo();
+ item.calendar = mockCalendar;
+ cal.alarms.setDefaultValues(item);
+ alarm = item.getAlarms()[0];
+ ok(alarm);
+ equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START);
+ equal(alarm.action, "SHOUT");
+ equal(alarm.offset.icalString, "-P2DT12H");
+
+ Services.prefs.clearUserPref("calendar.alarms.onfortodos");
+ Services.prefs.clearUserPref("calendar.alarms.todoalarmunit");
+ Services.prefs.clearUserPref("calendar.alarms.todoalarmlen");
+ cal.dtz.now = calnow;
+});
+
+add_task(async function test_calculateAlarmDate() {
+ let item = new CalEvent();
+ item.startDate = cal.createDateTime("20150815T120000");
+ item.endDate = cal.createDateTime("20150815T130000");
+
+ let calculateAlarmDate = cal.alarms.calculateAlarmDate.bind(cal.alarms, item);
+
+ let alarm = new CalAlarm();
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE;
+ alarm.alarmDate = cal.createDateTime("20150815T110000");
+ equal(calculateAlarmDate(alarm).icalString, "20150815T110000");
+
+ alarm = new CalAlarm();
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ alarm.offset = cal.createDuration("-PT1H");
+ equal(calculateAlarmDate(alarm).icalString, "20150815T110000Z");
+
+ alarm = new CalAlarm();
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_END;
+ alarm.offset = cal.createDuration("-PT2H");
+ equal(calculateAlarmDate(alarm).icalString, "20150815T110000Z");
+
+ item.startDate.isDate = true;
+ alarm = new CalAlarm();
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ alarm.offset = cal.createDuration("-PT1H");
+ equal(calculateAlarmDate(alarm).icalString, "20150814T230000Z");
+ item.startDate.isDate = false;
+
+ item.endDate.isDate = true;
+ alarm = new CalAlarm();
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_END;
+ alarm.offset = cal.createDuration("-PT2H");
+ equal(calculateAlarmDate(alarm).icalString, "20150814T220000Z");
+ item.endDate.isDate = false;
+
+ alarm = new CalAlarm();
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_END;
+ equal(calculateAlarmDate(alarm), null);
+});
diff --git a/comm/calendar/test/unit/test_attachment.js b/comm/calendar/test/unit/test_attachment.js
new file mode 100644
index 0000000000..6e2b935851
--- /dev/null
+++ b/comm/calendar/test/unit/test_attachment.js
@@ -0,0 +1,112 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function run_test() {
+ test_serialize();
+ test_hashes();
+ test_uriattach();
+ test_binaryattach();
+}
+
+function test_hashes() {
+ let attach = new CalAttachment();
+
+ attach.rawData = "hello";
+ let hash1 = attach.hashId;
+
+ attach.rawData = "world";
+ notEqual(hash1, attach.hashId);
+
+ attach.rawData = "hello";
+ equal(hash1, attach.hashId);
+
+ // Setting raw data should give us a BINARY attachment
+ equal(attach.getParameter("VALUE"), "BINARY");
+
+ attach.uri = Services.io.newURI("http://hello");
+
+ // Setting an uri should delete the value parameter
+ equal(attach.getParameter("VALUE"), null);
+}
+
+function test_uriattach() {
+ let attach = new CalAttachment();
+
+ // Attempt to set a property and check its values
+ let e = new CalEvent();
+ // eslint-disable-next-line no-useless-concat
+ e.icalString = "BEGIN:VEVENT\r\n" + "ATTACH;FMTTYPE=x-moz/test:http://hello\r\n" + "END:VEVENT";
+ let prop = e.icalComponent.getFirstProperty("ATTACH");
+ attach.icalProperty = prop;
+
+ notEqual(attach.getParameter("VALUE"), "BINARY");
+ equal(attach.formatType, "x-moz/test");
+ equal(attach.getParameter("FMTTYPE"), "x-moz/test");
+ equal(attach.uri.spec, Services.io.newURI("http://hello").spec);
+ equal(attach.rawData, "http://hello");
+}
+
+function test_binaryattach() {
+ let attach = new CalAttachment();
+ let e = new CalEvent();
+
+ let attachString =
+ "ATTACH;ENCODING=BASE64;FMTTYPE=x-moz/test2;VALUE=BINARY:aHR0cDovL2hlbGxvMg==\r\n";
+ let icalString = "BEGIN:VEVENT\r\n" + attachString + "END:VEVENT";
+ e.icalString = icalString;
+ let prop = e.icalComponent.getFirstProperty("ATTACH");
+ attach.icalProperty = prop;
+
+ equal(attach.formatType, "x-moz/test2");
+ equal(attach.getParameter("FMTTYPE"), "x-moz/test2");
+ equal(attach.encoding, "BASE64");
+ equal(attach.getParameter("ENCODING"), "BASE64");
+ equal(attach.uri, null);
+ equal(attach.rawData, "aHR0cDovL2hlbGxvMg==");
+ equal(attach.getParameter("VALUE"), "BINARY");
+
+ let propIcalString = attach.icalProperty.icalString;
+ ok(!!propIcalString.match(/ENCODING=BASE64/));
+ ok(!!propIcalString.match(/FMTTYPE=x-moz\/test2/));
+ ok(!!propIcalString.match(/VALUE=BINARY/));
+ ok(!!propIcalString.replace("\r\n ", "").match(/:aHR0cDovL2hlbGxvMg==/));
+
+ propIcalString = attach.clone().icalProperty.icalString;
+
+ ok(!!propIcalString.match(/ENCODING=BASE64/));
+ ok(!!propIcalString.match(/FMTTYPE=x-moz\/test2/));
+ ok(!!propIcalString.match(/VALUE=BINARY/));
+ ok(!!propIcalString.replace("\r\n ", "").match(/:aHR0cDovL2hlbGxvMg==/));
+}
+
+function test_serialize() {
+ let attach = new CalAttachment();
+ attach.formatType = "x-moz/test2";
+ attach.uri = Services.io.newURI("data:text/plain,");
+ equal(attach.icalString, "ATTACH;FMTTYPE=x-moz/test2:data:text/plain,\r\n");
+
+ attach = new CalAttachment();
+ attach.encoding = "BASE64";
+ attach.uri = Services.io.newURI("data:text/plain,");
+ equal(attach.icalString, "ATTACH;ENCODING=BASE64:data:text/plain,\r\n");
+
+ throws(() => {
+ attach.icalString = "X-STICKER:smiley";
+ }, /Illegal value/);
+
+ attach = new CalAttachment();
+ attach.uri = Services.io.newURI("data:text/plain,");
+ attach.setParameter("X-PROP", "VAL");
+ equal(attach.icalString, "ATTACH;X-PROP=VAL:data:text/plain,\r\n");
+ attach.setParameter("X-PROP", null);
+ equal(attach.icalString, "ATTACH:data:text/plain,\r\n");
+}
diff --git a/comm/calendar/test/unit/test_attendee.js b/comm/calendar/test/unit/test_attendee.js
new file mode 100644
index 0000000000..55051f720b
--- /dev/null
+++ b/comm/calendar/test/unit/test_attendee.js
@@ -0,0 +1,318 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function run_test() {
+ test_values();
+ test_serialize();
+ test_properties();
+ test_doubleParameters(); // Bug 875739
+ test_emptyAttendee();
+}
+
+function test_values() {
+ function findAttendeesInResults(event, expectedAttendees) {
+ // Getting all attendees
+ let allAttendees = event.getAttendees();
+
+ equal(allAttendees.length, expectedAttendees.length);
+
+ // Check if all expected attendees are found
+ for (let i = 0; i < expectedAttendees.length; i++) {
+ ok(allAttendees.includes(expectedAttendees[i]));
+ }
+
+ // Check if all found attendees are expected
+ for (let i = 0; i < allAttendees.length; i++) {
+ ok(expectedAttendees.includes(allAttendees[i]));
+ }
+ }
+ function findById(event, id, a) {
+ let foundAttendee = event.getAttendeeById(id);
+ equal(foundAttendee, a);
+ }
+ function testImmutability(a, properties) {
+ ok(!a.isMutable);
+ // Check if setting a property throws. It should.
+ for (let i = 0; i < properties.length; i++) {
+ let old = a[properties[i]];
+ throws(() => {
+ a[properties[i]] = old + 1;
+ }, /Can not modify immutable data container/);
+
+ equal(a[properties[i]], old);
+ }
+ }
+
+ // Create Attendee
+ let attendee1 = new CalAttendee();
+ // Testing attendee set/get.
+ let properties = ["id", "commonName", "rsvp", "role", "participationStatus", "userType"];
+ let values = ["myid", "mycn", "TRUE", "CHAIR", "DECLINED", "RESOURCE"];
+ // Make sure test is valid
+ equal(properties.length, values.length);
+
+ for (let i = 0; i < properties.length; i++) {
+ attendee1[properties[i]] = values[i];
+ equal(attendee1[properties[i]], values[i]);
+ }
+
+ // Create event
+ let event = new CalEvent();
+
+ // Add attendee to event
+ event.addAttendee(attendee1);
+
+ // Add 2nd attendee to event.
+ let attendee2 = new CalAttendee();
+ attendee2.id = "myid2";
+ event.addAttendee(attendee2);
+
+ // Finding by ID
+ findById(event, "myid", attendee1);
+ findById(event, "myid2", attendee2);
+
+ findAttendeesInResults(event, [attendee1, attendee2]);
+
+ // Making attendee immutable
+ attendee1.makeImmutable();
+ testImmutability(attendee1, properties);
+ // Testing cascaded immutability (event -> attendee)
+ event.makeImmutable();
+ testImmutability(attendee2, properties);
+
+ // Testing cloning
+ let eventClone = event.clone();
+ let clonedatts = eventClone.getAttendees();
+ let atts = event.getAttendees();
+ equal(atts.length, clonedatts.length);
+
+ for (let i = 0; i < clonedatts.length; i++) {
+ // The attributes should not be equal
+ notEqual(atts[i], clonedatts[i]);
+ // But the ids should
+ equal(atts[i].id, clonedatts[i].id);
+ }
+
+ // Make sure organizers are also cloned correctly
+ let attendee3 = new CalAttendee();
+ attendee3.id = "horst";
+ attendee3.isOrganizer = true;
+ let attendee4 = attendee3.clone();
+
+ ok(attendee4.isOrganizer);
+ attendee3.isOrganizer = false;
+ ok(attendee4.isOrganizer);
+}
+
+function test_serialize() {
+ let a = new CalAttendee();
+
+ throws(() => {
+ // eslint-disable-next-line no-unused-expressions
+ a.icalProperty;
+ }, /Component not initialized/);
+
+ a.id = "horst";
+ a.commonName = "Horst";
+ a.rsvp = "TRUE";
+
+ a.isOrganizer = false;
+
+ a.role = "CHAIR";
+ a.participationStatus = "DECLINED";
+ a.userType = "RESOURCE";
+
+ a.setProperty("X-NAME", "X-VALUE");
+
+ let prop = a.icalProperty;
+ dump(prop.icalString);
+ equal(prop.value, "horst");
+ equal(prop.propertyName, "ATTENDEE");
+ equal(prop.getParameter("CN"), "Horst");
+ equal(prop.getParameter("RSVP"), "TRUE");
+ equal(prop.getParameter("ROLE"), "CHAIR");
+ equal(prop.getParameter("PARTSTAT"), "DECLINED");
+ equal(prop.getParameter("CUTYPE"), "RESOURCE");
+ equal(prop.getParameter("X-NAME"), "X-VALUE");
+
+ a.isOrganizer = true;
+ prop = a.icalProperty;
+ equal(prop.value, "horst");
+ equal(prop.propertyName, "ORGANIZER");
+ equal(prop.getParameter("CN"), "Horst");
+ equal(prop.getParameter("RSVP"), "TRUE");
+ equal(prop.getParameter("ROLE"), "CHAIR");
+ equal(prop.getParameter("PARTSTAT"), "DECLINED");
+ equal(prop.getParameter("CUTYPE"), "RESOURCE");
+ equal(prop.getParameter("X-NAME"), "X-VALUE");
+}
+
+function test_properties() {
+ let a = new CalAttendee();
+
+ throws(() => {
+ // eslint-disable-next-line no-unused-expressions
+ a.icalProperty;
+ }, /Component not initialized/);
+
+ a.id = "horst";
+ a.commonName = "Horst";
+ a.rsvp = "TRUE";
+
+ a.isOrganizer = false;
+
+ a.role = "CHAIR";
+ a.participationStatus = "DECLINED";
+ a.userType = "RESOURCE";
+
+ // Only X-Props should show up in the enumerator
+ a.setProperty("X-NAME", "X-VALUE");
+ for (let [name, value] of a.properties) {
+ equal(name, "X-NAME");
+ equal(value, "X-VALUE");
+ }
+
+ a.deleteProperty("X-NAME");
+ for (let [name, value] of a.properties) {
+ do_throw("Unexpected property " + name + " = " + value);
+ }
+
+ a.setProperty("X-NAME", "X-VALUE");
+ a.setProperty("X-NAME", null);
+
+ for (let [name, value] of a.properties) {
+ do_throw("Unexpected property after setting null " + name + " = " + value);
+ }
+}
+
+function test_doubleParameters() {
+ function testParameters(aAttendees, aExpected) {
+ for (let attendee of aAttendees) {
+ let prop = attendee.icalProperty;
+ let parNames = [];
+ let parValues = [];
+
+ // Extract the parameters
+ for (
+ let paramName = prop.getFirstParameterName();
+ paramName;
+ paramName = prop.getNextParameterName()
+ ) {
+ parNames.push(paramName);
+ parValues.push(prop.getParameter(paramName));
+ }
+
+ // Check the results
+ let att_n = attendee.id.substr(7, 9);
+ for (let parIndex in parNames) {
+ ok(
+ aExpected[att_n].param.includes(parNames[parIndex]),
+ "Parameter " + parNames[parIndex] + " included in " + att_n
+ );
+ ok(
+ aExpected[att_n].values.includes(parValues[parIndex]),
+ "Value " + parValues[parIndex] + " for parameter " + parNames[parIndex]
+ );
+ }
+ ok(
+ parNames.length == aExpected[att_n].param.length,
+ "Each parameter has been considered for " + att_n
+ );
+ }
+ }
+
+ // Event with attendees and organizer with one of the parameter duplicated.
+ let ics = [
+ "BEGIN:VCALENDAR",
+ "VERSION:2.0",
+ "PRODID:-//Marketcircle Inc.//Daylite 4.0//EN",
+ "BEGIN:VEVENT",
+ "DTSTART:20130529T100000",
+ "DTEND:20130529T110000",
+ "SUMMARY:Summary",
+ "CREATED:20130514T124220Z",
+ "DTSTAMP:20130524T101307Z",
+ "UID:9482DDFA-07B4-44B9-8228-ED4BC17BA278",
+ "SEQUENCE:3",
+ "ORGANIZER;CN=CN_organizer;X-ORACLE-GUID=A5120D71D6193E11E04400144F;",
+ " X-UW-AVAILABLE-APPOINTMENT-ROLE=OWNER;X-UW-AVAILABLE-APPOINTMENT",
+ " -ROLE=OWNER:mailto:organizer@example.com",
+ "ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL;RSVP=TRUE;",
+ " PARTSTAT=NEEDS-ACTION;X-RECEIVED-DTSTAMP=",
+ " 20130827T124944Z;CN=CN_attendee1:mailto:attendee1@example.com",
+ "ATTENDEE;ROLE=CHAIR;CN=CN_attendee2;CUTYPE=INDIVIDUAL;",
+ " PARTSTAT=ACCEPTED;CN=CN_attendee2:mailto:attendee2@example.com",
+ "ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=RESOURCE;",
+ " PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CN=CN_attendee3",
+ " :mailto:attendee3@example.com",
+ 'ATTENDEE;CN="CN_attendee4";PARTSTAT=ACCEPTED;X-RECEIVED-DTSTAMP=',
+ " 20130827T124944Z;X-RECEIVED-SEQUENCE=0;X-RECEIVED-SEQUENCE=0",
+ " :mailto:attendee4@example.com",
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ].join("\n");
+
+ let expectedOrganizer = {
+ organizer: {
+ param: ["CN", "X-ORACLE-GUID", "X-UW-AVAILABLE-APPOINTMENT-ROLE"],
+ values: ["CN_organizer", "A5120D71D6193E11E04400144F", "OWNER"],
+ },
+ };
+ let expectedAttendee = {
+ attendee1: {
+ param: ["CN", "RSVP", "ROLE", "PARTSTAT", "CUTYPE", "X-RECEIVED-DTSTAMP"],
+ values: [
+ "CN_attendee1",
+ "TRUE",
+ "REQ-PARTICIPANT",
+ "NEEDS-ACTION",
+ "INDIVIDUAL",
+ "20130827T124944Z",
+ ],
+ },
+ attendee2: {
+ param: ["CN", "ROLE", "PARTSTAT", "CUTYPE"],
+ values: ["CN_attendee2", "CHAIR", "ACCEPTED", "INDIVIDUAL"],
+ },
+ attendee3: {
+ param: ["CN", "RSVP", "ROLE", "PARTSTAT", "CUTYPE"],
+ values: ["CN_attendee3", "TRUE", "REQ-PARTICIPANT", "NEEDS-ACTION", "RESOURCE"],
+ },
+ attendee4: {
+ param: ["CN", "PARTSTAT", "X-RECEIVED-DTSTAMP", "X-RECEIVED-SEQUENCE"],
+ values: ["CN_attendee4", "ACCEPTED", "20130827T124944Z", "0"],
+ },
+ };
+
+ let event = createEventFromIcalString(ics);
+ let organizer = [event.organizer];
+ let attendees = event.getAttendees();
+
+ testParameters(organizer, expectedOrganizer);
+ testParameters(attendees, expectedAttendee);
+}
+function test_emptyAttendee() {
+ // Event with empty attendee.
+ const event = createEventFromIcalString(
+ [
+ "BEGIN:VCALENDAR",
+ "VERSION:2.0",
+ "BEGIN:VEVENT",
+ "DTSTAMP:19700101T000000Z",
+ "DTSTART:19700101T000001Z",
+ "ATTENDEE:",
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ].join("\n")
+ );
+ const attendees = event.getAttendees();
+ equal(attendees.length, 0);
+}
diff --git a/comm/calendar/test/unit/test_auth_utils.js b/comm/calendar/test/unit/test_auth_utils.js
new file mode 100644
index 0000000000..929762561e
--- /dev/null
+++ b/comm/calendar/test/unit/test_auth_utils.js
@@ -0,0 +1,100 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const USERNAME = "fred";
+const PASSWORD = "********";
+const ORIGIN = "https://origin";
+const REALM = "realm";
+
+function run_test() {
+ do_get_profile();
+ run_next_test();
+}
+
+function checkLoginCount(total) {
+ Assert.equal(total, Services.logins.countLogins("", "", ""));
+}
+
+/**
+ * Tests the passwordManager{Get,Save,Remove} functions
+ */
+add_task(async function test_password_manager() {
+ await Services.logins.initializationPromise;
+ checkLoginCount(0);
+
+ // Save the password
+ cal.auth.passwordManagerSave(USERNAME, PASSWORD, ORIGIN, REALM);
+ checkLoginCount(1);
+
+ // Save again, should modify the existing login
+ cal.auth.passwordManagerSave(USERNAME, PASSWORD, ORIGIN, REALM);
+ checkLoginCount(1);
+
+ // Retrieve the saved password
+ let passout = {};
+ let found = cal.auth.passwordManagerGet(USERNAME, passout, ORIGIN, REALM);
+ Assert.equal(passout.value, PASSWORD);
+ Assert.ok(found);
+ checkLoginCount(1);
+
+ // Retrieving should still happen with signon saving disabled, but saving should not
+ Services.prefs.setBoolPref("signon.rememberSignons", false);
+ passout = {};
+ found = cal.auth.passwordManagerGet(USERNAME, passout, ORIGIN, REALM);
+ Assert.equal(passout.value, PASSWORD);
+ Assert.ok(found);
+
+ Assert.throws(
+ () => cal.auth.passwordManagerSave(USERNAME, PASSWORD, ORIGIN, REALM),
+ /NS_ERROR_NOT_AVAILABLE/
+ );
+ Services.prefs.clearUserPref("signon.rememberSignons");
+ checkLoginCount(1);
+
+ // Remove the password
+ found = cal.auth.passwordManagerRemove(USERNAME, ORIGIN, REALM);
+ checkLoginCount(0);
+ Assert.ok(found);
+
+ // Really gone?
+ found = cal.auth.passwordManagerRemove(USERNAME, ORIGIN, REALM);
+ checkLoginCount(0);
+ Assert.ok(!found);
+});
+
+/**
+ * Tests various origins that can be passed to passwordManagerSave
+ */
+add_task(async function test_password_manager_origins() {
+ await Services.logins.initializationPromise;
+ checkLoginCount(0);
+
+ // The scheme of the origin should be normalized to lowercase, this won't add any new passwords
+ cal.auth.passwordManagerSave(USERNAME, PASSWORD, "OAUTH:xpcshell@example.com", REALM);
+ checkLoginCount(1);
+ cal.auth.passwordManagerSave(USERNAME, PASSWORD, "oauth:xpcshell@example.com", REALM);
+ checkLoginCount(1);
+
+ // Make sure that the prePath isn't used for oauth, because that is only the scheme
+ let found = cal.auth.passwordManagerGet(USERNAME, {}, "oauth:", REALM);
+ Assert.ok(!found);
+
+ // Save a https url with a path (only prePath should be used)
+ cal.auth.passwordManagerSave(USERNAME, PASSWORD, "https://example.com/withpath", REALM);
+ found = cal.auth.passwordManagerGet(USERNAME, {}, "https://example.com", REALM);
+ Assert.ok(found);
+ checkLoginCount(2);
+
+ // Entering something that is not an URL should assume https
+ cal.auth.passwordManagerSave(USERNAME, PASSWORD, "example.net", REALM);
+ found = cal.auth.passwordManagerGet(USERNAME, {}, "https://example.net", REALM);
+ Assert.ok(found);
+ checkLoginCount(3);
+
+ // Cleanup
+ cal.auth.passwordManagerRemove(USERNAME, "oauth:xpcshell@example.com", REALM);
+ cal.auth.passwordManagerRemove(USERNAME, "https://example.com", REALM);
+ cal.auth.passwordManagerRemove(USERNAME, "https://example.net", REALM);
+ checkLoginCount(0);
+});
diff --git a/comm/calendar/test/unit/test_bug1199942.js b/comm/calendar/test/unit/test_bug1199942.js
new file mode 100644
index 0000000000..7d78cb1244
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug1199942.js
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function run_test() {
+ // Test the graceful handling of attendee ids for bug 1199942
+ createAttendee_test();
+ serializeEvent_test();
+}
+
+function createAttendee_test() {
+ let data = [
+ { input: "mailto:user1@example.net", expected: "mailto:user1@example.net" },
+ { input: "MAILTO:user2@example.net", expected: "mailto:user2@example.net" },
+ { input: "user3@example.net", expected: "mailto:user3@example.net" },
+ { input: "urn:uuid:user4", expected: "urn:uuid:user4" },
+ ];
+ let event = new CalEvent();
+ for (let test of data) {
+ let attendee = new CalAttendee();
+ attendee.id = test.input;
+ event.addAttendee(attendee);
+ let readAttendee = event.getAttendeeById(cal.email.prependMailTo(test.input));
+ equal(readAttendee.id, test.expected);
+ }
+}
+
+function serializeEvent_test() {
+ let ics =
+ "BEGIN:VCALENDAR\n" +
+ "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN\n" +
+ "VERSION:2.0\n" +
+ "BEGIN:VEVENT\n" +
+ "CREATED:20150801T213509Z\n" +
+ "LAST-MODIFIED:20150830T164104Z\n" +
+ "DTSTAMP:20150830T164104Z\n" +
+ "UID:a84c74d1-cfc6-4ddf-9d60-9e4afd8238cf\n" +
+ "SUMMARY:New Event\n" +
+ "ORGANIZER;RSVP=TRUE;CN=Tester1;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:user1@example.net\n" +
+ "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:MAILTO:user2@example.net\n" +
+ "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:user3@example.net\n" +
+ "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:user4@example.net\n" +
+ "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:urn:uuid:user5\n" +
+ "DTSTART:20150729T103000Z\n" +
+ "DTEND:20150729T113000Z\n" +
+ "TRANSP:OPAQUE\n" +
+ "END:VEVENT\n" +
+ "END:VCALENDAR\n";
+
+ let expectedIds = [
+ "mailto:user2@example.net",
+ "mailto:user3@example.net",
+ "mailto:user4@example.net",
+ "urn:uuid:user5",
+ ];
+ let event = createEventFromIcalString(ics);
+ let attendees = event.getAttendees();
+
+ // check whether all attendees get returned with expected id
+ for (let attendee of attendees) {
+ ok(expectedIds.includes(attendee.id));
+ }
+
+ // serialize the event again and check whether the attendees still are in shape
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ serializer.addItems([event]);
+ let serialized = ics_unfoldline(serializer.serializeToString());
+ for (let id of expectedIds) {
+ ok(serialized.search(id) != -1);
+ }
+}
diff --git a/comm/calendar/test/unit/test_bug1204255.js b/comm/calendar/test/unit/test_bug1204255.js
new file mode 100644
index 0000000000..9277378ec4
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug1204255.js
@@ -0,0 +1,146 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function run_test() {
+ // Test attendee duplicate handling for bug 1204255
+ test_newAttendee();
+ test_fromICS();
+}
+
+function test_newAttendee() {
+ let data = [
+ {
+ input: [
+ { id: "user2@example.net", partstat: "NEEDS-ACTION", cname: "NOT PREFIXED" },
+ { id: "mailto:user2@example.net", partstat: "NEEDS-ACTION", cname: "PREFIXED" },
+ ],
+ expected: { id: "mailto:user2@example.net", partstat: "NEEDS-ACTION", cname: "PREFIXED" },
+ },
+ {
+ input: [
+ { id: "mailto:user3@example.net", partstat: "NEEDS-ACTION", cname: "PREFIXED" },
+ { id: "user3@example.net", partstat: "NEEDS-ACTION", cname: "NOT PREFIXED" },
+ ],
+ expected: { id: "mailto:user3@example.net", partstat: "NEEDS-ACTION", cname: "NOT PREFIXED" },
+ },
+ {
+ input: [
+ { id: "mailto:user4@example.net", partstat: "ACCEPTED", cname: "PREFIXED" },
+ { id: "user4@example.net", partstat: "TENTATIVE", cname: "NOT PREFIXED" },
+ ],
+ expected: { id: "mailto:user4@example.net", partstat: "ACCEPTED", cname: "PREFIXED" },
+ },
+ {
+ input: [
+ { id: "user5@example.net", partstat: "TENTATIVE", cname: "NOT PREFIXED" },
+ { id: "mailto:user5@example.net", partstat: "ACCEPTED", cname: "PREFIXED" },
+ ],
+ expected: { id: "mailto:user5@example.net", partstat: "TENTATIVE", cname: "NOT PREFIXED" },
+ },
+ {
+ input: [
+ { id: "user6@example.net", partstat: "DECLINED", cname: "NOT PREFIXED" },
+ { id: "mailto:user6@example.net", partstat: "TENTATIVE", cname: "PREFIXED" },
+ ],
+ expected: { id: "mailto:user6@example.net", partstat: "DECLINED", cname: "NOT PREFIXED" },
+ },
+ {
+ input: [
+ { id: "user7@example.net", partstat: "TENTATIVE", cname: "NOT PREFIXED" },
+ { id: "mailto:user7@example.net", partstat: "DECLINED", cname: "PREFIXED" },
+ ],
+ expected: { id: "mailto:user7@example.net", partstat: "DECLINED", cname: "PREFIXED" },
+ },
+ ];
+
+ let event = new CalEvent();
+ for (let test of data) {
+ for (let input of test.input) {
+ let attendee = new CalAttendee();
+ attendee.id = input.id;
+ attendee.participationStatus = input.partstat;
+ attendee.commonName = input.cname;
+ event.addAttendee(attendee);
+ }
+ let readAttendee = event.getAttendeeById(cal.email.prependMailTo(test.expected.id));
+ equal(readAttendee.id, test.expected.id);
+ equal(
+ readAttendee.participationStatus,
+ test.expected.partstat,
+ "partstat matches for " + test.expected.id
+ );
+ equal(
+ readAttendee.commonName,
+ test.expected.cname,
+ "commonName matches for " + test.expected.id
+ );
+ }
+}
+
+function test_fromICS() {
+ let ics = [
+ "BEGIN:VCALENDAR",
+ "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN",
+ "VERSION:2.0",
+ "BEGIN:VEVENT",
+ "UID:a84c74d1-cfc6-4ddf-9d60-9e4afd8238cf",
+ "SUMMARY:New Event",
+ "DTSTART:20150729T103000Z",
+ "DTEND:20150729T113000Z",
+ "ORGANIZER;RSVP=TRUE;CN=Tester1;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:user1@example.net",
+
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user2@example.net',
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user2@example.net',
+
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user3@example.net',
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user3@example.net',
+
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=ACCEPTED;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user4@example.net',
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=TENTATIVE;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user4@example.net',
+
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=TENTATIVE;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user5@example.net',
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=ACCEPTED;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user5@example.net',
+
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=DECLINED;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user6@example.net',
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=TENTATIVE;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user6@example.net',
+
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=TENTATIVE;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user7@example.net',
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=DECLINED;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user7@example.net',
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ].join("\n");
+
+ let expected = [
+ { id: "mailto:user2@example.net", partstat: "NEEDS-ACTION", cname: "PREFIXED" },
+ { id: "mailto:user3@example.net", partstat: "NEEDS-ACTION", cname: "NOT PREFIXED" },
+ { id: "mailto:user4@example.net", partstat: "ACCEPTED", cname: "PREFIXED" },
+ { id: "mailto:user5@example.net", partstat: "TENTATIVE", cname: "NOT PREFIXED" },
+ { id: "mailto:user6@example.net", partstat: "DECLINED", cname: "NOT PREFIXED" },
+ { id: "mailto:user7@example.net", partstat: "DECLINED", cname: "PREFIXED" },
+ ];
+ let event = createEventFromIcalString(ics);
+ let attendees = event.getAttendees();
+
+ // check whether all attendees get returned as expected
+ equal(attendees.length, expected.length);
+ let count = 0;
+ for (let attendee of attendees) {
+ for (let exp of expected) {
+ if (attendee.id == exp.id) {
+ equal(attendee.participationStatus, exp.partstat, "partstat matches for " + exp.id);
+ equal(attendee.commonName, exp.cname, "commonName matches for " + exp.id);
+ count++;
+ }
+ }
+ }
+ equal(count, expected.length, "all attendees were processed");
+}
diff --git a/comm/calendar/test/unit/test_bug1209399.js b/comm/calendar/test/unit/test_bug1209399.js
new file mode 100644
index 0000000000..6392f25867
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug1209399.js
@@ -0,0 +1,117 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function run_test() {
+ // Test handling for multiple double quotes leading/trailing to attendee CN for bug 1209399
+ test_newAttendee();
+ test_fromICS();
+}
+
+function test_newAttendee() {
+ let data = [
+ {
+ input: { cname: null, id: "mailto:user1@example.net" },
+ expected: { cname: null },
+ },
+ {
+ input: { cname: "Test2", id: "mailto:user2@example.net" },
+ expected: { cname: "Test2" },
+ },
+ {
+ input: { cname: '"Test3"', id: "mailto:user3@example.net" },
+ expected: { cname: "Test3" },
+ },
+ {
+ input: { cname: '""Test4""', id: "mailto:user4@example.net" },
+ expected: { cname: "Test4" },
+ },
+ {
+ input: { cname: '""Test5"', id: "mailto:user5@example.net" },
+ expected: { cname: "Test5" },
+ },
+ {
+ input: { cname: '"Test6""', id: "mailto:user6@example.net" },
+ expected: { cname: "Test6" },
+ },
+ {
+ input: { cname: "", id: "mailto:user7@example.net" },
+ expected: { cname: "" },
+ },
+ {
+ input: { cname: '""', id: "mailto:user8@example.net" },
+ expected: { cname: null },
+ },
+ {
+ input: { cname: '""""', id: "mailto:user9@example.net" },
+ expected: { cname: null },
+ },
+ ];
+
+ let i = 0;
+ let event = new CalEvent();
+ for (let test of data) {
+ i++;
+ let attendee = new CalAttendee();
+ attendee.id = test.input.id;
+ attendee.commonName = test.input.cname;
+
+ event.addAttendee(attendee);
+ let readAttendee = event.getAttendeeById(test.input.id);
+ equal(
+ readAttendee.commonName,
+ test.expected.cname,
+ "Test #" + i + " for commonName matching of " + test.input.id
+ );
+ }
+}
+
+function test_fromICS() {
+ let ics = [
+ "BEGIN:VCALENDAR",
+ "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN",
+ "VERSION:2.0",
+ "BEGIN:VEVENT",
+ "UID:a84c74d1-cfc6-4ddf-9d60-9e4afd8238cf",
+ "SUMMARY:New Event",
+ "DTSTART:20150729T103000Z",
+ "DTEND:20150729T113000Z",
+ "ORGANIZER;RSVP=TRUE;CN=Tester1;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:user1@example.net",
+
+ "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN=Test2;ROLE=REQ-PARTICIPANT:mailto:user2@example.net",
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="Test3";ROLE=REQ-PARTICIPANT:mailto:user3@example.net',
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN=""Test4"";ROLE=REQ-PARTICIPANT:mailto:user4@example.net',
+ "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN=;ROLE=REQ-PARTICIPANT:mailto:user5@example.net",
+ "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:user6@example.net",
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="";ROLE=REQ-PARTICIPANT:mailto:user7@example.net',
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="""";ROLE=REQ-PARTICIPANT:mailto:user8@example.net',
+
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ].join("\n");
+
+ let expected = [
+ { id: "mailto:user2@example.net", cname: "Test2" },
+ { id: "mailto:user3@example.net", cname: "Test3" },
+ { id: "mailto:user4@example.net", cname: "" },
+ { id: "mailto:user5@example.net", cname: "" },
+ { id: "mailto:user6@example.net", cname: null },
+ { id: "mailto:user7@example.net", cname: "" },
+ { id: "mailto:user8@example.net", cname: "" },
+ ];
+ let event = createEventFromIcalString(ics);
+
+ equal(event.getAttendees().length, expected.length, "Check test consistency");
+ for (let exp of expected) {
+ let attendee = event.getAttendeeById(exp.id);
+ equal(attendee.commonName, exp.cname, "Test for commonName matching of " + exp.id);
+ }
+}
diff --git a/comm/calendar/test/unit/test_bug1790339.js b/comm/calendar/test/unit/test_bug1790339.js
new file mode 100644
index 0000000000..4d2bed95ce
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug1790339.js
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async () => {
+ let dbFile = do_get_profile();
+ dbFile.append("test_storage.sqlite");
+
+ let sql = await IOUtils.readUTF8(do_get_file("data/bug1790339.sql").path);
+ let db = Services.storage.openDatabase(dbFile);
+ db.executeSimpleSQL(sql);
+ db.close();
+
+ await new Promise(resolve => {
+ do_calendar_startup(resolve);
+ });
+
+ let calendar = Cc["@mozilla.org/calendar/calendar;1?type=storage"].createInstance(
+ Ci.calISyncWriteCalendar
+ );
+ calendar.uri = Services.io.newFileURI(dbFile);
+ calendar.id = "00000000-0000-0000-0000-000000000000";
+
+ checkItem(await calendar.getItem("00000000-0000-0000-0000-111111111111"));
+ checkItem(await calendar.getItem("00000000-0000-0000-0000-222222222222"));
+});
+
+function checkItem(item) {
+ info(`Checking item ${item.id}`);
+
+ let attachments = item.getAttachments();
+ Assert.equal(attachments.length, 1);
+ let attach = attachments[0];
+ Assert.equal(
+ attach.uri.spec,
+ "https://ftp.mozilla.org/pub/thunderbird/nightly/latest-comm-central/thunderbird-106.0a1.en-US.linux-x86_64.tar.bz2"
+ );
+
+ let attendees = item.getAttendees();
+ Assert.equal(attendees.length, 1);
+ let attendee = attendees[0];
+ Assert.equal(attendee.id, "mailto:test@example.com");
+ Assert.equal(attendee.role, "REQ-PARTICIPANT");
+ Assert.equal(attendee.participationStatus, "NEEDS-ACTION");
+
+ let recurrenceItems = item.recurrenceInfo.getRecurrenceItems();
+ Assert.equal(recurrenceItems.length, 1);
+ let recurrenceItem = recurrenceItems[0];
+ Assert.equal(recurrenceItem.type, "WEEKLY");
+ Assert.equal(recurrenceItem.interval, 22);
+ Assert.equal(recurrenceItem.isByCount, false);
+ Assert.equal(recurrenceItem.isFinite, true);
+ Assert.deepEqual(recurrenceItem.getComponent("BYDAY"), [2, 3, 4, 5, 6, 7, 1]);
+ Assert.equal(recurrenceItem.isNegative, false);
+
+ let relations = item.getRelations();
+ Assert.equal(relations.length, 1);
+ let relation = relations[0];
+ Assert.equal(relation.relType, "SIBLING");
+ Assert.equal(relation.relId, "19960401-080045-4000F192713@example.com");
+
+ let alarms = item.getAlarms();
+ Assert.equal(alarms.length, 1);
+ let alarm = alarms[0];
+ Assert.equal(alarm.action, "DISPLAY");
+ Assert.equal(alarm.offset.inSeconds, -300);
+ Assert.equal(
+ alarm.description,
+ "Make sure you don't miss this very very important event. It's essential that you don't forget."
+ );
+}
diff --git a/comm/calendar/test/unit/test_bug272411.js b/comm/calendar/test/unit/test_bug272411.js
new file mode 100644
index 0000000000..c871b53cf0
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug272411.js
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ let jsd = new Date();
+ let cdt = cal.dtz.jsDateToDateTime(jsd);
+
+ let cdtTime = cal.dtz.dateTimeToJsDate(cdt).getTime() / 1000;
+ let jsdTime = Math.floor(jsd.getTime() / 1000);
+
+ // calIDateTime is only accurate to the second, milliseconds need to be
+ // stripped.
+ equal(cdtTime, jsdTime);
+}
diff --git a/comm/calendar/test/unit/test_bug343792.js b/comm/calendar/test/unit/test_bug343792.js
new file mode 100644
index 0000000000..c212315af3
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug343792.js
@@ -0,0 +1,66 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ // Check that Bug 343792 doesn't regress:
+ // Freeze (hang) on RRULE which has INTERVAL=0
+
+ let icalString =
+ "BEGIN:VCALENDAR\n" +
+ "CALSCALE:GREGORIAN\n" +
+ "PRODID:-//Ximian//NONSGML Evolution Calendar//EN\n" +
+ "VERSION:2.0\n" +
+ "BEGIN:VTIMEZONE\n" +
+ "TZID:/softwarestudio.org/Olson_20011030_5/America/Los_Angeles\n" +
+ "X-LIC-LOCATION:America/Los_Angeles\n" +
+ "BEGIN:STANDARD\n" +
+ "TZOFFSETFROM:-0700\n" +
+ "TZOFFSETTO:-0800\n" +
+ "TZNAME:PST\n" +
+ "DTSTART:19701025T020000\n" +
+ "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;INTERVAL=1\n" +
+ "END:STANDARD\n" +
+ "BEGIN:DAYLIGHT\n" +
+ "TZOFFSETFROM:-0800\n" +
+ "TZOFFSETTO:-0700\n" +
+ "TZNAME:PDT\n" +
+ "DTSTART:19700405T020000\n" +
+ "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;INTERVAL=1\n" +
+ "END:DAYLIGHT\n" +
+ "END:VTIMEZONE\n" +
+ "BEGIN:VEVENT\n" +
+ "UID:20060705T145529-1768-1244-1267-46@localhost\n" +
+ "ORGANIZER:MAILTO:No Body\n" +
+ "DTSTAMP:20060705T145529Z\n" +
+ "DTSTART;TZID=/softwarestudio.org/Olson_20011030_5/America/Los_Angeles:\n" +
+ " 20060515T170000\n" +
+ "DTEND;TZID=/softwarestudio.org/Olson_20011030_5/America/Los_Angeles:\n" +
+ " 20060515T173000\n" +
+ "RRULE:FREQ=WEEKLY;INTERVAL=0\n" +
+ "LOCATION:Maui Building\n" +
+ "TRANSP:OPAQUE\n" +
+ "SEQUENCE:0\n" +
+ "SUMMARY:FW development Status\n" +
+ "PRIORITY:4\n" +
+ "CLASS:PUBLIC\n" +
+ "DESCRIPTION:Daily standup Mtg and/or status update on FW\n" +
+ "END:VEVENT\n" +
+ "END:VCALENDAR";
+
+ let event = createEventFromIcalString(icalString);
+ let start = createDate(2009, 4, 1);
+ let end = createDate(2009, 4, 30);
+
+ // the following call caused a never ending loop:
+ let occurrenceDates = event.recurrenceInfo.getOccurrenceDates(start, end, 0);
+ equal(occurrenceDates.length, 4);
+
+ // the following call caused a never ending loop:
+ let occurrences = event.recurrenceInfo.getOccurrences(start, end, 0);
+ equal(occurrences.length, 4);
+}
diff --git a/comm/calendar/test/unit/test_bug350845.js b/comm/calendar/test/unit/test_bug350845.js
new file mode 100644
index 0000000000..735f4b6eb4
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug350845.js
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ let event = createEventFromIcalString(
+ "BEGIN:VEVENT\n" +
+ "UID:182d2719-fe2a-44c1-9210-0286b16c0538\n" +
+ "X-FOO;X-BAR=BAZ:QUUX\n" +
+ "END:VEVENT"
+ );
+
+ // Test getters for imported event
+ equal(event.getProperty("X-FOO"), "QUUX");
+ ok(event.hasProperty("X-FOO"));
+ equal(event.getPropertyParameter("X-FOO", "X-BAR"), "BAZ");
+ ok(event.hasPropertyParameter("X-FOO", "X-BAR"));
+
+ // Test setters
+ throws(() => {
+ event.setPropertyParameter("X-UNKNOWN", "UNKNOWN", "VALUE");
+ }, /Property X-UNKNOWN not set/);
+
+ // More setters
+ event.setPropertyParameter("X-FOO", "X-BAR", "FNORD");
+ equal(event.getPropertyParameter("X-FOO", "X-BAR"), "FNORD");
+ notEqual(event.icalString.match(/^X-FOO;X-BAR=FNORD:QUUX$/m), null);
+
+ // Test we can get the parameter names
+ throws(() => {
+ event.getParameterNames("X-UNKNOWN");
+ }, /Property X-UNKNOWN not set/);
+ equal(event.getParameterNames("X-FOO").length, 1);
+ ok(event.getParameterNames("X-FOO").includes("X-BAR"));
+
+ // Deletion of parameters when deleting properties
+ event.deleteProperty("X-FOO");
+ ok(!event.hasProperty("X-FOO"));
+ event.setProperty("X-FOO", "SNORK");
+ equal(event.getProperty("X-FOO"), "SNORK");
+ equal(event.getParameterNames("X-FOO").length, 0);
+ equal(event.getPropertyParameter("X-FOO", "X-BAR"), null);
+}
diff --git a/comm/calendar/test/unit/test_bug356207.js b/comm/calendar/test/unit/test_bug356207.js
new file mode 100644
index 0000000000..1ad23c6acd
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug356207.js
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ // Check that Bug 356207 doesn't regress:
+ // Freeze (hang) on RRULE which has BYMONTHDAY and BYDAY
+
+ let icalString =
+ "BEGIN:VCALENDAR\n" +
+ "PRODID:-//Randy L Pearson//NONSGML Outlook2vCal V1.1//EN\n" +
+ "VERSION:2.0\n" +
+ "BEGIN:VEVENT\n" +
+ "CREATED:20040829T163323\n" +
+ "UID:00000000EBFAC68C9B92BF119D643623FBD17E1424312000\n" +
+ "SEQUENCE:1\n" +
+ "LAST-MODIFIED:20060615T231158\n" +
+ "DTSTAMP:20040829T163323\n" +
+ "ORGANIZER:Unknown\n" +
+ "DTSTART:20040901T141500\n" +
+ "DESCRIPTION:Contact Mary Tindall for more details.\n" +
+ "CLASS:PUBLIC\n" +
+ "LOCATION:Church\n" +
+ "CATEGORIES:Church Events\n" +
+ "SUMMARY:Friendship Circle\n" +
+ "PRIORITY:1\n" +
+ "DTEND:20040901T141500\n" +
+ "RRULE:FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=1;BYDAY=WE\n" +
+ "END:VEVENT\n" +
+ "END:VCALENDAR";
+
+ let event = createEventFromIcalString(icalString);
+ let start = createDate(2009, 0, 1);
+ let end = createDate(2009, 11, 31);
+
+ // the following call caused a never ending loop:
+ let occurrenceDates = event.recurrenceInfo.getOccurrenceDates(start, end, 0);
+ equal(occurrenceDates.length, 2);
+
+ // the following call caused a never ending loop:
+ let occurrences = event.recurrenceInfo.getOccurrences(start, end, 0);
+ equal(occurrences.length, 2);
+}
diff --git a/comm/calendar/test/unit/test_bug485571.js b/comm/calendar/test/unit/test_bug485571.js
new file mode 100644
index 0000000000..855109b7e8
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug485571.js
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+});
+
+function run_test() {
+ // Check that the RELATED property is correctly set
+ // after parsing the given VALARM component
+
+ // trigger set 15 minutes prior to the start of the event
+ check_relative(
+ "BEGIN:VALARM\n" +
+ "ACTION:DISPLAY\n" +
+ "TRIGGER:-PT15M\n" +
+ "DESCRIPTION:TEST\n" +
+ "END:VALARM",
+ Ci.calIAlarm.ALARM_RELATED_START
+ );
+
+ // trigger set 15 minutes prior to the start of the event
+ check_relative(
+ "BEGIN:VALARM\n" +
+ "ACTION:DISPLAY\n" +
+ "TRIGGER;VALUE=DURATION:-PT15M\n" +
+ "DESCRIPTION:TEST\n" +
+ "END:VALARM",
+ Ci.calIAlarm.ALARM_RELATED_START
+ );
+
+ // trigger set 15 minutes prior to the start of the event
+ check_relative(
+ "BEGIN:VALARM\n" +
+ "ACTION:DISPLAY\n" +
+ "TRIGGER;RELATED=START:-PT15M\n" +
+ "DESCRIPTION:TEST\n" +
+ "END:VALARM",
+ Ci.calIAlarm.ALARM_RELATED_START
+ );
+
+ // trigger set 15 minutes prior to the start of the event
+ check_relative(
+ "BEGIN:VALARM\n" +
+ "ACTION:DISPLAY\n" +
+ "TRIGGER;VALUE=DURATION;RELATED=START:-PT15M\n" +
+ "DESCRIPTION:TEST\n" +
+ "END:VALARM",
+ Ci.calIAlarm.ALARM_RELATED_START
+ );
+
+ // trigger set 5 minutes after the end of an event
+ check_relative(
+ "BEGIN:VALARM\n" +
+ "ACTION:DISPLAY\n" +
+ "TRIGGER;RELATED=END:PT5M\n" +
+ "DESCRIPTION:TEST\n" +
+ "END:VALARM",
+ Ci.calIAlarm.ALARM_RELATED_END
+ );
+
+ // trigger set 5 minutes after the end of an event
+ check_relative(
+ "BEGIN:VALARM\n" +
+ "ACTION:DISPLAY\n" +
+ "TRIGGER;VALUE=DURATION;RELATED=END:PT5M\n" +
+ "DESCRIPTION:TEST\n" +
+ "END:VALARM",
+ Ci.calIAlarm.ALARM_RELATED_END
+ );
+
+ // trigger set to an absolute date/time
+ check_absolute(
+ "BEGIN:VALARM\n" +
+ "ACTION:DISPLAY\n" +
+ "TRIGGER;VALUE=DATE-TIME:20090430T080000Z\n" +
+ "DESCRIPTION:TEST\n" +
+ "END:VALARM"
+ );
+}
+
+function check_relative(aIcalString, aRelated) {
+ let alarm = new CalAlarm();
+ alarm.icalString = aIcalString;
+ equal(alarm.related, aRelated);
+ equal(alarm.alarmDate, null);
+ notEqual(alarm.offset, null);
+}
+
+function check_absolute(aIcalString) {
+ let alarm = new CalAlarm();
+ alarm.icalString = aIcalString;
+ equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_ABSOLUTE);
+ ok(alarm.alarmDate != null);
+ equal(alarm.offset, null);
+}
diff --git a/comm/calendar/test/unit/test_bug486186.js b/comm/calendar/test/unit/test_bug486186.js
new file mode 100644
index 0000000000..cf25594790
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug486186.js
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+});
+
+function run_test() {
+ // ensure that RELATED property is correctly set on the VALARM component
+ let alarm = new CalAlarm();
+ alarm.action = "DISPLAY";
+ alarm.description = "test";
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_END;
+ alarm.offset = cal.createDuration("-PT15M");
+ if (alarm.icalString.search(/RELATED=END/) == -1) {
+ do_throw("Bug 486186: RELATED property missing in VALARM component");
+ }
+}
diff --git a/comm/calendar/test/unit/test_bug494140.js b/comm/calendar/test/unit/test_bug494140.js
new file mode 100644
index 0000000000..9a5baa6956
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug494140.js
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * In bug 494140 we found out that creating an exception to a series duplicates
+ * alarms. This unit test makes sure the alarms don't duplicate themselves. The
+ * same goes for relations and attachments.
+ */
+add_task(async () => {
+ let storageCal = getStorageCal();
+
+ let item = createEventFromIcalString(
+ "BEGIN:VEVENT\r\n" +
+ "CREATED:20090603T171401Z\r\n" +
+ "LAST-MODIFIED:20090617T080410Z\r\n" +
+ "DTSTAMP:20090617T080410Z\r\n" +
+ "UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5\r\n" +
+ "SUMMARY:Test\r\n" +
+ "DTSTART:20090603T073000Z\r\n" +
+ "DTEND:20090603T091500Z\r\n" +
+ "RRULE:FREQ=DAILY;COUNT=5\r\n" +
+ "RELATED-TO:RELTYPE=SIBLING:<foo@example.org>\r\n" +
+ "ATTACH:http://www.example.org/\r\n" +
+ "BEGIN:VALARM\r\n" +
+ "ACTION:DISPLAY\r\n" +
+ "TRIGGER;VALUE=DURATION:-PT10M\r\n" +
+ "DESCRIPTION:Mozilla Alarm: Test\r\n" +
+ "END:VALARM\r\n" +
+ "END:VEVENT"
+ );
+
+ // There should be one alarm, one relation and one attachment
+ equal(item.getAlarms().length, 1);
+ equal(item.getRelations().length, 1);
+ equal(item.getAttachments().length, 1);
+
+ // Change the occurrence to another day
+ let occ = item.recurrenceInfo.getOccurrenceFor(cal.createDateTime("20090604T073000Z"));
+ occ.QueryInterface(Ci.calIEvent);
+ occ.startDate = cal.createDateTime("20090618T073000Z");
+ item.recurrenceInfo.modifyException(occ, true);
+
+ // There should still be one alarm, one relation and one attachment
+ equal(item.getAlarms().length, 1);
+ equal(item.getRelations().length, 1);
+ equal(item.getAttachments().length, 1);
+
+ // Add the item to the storage calendar and retrieve it again
+ await storageCal.adoptItem(item);
+
+ let retrievedItem = await storageCal.getItem("c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5");
+ // There should still be one alarm, one relation and one attachment
+ equal(retrievedItem.getAlarms().length, 1);
+ equal(retrievedItem.getRelations().length, 1);
+ equal(retrievedItem.getAttachments().length, 1);
+});
diff --git a/comm/calendar/test/unit/test_bug523860.js b/comm/calendar/test/unit/test_bug523860.js
new file mode 100644
index 0000000000..c5455eda3d
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug523860.js
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+function run_test() {
+ // In bug 523860, we found out that in the spec doublequotes should not be
+ // escaped.
+ let prop = cal.icsService.createIcalProperty("DESCRIPTION");
+ let expected = "A String with \"quotes\" and 'other quotes'";
+
+ prop.value = expected;
+ equal(prop.icalString, "DESCRIPTION:" + expected + "\r\n");
+}
diff --git a/comm/calendar/test/unit/test_bug653924.js b/comm/calendar/test/unit/test_bug653924.js
new file mode 100644
index 0000000000..1573ae54aa
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug653924.js
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalRelation: "resource:///modules/CalRelation.jsm",
+});
+
+function run_test() {
+ const evt = new CalEvent();
+ const rel = new CalRelation("RELATED-TO:2424d594-0453-49a1-b842-6faee483ca79");
+ evt.addRelation(rel);
+
+ equal(1, evt.icalString.match(/RELATED-TO/g).length);
+ evt.icalString = evt.icalString; // eslint-disable-line no-self-assign
+ equal(1, evt.icalString.match(/RELATED-TO/g).length);
+}
diff --git a/comm/calendar/test/unit/test_bug668222.js b/comm/calendar/test/unit/test_bug668222.js
new file mode 100644
index 0000000000..eb6e9f5d0b
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug668222.js
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+});
+
+function run_test() {
+ let attendee = new CalAttendee();
+ attendee.id = "mailto:somebody";
+
+ // Set the property and make sure its there
+ attendee.setProperty("SCHEDULE-AGENT", "CLIENT");
+ equal(attendee.getProperty("SCHEDULE-AGENT"), "CLIENT");
+
+ // Reserialize the property, this has caused the property to go away
+ // in the past.
+ attendee.icalProperty = attendee.icalProperty; // eslint-disable-line no-self-assign
+ equal(attendee.getProperty("SCHEDULE-AGENT"), "CLIENT");
+
+ // Also make sure there are no promoted properties set. This does not
+ // technically belong to this bug, but I almost caused this error while
+ // writing the patch.
+ ok(!attendee.icalProperty.icalString.includes("RSVP"));
+}
diff --git a/comm/calendar/test/unit/test_bug759324.js b/comm/calendar/test/unit/test_bug759324.js
new file mode 100644
index 0000000000..e2dabcd9f9
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug759324.js
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let storage = getStorageCal();
+
+/**
+ * Checks if the capabilities.propagate-sequence feature of the storage calendar
+ * still works
+ */
+add_task(async function testBug759324() {
+ storage.setProperty("capabilities.propagate-sequence", "true");
+
+ let str = [
+ "BEGIN:VEVENT",
+ "UID:recItem",
+ "SEQUENCE:3",
+ "RRULE:FREQ=WEEKLY",
+ "DTSTART:20120101T010101Z",
+ "END:VEVENT",
+ ].join("\r\n");
+
+ let item = createEventFromIcalString(str);
+ let rid = cal.createDateTime("20120101T010101Z");
+ let rec = item.recurrenceInfo.getOccurrenceFor(rid);
+ rec.title = "changed";
+ item.recurrenceInfo.modifyException(rec, true);
+
+ do_test_pending();
+
+ let addedItem = await storage.addItem(item);
+ addedItem.QueryInterface(Ci.calIEvent);
+ let seq = addedItem.getProperty("SEQUENCE");
+ let occ = addedItem.recurrenceInfo.getOccurrenceFor(rid);
+
+ equal(seq, 3);
+ equal(occ.getProperty("SEQUENCE"), seq);
+
+ let changedItem = addedItem.clone();
+ changedItem.setProperty("SEQUENCE", parseInt(seq, 10) + 1);
+
+ checkModifiedItem(rid, await storage.modifyItem(changedItem, addedItem));
+});
+
+async function checkModifiedItem(rid, changedItem) {
+ changedItem.QueryInterface(Ci.calIEvent);
+ let seq = changedItem.getProperty("SEQUENCE");
+ let occ = changedItem.recurrenceInfo.getOccurrenceFor(rid);
+
+ equal(seq, 4);
+ equal(occ.getProperty("SEQUENCE"), seq);
+
+ // Now check with the pref off
+ storage.deleteProperty("capabilities.propagate-sequence");
+
+ let changedItem2 = changedItem.clone();
+ changedItem2.setProperty("SEQUENCE", parseInt(seq, 10) + 1);
+
+ checkNormalItem(rid, await storage.modifyItem(changedItem2, changedItem));
+}
+
+function checkNormalItem(rid, changedItem) {
+ changedItem.QueryInterface(Ci.calIEvent);
+ let seq = changedItem.getProperty("SEQUENCE");
+ let occ = changedItem.recurrenceInfo.getOccurrenceFor(rid);
+
+ equal(seq, 5);
+ equal(occ.getProperty("SEQUENCE"), 4);
+ completeTest();
+}
+
+function completeTest() {
+ do_test_finished();
+}
diff --git a/comm/calendar/test/unit/test_calIteratorUtils.js b/comm/calendar/test/unit/test_calIteratorUtils.js
new file mode 100644
index 0000000000..db5158d6c9
--- /dev/null
+++ b/comm/calendar/test/unit/test_calIteratorUtils.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for cal.iterate.*
+ */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+
+/**
+ * Test streamValues() iterates over all values found in a stream.
+ */
+add_task(async function testStreamValues() {
+ let src = Array(10)
+ .fill(null)
+ .map((_, i) => i + 1);
+ let stream = CalReadableStreamFactory.createReadableStream({
+ start(controller) {
+ for (let i = 0; i < src.length; i++) {
+ controller.enqueue(src[i]);
+ }
+ controller.close();
+ },
+ });
+
+ let dest = [];
+ for await (let value of cal.iterate.streamValues(stream)) {
+ dest.push(value);
+ }
+ Assert.ok(
+ src.every((val, idx) => (dest[idx] = val)),
+ "all values were read from the stream"
+ );
+});
diff --git a/comm/calendar/test/unit/test_calStorageHelpers.js b/comm/calendar/test/unit/test_calStorageHelpers.js
new file mode 100644
index 0000000000..b7b3d54f04
--- /dev/null
+++ b/comm/calendar/test/unit/test_calStorageHelpers.js
@@ -0,0 +1,23 @@
+const { newDateTime } = ChromeUtils.import("resource:///modules/calendar/calStorageHelpers.jsm");
+
+add_task(async function testNewDateTimeWithIcalTimezoneDef() {
+ // Define a timezone that is unlikely to match anything in common use
+ const icalTimezoneDef = `BEGIN:VTIMEZONE
+TZID:Totally_Made_Up_Standard_Time
+BEGIN:STANDARD
+DTSTART:19671029T020000
+TZOFFSETFROM:-0427
+TZOFFSETTO:-0527
+END:STANDARD
+END:VTIMEZONE`;
+
+ // 6 October, 2022 at 17:23:08 UTC
+ const dateTime = newDateTime(1665076988000000, icalTimezoneDef);
+
+ Assert.equal(dateTime.year, 2022, "year should be 2022");
+ Assert.equal(dateTime.month, 9, "zero-based month should be October");
+ Assert.equal(dateTime.day, 6, "day should be the 6th");
+ Assert.equal(dateTime.hour, 11, "hour should be 11 AM");
+ Assert.equal(dateTime.minute, 56, "minute should be 56");
+ Assert.equal(dateTime.second, 8, "second should be 8");
+});
diff --git a/comm/calendar/test/unit/test_caldav_requests.js b/comm/calendar/test/unit/test_caldav_requests.js
new file mode 100644
index 0000000000..2f46c09a8d
--- /dev/null
+++ b/comm/calendar/test/unit/test_caldav_requests.js
@@ -0,0 +1,970 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+
+var { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+var { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+var {
+ CalDavGenericRequest,
+ CalDavItemRequest,
+ CalDavDeleteItemRequest,
+ CalDavPropfindRequest,
+ CalDavHeaderRequest,
+ CalDavPrincipalPropertySearchRequest,
+ CalDavOutboxRequest,
+ CalDavFreeBusyRequest,
+} = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm");
+var { CalDavWebDavSyncHandler } = ChromeUtils.import(
+ "resource:///modules/caldav/CalDavRequestHandlers.jsm"
+);
+
+var { CalDavSession } = ChromeUtils.import("resource:///modules/caldav/CalDavSession.jsm");
+var { CalDavXmlns } = ChromeUtils.import("resource:///modules/caldav/CalDavUtils.jsm");
+var { Preferences } = ChromeUtils.importESModule("resource://gre/modules/Preferences.sys.mjs");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+class LowerMap extends Map {
+ get(key) {
+ return super.get(key.toLowerCase());
+ }
+ set(key, value) {
+ return super.set(key.toLowerCase(), value);
+ }
+}
+
+var gServer;
+
+var MockConflictPrompt = {
+ _origFunc: null,
+ overwrite: false,
+ register() {
+ if (!this._origFunc) {
+ this._origFunc = cal.provider.promptOverwrite;
+ cal.provider.promptOverwrite = (aMode, aItem) => {
+ return this.overwrite;
+ };
+ }
+ },
+
+ unregister() {
+ if (this._origFunc) {
+ cal.provider.promptOverwrite = this._origFunc;
+ this._origFunc = null;
+ }
+ },
+};
+
+class MockAlertsService {
+ QueryInterface = ChromeUtils.generateQI(["nsIAlertsService"]);
+ showAlert() {}
+}
+
+function replaceAlertsService() {
+ let originalAlertsServiceCID = MockRegistrar.register(
+ "@mozilla.org/alerts-service;1",
+ MockAlertsService
+ );
+ registerCleanupFunction(() => {
+ MockRegistrar.unregister(originalAlertsServiceCID);
+ });
+}
+
+var gMockCalendar = {
+ name: "xpcshell",
+ makeUri(insert, base) {
+ return base;
+ },
+ verboseLogging() {
+ return true;
+ },
+ ensureEncodedPath(x) {
+ return x;
+ },
+ ensureDecodedPath(x) {
+ return x;
+ },
+ startBatch() {},
+ endBatch() {},
+ addTargetCalendarItem() {},
+ finalizeUpdatedItems() {},
+ mHrefIndex: [],
+};
+gMockCalendar.superCalendar = gMockCalendar;
+
+class CalDavServer {
+ constructor(calendarId) {
+ this.server = new HttpServer();
+ this.calendarId = calendarId;
+ this.session = new CalDavSession(this.calendarId, "xpcshell");
+ this.serverRequests = {};
+
+ this.server.registerPrefixHandler(
+ "/principals/",
+ this.router.bind(this, this.principals.bind(this))
+ );
+ this.server.registerPrefixHandler(
+ "/calendars/",
+ this.router.bind(this, this.calendars.bind(this))
+ );
+ this.server.registerPrefixHandler(
+ "/requests/",
+ this.router.bind(this, this.requests.bind(this))
+ );
+ }
+
+ start() {
+ this.server.start(-1);
+ registerCleanupFunction(() => this.server.stop(() => {}));
+ }
+
+ reset() {
+ this.serverRequests = {};
+ }
+
+ uri(path) {
+ let base = Services.io.newURI(`http://localhost:${this.server.identity.primaryPort}/`);
+ return Services.io.newURI(path, null, base);
+ }
+
+ router(nextHandler, request, response) {
+ try {
+ let method = request.method;
+ let parameters = new Map(request.queryString.split("&").map(part => part.split("=", 2)));
+ let available = request.bodyInputStream.available();
+ let body =
+ available > 0 ? NetUtil.readInputStreamToString(request.bodyInputStream, available) : null;
+
+ let headers = new LowerMap();
+
+ let headerIterator = function* (enumerator) {
+ while (enumerator.hasMoreElements()) {
+ yield enumerator.getNext().QueryInterface(Ci.nsISupportsString);
+ }
+ };
+
+ for (let hdr of headerIterator(request.headers)) {
+ headers.set(hdr.data, request.getHeader(hdr.data));
+ }
+
+ return nextHandler(request, response, method, headers, parameters, body);
+ } catch (e) {
+ info("Server Error: " + e.fileName + ":" + e.lineNumber + ": " + e + "\n");
+ return null;
+ }
+ }
+
+ resetClient(client) {
+ MockConflictPrompt.unregister();
+ cal.manager.unregisterCalendar(client);
+ }
+
+ waitForLoad(aCalendar) {
+ return new Promise((resolve, reject) => {
+ let observer = cal.createAdapter(Ci.calIObserver, {
+ onLoad() {
+ let uncached = aCalendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject;
+ aCalendar.removeObserver(observer);
+
+ if (Components.isSuccessCode(uncached._lastStatus)) {
+ resolve(aCalendar);
+ } else {
+ reject(uncached._lastMessage);
+ }
+ },
+ });
+ aCalendar.addObserver(observer);
+ });
+ }
+
+ getClient() {
+ let uri = this.uri("/calendars/xpcshell/events");
+ let client = cal.manager.createCalendar("caldav", uri);
+ let uclient = client.wrappedJSObject;
+ client.name = "xpcshell";
+ client.setProperty("cache.enabled", true);
+
+ // Make sure we catch the last error message in case sync fails
+ monkeyPatch(uclient, "replayChangesOn", (protofunc, aListener) => {
+ protofunc({
+ onResult(operation, detail) {
+ uclient._lastStatus = operation.status;
+ uclient._lastMessage = detail;
+ aListener.onResult(operation, detail);
+ },
+ });
+ });
+
+ cal.manager.registerCalendar(client);
+
+ let cachedCalendar = cal.manager.getCalendarById(client.id);
+ return this.waitForLoad(cachedCalendar);
+ }
+
+ principals(request, response, method, headers, parameters, body) {
+ this.serverRequests.principals = { method, headers, parameters, body };
+
+ if (method == "REPORT" && request.path == "/principals/") {
+ response.setHeader("Content-Type", "application/xml");
+ response.write(dedent`
+ <?xml version="1.0" encoding="utf-8" ?>
+ <D:multistatus xmlns:D="DAV:" xmlns:B="http://BigCorp.com/ns/">
+ <D:response>
+ <D:href>http://www.example.com/users/jdoe</D:href>
+ <D:propstat>
+ <D:prop>
+ <D:displayname>John Doe</D:displayname>
+ <B:department>Widget Sales</B:department>
+ <B:phone>234-4567</B:phone>
+ <B:office>209</B:office>
+ </D:prop>
+ <D:status>HTTP/1.1 200 OK</D:status>
+ </D:propstat>
+ <D:propstat>
+ <D:prop>
+ <B:salary/>
+ </D:prop>
+ <D:status>HTTP/1.1 403 Forbidden</D:status>
+ </D:propstat>
+ </D:response>
+ <D:response>
+ <D:href>http://www.example.com/users/zsmith</D:href>
+ <D:propstat>
+ <D:prop>
+ <D:displayname>Zygdoebert Smith</D:displayname>
+ <B:department>Gadget Sales</B:department>
+ <B:phone>234-7654</B:phone>
+ <B:office>114</B:office>
+ </D:prop>
+ <D:status>HTTP/1.1 200 OK</D:status>
+ </D:propstat>
+ <D:propstat>
+ <D:prop>
+ <B:salary/>
+ </D:prop>
+ <D:status>HTTP/1.1 403 Forbidden</D:status>
+ </D:propstat>
+ </D:response>
+ </D:multistatus>
+ `);
+ response.setStatusLine(null, 207, "Multistatus");
+ } else if (method == "PROPFIND" && request.path == "/principals/xpcshell/user/") {
+ response.setHeader("Content-Type", "application/xml");
+ response.write(dedent`
+ <?xml version="1.0" encoding="utf-8"?>
+ <D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
+ <D:response>
+ <D:href>${this.uri("/principals/xpcshell/user").spec}</D:href>
+ <D:propstat>
+ <D:prop>
+ <C:calendar-home-set>
+ <D:href>${this.uri("/calendars/xpcshell/user/").spec}</D:href>
+ </C:calendar-home-set>
+ <C:calendar-user-address-set>
+ <D:href>mailto:xpcshell@example.com</D:href>
+ </C:calendar-user-address-set>
+ <C:schedule-inbox-URL>
+ <D:href>${this.uri("/calendars/xpcshell/inbox").spec}/</D:href>
+ </C:schedule-inbox-URL>
+ <C:schedule-outbox-URL>
+ <D:href>${this.uri("/calendars/xpcshell/outbox").spec}</D:href>
+ </C:schedule-outbox-URL>
+ </D:prop>
+ <D:status>HTTP/1.1 200 OK</D:status>
+ </D:propstat>
+ </D:response>
+ </D:multistatus>
+ `);
+ response.setStatusLine(null, 207, "Multistatus");
+ }
+ }
+
+ calendars(request, response, method, headers, parameters, body) {
+ this.serverRequests.calendars = { method, headers, parameters, body };
+
+ if (
+ method == "PROPFIND" &&
+ request.path.startsWith("/calendars/xpcshell/events") &&
+ headers.get("depth") == 0
+ ) {
+ response.setHeader("Content-Type", "application/xml");
+ response.write(dedent`
+ <?xml version="1.0" encoding="utf-8" ?>
+ <D:multistatus ${CalDavXmlns("D", "C", "CS")} xmlns:R="http://www.foo.bar/boxschema/">
+ <D:response>
+ <D:href>${request.path}</D:href>
+ <D:propstat>
+ <D:prop>
+ <D:resourcetype>
+ <D:collection/>
+ <C:calendar/>
+ </D:resourcetype>
+ <R:plain-text-prop>hello, world</R:plain-text-prop>
+ <D:principal-collection-set>
+ <D:href>${this.uri("/principals/").spec}</D:href>
+ <D:href>${this.uri("/principals/subthing/").spec}</D:href>
+ </D:principal-collection-set>
+ <D:current-user-principal>
+ <D:href>${this.uri("/principals/xpcshell/user").spec}</D:href>
+ </D:current-user-principal>
+ <D:supported-report-set>
+ <D:supported-report>
+ <D:report>
+ <D:principal-property-search/>
+ </D:report>
+ </D:supported-report>
+ <D:supported-report>
+ <D:report>
+ <C:calendar-multiget/>
+ </D:report>
+ </D:supported-report>
+ <D:supported-report>
+ <D:report>
+ <D:sync-collection/>
+ </D:report>
+ </D:supported-report>
+ </D:supported-report-set>
+ <C:supported-calendar-component-set>
+ <C:comp name="VEVENT"/>
+ <C:comp name="VTODO"/>
+ </C:supported-calendar-component-set>
+ <C:schedule-inbox-URL>
+ <D:href>${this.uri("/calendars/xpcshell/inbox").spec}</D:href>
+ </C:schedule-inbox-URL>
+ <C:schedule-outbox-URL>
+ ${this.uri("/calendars/xpcshell/outbox").spec}
+ </C:schedule-outbox-URL>
+ <CS:getctag>1413647159-1007960</CS:getctag>
+ </D:prop>
+ <D:status>HTTP/1.1 200 OK</D:status>
+ </D:propstat>
+ <D:propstat>
+ <D:prop>
+ <R:obscure-thing-not-found/>
+ </D:prop>
+ <D:status>HTTP/1.1 404 Not Found</D:status>
+ </D:propstat>
+ </D:response>
+ </D:multistatus>
+ `);
+ response.setStatusLine(null, 207, "Multistatus");
+ } else if (method == "POST" && request.path == "/calendars/xpcshell/outbox") {
+ response.setHeader("Content-Type", "application/xml");
+ response.write(dedent`
+ <?xml version="1.0" encoding="utf-8" ?>
+ <C:schedule-response ${CalDavXmlns("D", "C")}>
+ <D:response>
+ <D:href>mailto:recipient1@example.com</D:href>
+ <D:request-status>2.0;Success</D:request-status>
+ </D:response>
+ <D:response>
+ <D:href>mailto:recipient2@example.com</D:href>
+ <D:request-status>2.0;Success</D:request-status>
+ </D:response>
+ </C:schedule-response>
+ `);
+ response.setStatusLine(null, 200, "OK");
+ } else if (method == "POST" && request.path == "/calendars/xpcshell/outbox2") {
+ response.setHeader("Content-Type", "application/xml");
+ response.write(dedent`
+ <?xml version="1.0" encoding="utf-8" ?>
+ <C:schedule-response ${CalDavXmlns("D", "C")}>
+ <D:response>
+ <D:recipient>
+ <D:href>mailto:recipient1@example.com</D:href>
+ </D:recipient>
+ <D:request-status>2.0;Success</D:request-status>
+ <C:calendar-data>
+ BEGIN:VCALENDAR
+ PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
+ VERSION:2.0
+ METHOD:REQUEST
+ BEGIN:VFREEBUSY
+ DTSTART;VALUE=DATE:20180102
+ DTEND;VALUE=DATE:20180126
+ ORGANIZER:mailto:xpcshell@example.com
+ ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL:mail
+ to:recipient@example.com
+ FREEBUSY;FBTYPE=FREE:20180103T010101Z/20180117T010101Z
+ FREEBUSY;FBTYPE=BUSY:20180118T010101Z/P7D
+ END:VFREEBUSY
+ END:VCALENDAR
+ </C:calendar-data>
+ </D:response>
+ </C:schedule-response>
+ `);
+ response.setStatusLine(null, 200, "OK");
+ } else if (method == "OPTIONS" && request.path == "/calendars/xpcshell/") {
+ response.setHeader(
+ "DAV",
+ "1, 2, 3, access-control, extended-mkcol, resource-sharing, calendar-access, calendar-auto-schedule, calendar-query-extended, calendar-availability, calendarserver-sharing, inbox-availability"
+ );
+ response.setStatusLine(null, 200, "OK");
+ } else if (method == "REPORT" && request.path == "/calendars/xpcshell/events/") {
+ response.setHeader("Content-Type", "application/xml");
+ let bodydom = cal.xml.parseString(body);
+ let report = bodydom.documentElement.localName;
+ let eventName = String.fromCharCode(...new TextEncoder().encode("γ‚€γƒ™γƒ³γƒˆ"));
+ if (report == "sync-collection") {
+ response.write(dedent`
+ <?xml version="1.0" encoding="utf-8" ?>
+ <D:multistatus ${CalDavXmlns("D")}>
+ <D:response>
+ <D:href>${this.uri("/calendars/xpcshell/events/test.ics").spec}</D:href>
+ <D:propstat>
+ <D:prop>
+ <D:getcontenttype>text/calendar; charset=utf-8; component=VEVENT</D:getcontenttype>
+ <D:getetag>"2decee6ffb701583398996bfbdacb8eec53edf94"</D:getetag>
+ <D:displayname>${eventName}</D:displayname>
+ </D:prop>
+ <D:status>HTTP/1.1 200 OK</D:status>
+ </D:propstat>
+ </D:response>
+ </D:multistatus>
+ `);
+ } else if (report == "calendar-multiget") {
+ let event = new CalEvent();
+ event.title = "会議";
+ event.startDate = cal.dtz.now();
+ event.endDate = cal.dtz.now();
+ let icalString = String.fromCharCode(...new TextEncoder().encode(event.icalString));
+ response.write(dedent`
+ <?xml version="1.0" encoding="utf-8"?>
+ <D:multistatus ${CalDavXmlns("D", "C")}>
+ <D:response>
+ <D:href>${this.uri("/calendars/xpcshell/events/test.ics").spec}</D:href>
+ <D:propstat>
+ <D:prop>
+ <D:getetag>"2decee6ffb701583398996bfbdacb8eec53edf94"</D:getetag>
+ <C:calendar-data>${icalString}</C:calendar-data>
+ </D:prop>
+ </D:propstat>
+ </D:response>
+ </D:multistatus>
+ `);
+ }
+ response.setStatusLine(null, 207, "Multistatus");
+ } else {
+ console.log("XXX: " + method, request.path, [...headers.entries()]);
+ }
+ }
+
+ requests(request, response, method, headers, parameters, body) {
+ // ["", "requests", "generic"] := /requests/generic
+ let parts = request.path.split("/");
+ let id = parts[2];
+ let status = parseInt(parts[3] || "", 10) || 200;
+
+ if (id == "redirected") {
+ response.setHeader("Location", "/requests/redirected-target", false);
+ status = 302;
+ } else if (id == "dav") {
+ response.setHeader("DAV", "1, calendar-schedule, calendar-auto-schedule");
+ }
+
+ this.serverRequests[id] = { method, headers, parameters, body };
+
+ for (let [hdr, value] of headers.entries()) {
+ response.setHeader(hdr, "response-" + value, false);
+ }
+
+ response.setHeader("Content-Type", "application/xml");
+ response.write(`<response id="${id}">xpc</response>`);
+ response.setStatusLine(null, status, null);
+ }
+}
+
+function run_test() {
+ Preferences.set("calendar.debug.log", true);
+ Preferences.set("calendar.debug.log.verbose", true);
+ cal.console.maxLogLevel = "debug";
+ replaceAlertsService();
+
+ // TODO: make do_calendar_startup to work with this test and replace the startup code here
+ do_get_profile();
+ do_test_pending();
+
+ cal.manager.startup({
+ onResult() {
+ gServer = new CalDavServer("xpcshell@example.com");
+ gServer.start();
+ cal.timezoneService.startup({
+ onResult() {
+ run_next_test();
+ do_test_finished();
+ },
+ });
+ },
+ });
+}
+
+add_task(async function test_caldav_session() {
+ gServer.reset();
+
+ let prepared = 0;
+ let redirected = 0;
+ let completed = 0;
+ let restart = false;
+
+ gServer.session.authAdapters.localhost = {
+ async prepareRequest(aChannel) {
+ prepared++;
+ },
+
+ async prepareRedirect(aOldChannel, aNewChannel) {
+ redirected++;
+ },
+
+ async completeRequest(aResponse) {
+ completed++;
+ if (restart) {
+ restart = false;
+ return CalDavSession.RESTART_REQUEST;
+ }
+ return null;
+ },
+ };
+
+ // First a simple request
+ let uri = gServer.uri("/requests/session");
+ let request = new CalDavGenericRequest(gServer.session, gMockCalendar, "HEAD", uri);
+ await request.commit();
+
+ equal(prepared, 1);
+ equal(redirected, 0);
+ equal(completed, 1);
+
+ // Now a redirect
+ prepared = redirected = completed = 0;
+
+ uri = gServer.uri("/requests/redirected");
+ request = new CalDavGenericRequest(gServer.session, gMockCalendar, "HEAD", uri);
+ await request.commit();
+
+ equal(prepared, 1);
+ equal(redirected, 1);
+ equal(completed, 1);
+
+ // Now with restarting the request
+ prepared = redirected = completed = 0;
+ restart = true;
+
+ uri = gServer.uri("/requests/redirected");
+ request = new CalDavGenericRequest(gServer.session, gMockCalendar, "HEAD", uri);
+ await request.commit();
+
+ equal(prepared, 2);
+ equal(redirected, 2);
+ equal(completed, 2);
+});
+
+/**
+ * This test covers both GenericRequest and the base class CalDavRequestBase/CalDavResponseBase
+ */
+add_task(async function test_generic_request() {
+ gServer.reset();
+ let uri = gServer.uri("/requests/generic");
+ let headers = { "X-Hdr": "exists" };
+ let request = new CalDavGenericRequest(
+ gServer.session,
+ gMockCalendar,
+ "PUT",
+ uri,
+ headers,
+ "<body>xpc</body>",
+ "text/plain"
+ );
+
+ strictEqual(request.uri.spec, uri.spec);
+ strictEqual(request.session.id, gServer.session.id);
+ strictEqual(request.calendar, gMockCalendar);
+ strictEqual(request.uploadData, "<body>xpc</body>");
+ strictEqual(request.contentType, "text/plain");
+ strictEqual(request.response, null);
+ strictEqual(request.getHeader("X-Hdr"), null); // Only works after commit
+
+ let response = await request.commit();
+
+ ok(!!request.response);
+ equal(request.getHeader("X-Hdr"), "exists");
+
+ equal(response.uri.spec, uri.spec);
+ ok(!response.redirected);
+ equal(response.status, 200);
+ equal(response.statusCategory, 2);
+ ok(response.ok);
+ ok(!response.clientError);
+ ok(!response.conflict);
+ ok(!response.notFound);
+ ok(!response.serverError);
+ equal(response.text, '<response id="generic">xpc</response>');
+ equal(response.xml.documentElement.localName, "response");
+ equal(response.getHeader("X-Hdr"), "response-exists");
+
+ let serverResult = gServer.serverRequests.generic;
+
+ equal(serverResult.method, "PUT");
+ equal(serverResult.headers.get("x-hdr"), "exists");
+ equal(serverResult.headers.get("content-type"), "text/plain");
+ equal(serverResult.body, "<body>xpc</body>");
+});
+
+add_task(async function test_generic_redirected_request() {
+ gServer.reset();
+ let uri = gServer.uri("/requests/redirected");
+ let headers = {
+ Depth: 1,
+ Originator: "o",
+ Recipient: "r",
+ "If-None-Match": "*",
+ "If-Match": "123",
+ };
+ let request = new CalDavGenericRequest(
+ gServer.session,
+ gMockCalendar,
+ "PUT",
+ uri,
+ headers,
+ "<body>xpc</body>",
+ "text/plain"
+ );
+
+ let response = await request.commit();
+
+ ok(response.redirected);
+ equal(response.status, 200);
+ equal(response.text, '<response id="redirected-target">xpc</response>');
+ equal(response.xml.documentElement.getAttribute("id"), "redirected-target");
+
+ ok(gServer.serverRequests.redirected);
+ ok(gServer.serverRequests["redirected-target"]);
+
+ let results = gServer.serverRequests.redirected;
+ equal(results.headers.get("Depth"), 1);
+ equal(results.headers.get("Originator"), "o");
+ equal(results.headers.get("Recipient"), "r");
+ equal(results.headers.get("If-None-Match"), "*");
+ equal(results.headers.get("If-Match"), "123");
+
+ results = gServer.serverRequests["redirected-target"];
+ equal(results.headers.get("Depth"), 1);
+ equal(results.headers.get("Originator"), "o");
+ equal(results.headers.get("Recipient"), "r");
+ equal(results.headers.get("If-None-Match"), "*");
+ equal(results.headers.get("If-Match"), "123");
+
+ equal(response.lastRedirectStatus, 302);
+});
+
+add_task(async function test_item_request() {
+ gServer.reset();
+ let uri = gServer.uri("/requests/item/201");
+ let icalString = "BEGIN:VEVENT\r\nUID:123\r\nEND:VEVENT";
+ let componentString = `BEGIN:VCALENDAR\r\nPRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN\r\nVERSION:2.0\r\n${icalString}\r\nEND:VCALENDAR\r\n`;
+ let request = new CalDavItemRequest(
+ gServer.session,
+ gMockCalendar,
+ uri,
+ new CalEvent(icalString),
+ "*"
+ );
+ let response = await request.commit();
+
+ equal(response.status, 201);
+ ok(response.ok);
+
+ let serverResult = gServer.serverRequests.item;
+
+ equal(serverResult.method, "PUT");
+ equal(serverResult.body, componentString);
+ equal(serverResult.headers.get("If-None-Match"), "*");
+ ok(!serverResult.headers.has("If-Match"));
+
+ // Now the same with 204 No Content and an etag
+ gServer.reset();
+ uri = gServer.uri("/requests/item/204");
+ request = new CalDavItemRequest(
+ gServer.session,
+ gMockCalendar,
+ uri,
+ new CalEvent(icalString),
+ "123123"
+ );
+ response = await request.commit();
+
+ equal(response.status, 204);
+ ok(response.ok);
+
+ serverResult = gServer.serverRequests.item;
+
+ equal(serverResult.method, "PUT");
+ equal(serverResult.body, componentString);
+ equal(serverResult.headers.get("If-Match"), "123123");
+ ok(!serverResult.headers.has("If-None-Match"));
+
+ // Now the same with 200 OK and no etag
+ gServer.reset();
+ uri = gServer.uri("/requests/item/200");
+ request = new CalDavItemRequest(gServer.session, gMockCalendar, uri, new CalEvent(icalString));
+ response = await request.commit();
+
+ equal(response.status, 200);
+ ok(response.ok);
+
+ serverResult = gServer.serverRequests.item;
+
+ equal(serverResult.method, "PUT");
+ equal(serverResult.body, componentString);
+ ok(!serverResult.headers.has("If-Match"));
+ ok(!serverResult.headers.has("If-None-Match"));
+});
+
+add_task(async function test_delete_item_request() {
+ gServer.reset();
+ let uri = gServer.uri("/requests/deleteitem");
+ let request = new CalDavDeleteItemRequest(gServer.session, gMockCalendar, uri, "*");
+
+ strictEqual(request.uploadData, null);
+ strictEqual(request.contentType, null);
+
+ let response = await request.commit();
+
+ equal(response.status, 200);
+ ok(response.ok);
+
+ let serverResult = gServer.serverRequests.deleteitem;
+
+ equal(serverResult.method, "DELETE");
+ equal(serverResult.headers.get("If-Match"), "*");
+ ok(!serverResult.headers.has("If-None-Match"));
+
+ // Now the same with no etag, and a (valid) 404 response
+ gServer.reset();
+ uri = gServer.uri("/requests/deleteitem/404");
+ request = new CalDavDeleteItemRequest(gServer.session, gMockCalendar, uri);
+ response = await request.commit();
+
+ equal(response.status, 404);
+ ok(response.ok);
+
+ serverResult = gServer.serverRequests.deleteitem;
+
+ equal(serverResult.method, "DELETE");
+ ok(!serverResult.headers.has("If-Match"));
+ ok(!serverResult.headers.has("If-None-Match"));
+});
+
+add_task(async function test_propfind_request() {
+ gServer.reset();
+ let uri = gServer.uri("/calendars/xpcshell/events");
+ let props = [
+ "D:principal-collection-set",
+ "D:current-user-principal",
+ "D:supported-report-set",
+ "C:supported-calendar-component-set",
+ "C:schedule-inbox-URL",
+ "C:schedule-outbox-URL",
+ "R:obscure-thing-not-found",
+ ];
+ let request = new CalDavPropfindRequest(gServer.session, gMockCalendar, uri, props);
+ let response = await request.commit();
+
+ equal(response.status, 207);
+ ok(response.ok);
+
+ let results = gServer.serverRequests.calendars;
+
+ ok(
+ results.body.match(/<D:prop>\s*<D:principal-collection-set\/>\s*<D:current-user-principal\/>/)
+ );
+
+ equal(Object.keys(response.data).length, 1);
+ ok(!!response.data[uri.filePath]);
+ ok(!!response.firstProps);
+
+ let resprops = response.firstProps;
+
+ deepEqual(resprops["D:principal-collection-set"], [
+ gServer.uri("/principals/").spec,
+ gServer.uri("/principals/subthing/").spec,
+ ]);
+ equal(resprops["D:current-user-principal"], gServer.uri("/principals/xpcshell/user").spec);
+
+ deepEqual(
+ [...resprops["D:supported-report-set"].values()],
+ ["D:principal-property-search", "C:calendar-multiget", "D:sync-collection"]
+ );
+
+ deepEqual([...resprops["C:supported-calendar-component-set"].values()], ["VEVENT", "VTODO"]);
+ equal(resprops["C:schedule-inbox-URL"], gServer.uri("/calendars/xpcshell/inbox").spec);
+ equal(resprops["C:schedule-outbox-URL"], gServer.uri("/calendars/xpcshell/outbox").spec);
+ strictEqual(resprops["R:obscure-thing-not-found"], null);
+ equal(resprops["R:plain-text-prop"], "hello, world");
+});
+
+add_task(async function test_davheader_request() {
+ gServer.reset();
+ let uri = gServer.uri("/requests/dav");
+ let request = new CalDavHeaderRequest(gServer.session, gMockCalendar, uri);
+ let response = await request.commit();
+
+ let serverResult = gServer.serverRequests.dav;
+
+ equal(serverResult.method, "OPTIONS");
+ deepEqual([...response.features], ["calendar-schedule", "calendar-auto-schedule"]);
+ strictEqual(response.version, 1);
+});
+
+add_task(async function test_propsearch_request() {
+ gServer.reset();
+ let uri = gServer.uri("/principals/");
+ let props = ["D:displayname", "B:department", "B:phone", "B:office"];
+ let request = new CalDavPrincipalPropertySearchRequest(
+ gServer.session,
+ gMockCalendar,
+ uri,
+ "doE",
+ "D:displayname",
+ props
+ );
+ let response = await request.commit();
+
+ equal(response.status, 207);
+ ok(response.ok);
+
+ equal(response.data["http://www.example.com/users/jdoe"]["D:displayname"], "John Doe");
+
+ ok(gServer.serverRequests.principals.body.includes("<D:match>doE</D:match>"));
+ ok(gServer.serverRequests.principals.body.match(/<D:prop>\s*<D:displayname\/>\s*<\/D:prop>/));
+ ok(
+ gServer.serverRequests.principals.body.match(/<D:prop>\s*<D:displayname\/>\s*<B:department\/>/)
+ );
+});
+
+add_task(async function test_outbox_request() {
+ gServer.reset();
+ let icalString = "BEGIN:VEVENT\r\nUID:123\r\nEND:VEVENT";
+ let uri = gServer.uri("/calendars/xpcshell/outbox");
+ let request = new CalDavOutboxRequest(
+ gServer.session,
+ gMockCalendar,
+ uri,
+ "xpcshell@example.com",
+ ["recipient1@example.com", "recipient2@example.com"],
+ "REPLY",
+ new CalEvent(icalString)
+ );
+ let response = await request.commit();
+
+ equal(response.status, 200);
+ ok(response.ok);
+
+ let results = gServer.serverRequests.calendars;
+
+ ok(results.body.includes("METHOD:REPLY"));
+ equal(results.method, "POST");
+ equal(results.headers.get("Originator"), "xpcshell@example.com");
+ equal(results.headers.get("Recipient"), "recipient1@example.com, recipient2@example.com");
+});
+
+add_task(async function test_freebusy_request() {
+ gServer.reset();
+ let uri = gServer.uri("/calendars/xpcshell/outbox2");
+ let request = new CalDavFreeBusyRequest(
+ gServer.session,
+ gMockCalendar,
+ uri,
+ "mailto:xpcshell@example.com",
+ "mailto:recipient@example.com",
+ cal.createDateTime("20180101"),
+ cal.createDateTime("20180201")
+ );
+
+ let response = await request.commit();
+
+ equal(response.status, 200);
+ ok(response.ok);
+
+ let results = gServer.serverRequests.calendars;
+ equal(
+ ics_unfoldline(
+ results.body
+ .replace(/\r\n/g, "\n")
+ .replace(/(UID|DTSTAMP):[^\n]+\n/g, "")
+ .trim()
+ ),
+ dedent`
+ BEGIN:VCALENDAR
+ PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
+ VERSION:2.0
+ METHOD:REQUEST
+ BEGIN:VFREEBUSY
+ DTSTART;VALUE=DATE:20180101
+ DTEND;VALUE=DATE:20180201
+ ORGANIZER:mailto:xpcshell@example.com
+ ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL:mailto:recipient@example.com
+ END:VFREEBUSY
+ END:VCALENDAR
+ `
+ );
+ equal(results.method, "POST");
+ equal(results.headers.get("Content-Type"), "text/calendar; charset=utf-8");
+ equal(results.headers.get("Originator"), "mailto:xpcshell@example.com");
+ equal(results.headers.get("Recipient"), "mailto:recipient@example.com");
+
+ let first = response.firstRecipient;
+ equal(first.status, "2.0;Success");
+ deepEqual(
+ first.intervals.map(interval => interval.type),
+ ["UNKNOWN", "FREE", "BUSY", "UNKNOWN"]
+ );
+ deepEqual(
+ first.intervals.map(interval => interval.begin.icalString + ":" + interval.end.icalString),
+ [
+ "20180101:20180102",
+ "20180103T010101Z:20180117T010101Z",
+ "20180118T010101Z:20180125T010101Z",
+ "20180126:20180201",
+ ]
+ );
+});
+
+add_task(async function test_caldav_client() {
+ let client = await gServer.getClient();
+ let items = await client.getItemsAsArray(Ci.calICalendar.ITEM_FILTER_ALL_ITEMS, 0, null, null);
+
+ equal(items.length, 1);
+ equal(items[0].title, "会議");
+});
+
+/**
+ * Test non-ASCII text in the XML response is parsed correctly in CalDavWebDavSyncHandler.
+ */
+add_task(async function test_caldav_sync() {
+ gServer.reset();
+ let uri = gServer.uri("/calendars/xpcshell/events/");
+ gMockCalendar.session = gServer.session;
+ let webDavSync = new CalDavWebDavSyncHandler(gMockCalendar, uri);
+ await webDavSync.doWebDAVSync();
+ ok(webDavSync.logXML.includes("γ‚€γƒ™γƒ³γƒˆ"), "Non-ASCII text should be parsed correctly");
+});
+
+add_task(function test_can_get_google_adapter() {
+ // Initialize a session with bogus values
+ const session = new CalDavSession("xpcshell@example.com", "xpcshell");
+
+ // We don't have a facility for actually testing our Google CalDAV requests,
+ // but we can at least verify that the adapter looks okay at a glance
+ equal(
+ session.authAdapters["apidata.googleusercontent.com"].authorizationEndpoint,
+ "https://accounts.google.com/o/oauth2/auth"
+ );
+});
diff --git a/comm/calendar/test/unit/test_calmgr.js b/comm/calendar/test/unit/test_calmgr.js
new file mode 100644
index 0000000000..e4d09db0a3
--- /dev/null
+++ b/comm/calendar/test/unit/test_calmgr.js
@@ -0,0 +1,411 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+/**
+ * Tests the calICalendarManager interface
+ */
+function run_test() {
+ do_calendar_startup(run_next_test);
+}
+
+class CalendarManagerObserver {
+ QueryInterface = ChromeUtils.generateQI(["calICalendarManager"]);
+
+ constructor() {
+ this.reset();
+ }
+
+ reset() {
+ this.registered = [];
+ this.unregistering = [];
+ this.deleting = [];
+ }
+
+ check({ unregistering, registered, deleting }) {
+ equal(this.unregistering[0], unregistering);
+ equal(this.registered[0], registered);
+ equal(this.deleting[0], deleting);
+
+ this.reset();
+ }
+
+ onCalendarRegistered(calendar) {
+ this.registered.push(calendar.id);
+ }
+
+ onCalendarUnregistering(calendar) {
+ this.unregistering.push(calendar.id);
+ }
+
+ onCalendarDeleting(calendar) {
+ this.deleting.push(calendar.id);
+ }
+}
+
+add_test(function test_builtin_registration() {
+ function checkCalendarCount(net, rdonly, all) {
+ equal(cal.manager.networkCalendarCount, net);
+ equal(cal.manager.readOnlyCalendarCount, rdonly);
+ equal(cal.manager.calendarCount, all);
+ }
+
+ // Initially there should be no calendars.
+ checkCalendarCount(0, 0, 0);
+
+ // Create a local memory calendar, this shouldn't register any calendars.
+ let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ checkCalendarCount(0, 0, 0);
+
+ // Register an observer to test it.
+ let calmgrObserver = new CalendarManagerObserver();
+
+ let readOnly = false;
+ let calendarObserver = cal.createAdapter(Ci.calIObserver, {
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ equal(aCalendar.id, memory.id);
+ equal(aName, "readOnly");
+ readOnly = aValue;
+ },
+ });
+
+ memory.addObserver(calendarObserver);
+ cal.manager.addObserver(calmgrObserver);
+
+ // Register the calendar and check if its counted and observed.
+ cal.manager.registerCalendar(memory);
+ calmgrObserver.check({ registered: memory.id });
+ checkCalendarCount(0, 0, 1);
+
+ // The calendar should now have an id.
+ notEqual(memory.id, null);
+
+ // And be in the list of calendars.
+ equal(memory, cal.manager.getCalendarById(memory.id));
+ ok(cal.manager.getCalendars().some(x => x.id == memory.id));
+
+ // Make it readonly and check if the observer caught it.
+ memory.setProperty("readOnly", true);
+ equal(readOnly, true);
+
+ // Now unregister it.
+ cal.manager.unregisterCalendar(memory);
+ calmgrObserver.check({ unregistering: memory.id });
+ checkCalendarCount(0, 0, 0);
+
+ // The calendar shouldn't be in the list of ids.
+ equal(cal.manager.getCalendarById(memory.id), null);
+ ok(cal.manager.getCalendars().every(x => x.id != memory.id));
+
+ // And finally delete it.
+ cal.manager.removeCalendar(memory, Ci.calICalendarManager.REMOVE_NO_UNREGISTER);
+ calmgrObserver.check({ deleting: memory.id });
+ checkCalendarCount(0, 0, 0);
+
+ // Now remove the observer again.
+ cal.manager.removeObserver(calmgrObserver);
+ memory.removeObserver(calendarObserver);
+
+ // Check if removing it actually worked.
+ cal.manager.registerCalendar(memory);
+ cal.manager.removeCalendar(memory);
+ memory.setProperty("readOnly", false);
+ calmgrObserver.check({});
+ equal(readOnly, true);
+ checkCalendarCount(0, 0, 0);
+
+ // We are done now, start the next test.
+ run_next_test();
+});
+
+add_task(async function test_dynamic_registration() {
+ class CalendarProvider extends cal.provider.BaseClass {
+ QueryInterface = ChromeUtils.generateQI(["calICalendar"]);
+ type = "blm";
+
+ constructor() {
+ super();
+ this.initProviderBase();
+ }
+
+ getItems(itemFilter, count, rangeStart, rangeEnd, listener) {
+ return CalReadableStreamFactory.createEmptyReadableStream();
+ }
+ }
+
+ function checkCalendar(expectedCount = 1) {
+ let calendars = cal.manager.getCalendars();
+ equal(calendars.length, expectedCount);
+ let calendar = calendars[0];
+
+ if (expectedCount > 0) {
+ notEqual(calendar, null);
+ }
+ return calendar;
+ }
+
+ let calmgrObserver = new CalendarManagerObserver();
+ cal.manager.addObserver(calmgrObserver);
+ equal(cal.manager.calendarCount, 0);
+
+ // No provider registered.
+ let calendar = cal.manager.createCalendar("blm", Services.io.newURI("black-lives-matter://"));
+ equal(calendar, null);
+ ok(!cal.manager.hasCalendarProvider("blm"));
+
+ // Register dynamic provider.
+ cal.manager.registerCalendarProvider("blm", CalendarProvider);
+ calendar = cal.manager.createCalendar("blm", Services.io.newURI("black-lives-matter://"));
+ notEqual(calendar, null);
+ ok(calendar.wrappedJSObject instanceof CalendarProvider);
+ ok(cal.manager.hasCalendarProvider("blm"));
+
+ // Register a calendar using it.
+ cal.manager.registerCalendar(calendar);
+ calendar = checkCalendar();
+
+ let originalId = calendar.id;
+ calmgrObserver.check({ registered: originalId });
+
+ // Unregister the provider from under its feet.
+ cal.manager.unregisterCalendarProvider("blm");
+ calendar = checkCalendar();
+ calmgrObserver.check({ unregistering: originalId, registered: originalId });
+
+ equal(calendar.type, "blm");
+ equal(calendar.getProperty("force-disabled"), true);
+ equal(calendar.id, originalId);
+
+ // Re-register the provider should reactive it.
+ cal.manager.registerCalendarProvider("blm", CalendarProvider);
+ calendar = checkCalendar();
+ calmgrObserver.check({ unregistering: originalId, registered: originalId });
+
+ equal(calendar.type, "blm");
+ notEqual(calendar.getProperty("force-disabled"), true);
+ equal(calendar.id, originalId);
+
+ // Make sure calendar is loaded from prefs.
+ cal.manager.unregisterCalendarProvider("blm");
+ calmgrObserver.check({ unregistering: originalId, registered: originalId });
+
+ await new Promise(resolve => cal.manager.shutdown({ onResult: resolve }));
+ cal.manager.wrappedJSObject.mCache = null;
+ await new Promise(resolve => cal.manager.startup({ onResult: resolve }));
+ calmgrObserver.check({});
+
+ calendar = checkCalendar();
+ equal(calendar.type, "blm");
+ equal(calendar.getProperty("force-disabled"), true);
+ equal(calendar.id, originalId);
+
+ // Unregister the calendar for cleanup.
+ cal.manager.unregisterCalendar(calendar);
+ checkCalendar(0);
+ calmgrObserver.check({ unregistering: originalId });
+});
+
+add_test(function test_calobserver() {
+ function checkCounters(add, modify, del, alladd, allmodify, alldel) {
+ equal(calcounter.addItem, add);
+ equal(calcounter.modifyItem, modify);
+ equal(calcounter.deleteItem, del);
+ equal(allcounter.addItem, alladd === undefined ? add : alladd);
+ equal(allcounter.modifyItem, allmodify === undefined ? modify : allmodify);
+ equal(allcounter.deleteItem, alldel === undefined ? del : alldel);
+ resetCounters();
+ }
+ function resetCounters() {
+ calcounter = { addItem: 0, modifyItem: 0, deleteItem: 0 };
+ allcounter = { addItem: 0, modifyItem: 0, deleteItem: 0 };
+ }
+
+ // First of all we need a local calendar to work on and some variables
+ let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ let memory2 = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ let calcounter, allcounter;
+
+ // These observers will end up counting calls which we will use later on
+ let calobs = cal.createAdapter(Ci.calIObserver, {
+ onAddItem: () => calcounter.addItem++,
+ onModifyItem: () => calcounter.modifyItem++,
+ onDeleteItem: () => calcounter.deleteItem++,
+ });
+ let allobs = cal.createAdapter(Ci.calIObserver, {
+ onAddItem: () => allcounter.addItem++,
+ onModifyItem: () => allcounter.modifyItem++,
+ onDeleteItem: () => allcounter.deleteItem++,
+ });
+
+ // Set up counters and observers
+ resetCounters();
+ cal.manager.registerCalendar(memory);
+ cal.manager.registerCalendar(memory2);
+ cal.manager.addCalendarObserver(allobs);
+ memory.addObserver(calobs);
+
+ // Add an item
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.startDate = cal.dtz.now();
+ item.endDate = cal.dtz.now();
+ memory.addItem(item);
+ checkCounters(1, 0, 0);
+
+ // Modify the item
+ let newItem = item.clone();
+ newItem.title = "title";
+ memory.modifyItem(newItem, item);
+ checkCounters(0, 1, 0);
+
+ // Delete the item
+ newItem.generation++; // circumvent generation checks for easier code
+ memory.deleteItem(newItem);
+ checkCounters(0, 0, 1);
+
+ // Now check the same for adding the item to a calendar only observed by the
+ // calendar manager. The calcounters should still be 0, but the calendar
+ // manager counter should have an item added, modified and deleted
+ memory2.addItem(item);
+ memory2.modifyItem(newItem, item);
+ memory2.deleteItem(newItem);
+ checkCounters(0, 0, 0, 1, 1, 1);
+
+ // Remove observers
+ memory.removeObserver(calobs);
+ cal.manager.removeCalendarObserver(allobs);
+
+ // Make sure removing it actually worked
+ memory.addItem(item);
+ memory.modifyItem(newItem, item);
+ memory.deleteItem(newItem);
+ checkCounters(0, 0, 0);
+
+ // We are done now, start the next test
+ run_next_test();
+});
+
+add_test(function test_removeModes() {
+ function checkCounts(modes, shouldDelete, expectCount, extraFlags = 0) {
+ if (cal.manager.calendarCount == baseCalendarCount) {
+ cal.manager.registerCalendar(memory);
+ equal(cal.manager.calendarCount, baseCalendarCount + 1);
+ }
+ deleteCalled = false;
+ removeModes = modes;
+
+ cal.manager.removeCalendar(memory, extraFlags);
+ equal(cal.manager.calendarCount, baseCalendarCount + expectCount);
+ equal(deleteCalled, shouldDelete);
+ }
+ function mockCalendar(memory) {
+ let oldGetProperty = memory.wrappedJSObject.getProperty;
+ memory.wrappedJSObject.getProperty = function (name) {
+ if (name == "capabilities.removeModes") {
+ return removeModes;
+ }
+ return oldGetProperty.apply(this, arguments);
+ };
+
+ let oldDeleteCalendar = memory.wrappedJSObject.deleteCalendar;
+ memory.wrappedJSObject.deleteCalendar = function (calendar, listener) {
+ deleteCalled = true;
+ return oldDeleteCalendar.apply(this, arguments);
+ };
+ }
+
+ // For better readability
+ const SHOULD_DELETE = true,
+ SHOULD_NOT_DELETE = false;
+
+ let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ let baseCalendarCount = cal.manager.calendarCount;
+ let removeModes = null;
+ let deleteCalled = false;
+
+ mockCalendar(memory);
+
+ checkCounts([], SHOULD_NOT_DELETE, 1);
+ checkCounts(["unsubscribe"], SHOULD_NOT_DELETE, 0);
+ checkCounts(["unsubscribe", "delete"], SHOULD_DELETE, 0);
+ checkCounts(
+ ["unsubscribe", "delete"],
+ SHOULD_NOT_DELETE,
+ 0,
+ Ci.calICalendarManager.REMOVE_NO_DELETE
+ );
+ checkCounts(["delete"], SHOULD_DELETE, 0);
+
+ run_next_test();
+});
+
+add_test(function test_calprefs() {
+ let prop;
+ let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ cal.manager.registerCalendar(memory);
+ let memid = memory.id;
+
+ // First set a few values, one of each relevant type
+ memory.setProperty("stringpref", "abc");
+ memory.setProperty("boolpref", true);
+ memory.setProperty("intpref", 123);
+ memory.setProperty("bigintpref", 1394548721296);
+ memory.setProperty("floatpref", 0.5);
+
+ // Before checking the value, reinitialize the memory calendar with the
+ // same id to make sure the pref value isn't just cached
+ memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ memory.id = memid;
+
+ // First test the standard types
+ prop = memory.getProperty("stringpref");
+ equal(typeof prop, "string");
+ equal(prop, "abc");
+
+ prop = memory.getProperty("boolpref");
+ equal(typeof prop, "boolean");
+ equal(prop, true);
+
+ prop = memory.getProperty("intpref");
+ equal(typeof prop, "number");
+ equal(prop, 123);
+
+ // These two are a special case test for bug 979262
+ prop = memory.getProperty("bigintpref");
+ equal(typeof prop, "number");
+ equal(prop, 1394548721296);
+
+ prop = memory.getProperty("floatpref");
+ equal(typeof prop, "number");
+ equal(prop, 0.5);
+
+ // Check if changing pref types works. We need to reset the calendar again
+ // because retrieving the value just cached it again.
+ memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ memory.id = memid;
+
+ cal.manager.setCalendarPref_(memory, "boolpref", "kinda true");
+ prop = memory.getProperty("boolpref");
+ equal(typeof prop, "string");
+ equal(prop, "kinda true");
+
+ // Check if unsetting a pref works
+ memory.setProperty("intpref", null);
+ memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ memory.id = memid;
+ prop = memory.getProperty("intpref");
+ ok(prop === null);
+
+ // We are done now, start the next test
+ run_next_test();
+});
diff --git a/comm/calendar/test/unit/test_calreadablestreamfactory.js b/comm/calendar/test/unit/test_calreadablestreamfactory.js
new file mode 100644
index 0000000000..9da71e47ef
--- /dev/null
+++ b/comm/calendar/test/unit/test_calreadablestreamfactory.js
@@ -0,0 +1,195 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the ReadableStreams generated by CalReadableStreamFactory.
+ */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+
+/**
+ * @type {object} BoundedReadableStreamTestSpec
+ * @property {number} maxTotalItems
+ * @property {number} maxQueuedItems
+ * @property {number} actualTotalItems
+ * @property {number} actualChunkSize
+ * @property {Function} onChunk
+ */
+
+/**
+ * Common test for the BoundedReadableStream.
+ *
+ * @param {BoundedReadableStreamTestSpec} spec
+ */
+async function doBoundedReadableStreamTest({
+ maxTotalItems,
+ maxQueuedItems,
+ actualTotalItems,
+ actualChunkSize,
+ onChunk,
+}) {
+ let totalChunks = Math.ceil(actualTotalItems / actualChunkSize);
+ let stream = CalReadableStreamFactory.createBoundedReadableStream(maxTotalItems, maxQueuedItems, {
+ start(controller) {
+ let i = 0;
+ for (i; i < totalChunks; i++) {
+ controller.enqueue(
+ Array(actualChunkSize)
+ .fill(null)
+ .map(() => new CalEvent())
+ );
+ }
+ info(
+ `Enqueued ${
+ i * actualChunkSize
+ } items across ${i} chunks at a rate of ${actualChunkSize} items per chunk`
+ );
+ },
+ });
+
+ for await (let chunk of cal.iterate.streamValues(stream)) {
+ Assert.ok(Array.isArray(chunk), "chunk received is an array");
+ Assert.ok(
+ chunk.every(item => item instanceof CalEvent),
+ "all chunk elements are CalEvent instances"
+ );
+ onChunk(chunk);
+ }
+}
+
+/**
+ * Tests the BoundedReadableStream works as expected when the total items enqueued
+ * and the chunk size match the limits set.
+ */
+add_task(async function testBoundedReadableStreamWorksWithinLimits() {
+ let maxTotalItems = 35;
+ let maxQueuedItems = 5;
+ let totalChunks = 35 / 5;
+
+ let chunksRead = 0;
+ await doBoundedReadableStreamTest({
+ maxTotalItems,
+ maxQueuedItems,
+ actualTotalItems: maxTotalItems,
+ actualChunkSize: maxQueuedItems,
+ onChunk(chunk) {
+ Assert.equal(chunk.length, maxQueuedItems, `chunk has ${maxQueuedItems} items`);
+ chunksRead++;
+ },
+ });
+ Assert.equal(chunksRead, totalChunks, `received ${totalChunks} chunks from stream`);
+});
+
+/**
+ * Tests that the stream automatically closes when maxTotalItemsReached is true
+ * even if there are more items to come.
+ */
+add_task(async function testBoundedReadableStreamClosesIfMaxTotalItemsReached() {
+ let maxTotalItems = 35;
+ let maxQueuedItems = 5;
+ let items = [];
+
+ await doBoundedReadableStreamTest({
+ maxTotalItems,
+ maxQueuedItems,
+ actualTotalItems: 50,
+ actualChunkSize: 7,
+ onChunk(chunk) {
+ items = items.concat(chunk);
+ },
+ });
+ Assert.equal(items.length, maxTotalItems, `received ${maxTotalItems} items from stream`);
+});
+
+/**
+ * Test that chunks enqueued with smaller than the maxQueueSize value are held
+ * until the threshold is reached.
+ */
+add_task(async function testBoundedReadableStreamBuffersChunks() {
+ let maxTotalItems = 35;
+ let maxQueuedItems = 5;
+ let totalChunks = 35 / 5;
+
+ let chunksRead = 0;
+ await doBoundedReadableStreamTest({
+ maxTotalItems,
+ maxQueuedItems,
+ actualTotalItems: 35,
+ actualChunkSize: 1,
+ onChunk(chunk) {
+ Assert.equal(chunk.length, maxQueuedItems, `chunk has ${maxQueuedItems} items`);
+ chunksRead++;
+ },
+ });
+ Assert.equal(chunksRead, totalChunks, `received ${totalChunks} chunks from stream`);
+});
+
+/**
+ * Test the CombinedReadbleStream streams from all of its streams.
+ */
+add_task(async function testCombinedReadableStreamStreamsAll() {
+ let mkStream = () =>
+ CalReadableStreamFactory.createReadableStream({
+ start(controller) {
+ for (let i = 0; i < 5; i++) {
+ controller.enqueue(new CalEvent());
+ }
+ controller.close();
+ },
+ });
+
+ let stream = CalReadableStreamFactory.createCombinedReadableStream([
+ mkStream(),
+ mkStream(),
+ mkStream(),
+ ]);
+
+ let items = [];
+ for await (let value of cal.iterate.streamValues(stream)) {
+ Assert.ok(value instanceof CalEvent, "value read from stream is CalEvent instance");
+ items.push(value);
+ }
+ Assert.equal(items.length, 15, "read a total of 15 items from the stream");
+});
+
+/**
+ * Test the MappedReadableStream applies the MapStreamFunction to each value
+ * read from the stream.
+ */
+add_task(async function testMappedReadableStream() {
+ let stream = CalReadableStreamFactory.createMappedReadableStream(
+ CalReadableStreamFactory.createReadableStream({
+ start(controller) {
+ for (let i = 0; i < 10; i++) {
+ controller.enqueue(1);
+ }
+ controller.close();
+ },
+ }),
+ value => value * 0
+ );
+
+ let values = [];
+ for await (let value of cal.iterate.streamValues(stream)) {
+ Assert.equal(value, 0, "read value inverted to 0");
+ values.push(value);
+ }
+ Assert.equal(values.length, 10, "all 10 values were transformed");
+});
+
+/**
+ * Test the EmptyReadableStream is already closed.
+ */
+add_task(async function testEmptyReadableStream() {
+ let stream = CalReadableStreamFactory.createEmptyReadableStream();
+ let values = [];
+ for await (let value of cal.iterate.streamValues(stream)) {
+ values.push(value);
+ }
+ Assert.equal(values.length, 0, "no values were read from the empty stream");
+});
diff --git a/comm/calendar/test/unit/test_data_bags.js b/comm/calendar/test/unit/test_data_bags.js
new file mode 100644
index 0000000000..dd1f2a6abd
--- /dev/null
+++ b/comm/calendar/test/unit/test_data_bags.js
@@ -0,0 +1,151 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ test_listener_set();
+ test_observer_set();
+ test_operation_group();
+}
+
+function test_listener_set() {
+ let set = new cal.data.ListenerSet(Ci.calIOperationListener);
+ let listener1Id = null;
+ let listener2Id = null;
+
+ let listener1 = cal.createAdapter("calIOperationListener", {
+ onOperationComplete(aCalendar, aStatus, aOpType, aId, aDetail) {
+ listener1Id = aId;
+ },
+ });
+ let listener2 = cal.createAdapter("calIOperationListener", {
+ onOperationComplete(aCalendar, aStatus, aOpType, aId, aDetail) {
+ listener2Id = aId;
+ },
+ });
+
+ set.add(listener1);
+ set.add(listener2);
+ set.notify("onOperationComplete", [null, null, null, "test", null]);
+ equal(listener1Id, "test");
+ equal(listener2Id, "test");
+
+ set.delete(listener2);
+ listener1Id = listener2Id = null;
+ set.notify("onOperationComplete", [null, null, null, "test2", null]);
+ equal(listener1Id, "test2");
+ strictEqual(listener2Id, null);
+
+ // Re-adding the listener may lead to an endless loop if the notify
+ // function uses a live list of observers.
+ let called = 0;
+ let listener3 = cal.createAdapter("calIOperationListener", {
+ onOperationComplete(aCalendar, aStatus, aOpType, aId, aDetail) {
+ set.delete(listener3);
+ if (called == 0) {
+ set.add(listener3);
+ }
+ called++;
+ },
+ });
+
+ set.add(listener3);
+ set.notify("onOperationComplete", [null, null, null, "test3", null]);
+ equal(called, 1);
+}
+
+function test_observer_set() {
+ let set = new cal.data.ObserverSet(Ci.calIObserver);
+ let listenerCountBegin1 = 0;
+ let listenerCountBegin2 = 0;
+ let listenerCountEnd1 = 0;
+ let listenerCountEnd2 = 0;
+
+ let listener1 = cal.createAdapter("calIObserver", {
+ onStartBatch() {
+ listenerCountBegin1++;
+ },
+ onEndBatch() {
+ listenerCountEnd1++;
+ },
+ });
+ let listener2 = cal.createAdapter("calIObserver", {
+ onStartBatch() {
+ listenerCountBegin2++;
+ },
+ onEndBatch() {
+ listenerCountEnd2++;
+ },
+ });
+
+ set.add(listener1);
+ equal(listenerCountBegin1, 0);
+ equal(listenerCountEnd1, 0);
+ equal(set.batchCount, 0);
+
+ set.notify("onStartBatch");
+ equal(listenerCountBegin1, 1);
+ equal(listenerCountEnd1, 0);
+ equal(set.batchCount, 1);
+
+ set.add(listener2);
+ equal(listenerCountBegin1, 1);
+ equal(listenerCountEnd1, 0);
+ equal(listenerCountBegin2, 1);
+ equal(listenerCountEnd2, 0);
+ equal(set.batchCount, 1);
+
+ set.add(listener1);
+ equal(listenerCountBegin1, 1);
+ equal(listenerCountEnd1, 0);
+ equal(listenerCountBegin2, 1);
+ equal(listenerCountEnd2, 0);
+ equal(set.batchCount, 1);
+
+ set.notify("onEndBatch");
+ equal(listenerCountBegin1, 1);
+ equal(listenerCountEnd1, 1);
+ equal(listenerCountBegin2, 1);
+ equal(listenerCountEnd2, 1);
+ equal(set.batchCount, 0);
+}
+
+function test_operation_group() {
+ let calledCancel = false;
+ let calledOperationCancel = null;
+ let group = new cal.data.OperationGroup();
+ ok(group.id.endsWith("-0"));
+ ok(group.isPending);
+ equal(group.status, Cr.NS_OK);
+ ok(group.isEmpty);
+
+ let operation = {
+ id: 123,
+ isPending: true,
+ cancel: status => {
+ calledOperationCancel = status;
+ },
+ };
+
+ group.add(operation);
+ ok(!group.isEmpty);
+
+ group.notifyCompleted(Cr.NS_ERROR_FAILURE);
+ ok(!group.isPending);
+ equal(group.status, Cr.NS_ERROR_FAILURE);
+ strictEqual(calledOperationCancel, null);
+
+ group.remove(operation);
+ ok(group.isEmpty);
+
+ group = new cal.data.OperationGroup(() => {
+ calledCancel = true;
+ });
+ ok(group.id.endsWith("-1"));
+ group.add(operation);
+
+ group.cancel();
+ equal(group.status, Ci.calIErrors.OPERATION_CANCELLED);
+ equal(calledOperationCancel, Ci.calIErrors.OPERATION_CANCELLED);
+ ok(calledCancel);
+}
diff --git a/comm/calendar/test/unit/test_datetime.js b/comm/calendar/test/unit/test_datetime.js
new file mode 100644
index 0000000000..a754e570a8
--- /dev/null
+++ b/comm/calendar/test/unit/test_datetime.js
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ function getMozTimezone(tzid) {
+ return cal.timezoneService.getTimezone(tzid);
+ }
+
+ let date = cal.createDateTime();
+ date.resetTo(2005, 10, 13, 10, 0, 0, getMozTimezone("/mozilla.org/20050126_1/America/Bogota"));
+
+ equal(date.hour, 10);
+ equal(date.icalString, "20051113T100000");
+
+ let date_floating = date.getInTimezone(cal.dtz.floating);
+ equal(date_floating.hour, 10);
+
+ let date_utc = date.getInTimezone(cal.dtz.UTC);
+ equal(date_utc.hour, 15);
+ equal(date_utc.icalString, "20051113T150000Z");
+
+ date.hour = 25;
+ equal(date.hour, 1);
+ equal(date.day, 14);
+
+ // Test nativeTime on dates
+ // setting .isDate to be true on a date should not change its nativeTime
+ // bug 315954,
+ date.hour = 0;
+ let date_allday = date.clone();
+ date_allday.isDate = true;
+ equal(date.nativeTime, date_allday.nativeTime);
+
+ // Daylight savings test
+ date.resetTo(2006, 2, 26, 1, 0, 0, getMozTimezone("/mozilla.org/20050126_1/Europe/Amsterdam"));
+
+ equal(date.weekday, 0);
+ equal(date.timezoneOffset, 1 * 3600);
+
+ date.day += 1;
+ equal(date.timezoneOffset, 2 * 3600);
+
+ // Bug 398724 - Problems with floating all-day items
+ let event = new CalEvent(
+ "BEGIN:VEVENT\nUID:45674d53-229f-48c6-9f3b-f2b601e7ae4d\nSUMMARY:New Event\nDTSTART;VALUE=DATE:20071003\nDTEND;VALUE=DATE:20071004\nEND:VEVENT"
+ );
+ ok(event.startDate.timezone.isFloating);
+ ok(event.endDate.timezone.isFloating);
+
+ // Bug 392853 - Same times, different timezones, but subtractDate says times are PT0S apart
+ const zeroLength = cal.createDuration();
+ const a = cal.dtz.jsDateToDateTime(new Date());
+ a.timezone = getMozTimezone("/mozilla.org/20071231_1/Europe/Berlin");
+
+ let b = a.clone();
+ b.timezone = getMozTimezone("/mozilla.org/20071231_1/America/New_York");
+
+ let duration = a.subtractDate(b);
+ notEqual(duration.compare(zeroLength), 0);
+ notEqual(a.compare(b), 0);
+
+ // Should lead to zero length duration
+ b = a.getInTimezone(getMozTimezone("/mozilla.org/20071231_1/America/New_York"));
+ duration = a.subtractDate(b);
+ equal(duration.compare(zeroLength), 0);
+ equal(a.compare(b), 0);
+
+ // Check that we can get the same timezone with several aliases
+ equal(getMozTimezone("/mozilla.org/xyz/Asia/Calcutta").tzid, "Asia/Calcutta");
+ equal(getMozTimezone("Asia/Calcutta").tzid, "Asia/Calcutta");
+ equal(getMozTimezone("Asia/Kolkata").tzid, "Asia/Kolkata");
+
+ // A newly created date should be in UTC, as should its clone
+ let utc = cal.createDateTime();
+ equal(utc.timezone.tzid, "UTC");
+ equal(utc.clone().timezone.tzid, "UTC");
+ equal(utc.timezoneOffset, 0);
+
+ // Bug 794477 - setting jsdate across compartments needs to work
+ let someDate = new Date();
+ let createdDate = cal.dtz.jsDateToDateTime(someDate).getInTimezone(cal.dtz.defaultTimezone);
+ someDate.setMilliseconds(0);
+ equal(someDate.getTime(), cal.dtz.dateTimeToJsDate(createdDate).getTime());
+
+ // Comparing a date-time with a date of the same day should be 0
+ equal(cal.createDateTime("20120101T120000").compare(cal.createDateTime("20120101")), 0);
+ equal(cal.createDateTime("20120101").compare(cal.createDateTime("20120101T120000")), 0);
+}
diff --git a/comm/calendar/test/unit/test_datetime_before_1970.js b/comm/calendar/test/unit/test_datetime_before_1970.js
new file mode 100644
index 0000000000..a5e5dd0054
--- /dev/null
+++ b/comm/calendar/test/unit/test_datetime_before_1970.js
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ // Bug 769938 - dates before 1970 are not handled correctly
+ // due to signed vs. unsigned mismatch in PRTime in xpconnect
+
+ let dateTime1950 = cal.createDateTime();
+ dateTime1950.year = 1950;
+ equal(dateTime1950.year, 1950);
+
+ let dateTime1955 = cal.dtz.jsDateToDateTime(new Date(Date.UTC(1955, 6, 15)));
+ equal(dateTime1955.year, 1955);
+
+ let dateTime1965 = cal.createDateTime();
+ dateTime1965.nativeTime = -150000000000000;
+ equal(dateTime1965.year, 1965);
+ equal(dateTime1965.nativeTime, -150000000000000);
+
+ let dateTime1990 = cal.createDateTime();
+ dateTime1990.year = 1990;
+
+ let dateTime2050 = cal.createDateTime();
+ dateTime2050.year = 2050;
+
+ ok(dateTime1950.nativeTime < dateTime1955.nativeTime);
+ ok(dateTime1955.nativeTime < dateTime1965.nativeTime);
+ ok(dateTime1965.nativeTime < dateTime1990.nativeTime);
+ ok(dateTime1990.nativeTime < dateTime2050.nativeTime);
+}
diff --git a/comm/calendar/test/unit/test_datetimeformatter.js b/comm/calendar/test/unit/test_datetimeformatter.js
new file mode 100644
index 0000000000..d297b25d0b
--- /dev/null
+++ b/comm/calendar/test/unit/test_datetimeformatter.js
@@ -0,0 +1,604 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { formatter } = cal.dtz;
+
+const { CalTimezone } = ChromeUtils.import("resource:///modules/CalTimezone.jsm");
+const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+
+function run_test() {
+ do_calendar_startup(run_next_test);
+}
+
+// This test assumes the timezone of your system is not set to Pacific/Fakaofo or equivalent.
+
+// Time format is platform dependent, so we use alternative result sets here in 'expected'.
+// The first two meet configurations running for automated tests,
+// the first one is for Windows, the second one for Linux and Mac, unless otherwise noted.
+// If you get a failure for this test, add your pattern here.
+
+add_task(async function formatDate_test() {
+ let data = [
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Fakaofo",
+ dateformat: 0, // long
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Fakaofo",
+ dateformat: 1, // short
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ ];
+
+ let dateformat = Services.prefs.getIntPref("calendar.date.format", 0);
+ let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+ Services.prefs.setIntPref("calendar.date.format", test.input.dateformat);
+ let zone =
+ test.input.timezone == "floating"
+ ? cal.dtz.floating
+ : cal.timezoneService.getTimezone(test.input.timezone);
+ let date = cal.createDateTime(test.input.datetime).getInTimezone(zone);
+
+ let formatted = formatter.formatDate(date);
+ ok(
+ test.expected.includes(formatted),
+ "(test #" + i + ": result '" + formatted + "', expected '" + test.expected + "')"
+ );
+ }
+ // let's reset the preferences
+ Services.prefs.setStringPref("calendar.timezone.local", tzlocal);
+ Services.prefs.setIntPref("calendar.date.format", dateformat);
+});
+
+add_task(async function formatDateShort_test() {
+ let data = [
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "UTC",
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "floating",
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "UTC",
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "floating",
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ ];
+
+ let dateformat = Services.prefs.getIntPref("calendar.date.format", 0);
+ let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ // we make sure to have set long format
+ Services.prefs.setIntPref("calendar.date.format", 0);
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+
+ let zone =
+ test.input.timezone == "floating"
+ ? cal.dtz.floating
+ : cal.timezoneService.getTimezone(test.input.timezone);
+ let date = cal.createDateTime(test.input.datetime).getInTimezone(zone);
+
+ let formatted = formatter.formatDateShort(date);
+ ok(
+ test.expected.includes(formatted),
+ "(test #" + i + ": result '" + formatted + "', expected '" + test.expected + "')"
+ );
+ }
+ // let's reset the preferences
+ Services.prefs.setStringPref("calendar.timezone.local", tzlocal);
+ Services.prefs.setIntPref("calendar.date.format", dateformat);
+});
+
+add_task(async function formatDateLong_test() {
+ let data = [
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "UTC",
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "floating",
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "UTC",
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "floating",
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ ];
+
+ let dateformat = Services.prefs.getIntPref("calendar.date.format", 0);
+ let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ // we make sure to have set short format
+ Services.prefs.setIntPref("calendar.date.format", 1);
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+
+ let zone =
+ test.input.timezone == "floating"
+ ? cal.dtz.floating
+ : cal.timezoneService.getTimezone(test.input.timezone);
+ let date = cal.createDateTime(test.input.datetime).getInTimezone(zone);
+
+ let formatted = formatter.formatDateLong(date);
+ ok(
+ test.expected.includes(formatted),
+ "(test #" + i + ": result '" + formatted + "', expected '" + test.expected + "')"
+ );
+ }
+ // let's reset the preferences
+ Services.prefs.setStringPref("calendar.timezone.local", tzlocal);
+ Services.prefs.setIntPref("calendar.date.format", dateformat);
+});
+
+add_task(async function formatDateWithoutYear_test() {
+ let data = [
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: "Apr 1",
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: "Apr 1",
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "UTC",
+ },
+ expected: "Apr 1",
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "floating",
+ },
+ expected: "Apr 1",
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: "Apr 1",
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: "Apr 1",
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "UTC",
+ },
+ expected: "Apr 1",
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "floating",
+ },
+ expected: "Apr 1",
+ },
+ ];
+
+ let dateformat = Services.prefs.getIntPref("calendar.date.format", 0);
+ let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ // we make sure to have set short format
+ Services.prefs.setIntPref("calendar.date.format", 1);
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+
+ let zone =
+ test.input.timezone == "floating"
+ ? cal.dtz.floating
+ : cal.timezoneService.getTimezone(test.input.timezone);
+ let date = cal.createDateTime(test.input.datetime).getInTimezone(zone);
+
+ equal(formatter.formatDateWithoutYear(date), test.expected, "(test #" + i + ")");
+ }
+ // let's reset the preferences
+ Services.prefs.setStringPref("calendar.timezone.local", tzlocal);
+ Services.prefs.setIntPref("calendar.date.format", dateformat);
+});
+
+add_task(async function formatDateLongWithoutYear_test() {
+ let data = [
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: "Saturday, April 1",
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: "Saturday, April 1",
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "UTC",
+ },
+ expected: "Saturday, April 1",
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "floating",
+ },
+ expected: "Saturday, April 1",
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: "Saturday, April 1",
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: "Saturday, April 1",
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "UTC",
+ },
+ expected: "Saturday, April 1",
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "floating",
+ },
+ expected: "Saturday, April 1",
+ },
+ ];
+
+ let dateformat = Services.prefs.getIntPref("calendar.date.format", 0);
+ let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ // we make sure to have set short format
+ Services.prefs.setIntPref("calendar.date.format", 1);
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+
+ let zone =
+ test.input.timezone == "floating"
+ ? cal.dtz.floating
+ : cal.timezoneService.getTimezone(test.input.timezone);
+ let date = cal.createDateTime(test.input.datetime).getInTimezone(zone);
+
+ equal(formatter.formatDateLongWithoutYear(date), test.expected, "(test #" + i + ")");
+ }
+ // let's reset the preferences
+ Services.prefs.setStringPref("calendar.timezone.local", tzlocal);
+ Services.prefs.setIntPref("calendar.date.format", dateformat);
+});
+
+add_task(async function formatTime_test() {
+ let data = [
+ {
+ input: {
+ datetime: "20170401T090000",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: ["9:00 AM", "09:00"], // Windows+Mac, Linux.
+ },
+ {
+ input: {
+ datetime: "20170401T090000",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: ["9:00 AM", "09:00"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "UTC",
+ },
+ expected: ["6:00 PM", "18:00"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "floating",
+ },
+ expected: ["6:00 PM", "18:00"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: "All Day",
+ },
+ ];
+
+ let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+
+ let zone =
+ test.input.timezone == "floating"
+ ? cal.dtz.floating
+ : cal.timezoneService.getTimezone(test.input.timezone);
+ let date = cal.createDateTime(test.input.datetime).getInTimezone(zone);
+
+ let formatted = formatter.formatTime(date);
+ ok(
+ test.expected.includes(formatted),
+ "(test #" + i + ": result '" + formatted + "', expected '" + test.expected + "')"
+ );
+ }
+ // let's reset the preferences
+ Services.prefs.setStringPref("calendar.timezone.local", tzlocal);
+});
+
+add_task(function formatTime_test_with_arbitrary_timezone() {
+ // Create a timezone with an arbitrary offset and a time zone ID we can be
+ // reasonably sure Gecko won't recognize so we can be sure that we aren't
+ // relying on the time zone ID to be valid.
+ const tzdef =
+ "BEGIN:VTIMEZONE\n" +
+ "TZID:Nowhere/Middle\n" +
+ "BEGIN:STANDARD\n" +
+ "DTSTART:16010101T000000\n" +
+ "TZOFFSETFROM:-0741\n" +
+ "TZOFFSETTO:-0741\n" +
+ "END:STANDARD\n" +
+ "END:VTIMEZONE";
+
+ const timezone = new CalTimezone(
+ ICAL.Timezone.fromData({
+ tzid: "Nowhere/Middle",
+ component: tzdef,
+ })
+ );
+
+ const expected = ["6:19 AM", "06:19"];
+
+ const dateTime = cal.createDateTime("20220916T140000Z").getInTimezone(timezone);
+ const formatted = formatter.formatTime(dateTime);
+
+ ok(expected.includes(formatted), `expected '${expected}', actual result ${formatted}`);
+});
+
+add_task(async function formatInterval_test() {
+ let data = [
+ //1: task-without-dates
+ {
+ input: {},
+ expected: "no start or due date",
+ },
+ //2: task-without-due-date
+ {
+ input: { start: "20220916T140000Z" },
+ expected: [
+ "start date Friday, September 16, 2022 2:00 PM",
+ "start date Friday, September 16, 2022 14:00",
+ ],
+ },
+ //3: task-without-start-date
+ {
+ input: { end: "20220916T140000Z" },
+ expected: [
+ "due date Friday, September 16, 2022 2:00 PM",
+ "due date Friday, September 16, 2022 14:00",
+ ],
+ },
+ //4: all-day
+ {
+ input: {
+ start: "20220916T140000Z",
+ end: "20220916T140000Z",
+ allDay: true,
+ },
+ expected: "Friday, September 16, 2022",
+ },
+ //5: all-day-between-years
+ {
+ input: {
+ start: "20220916T140000Z",
+ end: "20230916T140000Z",
+ allDay: true,
+ },
+ expected: "September 16, 2022 – September 16, 2023",
+ },
+ //6: all-day-in-month
+ {
+ input: {
+ start: "20220916T140000Z",
+ end: "20220920T140000Z",
+ allDay: true,
+ },
+ expected: "September 16 – 20, 2022",
+ },
+ //7: all-day-between-months
+ {
+ input: {
+ start: "20220916T140000Z",
+ end: "20221020T140000Z",
+ allDay: true,
+ },
+ expected: "September 16 – October 20, 2022",
+ },
+ //8: same-date-time
+ {
+ input: {
+ start: "20220916T140000Z",
+ end: "20220916T140000Z",
+ },
+ expected: ["Friday, September 16, 2022 2:00 PM", "Friday, September 16, 2022 14:00"],
+ },
+ //9: same-day
+ {
+ input: {
+ start: "20220916T140000Z",
+ end: "20220916T160000Z",
+ },
+ expected: [
+ "Friday, September 16, 2022 2:00 PM – 4:00 PM",
+ "Friday, September 16, 2022 14:00 – 16:00",
+ ],
+ },
+ //10: several-days
+ {
+ input: {
+ start: "20220916T140000Z",
+ end: "20220920T160000Z",
+ },
+ expected: [
+ "Friday, September 16, 2022 2:00 PM – Tuesday, September 20, 2022 4:00 PM",
+ "Friday, September 16, 2022 14:00 – Tuesday, September 20, 2022 16:00",
+ ],
+ },
+ ];
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+ let startDate = test.input.start ? cal.createDateTime(test.input.start) : null;
+ let endDate = test.input.end ? cal.createDateTime(test.input.end) : null;
+
+ if (test.input.allDay) {
+ startDate.isDate = true;
+ }
+
+ let formatted = formatter.formatInterval(startDate, endDate);
+ ok(
+ test.expected.includes(formatted),
+ "(test #" + i + ": result '" + formatted + "', expected '" + test.expected + "')"
+ );
+ }
+});
diff --git a/comm/calendar/test/unit/test_deleted_items.js b/comm/calendar/test/unit/test_deleted_items.js
new file mode 100644
index 0000000000..d68c927dae
--- /dev/null
+++ b/comm/calendar/test/unit/test_deleted_items.js
@@ -0,0 +1,106 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+add_setup(function () {
+ // The deleted items service is started automatically by the start-up
+ // procedure, but that doesn't happen in XPCShell tests. Add an observer
+ // ourselves to simulate the behaviour.
+ let delmgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(Ci.calIDeletedItems);
+ Services.obs.addObserver(delmgr, "profile-after-change");
+
+ do_calendar_startup(run_next_test);
+});
+
+function check_delmgr_call(aFunc) {
+ let delmgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(Ci.calIDeletedItems);
+
+ return new Promise((resolve, reject) => {
+ delmgr.wrappedJSObject.completedNotifier.handleCompletion = aReason => {
+ if (aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED) {
+ resolve();
+ } else {
+ reject(aReason);
+ }
+ };
+ aFunc();
+ });
+}
+
+add_task(async function test_deleted_items() {
+ let delmgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(Ci.calIDeletedItems);
+
+ // No items have been deleted, retrieving one should return null.
+ equal(delmgr.getDeletedDate("random"), null);
+ equal(delmgr.getDeletedDate("random", "random"), null);
+
+ // Make sure the cache is initially flushed and that this doesn't throw an
+ // error.
+ await check_delmgr_call(() => delmgr.flush());
+
+ let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-storage-calendar://"));
+ cal.manager.registerCalendar(memory);
+
+ let item = new CalEvent();
+ item.id = "test-item-1";
+ item.startDate = cal.dtz.now();
+ item.endDate = cal.dtz.now();
+
+ // Add the item, it still shouldn't be in the deleted database.
+ await check_delmgr_call(() => memory.addItem(item));
+ equal(delmgr.getDeletedDate(item.id), null);
+ equal(delmgr.getDeletedDate(item.id, memory.id), null);
+
+ // We need to stop time so we have something to compare with.
+ let referenceDate = cal.createDateTime("20120726T112045");
+ referenceDate.timezone = cal.dtz.defaultTimezone;
+ let futureDate = cal.createDateTime("20380101T000000");
+ futureDate.timezone = cal.dtz.defaultTimezone;
+ let useFutureDate = false;
+ let oldNowFunction = cal.dtz.now;
+ cal.dtz.now = function () {
+ return (useFutureDate ? futureDate : referenceDate).clone();
+ };
+
+ // Deleting an item should trigger it being marked for deletion.
+ await check_delmgr_call(() => memory.deleteItem(item));
+
+ // Now check if it was deleted at our reference date.
+ let deltime = delmgr.getDeletedDate(item.id);
+ notEqual(deltime, null);
+ equal(deltime.compare(referenceDate), 0);
+
+ // The same with the calendar.
+ deltime = delmgr.getDeletedDate(item.id, memory.id);
+ notEqual(deltime, null);
+ equal(deltime.compare(referenceDate), 0);
+
+ // Item should not be found in other calendars.
+ equal(delmgr.getDeletedDate(item.id, "random"), null);
+
+ // Check if flushing works, we need to travel time for that.
+ useFutureDate = true;
+ await check_delmgr_call(() => delmgr.flush());
+ equal(delmgr.getDeletedDate(item.id), null);
+ equal(delmgr.getDeletedDate(item.id, memory.id), null);
+
+ // Start over with our past time.
+ useFutureDate = false;
+
+ // Add, delete, add. Item should no longer be deleted.
+ await check_delmgr_call(() => memory.addItem(item));
+ equal(delmgr.getDeletedDate(item.id), null);
+ await check_delmgr_call(() => memory.deleteItem(item));
+ equal(delmgr.getDeletedDate(item.id).compare(referenceDate), 0);
+ await check_delmgr_call(() => memory.addItem(item));
+ equal(delmgr.getDeletedDate(item.id), null);
+
+ // Revert now function, in case more tests are written.
+ cal.dtz.now = oldNowFunction;
+});
diff --git a/comm/calendar/test/unit/test_duration.js b/comm/calendar/test/unit/test_duration.js
new file mode 100644
index 0000000000..cef7bbddf9
--- /dev/null
+++ b/comm/calendar/test/unit/test_duration.js
@@ -0,0 +1,10 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ let a = cal.createDuration("PT1S");
+ let b = cal.createDuration("PT3S");
+ a.addDuration(b);
+ equal(a.icalString, "PT4S");
+}
diff --git a/comm/calendar/test/unit/test_email_utils.js b/comm/calendar/test/unit/test_email_utils.js
new file mode 100644
index 0000000000..a1a55e3f17
--- /dev/null
+++ b/comm/calendar/test/unit/test_email_utils.js
@@ -0,0 +1,265 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+});
+
+function run_test() {
+ test_prependMailTo();
+ test_removeMailTo();
+ test_getAttendeeEmail();
+ test_createRecipientList();
+ test_validateRecipientList();
+ test_attendeeMatchesAddresses();
+}
+
+function test_prependMailTo() {
+ let data = [
+ { input: "mailto:first.last@example.net", expected: "mailto:first.last@example.net" },
+ { input: "MAILTO:first.last@example.net", expected: "mailto:first.last@example.net" },
+ { input: "first.last@example.net", expected: "mailto:first.last@example.net" },
+ { input: "first.last.example.net", expected: "first.last.example.net" },
+ ];
+ for (let [i, test] of Object.entries(data)) {
+ equal(cal.email.prependMailTo(test.input), test.expected, "(test #" + i + ")");
+ }
+}
+
+function test_removeMailTo() {
+ let data = [
+ { input: "mailto:first.last@example.net", expected: "first.last@example.net" },
+ { input: "MAILTO:first.last@example.net", expected: "first.last@example.net" },
+ { input: "first.last@example.net", expected: "first.last@example.net" },
+ { input: "first.last.example.net", expected: "first.last.example.net" },
+ ];
+ for (let [i, test] of Object.entries(data)) {
+ equal(cal.email.removeMailTo(test.input), test.expected, "(test #" + i + ")");
+ }
+}
+
+function test_getAttendeeEmail() {
+ let data = [
+ {
+ input: {
+ id: "mailto:first.last@example.net",
+ cname: "Last, First",
+ email: null,
+ useCn: true,
+ },
+ expected: '"Last, First" <first.last@example.net>',
+ },
+ {
+ input: {
+ id: "mailto:first.last@example.net",
+ cname: "Last; First",
+ email: null,
+ useCn: true,
+ },
+ expected: '"Last; First" <first.last@example.net>',
+ },
+ {
+ input: { id: "mailto:first.last@example.net", cname: "First Last", email: null, useCn: true },
+ expected: "First Last <first.last@example.net>",
+ },
+ {
+ input: {
+ id: "mailto:first.last@example.net",
+ cname: "Last, First",
+ email: null,
+ useCn: false,
+ },
+ expected: "first.last@example.net",
+ },
+ {
+ input: { id: "mailto:first.last@example.net", cname: null, email: null, useCn: true },
+ expected: "first.last@example.net",
+ },
+ {
+ input: {
+ id: "urn:uuid:first.last.example.net",
+ cname: null,
+ email: "first.last@example.net",
+ useCn: false,
+ },
+ expected: "first.last@example.net",
+ },
+ {
+ input: {
+ id: "urn:uuid:first.last.example.net",
+ cname: null,
+ email: "first.last@example.net",
+ useCn: true,
+ },
+ expected: "first.last@example.net",
+ },
+ {
+ input: {
+ id: "urn:uuid:first.last.example.net",
+ cname: "First Last",
+ email: "first.last@example.net",
+ useCn: true,
+ },
+ expected: "First Last <first.last@example.net>",
+ },
+ {
+ input: { id: "urn:uuid:first.last.example.net", cname: null, email: null, useCn: false },
+ expected: "",
+ },
+ ];
+ for (let [i, test] of Object.entries(data)) {
+ let attendee = new CalAttendee();
+ attendee.id = test.input.id;
+ if (test.input.cname) {
+ attendee.commonName = test.input.cname;
+ }
+ if (test.input.email) {
+ attendee.setProperty("EMAIL", test.input.email);
+ }
+ equal(
+ cal.email.getAttendeeEmail(attendee, test.input.useCn),
+ test.expected,
+ "(test #" + i + ")"
+ );
+ }
+}
+
+function test_createRecipientList() {
+ let data = [
+ {
+ input: [
+ { id: "mailto:first@example.net", cname: null },
+ { id: "mailto:second@example.net", cname: null },
+ { id: "mailto:third@example.net", cname: null },
+ ],
+ expected: "first@example.net, second@example.net, third@example.net",
+ },
+ {
+ input: [
+ { id: "mailto:first@example.net", cname: "first example" },
+ { id: "mailto:second@example.net", cname: "second example" },
+ { id: "mailto:third@example.net", cname: "third example" },
+ ],
+ expected:
+ "first example <first@example.net>, second example <second@example.net>, " +
+ "third example <third@example.net>",
+ },
+ {
+ input: [
+ { id: "mailto:first@example.net", cname: "example, first" },
+ { id: "mailto:second@example.net", cname: "example, second" },
+ { id: "mailto:third@example.net", cname: "example, third" },
+ ],
+ expected:
+ '"example, first" <first@example.net>, "example, second" <second@example.net>, ' +
+ '"example, third" <third@example.net>',
+ },
+ {
+ input: [
+ { id: "mailto:first@example.net", cname: null },
+ { id: "urn:uuid:second.example.net", cname: null },
+ { id: "mailto:third@example.net", cname: null },
+ ],
+ expected: "first@example.net, third@example.net",
+ },
+ {
+ input: [
+ { id: "mailto:first@example.net", cname: "first" },
+ { id: "urn:uuid:second.example.net", cname: "second" },
+ { id: "mailto:third@example.net", cname: "third" },
+ ],
+ expected: "first <first@example.net>, third <third@example.net>",
+ },
+ ];
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+ let attendees = [];
+ for (let att of test.input) {
+ let attendee = new CalAttendee();
+ attendee.id = att.id;
+ if (att.cname) {
+ attendee.commonName = att.cname;
+ }
+ attendees.push(attendee);
+ }
+ equal(cal.email.createRecipientList(attendees), test.expected, "(test #" + i + ")");
+ }
+}
+
+function test_validateRecipientList() {
+ let data = [
+ {
+ input: "first.last@example.net",
+ expected: "first.last@example.net",
+ },
+ {
+ input: "first last <first.last@example.net>",
+ expected: "first last <first.last@example.net>",
+ },
+ {
+ input: '"last, first" <first.last@example.net>',
+ expected: '"last, first" <first.last@example.net>',
+ },
+ {
+ input: "last, first <first.last@example.net>",
+ expected: '"last, first" <first.last@example.net>',
+ },
+ {
+ input: '"last; first" <first.last@example.net>',
+ expected: '"last; first" <first.last@example.net>',
+ },
+ {
+ input: "first1.last1@example.net,first2.last2@example.net,first3.last2@example.net",
+ expected: "first1.last1@example.net, first2.last2@example.net, first3.last2@example.net",
+ },
+ {
+ input: "first1.last1@example.net, first2.last2@example.net, first3.last2@example.net",
+ expected: "first1.last1@example.net, first2.last2@example.net, first3.last2@example.net",
+ },
+ {
+ input:
+ 'first1.last1@example.net, first2 last2 <first2.last2@example.net>, "last3, first' +
+ '3" <first3.last2@example.net>',
+ expected:
+ 'first1.last1@example.net, first2 last2 <first2.last2@example.net>, "last3, fi' +
+ 'rst3" <first3.last2@example.net>',
+ },
+ {
+ input:
+ 'first1.last1@example.net, last2; first2 <first2.last2@example.net>, "last3; first' +
+ '3" <first3.last2@example.net>',
+ expected:
+ 'first1.last1@example.net, "last2; first2" <first2.last2@example.net>, "last' +
+ '3; first3" <first3.last2@example.net>',
+ },
+ {
+ input:
+ "first1 last2 <first1.last1@example.net>, last2, first2 <first2.last2@example.net>" +
+ ', "last3, first3" <first3.last2@example.net>',
+ expected:
+ 'first1 last2 <first1.last1@example.net>, "last2, first2" <first2.last2@examp' +
+ 'le.net>, "last3, first3" <first3.last2@example.net>',
+ },
+ ];
+
+ for (let [i, test] of Object.entries(data)) {
+ equal(cal.email.validateRecipientList(test.input), test.expected, "(test #" + i + ")");
+ }
+}
+
+function test_attendeeMatchesAddresses() {
+ let a = new CalAttendee("ATTENDEE:mailto:horst");
+ ok(cal.email.attendeeMatchesAddresses(a, ["HORST", "peter"]));
+ ok(!cal.email.attendeeMatchesAddresses(a, ["HORSTpeter", "peter"]));
+ ok(!cal.email.attendeeMatchesAddresses(a, ["peter"]));
+
+ a = new CalAttendee('ATTENDEE;EMAIL="horst":urn:uuid:horst');
+ ok(cal.email.attendeeMatchesAddresses(a, ["HORST", "peter"]));
+ ok(!cal.email.attendeeMatchesAddresses(a, ["HORSTpeter", "peter"]));
+ ok(!cal.email.attendeeMatchesAddresses(a, ["peter"]));
+}
diff --git a/comm/calendar/test/unit/test_extract.js b/comm/calendar/test/unit/test_extract.js
new file mode 100644
index 0000000000..96b1729f5c
--- /dev/null
+++ b/comm/calendar/test/unit/test_extract.js
@@ -0,0 +1,225 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This test works with code that is not timezone-aware.
+/* eslint-disable no-restricted-syntax */
+
+var { Extractor } = ChromeUtils.import("resource:///modules/calendar/calExtract.jsm");
+
+var extractor = new Extractor("en-US", 8);
+
+function run_test() {
+ // Sanity check to make sure the base url is still right. If this fails,
+ // don't forget to also fix the url in base/content/calendar-extract.js.
+ ok(extractor.checkBundle("en-US"));
+
+ test_event_start_end();
+ test_event_start_duration();
+ test_event_start_end_whitespace();
+ test_event_without_date();
+ test_event_next_year();
+ test_task_due();
+ test_overrides();
+ test_event_start_dollar_sign();
+}
+
+function test_event_start_end() {
+ let date = new Date(2012, 9, 1, 9, 0);
+ let title = "Wednesday meetup";
+ let content = "We'll meet at 2 pm and discuss until 3 pm.";
+
+ extractor.extract(title, content, date, undefined);
+ let guessed = extractor.guessStart();
+ let endGuess = extractor.guessEnd(guessed);
+
+ equal(guessed.year, 2012);
+ equal(guessed.month, 10);
+ equal(guessed.day, 3);
+ equal(guessed.hour, 14);
+ equal(guessed.minute, 0);
+
+ equal(endGuess.year, 2012);
+ equal(endGuess.month, 10);
+ equal(endGuess.day, 3);
+ equal(endGuess.hour, 15);
+ equal(endGuess.minute, 0);
+}
+
+function test_event_start_duration() {
+ let date = new Date(2012, 9, 1, 9, 0);
+ let title = "Wednesday meetup";
+ let content = "We'll meet at 2 pm and discuss for 30 minutes.";
+
+ extractor.extract(title, content, date, undefined);
+ let guessed = extractor.guessStart();
+ let endGuess = extractor.guessEnd(guessed);
+
+ equal(guessed.year, 2012);
+ equal(guessed.month, 10);
+ equal(guessed.day, 3);
+ equal(guessed.hour, 14);
+ equal(guessed.minute, 0);
+
+ equal(endGuess.year, 2012);
+ equal(endGuess.month, 10);
+ equal(endGuess.day, 3);
+ equal(endGuess.hour, 14);
+ equal(endGuess.minute, 30);
+}
+
+function test_event_start_end_whitespace() {
+ let date = new Date(2012, 9, 1, 9, 0);
+ let title = "Wednesday meetup";
+ let content = "We'll meet at2pm and discuss until\r\n3pm.";
+
+ extractor.extract(title, content, date, undefined);
+ let guessed = extractor.guessStart();
+ let endGuess = extractor.guessEnd(guessed);
+
+ equal(guessed.year, 2012);
+ equal(guessed.month, 10);
+ equal(guessed.day, 3);
+ equal(guessed.hour, 14);
+ equal(guessed.minute, 0);
+
+ equal(endGuess.year, 2012);
+ equal(endGuess.month, 10);
+ equal(endGuess.day, 3);
+ equal(endGuess.hour, 15);
+ equal(endGuess.minute, 0);
+}
+
+function test_event_without_date() {
+ let date = new Date(2012, 9, 1, 9, 0);
+ let title = "Meetup";
+ let content = "We'll meet at 2 pm and discuss until 3 pm.";
+
+ extractor.extract(title, content, date, undefined);
+ let guessed = extractor.guessStart();
+ let endGuess = extractor.guessEnd(guessed);
+
+ equal(guessed.year, 2012);
+ equal(guessed.month, 10);
+ equal(guessed.day, 1);
+ equal(guessed.hour, 14);
+ equal(guessed.minute, 0);
+
+ equal(endGuess.year, 2012);
+ equal(endGuess.month, 10);
+ equal(endGuess.day, 1);
+ equal(endGuess.hour, 15);
+ equal(endGuess.minute, 0);
+}
+
+function test_event_next_year() {
+ let date = new Date(2012, 9, 1, 9, 0);
+ let title = "Open day";
+ let content = "FYI: Next open day is planned for February 5th.";
+
+ extractor.extract(title, content, date, undefined);
+ let guessed = extractor.guessStart();
+ let endGuess = extractor.guessEnd(guessed);
+
+ equal(guessed.year, 2013);
+ equal(guessed.month, 2);
+ equal(guessed.day, 5);
+ equal(guessed.hour, undefined);
+ equal(guessed.minute, undefined);
+
+ equal(endGuess.year, undefined);
+ equal(endGuess.month, undefined);
+ equal(endGuess.day, undefined);
+ equal(endGuess.hour, undefined);
+ equal(endGuess.minute, undefined);
+}
+
+function test_task_due() {
+ let date = new Date(2012, 9, 1, 9, 0);
+ let title = "Assignment deadline";
+ let content = "This is a reminder that all assignments must be sent in by October 5th!.";
+
+ extractor.extract(title, content, date, undefined);
+ let guessed = extractor.guessStart(true);
+ let endGuess = extractor.guessEnd(guessed, true);
+
+ equal(guessed.year, 2012);
+ equal(guessed.month, 10);
+ equal(guessed.day, 1);
+ equal(guessed.hour, 9);
+ equal(guessed.minute, 0);
+
+ equal(endGuess.year, 2012);
+ equal(endGuess.month, 10);
+ equal(endGuess.day, 5);
+ equal(endGuess.hour, 0);
+ equal(endGuess.minute, 0);
+}
+
+function test_overrides() {
+ let date = new Date(2012, 9, 1, 9, 0);
+ let title = "Event invitation";
+ let content = "We'll meet 10:11 worromot";
+
+ extractor.extract(title, content, date, undefined);
+ let guessed = extractor.guessStart(false);
+ let endGuess = extractor.guessEnd(guessed, true);
+
+ equal(guessed.year, 2012);
+ equal(guessed.month, 10);
+ equal(guessed.day, 1);
+ equal(guessed.hour, 10);
+ equal(guessed.minute, 11);
+
+ equal(endGuess.year, undefined);
+ equal(endGuess.month, undefined);
+ equal(endGuess.day, undefined);
+ equal(endGuess.hour, undefined);
+ equal(endGuess.minute, undefined);
+
+ // recognize a custom "tomorrow" and hour.minutes pattern
+ let overrides = {
+ "from.hour.minutes": { add: "#2:#1", remove: "#1:#2" },
+ "from.tomorrow": { add: "worromot" },
+ };
+
+ Services.prefs.setStringPref("calendar.patterns.override", JSON.stringify(overrides));
+
+ extractor.extract(title, content, date, undefined);
+ guessed = extractor.guessStart(false);
+ endGuess = extractor.guessEnd(guessed, true);
+
+ equal(guessed.year, 2012);
+ equal(guessed.month, 10);
+ equal(guessed.day, 2);
+ equal(guessed.hour, 11);
+ equal(guessed.minute, 10);
+
+ equal(endGuess.year, undefined);
+ equal(endGuess.month, undefined);
+ equal(endGuess.day, undefined);
+ equal(endGuess.hour, undefined);
+ equal(endGuess.minute, undefined);
+}
+
+function test_event_start_dollar_sign() {
+ let date = new Date(2012, 9, 1, 9, 0);
+ let title = "Wednesday sale";
+ let content = "Sale starts at 3 pm and prices start at 2$.";
+
+ extractor.extract(title, content, date, undefined);
+ let guessed = extractor.guessStart();
+ let endGuess = extractor.guessEnd(guessed);
+
+ equal(guessed.year, 2012);
+ equal(guessed.month, 10);
+ equal(guessed.day, 3);
+ equal(guessed.hour, 15);
+ equal(guessed.minute, 0);
+
+ equal(endGuess.year, undefined);
+ equal(endGuess.month, undefined);
+ equal(endGuess.day, undefined);
+ equal(endGuess.hour, undefined);
+ equal(endGuess.minute, undefined);
+}
diff --git a/comm/calendar/test/unit/test_extract_parser.js b/comm/calendar/test/unit/test_extract_parser.js
new file mode 100644
index 0000000000..d1b0f48b80
--- /dev/null
+++ b/comm/calendar/test/unit/test_extract_parser.js
@@ -0,0 +1,160 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the CalExtractParser module.
+ */
+var { CalExtractParseNode, extendParseRule, prepareArguments } = ChromeUtils.import(
+ "resource:///modules/calendar/extract/CalExtractParser.jsm"
+);
+
+/**
+ * Tests to ensure extendParseRule() expands parse rules as we desire.
+ */
+add_task(function testExtendParseRule() {
+ let action = () => {};
+
+ let tests = [
+ {
+ name: "parse rules are expanded correctly",
+ input: {
+ name: "text",
+ patterns: ["TEXT"],
+ action,
+ },
+ expected: {
+ name: "text",
+ patterns: ["TEXT"],
+ action,
+ flags: [0],
+ graph: {
+ symbol: null,
+ flags: null,
+ descendants: [
+ {
+ symbol: "TEXT",
+ flags: 0,
+ descendants: [],
+ },
+ ],
+ },
+ },
+ },
+ {
+ name: "flags are detected correctly",
+ input: {
+ name: "text",
+ patterns: ["CHAR+", "TEXT?", "characters*"],
+ action,
+ },
+ expected: {
+ name: "text",
+ action,
+ patterns: ["CHAR", "TEXT", "characters"],
+ flags: [
+ CalExtractParseNode.FLAG_NONEMPTY | CalExtractParseNode.FLAG_MULTIPLE,
+ CalExtractParseNode.FLAG_OPTIONAL,
+ CalExtractParseNode.FLAG_OPTIONAL | CalExtractParseNode.FLAG_MULTIPLE,
+ ],
+ graph: {
+ symbol: null,
+ flags: null,
+ descendants: [
+ {
+ symbol: "CHAR",
+ flags: CalExtractParseNode.FLAG_NONEMPTY | CalExtractParseNode.FLAG_MULTIPLE,
+ descendants: [
+ {
+ symbol: "CHAR",
+ },
+ {
+ symbol: "TEXT",
+ flags: CalExtractParseNode.FLAG_OPTIONAL,
+ descendants: [
+ {
+ symbol: "characters",
+ flags: CalExtractParseNode.FLAG_OPTIONAL | CalExtractParseNode.FLAG_MULTIPLE,
+ descendants: [
+ {
+ symbol: "characters",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+ ];
+
+ for (let test of tests) {
+ info(`Test extendParseRule(): ${test.name}`);
+ compareExtractResults(extendParseRule(test.input), test.expected);
+ }
+});
+
+/**
+ * Tests prepareArguments() gives the correct arguments.
+ */
+add_task(function testReconcileArguments() {
+ let tests = [
+ {
+ name: "patterns without no flags bits are untouched",
+ rule: {
+ name: "text",
+ patterns: ["CHAR", "TEXT", "characters"],
+ flags: [0, 0, 0],
+ },
+ matched: [
+ ["CHAR", "is char"],
+ ["TEXT", "is text"],
+ ["characters", "is characters"],
+ ],
+ expected: ["is char", "is text", "is characters"],
+ },
+ {
+ name: "multi patterns are turned into arrays",
+ rule: {
+ name: "text",
+ patterns: ["CHAR", "TEXT", "characters"],
+ flags: [
+ CalExtractParseNode.FLAG_NONEMPTY | CalExtractParseNode.FLAG_MULTIPLE,
+ CalExtractParseNode.FLAG_OPTIONAL,
+ CalExtractParseNode.FLAG_OPTIONAL | CalExtractParseNode.FLAG_MULTIPLE,
+ ],
+ },
+ matched: [
+ ["CHAR", "is char"],
+ ["TEXT", "is text"],
+ ["characters", "is characters"],
+ ],
+ expected: [["is char"], "is text", ["is characters"]],
+ },
+ {
+ name: "unmatched optional patterns are null",
+ rule: {
+ name: "text",
+ patterns: ["CHAR", "TEXT", "characters"],
+ flags: [
+ CalExtractParseNode.FLAG_NONEMPTY | CalExtractParseNode.FLAG_MULTIPLE,
+ CalExtractParseNode.FLAG_OPTIONAL,
+ CalExtractParseNode.FLAG_OPTIONAL | CalExtractParseNode.FLAG_MULTIPLE,
+ ],
+ },
+ matched: [
+ ["CHAR", "is char"],
+ ["characters", "is characters"],
+ ],
+ expected: [["is char"], null, ["is characters"]],
+ },
+ ];
+
+ for (let test of tests) {
+ info(`Test prepareArguments(): ${test.name}`);
+ compareExtractResults(prepareArguments(test.rule, test.matched), test.expected);
+ }
+});
diff --git a/comm/calendar/test/unit/test_extract_parser_parse.js b/comm/calendar/test/unit/test_extract_parser_parse.js
new file mode 100644
index 0000000000..ac029c4303
--- /dev/null
+++ b/comm/calendar/test/unit/test_extract_parser_parse.js
@@ -0,0 +1,1317 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the CalExtractParser module.
+ */
+var { CalExtractParser } = ChromeUtils.import(
+ "resource:///modules/calendar/extract/CalExtractParser.jsm"
+);
+
+/**
+ * Tests parsing an empty string produces an empty lit.
+ */
+add_task(function testParseEmptyString() {
+ let parser = new CalExtractParser();
+ let result = parser.parse("");
+ Assert.equal(result.length, 0, "parsing empty string produces empty list");
+});
+
+/**
+ * Tests parsing with various non-flag rules works as expected.
+ */
+add_task(function testParseText() {
+ let parser = CalExtractParser.createInstance(
+ [
+ [/^your/i, "YOUR"],
+ [/^(appointment|meeting|booking)/i, "EVENT"],
+ [/^(at|@)/i, "AT"],
+ [/^on/i, "ON"],
+ [/^\d\d-\d\d-\d\d\d\d/, "DATE"],
+ [/^\d\d\d\d-\d\d-\d\d/, "ISODATE"],
+ [/^(was|is|has been| will be)/i, "BE"],
+ [/^(confirmed|booked|saved|created)/i, "CONFIRM"],
+ [/^[A-Z][A-Za-z0-9]+/, "NOUN"],
+ [/^,/],
+ [/^\S+/, "TEXT"],
+ [/^\s+/],
+ ],
+ [
+ {
+ name: "event",
+ patterns: ["text", "yourevent", "location", "BE", "CONFIRM"],
+ action: ([, title, location]) => ({
+ type: "event",
+ title,
+ location,
+ }),
+ },
+ {
+ name: "event",
+ patterns: ["yourevent", "location", "BE", "CONFIRM"],
+ action: ([title, location]) => ({
+ type: "event",
+ title,
+ location,
+ }),
+ },
+ {
+ name: "event",
+ patterns: ["yourevent", "ON", "date", "BE", "CONFIRM"],
+ action: ([title, , date]) => ({
+ type: "event",
+ title,
+ date,
+ }),
+ },
+ {
+ name: "event",
+ patterns: ["yourthing", "ON", "date", "BE", "CONFIRM"],
+ action: ([title, , date]) => ({
+ type: "event",
+ title,
+ date,
+ }),
+ },
+ {
+ name: "date",
+ patterns: ["DATE"],
+ action: ([value]) => ({
+ type: "date",
+ value,
+ }),
+ },
+ {
+ name: "date",
+ patterns: ["ISODATE"],
+ action: ([value]) => ({
+ type: "date",
+ value,
+ }),
+ },
+ {
+ name: "yourevent",
+ patterns: ["yourthing", "EVENT"],
+ action: ([value]) => value,
+ },
+ {
+ name: "yourthing",
+ patterns: ["YOUR", "text"],
+ action: ([, value]) => value,
+ },
+ {
+ name: "location",
+ patterns: ["AT", "text"],
+ action: ([, value]) => ({
+ type: "location",
+ value,
+ }),
+ },
+ {
+ name: "text",
+ patterns: ["TEXT"],
+ action: ([value]) => value,
+ },
+ {
+ name: "text",
+ patterns: ["NOUN"],
+ action: ([value]) => value,
+ },
+ ]
+ );
+
+ let tests = [
+ {
+ input: "Hello, your banking appointment at RealBank is booked!",
+ expected: [
+ {
+ type: "event",
+ title: {
+ type: "TEXT",
+ text: "banking",
+ sentence: 0,
+ position: 12,
+ },
+ location: {
+ type: "location",
+ value: {
+ type: "NOUN",
+ text: "RealBank",
+ sentence: 0,
+ position: 35,
+ },
+ },
+ },
+ ],
+ },
+ {
+ input: "your banking appointment at RealBank is booked!",
+ expected: [
+ {
+ type: "event",
+ title: {
+ type: "TEXT",
+ text: "banking",
+ sentence: 0,
+ position: 5,
+ },
+ location: {
+ type: "location",
+ value: {
+ type: "NOUN",
+ text: "RealBank",
+ sentence: 0,
+ position: 28,
+ },
+ },
+ },
+ ],
+ },
+ {
+ input: "Your Arraignment on 09-09-2021 is confirmed!",
+ expected: [
+ {
+ type: "event",
+ title: {
+ type: "NOUN",
+ text: "Arraignment",
+ sentence: 0,
+ position: 5,
+ },
+ date: {
+ type: "date",
+ value: {
+ type: "DATE",
+ text: "09-09-2021",
+ sentence: 0,
+ position: 20,
+ },
+ },
+ },
+ ],
+ },
+ ];
+
+ for (let test of tests) {
+ info(`Parsing string "${test.input}"...`);
+ let result = parser.parse(test.input);
+ Assert.equal(
+ result.length,
+ test.expected.length,
+ `parsing "${test.input}" resulted in ${test.expected.length} sentences`
+ );
+ info(`Comparing parse results for string "${test.input}"...`);
+ compareExtractResults(result, test.expected, "result");
+ }
+});
+
+/**
+ * Tests parsing unknown text produces a null result for the sentence.
+ */
+add_task(function testParseUnknownText() {
+ let parser = CalExtractParser.createInstance(
+ [
+ [/^No/, "NO"],
+ [/^rules/, "RULES"],
+ [/^for/, "FOR"],
+ [/^this/, "THIS"],
+ [/^Or/, "OR"],
+ [/^even/, "EVEN"],
+ [/^\s+/, "SPACE"],
+ ],
+ [
+ {
+ name: "statement",
+ patterns: ["NO", "SPACE", "RULES", "SPACE", "FOR", "SPACE", "THIS"],
+ action: () => "statement",
+ },
+ {
+ name: "statement",
+ patterns: ["OR", "SPACE", "THIS"],
+ action: () => "statement",
+ },
+ ]
+ );
+
+ let result = parser.parse("No rules for this. Or this. Or even this!");
+ Assert.equal(result.length, 3, "result has 3 sentences");
+ Assert.equal(result[0], "statement", "first sentence parsed properly");
+ Assert.equal(result[1], "statement", "second sentence parsed properly");
+ Assert.equal(result[2], null, "third sentence was not parsed properly");
+});
+
+/**
+ * Tests parsing without any parse rules produces a null result for each
+ * sentence.
+ */
+add_task(function testParseWithoutParseRules() {
+ let parser = CalExtractParser.createInstance(
+ [
+ [/^[A-Za-z]+/, "TEXT"],
+ [/^\s+/, "SPACE"],
+ ],
+ []
+ );
+ let result = parser.parse("No rules for this. Or this. Or event this!");
+ Assert.equal(result.length, 3, "result has 3 parsed sentences");
+ Assert.ok(
+ result.every(val => val == null),
+ "all parsed results are null"
+ );
+});
+
+/**
+ * Tests parsing using the "+" flag in various scenarios.
+ */
+add_task(function testParseWithPlusFlags() {
+ let parser = CalExtractParser.createInstance(
+ [
+ [/^we\b/i, "WE"],
+ [/^meet\b/i, "MEET"],
+ [/^at\b/i, "AT"],
+ [/^\d/, "NUMBER"],
+ [/^\S+/, "TEXT"],
+ [/^\s+/],
+ ],
+ [
+ {
+ name: "result",
+ patterns: ["subject", "text+", "meet", "time"],
+ action: ([subject, text, , time]) => ({
+ type: "result0",
+ subject,
+ text,
+ time,
+ }),
+ },
+ {
+ name: "result",
+ patterns: ["meet", "time", "text+"],
+ action: ([, time, text]) => ({
+ type: "result1",
+ time,
+ text,
+ }),
+ },
+ {
+ name: "result",
+ patterns: ["text+", "meet", "time"],
+ action: ([text, , time]) => ({
+ type: "result2",
+ time,
+ text,
+ }),
+ },
+ {
+ name: "subject",
+ patterns: ["WE"],
+ action: ([subject]) => ({
+ type: "subject",
+ subject,
+ }),
+ },
+ {
+ name: "meet",
+ patterns: ["MEET", "AT"],
+ action: ([meet, at]) => ({
+ type: "meet",
+ meet,
+ at,
+ }),
+ },
+ {
+ name: "time",
+ patterns: ["NUMBER"],
+ action: ([value]) => ({
+ type: "time",
+ value,
+ }),
+ },
+ {
+ name: "text",
+ patterns: ["TEXT"],
+ action: ([value]) => value,
+ },
+ ]
+ );
+
+ let tests = [
+ {
+ name: "using '+' flag can capture one pattern",
+ input: "We will meet at 7",
+ expected: [
+ {
+ type: "result0",
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ text: [
+ {
+ type: "TEXT",
+ text: "will",
+ sentence: 0,
+ position: 3,
+ },
+ ],
+ time: {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 16,
+ },
+ },
+ },
+ ],
+ },
+ {
+ name: "using the '+' flag can capture multiple patterns",
+ input: "We are coming to meet at 7",
+ expected: [
+ {
+ type: "result0",
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ text: [
+ {
+ type: "TEXT",
+ text: "are",
+ sentence: 0,
+ position: 3,
+ },
+ {
+ type: "TEXT",
+ text: "coming",
+ sentence: 0,
+ position: 7,
+ },
+ {
+ type: "TEXT",
+ text: "to",
+ sentence: 0,
+ position: 14,
+ },
+ ],
+ time: {
+ type: "time",
+ value: { type: "NUMBER", text: "7", sentence: 0, position: 25 },
+ },
+ },
+ ],
+ },
+ {
+ name: "using '+' fails if its pattern is unmatched",
+ input: "We meet at 7",
+ expected: [null],
+ },
+ {
+ name: "'+' can be used in the first position",
+ input: "Well do not meet at 7",
+ expected: [
+ {
+ type: "result2",
+ time: {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 20,
+ },
+ },
+ text: [
+ {
+ type: "TEXT",
+ text: "Well",
+ sentence: 0,
+ position: 0,
+ },
+ {
+ type: "TEXT",
+ text: "do",
+ sentence: 0,
+ position: 5,
+ },
+ {
+ type: "TEXT",
+ text: "not",
+ sentence: 0,
+ position: 8,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ name: "'+' can be used in the last position",
+ input: "Meet at 7 is the plan",
+ expected: [
+ {
+ type: "result1",
+ time: {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 8,
+ },
+ },
+ text: [
+ {
+ type: "TEXT",
+ text: "is",
+ sentence: 0,
+ position: 10,
+ },
+ {
+ type: "TEXT",
+ text: "the",
+ sentence: 0,
+ position: 13,
+ },
+ {
+ type: "TEXT",
+ text: "plan",
+ sentence: 0,
+ position: 17,
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ for (let test of tests) {
+ info(`Running test: ${test.name}`);
+ let result = parser.parse(test.input);
+ Assert.equal(
+ result.length,
+ test.expected.length,
+ `parsing "${test.input}" resulted in ${test.expected.length} sentences`
+ );
+ info(`Comparing parse results for string "${test.input}"...`);
+ compareExtractResults(result, test.expected, "result");
+ }
+});
+
+/**
+ * Tests parsing using the "*" flag in various scenarios.
+ */
+add_task(function testParseWithStarFlags() {
+ let parser = CalExtractParser.createInstance(
+ [
+ [/^we\b/i, "WE"],
+ [/^meet\b/i, "MEET"],
+ [/^at\b/i, "AT"],
+ [/^\d/, "NUMBER"],
+ [/^\S+/, "TEXT"],
+ [/^\s+/],
+ ],
+ [
+ {
+ name: "result",
+ patterns: ["subject", "text*", "meet", "time"],
+ action: ([subject, text, , time]) => ({
+ type: "result0",
+ subject,
+ text,
+ time,
+ }),
+ },
+ {
+ name: "result",
+ patterns: ["meet", "time", "text*"],
+ action: ([, time, text]) => ({
+ type: "result1",
+ time,
+ text,
+ }),
+ },
+ {
+ name: "result",
+ patterns: ["text*", "subject", "text", "meet", "time"],
+ action: ([text, subject, , time]) => ({
+ type: "result2",
+ text,
+ subject,
+ time,
+ }),
+ },
+ {
+ name: "subject",
+ patterns: ["WE"],
+ action: ([subject]) => ({
+ type: "subject",
+ subject,
+ }),
+ },
+ {
+ name: "meet",
+ patterns: ["MEET", "AT"],
+ action: ([meet, at]) => ({
+ type: "meet",
+ meet,
+ at,
+ }),
+ },
+ {
+ name: "time",
+ patterns: ["NUMBER"],
+ action: ([value]) => ({
+ type: "time",
+ value,
+ }),
+ },
+ {
+ name: "text",
+ patterns: ["TEXT"],
+ action: ([value]) => value,
+ },
+ ]
+ );
+
+ let tests = [
+ {
+ name: "using '*' flag can capture one pattern",
+ input: "We will meet at 7",
+ expected: [
+ {
+ type: "result0",
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ text: [
+ {
+ type: "TEXT",
+ text: "will",
+ sentence: 0,
+ position: 3,
+ },
+ ],
+ time: {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 16,
+ },
+ },
+ },
+ ],
+ },
+ {
+ name: "using the '*' flag can capture multiple patterns",
+ input: "We are coming to meet at 7",
+ expected: [
+ {
+ type: "result0",
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ text: [
+ {
+ type: "TEXT",
+ text: "are",
+ sentence: 0,
+ position: 3,
+ },
+ {
+ type: "TEXT",
+ text: "coming",
+ sentence: 0,
+ position: 7,
+ },
+ {
+ type: "TEXT",
+ text: "to",
+ sentence: 0,
+ position: 14,
+ },
+ ],
+ time: {
+ type: "time",
+ value: { type: "NUMBER", text: "7", sentence: 0, position: 25 },
+ },
+ },
+ ],
+ },
+ {
+ name: "'*' capture is optional",
+ input: "We meet at 7",
+ expected: [
+ {
+ type: "result0",
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ text: [],
+ time: {
+ type: "time",
+ value: { type: "NUMBER", text: "7", sentence: 0, position: 11 },
+ },
+ },
+ ],
+ },
+ {
+ name: "'*' can be used in the first position",
+ input: "To think we will meet at 7",
+ expected: [
+ {
+ type: "result2",
+ text: [
+ {
+ type: "TEXT",
+ text: "To",
+ sentence: 0,
+ position: 0,
+ },
+ {
+ type: "TEXT",
+ text: "think",
+ sentence: 0,
+ position: 3,
+ },
+ ],
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "we",
+ sentence: 0,
+ position: 9,
+ },
+ },
+ time: {
+ type: "meet",
+ meet: {
+ type: "MEET",
+ text: "meet",
+ sentence: 0,
+ position: 17,
+ },
+ at: {
+ type: "AT",
+ text: "at",
+ sentence: 0,
+ position: 22,
+ },
+ },
+ },
+ ],
+ },
+ {
+ name: "'*' can be used in the last position",
+ input: "Meet at 7 is the plan",
+ expected: [
+ {
+ type: "result1",
+ time: {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 8,
+ },
+ },
+ text: [
+ {
+ type: "TEXT",
+ text: "is",
+ sentence: 0,
+ position: 10,
+ },
+ {
+ type: "TEXT",
+ text: "the",
+ sentence: 0,
+ position: 13,
+ },
+ {
+ type: "TEXT",
+ text: "plan",
+ sentence: 0,
+ position: 17,
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ for (let test of tests) {
+ info(`Running test: ${test.name}`);
+ let result = parser.parse(test.input);
+ Assert.equal(
+ result.length,
+ test.expected.length,
+ `parsing "${test.input}" resulted in ${test.expected.length} sentences`
+ );
+ info(`Comparing parse results for string "${test.input}"...`);
+ compareExtractResults(result, test.expected, "result");
+ }
+});
+
+/**
+ * Tests parsing using the "?" flag in various scenarios.
+ */
+add_task(function testParseWithOptionalFlags() {
+ let parser = CalExtractParser.createInstance(
+ [
+ [/^we\b/i, "WE"],
+ [/^meet\b/i, "MEET"],
+ [/^at\b/i, "AT"],
+ [/^\d/, "NUMBER"],
+ [/^\S+/, "TEXT"],
+ [/^\s+/],
+ ],
+ [
+ {
+ name: "result",
+ patterns: ["subject", "text?", "meet", "time"],
+ action: ([subject, text, , time]) => ({
+ type: "result0",
+ subject,
+ text,
+ time,
+ }),
+ },
+ {
+ name: "result",
+ patterns: ["meet", "time", "text?"],
+ action: ([, time, text]) => ({
+ type: "result1",
+ time,
+ text,
+ }),
+ },
+ {
+ name: "result",
+ patterns: ["text?", "subject", "text", "meet", "time"],
+ action: ([text, subject, , time]) => ({
+ type: "result2",
+ text,
+ subject,
+ time,
+ }),
+ },
+ {
+ name: "subject",
+ patterns: ["WE"],
+ action: ([subject]) => ({
+ type: "subject",
+ subject,
+ }),
+ },
+ {
+ name: "meet",
+ patterns: ["MEET", "AT"],
+ action: ([meet, at]) => ({
+ type: "meet",
+ meet,
+ at,
+ }),
+ },
+ {
+ name: "time",
+ patterns: ["NUMBER"],
+ action: ([value]) => ({
+ type: "time",
+ value,
+ }),
+ },
+ {
+ name: "text",
+ patterns: ["TEXT"],
+ action: ([value]) => value,
+ },
+ ]
+ );
+
+ let tests = [
+ {
+ name: "using '?' flag can capture one pattern",
+ input: "We will meet at 7",
+ expected: [
+ {
+ type: "result0",
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ text: {
+ type: "TEXT",
+ text: "will",
+ sentence: 0,
+ position: 3,
+ },
+
+ time: {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 16,
+ },
+ },
+ },
+ ],
+ },
+ {
+ name: "'?' capture is optional",
+ input: "We meet at 7",
+ expected: [
+ {
+ type: "result0",
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ text: null,
+ time: {
+ type: "time",
+ value: { type: "NUMBER", text: "7", sentence: 0, position: 11 },
+ },
+ },
+ ],
+ },
+ {
+ name: "'?' can be used in the first position",
+ input: "Think we will meet at 7",
+ expected: [
+ {
+ type: "result2",
+ text: {
+ type: "TEXT",
+ text: "Think",
+ sentence: 0,
+ position: 0,
+ },
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "we",
+ sentence: 0,
+ position: 6,
+ },
+ },
+ time: {
+ type: "meet",
+ meet: {
+ type: "MEET",
+ text: "meet",
+ sentence: 0,
+ position: 14,
+ },
+ at: {
+ type: "AT",
+ text: "at",
+ sentence: 0,
+ position: 19,
+ },
+ },
+ },
+ ],
+ },
+ {
+ name: "'?' can be used in the last position",
+ input: "Meet at 7 please",
+ expected: [
+ {
+ type: "result1",
+ time: {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 8,
+ },
+ },
+ text: {
+ type: "TEXT",
+ text: "please",
+ sentence: 0,
+ position: 10,
+ },
+ },
+ ],
+ },
+ ];
+
+ for (let test of tests) {
+ info(`Running test: ${test.name}`);
+ let result = parser.parse(test.input);
+ Assert.equal(
+ result.length,
+ test.expected.length,
+ `parsing "${test.input}" resulted in ${test.expected.length} sentences`
+ );
+ info(`Comparing parse results for string "${test.input}"...`);
+ compareExtractResults(result, test.expected, "result");
+ }
+});
+
+/**
+ * Test the flags can be used together in the same rules.
+ */
+add_task(function testParseWithFlags() {
+ let tokens = [
+ [/^we\b/i, "WE"],
+ [/^meet\b/i, "MEET"],
+ [/^at\b/i, "AT"],
+ [/^\d/, "NUMBER"],
+ [/^\S+/, "TEXT"],
+ [/^\s+/],
+ ];
+
+ let patterns = [
+ {
+ name: "subject",
+ patterns: ["WE"],
+ action: ([subject]) => ({
+ type: "subject",
+ subject,
+ }),
+ },
+ {
+ name: "meet",
+ patterns: ["MEET", "AT"],
+ action: ([meet, at]) => ({
+ type: "meet",
+ meet,
+ at,
+ }),
+ },
+ {
+ name: "time",
+ patterns: ["NUMBER"],
+ action: ([value]) => ({
+ type: "time",
+ value,
+ }),
+ },
+ {
+ name: "text",
+ patterns: ["TEXT"],
+ action: ([value]) => value,
+ },
+ ];
+
+ let tests = [
+ {
+ patterns: ["subject?", "text*", "time+"],
+ variants: [
+ {
+ input: "We will 7",
+ expected: [
+ [
+ {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ [
+ {
+ type: "TEXT",
+ text: "will",
+ sentence: 0,
+ position: 3,
+ },
+ ],
+ [
+ {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 8,
+ },
+ },
+ ],
+ ],
+ ],
+ },
+ {
+ input: "7",
+ expected: [
+ [
+ null,
+ [],
+ [{ type: "time", value: { type: "NUMBER", text: "7", sentence: 0, position: 0 } }],
+ ],
+ ],
+ },
+ {
+ input: "we",
+ expected: [null],
+ },
+ {
+ input: "will 7",
+ expected: [
+ [
+ null,
+ [{ type: "TEXT", text: "will", sentence: 0, position: 0 }],
+ [{ type: "time", value: { type: "NUMBER", text: "7", sentence: 0, position: 5 } }],
+ ],
+ ],
+ },
+ {
+ input: "we 7",
+ expected: [
+ [
+ { type: "subject", subject: { type: "WE", text: "we", sentence: 0, position: 0 } },
+ [],
+ [{ type: "time", value: { type: "NUMBER", text: "7", sentence: 0, position: 3 } }],
+ ],
+ ],
+ },
+ ],
+ },
+ {
+ patterns: ["subject+", "text?", "time*"],
+ variants: [
+ {
+ input: "We will 7",
+ expected: [
+ [
+ [
+ {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ ],
+ {
+ type: "TEXT",
+ text: "will",
+ sentence: 0,
+ position: 3,
+ },
+
+ [
+ {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 8,
+ },
+ },
+ ],
+ ],
+ ],
+ },
+ {
+ input: "7",
+ expected: [null],
+ },
+ {
+ input: "will 7",
+ expected: [null],
+ },
+ {
+ input: "we 7",
+ expected: [
+ [
+ [
+ {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "we",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ ],
+ null,
+ [
+ {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 3,
+ },
+ },
+ ],
+ ],
+ ],
+ },
+ ],
+ },
+ {
+ patterns: ["subject*", "text+", "time?"],
+ variants: [
+ {
+ input: "We will 7",
+ expected: [
+ [
+ [
+ {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ ],
+ [
+ {
+ type: "TEXT",
+ text: "will",
+ sentence: 0,
+ position: 3,
+ },
+ ],
+ {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 8,
+ },
+ },
+ ],
+ ],
+ },
+ {
+ input: "will",
+ expected: [[[], [{ type: "TEXT", text: "will", sentence: 0, position: 0 }], null]],
+ },
+ {
+ input: "will 7",
+ expected: [
+ [
+ [],
+ [
+ {
+ type: "TEXT",
+ text: "will",
+ sentence: 0,
+ position: 0,
+ },
+ ],
+ {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 5,
+ },
+ },
+ ],
+ ],
+ },
+ {
+ input: "we will",
+ expected: [
+ [
+ [
+ {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "we",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ ],
+ [
+ {
+ type: "TEXT",
+ text: "will",
+ sentence: 0,
+ position: 3,
+ },
+ ],
+ null,
+ ],
+ ],
+ },
+ ],
+ },
+ ];
+
+ for (let test of tests) {
+ test = tests[2];
+ let rule = {
+ name: "result",
+ patterns: test.patterns,
+ action: args => args,
+ };
+ let parser = CalExtractParser.createInstance(tokens, [rule, ...patterns]);
+
+ for (let input of test.variants) {
+ input = test.variants[3];
+ info(`Testing pattern: ${test.patterns} with input "${input.input}".`);
+ let result = parser.parse(input.input);
+ info(`Comparing parse results for string "${input.input}"...`);
+ compareExtractResults(result, input.expected, "result");
+ }
+ }
+});
diff --git a/comm/calendar/test/unit/test_extract_parser_service.js b/comm/calendar/test/unit/test_extract_parser_service.js
new file mode 100644
index 0000000000..2118fad16b
--- /dev/null
+++ b/comm/calendar/test/unit/test_extract_parser_service.js
@@ -0,0 +1,96 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the CalExtractParserService. These are modified versions of the
+ * text_extract.js tests, for now.
+ */
+
+// This test works with code that is not timezone-aware.
+/* eslint-disable no-restricted-syntax */
+
+var { CalExtractParserService } = ChromeUtils.import(
+ "resource:///modules/calendar/extract/CalExtractParserService.jsm"
+);
+
+let service = new CalExtractParserService();
+
+/**
+ * Test the extraction of a start and end time using HOUR am/pm. Note: The
+ * service currently only selects event information from one sentence so the
+ * event title is not included here for now.
+ */
+add_task(function test_event_start_end() {
+ let now = new Date(2012, 9, 1, 9, 0);
+ let content = "We'll meet at 2 pm and discuss until 3 pm.";
+ let result = service.extract(content, {
+ now,
+ });
+
+ info(`Comparing extracted result for string "${content}"...`);
+ compareExtractResults(
+ result,
+ {
+ type: "event-guess",
+ startTime: {
+ type: "meridiem-time",
+ year: 2012,
+ month: 10,
+ day: 1,
+ hour: 14,
+ minute: 0,
+ meridiem: "pm",
+ },
+ endTime: {
+ type: "meridiem-time",
+ year: 2012,
+ month: 10,
+ day: 1,
+ hour: 15,
+ minute: 0,
+ meridiem: "pm",
+ },
+ priority: 0,
+ },
+ "result"
+ );
+});
+
+/**
+ * Test the extraction of a start and end time using a meridiem time for start
+ * and a duration for the end.
+ */
+add_task(function test_event_start_duration() {
+ let now = new Date(2012, 9, 1, 9, 0);
+ let content = "We'll meet at 2 pm and discuss for 30 minutes.";
+ let result = service.extract(content, {
+ now,
+ });
+ info(`Comparing extracted result for string "${content}"...`);
+ compareExtractResults(
+ result,
+ {
+ type: "event-guess",
+ startTime: {
+ type: "meridiem-time",
+ year: 2012,
+ month: 10,
+ day: 1,
+ hour: 14,
+ minute: 0,
+ meridiem: "pm",
+ },
+ endTime: {
+ type: "date-time",
+ year: 2012,
+ month: 10,
+ day: 1,
+ hour: 14,
+ minute: 30,
+ },
+ priority: 0,
+ },
+ "result"
+ );
+});
diff --git a/comm/calendar/test/unit/test_extract_parser_tokenize.js b/comm/calendar/test/unit/test_extract_parser_tokenize.js
new file mode 100644
index 0000000000..a77c72bcfc
--- /dev/null
+++ b/comm/calendar/test/unit/test_extract_parser_tokenize.js
@@ -0,0 +1,367 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the CalExtractParser module.
+ */
+var { CalExtractParser } = ChromeUtils.import(
+ "resource:///modules/calendar/extract/CalExtractParser.jsm"
+);
+
+/**
+ * Tests tokenizing an empty string gives an empty list.
+ */
+add_task(function testTokenizeEmptyString() {
+ let parser = new CalExtractParser();
+ let result = parser.tokenize("");
+ Assert.equal(result.length, 0, "tokenize empty string produces empty list");
+});
+
+/**
+ * Tests tokenisation works as expected.
+ */
+add_task(function testTokenizeWithRules() {
+ let parser = new CalExtractParser(
+ [
+ [/^(Monday|Tuesday|Wednesday)/, "DAY"],
+ [/^meet/, "MEET"],
+ [/^[A-Za-z]+/, "TEXT"],
+ [/^[0-9]+/, "NUMBER"],
+ [/^\s+/, "SPACE"],
+ [/^,/, "COMMA"],
+ ],
+ []
+ );
+
+ let text = `Hello there, can we meet on Monday? If not, then Tuesday. We can
+ also meet on Wednesday at 6`;
+
+ let expected = [
+ [
+ {
+ type: "TEXT",
+ text: "Hello",
+ sentence: 0,
+ position: 0,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 0,
+ position: 5,
+ },
+ {
+ type: "TEXT",
+ text: "there",
+ sentence: 0,
+ position: 6,
+ },
+ {
+ type: "COMMA",
+ text: ",",
+ sentence: 0,
+ position: 11,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 0,
+ position: 12,
+ },
+ {
+ type: "TEXT",
+ text: "can",
+ sentence: 0,
+ position: 13,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 0,
+ position: 16,
+ },
+ {
+ type: "TEXT",
+ text: "we",
+ sentence: 0,
+ position: 17,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 0,
+ position: 19,
+ },
+ {
+ type: "MEET",
+ text: "meet",
+ sentence: 0,
+ position: 20,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 0,
+ position: 24,
+ },
+ {
+ type: "TEXT",
+ text: "on",
+ sentence: 0,
+ position: 25,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 0,
+ position: 27,
+ },
+ {
+ type: "DAY",
+ text: "Monday",
+ sentence: 0,
+ position: 28,
+ },
+ ],
+ [
+ {
+ type: "TEXT",
+ text: "If",
+ sentence: 1,
+ position: 0,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 1,
+ position: 2,
+ },
+ {
+ type: "TEXT",
+ text: "not",
+ sentence: 1,
+ position: 3,
+ },
+ {
+ type: "COMMA",
+ text: ",",
+ sentence: 1,
+ position: 6,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 1,
+ position: 7,
+ },
+ {
+ type: "TEXT",
+ text: "then",
+ sentence: 1,
+ position: 8,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 1,
+ position: 12,
+ },
+ {
+ type: "DAY",
+ text: "Tuesday",
+ sentence: 1,
+ position: 13,
+ },
+ ],
+ [
+ {
+ type: "TEXT",
+ text: "We",
+ sentence: 2,
+ position: 0,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 2,
+ position: 2,
+ },
+ {
+ type: "TEXT",
+ text: "can",
+ sentence: 2,
+ position: 3,
+ },
+ {
+ type: "SPACE",
+ text: "\n ",
+ sentence: 2,
+ position: 6,
+ },
+ {
+ type: "TEXT",
+ text: "also",
+ sentence: 2,
+ position: 21,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 2,
+ position: 25,
+ },
+ {
+ type: "MEET",
+ text: "meet",
+ sentence: 2,
+ position: 26,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 2,
+ position: 30,
+ },
+ {
+ type: "TEXT",
+ text: "on",
+ sentence: 2,
+ position: 31,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 2,
+ position: 33,
+ },
+ {
+ type: "DAY",
+ text: "Wednesday",
+ sentence: 2,
+ position: 34,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 2,
+ position: 43,
+ },
+ {
+ type: "TEXT",
+ text: "at",
+ sentence: 2,
+ position: 44,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 2,
+ position: 46,
+ },
+ {
+ type: "NUMBER",
+ text: "6",
+ sentence: 2,
+ position: 47,
+ },
+ ],
+ ];
+
+ info(`Tokenizing string "${text}"...`);
+ let actual = parser.tokenize(text);
+ Assert.equal(actual.length, expected.length, `result has ${expected.length} sentences`);
+ info(`Comparing results of tokenizing "${text}"...`);
+ for (let i = 0; i < expected.length; i++) {
+ compareExtractResults(actual[i], expected[i], "result");
+ }
+});
+
+/**
+ * Tests tokenizing unknown text produces null.
+ */
+add_task(function testTokenizeUnknownText() {
+ let parser = new CalExtractParser([], []);
+ let result = parser.tokenize("text with no rules");
+ Assert.equal(result.length, 1, "tokenizing unknown text produced a result");
+ Assert.equal(result[0], null, "tokenizing unknown text produced a null result");
+});
+
+/**
+ * Tests omitting some token names omits them from the result.
+ */
+add_task(function testTokenRulesNamesOmitted() {
+ let parser = new CalExtractParser([
+ [/^Monday/, "DAY"],
+ [/^meet/, "MEET"],
+ [/^[A-Za-z]+/, "TEXT"],
+ [/^[0-9]+/, "NUMBER"],
+ [/^\s+/],
+ [/^,/],
+ ]);
+
+ let text = `Hello there, can we meet on Monday?`;
+ let expected = [
+ [
+ {
+ type: "TEXT",
+ text: "Hello",
+ sentence: 0,
+ position: 0,
+ },
+ {
+ type: "TEXT",
+ text: "there",
+ sentence: 0,
+ position: 6,
+ },
+ {
+ type: "TEXT",
+ text: "can",
+ sentence: 0,
+ position: 13,
+ },
+ {
+ type: "TEXT",
+ text: "we",
+ sentence: 0,
+ position: 17,
+ },
+ {
+ type: "MEET",
+ text: "meet",
+ sentence: 0,
+ position: 20,
+ },
+ {
+ type: "TEXT",
+ text: "on",
+ sentence: 0,
+ position: 25,
+ },
+ {
+ type: "DAY",
+ text: "Monday",
+ sentence: 0,
+ position: 28,
+ },
+ ],
+ ];
+
+ info(`Tokenizing string "${text}"...`);
+ let actual = parser.tokenize(text);
+ Assert.equal(actual.length, expected.length, `result has ${expected.length} sentences`);
+ info(`Comparing results of tokenizing string "${text}"..`);
+ for (let i = 0; i < expected.length; i++) {
+ compareExtractResults(actual[i], expected[i], "result");
+ }
+});
+
+/**
+ * Tests parsing an empty string produces an empty lit.
+ */
+add_task(function testParseEmptyString() {
+ let parser = new CalExtractParser();
+ let result = parser.parse("");
+ Assert.equal(result.length, 0, "parsing empty string produces empty list");
+});
diff --git a/comm/calendar/test/unit/test_filter.js b/comm/calendar/test/unit/test_filter.js
new file mode 100644
index 0000000000..3b76585bc6
--- /dev/null
+++ b/comm/calendar/test/unit/test_filter.js
@@ -0,0 +1,406 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+const { CalTodo } = ChromeUtils.import("resource:///modules/CalTodo.jsm");
+
+/* globals calFilter, CalReadableStreamFactory */
+Services.scriptloader.loadSubScript("chrome://calendar/content/widgets/calendar-filter.js");
+
+async function promiseItems(filter, calendar) {
+ return cal.iterate.streamToArray(filter.getItems(calendar));
+}
+
+add_task(() => new Promise(resolve => do_calendar_startup(resolve)));
+
+add_task(async function testDateRangeFilter() {
+ let calendar = CalendarTestUtils.createCalendar("test");
+
+ let testItems = {};
+ for (let [title, startDate, endDate] of [
+ ["before", "20210720", "20210721"],
+ ["during", "20210820", "20210821"],
+ ["after", "20210920", "20210921"],
+ ["overlaps_start", "20210720", "20210804"],
+ ["overlaps_end", "20210820", "20210904"],
+ ["overlaps_both", "20210720", "20210904"],
+ ]) {
+ let event = new CalEvent();
+ event.id = cal.getUUID();
+ event.title = title;
+ event.startDate = cal.createDateTime(startDate);
+ event.endDate = cal.createDateTime(endDate);
+ await calendar.addItem(event);
+ testItems[title] = event;
+ }
+
+ // Create a new filter.
+
+ let filter = new calFilter();
+ filter.startDate = cal.createDateTime("20210801");
+ filter.endDate = cal.createDateTime("20210831");
+
+ // Test dateRangeFilter.
+
+ Assert.ok(!filter.dateRangeFilter(testItems.before), "task doesn't pass date range filter");
+ Assert.ok(filter.dateRangeFilter(testItems.during), "task passes date range filter");
+ Assert.ok(!filter.dateRangeFilter(testItems.after), "task doesn't pass date range filter");
+ Assert.ok(filter.dateRangeFilter(testItems.overlaps_start), "task passes date range filter");
+ Assert.ok(filter.dateRangeFilter(testItems.overlaps_end), "task passes date range filter");
+ Assert.ok(filter.dateRangeFilter(testItems.overlaps_both), "task passes date range filter");
+
+ // Test isItemInFilters.
+
+ Assert.ok(!filter.isItemInFilters(testItems.before), "task doesn't pass all filters");
+ Assert.ok(filter.isItemInFilters(testItems.during), "task passes all filters");
+ Assert.ok(!filter.isItemInFilters(testItems.after), "task doesn't pass all filters");
+ Assert.ok(filter.isItemInFilters(testItems.overlaps_start), "task passes all filters");
+ Assert.ok(filter.isItemInFilters(testItems.overlaps_end), "task passes all filters");
+ Assert.ok(filter.isItemInFilters(testItems.overlaps_both), "task passes all filters");
+
+ // Test getItems.
+
+ let items = await promiseItems(filter, calendar);
+ Assert.equal(items.length, 4, "getItems returns expected number of items");
+ Assert.equal(items[0].title, "during", "correct item returned");
+ Assert.equal(items[1].title, "overlaps_start", "correct item returned");
+ Assert.equal(items[2].title, "overlaps_end", "correct item returned");
+ Assert.equal(items[3].title, "overlaps_both", "correct item returned");
+
+ // Change the date of the filter and test it all again.
+
+ filter.startDate = cal.createDateTime("20210825");
+ filter.endDate = cal.createDateTime("20210905");
+
+ // Test dateRangeFilter.
+
+ Assert.ok(!filter.dateRangeFilter(testItems.before), "task doesn't pass date range filter");
+ Assert.ok(!filter.dateRangeFilter(testItems.during), "task passes date range filter");
+ Assert.ok(!filter.dateRangeFilter(testItems.after), "task doesn't pass date range filter");
+ Assert.ok(!filter.dateRangeFilter(testItems.overlaps_start), "task passes date range filter");
+ Assert.ok(filter.dateRangeFilter(testItems.overlaps_end), "task passes date range filter");
+ Assert.ok(filter.dateRangeFilter(testItems.overlaps_both), "task passes date range filter");
+
+ // Test isItemInFilters.
+
+ Assert.ok(!filter.isItemInFilters(testItems.before), "task doesn't pass all filters");
+ Assert.ok(!filter.isItemInFilters(testItems.during), "task passes all filters");
+ Assert.ok(!filter.isItemInFilters(testItems.after), "task doesn't pass all filters");
+ Assert.ok(!filter.isItemInFilters(testItems.overlaps_start), "task passes all filters");
+ Assert.ok(filter.isItemInFilters(testItems.overlaps_end), "task passes all filters");
+ Assert.ok(filter.isItemInFilters(testItems.overlaps_both), "task passes all filters");
+
+ // Test getItems.
+
+ items = await promiseItems(filter, calendar);
+ Assert.equal(items.length, 2, "getItems returns expected number of items");
+ Assert.equal(items[0].title, "overlaps_end", "correct item returned");
+ Assert.equal(items[1].title, "overlaps_both", "correct item returned");
+});
+
+add_task(async function testItemTypeFilter() {
+ let calendar = CalendarTestUtils.createCalendar("test");
+
+ let event = new CalEvent();
+ event.id = cal.getUUID();
+ event.title = "New event";
+ event.startDate = cal.createDateTime("20210803T205500Z");
+ event.endDate = cal.createDateTime("20210803T210200Z");
+ await calendar.addItem(event);
+
+ let task = new CalTodo();
+ task.id = cal.getUUID();
+ task.title = "New task";
+ task.entryDate = cal.createDateTime("20210806T090000Z");
+ task.dueDate = cal.createDateTime("20210810T140000Z");
+ await calendar.addItem(task);
+
+ // Create a new filter.
+
+ let filter = new calFilter();
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_ALL;
+ filter.startDate = cal.createDateTime("20210801");
+ filter.endDate = cal.createDateTime("20210831");
+
+ // Check both item types pass ITEM_FILTER_TYPE_ALL.
+
+ Assert.ok(filter.itemTypeFilter(task), "task passes item type filter");
+ Assert.ok(filter.itemTypeFilter(event), "event passes item type filter");
+
+ Assert.ok(filter.isItemInFilters(task), "task passes all filters");
+ Assert.ok(filter.isItemInFilters(event), "event passes all filters");
+
+ let items = await promiseItems(filter, calendar);
+ Assert.equal(items.length, 2, "getItems returns expected number of items");
+ Assert.equal(items[0].title, "New event", "correct item returned");
+ Assert.equal(items[1].title, "New task", "correct item returned");
+
+ // Check only tasks pass ITEM_FILTER_TYPE_TODO.
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_TODO;
+
+ Assert.ok(filter.itemTypeFilter(task), "task passes item type filter");
+ Assert.ok(!filter.itemTypeFilter(event), "event doesn't pass item type filter");
+
+ Assert.ok(filter.isItemInFilters(task), "task passes all filters");
+ Assert.ok(!filter.isItemInFilters(event), "event doesn't pass all filters");
+
+ items = await promiseItems(filter, calendar);
+ Assert.equal(items.length, 1, "getItems returns expected number of items");
+ Assert.equal(items[0].title, "New task", "correct item returned");
+
+ // Check only events pass ITEM_FILTER_TYPE_EVENT.
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT;
+
+ Assert.ok(!filter.itemTypeFilter(task), "task doesn't pass item type filter");
+ Assert.ok(filter.itemTypeFilter(event), "event passes item type filter");
+
+ Assert.ok(!filter.isItemInFilters(task), "task doesn't pass all filters");
+ Assert.ok(filter.isItemInFilters(event), "event passes all filters");
+
+ items = await promiseItems(filter, calendar);
+ Assert.equal(items.length, 1, "getItems returns expected number of items");
+ Assert.equal(items[0].title, "New event", "correct item returned");
+
+ // Check neither tasks or events pass ITEM_FILTER_TYPE_JOURNAL.
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_JOURNAL;
+
+ Assert.ok(!filter.itemTypeFilter(event), "event doesn't pass item type filter");
+ Assert.ok(!filter.itemTypeFilter(task), "task doesn't pass item type filter");
+
+ Assert.ok(!filter.isItemInFilters(task), "task doesn't pass all filters");
+ Assert.ok(!filter.isItemInFilters(event), "event doesn't pass all filters");
+
+ items = await promiseItems(filter, calendar);
+ Assert.equal(items.length, 0, "getItems returns expected number of items");
+});
+
+add_task(async function testItemTypeFilterTaskCompletion() {
+ let calendar = CalendarTestUtils.createCalendar("test");
+
+ let completeTask = new CalTodo();
+ completeTask.id = cal.getUUID();
+ completeTask.title = "Complete Task";
+ completeTask.entryDate = cal.createDateTime("20210806T090000Z");
+ completeTask.dueDate = cal.createDateTime("20210810T140000Z");
+ completeTask.percentComplete = 100;
+ await calendar.addItem(completeTask);
+
+ let incompleteTask = new CalTodo();
+ incompleteTask.id = cal.getUUID();
+ incompleteTask.title = "Incomplete Task";
+ incompleteTask.entryDate = cal.createDateTime("20210806T090000Z");
+ incompleteTask.dueDate = cal.createDateTime("20210810T140000Z");
+ completeTask.completedDate = null;
+ await calendar.addItem(incompleteTask);
+
+ let filter = new calFilter();
+ filter.startDate = cal.createDateTime("20210801");
+ filter.endDate = cal.createDateTime("20210831");
+
+ let checks = [
+ { flags: Ci.calICalendar.ITEM_FILTER_TYPE_TODO, expectComplete: true, expectIncomplete: true },
+ {
+ flags: Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_YES,
+ expectComplete: true,
+ expectIncomplete: false,
+ },
+ {
+ flags: Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_NO,
+ expectComplete: false,
+ expectIncomplete: true,
+ },
+ {
+ flags: Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL,
+ expectComplete: true,
+ expectIncomplete: true,
+ },
+ ];
+
+ for (let { flags, expectComplete, expectIncomplete } of checks) {
+ info(`testing with flags = ${flags}`);
+ filter.itemType = flags;
+
+ Assert.equal(
+ filter.itemTypeFilter(completeTask),
+ expectComplete,
+ "complete task matches item type filter"
+ );
+ Assert.equal(
+ filter.itemTypeFilter(incompleteTask),
+ expectIncomplete,
+ "incomplete task matches item type filter"
+ );
+
+ Assert.equal(
+ filter.isItemInFilters(completeTask),
+ expectComplete,
+ "complete task matches all filters"
+ );
+ Assert.equal(
+ filter.isItemInFilters(incompleteTask),
+ expectIncomplete,
+ "incomplete task matches all filters"
+ );
+
+ let expectedTitles = [];
+ if (expectComplete) {
+ expectedTitles.push(completeTask.title);
+ }
+ if (expectIncomplete) {
+ expectedTitles.push(incompleteTask.title);
+ }
+ let items = await promiseItems(filter, calendar);
+ Assert.deepEqual(
+ items.map(i => i.title),
+ expectedTitles,
+ "getItems returns correct items"
+ );
+ }
+});
+
+/**
+ * Tests that calFilter.getItems uses the correct flags when calling
+ * calICalendar.getItems. This is important because calFilter is used both by
+ * setting the itemType filter and with a calFilterProperties object.
+ */
+add_task(async function testGetItemsFilterFlags() {
+ let fakeCalendar = {
+ getItems(filter, count, rangeStart, rangeEndEx) {
+ Assert.equal(filter, expected.filter, "getItems called with the right filter");
+ if (expected.rangeStart) {
+ Assert.equal(
+ rangeStart.compare(expected.rangeStart),
+ 0,
+ "getItems called with the right start date"
+ );
+ }
+ if (expected.rangeEndEx) {
+ Assert.equal(
+ rangeEndEx.compare(expected.rangeEndEx),
+ 0,
+ "getItems called with the right end date"
+ );
+ }
+ return CalReadableStreamFactory.createEmptyReadableStream();
+ },
+ };
+
+ // Test the basic item types.
+ // A request for TODO items requires one of the ITEM_FILTER_COMPLETED flags,
+ // if none are supplied then ITEM_FILTER_COMPLETED_ALL is added.
+ // (These flags have no effect on EVENT items.)
+
+ let filter = new calFilter();
+ let expected = {
+ filter: Ci.calICalendar.ITEM_FILTER_TYPE_ALL | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL,
+ };
+ filter.getItems(fakeCalendar);
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT;
+ expected.filter = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT;
+ filter.getItems(fakeCalendar);
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_TODO;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+ filter.getItems(fakeCalendar);
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_ALL;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_ALL | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+ filter.getItems(fakeCalendar);
+
+ // Test that we get occurrences if we have an end date.
+
+ filter.startDate = cal.createDateTime("20220201T000000Z");
+ filter.endDate = cal.createDateTime("20220301T000000Z");
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_ALL |
+ Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL |
+ Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES;
+ expected.rangeStart = filter.startDate;
+ expected.rangeEndEx = filter.endDate;
+ filter.getItems(fakeCalendar);
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_EVENT | Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES;
+ filter.getItems(fakeCalendar);
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_TODO;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO |
+ Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL |
+ Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES;
+ filter.getItems(fakeCalendar);
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_ALL;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_ALL |
+ Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL |
+ Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES;
+ filter.getItems(fakeCalendar);
+
+ filter.startDate = null;
+ filter.endDate = null;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_ALL | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+ delete expected.rangeStart;
+ delete expected.rangeEndEx;
+ filter.getItems(fakeCalendar);
+
+ // Test that completed tasks are correctly filtered.
+
+ filter.itemType =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_YES;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_YES;
+ filter.getItems(fakeCalendar);
+
+ filter.itemType =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_NO;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_NO;
+ filter.getItems(fakeCalendar);
+
+ filter.itemType =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+ filter.getItems(fakeCalendar);
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_TODO;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+ filter.getItems(fakeCalendar);
+
+ // Using `applyFilter` needs a selected date or the test dies trying to find the
+ // `currentView` function, which doesn't exist in an XPCShell test.
+ filter.selectedDate = cal.dtz.now();
+ filter.applyFilter("completed");
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO |
+ Ci.calICalendar.ITEM_FILTER_COMPLETED_YES |
+ Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES;
+ filter.getItems(fakeCalendar);
+
+ filter.applyFilter("open");
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_NO;
+ filter.getItems(fakeCalendar);
+
+ filter.applyFilter();
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+ filter.getItems(fakeCalendar);
+});
diff --git a/comm/calendar/test/unit/test_filter_mixin.js b/comm/calendar/test/unit/test_filter_mixin.js
new file mode 100644
index 0000000000..0d30afb616
--- /dev/null
+++ b/comm/calendar/test/unit/test_filter_mixin.js
@@ -0,0 +1,1083 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+const { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs");
+
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+const { CalRecurrenceInfo } = ChromeUtils.import("resource:///modules/CalRecurrenceInfo.jsm");
+const { CalTodo } = ChromeUtils.import("resource:///modules/CalTodo.jsm");
+
+/* globals CalendarFilteredViewMixin, CalReadableStreamFactory */
+Services.scriptloader.loadSubScript("chrome://calendar/content/widgets/calendar-filter.js");
+
+class TestCalFilter extends CalendarFilteredViewMixin(class {}) {
+ addedItems = [];
+ removedItems = [];
+ removedCalendarIds = [];
+
+ clearItems() {
+ info("clearItems");
+ this.addedItems.length = 0;
+ this.removedItems.length = 0;
+ this.removedCalendarIds.length = 0;
+ }
+
+ addItems(items) {
+ info("addItems");
+ this.addedItems.push(...items);
+ }
+
+ removeItems(items) {
+ info("removeItems");
+ this.removedItems.push(...items);
+ }
+
+ removeItemsFromCalendar(calendarId) {
+ info("removeItemsFromCalendar");
+ this.removedCalendarIds.push(calendarId);
+ }
+}
+
+let testItems = {};
+let addedTestItems = {};
+
+add_setup(async function () {
+ await new Promise(resolve => do_calendar_startup(resolve));
+
+ for (let [title, startDate, endDate] of [
+ ["before", "20210720", "20210721"],
+ ["during", "20210820", "20210821"],
+ ["after", "20210920", "20210921"],
+ ["overlaps_start", "20210720", "20210804"],
+ ["overlaps_end", "20210820", "20210904"],
+ ["overlaps_both", "20210720", "20210904"],
+ ]) {
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = title;
+ item.startDate = cal.createDateTime(startDate);
+ item.endDate = cal.createDateTime(endDate);
+ testItems[title] = item;
+ }
+
+ let repeatingItem = new CalEvent();
+ repeatingItem.id = cal.getUUID();
+ repeatingItem.title = "repeating";
+ repeatingItem.startDate = cal.createDateTime("20210818T120000");
+ repeatingItem.endDate = cal.createDateTime("20210818T130000");
+ repeatingItem.recurrenceInfo = new CalRecurrenceInfo(repeatingItem);
+ repeatingItem.recurrenceInfo.appendRecurrenceItem(
+ cal.createRecurrenceRule("RRULE:FREQ=DAILY;INTERVAL=5;COUNT=4")
+ );
+ testItems.repeating = repeatingItem;
+});
+
+add_task(async function testAddItems() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ for (let title of ["before", "after"]) {
+ testWidget.clearItems();
+ addedTestItems[title] = await calendar.addItem(testItems[title]);
+ Assert.equal(testWidget.addedItems.length, 0);
+ }
+
+ for (let title of ["during", "overlaps_start", "overlaps_end", "overlaps_both"]) {
+ testWidget.clearItems();
+ addedTestItems[title] = await calendar.addItem(testItems[title]);
+
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.addedItems[0].title, title);
+ }
+
+ testWidget.clearItems();
+ addedTestItems.repeating = await calendar.addItem(testItems.repeating);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "repeating");
+ Assert.equal(testWidget.addedItems[0].startDate.icalString, "20210818T120000");
+ Assert.equal(testWidget.addedItems[0].endDate.icalString, "20210818T130000");
+ Assert.equal(testWidget.addedItems[1].title, "repeating");
+ Assert.equal(testWidget.addedItems[1].startDate.icalString, "20210823T120000");
+ Assert.equal(testWidget.addedItems[1].endDate.icalString, "20210823T130000");
+ Assert.equal(testWidget.addedItems[2].title, "repeating");
+ Assert.equal(testWidget.addedItems[2].startDate.icalString, "20210828T120000");
+ Assert.equal(testWidget.addedItems[2].endDate.icalString, "20210828T130000");
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testRefresh() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ // Add all calendar items.
+ const promises = [];
+ for (const key in testItems) {
+ promises.push(calendar.addItem(testItems[key]));
+ }
+ await Promise.all(promises);
+
+ testWidget.startDate = cal.createDateTime("20210801");
+ testWidget.endDate = cal.createDateTime("20210831");
+ await testWidget.refreshItems();
+
+ Assert.equal(testWidget.addedItems.length, 7, "getItems returns expected number of items");
+ Assert.equal(testWidget.addedItems[0].title, "during", "correct item returned");
+ Assert.equal(testWidget.addedItems[1].title, "overlaps_start", "correct item returned");
+ Assert.equal(testWidget.addedItems[2].title, "overlaps_end", "correct item returned");
+ Assert.equal(testWidget.addedItems[3].title, "overlaps_both", "correct item returned");
+ Assert.equal(testWidget.addedItems[4].title, "repeating");
+ Assert.equal(testWidget.addedItems[4].startDate.icalString, "20210818T120000");
+ Assert.equal(testWidget.addedItems[4].endDate.icalString, "20210818T130000");
+ Assert.equal(testWidget.addedItems[5].title, "repeating");
+ Assert.equal(testWidget.addedItems[5].startDate.icalString, "20210823T120000");
+ Assert.equal(testWidget.addedItems[5].endDate.icalString, "20210823T130000");
+ Assert.equal(testWidget.addedItems[6].title, "repeating");
+ Assert.equal(testWidget.addedItems[6].startDate.icalString, "20210828T120000");
+ Assert.equal(testWidget.addedItems[6].endDate.icalString, "20210828T130000");
+
+ testWidget.startDate = cal.createDateTime("20210825");
+ testWidget.endDate = cal.createDateTime("20210905");
+ await testWidget.refreshItems();
+
+ Assert.equal(testWidget.addedItems.length, 4, "getItems returns expected number of items");
+ Assert.equal(testWidget.addedItems[0].title, "overlaps_end", "correct item returned");
+ Assert.equal(testWidget.addedItems[1].title, "overlaps_both", "correct item returned");
+ Assert.equal(testWidget.addedItems[2].title, "repeating");
+ Assert.equal(testWidget.addedItems[2].startDate.icalString, "20210828T120000");
+ Assert.equal(testWidget.addedItems[2].endDate.icalString, "20210828T130000");
+ Assert.equal(testWidget.addedItems[3].title, "repeating");
+ Assert.equal(testWidget.addedItems[3].startDate.icalString, "20210902T120000");
+ Assert.equal(testWidget.addedItems[3].endDate.icalString, "20210902T130000");
+
+ // Verify that refreshing while the widget is inactive doesn't prevent later
+ // attempts to refresh from succeeding.
+ testWidget.deactivate();
+ testWidget.clearItems();
+ Assert.equal(
+ testWidget.addedItems.length,
+ 0,
+ "there should be no items after deactivation and clearing"
+ );
+
+ await testWidget.refreshItems();
+ Assert.equal(testWidget.addedItems.length, 0, "refreshing while inactive should not add items");
+
+ await testWidget.activate();
+ Assert.equal(testWidget.addedItems.length, 4, "getItems returns expected number of items");
+ Assert.equal(testWidget.addedItems[0].title, "overlaps_end", "correct item returned");
+ Assert.equal(testWidget.addedItems[1].title, "overlaps_both", "correct item returned");
+ Assert.equal(testWidget.addedItems[2].title, "repeating");
+ Assert.equal(testWidget.addedItems[2].startDate.icalString, "20210828T120000");
+ Assert.equal(testWidget.addedItems[2].endDate.icalString, "20210828T130000");
+ Assert.equal(testWidget.addedItems[3].title, "repeating");
+ Assert.equal(testWidget.addedItems[3].startDate.icalString, "20210902T120000");
+ Assert.equal(testWidget.addedItems[3].endDate.icalString, "20210902T130000");
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testRemoveItems() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ for (let title of ["before", "after"]) {
+ testWidget.clearItems();
+ await calendar.deleteItem(addedTestItems[title]);
+ Assert.equal(testWidget.removedItems.length, 0);
+ }
+
+ for (let title of ["during", "overlaps_start", "overlaps_end", "overlaps_both"]) {
+ testWidget.clearItems();
+ await calendar.deleteItem(addedTestItems[title]);
+
+ Assert.equal(testWidget.removedItems.length, 1);
+ Assert.equal(testWidget.removedItems[0].title, title);
+ }
+
+ testWidget.clearItems();
+ await calendar.deleteItem(addedTestItems.repeating);
+
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.equal(testWidget.removedItems[0].title, "repeating");
+ Assert.equal(testWidget.removedItems[0].startDate.icalString, "20210818T120000");
+ Assert.equal(testWidget.removedItems[0].endDate.icalString, "20210818T130000");
+ Assert.equal(testWidget.removedItems[1].title, "repeating");
+ Assert.equal(testWidget.removedItems[1].startDate.icalString, "20210823T120000");
+ Assert.equal(testWidget.removedItems[1].endDate.icalString, "20210823T130000");
+ Assert.equal(testWidget.removedItems[2].title, "repeating");
+ Assert.equal(testWidget.removedItems[2].startDate.icalString, "20210828T120000");
+ Assert.equal(testWidget.removedItems[2].endDate.icalString, "20210828T130000");
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testModifyItem() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "change me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.addedItems[0].title, "change me");
+
+ let changedItem = item.clone();
+ changedItem.title = "changed";
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.addedItems[0].title, "changed");
+ Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0]));
+ Assert.equal(testWidget.removedItems.length, 1);
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+
+ testWidget.clearItems();
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.removedItems.length, 1);
+ Assert.equal(testWidget.removedItems[0].title, "changed");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testMoveItemWithinRange() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "move me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+
+ let changedItem = item.clone();
+ changedItem.startDate = cal.createDateTime("20210805T180000");
+ changedItem.endDate = cal.createDateTime("20210805T190000");
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+ Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0]));
+ Assert.equal(testWidget.removedItems.length, 1);
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+
+ testWidget.clearItems();
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.removedItems.length, 1);
+ Assert.equal(testWidget.removedItems[0].title, "move me");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testMoveItemOutOfRange() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "move me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+
+ let changedItem = item.clone();
+ changedItem.startDate = cal.createDateTime("20210905T170000");
+ changedItem.endDate = cal.createDateTime("20210905T180000");
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 1);
+ Assert.equal(testWidget.removedItems[0].title, "move me");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+
+ testWidget.clearItems();
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.removedItems.length, 0);
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testMoveItemInToRange() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "move me";
+ item.startDate = cal.createDateTime("20210705T170000");
+ item.endDate = cal.createDateTime("20210705T180000");
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 0);
+
+ let changedItem = item.clone();
+ changedItem.startDate = cal.createDateTime("20210805T170000");
+ changedItem.endDate = cal.createDateTime("20210805T180000");
+
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+ Assert.equal(testWidget.removedItems.length, 0);
+
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.removedItems.length, 1);
+ Assert.equal(testWidget.removedItems[0].title, "move me");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(testWidget.addedItems[0]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testModifyRecurringItem() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "change me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+ item.recurrenceInfo = new CalRecurrenceInfo(item);
+ item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3"));
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "change me");
+ Assert.equal(testWidget.addedItems[1].title, "change me");
+ Assert.equal(testWidget.addedItems[2].title, "change me");
+
+ let changedItem = item.clone();
+ changedItem.title = "changed";
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "changed");
+ Assert.equal(testWidget.addedItems[1].title, "changed");
+ Assert.equal(testWidget.addedItems[2].title, "changed");
+ Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.addedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.addedItems[2].hasSameIds(addedItems[2]));
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ testWidget.clearItems();
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.equal(testWidget.removedItems[0].title, "changed");
+ Assert.equal(testWidget.removedItems[1].title, "changed");
+ Assert.equal(testWidget.removedItems[2].title, "changed");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testMoveRecurringItemWithinRange() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "move me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+ item.recurrenceInfo = new CalRecurrenceInfo(item);
+ item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3"));
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+ Assert.equal(testWidget.addedItems[1].title, "move me");
+ Assert.equal(testWidget.addedItems[2].title, "move me");
+
+ let changedItem = item.clone();
+ changedItem.startDate = cal.createDateTime("20210805T180000");
+ changedItem.endDate = cal.createDateTime("20210805T190000");
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(changedItem, item);
+
+ // This maybe should call modifyItems, but instead it calls addItems and removeItems.
+
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.equal(testWidget.removedItems[0].title, "move me");
+ Assert.equal(testWidget.removedItems[1].title, "move me");
+ Assert.equal(testWidget.removedItems[2].title, "move me");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+ Assert.equal(testWidget.addedItems[1].title, "move me");
+ Assert.equal(testWidget.addedItems[2].title, "move me");
+ Assert.equal(testWidget.addedItems[0].startDate.icalString, "20210805T180000");
+ Assert.equal(testWidget.addedItems[1].startDate.icalString, "20210806T180000");
+ Assert.equal(testWidget.addedItems[2].startDate.icalString, "20210807T180000");
+ Assert.equal(testWidget.addedItems[0].endDate.icalString, "20210805T190000");
+ Assert.equal(testWidget.addedItems[1].endDate.icalString, "20210806T190000");
+ Assert.equal(testWidget.addedItems[2].endDate.icalString, "20210807T190000");
+
+ addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.equal(testWidget.removedItems[0].title, "move me");
+ Assert.equal(testWidget.removedItems[1].title, "move me");
+ Assert.equal(testWidget.removedItems[2].title, "move me");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testMoveRecurringItemOutOfRange() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "move me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+ item.recurrenceInfo = new CalRecurrenceInfo(item);
+ item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3"));
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+ Assert.equal(testWidget.addedItems[1].title, "move me");
+ Assert.equal(testWidget.addedItems[2].title, "move me");
+
+ let changedItem = item.clone();
+ changedItem.startDate = cal.createDateTime("20210905T170000");
+ changedItem.endDate = cal.createDateTime("20210905T180000");
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.equal(testWidget.removedItems[0].title, "move me");
+ Assert.equal(testWidget.removedItems[1].title, "move me");
+ Assert.equal(testWidget.removedItems[2].title, "move me");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ testWidget.clearItems();
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.removedItems.length, 0);
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testMoveRecurringItemInToRange() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "move me";
+ item.startDate = cal.createDateTime("20210705T170000");
+ item.endDate = cal.createDateTime("20210705T180000");
+ item.recurrenceInfo = new CalRecurrenceInfo(item);
+ item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3"));
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 0);
+
+ let changedItem = item.clone();
+ changedItem.startDate = cal.createDateTime("20210805T170000");
+ changedItem.endDate = cal.createDateTime("20210805T180000");
+
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+ Assert.equal(testWidget.addedItems[1].title, "move me");
+ Assert.equal(testWidget.addedItems[2].title, "move me");
+ Assert.equal(testWidget.removedItems.length, 0);
+
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.equal(testWidget.removedItems[0].title, "move me");
+ Assert.equal(testWidget.removedItems[1].title, "move me");
+ Assert.equal(testWidget.removedItems[2].title, "move me");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(testWidget.addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(testWidget.addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(testWidget.addedItems[2]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testModifyOccurrence() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "change me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+ item.recurrenceInfo = new CalRecurrenceInfo(item);
+ item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3"));
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "change me");
+ Assert.equal(testWidget.addedItems[1].title, "change me");
+ Assert.equal(testWidget.addedItems[2].title, "change me");
+
+ let occurrences = item.recurrenceInfo.getOccurrences(
+ testWidget.startDate,
+ testWidget.endDate,
+ 100
+ );
+ let changedOccurrence = occurrences[1].clone();
+ changedOccurrence.title = "changed";
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(
+ cal.itip.prepareSequence(changedOccurrence, occurrences[1]),
+ occurrences[1]
+ );
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "change me");
+ Assert.equal(testWidget.addedItems[1].title, "changed");
+ Assert.equal(testWidget.addedItems[2].title, "change me");
+ Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.addedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.addedItems[2].hasSameIds(addedItems[2]));
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ testWidget.clearItems();
+ await calendar.deleteItem(item);
+
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testDeleteOccurrence() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "change me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+ item.recurrenceInfo = new CalRecurrenceInfo(item);
+ item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3"));
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "change me");
+ Assert.equal(testWidget.addedItems[1].title, "change me");
+ Assert.equal(testWidget.addedItems[2].title, "change me");
+
+ let changedItem = item.clone();
+ let occurrences = changedItem.recurrenceInfo.getOccurrences(
+ testWidget.startDate,
+ testWidget.endDate,
+ 100
+ );
+ changedItem.recurrenceInfo.removeOccurrenceAt(occurrences[1].recurrenceId);
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 2);
+ Assert.equal(testWidget.addedItems[0].title, "change me");
+ Assert.equal(testWidget.addedItems[1].title, "change me");
+ Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.addedItems[1].hasSameIds(addedItems[2]));
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ testWidget.clearItems();
+ await calendar.deleteItem(item);
+
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testMoveOccurrenceWithinRange() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "move me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+ item.recurrenceInfo = new CalRecurrenceInfo(item);
+ item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3"));
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+ Assert.equal(testWidget.addedItems[1].title, "move me");
+ Assert.equal(testWidget.addedItems[2].title, "move me");
+
+ let occurrences = item.recurrenceInfo.getOccurrences(
+ testWidget.startDate,
+ testWidget.endDate,
+ 100
+ );
+ let changedOccurrence = occurrences[1].clone();
+ changedOccurrence.startDate = cal.createDateTime("20210806T173000");
+ changedOccurrence.endDate = cal.createDateTime("20210806T183000");
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(
+ cal.itip.prepareSequence(changedOccurrence, occurrences[1]),
+ occurrences[1]
+ );
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+ Assert.equal(testWidget.addedItems[1].title, "move me");
+ Assert.equal(testWidget.addedItems[2].title, "move me");
+ Assert.equal(testWidget.addedItems[0].startDate.icalString, "20210805T170000");
+ Assert.equal(testWidget.addedItems[1].startDate.icalString, "20210806T173000");
+ Assert.equal(testWidget.addedItems[2].startDate.icalString, "20210807T170000");
+ Assert.equal(testWidget.addedItems[0].endDate.icalString, "20210805T180000");
+ Assert.equal(testWidget.addedItems[1].endDate.icalString, "20210806T183000");
+ Assert.equal(testWidget.addedItems[2].endDate.icalString, "20210807T180000");
+ Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.addedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.addedItems[2].hasSameIds(addedItems[2]));
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ testWidget.clearItems();
+ await calendar.deleteItem(item);
+
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.equal(testWidget.removedItems[0].title, "move me");
+ Assert.equal(testWidget.removedItems[1].title, "move me");
+ Assert.equal(testWidget.removedItems[2].title, "move me");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testChangeTaskCompletion() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let incompleteTask = new CalTodo();
+ incompleteTask.id = cal.getUUID();
+ incompleteTask.title = "incomplete task";
+ incompleteTask.startDate = cal.createDateTime("20210805T170000");
+ incompleteTask.endDate = cal.createDateTime("20210805T180000");
+
+ // Set the widget to only show incomplete tasks.
+
+ testWidget.itemType =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_NO;
+
+ // Add an incomplete task to the calendar.
+
+ testWidget.clearItems();
+ incompleteTask = await calendar.addItem(incompleteTask);
+
+ Assert.equal(testWidget.addedItems.length, 1, "incomplete item was added");
+ Assert.equal(testWidget.addedItems[0].title, "incomplete task");
+
+ // Complete the task. It should be removed from the widget.
+
+ let completeTask = incompleteTask.clone();
+ completeTask.title = "complete task";
+ completeTask.percentComplete = 100;
+
+ testWidget.clearItems();
+ completeTask = await calendar.modifyItem(completeTask, incompleteTask);
+
+ Assert.equal(testWidget.removedItems.length, 1, "complete item was removed");
+ Assert.equal(testWidget.removedItems[0].title, "incomplete task");
+
+ // Mark the task as incomplete again. It should be added back to the widget.
+
+ let incompleteAgainItem = completeTask.clone();
+ incompleteAgainItem.title = "incomplete again task";
+ incompleteAgainItem.percentComplete = 50;
+
+ testWidget.clearItems();
+ await calendar.modifyItem(incompleteAgainItem, completeTask);
+
+ Assert.equal(testWidget.addedItems.length, 1, "incomplete item was added");
+ Assert.equal(testWidget.addedItems[0].title, "incomplete again task");
+
+ // Clean up.
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testDisableEnableCalendar() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ addedTestItems.during = await calendar.addItem(testItems.during);
+
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.equal(testWidget.removedCalendarIds.length, 0);
+
+ // Test disabling and enabling the calendar.
+
+ testWidget.clearItems();
+ calendar.setProperty("disabled", true);
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.deepEqual(testWidget.removedCalendarIds, [calendar.id]);
+
+ testWidget.clearItems();
+ calendar.setProperty("disabled", false);
+ await TestUtils.waitForCondition(() => testWidget.addedItems.length == 1);
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.equal(testWidget.removedCalendarIds.length, 0);
+
+ // Test hiding and showing the calendar.
+
+ testWidget.clearItems();
+ calendar.setProperty("calendar-main-in-composite", false);
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.deepEqual(testWidget.removedCalendarIds, [calendar.id]);
+
+ testWidget.clearItems();
+ calendar.setProperty("calendar-main-in-composite", true);
+ await TestUtils.waitForCondition(() => testWidget.addedItems.length == 1);
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.equal(testWidget.removedCalendarIds.length, 0);
+
+ // Test disabling and enabling the calendar while it is hidden.
+
+ testWidget.clearItems();
+ calendar.setProperty("calendar-main-in-composite", false);
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.deepEqual(testWidget.removedCalendarIds, [calendar.id]);
+
+ testWidget.clearItems();
+ calendar.setProperty("disabled", true);
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.deepEqual(testWidget.removedCalendarIds, [calendar.id]);
+
+ testWidget.clearItems();
+ calendar.setProperty("disabled", false);
+ await new Promise(resolve => do_timeout(500, resolve));
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.equal(testWidget.removedCalendarIds.length, 0);
+
+ testWidget.clearItems();
+ calendar.setProperty("calendar-main-in-composite", true);
+ await TestUtils.waitForCondition(() => testWidget.addedItems.length == 1);
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.equal(testWidget.removedCalendarIds.length, 0);
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testChangeWhileHidden() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "change me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+
+ testWidget.clearItems();
+ calendar.setProperty("calendar-main-in-composite", false);
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 0);
+
+ let changedItem = item.clone();
+ changedItem.title = "changed";
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 0);
+
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 0);
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testChangeWhileRefreshing() {
+ // Create a calendar we can control the output of.
+
+ let pumpCalendar = {
+ type: "pump",
+ uri: Services.io.newURI("pump:test-calendar"),
+ getProperty(name) {
+ switch (name) {
+ case "disabled":
+ return false;
+ case "calendar-main-in-composite":
+ return true;
+ }
+ return null;
+ },
+ addObserver() {},
+
+ getItems(filter, count, rangeStart, rangeEndEx) {
+ return CalReadableStreamFactory.createReadableStream({
+ async start(controller) {
+ pumpCalendar.controller = controller;
+ },
+ });
+ },
+ };
+ cal.manager.registerCalendar(pumpCalendar);
+
+ // Create a new widget and a Promise waiting for it to be ready.
+
+ let widget = new TestCalFilter();
+ widget.id = "test-filter";
+ widget.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_ALL;
+
+ let ready1 = widget.ready;
+ let ready1Resolved, ready1Rejected;
+ ready1.then(
+ arg => {
+ ready1Resolved = true;
+ },
+ arg => {
+ ready1Rejected = true;
+ }
+ );
+
+ // Ask the calendars for items. Get a waiting Promise before and after doing so.
+ // These should be the same as the earlier Promise.
+
+ Assert.equal(widget.ready, ready1, ".ready should return the same Promise");
+ Assert.equal(widget.activate(), ready1, ".activate should return the same Promise");
+ Assert.equal(widget.ready, ready1, ".ready should return the same Promise");
+
+ // Return some items from the calendar. They should be sent to addItems.
+
+ pumpCalendar.controller.enqueue([testItems.during]);
+ await TestUtils.waitForCondition(() => widget.addedItems.length == 1, "first added item");
+ Assert.equal(widget.addedItems[0].title, testItems.during.title, "added item was expected");
+
+ // Make the widget inactive. This invalidates the earlier call to `refreshItems`.
+
+ widget.deactivate();
+
+ // Return some more items from the calendar. These should be ignored.
+
+ // Even though the data is now invalid the original Promise should not have been replaced with a
+ // new one.
+
+ Assert.equal(widget.ready, ready1, ".ready should return the same Promise");
+
+ pumpCalendar.controller.enqueue([testItems.after]);
+ pumpCalendar.controller.close();
+
+ // We're testing that nothing happens. Give it time to potentially happen.
+ await new Promise(resolve => do_timeout(500, resolve));
+
+ Assert.equal(widget.addedItems.length, 1, "no more items added");
+ Assert.equal(widget.addedItems[0].title, testItems.during.title, "added item was expected");
+ Assert.equal(ready1Resolved, undefined, "Promise did not yet resolve");
+ Assert.equal(ready1Rejected, undefined, "Promise did not yet reject");
+
+ // Make the widget active again so we can test some other things.
+
+ widget.clearItems();
+
+ Assert.equal(widget.activate(), ready1, ".activate should return the same Promise");
+ Assert.equal(widget.ready, ready1, ".ready should return the same Promise");
+
+ pumpCalendar.controller.enqueue([testItems.during]);
+ await TestUtils.waitForCondition(() => widget.addedItems.length == 1, "first added item");
+ Assert.equal(widget.addedItems[0].title, testItems.during.title, "added item was expected");
+
+ // ... then before it finishes, force another refresh. We're still waiting for the original
+ // Promise because no refresh has completed yet.
+ // Return a different item, just to be sure we got the one we expected.
+
+ Assert.equal(widget.refreshItems(true), ready1, ".refreshItems should return the same Promise");
+ Assert.equal(widget.addedItems.length, 0, "items were cleared");
+
+ pumpCalendar.controller.enqueue([testItems.before]);
+ pumpCalendar.controller.close();
+
+ // Finally we have a completed refresh. The Promise should resolve now.
+
+ await ready1;
+ await TestUtils.waitForCondition(() => widget.addedItems.length == 1, "first added item");
+ Assert.equal(widget.addedItems[0].title, testItems.before.title, "added item was expected");
+
+ // The Promise should not be replaced until the dates or item type change, or we force a refresh.
+
+ Assert.equal(widget.ready, ready1, ".ready should return the same Promise");
+ Assert.equal(widget.refreshItems(), ready1, ".refreshItems should return the same Promise");
+
+ // Force refresh again. There should be a new ready Promise, since the old one was resolved and
+ // we forced a refresh.
+
+ let ready2 = widget.refreshItems(true);
+ Assert.notEqual(ready2, ready1, ".refreshItems should return a new Promise");
+ Assert.equal(widget.addedItems.length, 0, "items were cleared");
+
+ pumpCalendar.controller.enqueue([testItems.after]);
+ pumpCalendar.controller.close();
+
+ await ready2;
+ await TestUtils.waitForCondition(() => widget.addedItems.length == 1, "first added item");
+ Assert.equal(widget.addedItems[0].title, testItems.after.title, "added item was expected");
+
+ // Change the item type. There should be a new ready Promise, since the old one was resolved and
+ // the item type changed.
+
+ widget.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT;
+ let ready3 = widget.ready;
+ Assert.notEqual(ready3, ready2, ".ready should return a new Promise");
+ Assert.equal(widget.refreshItems(), ready3, ".refreshItems should return the same Promise");
+ Assert.equal(widget.addedItems.length, 0, "items were cleared");
+
+ pumpCalendar.controller.enqueue([testItems.during]);
+ pumpCalendar.controller.close();
+
+ await ready3;
+ await TestUtils.waitForCondition(() => widget.addedItems.length == 1, "first added item");
+ Assert.equal(widget.addedItems[0].title, testItems.during.title, "added item was expected");
+
+ // Change the start date. There should be a new ready Promise, since the old one was resolved and
+ // the start date changed.
+
+ widget.startDate = cal.createDateTime("20220317");
+ let ready4 = widget.ready;
+ Assert.notEqual(ready4, ready3, ".ready should return a new Promise");
+ Assert.equal(widget.refreshItems(), ready4, ".refreshItems should return the same Promise");
+
+ pumpCalendar.controller.close();
+ await ready4;
+
+ // Change the end date. There should be a new ready Promise, since the old one was resolved and
+ // the end date changed.
+
+ widget.endDate = cal.createDateTime("20220318");
+ let ready5 = widget.ready;
+ Assert.notEqual(ready5, ready4, ".ready should return a new Promise");
+ Assert.equal(widget.refreshItems(), ready5, ".refreshItems should return the same Promise");
+
+ pumpCalendar.controller.close();
+ await ready5;
+});
+
+async function initializeCalendarAndTestWidget() {
+ const calendar = CalendarTestUtils.createCalendar("test", "storage");
+ calendar.setProperty("calendar-main-in-composite", true);
+
+ const testWidget = new TestCalFilter();
+ testWidget.startDate = cal.createDateTime("20210801");
+ testWidget.endDate = cal.createDateTime("20210831");
+ testWidget.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_ALL;
+ await testWidget.activate();
+
+ return { calendar, testWidget };
+}
diff --git a/comm/calendar/test/unit/test_filter_tree_view.js b/comm/calendar/test/unit/test_filter_tree_view.js
new file mode 100644
index 0000000000..97313849c4
--- /dev/null
+++ b/comm/calendar/test/unit/test_filter_tree_view.js
@@ -0,0 +1,451 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+const { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs");
+
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+const { CalRecurrenceInfo } = ChromeUtils.import("resource:///modules/CalRecurrenceInfo.jsm");
+const { CalRecurrenceRule } = ChromeUtils.import("resource:///modules/CalRecurrenceRule.jsm");
+
+const { TreeSelection } = ChromeUtils.importESModule(
+ "chrome://messenger/content/tree-selection.mjs"
+);
+
+Services.scriptloader.loadSubScript("chrome://messenger/content/jsTreeView.js");
+Services.scriptloader.loadSubScript("chrome://calendar/content/widgets/calendar-filter.js");
+/* globals CalendarFilteredTreeView */
+Services.scriptloader.loadSubScript(
+ "chrome://calendar/content/widgets/calendar-filter-tree-view.js"
+);
+
+const testItems = {};
+
+add_setup(async function () {
+ await new Promise(resolve => do_calendar_startup(resolve));
+
+ // Create events useful for testing.
+ for (const [title, startDate, endDate] of [
+ ["one", "20221126T010000", "20221126T013000"],
+ ["two", "20221126T020000", "20221126T073000"],
+ ["three", "20221126T030000", "20221126T033000"],
+ ["four", "20221126T040000", "20221126T043000"],
+ ["five", "20221126T050000", "20221126T053000"],
+ ["six", "20221126T060000", "20221126T063000"],
+ ]) {
+ const item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = title;
+ item.startDate = cal.createDateTime(startDate);
+ item.endDate = cal.createDateTime(endDate);
+ testItems[title] = item;
+ }
+
+ const recurring = new CalEvent();
+ recurring.id = cal.getUUID();
+ recurring.title = "recurring event";
+ recurring.startDate = cal.createDateTime("20221124T053000");
+ recurring.endDate = cal.createDateTime("20221124T063000");
+
+ const recurRule = cal.createRecurrenceRule();
+ recurRule.type = "DAILY";
+ recurRule.byCount = true;
+ recurRule.count = 5;
+
+ const recurInfo = new CalRecurrenceInfo(recurring);
+ recurInfo.appendRecurrenceItem(recurRule);
+
+ recurring.recurrenceInfo = recurInfo;
+
+ testItems.recurring = recurring;
+});
+
+add_task(async function testAddItemsAndSort() {
+ const { calendar, view } = await initializeCalendarAndView();
+
+ assertViewContainsItemsInOrder(view);
+
+ await calendar.addItem(testItems.one);
+ assertViewContainsItemsInOrder(view, "one");
+
+ await calendar.addItem(testItems.three);
+ await calendar.addItem(testItems.four);
+ assertViewContainsItemsInOrder(view, "one", "three", "four");
+
+ // Verify that items are sorted by start time by default.
+ await calendar.addItem(testItems.two);
+ assertViewContainsItemsInOrder(view, "one", "two", "three", "four");
+
+ // Change sort to ascending by title.
+ view.cycleHeader({ id: "title" });
+ assertViewContainsItemsInOrder(view, "four", "one", "three", "two");
+
+ // Verify that items are sorted appropriately on add.
+ await calendar.addItem(testItems.five);
+ assertViewContainsItemsInOrder(view, "five", "four", "one", "three", "two");
+
+ // Change sort to descending by title.
+ view.cycleHeader({ id: "title" });
+ assertViewContainsItemsInOrder(view, "two", "three", "one", "four", "five");
+
+ await calendar.addItem(testItems.six);
+ assertViewContainsItemsInOrder(view, "two", "three", "six", "one", "four", "five");
+
+ // Re-sort by start date for testing recurrences.
+ view.cycleHeader({ id: "startDate" });
+
+ // Verify that recurring events which occur more than once in the filter range
+ // show up more than once. Also verify that occurrences outside the filter
+ // range do not display.
+ await calendar.addItem(testItems.recurring);
+ assertViewContainsItemsInOrder(
+ view,
+ "one",
+ "two",
+ "three",
+ "four",
+ "five",
+ "recurring event",
+ "six",
+ "recurring event"
+ );
+
+ CalendarTestUtils.removeCalendar(calendar);
+ view.deactivate();
+});
+
+add_task(async function testInitializeWithExistingCalenderEvents() {
+ const calendar = CalendarTestUtils.createCalendar("test", "storage");
+ calendar.setProperty("calendar-main-in-composite", true);
+
+ // Add items to the calendar before we initialize the view.
+ await calendar.addItem(testItems.one);
+ await calendar.addItem(testItems.three);
+ await calendar.addItem(testItems.four);
+
+ const view = new CalendarFilteredTreeView();
+ view.startDate = cal.createDateTime("20221126");
+ view.endDate = cal.createDateTime("20221128");
+ view.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT;
+
+ const tree = {
+ _batchUpdated: false,
+ _batchDepth: false,
+
+ beginUpdateBatch() {},
+ endUpdateBatch() {},
+ invalidateRow(index) {},
+ };
+ view.setTree(tree);
+
+ // Wait for the view to fetch items and update.
+ await view.activate();
+
+ // Verify that items added to the calendar before initializing are displayed.
+ assertViewContainsItemsInOrder(view, "one", "three", "four");
+
+ // Verify that adding further items causes them to be displayed as well.
+ await calendar.addItem(testItems.two);
+ assertViewContainsItemsInOrder(view, "one", "two", "three", "four");
+
+ CalendarTestUtils.removeCalendar(calendar);
+ view.deactivate();
+});
+
+add_task(async function testRemoveItems() {
+ const { calendar, view } = await initializeCalendarAndView();
+
+ // Record the calendar items so we can use them to delete.
+ const calendarItems = {};
+ for (const key in testItems) {
+ calendarItems[key] = await calendar.addItem(testItems[key]);
+ }
+
+ // Sanity check.
+ assertViewContainsItemsInOrder(
+ view,
+ "one",
+ "two",
+ "three",
+ "four",
+ "five",
+ "recurring event",
+ "six",
+ "recurring event"
+ );
+
+ await calendar.deleteItem(calendarItems.two);
+ assertViewContainsItemsInOrder(
+ view,
+ "one",
+ "three",
+ "four",
+ "five",
+ "recurring event",
+ "six",
+ "recurring event"
+ );
+
+ // Verify that all occurrences of recurring items are removed.
+ await calendar.deleteItem(calendarItems.recurring);
+ assertViewContainsItemsInOrder(view, "one", "three", "four", "five", "six");
+
+ await calendar.deleteItem(calendarItems.three);
+ await calendar.deleteItem(calendarItems.four);
+ assertViewContainsItemsInOrder(view, "one", "five", "six");
+
+ // Verify that sort order doesn't impact removal.
+ view.cycleHeader({ id: "title" });
+ await calendar.deleteItem(calendarItems.five);
+ assertViewContainsItemsInOrder(view, "one", "six");
+
+ CalendarTestUtils.removeCalendar(calendar);
+ view.deactivate();
+});
+
+add_task(async function testClearItems() {
+ const { calendar, view } = await initializeCalendarAndView();
+
+ // Add all calendar items.
+ const promises = [];
+ for (const key in testItems) {
+ promises.push(calendar.addItem(testItems[key]));
+ }
+ await Promise.all(promises);
+
+ // Sanity check.
+ assertViewContainsItemsInOrder(
+ view,
+ "one",
+ "two",
+ "three",
+ "four",
+ "five",
+ "recurring event",
+ "six",
+ "recurring event"
+ );
+
+ // Directly call clear, as there isn't a convenient way to trigger it via the
+ // calendar.
+ view.clearItems();
+
+ assertViewContainsItemsInOrder(view);
+
+ CalendarTestUtils.removeCalendar(calendar);
+ view.deactivate();
+});
+
+add_task(async function testFilterFunction() {
+ const { calendar, view } = await initializeCalendarAndView();
+
+ // Add some items which will match the filter and some which won't.
+ const promises = [];
+ for (const key of ["one", "two", "five", "recurring"]) {
+ promises.push(calendar.addItem(testItems[key]));
+ }
+ await Promise.all(promises);
+
+ // Add a selection to ensure that selections don't persist when filter changes.
+ view.selection.toggleSelect(0);
+
+ // Sanity check.
+ assertViewContainsItemsInOrder(view, "one", "two", "five", "recurring event", "recurring event");
+ Assert.ok(view.selection.isSelected(0), "item 'one' should be selected");
+
+ // Verify that setting filter function appropriately hides non-matching items.
+ view.setFilterFunction(item => {
+ return item.title.includes("f");
+ });
+ assertViewContainsItemsInOrder(view, "five");
+ Assert.ok(!view.selection.isSelected(0), "item 'five' should not be selected");
+
+ // Verify that matching items display when added.
+ await calendar.addItem(testItems.four);
+ assertViewContainsItemsInOrder(view, "four", "five");
+
+ // Verify that sorting respects filter.
+ view.cycleHeader({ id: "title" });
+ assertViewContainsItemsInOrder(view, "five", "four");
+
+ // Verify that non-matching items don't display when added.
+ await calendar.addItem(testItems.six);
+ assertViewContainsItemsInOrder(view, "five", "four");
+
+ // Verify that clearing the filter shows all items properly sorted.
+ view.clearFilter();
+ assertViewContainsItemsInOrder(
+ view,
+ "five",
+ "four",
+ "one",
+ "recurring event",
+ "recurring event",
+ "six",
+ "two"
+ );
+
+ CalendarTestUtils.removeCalendar(calendar);
+ view.deactivate();
+});
+
+add_task(async function testRemoveItemsFromCalendar() {
+ const { calendar, view } = await initializeCalendarAndView();
+
+ const secondCalendar = CalendarTestUtils.createCalendar("test", "storage");
+ secondCalendar.setProperty("calendar-main-in-composite", true);
+
+ const promises = [];
+
+ // Add some items to the first calendar.
+ for (const key of ["one", "two", "five", "recurring"]) {
+ promises.push(calendar.addItem(testItems[key]));
+ }
+
+ // Add the rest to the second calendar.
+ for (const key of ["three", "four", "six"]) {
+ promises.push(secondCalendar.addItem(testItems[key]));
+ }
+
+ await Promise.all(promises);
+
+ // Verify that both calendars are displayed.
+ assertViewContainsItemsInOrder(
+ view,
+ "one",
+ "two",
+ "three",
+ "four",
+ "five",
+ "recurring event",
+ "six",
+ "recurring event"
+ );
+
+ // Verify that removing items from a specific calendar removes exactly those
+ // events from the view.
+ view.removeItemsFromCalendar(calendar.id);
+
+ assertViewContainsItemsInOrder(view, "three", "four", "six");
+
+ CalendarTestUtils.removeCalendar(calendar);
+ CalendarTestUtils.removeCalendar(secondCalendar);
+ view.deactivate();
+});
+
+add_task(async function testSortRespectsSelection() {
+ const { calendar, view } = await initializeCalendarAndView();
+
+ // Add all calendar items.
+ const promises = [];
+ for (const key in testItems) {
+ promises.push(calendar.addItem(testItems[key]));
+ }
+ await Promise.all(promises);
+
+ view.selection.toggleSelect(1);
+ view.selection.toggleSelect(5);
+ view.selection.toggleSelect(6);
+
+ view.selection.currentIndex = 1;
+
+ // Sanity check.
+ assertViewContainsItemsInOrder(
+ view,
+ "one",
+ "two",
+ "three",
+ "four",
+ "five",
+ "recurring event",
+ "six",
+ "recurring event"
+ );
+
+ // Sanity check selection; two, recurring event, and six should be selected,
+ // nothing else.
+ Assert.ok(view.selection.isSelected(1), "item 'two' should be selected");
+ Assert.ok(view.selection.isSelected(5), "item 'recurring event' should be selected");
+ Assert.ok(view.selection.isSelected(6), "item 'three' should be selected");
+ Assert.equal(view.selection.currentIndex, 1, "item 'two' should be the current selection");
+ for (const row of [0, 2, 3, 4, 7]) {
+ Assert.ok(!view.selection.isSelected(row), `row ${row} should not be selected`);
+ }
+
+ // Verify that sorting the tree keeps the same events selected.
+ view.cycleHeader({ id: "title" });
+
+ assertViewContainsItemsInOrder(
+ view,
+ "five",
+ "four",
+ "one",
+ "recurring event",
+ "recurring event",
+ "six",
+ "three",
+ "two"
+ );
+
+ Assert.ok(view.selection.isSelected(7), "item 'two' should remain selected");
+ Assert.ok(view.selection.isSelected(5), "item 'recurring event' should remain selected");
+ Assert.ok(view.selection.isSelected(3), "item 'three' should remain selected");
+ Assert.equal(view.selection.currentIndex, 7, "item 'two' should be the current selection");
+ for (const row of [0, 1, 2, 4, 6]) {
+ Assert.ok(!view.selection.isSelected(row), `row ${row} should not be selected`);
+ }
+
+ CalendarTestUtils.removeCalendar(calendar);
+ view.deactivate();
+});
+
+function assertViewContainsItemsInOrder(view, ...expected) {
+ const actual = [];
+ for (let i = 0; i < view.rowCount; i++) {
+ actual.push(view.getCellText(i, { id: "title" }));
+ }
+
+ // Check array length. We don't use Assert.equal() here in order to provide
+ // better debugging output.
+ if (actual.length != expected.length) {
+ Assert.report(
+ actual.length != expected.length,
+ actual,
+ expected,
+ `${JSON.stringify(actual)} should have the same length as ${JSON.stringify(expected)}`
+ );
+ }
+
+ Assert.deepEqual(actual, expected);
+}
+
+async function initializeCalendarAndView() {
+ const calendar = CalendarTestUtils.createCalendar("test", "storage");
+ calendar.setProperty("calendar-main-in-composite", true);
+
+ const view = new CalendarFilteredTreeView();
+ view.startDate = cal.createDateTime("20221126");
+ view.endDate = cal.createDateTime("20221128");
+ view.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT;
+ view.activate();
+
+ const tree = {
+ _batchUpdated: false,
+ _batchDepth: false,
+
+ beginUpdateBatch() {},
+ endUpdateBatch() {},
+ invalidateRow(index) {},
+ };
+ view.setTree(tree);
+
+ const selection = new TreeSelection(tree);
+ selection.view = view;
+ view.selection = selection;
+ selection.clearSelection();
+
+ return { calendar, view };
+}
diff --git a/comm/calendar/test/unit/test_freebusy.js b/comm/calendar/test/unit/test_freebusy.js
new file mode 100644
index 0000000000..73ac60fb3d
--- /dev/null
+++ b/comm/calendar/test/unit/test_freebusy.js
@@ -0,0 +1,88 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ test_freebusy();
+ test_period();
+}
+
+function test_freebusy() {
+ let icsService = Cc["@mozilla.org/calendar/ics-service;1"].getService(Ci.calIICSService);
+
+ // Bug 415987 - FREEBUSY decoding does not support comma-separated entries
+ // (https://bugzilla.mozilla.org/show_bug.cgi?id=415987)
+ let fbVal1 = "20080206T160000Z/PT1H";
+ let fbVal2 = "20080206T180000Z/PT1H";
+ let fbVal3 = "20080206T220000Z/PT1H";
+ let data =
+ "BEGIN:VCALENDAR\n" +
+ "BEGIN:VFREEBUSY\n" +
+ "FREEBUSY;FBTYPE=BUSY:" +
+ fbVal1 +
+ "," +
+ fbVal2 +
+ "," +
+ fbVal3 +
+ "\n" +
+ "END:VFREEBUSY\n" +
+ "END:VCALENDAR\n";
+ let fbComp = icsService.parseICS(data).getFirstSubcomponent("VFREEBUSY");
+ equal(fbComp.getFirstProperty("FREEBUSY").value, fbVal1);
+ equal(fbComp.getNextProperty("FREEBUSY").value, fbVal2);
+ equal(fbComp.getNextProperty("FREEBUSY").value, fbVal3);
+}
+
+function test_period() {
+ let period = Cc["@mozilla.org/calendar/period;1"].createInstance(Ci.calIPeriod);
+
+ period.start = cal.createDateTime("20120101T010101");
+ period.end = cal.createDateTime("20120101T010102");
+
+ equal(period.icalString, "20120101T010101/20120101T010102");
+ equal(period.duration.icalString, "PT1S");
+
+ period.icalString = "20120101T010103/20120101T010104";
+
+ equal(period.start.icalString, "20120101T010103");
+ equal(period.end.icalString, "20120101T010104");
+ equal(period.duration.icalString, "PT1S");
+
+ period.icalString = "20120101T010105/PT1S";
+ equal(period.start.icalString, "20120101T010105");
+ equal(period.end.icalString, "20120101T010106");
+ equal(period.duration.icalString, "PT1S");
+
+ period.makeImmutable();
+ // ical.js doesn't support immutability yet
+ // throws(
+ // () => {
+ // period.start = cal.createDateTime("20120202T020202");
+ // },
+ // /0x80460002/,
+ // "Object is Immutable"
+ // );
+ // throws(
+ // () => {
+ // period.end = cal.createDateTime("20120202T020202");
+ // },
+ // /0x80460002/,
+ // "Object is Immutable"
+ // );
+
+ let copy = period.clone();
+ equal(copy.start.icalString, "20120101T010105");
+ equal(copy.end.icalString, "20120101T010106");
+ equal(copy.duration.icalString, "PT1S");
+
+ copy.start.icalString = "20120101T010106";
+ copy.end = cal.createDateTime("20120101T010107");
+
+ equal(period.start.icalString, "20120101T010105");
+ equal(period.end.icalString, "20120101T010106");
+ equal(period.duration.icalString, "PT1S");
+}
diff --git a/comm/calendar/test/unit/test_freebusy_service.js b/comm/calendar/test/unit/test_freebusy_service.js
new file mode 100644
index 0000000000..e19d51f943
--- /dev/null
+++ b/comm/calendar/test/unit/test_freebusy_service.js
@@ -0,0 +1,201 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var freebusy = Cc["@mozilla.org/calendar/freebusy-service;1"].getService(Ci.calIFreeBusyService);
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ test_found();
+ test_noproviders();
+ test_failure();
+ test_cancel();
+}
+
+function test_found() {
+ _clearProviders();
+
+ equal(_countProviders(), 0);
+
+ let provider1 = {
+ id: 1,
+ getFreeBusyIntervals(aCalId, aStart, aEnd, aTypes, aListener) {
+ aListener.onResult(null, []);
+ },
+ };
+
+ let provider2 = {
+ id: 2,
+ called: false,
+ getFreeBusyIntervals(aCalId, aStart, aEnd, aTypes, aListener) {
+ ok(!this.called);
+ this.called = true;
+
+ let interval = new cal.provider.FreeBusyInterval(
+ aCalId,
+ Ci.calIFreeBusyInterval.BUSY,
+ aStart,
+ aEnd
+ );
+ aListener.onResult(null, [interval]);
+ },
+ };
+ provider2.wrappedJSObject = provider2;
+
+ freebusy.addProvider(provider1);
+ equal(_countProviders(), 1);
+ freebusy.addProvider(provider2);
+ equal(_countProviders(), 2);
+ freebusy.removeProvider(provider1);
+ equal(_countProviders(), 1);
+ equal(_getFirstProvider().id, 2);
+
+ let listener = {
+ called: false,
+ onResult(request, result) {
+ equal(result.length, 1);
+ equal(result[0].interval.start.icalString, "20120101T010101");
+ equal(result[0].interval.end.icalString, "20120102T010101");
+ equal(result[0].freeBusyType, Ci.calIFreeBusyInterval.BUSY);
+
+ equal(result.length, 1);
+ ok(provider2.called);
+ do_test_finished();
+ },
+ };
+
+ do_test_pending();
+ freebusy.getFreeBusyIntervals(
+ "email",
+ cal.createDateTime("20120101T010101"),
+ cal.createDateTime("20120102T010101"),
+ Ci.calIFreeBusyInterval.BUSY_ALL,
+ listener
+ );
+}
+
+function test_noproviders() {
+ _clearProviders();
+
+ let listener = {
+ onResult(request, result) {
+ ok(!this.called);
+ equal(result.length, 0);
+ equal(request.status, 0);
+ do_test_finished();
+ },
+ };
+
+ do_test_pending();
+ freebusy.getFreeBusyIntervals(
+ "email",
+ cal.createDateTime("20120101T010101"),
+ cal.createDateTime("20120102T010101"),
+ Ci.calIFreeBusyInterval.BUSY_ALL,
+ listener
+ );
+}
+
+function test_failure() {
+ _clearProviders();
+
+ let provider = {
+ called: false,
+ getFreeBusyIntervals(aCalId, aStart, aEnd, aTypes, aListener) {
+ ok(!this.called);
+ this.called = true;
+ aListener.onResult({ status: Cr.NS_ERROR_FAILURE }, "notFound");
+ },
+ };
+
+ let listener = {
+ onResult(request, result) {
+ ok(!this.called);
+ equal(result.length, 0);
+ equal(request.status, 0);
+ ok(provider.called);
+ do_test_finished();
+ },
+ };
+
+ freebusy.addProvider(provider);
+
+ do_test_pending();
+ freebusy.getFreeBusyIntervals(
+ "email",
+ cal.createDateTime("20120101T010101"),
+ cal.createDateTime("20120102T010101"),
+ Ci.calIFreeBusyInterval.BUSY_ALL,
+ listener
+ );
+}
+
+function test_cancel() {
+ _clearProviders();
+
+ let provider = {
+ QueryInterface: ChromeUtils.generateQI(["calIFreeBusyProvider", "calIOperation"]),
+ getFreeBusyIntervals(aCalId, aStart, aEnd, aTypes, aListener) {
+ Services.tm.currentThread.dispatch(
+ {
+ run() {
+ dump("Cancelling freebusy query...");
+ operation.cancel();
+ },
+ },
+ Ci.nsIEventTarget.DISPATCH_NORMAL
+ );
+
+ // No listener call, we emulate a long running search
+ // Do return the operation though
+ return this;
+ },
+
+ isPending: true,
+ cancelCalled: false,
+ status: Cr.NS_OK,
+ cancel() {
+ this.cancelCalled = true;
+ },
+ };
+
+ let listener = {
+ called: false,
+ onResult(request, result) {
+ equal(result, null);
+
+ // If an exception occurs, the operation is not added to the opgroup
+ ok(!provider.cancelCalled);
+ do_test_finished();
+ },
+ };
+
+ freebusy.addProvider(provider);
+
+ do_test_pending();
+ let operation = freebusy.getFreeBusyIntervals(
+ "email",
+ cal.createDateTime("20120101T010101"),
+ cal.createDateTime("20120102T010101"),
+ Ci.calIFreeBusyInterval.BUSY_ALL,
+ listener
+ );
+}
+
+// The following functions are not in the interface description and probably
+// don't need to be. Make assumptions about the implementation instead.
+
+function _clearProviders() {
+ freebusy.wrappedJSObject.mProviders = new Set();
+}
+
+function _countProviders() {
+ return freebusy.wrappedJSObject.mProviders.size;
+}
+
+function _getFirstProvider() {
+ return [...freebusy.wrappedJSObject.mProviders][0].wrappedJSObject;
+}
diff --git a/comm/calendar/test/unit/test_hashedarray.js b/comm/calendar/test/unit/test_hashedarray.js
new file mode 100644
index 0000000000..bd1fae68ad
--- /dev/null
+++ b/comm/calendar/test/unit/test_hashedarray.js
@@ -0,0 +1,210 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calHashedArray.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function run_test() {
+ test_array_base();
+ test_array_sorted();
+ test_hashAccessor();
+}
+
+/**
+ * Helper function to create an item that has a sensible hash id, with the given
+ * title identification.
+ *
+ * @param ident The title to identify the item.
+ * @returns The created item.
+ */
+function hashedCreateItem(ident) {
+ let item = new CalEvent();
+ item.calendar = { id: "test" };
+ item.id = cal.getUUID();
+ item.title = ident;
+ return item;
+}
+
+/**
+ * Comparator function to sort the items by their title
+ *
+ * @param a Object to compare.
+ * @param b Object to compare with.
+ * @returns 0, -1, or 1 (usual comptor meanings)
+ */
+function titleComptor(a, b) {
+ if (a.title > b.title) {
+ return 1;
+ } else if (a.title < b.title) {
+ return -1;
+ }
+ return 0;
+}
+
+/**
+ * Checks if the hashed array accessor functions work for the status of the
+ * items array.
+ *
+ * @param har The Hashed Array
+ * @param testItems The array of test items
+ * @param itemAccessor The accessor func to retrieve the items
+ * @throws Exception If the arrays are not the same.
+ */
+function checkConsistancy(har, testItems, itemAccessor) {
+ itemAccessor =
+ itemAccessor ||
+ function (item) {
+ return item;
+ };
+ for (let idx in testItems) {
+ let testItem = itemAccessor(testItems[idx]);
+ equal(itemAccessor(har.itemByIndex(idx)).title, testItem.title);
+ equal(itemAccessor(har.itemById(testItem.hashId)).title, testItem.title);
+ equal(har.indexOf(testItems[idx]), idx);
+ }
+}
+
+/**
+ * Man, this function is really hard to keep general enough, I'm almost tempted
+ * to duplicate the code. It checks if the remove and modify operations work for
+ * the given hashed array.
+ *
+ * @param har The Hashed Array
+ * @param testItems The js array with the items
+ * @param postprocessFunc (optional) The function to call after each
+ * operation, but before checking consistency.
+ * @param itemAccessor (optional) The function to access the item for an
+ * array element.
+ * @param itemCreator (optional) Function to create a new item for the
+ * array.
+ */
+function testRemoveModify(har, testItems, postprocessFunc, itemAccessor, itemCreator) {
+ postprocessFunc =
+ postprocessFunc ||
+ function (a, b) {
+ return [a, b];
+ };
+ itemCreator = itemCreator || (title => hashedCreateItem(title));
+ itemAccessor =
+ itemAccessor ||
+ function (item) {
+ return item;
+ };
+
+ // Now, delete the second item and check again
+ har.removeById(itemAccessor(testItems[1]).hashId);
+ testItems.splice(1, 1);
+ [har, testItems] = postprocessFunc(har, testItems);
+
+ checkConsistancy(har, testItems, itemAccessor);
+
+ // Try the same by index
+ har.removeByIndex(2);
+ testItems.splice(2, 1);
+ [har, testItems] = postprocessFunc(har, testItems);
+ checkConsistancy(har, testItems, itemAccessor);
+
+ // Try modifying an item
+ let newInstance = itemCreator("z-changed");
+ itemAccessor(newInstance).id = itemAccessor(testItems[0]).id;
+ testItems[0] = newInstance;
+ har.modifyItem(newInstance);
+ [har, testItems] = postprocessFunc(har, testItems);
+ checkConsistancy(har, testItems, itemAccessor);
+}
+
+/**
+ * Tests the basic cal.HashedArray
+ */
+function test_array_base() {
+ let har, testItems;
+
+ // Test normal additions
+ har = new cal.HashedArray();
+ testItems = ["a", "b", "c", "d"].map(hashedCreateItem);
+
+ testItems.forEach(har.addItem, har);
+ checkConsistancy(har, testItems);
+ testRemoveModify(har, testItems);
+
+ // Test adding in batch mode
+ har = new cal.HashedArray();
+ testItems = ["e", "f", "g", "h"].map(hashedCreateItem);
+ har.startBatch();
+ testItems.forEach(har.addItem, har);
+ har.endBatch();
+ checkConsistancy(har, testItems);
+ testRemoveModify(har, testItems);
+}
+
+/**
+ * Tests the sorted cal.SortedHashedArray
+ */
+function test_array_sorted() {
+ let har, testItems, testItemsSorted;
+
+ function sortedPostProcess(harParam, tiParam) {
+ tiParam = tiParam.sort(titleComptor);
+ return [harParam, tiParam];
+ }
+
+ // Test normal additions
+ har = new cal.SortedHashedArray(titleComptor);
+ testItems = ["d", "c", "a", "b"].map(hashedCreateItem);
+ testItemsSorted = testItems.sort(titleComptor);
+
+ testItems.forEach(har.addItem, har);
+ checkConsistancy(har, testItemsSorted);
+ testRemoveModify(har, testItemsSorted, sortedPostProcess);
+
+ // Test adding in batch mode
+ har = new cal.SortedHashedArray(titleComptor);
+ testItems = ["e", "f", "g", "h"].map(hashedCreateItem);
+ testItemsSorted = testItems.sort(titleComptor);
+ har.startBatch();
+ testItems.forEach(har.addItem, har);
+ har.endBatch();
+ checkConsistancy(har, testItemsSorted);
+ testRemoveModify(har, testItemsSorted, sortedPostProcess);
+}
+
+/**
+ * Tests cal.SortedHashedArray with a custom hashAccessor.
+ */
+function test_hashAccessor() {
+ let har, testItems, testItemsSorted;
+ let comptor = (a, b) => titleComptor(a.item, b.item);
+
+ har = new cal.SortedHashedArray(comptor);
+ har.hashAccessor = function (obj) {
+ return obj.item.hashId;
+ };
+
+ function itemAccessor(obj) {
+ if (!obj) {
+ do_throw("WTF?");
+ }
+ return obj.item;
+ }
+
+ function itemCreator(title) {
+ return { item: hashedCreateItem(title) };
+ }
+
+ function sortedPostProcess(harParam, tiParam) {
+ tiParam = tiParam.sort(comptor);
+ return [harParam, tiParam];
+ }
+
+ testItems = ["d", "c", "a", "b"].map(itemCreator);
+
+ testItemsSorted = testItems.sort(comptor);
+ testItems.forEach(har.addItem, har);
+ checkConsistancy(har, testItemsSorted, itemAccessor);
+ testRemoveModify(har, testItemsSorted, sortedPostProcess, itemAccessor, itemCreator);
+}
diff --git a/comm/calendar/test/unit/test_ics.js b/comm/calendar/test/unit/test_ics.js
new file mode 100644
index 0000000000..e63361fc35
--- /dev/null
+++ b/comm/calendar/test/unit/test_ics.js
@@ -0,0 +1,235 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalRelation: "resource:///modules/CalRelation.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+function run_test() {
+ test_folding();
+ test_icalProps();
+ test_roundtrip();
+ test_duration();
+ test_serialize();
+}
+
+var test_data = [
+ {
+ expectedDateProps: {
+ month: 10,
+ day: 25,
+ year: 2004,
+ isDate: true,
+ },
+ expectedProps: {
+ title: "Christmas",
+ id: "20041119T052239Z-1000472-1-5c0746bb-Oracle",
+ priority: 0,
+ status: "CONFIRMED",
+ },
+ ics:
+ "BEGIN:VCALENDAR\n" +
+ "PRODID:-//ORACLE//NONSGML CSDK 9.0.5 - CalDAVServlet 9.0.5//EN\n" +
+ "VERSION:2.0\n" +
+ "BEGIN:VEVENT\n" +
+ "UID:20041119T052239Z-1000472-1-5c0746bb-Oracle\n" +
+ "ORGANIZER;X-ORACLE-GUID=E9359406791C763EE0305794071A39A4;CN=Simon Vaillan\n" +
+ " court:mailto:simon.vaillancourt@oracle.com\n" +
+ "SEQUENCE:0\n" +
+ "DTSTAMP:20041124T010028Z\n" +
+ "CREATED:20041119T052239Z\n" +
+ "X-ORACLE-EVENTINSTANCE-GUID:I1+16778354+1+1+438153759\n" +
+ "X-ORACLE-EVENT-GUID:E1+16778354+1+438153759\n" +
+ "X-ORACLE-EVENTTYPE:DAY EVENT\n" +
+ "TRANSP:TRANSPARENT\n" +
+ "SUMMARY:Christmas\n" +
+ "STATUS:CONFIRMED\n" +
+ "PRIORITY:0\n" +
+ "DTSTART;VALUE=DATE:20041125\n" +
+ "DTEND;VALUE=DATE:20041125\n" +
+ "CLASS:PUBLIC\n" +
+ "ATTENDEE;X-ORACLE-GUID=E92F51FB4A48E91CE0305794071A149C;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=James Stevens;PARTSTAT=NEEDS-ACTION:mailto:james.stevens@o\n" +
+ " racle.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E9359406791C763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=FALSE;CN=Simon Vaillancourt;PARTSTAT=ACCEPTED:mailto:simon.vaillan\n" +
+ " court@oracle.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E9359406791D763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Bernard Desruisseaux;PARTSTAT=NEEDS-ACTION:mailto:bernard.\n" +
+ " desruisseaux@oracle.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E9359406791E763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Mario Bonin;PARTSTAT=NEEDS-ACTION:mailto:mario.bonin@oracl\n" +
+ " e.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E9359406791F763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Jeremy Chone;PARTSTAT=NEEDS-ACTION:mailto:jeremy.chone@ora\n" +
+ " cle.com\n" +
+ "ATTENDEE;X-ORACLE-PERSONAL-COMMENT-ISDIRTY=TRUE;X-ORACLE-GUID=E9359406792\n" +
+ " 0763EE0305794071A39A4;CUTYPE=INDIVIDUAL;RSVP=TRUE;CN=Mike Shaver;PARTSTA\n" +
+ " T=NEEDS-ACTION:mailto:mike.x.shaver@oracle.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067921763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=David Ball;PARTSTAT=NEEDS-ACTION:mailto:david.ball@oracle.\n" +
+ " com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067922763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Marten Haring;PARTSTAT=NEEDS-ACTION:mailto:marten.den.hari\n" +
+ " ng@oracle.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067923763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Peter Egyed;PARTSTAT=NEEDS-ACTION:mailto:peter.egyed@oracl\n" +
+ " e.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067924763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Francois Perrault;PARTSTAT=NEEDS-ACTION:mailto:francois.pe\n" +
+ " rrault@oracle.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067925763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Vladimir Vukicevic;PARTSTAT=NEEDS-ACTION:mailto:vladimir.v\n" +
+ " ukicevic@oracle.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067926763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Cyrus Daboo;PARTSTAT=NEEDS-ACTION:mailto:daboo@isamet.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067927763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Lisa Dusseault;PARTSTAT=NEEDS-ACTION:mailto:lisa@osafounda\n" +
+ " tion.org\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067928763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Dan Mosedale;PARTSTAT=NEEDS-ACTION:mailto:dan.mosedale@ora\n" +
+ " cle.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067929763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Stuart Parmenter;PARTSTAT=NEEDS-ACTION:mailto:stuart.parme\n" +
+ " nter@oracle.com\n" +
+ "END:VEVENT\n" +
+ "END:VCALENDAR\n",
+ },
+ {
+ expectedProps: { "x-magic": "mymagicstring" },
+ ics:
+ "BEGIN:VEVENT\n" +
+ "UID:1\n" +
+ "DTSTART:20070521T100000Z\n" +
+ "X-MAGIC:mymagicstring\n" +
+ "END:VEVENT",
+ },
+];
+
+function test_roundtrip() {
+ function checkEvent(data, event) {
+ checkRoundtrip(data.expectedProps, event);
+
+ // Checking dates
+ if ("expectedDateProps" in data) {
+ checkProps(data.expectedDateProps, event.startDate);
+ checkProps(data.expectedDateProps, event.endDate);
+ }
+ }
+
+ for (let data of test_data) {
+ // First round, use the icalString setter which uses synchronous parsing
+ dump("Checking" + data.ics + "\n");
+ let event = createEventFromIcalString(data.ics);
+ checkEvent(data, event);
+
+ // Now, try the same thing with asynchronous parsing. We need a copy of
+ // the data variable, otherwise javascript will mix the data between
+ // foreach loop iterations.
+ do_test_pending();
+ let thisdata = data;
+ cal.icsService.parseICSAsync(data.ics, {
+ onParsingComplete(rc, rootComp) {
+ try {
+ ok(Components.isSuccessCode(rc));
+ let event2 = new CalEvent();
+ event2.icalComponent = rootComp;
+ checkEvent(thisdata, event2);
+ do_test_finished();
+ } catch (e) {
+ do_throw(e + "\n");
+ do_test_finished();
+ }
+ },
+ });
+ }
+}
+
+function test_folding() {
+ // check folding
+ const id =
+ "loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong-id-provoking-folding";
+ let todo = new CalTodo(),
+ todo_ = new CalTodo();
+ todo.id = id;
+ todo_.icalString = todo.icalString;
+ equal(todo.id, todo_.id);
+ equal(todo_.icalComponent.getFirstProperty("UID").value, id);
+}
+
+function test_icalProps() {
+ checkIcalProp("ATTACH", new CalAttachment());
+ checkIcalProp("ATTENDEE", new CalAttendee());
+ checkIcalProp("RELATED-TO", new CalRelation());
+}
+
+/*
+ * Helper functions
+ */
+
+function checkIcalProp(aPropName, aObj) {
+ let prop1 = cal.icsService.createIcalProperty(aPropName);
+ let prop2 = cal.icsService.createIcalProperty(aPropName);
+ prop1.value = "foo";
+ prop2.value = "bar";
+ prop1.setParameter("X-FOO", "BAR");
+
+ if (aObj.setParameter) {
+ aObj.icalProperty = prop1;
+ equal(aObj.getParameter("X-FOO"), "BAR");
+ aObj.icalProperty = prop2;
+ equal(aObj.getParameter("X-FOO"), null);
+ } else if (aObj.setProperty) {
+ aObj.icalProperty = prop1;
+ equal(aObj.getProperty("X-FOO"), "BAR");
+ aObj.icalProperty = prop2;
+ equal(aObj.getProperty("X-FOO"), null);
+ }
+}
+
+function checkProps(expectedProps, obj) {
+ for (let key in expectedProps) {
+ equal(obj[key], expectedProps[key]);
+ }
+}
+
+function checkRoundtrip(expectedProps, obj) {
+ let icsdata = obj.icalString;
+ for (let key in expectedProps) {
+ // Need translation
+ let icskey = key;
+ switch (key) {
+ case "id":
+ icskey = "uid";
+ break;
+ case "title":
+ icskey = "summary";
+ break;
+ }
+ ok(icsdata.includes(icskey.toUpperCase()));
+ ok(icsdata.includes(expectedProps[key]));
+ }
+}
+
+function test_duration() {
+ let e = new CalEvent();
+ e.startDate = cal.createDateTime();
+ e.endDate = null;
+ equal(e.duration.icalString, "PT0S");
+}
+
+function test_serialize() {
+ let e = new CalEvent();
+ let prop = cal.icsService.createIcalComponent("VTODO");
+
+ throws(() => {
+ e.icalComponent = prop;
+ }, /Illegal value/);
+}
diff --git a/comm/calendar/test/unit/test_ics_parser.js b/comm/calendar/test/unit/test_ics_parser.js
new file mode 100644
index 0000000000..cd53823935
--- /dev/null
+++ b/comm/calendar/test/unit/test_ics_parser.js
@@ -0,0 +1,220 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ test_roundtrip();
+ test_async();
+ test_failures();
+ test_fake_parent();
+ test_props_comps();
+ test_timezone();
+}
+
+function test_props_comps() {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ let str = [
+ "BEGIN:VCALENDAR",
+ "X-WR-CALNAME:CALNAME",
+ "BEGIN:VJOURNAL",
+ "LOCATION:BEFORE TIME",
+ "END:VJOURNAL",
+ "BEGIN:VEVENT",
+ "UID:123",
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ].join("\r\n");
+ parser.parseString(str);
+
+ let props = parser.getProperties();
+ equal(props.length, 1);
+ equal(props[0].propertyName, "X-WR-CALNAME");
+ equal(props[0].value, "CALNAME");
+
+ let comps = parser.getComponents();
+ equal(comps.length, 1);
+ equal(comps[0].componentType, "VJOURNAL");
+ equal(comps[0].location, "BEFORE TIME");
+}
+
+function test_failures() {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+
+ do_test_pending();
+ parser.parseString("BOGUS", {
+ onParsingComplete(rc, opparser) {
+ dump("Note: The previous error message is expected ^^\n");
+ equal(rc, Cr.NS_ERROR_FAILURE);
+ do_test_finished();
+ },
+ });
+
+ // No real error here, but there is a message...
+ parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ let str = ["BEGIN:VWORLD", "BEGIN:VEVENT", "UID:123", "END:VEVENT", "END:VWORLD"].join("\r\n");
+ dump("Note: The following error message is expected:\n");
+ parser.parseString(str);
+ equal(parser.getComponents().length, 0);
+ equal(parser.getItems().length, 0);
+}
+
+function test_fake_parent() {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+
+ let str = [
+ "BEGIN:VCALENDAR",
+ "BEGIN:VEVENT",
+ "UID:123",
+ "RECURRENCE-ID:20120101T010101",
+ "DTSTART:20120101T010102",
+ "LOCATION:HELL",
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ].join("\r\n");
+
+ parser.parseString(str);
+
+ let items = parser.getItems();
+ equal(items.length, 1);
+ let item = items[0].QueryInterface(Ci.calIEvent);
+
+ equal(item.id, "123");
+ ok(!!item.recurrenceInfo);
+ equal(item.startDate.icalString, "20120101T010101");
+ equal(item.getProperty("X-MOZ-FAKED-MASTER"), "1");
+
+ let rinfo = item.recurrenceInfo;
+
+ equal(rinfo.countRecurrenceItems(), 1);
+ let excs = rinfo.getOccurrences(cal.createDateTime("20120101T010101"), null, 0);
+ equal(excs.length, 1);
+ let exc = excs[0].QueryInterface(Ci.calIEvent);
+ equal(exc.startDate.icalString, "20120101T010102");
+
+ equal(parser.getParentlessItems()[0], exc);
+}
+
+function test_async() {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ let str = [
+ "BEGIN:VCALENDAR",
+ "BEGIN:VTODO",
+ "UID:1",
+ "DTSTART:20120101T010101",
+ "DUE:20120101T010102",
+ "END:VTODO",
+ "BEGIN:VTODO",
+ "UID:2",
+ "DTSTART:20120101T010103",
+ "DUE:20120101T010104",
+ "END:VTODO",
+ "END:VCALENDAR",
+ ].join("\r\n");
+
+ do_test_pending();
+ parser.parseString(str, {
+ onParsingComplete(rc, opparser) {
+ let items = parser.getItems();
+ equal(items.length, 2);
+ let item = items[0];
+ ok(item.isTodo());
+
+ equal(item.entryDate.icalString, "20120101T010101");
+ equal(item.dueDate.icalString, "20120101T010102");
+
+ item = items[1];
+ ok(item.isTodo());
+
+ equal(item.entryDate.icalString, "20120101T010103");
+ equal(item.dueDate.icalString, "20120101T010104");
+
+ do_test_finished();
+ },
+ });
+}
+
+function test_timezone() {
+ // TODO
+}
+
+function test_roundtrip() {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ let str = [
+ "BEGIN:VCALENDAR",
+ "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN",
+ "VERSION:2.0",
+ "X-PROP:VAL",
+ "BEGIN:VTODO",
+ "UID:1",
+ "DTSTART:20120101T010101",
+ "DUE:20120101T010102",
+ "END:VTODO",
+ "BEGIN:VJOURNAL",
+ "LOCATION:BEFORE TIME",
+ "END:VJOURNAL",
+ "END:VCALENDAR",
+ "",
+ ].join("\r\n");
+
+ parser.parseString(str);
+
+ let items = parser.getItems();
+ serializer.addItems(items);
+
+ parser.getProperties().forEach(serializer.addProperty, serializer);
+ parser.getComponents().forEach(serializer.addComponent, serializer);
+
+ equal(
+ serializer.serializeToString().split("\r\n").sort().join("\r\n"),
+ str.split("\r\n").sort().join("\r\n")
+ );
+
+ // Test parseFromStream
+ parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ let stream = serializer.serializeToInputStream();
+
+ parser.parseFromStream(stream);
+
+ items = parser.getItems();
+ let comps = parser.getComponents();
+ let props = parser.getProperties();
+ equal(items.length, 1);
+ equal(comps.length, 1);
+ equal(props.length, 1);
+
+ let everything = items[0].icalString
+ .split("\r\n")
+ .concat(comps[0].serializeToICS().split("\r\n"));
+ everything.push(props[0].icalString.split("\r\n")[0]);
+ everything.sort();
+
+ equal(everything.join("\r\n"), str.split("\r\n").concat([""]).sort().join("\r\n"));
+
+ // Test serializeToStream/parseFromStream
+ parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ let pipe = Cc["@mozilla.org/pipe;1"].createInstance(Ci.nsIPipe);
+ pipe.init(true, true, 0, 0, null);
+
+ serializer.serializeToStream(pipe.outputStream);
+ parser.parseFromStream(pipe.inputStream);
+
+ items = parser.getItems();
+ comps = parser.getComponents();
+ props = parser.getProperties();
+ equal(items.length, 1);
+ equal(comps.length, 1);
+ equal(props.length, 1);
+
+ everything = items[0].icalString.split("\r\n").concat(comps[0].serializeToICS().split("\r\n"));
+ everything.push(props[0].icalString.split("\r\n")[0]);
+ everything.sort();
+
+ equal(everything.join("\r\n"), str.split("\r\n").concat([""]).sort().join("\r\n"));
+}
diff --git a/comm/calendar/test/unit/test_ics_service.js b/comm/calendar/test/unit/test_ics_service.js
new file mode 100644
index 0000000000..1b69802406
--- /dev/null
+++ b/comm/calendar/test/unit/test_ics_service.js
@@ -0,0 +1,289 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalRelation: "resource:///modules/CalRelation.jsm",
+});
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ test_iterator();
+ test_icalcomponent();
+ test_icsservice();
+ test_icalstring();
+ test_param();
+ test_icalproperty();
+}
+
+function test_icalstring() {
+ function checkComp(createFunc, icalString, members, properties) {
+ let thing = createFunc(icalString);
+ equal(ics_unfoldline(thing.icalString), icalString + "\r\n");
+
+ if (members) {
+ for (let k in members) {
+ equal(thing[k], members[k]);
+ }
+ }
+
+ if (properties) {
+ for (let k in properties) {
+ if ("getParameter" in thing) {
+ equal(thing.getParameter(k), properties[k]);
+ } else if ("getProperty" in thing) {
+ equal(thing.getProperty(k), properties[k]);
+ }
+ }
+ }
+ return thing;
+ }
+
+ let attach = checkComp(
+ icalString => new CalAttachment(icalString),
+ "ATTACH;ENCODING=BASE64;FMTTYPE=text/calendar;FILENAME=test.ics:http://example.com/test.ics",
+ { formatType: "text/calendar", encoding: "BASE64" },
+ { FILENAME: "test.ics" }
+ );
+ equal(attach.uri.spec, "http://example.com/test.ics");
+
+ checkComp(
+ icalString => new CalAttendee(icalString),
+ "ATTENDEE;RSVP=TRUE;CN=Name;PARTSTAT=ACCEPTED;CUTYPE=RESOURCE;ROLE=REQ-PARTICIPANT;X-THING=BAR:mailto:test@example.com",
+ {
+ id: "mailto:test@example.com",
+ commonName: "Name",
+ rsvp: "TRUE",
+ isOrganizer: false,
+ role: "REQ-PARTICIPANT",
+ participationStatus: "ACCEPTED",
+ userType: "RESOURCE",
+ },
+ { "X-THING": "BAR" }
+ );
+
+ checkComp(
+ icalString => new CalRelation(icalString),
+ "RELATED-TO;RELTYPE=SIBLING;FOO=BAR:VALUE",
+ { relType: "SIBLING", relId: "VALUE" },
+ { FOO: "BAR" }
+ );
+
+ let rrule = checkComp(
+ cal.createRecurrenceRule.bind(cal),
+ "RRULE:FREQ=WEEKLY;COUNT=5;INTERVAL=2;BYDAY=MO",
+ { count: 5, isByCount: true, type: "WEEKLY", interval: 2 }
+ );
+ equal(rrule.getComponent("BYDAY").toString(), [2].toString());
+
+ let rdate = checkComp(cal.createRecurrenceDate.bind(cal), "RDATE:20120101T000000", {
+ isNegative: false,
+ });
+ equal(rdate.date.compare(cal.createDateTime("20120101T000000")), 0);
+
+ /* TODO consider removing period support, ics throws badarg
+ let rdateperiod = checkComp(cal.createRecurrenceDate.bind(cal),
+ "RDATE;VALUE=PERIOD;20120101T000000Z/20120102T000000Z");
+ equal(rdate.date.compare(cal.createDateTime("20120101T000000Z")), 0);
+ */
+
+ let exdate = checkComp(cal.createRecurrenceDate.bind(cal), "EXDATE:20120101T000000", {
+ isNegative: true,
+ });
+ equal(exdate.date.compare(cal.createDateTime("20120101T000000")), 0);
+}
+
+function test_icsservice() {
+ function checkProp(createFunc, icalString, members, parameters) {
+ let thing = createFunc(icalString);
+ equal(ics_unfoldline(thing.icalString), icalString + "\r\n");
+
+ for (let k in members) {
+ equal(thing[k], members[k]);
+ }
+
+ for (let k in parameters) {
+ equal(thing.getParameter(k), parameters[k]);
+ }
+ return thing;
+ }
+
+ // Test ::createIcalPropertyFromString
+ checkProp(
+ cal.icsService.createIcalPropertyFromString.bind(cal.icsService),
+ "ATTACH;ENCODING=BASE64;FMTTYPE=text/calendar;FILENAME=test.ics:http://example.com/test.ics",
+ { value: "http://example.com/test.ics", propertyName: "ATTACH" },
+ { ENCODING: "BASE64", FMTTYPE: "text/calendar", FILENAME: "test.ics" }
+ );
+
+ checkProp(
+ cal.icsService.createIcalPropertyFromString.bind(cal.icsService),
+ "DESCRIPTION:new\\nlines\\nare\\ngreat\\,eh?",
+ {
+ value: "new\nlines\nare\ngreat,eh?",
+ valueAsIcalString: "new\\nlines\\nare\\ngreat\\,eh?",
+ },
+ {}
+ );
+
+ // Test ::createIcalProperty
+ let attach2 = cal.icsService.createIcalProperty("ATTACH");
+ equal(attach2.propertyName, "ATTACH");
+ attach2.value = "http://example.com/";
+ equal(attach2.icalString, "ATTACH:http://example.com/\r\n");
+}
+
+function test_icalproperty() {
+ let comp = cal.icsService.createIcalComponent("VEVENT");
+ let prop = cal.icsService.createIcalProperty("PROP");
+ prop.value = "VAL";
+
+ comp.addProperty(prop);
+ equal(prop.parent.toString(), comp.toString());
+ equal(prop.valueAsDatetime, null);
+
+ prop = cal.icsService.createIcalProperty("DESCRIPTION");
+ prop.value = "A\nB";
+ equal(prop.value, "A\nB");
+ equal(prop.valueAsIcalString, "A\\nB");
+ equal(prop.valueAsDatetime, null);
+
+ prop = cal.icsService.createIcalProperty("DESCRIPTION");
+ prop.valueAsIcalString = "A\\nB";
+ equal(prop.value, "A\nB");
+ equal(prop.valueAsIcalString, "A\\nB");
+ equal(prop.valueAsDatetime, null);
+
+ prop = cal.icsService.createIcalProperty("DESCRIPTION");
+ prop.value = "A\\nB";
+ equal(prop.value, "A\\nB");
+ equal(prop.valueAsIcalString, "A\\\\nB");
+ equal(prop.valueAsDatetime, null);
+
+ prop = cal.icsService.createIcalProperty("GEO");
+ prop.value = "43.4913662534171;12.085559129715";
+ equal(prop.value, "43.4913662534171;12.085559129715");
+ equal(prop.valueAsIcalString, "43.4913662534171;12.085559129715");
+}
+
+function test_icalcomponent() {
+ let event = cal.icsService.createIcalComponent("VEVENT");
+ let alarm = cal.icsService.createIcalComponent("VALARM");
+ event.addSubcomponent(alarm);
+
+ // Check that the parent works and does not appear on cloned instances
+ let alarm2 = alarm.clone();
+ equal(alarm.parent.toString(), event.toString());
+ equal(alarm2.parent, null);
+
+ function check_getset(key, value) {
+ dump("Checking " + key + " = " + value + "\n");
+ event[key] = value;
+ let valuestring = value.icalString || value;
+ equal(event[key].icalString || event[key], valuestring);
+ equal(event.serializeToICS().match(new RegExp(valuestring, "g")).length, 1);
+ event[key] = value;
+ equal(event.serializeToICS().match(new RegExp(valuestring, "g")).length, 1);
+ }
+
+ let props = [
+ ["uid", "123"],
+ ["prodid", "//abc/123"],
+ ["version", "2.0"],
+ ["method", "REQUEST"],
+ ["status", "TENTATIVE"],
+ ["summary", "sum"],
+ ["description", "descr"],
+ ["location", "here"],
+ ["categories", "cat"],
+ ["URL", "url"],
+ ["priority", 5],
+ ["startTime", cal.createDateTime("20120101T010101")],
+ ["endTime", cal.createDateTime("20120101T010102")],
+ /* TODO readonly, how to set... ["duration", cal.createDuration("PT2S")], */
+ ["dueTime", cal.createDateTime("20120101T010103")],
+ ["stampTime", cal.createDateTime("20120101T010104")],
+ ["createdTime", cal.createDateTime("20120101T010105")],
+ ["completedTime", cal.createDateTime("20120101T010106")],
+ ["lastModified", cal.createDateTime("20120101T010107")],
+ ["recurrenceId", cal.createDateTime("20120101T010108")],
+ ];
+
+ for (let prop of props) {
+ check_getset(...prop);
+ }
+}
+
+function test_param() {
+ let prop = cal.icsService.createIcalProperty("DTSTART");
+ prop.value = "20120101T010101";
+ equal(prop.icalString, "DTSTART:20120101T010101\r\n");
+ prop.setParameter("VALUE", "TEXT");
+ equal(prop.icalString, "DTSTART;VALUE=TEXT:20120101T010101\r\n");
+ prop.removeParameter("VALUE");
+ equal(prop.icalString, "DTSTART:20120101T010101\r\n");
+
+ prop.setParameter("X-FOO", "BAR");
+ equal(prop.icalString, "DTSTART;X-FOO=BAR:20120101T010101\r\n");
+ prop.removeParameter("X-FOO", "BAR");
+ equal(prop.icalString, "DTSTART:20120101T010101\r\n");
+}
+
+function test_iterator() {
+ // Property iterator
+ let comp = cal.icsService.createIcalComponent("VEVENT");
+ let propNames = ["X-ONE", "X-TWO"];
+ for (let i = 0; i < propNames.length; i++) {
+ let prop = cal.icsService.createIcalProperty(propNames[i]);
+ prop.value = "" + (i + 1);
+ comp.addProperty(prop);
+ }
+
+ for (let prop = comp.getFirstProperty("ANY"); prop; prop = comp.getNextProperty("ANY")) {
+ equal(prop.propertyName, propNames.shift());
+ equal(prop.parent.toString(), comp.toString());
+ }
+ propNames = ["X-ONE", "X-TWO"];
+ for (let prop = comp.getNextProperty("ANY"); prop; prop = comp.getNextProperty("ANY")) {
+ equal(prop.propertyName, propNames.shift());
+ equal(prop.parent.toString(), comp.toString());
+ }
+
+ // Property iterator with multiple values
+ // eslint-disable-next-line no-useless-concat
+ comp = cal.icsService.parseICS("BEGIN:VEVENT\r\n" + "CATEGORIES:a,b,c\r\n" + "END:VEVENT");
+ let propValues = ["a", "b", "c"];
+ for (
+ let prop = comp.getFirstProperty("CATEGORIES");
+ prop;
+ prop = comp.getNextProperty("CATEGORIES")
+ ) {
+ equal(prop.propertyName, "CATEGORIES");
+ equal(prop.value, propValues.shift());
+ equal(prop.parent.toString(), comp.toString());
+ }
+
+ // Param iterator
+ let dtstart = cal.icsService.createIcalProperty("DTSTART");
+ let params = ["X-ONE", "X-TWO"];
+ for (let i = 0; i < params.length; i++) {
+ dtstart.setParameter(params[i], "" + (i + 1));
+ }
+
+ for (let prop = dtstart.getFirstParameterName(); prop; prop = dtstart.getNextParameterName()) {
+ equal(prop, params.shift());
+ }
+
+ // Now try again, but start with next. Should act like first
+ params = ["X-ONE", "X-TWO"];
+ for (let param = dtstart.getNextParameterName(); param; param = dtstart.getNextParameterName()) {
+ equal(param, params.shift());
+ }
+}
diff --git a/comm/calendar/test/unit/test_imip.js b/comm/calendar/test/unit/test_imip.js
new file mode 100644
index 0000000000..ba9e5f5c6b
--- /dev/null
+++ b/comm/calendar/test/unit/test_imip.js
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalItipEmailTransport } = ChromeUtils.import("resource:///modules/CalItipEmailTransport.jsm");
+
+function itipItemForTest(title, seq) {
+ let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem);
+ itipItem.init(
+ [
+ "BEGIN:VCALENDAR",
+ "METHOD:REQUEST",
+ "BEGIN:VEVENT",
+ "SUMMARY:" + title,
+ "SEQUENCE:" + (seq || 0),
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ].join("\r\n")
+ );
+ return itipItem;
+}
+
+let transport = new CalItipEmailTransport();
+
+add_task(function test_title_in_subject() {
+ Services.prefs.setBoolPref("calendar.itip.useInvitationSubjectPrefixes", false);
+ let items = transport._prepareItems(itipItemForTest("foo"));
+ equal(items.subject, "foo");
+});
+
+add_task(function test_title_in_summary() {
+ Services.prefs.setBoolPref("calendar.itip.useInvitationSubjectPrefixes", true);
+ let items = transport._prepareItems(itipItemForTest("bar"));
+ equal(items.subject, "Invitation: bar");
+});
+
+add_task(function test_updated_title_in_subject() {
+ Services.prefs.setBoolPref("calendar.itip.useInvitationSubjectPrefixes", false);
+ let items = transport._prepareItems(itipItemForTest("foo", 2));
+ equal(items.subject, "foo");
+});
+
+add_task(function test_updated_title_in_summary() {
+ Services.prefs.setBoolPref("calendar.itip.useInvitationSubjectPrefixes", true);
+ let items = transport._prepareItems(itipItemForTest("bar", 2));
+ equal(items.subject, "Updated: bar");
+});
diff --git a/comm/calendar/test/unit/test_invitationutils.js b/comm/calendar/test/unit/test_invitationutils.js
new file mode 100644
index 0000000000..223ff1a6e6
--- /dev/null
+++ b/comm/calendar/test/unit/test_invitationutils.js
@@ -0,0 +1,1654 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+});
+
+function run_test() {
+ do_calendar_startup(run_next_test);
+}
+
+// tests for calInvitationUtils.jsm
+
+// Make sure that the Europe/Berlin timezone and long datetime format is set.
+Services.prefs.setIntPref("calendar.date.format", 0);
+Services.prefs.setStringPref("calendar.timezone.local", "Europe/Berlin");
+
+/**
+ * typedef {Object} FullIcsValue
+ *
+ * @property {Object<string, string>} 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<string, (IcsValue | IcsValue[])>} [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.
+ *
+ * @returns {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"];
+
+ 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",
+ "TZOFFSETFROM:+0100",
+ "TZOFFSETTO:+0200",
+ "TZNAME:CEST",
+ "DTSTART:19700329T020000",
+ "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU",
+ "END:DAYLIGHT",
+ "BEGIN:STANDARD",
+ "TZOFFSETFROM:+0200",
+ "TZOFFSETTO:+0100",
+ "TZNAME:CET",
+ "DTSTART:19701025T030000",
+ "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU",
+ "END:STANDARD",
+ "END:VTIMEZONE",
+ "BEGIN:VEVENT",
+ ]);
+
+ 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);
+ }
+
+ // 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: "REQUEST",
+ attendee: "",
+ },
+ expected: "Organizer has invited you to Test Event",
+ },
+ {
+ name: "Organizer cancels event",
+ input: {
+ method: "CANCEL",
+ attendee: "",
+ },
+ expected: "Organizer has canceled this event: Test Event",
+ },
+ {
+ name: "Organizer declines counter proposal",
+ input: {
+ 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: "COUNTER",
+ attendee: {
+ params: { rsvp: "TRUE", cn: "Attendee1", partstat: "DECLINED", role: "REQ-PARTICIPANT" },
+ value: "attendee1@example.net",
+ },
+ },
+ expected: 'Attendee1 <attendee1@example.net> has made a counterproposal for "Test Event":',
+ },
+ {
+ name: "Attendee replies with acceptance",
+ input: {
+ method: "REPLY",
+ attendee: {
+ params: { rsvp: "TRUE", cn: "Attendee1", partstat: "ACCEPTED", role: "REQ-PARTICIPANT" },
+ value: "attendee1@example.net",
+ },
+ },
+ expected: "Attendee1 <attendee1@example.net> has accepted your event invitation.",
+ },
+ {
+ name: "Attendee replies with tentative acceptance",
+ input: {
+ method: "REPLY",
+ attendee: {
+ params: { rsvp: "TRUE", cn: "Attendee1", partstat: "TENTATIVE", role: "REQ-PARTICIPANT" },
+ value: "attendee1@example.net",
+ },
+ },
+ expected: "Attendee1 <attendee1@example.net> has accepted your event invitation.",
+ },
+ {
+ name: "Attendee replies with declined",
+ input: {
+ method: "REPLY",
+ attendee: {
+ params: { rsvp: "TRUE", cn: "Attendee1", partstat: "DECLINED", role: "REQ-PARTICIPANT" },
+ value: "attendee1@example.net",
+ },
+ },
+ expected: "Attendee1 <attendee1@example.net> has declined your event invitation.",
+ },
+ {
+ name: "Attendee1 accepts and Attendee2 declines",
+ input: {
+ 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 <attendee1@example.net> has accepted your event invitation.",
+ },
+ {
+ name: "Unsupported method",
+ input: {
+ method: "UNSUPPORTED",
+ attendee: "",
+ },
+ expected: "Event Invitation",
+ },
+ {
+ name: "No method",
+ input: {
+ method: "",
+ attendee: "",
+ },
+ expected: "Event Invitation",
+ },
+ ];
+ for (let test of data) {
+ let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem);
+ let item = getIcs(test.input);
+ itipItem.init(item);
+ 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 ${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 = [
+ {
+ 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",
+ content:
+ 'Go to <a class="moz-txt-link-freetext" href="https://www.example.net">' +
+ "https://www.example.net</a> if you can.",
+ },
+ },
+ {
+ name: "Description plain link",
+ input: { description: "Go to www.example.net if you can." },
+ expected: {
+ node: "imipHtml-description-content",
+ content:
+ 'Go to <a class="moz-txt-link-abbreviated" href="http://www.example.net">' +
+ "www.example.net</a> if you can.",
+ },
+ },
+ {
+ name: "Description with +/-",
+ input: { description: "Let's see if +/- still can be displayed." },
+ expected: {
+ node: "imipHtml-description-content",
+ content: "Let's see if +/- still can be displayed.",
+ },
+ },
+ {
+ name: "Description with mailto",
+ input: { description: "Or write to mailto:faq@example.net instead." },
+ expected: {
+ node: "imipHtml-description-content",
+ content:
+ 'Or write to <a class="moz-txt-link-freetext" ' +
+ 'href="mailto:faq@example.net">mailto:faq@example.net</a> instead.',
+ },
+ },
+ {
+ name: "Description with email",
+ input: { description: "Or write to faq@example.net instead." },
+ expected: {
+ node: "imipHtml-description-content",
+ content:
+ 'Or write to <a class="moz-txt-link-abbreviated" ' +
+ 'href="mailto:faq@example.net">faq@example.net</a> instead.',
+ },
+ },
+ {
+ name: "Description with emoticon",
+ input: { description: "It's up to you ;-)" },
+ expected: {
+ node: "imipHtml-description-content",
+ content: "It's up to you ;-)",
+ },
+ },
+ {
+ name: "Removed script injection from description",
+ input: {
+ description:
+ 'Let\'s see how evil we can be: <script language="JavaScript">' +
+ 'document.getElementById("imipHtml-description-content")' +
+ '.write("Script embedded!")</script>',
+ },
+ expected: {
+ node: "imipHtml-description-content",
+ content: "Let's see how evil we can be: ",
+ },
+ },
+ {
+ name: "Removed img src injection from description",
+ input: {
+ description:
+ 'Or we can try: <img src="document.getElementById("imipHtml-' +
+ 'description-descr").innerText" >',
+ },
+ expected: {
+ node: "imipHtml-description-content",
+ content: "Or we can try: ",
+ },
+ },
+ {
+ name: "Description with special characters",
+ input: {
+ description:
+ 'Check <a href="http://example.com">example.com</a>&nbsp;&nbsp;&mdash; only 3 &euro;',
+ },
+ expected: {
+ node: "imipHtml-description-content",
+ content: 'Check <a href="http://example.com">example.com</a>&nbsp;&nbsp;β€” only 3 €',
+ },
+ },
+ {
+ name: "URL",
+ input: { url: "http://www.example.org/event.ics" },
+ expected: {
+ node: "imipHtml-url-content",
+ content:
+ '<a class="moz-txt-link-freetext" href="http://www.example.org/event.ics">' +
+ "http://www.example.org/event.ics</a>",
+ },
+ },
+ {
+ name: "URL attachment",
+ input: { attach: "http://www.example.org" },
+ expected: {
+ node: "imipHtml-attachments-content",
+ content:
+ '<a class="moz-txt-link-freetext" href="http://www.example.org/">' +
+ "http://www.example.org/</a>",
+ },
+ },
+ {
+ name: "Non-URL attachment is ignored",
+ input: {
+ 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",
+ content:
+ '<a class="moz-txt-link-freetext" href="http://www.example.org/first/">' +
+ "http://www.example.org/first/</a><br>" +
+ '<a class="moz-txt-link-freetext" href="http://www.example.org/second">' +
+ "http://www.example.org/second</a><br>" +
+ '<a class="moz-txt-link-freetext">file:///N:/folder/third.file</a>',
+ },
+ },
+ {
+ name: "Attendees",
+ input: {
+ 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",
+ attendeesList: [
+ {
+ name: "Attendee 1 <attendee1@example.net>",
+ title:
+ "Attendee 1 <attendee1@example.net> is an optional " +
+ "participant. Attendee 1 still needs to reply.",
+ icon: {
+ attendeerole: "OPT-PARTICIPANT",
+ usertype: "INDIVIDUAL",
+ partstat: "NEEDS-ACTION",
+ },
+ },
+ {
+ name: "attendee2@example.net",
+ title:
+ "attendee2@example.net (group) is a non-participant. " +
+ "attendee2@example.net has confirmed attendance.",
+ icon: {
+ attendeerole: "NON-PARTICIPANT",
+ usertype: "GROUP",
+ partstat: "ACCEPTED",
+ },
+ },
+ {
+ name: "attendee3@example.net",
+ title:
+ "attendee3@example.net (resource) is a required " +
+ "participant. attendee3@example.net has confirmed attendance " +
+ "tentatively.",
+ icon: {
+ attendeerole: "REQ-PARTICIPANT",
+ usertype: "RESOURCE",
+ partstat: "TENTATIVE",
+ },
+ },
+ {
+ name: "attendee4@example.net (delegated from attendee5@example.net)",
+ title:
+ "attendee4@example.net (room) is an optional participant. " +
+ "attendee4@example.net has declined attendance.",
+ icon: {
+ attendeerole: "OPT-PARTICIPANT",
+ usertype: "ROOM",
+ partstat: "DECLINED",
+ },
+ },
+ {
+ name: "attendee5@example.net",
+ title:
+ "attendee5@example.net is an optional participant. " +
+ "attendee5@example.net has delegated attendance to " +
+ "attendee4@example.net.",
+ icon: {
+ attendeerole: "OPT-PARTICIPANT",
+ usertype: "UNKNOWN",
+ partstat: "DELEGATED",
+ },
+ },
+ {
+ name: "attendee6@example.net",
+ title:
+ "attendee6@example.net is a required participant. " +
+ "attendee6@example.net still needs to reply.",
+ icon: {
+ attendeerole: "REQ-PARTICIPANT",
+ usertype: "INDIVIDUAL",
+ partstat: "NEEDS-ACTION",
+ },
+ },
+ {
+ name: "attendee7@example.net",
+ title:
+ "attendee7@example.net is a required participant. " +
+ "attendee7@example.net still needs to reply.",
+ icon: {
+ attendeerole: "REQ-PARTICIPANT",
+ usertype: "INDIVIDUAL",
+ partstat: "NEEDS-ACTION",
+ },
+ },
+ ],
+ },
+ },
+ {
+ name: "Organizer",
+ input: {
+ organizer: {
+ params: {
+ partstat: "ACCEPTED",
+ role: "CHAIR",
+ cutype: "INDIVIDUAL",
+ cn: '"The Organizer"',
+ },
+ value: "organizer@example.net",
+ },
+ },
+ expected: {
+ node: "imipHtml-organizer-cell",
+ organizer: {
+ name: "The Organizer <organizer@example.net>",
+ title:
+ "The Organizer <organizer@example.net> chairs the event. " +
+ "The Organizer has confirmed attendance.",
+ icon: {
+ attendeerole: "CHAIR",
+ usertype: "INDIVIDUAL",
+ partstat: "ACCEPTED",
+ },
+ },
+ },
+ },
+ ];
+
+ 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})`
+ );
+ }
+ }
+
+ 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})`
+ );
+ 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 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: { 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",
+ content: [
+ { type: "added", text: "Another location" },
+ { type: "removed", text: "This place" },
+ ],
+ },
+ },
+ {
+ name: "Summary",
+ input: {
+ previous: { summary: "My invitation" },
+ current: { summary: "My new invitation" },
+ },
+ expected: {
+ node: "imipHtml-summary-content",
+ content: [
+ { type: "added", text: "My new invitation" },
+ { type: "removed", text: "My invitation" },
+ ],
+ },
+ },
+ {
+ name: "When",
+ input: {
+ previous: {
+ dtstart: "20150909T130000",
+ dtend: "20150909T140000",
+ },
+ current: {
+ dtstart: "20150909T140000",
+ dtend: "20150909T150000",
+ },
+ },
+ expected: {
+ node: "imipHtml-when-content",
+ 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: "organizer1@example.net" },
+ current: { organizer: "organizer1@example.net" },
+ },
+ expected: {
+ node: "imipHtml-organizer-cell",
+ 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: [
+ {
+ 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: [
+ {
+ // 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",
+ 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" },
+ ],
+ },
+ },
+ ];
+
+ 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;
+ }
+ }
+
+ 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",
+ identity: {
+ fullName: "Invitation sender",
+ email: "sender@example.net",
+ replyTo: "no-reply@example.net",
+ organization: "Example Net",
+ cc: "cc@example.net",
+ bcc: "bcc@example.net",
+ },
+ },
+ expected:
+ "MIME-version: 1.0\r\n" +
+ "Return-path: no-reply@example.net\r\n" +
+ "From: Invitation sender <sender@example.net>\r\n" +
+ "Organization: Example Net\r\n" +
+ "To: recipient@example.net\r\n" +
+ "Subject: Invitation: test subject\r\n" +
+ "Cc: cc@example.net\r\n" +
+ "Bcc: bcc@example.net\r\n",
+ },
+ {
+ // test #2
+ input: {
+ toList: 'rec1@example.net, Recipient 2 <rec2@example.net>, "Rec, 3" <rec3@example.net>',
+ subject: "Invitation: test subject",
+ identity: {
+ fullName: '"invitation, sender"',
+ email: "sender@example.net",
+ replyTo: "no-reply@example.net",
+ organization: "Example Net",
+ cc: 'cc1@example.net, Cc 2 <cc2@example.net>, "Cc, 3" <cc3@example.net>',
+ bcc: 'bcc1@example.net, BCc 2 <bcc2@example.net>, "Bcc, 3" <bcc3@example.net>',
+ },
+ },
+ expected:
+ "MIME-version: 1.0\r\n" +
+ "Return-path: no-reply@example.net\r\n" +
+ 'From: "invitation, sender" <sender@example.net>\r\n' +
+ "Organization: Example Net\r\n" +
+ 'To: rec1@example.net, Recipient 2 <rec2@example.net>,\r\n "Rec, 3" <rec3@example.net>\r\n' +
+ "Subject: Invitation: test subject\r\n" +
+ 'Cc: cc1@example.net, Cc 2 <cc2@example.net>, "Cc, 3" <cc3@example.net>\r\n' +
+ 'Bcc: bcc1@example.net, BCc 2 <bcc2@example.net>, "Bcc, 3"\r\n <bcc3@example.net>\r\n',
+ },
+ {
+ // test #3
+ input: {
+ toList: "recipient@example.net",
+ subject: "Invitation: test subject",
+ identity: { email: "sender@example.net" },
+ },
+ expected:
+ "MIME-version: 1.0\r\n" +
+ "From: sender@example.net\r\n" +
+ "To: recipient@example.net\r\n" +
+ "Subject: Invitation: test subject\r\n",
+ },
+ {
+ // test #4
+ input: {
+ toList: "Max MΓΌller <mueller@example.net>",
+ subject: "Invitation: Diacritis check (üÀé)",
+ identity: {
+ fullName: "RenΓ©",
+ email: "sender@example.net",
+ replyTo: "Max & RenΓ© <no-reply@example.net>",
+ organization: "Max & RenΓ©",
+ cc: "RenΓ© <cc@example.net>",
+ bcc: "RenΓ© <bcc@example.net>",
+ },
+ },
+ expected:
+ "MIME-version: 1.0\r\n" +
+ "Return-path: =?UTF-8?B?TWF4ICYgUmVuw6k=?= <no-reply@example.net>\r\n" +
+ "From: =?UTF-8?B?UmVuw6k=?= <sender@example.net>\r\n" +
+ "Organization: =?UTF-8?B?TWF4ICYgUmVuw6k=?=\r\n" +
+ "To: =?UTF-8?Q?Max_M=C3=BCller?= <mueller@example.net>\r\n" +
+ "Subject: =?UTF-8?B?SW52aXRhdGlvbjogRGlhY3JpdGlzIGNoZWNrICjDvMOk?=\r\n =?UTF-8?B" +
+ "?w6kp?=\r\n" +
+ "Cc: =?UTF-8?B?UmVuw6k=?= <cc@example.net>\r\n" +
+ "Bcc: =?UTF-8?B?UmVuw6k=?= <bcc@example.net>\r\n",
+ },
+ ];
+ let i = 0;
+ for (let test of data) {
+ i++;
+ info(`Running test #${i}`);
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = test.input.identity.email || null;
+ identity.fullName = test.input.identity.fullName || null;
+ identity.replyTo = test.input.identity.replyTo || null;
+ identity.organization = test.input.identity.organization || null;
+ identity.doCc = test.input.identity.doCc || test.input.identity.cc;
+ identity.doCcList = test.input.identity.cc || null;
+ identity.doBcc = test.input.identity.doBcc || test.input.identity.bcc;
+ identity.doBccList = test.input.identity.bcc || null;
+
+ let composeUtils = Cc["@mozilla.org/messengercompose/computils;1"].createInstance(
+ Ci.nsIMsgCompUtils
+ );
+ let messageId = composeUtils.msgGenerateMessageId(identity, null);
+
+ let header = cal.invitation.getHeaderSection(
+ messageId,
+ identity,
+ test.input.toList,
+ test.input.subject
+ );
+ // we test Date and Message-ID headers separately to avoid false positives
+ ok(!!header.match(/Date:.+(?:\n|\r\n|\r)/), "(test #" + i + "): date");
+ ok(!!header.match(/Message-ID:.+(?:\n|\r\n|\r)/), "(test #" + i + "): message-id");
+ equal(
+ header.replace(/Date:.+(?:\n|\r\n|\r)/, "").replace(/Message-ID:.+(?:\n|\r\n|\r)/, ""),
+ test.expected.replace(/Date:.+(?:\n|\r\n|\r)/, "").replace(/Message-ID:.+(?:\n|\r\n|\r)/, ""),
+ "(test #" + i + "): all headers"
+ );
+ }
+});
+
+add_task(async function convertFromUnicode_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\nmüller",
+ },
+ {
+ // test #4
+ input: "mΓΌller\r\nmΓΌller",
+ expected: "müller\r\nmüller",
+ },
+ ];
+ let i = 0;
+ for (let test of data) {
+ i++;
+ equal(cal.invitation.convertFromUnicode(test.input), test.expected, "(test #" + i + ")");
+ }
+});
+
+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: "",
+ },
+ ];
+ let i = 0;
+ for (let test of data) {
+ i++;
+ equal(cal.invitation.encodeUTF8(test.input), test.expected, "(test #" + i + ")");
+ }
+});
+
+add_task(async function encodeMimeHeader_test() {
+ let data = [
+ {
+ // test #1
+ input: {
+ header: "Max MΓΌller <m.mueller@example.net>",
+ isEmail: true,
+ },
+ expected: "=?UTF-8?Q?Max_M=C3=BCller?= <m.mueller@example.net>",
+ },
+ {
+ // test #2
+ input: {
+ header: "Max Mueller <m.mueller@example.net>",
+ isEmail: true,
+ },
+ expected: "Max Mueller <m.mueller@example.net>",
+ },
+ {
+ // test #3
+ input: {
+ header: "MΓΌller & MΓΌller",
+ isEmail: false,
+ },
+ expected: "=?UTF-8?B?TcO8bGxlciAmIE3DvGxsZXI=?=",
+ },
+ ];
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+ equal(
+ cal.invitation.encodeMimeHeader(test.input.header, test.input.isEmail),
+ test.expected,
+ "(test #" + i + ")"
+ );
+ }
+});
+
+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",
+ },
+ ],
+ expected: /^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} [+-]\d{4}$/,
+ };
+
+ let i = 0;
+ let timezone = Services.prefs.getStringPref("calendar.timezone.local", null);
+ for (let test of data.input) {
+ i++;
+ if (test.timezone) {
+ Services.prefs.setStringPref("calendar.timezone.local", test.timezone);
+ } else {
+ Services.prefs.clearUserPref("calendar.timezone.local");
+ }
+ let date = test.date ? new Date(test.date) : null;
+ let re = new RegExp(data.expected);
+ ok(re.test(cal.invitation.getRfc5322FormattedDate(date)), "(test #" + i + ")");
+ }
+ Services.prefs.setStringPref("calendar.timezone.local", timezone);
+});
+
+add_task(async function parseCounter_test() {
+ // We are disabling this rule for a more consistent display of this data
+ /* eslint-disable object-curly-newline */
+ let data = [
+ {
+ name: "Basic test to check all currently supported properties",
+ input: {
+ 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",
+ },
+ 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: {
+ summary: {
+ proposed: "Test Event 2",
+ original: "Test Event",
+ },
+ location: {
+ proposed: "Room 2",
+ original: "Room 1",
+ },
+ 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$/,
+ },
+ 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$/,
+ },
+ comment: {
+ proposed: "Sorry, I cannot make it that time.",
+ original: null,
+ },
+ },
+ },
+ },
+ {
+ name: "Test with an unsupported property has been changed",
+ input: {
+ proposed: {
+ method: "COUNTER",
+ attendee: {
+ params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" },
+ value: "attendee@example.net",
+ },
+ location: "Room 2",
+ attach: "http://www.example2.com",
+ dtstamp: "20150909T182048Z",
+ },
+ },
+ expected: {
+ result: { descr: "", type: "OK" },
+ differences: { location: { proposed: "Room 2", original: "Room 1" } },
+ },
+ },
+ {
+ name: "Proposed change not based on the latest update of the invitation",
+ input: {
+ proposed: {
+ method: "COUNTER",
+ attendee: {
+ params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" },
+ value: "attendee@example.net",
+ },
+ location: "Room 2",
+ dtstamp: "20150909T171048Z",
+ },
+ },
+ expected: {
+ result: {
+ descr: "This is a counterproposal not based on the latest event update.",
+ type: "NOTLATESTUPDATE",
+ },
+ differences: { location: { proposed: "Room 2", original: "Room 1" } },
+ },
+ },
+ {
+ name: "Proposed change based on a meanwhile reschuled invitation",
+ input: {
+ proposed: {
+ method: "COUNTER",
+ attendee: {
+ params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" },
+ value: "attendee@example.net",
+ },
+ location: "Room 2",
+ sequence: "0",
+ dtstamp: "20150909T182048Z",
+ },
+ },
+ expected: {
+ result: {
+ descr: "This is a counterproposal to an already rescheduled event.",
+ type: "OUTDATED",
+ },
+ differences: { location: { proposed: "Room 2", original: "Room 1" } },
+ },
+ },
+ {
+ name: "Proposed change for an later sequence of the event",
+ input: {
+ proposed: {
+ method: "COUNTER",
+ attendee: {
+ params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" },
+ value: "attendee@example.net",
+ },
+ location: "Room 2",
+ sequence: "2",
+ dtstamp: "20150909T182048Z",
+ },
+ },
+ expected: {
+ result: {
+ descr: "Invalid sequence number in counterproposal.",
+ type: "ERROR",
+ },
+ differences: {},
+ },
+ },
+ {
+ name: "Proposal to a different event",
+ input: {
+ proposed: {
+ method: "COUNTER",
+ uid: "cb189fdc-0000-0000-0000-31a08802249d",
+ attendee: {
+ params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" },
+ value: "attendee@example.net",
+ },
+ location: "Room 2",
+ dtstamp: "20150909T182048Z",
+ },
+ },
+ expected: {
+ result: {
+ descr: "Mismatch of uid or organizer in counterproposal.",
+ type: "ERROR",
+ },
+ differences: {},
+ },
+ },
+ {
+ name: "Proposal with a different organizer",
+ input: {
+ proposed: {
+ method: "COUNTER",
+ organizer: {
+ params: { rsvp: "TRUE", cn: "Organizer", partstat: "ACCEPTED", role: "CHAIR" },
+ value: "organizer2@example.net",
+ },
+ attendee: {
+ params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" },
+ value: "attendee@example.net",
+ },
+ dtstamp: "20150909T182048Z",
+ },
+ },
+ expected: {
+ result: {
+ descr: "Mismatch of uid or organizer in counterproposal.",
+ type: "ERROR",
+ },
+ differences: {},
+ },
+ },
+ {
+ name: "Counterproposal without any difference",
+ input: {
+ proposed: { method: "COUNTER" },
+ },
+ expected: {
+ result: {
+ descr: "No difference in counterproposal detected.",
+ type: "NODIFF",
+ },
+ differences: {},
+ },
+ },
+ ];
+ /* eslint-enable object-curly-newline */
+
+ let getItem = function (aProperties) {
+ 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 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 ${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 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(
+ proposed.test(foundProposed),
+ `(test ${test.name}: proposed "${foundProposed}" for ${prop} matches expected regex)`
+ );
+ } else {
+ equal(
+ foundProposed,
+ proposed,
+ `(test ${test.name}: proposed for ${prop} matches expected)`
+ );
+ }
+
+ 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);
+ }
+ });
+ for (let prop in test.expected.differences) {
+ if (!parsedProps.includes(prop)) {
+ missingProps.push(prop);
+ }
+ }
+ ok(
+ additionalProps.length == 0,
+ `(test ${test.name}: should be no additional properties: ${additionalProps})`
+ );
+ ok(
+ missingProps.length == 0,
+ `(test ${test.name}: should be no missing properties: ${missingProps})`
+ );
+ }
+});
diff --git a/comm/calendar/test/unit/test_items.js b/comm/calendar/test/unit/test_items.js
new file mode 100644
index 0000000000..fb7fa38ec5
--- /dev/null
+++ b/comm/calendar/test/unit/test_items.js
@@ -0,0 +1,465 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ test_aclmanager();
+ test_calendar();
+ test_immutable();
+ test_attendee();
+ test_attachment();
+ test_lastack();
+ test_categories();
+ test_alarm();
+ test_isEvent();
+ test_isTodo();
+ test_recurring_event_properties();
+ test_recurring_todo_properties();
+ test_recurring_event_exception_properties();
+ test_recurring_todo_exception_properties();
+}
+
+function test_aclmanager() {
+ let mockCalendar = {
+ QueryInterface: ChromeUtils.generateQI(["calICalendar"]),
+
+ get superCalendar() {
+ return this;
+ },
+ get aclManager() {
+ return this;
+ },
+
+ getItemEntry(item) {
+ if (item.id == "withentry") {
+ return itemEntry;
+ }
+ return null;
+ },
+ };
+
+ let itemEntry = {
+ QueryInterface: ChromeUtils.generateQI(["calIItemACLEntry"]),
+ userCanModify: true,
+ userCanRespond: false,
+ userCanViewAll: true,
+ userCanViewDateAndTime: false,
+ };
+
+ let event = new CalEvent();
+ event.id = "withentry";
+ event.calendar = mockCalendar;
+
+ equal(event.aclEntry.userCanModify, itemEntry.userCanModify);
+ equal(event.aclEntry.userCanRespond, itemEntry.userCanRespond);
+ equal(event.aclEntry.userCanViewAll, itemEntry.userCanViewAll);
+ equal(event.aclEntry.userCanViewDateAndTime, itemEntry.userCanViewDateAndTime);
+
+ let parentEntry = new CalEvent();
+ parentEntry.id = "parententry";
+ parentEntry.calendar = mockCalendar;
+ parentEntry.parentItem = event;
+
+ equal(parentEntry.aclEntry.userCanModify, itemEntry.userCanModify);
+ equal(parentEntry.aclEntry.userCanRespond, itemEntry.userCanRespond);
+ equal(parentEntry.aclEntry.userCanViewAll, itemEntry.userCanViewAll);
+ equal(parentEntry.aclEntry.userCanViewDateAndTime, itemEntry.userCanViewDateAndTime);
+
+ event = new CalEvent();
+ event.id = "noentry";
+ event.calendar = mockCalendar;
+ equal(event.aclEntry, null);
+}
+
+function test_calendar() {
+ let event = new CalEvent();
+ let parentEntry = new CalEvent();
+
+ let mockCalendar = {
+ QueryInterface: ChromeUtils.generateQI(["calICalendar"]),
+ id: "one",
+ };
+
+ parentEntry.calendar = mockCalendar;
+ event.parentItem = parentEntry;
+
+ notEqual(event.calendar, null);
+ equal(event.calendar.id, "one");
+}
+
+function test_attachment() {
+ let e = new CalEvent();
+
+ let a = new CalAttachment();
+ a.rawData = "horst";
+
+ let b = new CalAttachment();
+ b.rawData = "bruno";
+
+ e.addAttachment(a);
+ equal(e.getAttachments().length, 1);
+
+ e.addAttachment(b);
+ equal(e.getAttachments().length, 2);
+
+ e.removeAttachment(a);
+ equal(e.getAttachments().length, 1);
+
+ e.removeAllAttachments();
+ equal(e.getAttachments().length, 0);
+}
+
+function test_attendee() {
+ let e = new CalEvent();
+ equal(e.getAttendeeById("unknown"), null);
+ equal(e.getAttendees().length, 0);
+
+ let a = new CalAttendee();
+ a.id = "mailto:horst";
+
+ let b = new CalAttendee();
+ b.id = "mailto:bruno";
+
+ e.addAttendee(a);
+ equal(e.getAttendees().length, 1);
+ equal(e.getAttendeeById("mailto:horst"), a);
+
+ e.addAttendee(b);
+ equal(e.getAttendees().length, 2);
+
+ let comp = e.icalComponent;
+ let aprop = comp.getFirstProperty("ATTENDEE");
+ equal(aprop.value, "mailto:horst");
+ aprop = comp.getNextProperty("ATTENDEE");
+ equal(aprop.value, "mailto:bruno");
+ equal(comp.getNextProperty("ATTENDEE"), null);
+
+ e.removeAttendee(a);
+ equal(e.getAttendees().length, 1);
+ equal(e.getAttendeeById("mailto:horst"), null);
+
+ e.removeAllAttendees();
+ equal(e.getAttendees().length, 0);
+}
+
+function test_categories() {
+ let e = new CalEvent();
+
+ equal(e.getCategories().length, 0);
+
+ let cat = ["a", "b", "c"];
+ e.setCategories(cat);
+
+ cat[0] = "err";
+ equal(e.getCategories().join(","), "a,b,c");
+
+ let comp = e.icalComponent;
+ let getter = comp.getFirstProperty.bind(comp);
+
+ cat[0] = "a";
+ while (cat.length) {
+ equal(cat.shift(), getter("CATEGORIES").value);
+ getter = comp.getNextProperty.bind(comp);
+ }
+}
+
+function test_alarm() {
+ let e = new CalEvent();
+ let alarm = new CalAlarm();
+
+ alarm.action = "DISPLAY";
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE;
+ alarm.alarmDate = cal.createDateTime();
+
+ e.addAlarm(alarm);
+ let ecomp = e.icalComponent;
+ let vcomp = ecomp.getFirstSubcomponent("VALARM");
+ equal(vcomp.serializeToICS(), alarm.icalString);
+
+ let alarm2 = alarm.clone();
+
+ e.addAlarm(alarm2);
+
+ equal(e.getAlarms().length, 2);
+ e.deleteAlarm(alarm);
+ equal(e.getAlarms().length, 1);
+ equal(e.getAlarms()[0], alarm2);
+
+ e.clearAlarms();
+ equal(e.getAlarms().length, 0);
+}
+
+function test_immutable() {
+ let event = new CalEvent();
+
+ let date = cal.createDateTime();
+ date.timezone = cal.timezoneService.getTimezone("Europe/Berlin");
+ event.alarmLastAck = date;
+
+ let org = new CalAttendee();
+ org.id = "one";
+ event.organizer = org;
+
+ let alarm = new CalAlarm();
+ alarm.action = "DISPLAY";
+ alarm.description = "foo";
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ alarm.offset = cal.createDuration("PT1S");
+ event.addAlarm(alarm);
+
+ event.setProperty("X-NAME", "X-VALUE");
+ event.setPropertyParameter("X-NAME", "X-PARAM", "X-PARAMVAL");
+
+ event.setCategories(["a", "b", "c"]);
+
+ equal(event.alarmLastAck.timezone.tzid, cal.dtz.UTC.tzid);
+
+ event.makeImmutable();
+
+ // call again, should not throw
+ event.makeImmutable();
+
+ ok(!event.alarmLastAck.isMutable);
+ ok(!org.isMutable);
+ ok(!alarm.isMutable);
+
+ throws(() => {
+ event.alarmLastAck = cal.createDateTime();
+ }, /Can not modify immutable data container/);
+ throws(() => {
+ event.calendar = null;
+ }, /Can not modify immutable data container/);
+ throws(() => {
+ event.parentItem = null;
+ }, /Can not modify immutable data container/);
+ throws(() => {
+ event.setCategories(["d", "e", "f"]);
+ }, /Can not modify immutable data container/);
+
+ let event2 = event.clone();
+ event2.organizer.id = "two";
+
+ equal(org.id, "one");
+ equal(event2.organizer.id, "two");
+
+ equal(event2.getProperty("X-NAME"), "X-VALUE");
+ equal(event2.getPropertyParameter("X-NAME", "X-PARAM"), "X-PARAMVAL");
+
+ event2.setPropertyParameter("X-NAME", "X-PARAM", null);
+ equal(event2.getPropertyParameter("X-NAME", "X-PARAM"), null);
+
+ // TODO more clone checks
+}
+
+function test_lastack() {
+ let e = new CalEvent();
+
+ e.alarmLastAck = cal.createDateTime("20120101T010101");
+
+ // Our items don't support this yet
+ // equal(e.getProperty("X-MOZ-LASTACK"), "20120101T010101");
+
+ let comp = e.icalComponent;
+ let prop = comp.getFirstProperty("X-MOZ-LASTACK");
+
+ equal(prop.value, "20120101T010101Z");
+
+ prop.value = "20120101T010102Z";
+
+ e.icalComponent = comp;
+
+ equal(e.alarmLastAck.icalString, "20120101T010102Z");
+}
+
+/**
+ * Test isEvent() returns the correct value for events and todos.
+ */
+function test_isEvent() {
+ let event = new CalEvent();
+ let todo = new CalTodo();
+
+ Assert.ok(event.isEvent(), "isEvent() returns true for events");
+ Assert.ok(!todo.isEvent(), "isEvent() returns false for todos");
+}
+
+/**
+ * Test isTodo() returns the correct value for events and todos.
+ */
+function test_isTodo() {
+ let todo = new CalTodo();
+ let event = new CalEvent();
+
+ Assert.ok(todo.isTodo(), "isTodo() returns true for todos");
+ Assert.ok(!event.isTodo(), "isTodo() returns false for events");
+}
+
+/**
+ * Function for testing that the "properties" property of each supplied
+ * calItemBase occurrence includes those inherited from the parent.
+ *
+ * @param {calItemBase[]} items - A list of item occurrences to test.
+ * @param {calItemBase} parent - The item to use as the parent.
+ * @param {object} [overrides] - A set of key value pairs than can be passed
+ * to indicate what to expect for some properties.
+ */
+function doPropertiesTest(items, parent, overrides = {}) {
+ let skippedProps = ["DTSTART", "DTEND"];
+ let toString = value =>
+ value && value instanceof Ci.calIDateTime ? value.icalString : value && value.toString();
+
+ for (let item of items) {
+ info(`Testing occurrence with recurrenceId="${item.recurrenceId.icalString}...`);
+
+ let parentProperties = new Map(parent.properties);
+ let itemProperties = new Map(item.properties);
+ for (let [name, value] of parentProperties.entries()) {
+ if (!skippedProps.includes(name)) {
+ if (overrides[name]) {
+ Assert.equal(
+ toString(itemProperties.get(name)),
+ toString(overrides[name]),
+ `"${name}" value is value expected by overrides`
+ );
+ } else {
+ Assert.equal(
+ toString(itemProperties.get(name)),
+ toString(value),
+ `"${name}" value is same as parent`
+ );
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Test the "properties" property of a recurring CalEvent inherits parent
+ * properties properly.
+ */
+function test_recurring_event_properties() {
+ let event = new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ DTSTAMP:20210716T000000Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Parent Event
+ CATEGORIES:Business
+ LOCATION: Mochitest
+ DTSTART:20210716T000000Z
+ DTEND:20210716T110000Z
+ RRULE:FREQ=DAILY;UNTIL=20210719T110000Z
+ DESCRIPTION:This is the main event.
+ END:VEVENT
+ `);
+ let occurrences = event.recurrenceInfo.getOccurrences(
+ cal.createDateTime("20210701"),
+ cal.createDateTime("20210731"),
+ Infinity
+ );
+ doPropertiesTest(occurrences, event.parentItem);
+}
+
+/**
+ * Test the "properties" property of a recurring CalEvent exception inherits
+ * parent properties properly.
+ */
+function test_recurring_event_exception_properties() {
+ let event = new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ DTSTAMP:20210716T000000Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Parent Event
+ CATEGORIES:Business
+ LOCATION: Mochitest
+ DTSTART:20210716T000000Z
+ DTEND:20210716T110000Z
+ RRULE:FREQ=DAILY;UNTIL=20210719T110000Z
+ DESCRIPTION:This is the main event.
+ END:VEVENT
+ `);
+ let occurrences = event.recurrenceInfo.getOccurrences(
+ cal.createDateTime("20210701"),
+ cal.createDateTime("20210731"),
+ Infinity
+ );
+ let target = occurrences[0].clone();
+ let newDescription = "This is an exception.";
+ target.setProperty("DESCRIPTION", newDescription);
+ event.parentItem.recurrenceInfo.modifyException(target);
+ target = event.parentItem.recurrenceInfo.getExceptionFor(target.recurrenceId);
+ Assert.ok(target);
+ doPropertiesTest([target], event.parentItem, { DESCRIPTION: newDescription });
+}
+
+/**
+ * Test the "properties" property of a recurring CalTodo inherits parent
+ * properties properly.
+ */
+function test_recurring_todo_properties() {
+ let task = new CalTodo(CalendarTestUtils.dedent`
+ BEGIN:VTODO
+ DTSTAMP:20210716T225440Z
+ UID:673e125d-fe6b-465d-8a38-9c9373ca9705
+ SUMMARY:Main Task
+ RRULE:FREQ=DAILY;UNTIL=20210719T230000Z
+ DTSTART;TZID=America/Port_of_Spain:20210716T190000
+ PERCENT-COMPLETE:0
+ LOCATION:Mochitest
+ DESCRIPTION:This is the main task.
+ END:VTODO
+ `);
+ let occurrences = task.recurrenceInfo.getOccurrences(
+ cal.createDateTime("20210701"),
+ cal.createDateTime("20210731"),
+ Infinity
+ );
+ doPropertiesTest(occurrences, task.parentItem);
+}
+
+/**
+ * Test the "properties" property of a recurring CalTodo exception inherits
+ * parent properties properly.
+ */
+function test_recurring_todo_exception_properties() {
+ let task = new CalTodo(CalendarTestUtils.dedent`
+ BEGIN:VTODO
+ DTSTAMP:20210716T225440Z
+ UID:673e125d-fe6b-465d-8a38-9c9373ca9705
+ SUMMARY:Main Task
+ RRULE:FREQ=DAILY;UNTIL=20210719T230000Z
+ DTSTART;TZID=America/Port_of_Spain:20210716T190000
+ PERCENT-COMPLETE:0
+ LOCATION:Mochitest
+ DESCRIPTION:This is the main task.
+ END:VTODO
+ `);
+ let occurrences = task.recurrenceInfo.getOccurrences(
+ cal.createDateTime("20210701"),
+ cal.createDateTime("20210731"),
+ Infinity
+ );
+ let target = occurrences[0].clone();
+ let newDescription = "This is an exception.";
+ target.setProperty("DESCRIPTION", newDescription);
+ task.parentItem.recurrenceInfo.modifyException(target);
+ target = task.parentItem.recurrenceInfo.getExceptionFor(target.recurrenceId);
+ Assert.ok(target);
+ doPropertiesTest([target], task.parentItem, { DESCRIPTION: newDescription });
+}
diff --git a/comm/calendar/test/unit/test_itip_message_sender.js b/comm/calendar/test/unit/test_itip_message_sender.js
new file mode 100644
index 0000000000..77a110a875
--- /dev/null
+++ b/comm/calendar/test/unit/test_itip_message_sender.js
@@ -0,0 +1,358 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CalAttendee } = ChromeUtils.import("resource:///modules/CalAttendee.jsm");
+var { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+var { CalItipMessageSender } = ChromeUtils.import("resource:///modules/CalItipMessageSender.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+const identityEmail = "user@example.com";
+const eventOrganizerEmail = "eventorganizer@example.com";
+
+/**
+ * Creates a calendar event mimicking an event to which we have received an
+ * invitation.
+ *
+ * @param {string} organizerEmail - The email address of the event organizer.
+ * @param {string} attendeeEmail - The email address of an attendee who has
+ * accepted the invitation.
+ * @returns {calIItemBase} - The new calendar event.
+ */
+function createIncomingEvent(organizerEmail, attendeeEmail) {
+ const organizerId = cal.email.prependMailTo(organizerEmail);
+ const attendeeId = cal.email.prependMailTo(attendeeEmail);
+
+ const icalString = CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ CREATED:20210105T000000Z
+ DTSTAMP:20210501T000000Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Test Invitation
+ DTSTART:20210105T000000Z
+ DTEND:20210105T100000Z
+ STATUS:CONFIRMED
+ SUMMARY:Test Event
+ ORGANIZER;CN=${organizerEmail}:${organizerId}
+ ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;
+ RSVP=TRUE;CN=other@example.com;:mailto:other@example.com
+ ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;
+ RSVP=TRUE;CN=${attendeeEmail};:${attendeeId}
+ X-MOZ-RECEIVED-SEQUENCE:0
+ X-MOZ-RECEIVED-DTSTAMP:20210501T000000Z
+ X-MOZ-GENERATION:0
+ END:VEVENT
+ `;
+
+ return new CalEvent(icalString);
+}
+
+let calendar;
+
+/**
+ * Ensure the calendar manager is available, initialize the calendar and
+ * identity we use for testing.
+ */
+add_setup(async function () {
+ do_get_profile();
+
+ await new Promise(resolve => do_load_calmgr(resolve));
+ calendar = CalendarTestUtils.createCalendar("Test", "memory");
+
+ const identity = MailServices.accounts.createIdentity();
+ identity.email = identityEmail;
+
+ const account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ `${account.key}user`,
+ "localhost",
+ "none"
+ );
+ account.addIdentity(identity);
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeIncomingServer(account.incomingServer, false);
+ MailServices.accounts.removeAccount(account);
+ });
+
+ calendar.setProperty("imip.identity.key", identity.key);
+ calendar.setProperty("organizerId", cal.email.prependMailTo(identityEmail));
+});
+
+add_task(async function testAddAttendeesToOwnEvent() {
+ const icalString = CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ CREATED:20210105T000000Z
+ DTSTAMP:20210501T000000Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Test Invitation
+ DTSTART:20210105T000000Z
+ DTEND:20210105T100000Z
+ STATUS:CONFIRMED
+ SUMMARY:Test Event
+ X-MOZ-SEND-INVITATIONS:TRUE
+ END:VEVENT
+ `;
+
+ const item = new CalEvent(icalString);
+ const savedItem = await calendar.addItem(item);
+
+ // Modify the event to include an attendee not in the original, as well as the
+ // organizer. As of the writing of this test, this is the expected behavior
+ // for adding an attendee to an event which previously had none.
+ const newAttendeeEmail = "foo@example.com";
+ const newAttendee = new CalAttendee();
+ newAttendee.id = newAttendeeEmail;
+
+ const organizer = new CalAttendee();
+ organizer.isOrganizer = true;
+ organizer.id = identityEmail;
+
+ const organizerAsAttendee = new CalAttendee();
+ organizerAsAttendee.id = identityEmail;
+
+ const targetItem = savedItem.clone();
+ targetItem.addAttendee(newAttendee);
+ targetItem.addAttendee(organizer);
+ targetItem.addAttendee(organizerAsAttendee);
+ const modifiedItem = await calendar.modifyItem(targetItem, savedItem);
+
+ // Test that a sender with an original item and for which the current user is
+ // both an attendee and the organizer will generate a REQUEST, but not send a
+ // message to the organizer.
+ const sender = new CalItipMessageSender(savedItem, null);
+
+ const result = sender.buildOutgoingMessages(Ci.calIOperationListener.MODIFY, modifiedItem);
+ Assert.equal(result, 1, "return value should indicate there are pending messages");
+ Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message");
+
+ const [msg] = sender.pendingMessages;
+ Assert.equal(msg.method, "REQUEST", "message method should be 'REQUEST'");
+ Assert.equal(msg.recipients.length, 1, "message should have one recipient");
+
+ const [recipient] = msg.recipients;
+ Assert.equal(
+ recipient.id,
+ cal.email.prependMailTo(newAttendeeEmail),
+ "recipient should be the non-organizer attendee"
+ );
+
+ await calendar.deleteItem(modifiedItem);
+
+ // Now also cancel the event. No mail should be sent to self.
+ const targetItem2 = modifiedItem.clone();
+
+ targetItem2.setProperty("STATUS", "CANCELLED");
+ targetItem2.setProperty("SEQUENCE", "2");
+ const modifiedItem2 = await calendar.addItem(targetItem2);
+ const sender2 = new CalItipMessageSender(modifiedItem2, null);
+
+ const result2 = sender2.buildOutgoingMessages(Ci.calIOperationListener.MODIFY, modifiedItem2);
+ Assert.equal(result2, 1, "return value should indicate there are pending messages");
+ Assert.equal(sender2.pendingMessageCount, 1, "there should be one pending message");
+
+ const [msg2] = sender2.pendingMessages;
+ Assert.equal(msg2.method, "CANCEL", "deletion message method should be 'CANCEL'");
+ Assert.equal(msg2.recipients.length, 1, "deletion message should have one recipient");
+
+ const [recipient2] = msg2.recipients;
+ Assert.equal(
+ recipient2.id,
+ cal.email.prependMailTo(newAttendeeEmail),
+ "for deletion message, recipient should be the non-organizer attendee"
+ );
+});
+
+add_task(async function testAddAdditionalAttendee() {
+ const icalString = CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ CREATED:20210105T000000Z
+ DTSTAMP:20210501T000000Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Test Invitation
+ DTSTART:20210105T000000Z
+ DTEND:20210105T100000Z
+ STATUS:CONFIRMED
+ SUMMARY:Test Event
+ ORGANIZER;CN=${identityEmail}:${cal.email.prependMailTo(identityEmail)}
+ ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;
+ RSVP=TRUE;CN=other@example.com;:mailto:other@example.com
+ ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;
+ RSVP=TRUE;CN=${identityEmail};:${cal.email.prependMailTo(identityEmail)}
+ X-MOZ-SEND-INVITATIONS:TRUE
+ END:VEVENT
+ `;
+
+ const item = new CalEvent(icalString);
+ const savedItem = await calendar.addItem(item);
+
+ // Modify the event to include an attendee not in the original.
+ const newAttendeeEmail = "bar@example.com";
+ const newAttendee = new CalAttendee();
+ newAttendee.id = newAttendeeEmail;
+
+ const organizer = new CalAttendee();
+ organizer.isOrganizer = true;
+ organizer.id = identityEmail;
+
+ const organizerAsAttendee = new CalAttendee();
+ organizerAsAttendee.id = identityEmail;
+
+ const targetItem = savedItem.clone();
+ targetItem.addAttendee(newAttendee);
+ const modifiedItem = await calendar.modifyItem(targetItem, savedItem);
+
+ // Test that adding an attendee won't cause messages to be sent to the
+ // existing attendees.
+ const sender = new CalItipMessageSender(savedItem, null);
+
+ const result = sender.buildOutgoingMessages(Ci.calIOperationListener.MODIFY, modifiedItem);
+ Assert.equal(result, 1, "return value should indicate there are pending messages");
+ Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message");
+
+ const [msg] = sender.pendingMessages;
+ Assert.equal(msg.method, "REQUEST", "message method should be 'REQUEST'");
+ Assert.equal(msg.recipients.length, 1, "message should have one recipient");
+
+ const [recipient] = msg.recipients;
+ Assert.equal(
+ recipient.id,
+ cal.email.prependMailTo(newAttendeeEmail),
+ "recipient should be the new attendee"
+ );
+
+ await calendar.deleteItem(modifiedItem);
+});
+
+add_task(async function testInvitationReceived() {
+ const item = createIncomingEvent(eventOrganizerEmail, identityEmail);
+ const savedItem = await calendar.addItem(item);
+
+ const attendeeId = cal.email.prependMailTo(identityEmail);
+
+ // Test that a sender with no original item and for which the current user is
+ // an attendee but not the organizer (representing a new incoming invitation)
+ // generates a single pending REPLY message on ADD.
+ const currentUserAsAttendee = savedItem.getAttendeeById(attendeeId);
+ const sender = new CalItipMessageSender(null, currentUserAsAttendee);
+
+ const result = sender.buildOutgoingMessages(Ci.calIOperationListener.ADD, savedItem);
+ Assert.equal(result, 1, "return value should indicate there are pending messages");
+ Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message");
+
+ const [msg] = sender.pendingMessages;
+ Assert.equal(msg.method, "REPLY", "message method should be 'REPLY'");
+ Assert.equal(msg.recipients.length, 1, "message should have one recipient");
+
+ const [recipient] = msg.recipients;
+ Assert.equal(
+ recipient.id,
+ cal.email.prependMailTo(eventOrganizerEmail),
+ "recipient should be the event organizer"
+ );
+
+ const attendeeList = msg.item.getAttendees();
+ Assert.equal(attendeeList.length, 1, "there should be one attendee listed in the message");
+
+ const [attendee] = attendeeList;
+ Assert.equal(attendee.id, attendeeId, "listed attendee should be the current user");
+ Assert.equal(
+ attendee.participationStatus,
+ "ACCEPTED",
+ "current user's participation status should be 'ACCEPTED'"
+ );
+
+ await calendar.deleteItem(savedItem);
+});
+
+add_task(async function testParticipationStatusUpdated() {
+ const item = createIncomingEvent(eventOrganizerEmail, identityEmail);
+ const savedItem = await calendar.addItem(item);
+
+ const attendeeId = cal.email.prependMailTo(identityEmail);
+
+ // Modify the event to update the user's participation status.
+ const targetItem = savedItem.clone();
+ const currentUserAsAttendee = targetItem.getAttendeeById(attendeeId);
+ currentUserAsAttendee.participationStatus = "TENTATIVE";
+ const modifiedItem = await calendar.modifyItem(targetItem, savedItem);
+
+ // Test that a sender for which the current user is an attendee but not the
+ // organizer will generate a pending REPLY message on MODIFY.
+ const sender = new CalItipMessageSender(savedItem, currentUserAsAttendee);
+ const result = sender.buildOutgoingMessages(Ci.calIOperationListener.MODIFY, modifiedItem);
+
+ Assert.equal(result, 1, "return value should indicate there are pending messages");
+ Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message");
+
+ const [msg] = sender.pendingMessages;
+ Assert.equal(msg.method, "REPLY", "message method should be 'REPLY'");
+ Assert.equal(msg.recipients.length, 1, "message should have one recipient");
+
+ const [recipient] = msg.recipients;
+ Assert.equal(
+ recipient.id,
+ cal.email.prependMailTo(eventOrganizerEmail),
+ "recipient should be the event organizer"
+ );
+
+ const attendeeList = msg.item.getAttendees();
+ Assert.equal(attendeeList.length, 1, "there should be one attendee listed in the message");
+
+ const [attendee] = attendeeList;
+ Assert.equal(attendee.id, attendeeId, "listed attendee should be the current user");
+ Assert.equal(
+ attendee.participationStatus,
+ "TENTATIVE",
+ "current user's participation status should be 'TENTATIVE'"
+ );
+
+ await calendar.deleteItem(modifiedItem);
+});
+
+add_task(async function testEventDeleted() {
+ const item = createIncomingEvent(eventOrganizerEmail, identityEmail);
+ const savedItem = await calendar.addItem(item);
+
+ const attendeeId = cal.email.prependMailTo(identityEmail);
+
+ await calendar.deleteItem(savedItem);
+ const currentUserAsAttendee = savedItem.getAttendeeById(attendeeId);
+
+ // Test that a sender with no original item and for which the current user is
+ // an attendee but not the organizer (representing the user deleting an event
+ // from their calendar) generates a single REPLY message to the organizer on
+ // DELETE.
+ const sender = new CalItipMessageSender(null, currentUserAsAttendee);
+ const result = sender.buildOutgoingMessages(Ci.calIOperationListener.DELETE, savedItem);
+
+ Assert.equal(result, 1, "return value should indicate there are pending messages");
+ Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message");
+
+ const [msg] = sender.pendingMessages;
+ Assert.equal(msg.method, "REPLY", "message method should be 'REPLY'");
+ Assert.equal(msg.recipients.length, 1, "message should have one recipient");
+
+ const [recipient] = msg.recipients;
+ Assert.equal(
+ recipient.id,
+ cal.email.prependMailTo(eventOrganizerEmail),
+ "recipient should be the event organizer"
+ );
+
+ const attendeeList = msg.item.getAttendees();
+ Assert.equal(attendeeList.length, 1, "there should be one attendee listed in the message");
+
+ const [attendee] = attendeeList;
+ Assert.equal(attendee.id, attendeeId, "listed attendee should be the current user");
+ Assert.equal(
+ attendee.participationStatus,
+ "DECLINED",
+ "current user's participation status should be 'DECLINED'"
+ );
+});
diff --git a/comm/calendar/test/unit/test_itip_utils.js b/comm/calendar/test/unit/test_itip_utils.js
new file mode 100644
index 0000000000..5c4678ab1d
--- /dev/null
+++ b/comm/calendar/test/unit/test_itip_utils.js
@@ -0,0 +1,831 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalItipEmailTransport: "resource:///modules/CalItipEmailTransport.jsm",
+});
+
+// tests for calItipUtils.jsm
+
+do_get_profile();
+
+/*
+ * Helper function to get an ics for testing sequence and stamp comparison
+ *
+ * @param {String} aAttendee - A serialized ATTENDEE property
+ * @param {String} aSequence - A serialized SEQUENCE property
+ * @param {String} aDtStamp - A serialized DTSTAMP property
+ * @param {String} aXMozReceivedSequence - A serialized X-MOZ-RECEIVED-SEQUENCE property
+ * @param {String} aXMozReceivedDtStamp - A serialized X-MOZ-RECEIVED-STAMP property
+ */
+function getSeqStampTestIcs(aProperties) {
+ // we make sure to have a dtstamp property to get a valid ics
+ let dtStamp = "20150909T181048Z";
+ let additionalProperties = "";
+ aProperties.forEach(aProp => {
+ if (aProp.startsWith("DTSTAMP:")) {
+ dtStamp = aProp;
+ } else {
+ additionalProperties += "\r\n" + aProp;
+ }
+ });
+
+ return [
+ "BEGIN:VCALENDAR",
+ "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN",
+ "VERSION:2.0",
+ "METHOD:REQUEST",
+ "BEGIN:VTIMEZONE",
+ "TZID:Europe/Berlin",
+ "BEGIN:DAYLIGHT",
+ "TZOFFSETFROM:+0100",
+ "TZOFFSETTO:+0200",
+ "TZNAME:CEST",
+ "DTSTART:19700329T020000",
+ "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU",
+ "END:DAYLIGHT",
+ "BEGIN:STANDARD",
+ "TZOFFSETFROM:+0200",
+ "TZOFFSETTO:+0100",
+ "TZNAME:CET",
+ "DTSTART:19701025T030000",
+ "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU",
+ "END:STANDARD",
+ "END:VTIMEZONE",
+ "BEGIN:VEVENT",
+ "CREATED:20150909T180909Z",
+ "LAST-MODIFIED:20150909T181048Z",
+ dtStamp,
+ "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" +
+ additionalProperties,
+ "DTSTART;TZID=Europe/Berlin:20150909T210000",
+ "DTEND;TZID=Europe/Berlin:20150909T220000",
+ "TRANSP:OPAQUE",
+ "LOCATION:Room 1",
+ "DESCRIPTION:Let us get together",
+ "URL:http://www.example.com",
+ "ATTACH:http://www.example.com",
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ].join("\r\n");
+}
+
+function getSeqStampTestItems(aTest) {
+ let items = [];
+ for (let input of aTest.input) {
+ if (input.item) {
+ // in this case, we need to return an event
+ let attendee = "";
+ if ("attendee" in input.item && input.item.attendee != {}) {
+ let att = new CalAttendee();
+ att.id = input.item.attendee.id || "mailto:otherattendee@example.net";
+ if ("receivedSeq" in input.item.attendee && input.item.attendee.receivedSeq.length) {
+ att.setProperty("RECEIVED-SEQUENCE", input.item.attendee.receivedSeq);
+ }
+ if ("receivedStamp" in input.item.attendee && input.item.attendee.receivedStamp.length) {
+ att.setProperty("RECEIVED-DTSTAMP", input.item.attendee.receivedStamp);
+ }
+ }
+ let sequence = "";
+ if ("sequence" in input.item && input.item.sequence.length) {
+ sequence = "SEQUENCE:" + input.item.sequence;
+ }
+ let dtStamp = "DTSTAMP:20150909T181048Z";
+ if ("dtStamp" in input.item && input.item.dtStamp) {
+ dtStamp = "DTSTAMP:" + input.item.dtStamp;
+ }
+ let xMozReceivedSeq = "";
+ if ("xMozReceivedSeq" in input.item && input.item.xMozReceivedSeq.length) {
+ xMozReceivedSeq = "X-MOZ-RECEIVED-SEQUENCE:" + input.item.xMozReceivedSeq;
+ }
+ let xMozReceivedStamp = "";
+ if ("xMozReceivedStamp" in input.item && input.item.xMozReceivedStamp.length) {
+ xMozReceivedStamp = "X-MOZ-RECEIVED-DTSTAMP:" + input.item.xMozReceivedStamp;
+ }
+ let xMsAptSeq = "";
+ if ("xMsAptSeq" in input.item && input.item.xMsAptSeq.length) {
+ xMsAptSeq = "X-MICROSOFT-CDO-APPT-SEQUENCE:" + input.item.xMsAptSeq;
+ }
+ let testItem = new CalEvent();
+ testItem.icalString = getSeqStampTestIcs([
+ attendee,
+ sequence,
+ dtStamp,
+ xMozReceivedSeq,
+ xMozReceivedStamp,
+ xMsAptSeq,
+ ]);
+ items.push(testItem);
+ } else {
+ // in this case, we need to return an attendee
+ let att = new CalAttendee();
+ att.id = input.attendee.id || "mailto:otherattendee@example.net";
+ if (input.attendee.receivedSeq && input.attendee.receivedSeq.length) {
+ att.setProperty("RECEIVED-SEQUENCE", input.attendee.receivedSeq);
+ }
+ if (input.attendee.receivedStamp && input.attendee.receivedStamp.length) {
+ att.setProperty("RECEIVED-DTSTAMP", input.attendee.receivedStamp);
+ }
+ items.push(att);
+ }
+ }
+ return items;
+}
+
+add_task(function test_getMessageSender() {
+ let data = [
+ {
+ input: null,
+ expected: null,
+ },
+ {
+ input: {},
+ expected: null,
+ },
+ {
+ input: { author: "Sender 1 <sender1@example.net>" },
+ expected: "sender1@example.net",
+ },
+ ];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ equal(cal.itip.getMessageSender(test.input), test.expected, "(test #" + i + ")");
+ }
+});
+
+add_task(function test_getSequence() {
+ // assigning an empty string results in not having the property in the ics here
+ let data = [
+ {
+ input: [{ item: { sequence: "", xMozReceivedSeq: "" } }],
+ expected: 0,
+ },
+ {
+ input: [{ item: { sequence: "0", xMozReceivedSeq: "" } }],
+ expected: 0,
+ },
+ {
+ input: [{ item: { sequence: "", xMozReceivedSeq: "0" } }],
+ expected: 0,
+ },
+ {
+ input: [{ item: { sequence: "1", xMozReceivedSeq: "" } }],
+ expected: 1,
+ },
+ {
+ input: [{ item: { sequence: "", xMozReceivedSeq: "1" } }],
+ expected: 1,
+ },
+ {
+ input: [{ attendee: { receivedSeq: "" } }],
+ expected: 0,
+ },
+ {
+ input: [{ attendee: { receivedSeq: "0" } }],
+ expected: 0,
+ },
+ {
+ input: [{ attendee: { receivedSeq: "1" } }],
+ expected: 1,
+ },
+ ];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ let testItems = getSeqStampTestItems(test);
+ equal(cal.itip.getSequence(testItems[0], testItems[1]), test.expected, "(test #" + i + ")");
+ }
+});
+
+add_task(function test_getStamp() {
+ // assigning an empty string results in not having the property in the ics here. However, there
+ // must be always an dtStamp for item - if it's missing it will be set by the test code to make
+ // sure we get a valid ics
+ let data = [
+ {
+ // !dtStamp && !xMozReceivedStamp => test default value
+ input: [{ item: { dtStamp: "", xMozReceivedStamp: "" } }],
+ expected: "20150909T181048Z",
+ },
+ {
+ // dtStamp && !xMozReceivedStamp => dtStamp
+ input: [{ item: { dtStamp: "20150910T181048Z", xMozReceivedStamp: "" } }],
+ expected: "20150910T181048Z",
+ },
+ {
+ // dtStamp && xMozReceivedStamp => xMozReceivedStamp
+ input: [{ item: { dtStamp: "20150909T181048Z", xMozReceivedStamp: "20150910T181048Z" } }],
+ expected: "20150910T181048Z",
+ },
+ {
+ input: [{ attendee: { receivedStamp: "" } }],
+ expected: null,
+ },
+ {
+ input: [{ attendee: { receivedStamp: "20150910T181048Z" } }],
+ expected: "20150910T181048Z",
+ },
+ ];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ let result = cal.itip.getStamp(getSeqStampTestItems(test)[0]);
+ if (result) {
+ result = result.icalString;
+ }
+ equal(result, test.expected, "(test #" + i + ")");
+ }
+});
+
+add_task(function test_compareSequence() {
+ // it is sufficient to test here with sequence for items - full test coverage for
+ // x-moz-received-sequence is already provided by test_compareSequence
+ let data = [
+ {
+ // item1.seq == item2.seq
+ input: [{ item: { sequence: "2" } }, { item: { sequence: "2" } }],
+ expected: 0,
+ },
+ {
+ // item1.seq > item2.seq
+ input: [{ item: { sequence: "3" } }, { item: { sequence: "2" } }],
+ expected: 1,
+ },
+ {
+ // item1.seq < item2.seq
+ input: [{ item: { sequence: "2" } }, { item: { sequence: "3" } }],
+ expected: -1,
+ },
+ {
+ // attendee1.seq == attendee2.seq
+ input: [{ attendee: { receivedSeq: "2" } }, { attendee: { receivedSeq: "2" } }],
+ expected: 0,
+ },
+ {
+ // attendee1.seq > attendee2.seq
+ input: [{ attendee: { receivedSeq: "3" } }, { attendee: { receivedSeq: "2" } }],
+ expected: 1,
+ },
+ {
+ // attendee1.seq < attendee2.seq
+ input: [{ attendee: { receivedSeq: "2" } }, { attendee: { receivedSeq: "3" } }],
+ expected: -1,
+ },
+ {
+ // item.seq == attendee.seq
+ input: [{ item: { sequence: "2" } }, { attendee: { receivedSeq: "2" } }],
+ expected: 0,
+ },
+ {
+ // item.seq > attendee.seq
+ input: [{ item: { sequence: "3" } }, { attendee: { receivedSeq: "2" } }],
+ expected: 1,
+ },
+ {
+ // item.seq < attendee.seq
+ input: [{ item: { sequence: "2" } }, { attendee: { receivedSeq: "3" } }],
+ expected: -1,
+ },
+ ];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ let testItems = getSeqStampTestItems(test);
+ equal(cal.itip.compareSequence(testItems[0], testItems[1]), test.expected, "(test #" + i + ")");
+ }
+});
+
+add_task(function test_compareStamp() {
+ // it is sufficient to test here with dtstamp for items - full test coverage for
+ // x-moz-received-stamp is already provided by test_compareStamp
+ let data = [
+ {
+ // item1.stamp == item2.stamp
+ input: [{ item: { dtStamp: "20150910T181048Z" } }, { item: { dtStamp: "20150910T181048Z" } }],
+ expected: 0,
+ },
+ {
+ // item1.stamp > item2.stamp
+ input: [{ item: { dtStamp: "20150911T181048Z" } }, { item: { dtStamp: "20150910T181048Z" } }],
+ expected: 1,
+ },
+ {
+ // item1.stamp < item2.stamp
+ input: [{ item: { dtStamp: "20150910T181048Z" } }, { item: { dtStamp: "20150911T181048Z" } }],
+ expected: -1,
+ },
+ {
+ // attendee1.stamp == attendee2.stamp
+ input: [
+ { attendee: { receivedStamp: "20150910T181048Z" } },
+ { attendee: { receivedStamp: "20150910T181048Z" } },
+ ],
+ expected: 0,
+ },
+ {
+ // attendee1.stamp > attendee2.stamp
+ input: [
+ { attendee: { receivedStamp: "20150911T181048Z" } },
+ { attendee: { receivedStamp: "20150910T181048Z" } },
+ ],
+ expected: 1,
+ },
+ {
+ // attendee1.stamp < attendee2.stamp
+ input: [
+ { attendee: { receivedStamp: "20150910T181048Z" } },
+ { attendee: { receivedStamp: "20150911T181048Z" } },
+ ],
+ expected: -1,
+ },
+ {
+ // item.stamp == attendee.stamp
+ input: [
+ { item: { dtStamp: "20150910T181048Z" } },
+ { attendee: { receivedStamp: "20150910T181048Z" } },
+ ],
+ expected: 0,
+ },
+ {
+ // item.stamp > attendee.stamp
+ input: [
+ { item: { dtStamp: "20150911T181048Z" } },
+ { attendee: { receivedStamp: "20150910T181048Z" } },
+ ],
+ expected: 1,
+ },
+ {
+ // item.stamp < attendee.stamp
+ input: [
+ { item: { dtStamp: "20150910T181048Z" } },
+ { attendee: { receivedStamp: "20150911T181048Z" } },
+ ],
+ expected: -1,
+ },
+ ];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ let testItems = getSeqStampTestItems(test);
+ equal(cal.itip.compareStamp(testItems[0], testItems[1]), test.expected, "(test #" + i + ")");
+ }
+});
+
+add_task(function test_compare() {
+ // it is sufficient to test here with items only - full test coverage for attendees or
+ // item/attendee is already provided by test_compareSequence and test_compareStamp
+ let data = [
+ {
+ // item1.seq == item2.seq && item1.stamp == item2.stamp
+ input: [
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ ],
+ expected: 0,
+ },
+ {
+ // item1.seq == item2.seq && item1.stamp > item2.stamp
+ input: [
+ { item: { sequence: "2", dtStamp: "20150911T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ ],
+ expected: 1,
+ },
+ {
+ // item1.seq == item2.seq && item1.stamp < item2.stamp
+ input: [
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150911T181048Z" } },
+ ],
+ expected: -1,
+ },
+ {
+ // item1.seq > item2.seq && item1.stamp == item2.stamp
+ input: [
+ { item: { sequence: "3", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ ],
+ expected: 1,
+ },
+ {
+ // item1.seq > item2.seq && item1.stamp > item2.stamp
+ input: [
+ { item: { sequence: "3", dtStamp: "20150911T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ ],
+ expected: 1,
+ },
+ {
+ // item1.seq > item2.seq && item1.stamp < item2.stamp
+ input: [
+ { item: { sequence: "3", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150911T181048Z" } },
+ ],
+ expected: 1,
+ },
+ {
+ // item1.seq < item2.seq && item1.stamp == item2.stamp
+ input: [
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "3", dtStamp: "20150910T181048Z" } },
+ ],
+ expected: -1,
+ },
+ {
+ // item1.seq < item2.seq && item1.stamp > item2.stamp
+ input: [
+ { item: { sequence: "2", dtStamp: "20150911T181048Z" } },
+ { item: { sequence: "3", dtStamp: "20150910T181048Z" } },
+ ],
+ expected: -1,
+ },
+ {
+ // item1.seq < item2.seq && item1.stamp < item2.stamp
+ input: [
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "3", dtStamp: "20150911T181048Z" } },
+ ],
+ expected: -1,
+ },
+ ];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ let testItems = getSeqStampTestItems(test);
+ equal(cal.itip.compare(testItems[0], testItems[1]), test.expected, "(test #" + i + ")");
+ }
+});
+
+add_task(function test_getAttendeesBySender() {
+ let data = [
+ {
+ input: {
+ attendees: [
+ { id: "mailto:user1@example.net", sentBy: null },
+ { id: "mailto:user2@example.net", sentBy: null },
+ ],
+ sender: "user1@example.net",
+ },
+ expected: ["mailto:user1@example.net"],
+ },
+ {
+ input: {
+ attendees: [
+ { id: "mailto:user1@example.net", sentBy: null },
+ { id: "mailto:user2@example.net", sentBy: null },
+ ],
+ sender: "user3@example.net",
+ },
+ expected: [],
+ },
+ {
+ input: {
+ attendees: [
+ { id: "mailto:user1@example.net", sentBy: "mailto:user3@example.net" },
+ { id: "mailto:user2@example.net", sentBy: null },
+ ],
+ sender: "user3@example.net",
+ },
+ expected: ["mailto:user1@example.net"],
+ },
+ {
+ input: {
+ attendees: [
+ { id: "mailto:user1@example.net", sentBy: null },
+ { id: "mailto:user2@example.net", sentBy: "mailto:user1@example.net" },
+ ],
+ sender: "user1@example.net",
+ },
+ expected: ["mailto:user1@example.net", "mailto:user2@example.net"],
+ },
+ {
+ input: { attendees: [], sender: "user1@example.net" },
+ expected: [],
+ },
+ {
+ input: {
+ attendees: [
+ { id: "mailto:user1@example.net", sentBy: null },
+ { id: "mailto:user2@example.net", sentBy: null },
+ ],
+ sender: "",
+ },
+ expected: [],
+ },
+ {
+ input: {
+ attendees: [
+ { id: "mailto:user1@example.net", sentBy: null },
+ { id: "mailto:user2@example.net", sentBy: null },
+ ],
+ sender: null,
+ },
+ expected: [],
+ },
+ ];
+
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ let attendees = [];
+ for (let att of test.input.attendees) {
+ let attendee = new CalAttendee();
+ attendee.id = att.id;
+ if (att.sentBy) {
+ attendee.setProperty("SENT-BY", att.sentBy);
+ }
+ attendees.push(attendee);
+ }
+ let detected = [];
+ cal.itip.getAttendeesBySender(attendees, test.input.sender).forEach(att => {
+ detected.push(att.id);
+ });
+ ok(
+ detected.every(aId => test.expected.includes(aId)),
+ "(test #" + i + " ok1)"
+ );
+ ok(
+ test.expected.every(aId => detected.includes(aId)),
+ "(test #" + i + " ok2)"
+ );
+ }
+});
+
+add_task(function test_resolveDelegation() {
+ let data = [
+ {
+ input: {
+ attendee:
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net";CN="Attendee 1":mailto:at' +
+ "tendee1@example.net",
+ attendees: [
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net";CN="Attendee 1":mailto:at' +
+ "tendee1@example.net",
+ 'ATTENDEE;DELEGATED-TO="mailto:attendee1@example.net";CN="Attendee 2":mailto:atte' +
+ "ndee2@example.net",
+ ],
+ },
+ expected: {
+ delegatees: "",
+ delegators: "Attendee 2 <attendee2@example.net>",
+ },
+ },
+ {
+ input: {
+ attendee:
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net":mailto:attendee1@example.net',
+ attendees: [
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net":mailto:attendee1@example.net',
+ 'ATTENDEE;DELEGATED-TO="mailto:attendee1@example.net":mailto:attendee2@example.net',
+ ],
+ },
+ expected: {
+ delegatees: "",
+ delegators: "attendee2@example.net",
+ },
+ },
+ {
+ input: {
+ attendee:
+ 'ATTENDEE;DELEGATED-TO="mailto:attendee2@example.net";CN="Attendee 1":mailto:atte' +
+ "ndee1@example.net",
+ attendees: [
+ 'ATTENDEE;DELEGATED-TO="mailto:attendee2@example.net";CN="Attendee 1":mailto:atte' +
+ "ndee1@example.net",
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee1@example.net";CN="Attendee 2":mailto:at' +
+ "tendee2@example.net",
+ ],
+ },
+ expected: {
+ delegatees: "Attendee 2 <attendee2@example.net>",
+ delegators: "",
+ },
+ },
+ {
+ input: {
+ attendee:
+ 'ATTENDEE;DELEGATED-TO="mailto:attendee2@example.net":mailto:attendee1@example.net',
+ attendees: [
+ 'ATTENDEE;DELEGATED-TO="mailto:attendee2@example.net":mailto:attendee1@example.net',
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee1@example.net":mailto:attendee2@example.net',
+ ],
+ },
+ expected: {
+ delegatees: "attendee2@example.net",
+ delegators: "",
+ },
+ },
+ {
+ input: {
+ attendee: "ATTENDEE:mailto:attendee1@example.net",
+ attendees: [
+ "ATTENDEE:mailto:attendee1@example.net",
+ "ATTENDEE:mailto:attendee2@example.net",
+ ],
+ },
+ expected: {
+ delegatees: "",
+ delegators: "",
+ },
+ },
+ {
+ input: {
+ attendee:
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net";DELEGATED-TO="mailto:atte' +
+ 'ndee3@example.net":mailto:attendee1@example.net',
+ attendees: [
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net";DELEGATED-TO="mailto:atte' +
+ 'ndee3@example.net":mailto:attendee1@example.net',
+ 'ATTENDEE;DELEGATED-TO="mailto:attendee1@example.net":mailto:attendee2@example.net',
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee1@example.net":mailto:attendee3@example.net',
+ ],
+ },
+ expected: {
+ delegatees: "attendee3@example.net",
+ delegators: "attendee2@example.net",
+ },
+ },
+ ];
+ let i = 0;
+ for (let test of data) {
+ i++;
+ let attendees = [];
+ for (let att of test.input.attendees) {
+ let attendee = new CalAttendee();
+ attendee.icalString = att;
+ attendees.push(attendee);
+ }
+ let attendee = new CalAttendee();
+ attendee.icalString = test.input.attendee;
+ let result = cal.itip.resolveDelegation(attendee, attendees);
+ equal(result.delegatees, test.expected.delegatees, "(test #" + i + " - delegatees)");
+ equal(result.delegators, test.expected.delegators, "(test #" + i + " - delegators)");
+ }
+});
+
+/**
+ * Tests the various ways to use the getInvitedAttendee function.
+ */
+add_task(async function test_getInvitedAttendee() {
+ class MockCalendar {
+ supportsScheduling = true;
+
+ constructor(invitedAttendee) {
+ this.invitedAttendee = invitedAttendee;
+ }
+
+ getSchedulingSupport() {
+ return this;
+ }
+
+ getInvitedAttendee() {
+ return this.invitedAttendee;
+ }
+ }
+
+ let invitedAttendee = new CalAttendee();
+ invitedAttendee.id = "mailto:invited@example.com";
+
+ let calendar = new MockCalendar(invitedAttendee);
+ let event = new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ CREATED:20210105T000000Z
+ DTSTAMP:20210501T000000Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Test Invitation
+ DTSTART:20210105T000000Z
+ DTEND:20210105T100000Z
+ STATUS:CONFIRMED
+ SUMMARY:Test Event
+ ORGANIZER;CN=events@example.com:mailto:events@example.com
+ ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;
+ RSVP=TRUE;CN=invited@example.com;:mailto:invited@example.com
+ END:VEVENT
+ `);
+
+ // No calendar configured or provided.
+ Assert.ok(
+ !cal.itip.getInvitedAttendee(event),
+ "returns falsy when item has no calendar and none provided"
+ );
+
+ // No calendar configured but one provided.
+ Assert.ok(
+ cal.itip.getInvitedAttendee(event, calendar) == invitedAttendee,
+ "returns the result from the provided calendar when item has none configured"
+ );
+
+ // Calendar configured, none provided.
+ event.calendar = calendar;
+ Assert.ok(
+ cal.itip.getInvitedAttendee(event) == invitedAttendee,
+ "returns the result of the item's calendar when calendar not provided"
+ );
+
+ // Calendar configured, one provided.
+ Assert.ok(
+ !cal.itip.getInvitedAttendee(event, new MockCalendar()),
+ "returns the result of the provided calendar even if item's calendar is configured"
+ );
+
+ // Calendar does not implement nsISchedulingSupport.
+ calendar.supportsScheduling = false;
+ Assert.ok(
+ !cal.itip.getInvitedAttendee(event),
+ "returns falsy if the calendar does not indicate nsISchedulingSupport"
+ );
+
+ // X-MOZ-INVITED-ATTENDEE set on event.
+ event.setProperty("X-MOZ-INVITED-ATTENDEE", "mailto:invited@example.com");
+
+ let attendee = cal.itip.getInvitedAttendee(event);
+ Assert.ok(
+ attendee && attendee.id == "mailto:invited@example.com",
+ "returns the attendee matching X-MOZ-INVITED-ATTENDEE if set"
+ );
+
+ // X-MOZ-INVITED-ATTENDEE set to non-existent attendee
+ event.setProperty("X-MOZ-INVITED-ATTENDEE", "mailto:nobody@example.com");
+ Assert.ok(
+ !cal.itip.getInvitedAttendee(event),
+ "returns falsy for non-existent X-MOZ-INVITED-ATTENDEE"
+ );
+});
+
+/**
+ * Tests the getImipTransport function returns the correct calIItipTransport.
+ */
+add_task(function test_getImipTransport() {
+ let event = new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ CREATED:20210105T000000Z
+ DTSTAMP:20210501T000000Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Test Invitation
+ DTSTART:20210105T000000Z
+ DTEND:20210105T100000Z
+ STATUS:CONFIRMED
+ SUMMARY:Test Event
+ END:VEVENT
+ `);
+
+ // Without X-MOZ-INVITED-ATTENDEE property.
+ let account1 = MailServices.accounts.createAccount();
+ let identity1 = MailServices.accounts.createIdentity();
+ identity1.email = "id1@example.com";
+ account1.addIdentity(identity1);
+
+ let calendarTransport = new CalItipEmailTransport(account1, identity1);
+ event.calendar = {
+ getProperty(key) {
+ switch (key) {
+ case "itip.transport":
+ return calendarTransport;
+ case "imip.idenity":
+ return identity1;
+ default:
+ return null;
+ }
+ },
+ };
+
+ Assert.ok(
+ cal.itip.getImipTransport(event) == calendarTransport,
+ "returns the calendar's transport when no X-MOZ-INVITED-ATTENDEE property"
+ );
+
+ // With X-MOZ-INVITED-ATTENDEE property.
+ let account2 = MailServices.accounts.createAccount();
+ let identity2 = MailServices.accounts.createIdentity();
+ identity2.email = "id2@example.com";
+ account2.addIdentity(identity2);
+ account2.incomingServer = MailServices.accounts.createIncomingServer(
+ "id2",
+ "example.com",
+ "imap"
+ );
+
+ event.setProperty("X-MOZ-INVITED-ATTENDEE", "mailto:id2@example.com");
+
+ let customTransport = cal.itip.getImipTransport(event);
+ Assert.ok(customTransport);
+
+ Assert.ok(
+ customTransport.mDefaultAccount == account2,
+ "returns a transport using an account for the X-MOZ-INVITED-ATTENDEE identity when set"
+ );
+
+ Assert.ok(
+ customTransport.mDefaultIdentity == identity2,
+ "returns a transport using the identity of the X-MOZ-INVITED-ATTENDEE property when set"
+ );
+});
diff --git a/comm/calendar/test/unit/test_l10n_utils.js b/comm/calendar/test/unit/test_l10n_utils.js
new file mode 100644
index 0000000000..98d93042fd
--- /dev/null
+++ b/comm/calendar/test/unit/test_l10n_utils.js
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ do_calendar_startup(run_next_test);
+}
+
+// tests for calL10NUtils.jsm
+/* Incomplete - still missing test coverage for:
+ * getAnyString
+ * getString
+ * getCalString
+ * getLtnString
+ * getDateFmtString
+ * formatMonth
+ */
+
+add_task(async function calendarInfo_test() {
+ let data = [
+ {
+ input: { locale: "en-US" },
+ expected: {
+ properties: ["firstDayOfWeek", "minDays", "weekend", "calendar", "locale"],
+ },
+ },
+ {
+ input: { locale: "EN-US" },
+ expected: {
+ properties: ["firstDayOfWeek", "minDays", "weekend", "calendar", "locale"],
+ },
+ },
+ {
+ input: { locale: "et" },
+ expected: {
+ properties: ["firstDayOfWeek", "minDays", "weekend", "calendar", "locale"],
+ },
+ },
+ {
+ input: { locale: null }, // this also would trigger caching tests
+ expected: {
+ properties: ["firstDayOfWeek", "minDays", "weekend", "calendar", "locale"],
+ },
+ },
+ ];
+ let useOSLocaleFormat = Services.prefs.getBoolPref("intl.regional_prefs.use_os_locales", false);
+ let osprefs = Cc["@mozilla.org/intl/ospreferences;1"].getService(Ci.mozIOSPreferences);
+ let appLocale = Services.locale.appLocalesAsBCP47[0];
+ let rsLocale = osprefs.regionalPrefsLocales[0];
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+ let info = cal.l10n.calendarInfo(test.input.locale);
+ equal(
+ Object.keys(info).length,
+ test.expected.properties.length,
+ "expected number of attributes (test #" + i + ")"
+ );
+ for (let prop of test.expected.properties) {
+ ok(prop in info, prop + " exists (test #" + i + ")");
+ }
+
+ if (!test.input.locale && appLocale != rsLocale) {
+ // if aLocale is null we test with the current date and time formatting setting
+ // let's test the caching mechanism - this test section is pointless if app and
+ // OS locale are the same like probably on automation
+ Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", !useOSLocaleFormat);
+ let info2 = cal.l10n.calendarInfo();
+ equal(
+ Object.keys(info).length,
+ test.expected.properties.length,
+ "caching test - equal number of properties (test #" + i + ")"
+ );
+ for (let prop of Object.keys(info)) {
+ ok(prop in info2, "caching test - " + prop + " exists in both objects (test #" + i + ")");
+ equal(
+ info2[prop],
+ info[prop],
+ "caching test - value for " + prop + " is equal in both objects (test #" + i + ")"
+ );
+ }
+ // we reset the cache and test again - it's suffient here to find one changed property,
+ // so we use locale since that must change always in that scenario
+ // info2 = cal.l10n.calendarInfo(null, true);
+ Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", useOSLocaleFormat);
+ // This is currently disabled since the code actually doesn't reset the cache anyway.
+ // When re-enabling, be aware that macOS returns just "en" for rsLocale while other
+ // OS provide "en-US".
+ /*
+ notEqual(
+ info2.locale,
+ info.locale,
+ "caching retest - value for locale is different in both objects (test #" + i + ")"
+ );
+ */
+ }
+ }
+});
diff --git a/comm/calendar/test/unit/test_lenient_parsing.js b/comm/calendar/test/unit/test_lenient_parsing.js
new file mode 100644
index 0000000000..7d20584996
--- /dev/null
+++ b/comm/calendar/test/unit/test_lenient_parsing.js
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests that ICAL.design.strict is set to false in both the main thread and
+ * the ICS parsing worker. If either or both is set to true, this will fail.
+ */
+
+add_task(async function () {
+ const item = await new Promise((resolve, reject) => {
+ Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser).parseString(
+ dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ SUMMARY:An event!
+ DTSTART:20240331
+ DTEND:20240331
+ END:VEVENT
+ END:VCALENDAR
+ `,
+ {
+ QueryInterface: ChromeUtils.generateQI(["calIIcsParsingListener"]),
+ onParsingComplete(rv, parser) {
+ if (Components.isSuccessCode(rv)) {
+ resolve(parser.getItems()[0]);
+ } else {
+ reject(rv);
+ }
+ },
+ }
+ );
+ });
+
+ Assert.equal(item.startDate.year, 2024);
+ Assert.equal(item.startDate.month, 2);
+ Assert.equal(item.startDate.day, 31);
+ Assert.equal(item.endDate.year, 2024);
+ Assert.equal(item.endDate.month, 2);
+ Assert.equal(item.endDate.day, 31);
+});
diff --git a/comm/calendar/test/unit/test_providers.js b/comm/calendar/test/unit/test_providers.js
new file mode 100644
index 0000000000..b800e47727
--- /dev/null
+++ b/comm/calendar/test/unit/test_providers.js
@@ -0,0 +1,426 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint no-useless-concat: "off" */
+
+var icalStringArray = [
+ // Comments refer to the range defined in testGetItems().
+ // 1: one-hour event
+ "BEGIN:VEVENT\n" + "DTSTART:20020402T114500Z\n" + "DTEND:20020402T124500Z\n" + "END:VEVENT\n",
+ // 2: Test a zero-length event with DTSTART and DTEND
+ "BEGIN:VEVENT\n" + "DTSTART:20020402T000000Z\n" + "DTEND:20020402T000000Z\n" + "END:VEVENT\n",
+ // 3: Test a zero-length event with DTSTART and no DTEND
+ "BEGIN:VEVENT\n" + "DTSTART:20020402T000000Z\n" + "END:VEVENT\n",
+ // 4: Test a zero-length event with DTEND set and no DTSTART. Invalid!
+ "BEGIN:VEVENT\n" + "DTEND:20020402T000000Z\n" + "END:VEVENT\n",
+ // 5: one-hour event that is outside the range
+ "BEGIN:VEVENT\n" + "DTSTART:20020401T114500Z\n" + "DTEND:20020401T124500Z\n" + "END:VEVENT\n",
+ // 6: one-hour event that starts outside the range and ends inside.
+ "BEGIN:VEVENT\n" + "DTSTART:20020401T114500Z\n" + "DTEND:20020402T124500Z\n" + "END:VEVENT\n",
+ // 7: one-hour event that starts inside the range and ends outside.
+ "BEGIN:VEVENT\n" + "DTSTART:20020402T114500Z\n" + "DTEND:20020403T124500Z\n" + "END:VEVENT\n",
+ // 8: one-hour event that starts at the end of the range.
+ "BEGIN:VEVENT\n" + "DTSTART:20020403T000000Z\n" + "DTEND:20020403T124500Z\n" + "END:VEVENT\n",
+ // 9: allday event that starts at start of range and ends at end of range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART;VALUE=DATE:20020402\n" +
+ "DTEND;VALUE=DATE:20020403\n" +
+ "END:VEVENT\n",
+ // 10: allday event that starts at end of range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART;VALUE=DATE:20020403\n" +
+ "DTEND;VALUE=DATE:20020404\n" +
+ "END:VEVENT\n",
+ // 11: allday event that ends at start of range. See bug 333363.
+ "BEGIN:VEVENT\n" +
+ "DTSTART;VALUE=DATE:20020401\n" +
+ "DTEND;VALUE=DATE:20020402\n" +
+ "END:VEVENT\n",
+ // 12: daily recurring allday event. parent item in the range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART;VALUE=DATE:20020402\n" +
+ "DTEND;VALUE=DATE:20020403\n" +
+ "RRULE:FREQ=DAILY;INTERVAL=1;COUNT=10\n" +
+ "END:VEVENT\n",
+ // 13: daily recurring allday event. First occurrence in the range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART;VALUE=DATE:20020401\n" +
+ "DTEND;VALUE=DATE:20020402\n" +
+ "RRULE:FREQ=DAILY;COUNT=10\n" +
+ "END:VEVENT\n",
+ // 14: two-daily recurring allday event. Not in the range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART;VALUE=DATE:20020401\n" +
+ "DTEND;VALUE=DATE:20020402\n" +
+ "RRULE:FREQ=DAILY;INTERVAL=2;COUNT=10\n" +
+ "END:VEVENT\n",
+ // 15: daily recurring one-hour event. Parent in the range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART:20020402T100000Z\n" +
+ "DTEND:20020402T110000Z\n" +
+ "RRULE:FREQ=DAILY;COUNT=10\n" +
+ "END:VEVENT\n",
+ // 16: daily recurring one-hour event. Occurrence in the range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART:20020401T100000Z\n" +
+ "DTEND:20020401T110000Z\n" +
+ "RRULE:FREQ=DAILY;COUNT=10\n" +
+ "END:VEVENT\n",
+ // 17: zero-length task with DTSTART and DUE set at start of range.
+ "BEGIN:VTODO\n" + "DTSTART:20020402T000000Z\n" + "DUE:20020402T000000Z\n" + "END:VTODO\n",
+ // 18: zero-length event with only DTSTART set at start of range.
+ "BEGIN:VTODO\n" + "DTSTART:20020402T000000Z\n" + "END:VTODO\n",
+ // 19: zero-length event with only DUE set at start of range.
+ "BEGIN:VTODO\n" + "DUE:20020402T000000Z\n" + "END:VTODO\n",
+ // 20: one-hour todo within the range.
+ "BEGIN:VTODO\n" + "DTSTART:20020402T110000Z\n" + "DUE:20020402T120000Z\n" + "END:VTODO\n",
+ // 21: zero-length todo that starts at end of range.
+ "BEGIN:VTODO\n" + "DTSTART:20020403T000000Z\n" + "DUE:20020403T010000Z\n" + "END:VTODO\n",
+ // 22: one-hour todo that ends at start of range.
+ "BEGIN:VTODO\n" + "DTSTART:20020401T230000Z\n" + "DUE:20020402T000000Z\n" + "END:VTODO\n",
+ // 23: daily recurring one-hour event. Parent in the range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART:20020402T000000\n" +
+ "DTEND:20020402T010000\n" +
+ "RRULE:FREQ=DAILY;COUNT=10\n" +
+ "END:VEVENT\n",
+ // 24: daily recurring 24-hour event. Parent in the range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART:20020402T000000\n" +
+ "DTEND:20020403T000000\n" +
+ "RRULE:FREQ=DAILY;COUNT=10\n" +
+ "END:VEVENT\n",
+ // 25: todo that has neither start nor due date set.
+ // Should be returned on every getItems() call. See bug 405459.
+ "BEGIN:VTODO\n" + "SUMMARY:Todo\n" + "END:VTODO\n",
+ // 26: todo that has neither start nor due date but
+ // a completion time set after range. See bug 405459.
+ "BEGIN:VTODO\n" + "SUMMARY:Todo\n" + "COMPLETED:20030404T000001\n" + "END:VTODO\n",
+ // 27: todo that has neither start nor due date but a
+ // completion time set in the range. See bug 405459.
+ "BEGIN:VTODO\n" + "SUMMARY:Todo\n" + "COMPLETED:20020402T120001\n" + "END:VTODO\n",
+ // 28: todo that has neither start nor due date but a
+ // completion time set before the range. See bug 405459.
+ "BEGIN:VTODO\n" + "SUMMARY:Todo\n" + "COMPLETED:20020402T000000\n" + "END:VTODO\n",
+ // 29: todo that has neither start nor due date set,
+ // has the status "COMPLETED" but no completion time. See bug 405459.
+ "BEGIN:VTODO\n" + "SUMMARY:Todo\n" + "STATUS:COMPLETED\n" + "END:VTODO\n",
+ // 30: one-hour event with duration (in the range). See bug 390492.
+ "BEGIN:VEVENT\n" + "DTSTART:20020402T114500Z\n" + "DURATION:PT1H\n" + "END:VEVENT\n",
+ // 31: one-hour event with duration (after the range). See bug 390492.
+ "BEGIN:VEVENT\n" + "DTSTART:20020403T000000Z\n" + "DURATION:PT1H\n" + "END:VEVENT\n",
+ // 32: one-hour event with duration (before the range). See bug 390492.
+ "BEGIN:VEVENT\n" + "DTSTART:20020401T230000Z\n" + "DURATION:PT1H\n" + "END:VEVENT\n",
+ // 33: one-day event with duration. Starts in the range, Ends outside. See bug 390492.
+ "BEGIN:VEVENT\n" + "DTSTART:20020402T120000Z\n" + "DURATION:P1D\n" + "END:VEVENT\n",
+ // 34: one-day event with duration. Starts before the range. Ends inside. See bug 390492.
+ "BEGIN:VEVENT\n" + "DTSTART:20020401T120000Z\n" + "DURATION:P1D\n" + "END:VEVENT\n",
+ // 35: one-day event with duration (before the range). See bug 390492.
+ "BEGIN:VEVENT\n" + "DTSTART:20020401T000000Z\n" + "DURATION:P1D\n" + "END:VEVENT\n",
+ // 36: one-day event with duration (after the range). See bug 390492.
+ "BEGIN:VEVENT\n" + "DTSTART:20020403T000000Z\n" + "DURATION:P1D\n" + "END:VEVENT\n",
+];
+
+add_task(async function testIcalData() {
+ // First entry is test number, second item is expected result for testGetItems().
+ let wantedArray = [
+ [1, 1],
+ [2, 1],
+ [3, 1],
+ [5, 0],
+ [6, 1],
+ [7, 1],
+ [8, 0],
+ [9, 1],
+ [10, 0],
+ [11, 0],
+ [12, 1],
+ [13, 1],
+ [14, 0],
+ [15, 1],
+ [16, 1],
+ [17, 1],
+ [18, 1],
+ [19, 1],
+ [20, 1],
+ [21, 0],
+ [22, 0],
+ [23, 1],
+ [24, 1],
+ [25, 1],
+ [26, 1],
+ [27, 1],
+ [28, 0],
+ [29, 1],
+ [30, 1],
+ [31, 0],
+ [32, 0],
+ [33, 1],
+ [34, 1],
+ [35, 0],
+ [36, 0],
+ ];
+
+ for (let i = 0; i < wantedArray.length; i++) {
+ let itemArray = wantedArray[i];
+ // Correct for 1 to stay in synch with test numbers.
+ let calItem = icalStringArray[itemArray[0] - 1];
+
+ let item;
+ if (calItem.search(/VEVENT/) != -1) {
+ item = createEventFromIcalString(calItem);
+ } else if (calItem.search(/VTODO/) != -1) {
+ item = createTodoFromIcalString(calItem);
+ }
+
+ print("Test " + wantedArray[i][0]);
+ await testGetItems(item, itemArray[1]);
+ await testGetItem(item);
+ }
+
+ /**
+ * Adds aItem to a calendar and performs a getItems() call using the
+ * following range:
+ * 2002/04/02 0:00 - 2002/04/03 0:00
+ * The amount of returned items is compared with expected amount (aResult).
+ * Additionally, the properties of the returned item are compared with aItem.
+ */
+ async function testGetItems(aItem, aResult) {
+ for (let calendar of [getStorageCal(), getMemoryCal()]) {
+ await checkCalendar(calendar, aItem, aResult);
+ }
+ }
+
+ async function checkCalendar(calendar, aItem, aResult) {
+ // add item to calendar
+ await calendar.addItem(aItem);
+
+ // construct range
+ let rangeStart = createDate(2002, 3, 2); // 3 = April
+ let rangeEnd = rangeStart.clone();
+ rangeEnd.day += 1;
+
+ // filter options
+ let filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_ALL |
+ Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES |
+ Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+
+ // implement listener
+ let count = 0;
+ for await (let items of cal.iterate.streamValues(
+ calendar.getItems(filter, 0, rangeStart, rangeEnd)
+ )) {
+ if (items.length) {
+ count += items.length;
+ for (let i = 0; i < items.length; i++) {
+ // Don't check creationDate as it changed when we added the item to the database.
+ compareItemsSpecific(items[i].parentItem, aItem, [
+ "start",
+ "end",
+ "duration",
+ "title",
+ "priority",
+ "privacy",
+ "status",
+ "alarmLastAck",
+ "recurrenceStartDate",
+ ]);
+ }
+ }
+ }
+ equal(count, aResult);
+ }
+
+ /**
+ * (1) Add aItem to a calendar.
+ * The properties of the added item are compared with the passed item.
+ * (2) Perform a getItem() call.
+ * The properties of the returned item are compared with the passed item.
+ */
+ async function testGetItem(aItem) {
+ // get calendars
+ let calArray = [];
+ calArray.push(getStorageCal());
+ calArray.push(getMemoryCal());
+ for (let calendar of calArray) {
+ let count = 0;
+ let returnedItem = null;
+
+ let aDetail = await calendar.addItem(aItem);
+ compareItemsSpecific(aDetail, aItem);
+ // perform getItem() on calendar
+ returnedItem = await calendar.getItem(aDetail.id);
+ count = returnedItem ? 1 : 0;
+
+ equal(count, 1);
+ // Don't check creationDate as it changed when we added the item to the database.
+ compareItemsSpecific(returnedItem, aItem, [
+ "start",
+ "end",
+ "duration",
+ "title",
+ "priority",
+ "privacy",
+ "status",
+ "alarmLastAck",
+ "recurrenceStartDate",
+ ]);
+ }
+ }
+});
+
+add_task(async function testMetaData() {
+ async function testMetaData_(aCalendar) {
+ dump("testMetaData_() calendar type: " + aCalendar.type + "\n");
+ let event1 = createEventFromIcalString(
+ "BEGIN:VEVENT\n" + "DTSTART;VALUE=DATE:20020402\n" + "END:VEVENT\n"
+ );
+
+ event1.id = "item1";
+ await aCalendar.addItem(event1);
+
+ aCalendar.setMetaData("item1", "meta1");
+ equal(aCalendar.getMetaData("item1"), "meta1");
+ equal(aCalendar.getMetaData("unknown"), null);
+
+ let event2 = event1.clone();
+ event2.id = "item2";
+ await aCalendar.addItem(event2);
+
+ aCalendar.setMetaData("item2", "meta2-");
+ equal(aCalendar.getMetaData("item2"), "meta2-");
+
+ aCalendar.setMetaData("item2", "meta2");
+ equal(aCalendar.getMetaData("item2"), "meta2");
+
+ let ids = aCalendar.getAllMetaDataIds();
+ let values = aCalendar.getAllMetaDataValues();
+ equal(values.length, 2);
+ equal(ids.length, 2);
+ ok(ids[0] == "item1" || ids[1] == "item1");
+ ok(ids[0] == "item2" || ids[1] == "item2");
+ ok(values[0] == "meta1" || values[1] == "meta1");
+ ok(values[0] == "meta2" || values[1] == "meta2");
+
+ await aCalendar.deleteItem(event1);
+
+ equal(aCalendar.getMetaData("item1"), null);
+ ids = aCalendar.getAllMetaDataIds();
+ values = aCalendar.getAllMetaDataValues();
+ equal(values.length, 1);
+ equal(ids.length, 1);
+ ok(ids[0] == "item2");
+ ok(values[0] == "meta2");
+
+ aCalendar.deleteMetaData("item2");
+ equal(aCalendar.getMetaData("item2"), null);
+ values = aCalendar.getAllMetaDataValues();
+ ids = aCalendar.getAllMetaDataIds();
+ equal(values.length, 0);
+ equal(ids.length, 0);
+
+ aCalendar.setMetaData("item2", "meta2");
+ equal(aCalendar.getMetaData("item2"), "meta2");
+ await new Promise(resolve => {
+ aCalendar.QueryInterface(Ci.calICalendarProvider).deleteCalendar(aCalendar, {
+ onCreateCalendar: () => {},
+ onDeleteCalendar: resolve,
+ });
+ });
+ values = aCalendar.getAllMetaDataValues();
+ ids = aCalendar.getAllMetaDataIds();
+ equal(values.length, 0);
+ equal(ids.length, 0);
+
+ aCalendar.deleteMetaData("unknown"); // check graceful return
+ }
+
+ await testMetaData_(getMemoryCal());
+ await testMetaData_(getStorageCal());
+});
+
+/*
+async function testOfflineStorage(storageGetter, isRecurring) {
+ let storage = storageGetter();
+ print(`Running offline storage test for ${storage.type} calendar for ${isRecurring ? "recurring" : "normal"} item`);
+
+ let event1 = createEventFromIcalString("BEGIN:VEVENT\n" +
+ "DTSTART;VALUE=DATE:20020402\n" +
+ "DTEND;VALUE=DATE:20020403\n" +
+ "SUMMARY:event1\n" +
+ (isRecurring ? "RRULE:FREQ=DAILY;INTERVAL=1;COUNT=10\n" : "") +
+ "END:VEVENT\n");
+
+ event1 = await storage.addItem(event1);
+
+ // Make sure the event is really in the calendar
+ let result = await storage.getAllItems();
+ equal(result.length, 1);
+
+ // When searching for offline added items, there are none
+ let filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_CREATED;
+ result = await storage.getItems(filter, 0, null, null);
+ equal(result.length, 0);
+
+ // Mark the item as offline added
+ await storage.addOfflineItem(event1);
+
+ // Now there should be an offline item
+ result = await storage.getItems(filter, 0, null, null);
+ equal(result.length, 1);
+
+ let event2 = event1.clone();
+ event2.title = "event2";
+
+ event2 = await storage.modifyItem(event2, event1);
+
+ await storage.modifyOfflineItem(event2);
+
+ // The flag should still be offline added, as it was already marked as such
+ filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_CREATED;
+ result = await storage.getItems(filter, 0, null, null);
+ equal(result.length, 1);
+
+ // Reset the flag
+ await storage.resetItemOfflineFlag(event2);
+
+ // No more offline items after resetting the flag
+ filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_CREATED;
+ result = await storage.getItems(filter, 0, null, null);
+ equal(result.length, 0);
+
+ // Setting modify flag without one set should actually set that flag
+ await storage.modifyOfflineItem(event2);
+ filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_CREATED;
+ result = await storage.getItems(filter, 0, null, null);
+ equal(result.length, 0);
+
+ filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_MODIFIED;
+ result = await storage.getItems(filter, 0, null, null);
+ equal(result.length, 1);
+
+ // Setting the delete flag should modify the flag accordingly
+ await storage.deleteOfflineItem(event2);
+ filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_MODIFIED;
+ result = await storage.getItems(filter, 0, null, null);
+ equal(result.length, 0);
+
+ filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_DELETED;
+ result = await storage.getItems(filter, 0, null, null);
+ equal(result.length, 1);
+
+ // Setting the delete flag on an offline added item should remove it
+ await storage.resetItemOfflineFlag(event2);
+ await storage.addOfflineItem(event2);
+ await storage.deleteOfflineItem(event2);
+ result = await storage.getItemsAsArray(Ci.calICalendar.ITEM_FILTER_ALL_ITEMS, 0, null, null);
+ equal(result.length, 0);
+}
+
+add_task(testOfflineStorage.bind(null, () => getMemoryCal(), false));
+add_task(testOfflineStorage.bind(null, () => getStorageCal(), false));
+add_task(testOfflineStorage.bind(null, () => getMemoryCal(), true));
+add_task(testOfflineStorage.bind(null, () => getStorageCal(), true));
+*/
diff --git a/comm/calendar/test/unit/test_recur.js b/comm/calendar/test/unit/test_recur.js
new file mode 100644
index 0000000000..32033d5b85
--- /dev/null
+++ b/comm/calendar/test/unit/test_recur.js
@@ -0,0 +1,1361 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+});
+
+function makeEvent(str) {
+ return createEventFromIcalString("BEGIN:VEVENT\n" + str + "END:VEVENT");
+}
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ test_interface();
+ test_rrule_interface();
+ test_rules();
+ test_failures();
+ test_limit();
+ test_startdate_change();
+ test_idchange();
+ test_rrule_icalstring();
+ test_immutable();
+ test_icalComponent();
+}
+
+function test_rules() {
+ function check_recur(event, expected, endDate, ignoreNextOccCheck) {
+ dump("Checking '" + event.getProperty("DESCRIPTION") + "'\n");
+
+ // Immutability is required for testing the recurrenceEndDate property.
+ event.makeImmutable();
+
+ // Get recurrence dates
+ let start = createDate(1990, 0, 1);
+ let end = createDate(2020, 0, 1);
+ let recdates = event.recurrenceInfo.getOccurrenceDates(start, end, 0);
+ let occurrences = event.recurrenceInfo.getOccurrences(start, end, 0);
+
+ // Check number of items
+ dump("Expected " + expected.length + " occurrences\n");
+ dump("Got: " + recdates.map(x => x.toString()) + "\n");
+ equal(recdates.length, expected.length);
+ let fmt = cal.dtz.formatter;
+
+ for (let i = 0; i < expected.length; i++) {
+ // Check each date
+ let expectedDate = cal.createDateTime(expected[i]);
+ dump(
+ "Expecting instance at " + expectedDate + "(" + fmt.dayName(expectedDate.weekday) + ")\n"
+ );
+ dump("Recdate:");
+ equal(recdates[i].icalString, expected[i]);
+
+ // Make sure occurrences are correct
+ dump("Occurrence:");
+ occurrences[i].QueryInterface(Ci.calIEvent);
+ equal(occurrences[i].startDate.icalString, expected[i]);
+
+ if (ignoreNextOccCheck) {
+ continue;
+ }
+
+ // Make sure getNextOccurrence works correctly
+ let nextOcc = event.recurrenceInfo.getNextOccurrence(recdates[i]);
+ if (expected.length > i + 1) {
+ notEqual(nextOcc, null);
+ dump("Checking next occurrence: " + expected[i + 1] + "\n");
+ nextOcc.QueryInterface(Ci.calIEvent);
+ equal(nextOcc.startDate.icalString, expected[i + 1]);
+ } else {
+ dump("Expecting no more occurrences, found " + (nextOcc ? nextOcc.startDate : null) + "\n");
+ equal(nextOcc, null);
+ }
+
+ // Make sure getPreviousOccurrence works correctly
+ let prevOcc = event.recurrenceInfo.getPreviousOccurrence(recdates[i]);
+ if (i > 0) {
+ dump(
+ "Checking previous occurrence: " +
+ expected[i - 1] +
+ ", found " +
+ (prevOcc ? prevOcc.startDate : prevOcc) +
+ "\n"
+ );
+ notEqual(prevOcc, null);
+ prevOcc.QueryInterface(Ci.calIEvent);
+ equal(prevOcc.startDate.icalString, expected[i - 1]);
+ } else {
+ dump(
+ "Expecting no previous occurrences, found " +
+ (prevOcc ? prevOcc.startDate : prevOcc) +
+ "\n"
+ );
+ equal(prevOcc, null);
+ }
+ }
+
+ if (typeof endDate == "string") {
+ endDate = cal.createDateTime(endDate).nativeTime;
+ }
+ equal(event.recurrenceInfo.recurrenceEndDate, endDate);
+
+ // Make sure recurrenceInfo.clone works correctly
+ test_clone(event);
+ }
+
+ // Test specific items/rules
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:Repeat every tuesday and wednesday starting " +
+ "Tue 2nd April 2002\n" +
+ "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=6;BYDAY=TU,WE\n" +
+ "DTSTART:20020402T114500\n" +
+ "DTEND:20020402T124500\n"
+ ),
+ [
+ "20020402T114500",
+ "20020403T114500",
+ "20020409T114500",
+ "20020410T114500",
+ "20020416T114500",
+ "20020417T114500",
+ ],
+ "20020417T124500"
+ );
+
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:Repeat every thursday starting Tue 2nd April 2002\n" +
+ "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=6;BYDAY=TH\n" +
+ "DTSTART:20020402T114500\n" +
+ "DTEND:20020402T124500\n"
+ ),
+ [
+ "20020402T114500", // DTSTART part of the resulting set
+ "20020404T114500",
+ "20020411T114500",
+ "20020418T114500",
+ "20020425T114500",
+ "20020502T114500",
+ "20020509T114500",
+ ],
+ "20020509T124500"
+ );
+
+ // Bug 469840 - Recurring Sundays incorrect
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=6;BYDAY=WE,SA,SU with DTSTART:20081217T133000\n" +
+ "RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=6;BYDAY=WE,SA,SU\n" +
+ "DTSTART:20081217T133000\n" +
+ "DTEND:20081217T143000\n"
+ ),
+ [
+ "20081217T133000",
+ "20081220T133000",
+ "20081221T133000",
+ "20081231T133000",
+ "20090103T133000",
+ "20090104T133000",
+ ],
+ "20090104T143000"
+ );
+
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=6;WKST=SU;BYDAY=WE,SA,SU with DTSTART:20081217T133000\n" +
+ "RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=6;WKST=SU;BYDAY=WE,SA,SU\n" +
+ "DTSTART:20081217T133000\n" +
+ "DTEND:20081217T143000\n"
+ ),
+ [
+ "20081217T133000",
+ "20081220T133000",
+ "20081228T133000",
+ "20081231T133000",
+ "20090103T133000",
+ "20090111T133000",
+ ],
+ "20090111T143000"
+ );
+
+ // bug 353797: occurrences for repeating all day events should stay "all-day"
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:Allday repeat every thursday starting Tue 2nd April 2002\n" +
+ "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=3;BYDAY=TH\n" +
+ "DTSTART;VALUE=DATE:20020404\n" +
+ "DTEND;VALUE=DATE:20020405\n"
+ ),
+ ["20020404", "20020411", "20020418"],
+ "20020419"
+ );
+
+ /* Test disabled, because BYWEEKNO is known to be broken
+ check_recur(makeEvent("DESCRIPTION:Monday of week number 20 (where the default start of the week is Monday)\n" +
+ "RRULE:FREQ=YEARLY;INTERVAL=1;COUNT=6;BYDAY=MO;BYWEEKNO=20\n" +
+ "DTSTART:19970512T090000",
+ ["19970512T090000", "19980511T090000", "19990517T090000" +
+ "20000515T090000", "20010514T090000", "20020513T090000"]);
+ */
+
+ // bug 899326: Recurrences with BYMONTHDAY=X,X,31 don't show at all in months with less than 31 days
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:Every 11th & 31st of every Month\n" +
+ "RRULE:FREQ=MONTHLY;COUNT=6;BYMONTHDAY=11,31\n" +
+ "DTSTART:20130731T160000\n" +
+ "DTEND:20130731T170000\n"
+ ),
+ [
+ "20130731T160000",
+ "20130811T160000",
+ "20130831T160000",
+ "20130911T160000",
+ "20131011T160000",
+ "20131031T160000",
+ ],
+ "20131031T170000"
+ );
+
+ // bug 899770: Monthly Recurrences with BYDAY and BYMONTHDAY with more than 2 dates are not working
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:Every WE & SA the 6th, 20th & 31st\n" +
+ "RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=WE,SA;BYMONTHDAY=6,20,31\n" +
+ "DTSTART:20130706T160000\n" +
+ "DTEND:20130706T170000\n"
+ ),
+ [
+ "20130706T160000",
+ "20130720T160000",
+ "20130731T160000",
+ "20130831T160000",
+ "20131106T160000",
+ "20131120T160000",
+ ],
+ "20131120T170000"
+ );
+
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:Every day, use exdate to exclude the second day\n" +
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "DTSTART:20020402T114500Z\n" +
+ "EXDATE:20020403T114500Z\n"
+ ),
+ ["20020402T114500Z", "20020404T114500Z"],
+ "20020404T114500"
+ );
+
+ // test for issue 734245
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:Every day, use exdate of type DATE to exclude the second day\n" +
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "DTSTART:20020402T114500Z\n" +
+ "EXDATE;VALUE=DATE:20020403\n"
+ ),
+ ["20020402T114500Z", "20020404T114500Z"],
+ "20020404T114500"
+ );
+
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:Use EXDATE to eliminate the base event\n" +
+ "RRULE:FREQ=DAILY;COUNT=1\n" +
+ "DTSTART:20020402T114500Z\n" +
+ "EXDATE:20020402T114500Z\n"
+ ),
+ [],
+ -9223372036854775000
+ );
+
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "UID:123\n" +
+ "DESCRIPTION:Every day, exception put on exdated day\n" +
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "DTSTART:20020402T114500Z\n" +
+ "EXDATE:20020403T114500Z\n" +
+ "END:VEVENT\n" +
+ "BEGIN:VEVENT\n" +
+ "DTSTART:20020403T114500Z\n" +
+ "UID:123\n" +
+ "RECURRENCE-ID:20020404T114500Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ ["20020402T114500Z", "20020403T114500Z"],
+ "20020403T114500",
+ true
+ ); // ignore next occ check, bug 455490
+
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "UID:123\n" +
+ "DESCRIPTION:Every day, exception put on exdated start day\n" +
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "DTSTART:20020402T114500Z\n" +
+ "EXDATE:20020402T114500Z\n" +
+ "END:VEVENT\n" +
+ "BEGIN:VEVENT\n" +
+ "DTSTART:20020402T114500Z\n" +
+ "UID:123\n" +
+ "RECURRENCE-ID:20020404T114500Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ ["20020402T114500Z", "20020403T114500Z"],
+ "20020403T114500",
+ true /* ignore next occ check, bug 455490 */
+ );
+
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Daily on weekdays with UNTIL\n" +
+ "RRULE:FREQ=DAILY;UNTIL=20111217T220000Z;BYDAY=MO,TU,WE,TH,FR\n" +
+ "DTSTART:20111212T220000Z\n" +
+ "DTEND:20111212T230000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20111212T220000Z",
+ "20111213T220000Z",
+ "20111214T220000Z",
+ "20111215T220000Z",
+ "20111216T220000Z",
+ ],
+ "20111216T230000",
+ false
+ );
+
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Daily on weekdays with UNTIL and exception\n" +
+ "RRULE:FREQ=DAILY;UNTIL=20111217T220000Z;BYDAY=MO,TU,WE,TH,FR\n" +
+ "EXDATE:20111214T220000Z\n" +
+ "DTSTART:20111212T220000Z\n" +
+ "DTEND:20111212T230000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ ["20111212T220000Z", "20111213T220000Z", "20111215T220000Z", "20111216T220000Z"],
+ "20111216T230000",
+ false
+ );
+
+ // Bug 958978: Yearly recurrence, the last day of a specified month.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Yearly the last day of February\n" +
+ "RRULE:FREQ=YEARLY;COUNT=6;BYMONTHDAY=-1;BYMONTH=2\n" +
+ "DTSTART:20140228T220000Z\n" +
+ "DTEND:20140228T230000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20140228T220000Z",
+ "20150228T220000Z",
+ "20160229T220000Z",
+ "20170228T220000Z",
+ "20180228T220000Z",
+ "20190228T220000Z",
+ ],
+ "20190228T230000",
+ false
+ );
+
+ // Bug 958978: Yearly recurrence, the last day of a not specified month.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Yearly the last day of April without BYMONTH=4 in the rule\n" +
+ "RRULE:FREQ=YEARLY;COUNT=6;BYMONTHDAY=-1\n" +
+ "DTSTART:20140430T220000Z\n" +
+ "DTEND:20140430T230000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20140430T220000Z",
+ "20150430T220000Z",
+ "20160430T220000Z",
+ "20170430T220000Z",
+ "20180430T220000Z",
+ "20190430T220000Z",
+ ],
+ "20190430T230000",
+ false
+ );
+
+ // Bug 958978 - Check a yearly recurrence on every WE and FR of January and March
+ // (more BYMONTH and more BYDAY).
+ // Check for the occurrences in the first year.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Yearly every WE and FR of January and March (more BYMONTH and more BYDAY)\n" +
+ "RRULE:FREQ=YEARLY;COUNT=18;BYMONTH=1,3;BYDAY=WE,FR\n" +
+ "DTSTART:20140101T150000Z\n" +
+ "DTEND:20140101T160000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20140101T150000Z",
+ "20140103T150000Z",
+ "20140108T150000Z",
+ "20140110T150000Z",
+ "20140115T150000Z",
+ "20140117T150000Z",
+ "20140122T150000Z",
+ "20140124T150000Z",
+ "20140129T150000Z",
+ "20140131T150000Z",
+ "20140305T150000Z",
+ "20140307T150000Z",
+ "20140312T150000Z",
+ "20140314T150000Z",
+ "20140319T150000Z",
+ "20140321T150000Z",
+ "20140326T150000Z",
+ "20140328T150000Z",
+ ],
+ "20140328T160000",
+ false
+ );
+
+ // Bug 958978 - Check a yearly recurrence every day of January (BYMONTH and more BYDAY).
+ // Check for all the occurrences in the first year.
+ let expectedDates = [];
+ for (let i = 1; i < 32; i++) {
+ expectedDates.push("201401" + (i < 10 ? "0" + i : i) + "T150000Z");
+ }
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Yearly, every day of January (one BYMONTH and more BYDAY)\n" +
+ "RRULE:FREQ=YEARLY;COUNT=31;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA\n" +
+ "DTSTART:20140101T150000Z\n" +
+ "DTEND:20140101T160000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ expectedDates,
+ "20140131T160000",
+ false
+ );
+
+ // Bug 958974 - Monthly recurrence every WE, FR and the third MO (monthly with more bydays).
+ // Check the occurrences in the first month until the week with the first monday of the rule.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Monthly every Wednesday, Friday and the third Monday\n" +
+ "RRULE:FREQ=MONTHLY;COUNT=8;BYDAY=3MO,WE,FR\n" +
+ "DTSTART:20150102T080000Z\n" +
+ "DTEND:20150102T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20150102T080000Z",
+ "20150107T080000Z",
+ "20150109T080000Z",
+ "20150114T080000Z",
+ "20150116T080000Z",
+ "20150119T080000Z",
+ "20150121T080000Z",
+ "20150123T080000Z",
+ ],
+ "20150123T090000",
+ false
+ );
+
+ // Bug 419490 - Monthly recurrence, the fifth Saturday starting from February.
+ // Check a monthly rule that specifies a day that is not part of the month
+ // the events starts in.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Monthly the fifth Saturday\n" +
+ "RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=5SA\n" +
+ "DTSTART:20150202T080000Z\n" +
+ "DTEND:20150202T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20150202T080000Z",
+ "20150530T080000Z",
+ "20150829T080000Z",
+ "20151031T080000Z",
+ "20160130T080000Z",
+ "20160430T080000Z",
+ "20160730T080000Z",
+ ],
+ "20160730T090000",
+ false
+ );
+
+ // Bug 419490 - Monthly recurrence, the fifth Wednesday every two months starting from February.
+ // Check a monthly rule that specifies a day that is not part of the month
+ // the events starts in.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Monthly the fifth Friday every two months\n" +
+ "RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=6;BYDAY=5FR\n" +
+ "DTSTART:20150202T080000Z\n" +
+ "DTEND:20150202T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20150202T080000Z",
+ "20151030T080000Z",
+ "20160429T080000Z",
+ "20161230T080000Z",
+ "20170630T080000Z",
+ "20171229T080000Z",
+ "20180629T080000Z",
+ ],
+ "20180629T090000",
+ false
+ );
+
+ // Bugs 419490, 958974 - Monthly recurrence, the 2nd Monday, 5th Wednesday and the 5th to last Saturday every month starting from February.
+ // Check a monthly rule that specifies a day that is not part of the month
+ // the events starts in with positive and negative position along with other byday.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Monthly the 2nd Monday, 5th Wednesday and the 5th to last Saturday every month\n" +
+ "RRULE:FREQ=MONTHLY;COUNT=7;BYDAY=2MO,-5WE,5SA\n" +
+ "DTSTART:20150401T080000Z\n" +
+ "DTEND:20150401T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20150401T080000Z",
+ "20150413T080000Z",
+ "20150511T080000Z",
+ "20150530T080000Z",
+ "20150608T080000Z",
+ "20150701T080000Z",
+ "20150713T080000Z",
+ ],
+ "20150713T090000",
+ false
+ );
+
+ // Bug 1146500 - Monthly recurrence, every MO and FR when are odd days starting from the 1st of March.
+ // Check the first occurrence when we have BYDAY along with BYMONTHDAY.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Monthly recurrence, every MO and FR when are odd days starting from the 1st of March\n" +
+ "RRULE:FREQ=MONTHLY;BYDAY=MO,FR;BYMONTHDAY=1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31;COUNT=4\n" +
+ "DTSTART:20150301T080000Z\n" +
+ "DTEND:20150301T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20150301T080000Z",
+ "20150309T080000Z",
+ "20150313T080000Z",
+ "20150323T080000Z",
+ "20150327T080000Z",
+ ],
+ "20150327T090000",
+ false
+ );
+
+ // Bug 1146500 - Monthly recurrence, every MO and FR when are odd days starting from the 1st of April.
+ // Check the first occurrence when we have BYDAY along with BYMONTHDAY.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Monthly recurrence, every MO and FR when are odd days starting from the 1st of March\n" +
+ "RRULE:FREQ=MONTHLY;BYDAY=MO,FR;BYMONTHDAY=1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31;COUNT=4\n" +
+ "DTSTART:20150401T080000Z\n" +
+ "DTEND:20150401T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20150401T080000Z",
+ "20150403T080000Z",
+ "20150413T080000Z",
+ "20150417T080000Z",
+ "20150427T080000Z",
+ ],
+ "20150427T090000",
+ false
+ );
+
+ // Bug 1146500 - Monthly recurrence, every MO and FR when are odd days starting from the 1st of April.
+ // Check the first occurrence when we have BYDAY along with BYMONTHDAY.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Monthly recurrence, every MO and FR when are odd days starting from the 1st of March\n" +
+ "RRULE:FREQ=MONTHLY;BYDAY=MO,SA;BYMONTHDAY=1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31;COUNT=4\n" +
+ "DTSTART:20150401T080000Z\n" +
+ "DTEND:20150401T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20150401T080000Z",
+ "20150411T080000Z",
+ "20150413T080000Z",
+ "20150425T080000Z",
+ "20150427T080000Z",
+ ],
+ "20150427T090000",
+ false
+ );
+
+ // Bug 1146500 - Monthly every SU and FR when are odd days starting from 28 of February (BYDAY and BYMONTHDAY).
+ // Check the first occurrence when we have BYDAY along with BYMONTHDAY.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Monthly recurrence, every SU and FR when are odd days starting from the 1st of March\n" +
+ "RRULE:FREQ=MONTHLY;BYDAY=SU,FR;BYMONTHDAY=1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31;COUNT=9\n" +
+ "DTSTART:20150228T080000Z\n" +
+ "DTEND:20150228T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20150228T080000Z",
+ "20150301T080000Z",
+ "20150313T080000Z",
+ "20150315T080000Z",
+ "20150327T080000Z",
+ "20150329T080000Z",
+ "20150403T080000Z",
+ "20150405T080000Z",
+ "20150417T080000Z",
+ "20150419T080000Z",
+ ],
+ "20150419T090000",
+ false
+ );
+
+ // Bug 1103187 - Monthly recurrence with only MONTHLY tag in the rule. Recurrence day taken
+ // from the start date. Check four occurrences.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Only Monthly recurrence\n" +
+ "RRULE:FREQ=MONTHLY;COUNT=4\n" +
+ "DTSTART:20160404T080000Z\n" +
+ "DTEND:20160404T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ ["20160404T080000Z", "20160504T080000Z", "20160604T080000Z", "20160704T080000Z"],
+ "20160704T090000",
+ false
+ );
+
+ // Bug 1265554 - Monthly recurrence with only MONTHLY tag in the rule. Recurrence on the 31st
+ // of the month. Check for 6 occurrences.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Only Monthly recurrence, the 31st\n" +
+ "RRULE:FREQ=MONTHLY;COUNT=6\n" +
+ "DTSTART:20160131T150000Z\n" +
+ "DTEND:20160131T160000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20160131T150000Z",
+ "20160331T150000Z",
+ "20160531T150000Z",
+ "20160731T150000Z",
+ "20160831T150000Z",
+ "20161031T150000Z",
+ ],
+ "20161031T160000",
+ false
+ );
+
+ // Bug 1265554 - Monthly recurrence with only MONTHLY tag in the rule. Recurrence on the 31st
+ // of the month every two months. Check for 6 occurrences.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Only Monthly recurrence, the 31st every 2 months\n" +
+ "RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=6\n" +
+ "DTSTART:20151231T150000Z\n" +
+ "DTEND:20151231T160000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20151231T150000Z",
+ "20160831T150000Z",
+ "20161031T150000Z",
+ "20161231T150000Z",
+ "20170831T150000Z",
+ "20171031T150000Z",
+ ],
+ "20171031T160000",
+ false
+ );
+
+ let item, occ1;
+ item = makeEvent(
+ "DESCRIPTION:occurrence on day 1 moved between the occurrences " +
+ "on days 2 and 3\n" +
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "DTSTART:20020402T114500Z\n"
+ );
+ occ1 = item.recurrenceInfo.getOccurrenceFor(createDate(2002, 3, 2, true, 11, 45, 0));
+ occ1.QueryInterface(Ci.calIEvent);
+ occ1.startDate = createDate(2002, 3, 3, true, 12, 0, 0);
+ item.recurrenceInfo.modifyException(occ1, true);
+ check_recur(
+ item,
+ ["20020403T114500Z", "20020403T120000Z", "20020404T114500Z"],
+ "20020404T114500"
+ );
+
+ item = makeEvent(
+ "DESCRIPTION:occurrence on day 1 moved between the occurrences " +
+ "on days 2 and 3, EXDATE on day 2\n" +
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "DTSTART:20020402T114500Z\n" +
+ "EXDATE:20020403T114500Z\n"
+ );
+ occ1 = item.recurrenceInfo.getOccurrenceFor(createDate(2002, 3, 2, true, 11, 45, 0));
+ occ1.QueryInterface(Ci.calIEvent);
+ occ1.startDate = createDate(2002, 3, 3, true, 12, 0, 0);
+ item.recurrenceInfo.modifyException(occ1, true);
+ check_recur(item, ["20020403T120000Z", "20020404T114500Z"], "20020404T114500");
+
+ item = makeEvent(
+ "DESCRIPTION:all occurrences have exceptions\n" +
+ "RRULE:FREQ=DAILY;COUNT=2\n" +
+ "DTSTART:20020402T114500Z\n"
+ );
+ occ1 = item.recurrenceInfo.getOccurrenceFor(createDate(2002, 3, 2, true, 11, 45, 0));
+ occ1.QueryInterface(Ci.calIEvent);
+ occ1.startDate = createDate(2002, 3, 2, true, 12, 0, 0);
+ item.recurrenceInfo.modifyException(occ1, true);
+ let occ2 = item.recurrenceInfo.getOccurrenceFor(createDate(2002, 3, 3, true, 11, 45, 0));
+ occ2.QueryInterface(Ci.calIEvent);
+ occ2.startDate = createDate(2002, 3, 3, true, 12, 0, 0);
+ item.recurrenceInfo.modifyException(occ2, true);
+ check_recur(item, ["20020402T120000Z", "20020403T120000Z"], "20020403T114500");
+
+ item = makeEvent(
+ "DESCRIPTION:rdate and exception before the recurrence start date\n" +
+ "RRULE:FREQ=DAILY;COUNT=2\n" +
+ "DTSTART:20020402T114500Z\n" +
+ "RDATE:20020401T114500Z\n"
+ );
+ occ1 = item.recurrenceInfo.getOccurrenceFor(createDate(2002, 3, 2, true, 11, 45, 0));
+ occ1.QueryInterface(Ci.calIEvent);
+ occ1.startDate = createDate(2002, 2, 30, true, 11, 45, 0);
+ item.recurrenceInfo.modifyException(occ1, true);
+ check_recur(
+ item,
+ ["20020330T114500Z", "20020401T114500Z", "20020403T114500Z"],
+ "20020403T114500"
+ );
+
+ item = makeEvent(
+ "DESCRIPTION:bug 734245, an EXDATE of type DATE shall also match a DTSTART of type DATE-TIME\n" +
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "DTSTART:20020401T114500Z\n" +
+ "EXDATE;VALUE=DATE:20020402\n"
+ );
+ check_recur(item, ["20020401T114500Z", "20020403T114500Z"], "20020403T114500");
+
+ item = makeEvent(
+ "DESCRIPTION:EXDATE with a timezone\n" +
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "DTSTART;TZID=Europe/Berlin:20020401T114500\n" +
+ "EXDATE;TZID=Europe/Berlin:20020402T114500\n"
+ );
+ check_recur(item, ["20020401T114500", "20020403T114500"], "20020403T094500");
+
+ // Unsupported SECONDLY FREQ value.
+ item = makeEvent(
+ "DESCRIPTION:bug 1770984\nRRULE:FREQ=SECONDLY;COUNT=60\nDTSTART:20220606T114500Z\n"
+ );
+ check_recur(item, [], "20220606T114500Z");
+
+ // Unsupported MINUTELY FREQ value.
+ item = makeEvent(
+ "DESCRIPTION:bug 1770984\nRRULE:FREQ=MINUTELY;COUNT=60\nDTSTART:20220606T114500Z\n"
+ );
+ check_recur(item, [], "20220606T114500Z");
+}
+
+function test_limit() {
+ let item = makeEvent(
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "UID:1\n" +
+ "DTSTART:20020401T114500\n" +
+ "DTEND:20020401T124500\n"
+ );
+ dump("ics: " + item.icalString + "\n");
+
+ let start = createDate(1990, 0, 1);
+ let end = createDate(2020, 0, 1);
+ let recdates = item.recurrenceInfo.getOccurrenceDates(start, end, 0);
+ let occurrences = item.recurrenceInfo.getOccurrences(start, end, 0);
+
+ equal(recdates.length, 3);
+ equal(occurrences.length, 3);
+
+ recdates = item.recurrenceInfo.getOccurrenceDates(start, end, 2);
+ occurrences = item.recurrenceInfo.getOccurrences(start, end, 2);
+
+ equal(recdates.length, 2);
+ equal(occurrences.length, 2);
+
+ recdates = item.recurrenceInfo.getOccurrenceDates(start, end, 9);
+ occurrences = item.recurrenceInfo.getOccurrences(start, end, 9);
+
+ equal(recdates.length, 3);
+ equal(occurrences.length, 3);
+}
+
+function test_clone(event) {
+ let oldRecurItems = event.recurrenceInfo.getRecurrenceItems();
+ let cloned = event.recurrenceInfo.clone();
+ let newRecurItems = cloned.getRecurrenceItems();
+
+ // Check number of recurrence items
+ equal(oldRecurItems.length, newRecurItems.length);
+
+ for (let i = 0; i < oldRecurItems.length; i++) {
+ // Check if recurrence item cloned correctly
+ equal(oldRecurItems[i].icalProperty.icalString, newRecurItems[i].icalProperty.icalString);
+ }
+}
+
+function test_interface() {
+ let item = makeEvent(
+ "DTSTART:20020402T114500Z\n" +
+ "DTEND:20020402T124500Z\n" +
+ "RRULE:FREQ=WEEKLY;COUNT=6;BYDAY=TU,WE\r\n" +
+ "EXDATE:20020403T114500Z\r\n" +
+ "RDATE:20020401T114500Z\r\n"
+ );
+
+ let rinfo = item.recurrenceInfo;
+ ok(cal.data.compareObjects(rinfo.item, item, Ci.calIEvent));
+
+ // getRecurrenceItems
+ let ritems = rinfo.getRecurrenceItems();
+ equal(ritems.length, 3);
+
+ let checkritems = new Map(
+ ritems.map(ritem => [ritem.icalProperty.propertyName, ritem.icalProperty])
+ );
+ let rparts = new Map(
+ checkritems
+ .get("RRULE")
+ .value.split(";")
+ .map(value => value.split("=", 2))
+ );
+ equal(rparts.size, 3);
+ equal(rparts.get("FREQ"), "WEEKLY");
+ equal(rparts.get("COUNT"), "6");
+ equal(rparts.get("BYDAY"), "TU,WE");
+ equal(checkritems.get("EXDATE").value, "20020403T114500Z");
+ equal(checkritems.get("RDATE").value, "20020401T114500Z");
+
+ // setRecurrenceItems
+ let newRItems = [cal.createRecurrenceRule(), cal.createRecurrenceDate()];
+
+ newRItems[0].type = "DAILY";
+ newRItems[0].interval = 1;
+ newRItems[0].count = 1;
+ newRItems[1].isNegative = true;
+ newRItems[1].date = cal.createDateTime("20020404T114500Z");
+
+ rinfo.setRecurrenceItems(newRItems);
+ let itemString = item.icalString;
+
+ equal(itemString.match(/RRULE:[A-Z=,]*FREQ=WEEKLY/), null);
+ equal(itemString.match(/EXDATE[A-Z;=-]*:20020403T114500Z/, null));
+ equal(itemString.match(/RDATE[A-Z;=-]*:20020401T114500Z/, null));
+ notEqual(itemString.match(/RRULE:[A-Z=,]*FREQ=DAILY/), null);
+ notEqual(itemString.match(/EXDATE[A-Z;=-]*:20020404T114500Z/, null));
+
+ // This may be an implementation detail, but we don't want this breaking
+ rinfo.wrappedJSObject.ensureSortedRecurrenceRules();
+ equal(
+ rinfo.wrappedJSObject.mNegativeRules[0].icalProperty.icalString,
+ newRItems[1].icalProperty.icalString
+ );
+ equal(
+ rinfo.wrappedJSObject.mPositiveRules[0].icalProperty.icalString,
+ newRItems[0].icalProperty.icalString
+ );
+
+ // countRecurrenceItems
+ equal(2, rinfo.countRecurrenceItems());
+
+ // clearRecurrenceItems
+ rinfo.clearRecurrenceItems();
+ equal(0, rinfo.countRecurrenceItems());
+
+ // appendRecurrenceItems / getRecurrenceItemAt / insertRecurrenceItemAt
+ rinfo.appendRecurrenceItem(ritems[0]);
+ rinfo.appendRecurrenceItem(ritems[1]);
+ rinfo.insertRecurrenceItemAt(ritems[2], 0);
+
+ ok(cal.data.compareObjects(ritems[2], rinfo.getRecurrenceItemAt(0), Ci.calIRecurrenceItem));
+ ok(cal.data.compareObjects(ritems[0], rinfo.getRecurrenceItemAt(1), Ci.calIRecurrenceItem));
+ ok(cal.data.compareObjects(ritems[1], rinfo.getRecurrenceItemAt(2), Ci.calIRecurrenceItem));
+
+ // deleteRecurrenceItem
+ rinfo.deleteRecurrenceItem(ritems[0]);
+ ok(!item.icalString.includes("RRULE"));
+
+ // deleteRecurrenceItemAt
+ rinfo.deleteRecurrenceItemAt(1);
+ itemString = item.icalString;
+ ok(!itemString.includes("EXDATE"));
+ ok(itemString.includes("RDATE"));
+
+ // insertRecurrenceItemAt with exdate
+ rinfo.insertRecurrenceItemAt(ritems[1], 1);
+ ok(cal.data.compareObjects(ritems[1], rinfo.getRecurrenceItemAt(1), Ci.calIRecurrenceItem));
+ rinfo.deleteRecurrenceItem(ritems[1]);
+
+ // isFinite = true
+ ok(rinfo.isFinite);
+ rinfo.appendRecurrenceItem(ritems[0]);
+ ok(rinfo.isFinite);
+
+ // isFinite = false
+ let item2 = makeEvent(
+ // eslint-disable-next-line no-useless-concat
+ "DTSTART:20020402T114500Z\n" + "DTEND:20020402T124500Z\n" + "RRULE:FREQ=WEEKLY;BYDAY=TU,WE\n"
+ );
+ ok(!item2.recurrenceInfo.isFinite);
+
+ // removeOccurrenceAt/restoreOccurreceAt
+ let occDate1 = cal.createDateTime("20020403T114500Z");
+ let occDate2 = cal.createDateTime("20020404T114500Z");
+ rinfo.removeOccurrenceAt(occDate1);
+ ok(item.icalString.includes("EXDATE"));
+ rinfo.restoreOccurrenceAt(occDate1);
+ ok(!item.icalString.includes("EXDATE"));
+
+ // modifyException / getExceptionFor
+ let occ1 = rinfo.getOccurrenceFor(occDate1);
+ occ1.QueryInterface(Ci.calIEvent);
+ occ1.startDate = cal.createDateTime("20020401T114500");
+ rinfo.modifyException(occ1, true);
+ ok(rinfo.getExceptionFor(occDate1) != null);
+
+ // modifyException immutable
+ let occ2 = rinfo.getOccurrenceFor(occDate2);
+ occ2.makeImmutable();
+ rinfo.modifyException(occ2, true);
+ ok(rinfo.getExceptionFor(occDate2) != null);
+
+ // getExceptionIds
+ let ids = rinfo.getExceptionIds();
+ equal(ids.length, 2);
+ ok(ids[0].compare(occDate1) == 0);
+ ok(ids[1].compare(occDate2) == 0);
+
+ // removeExceptionFor
+ rinfo.removeExceptionFor(occDate1);
+ ok(rinfo.getExceptionFor(occDate1) == null);
+ equal(rinfo.getExceptionIds().length, 1);
+}
+
+function test_rrule_interface() {
+ let item = makeEvent(
+ "DTSTART:20020402T114500Z\r\n" +
+ "DTEND:20020402T124500Z\r\n" +
+ "RRULE:INTERVAL=2;FREQ=WEEKLY;COUNT=6;BYDAY=TU,WE\r\n"
+ );
+
+ let rrule = item.recurrenceInfo.getRecurrenceItemAt(0);
+ rrule.QueryInterface(Ci.calIRecurrenceRule);
+ equal(rrule.type, "WEEKLY");
+ equal(rrule.interval, 2);
+ equal(rrule.count, 6);
+ ok(rrule.isByCount);
+ ok(!rrule.isNegative);
+ ok(rrule.isFinite);
+ equal(rrule.getComponent("BYDAY").toString(), [3, 4].toString());
+
+ // Now start changing things
+ rrule.setComponent("BYDAY", [4, 5]);
+ equal(rrule.icalString.match(/BYDAY=WE,TH/), "BYDAY=WE,TH");
+
+ rrule.count = -1;
+ ok(!rrule.isByCount);
+ ok(!rrule.isFinite);
+ equal(rrule.icalString.match(/COUNT=/), null);
+ throws(() => rrule.count, /0x80004005/);
+
+ rrule.interval = 1;
+ equal(rrule.interval, 1);
+ equal(rrule.icalString.match(/INTERVAL=/), null);
+
+ rrule.interval = 3;
+ equal(rrule.interval, 3);
+ equal(rrule.icalString.match(/INTERVAL=3/), "INTERVAL=3");
+
+ rrule.type = "MONTHLY";
+ equal(rrule.type, "MONTHLY");
+ equal(rrule.icalString.match(/FREQ=MONTHLY/), "FREQ=MONTHLY");
+
+ // untilDate (without UTC)
+ rrule.count = 3;
+ let untilDate = cal.createDateTime();
+ untilDate.timezone = cal.timezoneService.getTimezone("Europe/Berlin");
+ rrule.untilDate = untilDate;
+ ok(!rrule.isByCount);
+ throws(() => rrule.count, /0x80004005/);
+ equal(rrule.untilDate.icalString, untilDate.getInTimezone(cal.dtz.UTC).icalString);
+
+ // untilDate (with UTC)
+ rrule.count = 3;
+ untilDate = cal.createDateTime();
+ untilDate.timezone = cal.dtz.UTC;
+ rrule.untilDate = untilDate;
+ ok(!rrule.isByCount);
+ throws(() => rrule.count, /0x80004005/);
+ equal(rrule.untilDate.icalString, untilDate.icalString);
+}
+
+function test_startdate_change() {
+ // Setting a start date if its missing shouldn't throw
+ // eslint-disable-next-line no-useless-concat
+ let item = makeEvent("DTEND:20020402T124500Z\r\n" + "RRULE:FREQ=DAILY\r\n");
+ item.startDate = cal.createDateTime("20020502T114500Z");
+
+ function makeRecEvent(str) {
+ // eslint-disable-next-line no-useless-concat
+ return makeEvent("DTSTART:20020402T114500Z\r\n" + "DTEND:20020402T134500Z\r\n" + str);
+ }
+
+ function changeBy(changeItem, dur) {
+ let newDate = changeItem.startDate.clone();
+ newDate.addDuration(cal.createDuration(dur));
+ changeItem.startDate = newDate;
+ }
+
+ let ritem;
+
+ // Changing an existing start date for a recurring item shouldn't either
+ item = makeRecEvent("RRULE:FREQ=DAILY\r\n");
+ changeBy(item, "PT1H");
+
+ // Event with an rdate
+ item = makeRecEvent("RDATE:20020403T114500Z\r\n");
+ changeBy(item, "PT1H");
+ ritem = item.recurrenceInfo.getRecurrenceItemAt(0);
+ ritem.QueryInterface(Ci.calIRecurrenceDate);
+ equal(ritem.date.icalString, "20020403T124500Z");
+
+ // Event with an exdate
+ item = makeRecEvent("EXDATE:20020403T114500Z\r\n");
+ changeBy(item, "PT1H");
+ ritem = item.recurrenceInfo.getRecurrenceItemAt(0);
+ ritem.QueryInterface(Ci.calIRecurrenceDate);
+ equal(ritem.date.icalString, "20020403T124500Z");
+
+ // Event with an rrule with until date
+ item = makeRecEvent("RRULE:FREQ=WEEKLY;UNTIL=20020406T114500Z\r\n");
+ changeBy(item, "PT1H");
+ ritem = item.recurrenceInfo.getRecurrenceItemAt(0);
+ ritem.QueryInterface(Ci.calIRecurrenceRule);
+ equal(ritem.untilDate.icalString, "20020406T124500Z");
+
+ // Event with an exception item
+ item = makeRecEvent("RRULE:FREQ=DAILY\r\n");
+ let occ = item.recurrenceInfo.getOccurrenceFor(cal.createDateTime("20020406T114500Z"));
+ occ.QueryInterface(Ci.calIEvent);
+ occ.startDate = cal.createDateTime("20020406T124500Z");
+ item.recurrenceInfo.modifyException(occ, true);
+ changeBy(item, "PT1H");
+ equal(item.startDate.icalString, "20020402T124500Z");
+ occ = item.recurrenceInfo.getExceptionFor(cal.createDateTime("20020406T124500Z"));
+ occ.QueryInterface(Ci.calIEvent);
+ equal(occ.startDate.icalString, "20020406T134500Z");
+}
+
+function test_idchange() {
+ let item = makeEvent(
+ "UID:unchanged\r\n" +
+ "DTSTART:20020402T114500Z\r\n" +
+ "DTEND:20020402T124500Z\r\n" +
+ "RRULE:FREQ=DAILY\r\n"
+ );
+ let occ = item.recurrenceInfo.getOccurrenceFor(cal.createDateTime("20020406T114500Z"));
+ occ.QueryInterface(Ci.calIEvent);
+ occ.startDate = cal.createDateTime("20020406T124500Z");
+ item.recurrenceInfo.modifyException(occ, true);
+ equal(occ.id, "unchanged");
+
+ item.id = "changed";
+
+ occ = item.recurrenceInfo.getExceptionFor(cal.createDateTime("20020406T114500Z"));
+ equal(occ.id, "changed");
+}
+
+function test_failures() {
+ let item = makeEvent(
+ "DTSTART:20020402T114500Z\r\n" +
+ "DTEND:20020402T124500Z\r\n" +
+ "RRULE:INTERVAL=2;FREQ=WEEKLY;COUNT=6;BYDAY=TU,WE\r\n"
+ );
+ let rinfo = item.recurrenceInfo;
+ let ritem = cal.createRecurrenceDate();
+
+ throws(() => rinfo.getRecurrenceItemAt(-1), /Illegal value/, "Invalid Argument");
+ throws(() => rinfo.getRecurrenceItemAt(1), /Illegal value/, "Invalid Argument");
+ throws(() => rinfo.deleteRecurrenceItemAt(-1), /Illegal value/, "Invalid Argument");
+ throws(() => rinfo.deleteRecurrenceItemAt(1), /Illegal value/, "Invalid Argument");
+ throws(() => rinfo.deleteRecurrenceItem(ritem), /Illegal value/, "Invalid Argument");
+ throws(() => rinfo.insertRecurrenceItemAt(ritem, -1), /Illegal value/, "Invalid Argument");
+ throws(() => rinfo.insertRecurrenceItemAt(ritem, 2), /Illegal value/, "Invalid Argument");
+ throws(
+ () => rinfo.restoreOccurrenceAt(cal.createDateTime("20080101T010101")),
+ /Illegal value/,
+ "Invalid Argument"
+ );
+ throws(() => new CalRecurrenceInfo().isFinite, /Component not initialized/);
+
+ // modifyException with a different parent item
+ let occ = rinfo.getOccurrenceFor(cal.createDateTime("20120102T114500Z"));
+ occ.calendar = {};
+ occ.id = "1234";
+ occ.parentItem = occ;
+ throws(() => rinfo.modifyException(occ, true), /Illegal value/, "Invalid Argument");
+
+ occ = rinfo.getOccurrenceFor(cal.createDateTime("20120102T114500Z"));
+ occ.recurrenceId = null;
+ throws(() => rinfo.modifyException(occ, true), /Illegal value/, "Invalid Argument");
+
+ // Missing DTSTART/DUE but RRULE
+ item = createTodoFromIcalString(
+ "BEGIN:VCALENDAR\r\n" +
+ "BEGIN:VTODO\r\n" +
+ "RRULE:FREQ=DAILY\r\n" +
+ "END:VTODO\r\n" +
+ "END:VCALENDAR\r\n"
+ );
+ rinfo = item.recurrenceInfo;
+ equal(
+ rinfo.getOccurrenceDates(
+ cal.createDateTime("20120101T010101"),
+ cal.createDateTime("20120203T010101"),
+ 0
+ ).length,
+ 0
+ );
+}
+
+function test_immutable() {
+ let item = createTodoFromIcalString(
+ "BEGIN:VCALENDAR\r\n" +
+ "BEGIN:VTODO\r\n" +
+ "RRULE:FREQ=DAILY\r\n" +
+ "END:VTODO\r\n" +
+ "END:VCALENDAR\r\n"
+ );
+ ok(item.recurrenceInfo.isMutable);
+ let rinfo = item.recurrenceInfo.clone();
+ let ritem = cal.createRecurrenceDate();
+ rinfo.makeImmutable();
+ rinfo.makeImmutable(); // Doing so twice shouldn't throw
+ throws(() => rinfo.appendRecurrenceItem(ritem), /Can not modify immutable data container/);
+ ok(!rinfo.isMutable);
+
+ item.recurrenceInfo.appendRecurrenceItem(ritem);
+}
+
+function test_rrule_icalstring() {
+ let recRule = cal.createRecurrenceRule();
+ recRule.type = "DAILY";
+ recRule.interval = 4;
+ equal(recRule.icalString, "RRULE:FREQ=DAILY;INTERVAL=4\r\n");
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "DAILY";
+ recRule.setComponent("BYDAY", [2, 3, 4, 5, 6]);
+ equal(recRule.icalString, "RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR\r\n");
+ deepEqual(recRule.getComponent("BYDAY"), [2, 3, 4, 5, 6]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "WEEKLY";
+ recRule.interval = 3;
+ recRule.setComponent("BYDAY", [2, 4, 6]);
+ equal(recRule.icalString, "RRULE:FREQ=WEEKLY;INTERVAL=3;BYDAY=MO,WE,FR\r\n");
+ deepEqual(recRule.getComponent("BYDAY"), [2, 4, 6]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "MONTHLY";
+ recRule.setComponent("BYDAY", [2, 3, 4, 5, 6, 7, 1]);
+ equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR,SA,SU\r\n");
+ deepEqual(recRule.getComponent("BYDAY"), [2, 3, 4, 5, 6, 7, 1]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "MONTHLY";
+ recRule.setComponent("BYDAY", [10]);
+ equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYDAY=1MO\r\n");
+ deepEqual(recRule.getComponent("BYDAY"), [10]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "MONTHLY";
+ recRule.setComponent("BYDAY", [20]);
+ equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYDAY=2WE\r\n");
+ deepEqual(recRule.getComponent("BYDAY"), [20]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "MONTHLY";
+ recRule.setComponent("BYDAY", [-22]);
+ equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYDAY=-2FR\r\n");
+ deepEqual(recRule.getComponent("BYDAY"), [-22]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "MONTHLY";
+ recRule.setComponent("BYMONTHDAY", [5]);
+ equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYMONTHDAY=5\r\n");
+ deepEqual(recRule.getComponent("BYMONTHDAY"), [5]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "MONTHLY";
+ recRule.setComponent("BYMONTHDAY", [1, 9, 17]);
+ equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYMONTHDAY=1,9,17\r\n");
+ deepEqual(recRule.getComponent("BYMONTHDAY"), [1, 9, 17]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "YEARLY";
+ recRule.setComponent("BYMONTH", [1]);
+ recRule.setComponent("BYMONTHDAY", [3]);
+ ok(
+ [
+ "RRULE:FREQ=YEARLY;BYMONTHDAY=3;BYMONTH=1\r\n",
+ "RRULE:FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=3\r\n",
+ ].includes(recRule.icalString)
+ );
+ deepEqual(recRule.getComponent("BYMONTH"), [1]);
+ deepEqual(recRule.getComponent("BYMONTHDAY"), [3]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "YEARLY";
+ recRule.setComponent("BYMONTH", [4]);
+ recRule.setComponent("BYDAY", [3]);
+ ok(
+ [
+ "RRULE:FREQ=YEARLY;BYDAY=TU;BYMONTH=4\r\n",
+ "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=TU\r\n",
+ ].includes(recRule.icalString)
+ );
+ deepEqual(recRule.getComponent("BYMONTH"), [4]);
+ deepEqual(recRule.getComponent("BYDAY"), [3]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "YEARLY";
+ recRule.setComponent("BYMONTH", [4]);
+ recRule.setComponent("BYDAY", [10]);
+ ok(
+ [
+ "RRULE:FREQ=YEARLY;BYDAY=1MO;BYMONTH=4\r\n",
+ "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1MO\r\n",
+ ].includes(recRule.icalString)
+ );
+ deepEqual(recRule.getComponent("BYMONTH"), [4]);
+ deepEqual(recRule.getComponent("BYDAY"), [10]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "YEARLY";
+ recRule.setComponent("BYMONTH", [4]);
+ recRule.setComponent("BYDAY", [-22]);
+ ok(
+ [
+ "RRULE:FREQ=YEARLY;BYDAY=-2FR;BYMONTH=4\r\n",
+ "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-2FR\r\n",
+ ].includes(recRule.icalString)
+ );
+ deepEqual(recRule.getComponent("BYMONTH"), [4]);
+ deepEqual(recRule.getComponent("BYDAY"), [-22]);
+}
+
+function test_icalComponent() {
+ let duration = "PT3600S";
+ let eventString =
+ "DESCRIPTION:Repeat every Thursday starting Tue 2nd April 2002\n" +
+ "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=6;BYDAY=TH\n" +
+ "DTSTART:20020402T114500\n" +
+ `DURATION:${duration}\n`;
+
+ let firstOccurrenceDate = createDate(2002, 4, 4, true, 11, 45, 0);
+
+ // Test each of these cases from the conditional in the icalComponent getter.
+ // * mIsProxy = true, value === null
+ // * mIsProxy = true, value !== null
+ // * mIsProxy = false, value === null
+ // * mIsProxy = false, value !== null
+ //
+ // Create a proxy for a given occurrence, modify properties on the proxy
+ // (checking before and after), then call the icalComponent getter to see
+ // whether both parent item and proxy item have the correct properties.
+
+ let parent = makeEvent(eventString);
+ let proxy = parent.recurrenceInfo.getOccurrenceFor(firstOccurrenceDate);
+
+ equal(parent.getProperty("DURATION"), duration);
+ equal(proxy.getProperty("DURATION"), duration);
+
+ equal(parent.getProperty("LOCATION"), null);
+ equal(proxy.getProperty("LOCATION"), null);
+
+ let newDuration = "PT2200S";
+ let location = "Sherwood Forest";
+
+ proxy.setProperty("DURATION", newDuration);
+ proxy.setProperty("LOCATION", location);
+
+ equal(parent.getProperty("DURATION"), duration);
+ equal(proxy.getProperty("DURATION"), newDuration);
+
+ equal(parent.getProperty("LOCATION"), null);
+ equal(proxy.getProperty("LOCATION"), location);
+
+ equal(parent.icalComponent.duration.toString(), duration);
+ equal(proxy.icalComponent.duration.toString(), newDuration);
+
+ equal(parent.icalComponent.location, null);
+ equal(proxy.icalComponent.location, location);
+
+ // Test for bug 580896.
+
+ let event = makeEvent(eventString);
+ equal(event.getProperty("DURATION"), duration, "event has correct DURATION");
+
+ let occurrence = event.recurrenceInfo.getOccurrenceFor(firstOccurrenceDate);
+
+ equal(occurrence.getProperty("DURATION"), duration, "occurrence has correct DURATION");
+ equal(Boolean(occurrence.getProperty("DTEND")), true, "occurrence has DTEND");
+
+ ok(occurrence.icalComponent.duration, "occurrence icalComponent has DURATION");
+
+ // Changing the end date causes the duration to be set to null.
+ occurrence.endDate = createDate(2002, 4, 3);
+
+ equal(occurrence.getProperty("DURATION"), null, "occurrence DURATION has been set to null");
+
+ ok(!occurrence.icalComponent.duration, "occurrence icalComponent does not have DURATION");
+}
diff --git a/comm/calendar/test/unit/test_recurrence_utils.js b/comm/calendar/test/unit/test_recurrence_utils.js
new file mode 100644
index 0000000000..53f1aaf99f
--- /dev/null
+++ b/comm/calendar/test/unit/test_recurrence_utils.js
@@ -0,0 +1,371 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { countOccurrences } = ChromeUtils.import(
+ "resource:///modules/calendar/calRecurrenceUtils.jsm"
+);
+
+function run_test() {
+ do_calendar_startup(run_next_test);
+}
+
+// tests for calRecurrenceUtils.jsm
+/* Incomplete - still missing test coverage for:
+ * recurrenceRule2String
+ * splitRecurrenceRules
+ * checkRecurrenceRule
+ */
+
+function getIcs(aProperties) {
+ let calendar = [
+ "BEGIN:VCALENDAR",
+ "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN",
+ "VERSION:2.0",
+ "BEGIN:VTIMEZONE",
+ "TZID:Europe/Berlin",
+ "BEGIN:DAYLIGHT",
+ "TZOFFSETFROM:+0100",
+ "TZOFFSETTO:+0200",
+ "TZNAME:CEST",
+ "DTSTART:19700329T020000",
+ "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3",
+ "END:DAYLIGHT",
+ "BEGIN:STANDARD",
+ "TZOFFSETFROM:+0200",
+ "TZOFFSETTO:+0100",
+ "TZNAME:CET",
+ "DTSTART:19701025T030000",
+ "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10",
+ "END:STANDARD",
+ "END:VTIMEZONE",
+ ];
+ calendar = calendar.concat(aProperties);
+ calendar = calendar.concat(["END:VCALENDAR"]);
+
+ return calendar.join("\r\n");
+}
+
+add_task(async function countOccurrences_test() {
+ let data = [
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98000",
+ "SUMMARY:Occurring 3 times until a date",
+ "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 3,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98001",
+ "SUMMARY:Occurring 3 times until a date with one exception in the middle",
+ "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+ "EXDATE;TZID=Europe/Berlin:20180921T120000",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 2,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98002",
+ "SUMMARY:Occurring 3 times until a date with one exception at the end",
+ "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+ "EXDATE;TZID=Europe/Berlin:20180922T120000",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 2,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98003",
+ "SUMMARY:Occurring 3 times until a date with one exception at the beginning",
+ "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+ "EXDATE;TZID=Europe/Berlin:20180920T120000",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 2,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98004",
+ "SUMMARY:Occurring 3 times until a date with the middle occurrence moved after the end",
+ "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98004",
+ "SUMMARY:The moved occurrence",
+ "RECURRENCE-ID:20180921T100000Z",
+ "DTSTART;TZID=Europe/Berlin:20180924T120000",
+ "DTEND;TZID=Europe/Berlin:20180924T130000",
+ "END:VEVENT",
+ ],
+ expected: 3,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98005",
+ "SUMMARY:Occurring 3 times until a date with the middle occurrence moved before the beginning",
+ "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98005",
+ "SUMMARY:The moved occurrence",
+ "RECURRENCE-ID:20180921T100000Z",
+ "DTSTART;TZID=Europe/Berlin:20180918T120000",
+ "DTEND;TZID=Europe/Berlin:20180918T130000",
+ "END:VEVENT",
+ ],
+ expected: 3,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98006",
+ "SUMMARY:Occurring 1 times until a date",
+ "RRULE:FREQ=DAILY;UNTIL=20180920T100000Z",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 1,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98007",
+ "SUMMARY:Occurring 1 times until a date with occernce removed",
+ "RRULE:FREQ=DAILY;UNTIL=20180920T100000Z",
+ "EXDATE;TZID=Europe/Berlin:20180920T120000",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 0,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98008",
+ "SUMMARY:Occurring for 3 times",
+ "RRULE:FREQ=DAILY;COUNT=3",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 3,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98009",
+ "SUMMARY:Occurring for 3 times with an exception in the middle",
+ "EXDATE;TZID=Europe/Berlin:20180921T120000",
+ "RRULE:FREQ=DAILY;COUNT=3",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 2,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98010",
+ "SUMMARY:Occurring for 3 times with an exception at the end",
+ "EXDATE;TZID=Europe/Berlin:20180922T120000",
+ "RRULE:FREQ=DAILY;COUNT=3",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 2,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98011",
+ "SUMMARY:Occurring for 3 times with an exception at the beginning",
+ "EXDATE;TZID=Europe/Berlin:20180920T120000",
+ "RRULE:FREQ=DAILY;COUNT=3",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 2,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98012",
+ "SUMMARY:Occurring for 1 time",
+ "RRULE:FREQ=DAILY;COUNT=1",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 1,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98013",
+ "SUMMARY:Occurring for 0 times",
+ "RRULE:FREQ=DAILY;COUNT=1",
+ "EXDATE;TZID=Europe/Berlin:20180920T120000",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 0,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98014",
+ "SUMMARY:Occurring infinitely",
+ "RRULE:FREQ=DAILY",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: null,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98015",
+ "SUMMARY:Non-occurring item",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: null,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98016",
+ "SUMMARY:Occurring for 3 time and 1 rdate",
+ "RRULE:FREQ=DAILY;COUNT=3",
+ "RDATE;TZID=Europe/Berlin:20180923T100000",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 4,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98017",
+ "SUMMARY:Occurring for 3 rdates",
+ "RDATE;TZID=Europe/Berlin:20180920T120000",
+ "RDATE;TZID=Europe/Berlin:20180921T100000",
+ "RDATE;TZID=Europe/Berlin:20180922T140000",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 3,
+ },
+ ];
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+
+ let ics = getIcs(test.input);
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ parser.parseString(ics);
+ let items = parser.getItems();
+
+ ok(items.length > 0, "parsing input succeeded (test #" + i + ")");
+ for (let item of items) {
+ equal(
+ countOccurrences(item),
+ test.expected,
+ "expected number of occurrences (test #" + i + " - '" + item.title + "')"
+ );
+ }
+ }
+});
diff --git a/comm/calendar/test/unit/test_relation.js b/comm/calendar/test/unit/test_relation.js
new file mode 100644
index 0000000000..148d5a6118
--- /dev/null
+++ b/comm/calendar/test/unit/test_relation.js
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalRelation: "resource:///modules/CalRelation.jsm",
+});
+
+function run_test() {
+ // Create Relation
+ let relation1 = new CalRelation();
+
+ // Create Items
+ let event1 = new CalEvent();
+ let event2 = new CalEvent();
+
+ // Testing relation set/get.
+ let properties = {
+ relType: "PARENT",
+ relId: event2.id,
+ };
+
+ for (let [property, value] of Object.entries(properties)) {
+ relation1[property] = value;
+ equal(relation1[property], value);
+ }
+
+ // Add relation to event
+ event1.addRelation(relation1);
+
+ // Add 2nd relation to event.
+ let relation2 = new CalRelation();
+ relation2.relId = "myid2";
+ event1.addRelation(relation2);
+
+ // Check the item functions
+ checkRelations(event1, [relation1, relation2]);
+
+ // modify the Relations
+ modifyRelations(event1, [relation1, relation2]);
+
+ // test icalproperty
+ // eslint-disable-next-line no-unused-expressions
+ relation2.icalProperty;
+
+ test_icalprop();
+}
+
+function checkRelations(event, expRel) {
+ let allRel = event.getRelations();
+ equal(allRel.length, expRel.length);
+
+ // check if all expacted relations are found
+ for (let i = 0; i < expRel.length; i++) {
+ ok(allRel.includes(expRel[i]));
+ }
+
+ // Check if all found relations are expected
+ for (let i = 0; i < allRel.length; i++) {
+ ok(expRel.includes(allRel[i]));
+ }
+}
+
+function modifyRelations(event, oldRel) {
+ let allRel = event.getRelations();
+ let rel = allRel[0];
+
+ // modify the properties
+ rel.relType = "SIBLING";
+ equal(rel.relType, "SIBLING");
+ equal(rel.relType, allRel[0].relType);
+
+ // remove one relation
+ event.removeRelation(rel);
+ equal(event.getRelations().length, oldRel.length - 1);
+
+ // add one relation and remove all relations
+ event.addRelation(oldRel[0]);
+ event.removeAllRelations();
+ equal(event.getRelations(), 0);
+}
+
+function test_icalprop() {
+ let rel = new CalRelation();
+
+ rel.relType = "SIBLING";
+ rel.setParameter("X-PROP", "VAL");
+ rel.relId = "value";
+
+ let prop = rel.icalProperty;
+ let propOrig = rel.icalProperty;
+
+ equal(rel.icalString, prop.icalString);
+
+ equal(prop.value, "value");
+ equal(prop.getParameter("X-PROP"), "VAL");
+ equal(prop.getParameter("RELTYPE"), "SIBLING");
+
+ prop.value = "changed";
+ prop.setParameter("RELTYPE", "changedtype");
+ prop.setParameter("X-PROP", "changedxprop");
+
+ equal(rel.relId, "value");
+ equal(rel.getParameter("X-PROP"), "VAL");
+ equal(rel.relType, "SIBLING");
+
+ rel.icalProperty = prop;
+
+ equal(rel.relId, "changed");
+ equal(rel.getParameter("X-PROP"), "changedxprop");
+ equal(rel.relType, "changedtype");
+
+ rel.icalString = propOrig.icalString;
+
+ equal(rel.relId, "value");
+ equal(rel.getParameter("X-PROP"), "VAL");
+ equal(rel.relType, "SIBLING");
+
+ let rel2 = rel.clone();
+ rel.icalProperty = prop;
+
+ notEqual(rel.icalString, rel2.icalString);
+
+ rel.deleteParameter("X-PROP");
+ equal(rel.icalProperty.getParameter("X-PROP"), null);
+
+ throws(() => {
+ rel.icalString = "X-UNKNOWN:value";
+ }, /Illegal value/);
+}
diff --git a/comm/calendar/test/unit/test_rfc3339_parser.js b/comm/calendar/test/unit/test_rfc3339_parser.js
new file mode 100644
index 0000000000..8098bdddf6
--- /dev/null
+++ b/comm/calendar/test/unit/test_rfc3339_parser.js
@@ -0,0 +1,188 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ // Check if the RFC 3339 date and timezone are properly parsed to the
+ // expected result and if the result is properly mapped back into the RFC
+ // 3339 date.
+ function testRfc3339(
+ aRfc3339Date,
+ aTimezone,
+ aExpectedDateTime,
+ aExpectedRfc3339Date = aRfc3339Date
+ ) {
+ // Test creating a dateTime object from an RFC 3339 string.
+ let dateTime = cal.dtz.fromRFC3339(aRfc3339Date, aTimezone);
+
+ // Check that each property is as expected.
+ let expectedDateProps = {
+ year: aExpectedDateTime[0],
+ month: aExpectedDateTime[1] - 1, // 0 based month.
+ day: aExpectedDateTime[2],
+ hour: aExpectedDateTime[3],
+ minute: aExpectedDateTime[4],
+ second: aExpectedDateTime[5],
+ timezone: aExpectedDateTime[6],
+ isDate: aExpectedDateTime[7],
+ };
+ for (let prop in expectedDateProps) {
+ info("Checking prop: " + prop);
+ // Object comparison fails with ical.js, and we only want to check
+ // that we have the right timezone.
+ if (prop == "timezone") {
+ equal(dateTime[prop].tzid, expectedDateProps[prop].tzid);
+ } else {
+ equal(dateTime[prop], expectedDateProps[prop]);
+ }
+ }
+
+ // Test round tripping that dateTime object back to an RFC 3339 string.
+ let rfc3339Date = cal.dtz.toRFC3339(dateTime);
+
+ // In theory this should just match the input RFC 3339 date, but there are
+ // multiple ways of generating the same time, e.g. 2006-03-14Z is
+ // equivalent to 2006-03-14.
+ equal(rfc3339Date, aExpectedRfc3339Date);
+ }
+
+ /*
+ * Some notes about the differences between calIDateTime and the RFC 3339
+ * specification:
+ * 1. calIDateTime does not support fractions of a second, they are
+ * stripped.
+ * 2. If a timezone cannot be matched to the given time offset, the
+ * date/time is returned as a UTC date/time.
+ * 3. The first timezone (alphabetically) that has the same offset is
+ * chosen.
+ * 4. Leap seconds are not supported by calIDateTime, it resets to
+ * [0-23]:[0-59]:[0-59].
+ *
+ * All tests are done under the default timezone and UTC (although both
+ * should give the same time).
+ */
+
+ // An arbitrary timezone (that has daylight savings time).
+ let getTz = aTz => cal.timezoneService.getTimezone(aTz);
+ let timezone = getTz("America/New_York");
+ let utc = cal.dtz.UTC;
+
+ // Timezones used in tests. This isn't a great representation, as we don't
+ // care what the actual timezone is, just that the offset is correct. Offset
+ // isn't presently easily accessible from the timezone object, however.
+ let utcminus6 = getTz("America/Bahia_Banderas");
+ let dawson = getTz("America/Dawson");
+
+ /*
+ * Basic tests
+ */
+ // This represents March 14, 2006 in the default timezone.
+ testRfc3339("2006-03-14", timezone, [2006, 3, 14, 0, 0, 0, timezone, true]);
+ testRfc3339("2006-03-14", utc, [2006, 3, 14, 0, 0, 0, utc, true]);
+ // This represents March 14, 2006 in UTC.
+ testRfc3339("2006-03-14Z", timezone, [2006, 3, 14, 0, 0, 0, utc, true], "2006-03-14");
+ testRfc3339("2006-03-14Z", utc, [2006, 3, 14, 0, 0, 0, utc, true], "2006-03-14");
+
+ // This represents 30 minutes and 53 seconds past the 13th hour of November
+ // 14, 2050 in UTC.
+ testRfc3339(
+ "2050-11-14t13:30:53z",
+ timezone,
+ [2050, 11, 14, 13, 30, 53, utc, false],
+ "2050-11-14T13:30:53Z"
+ );
+ testRfc3339(
+ "2050-11-14t13:30:53z",
+ utc,
+ [2050, 11, 14, 13, 30, 53, utc, false],
+ "2050-11-14T13:30:53Z"
+ );
+
+ // This represents 03:00:23 on October 14, 2004 in Central Standard Time.
+ testRfc3339("2004-10-14T03:00:23-06:00", timezone, [2004, 10, 14, 3, 0, 23, utcminus6, false]);
+ testRfc3339("2004-10-14T03:00:23-06:00", utc, [2004, 10, 14, 3, 0, 23, utcminus6, false]);
+
+ /*
+ * The following tests are the RFC 3339 examples
+ * http://tools.ietf.org/html/rfc3339
+ * Most of these would "fail" since iCalDateTime does not supported
+ * all parts of the specification, the true proper response is next to each
+ * test line as a comment.
+ */
+
+ // This represents 20 minutes and 50.52 seconds after the 23rd hour of
+ // April 12th, 1985 in UTC.
+ testRfc3339(
+ "1985-04-12T23:20:50.52Z",
+ timezone,
+ [1985, 4, 12, 23, 20, 50, utc, false],
+ "1985-04-12T23:20:50Z"
+ ); // 1985/04/12 23:20:50.52 UTC isDate=0
+ testRfc3339(
+ "1985-04-12T23:20:50.52Z",
+ utc,
+ [1985, 4, 12, 23, 20, 50, utc, false],
+ "1985-04-12T23:20:50Z"
+ ); // 1985/04/12 23:20:50.52 UTC isDate=0
+
+ // This represents 39 minutes and 57 seconds after the 16th hour of December
+ // 19th, 1996 with an offset of -08:00 from UTC (Pacific Standard Time).
+ // Note that this is equivalent to in UTC.
+ testRfc3339("1996-12-19T16:39:57-08:00", timezone, [1996, 12, 19, 16, 39, 57, dawson, false]);
+ testRfc3339("1996-12-19T16:39:57-08:00", utc, [1996, 12, 19, 16, 39, 57, dawson, false]);
+ testRfc3339("1996-12-20T00:39:57Z", timezone, [1996, 12, 20, 0, 39, 57, utc, false]);
+ testRfc3339("1996-12-20T00:39:57Z", utc, [1996, 12, 20, 0, 39, 57, utc, false]);
+
+ // This represents the same instant of time as noon, January 1, 1937,
+ // Netherlands time. Standard time in the Netherlands was exactly 19 minutes
+ // and 32.13 seconds ahead of UTC by law from 1909-05-01 through 1937-06-30.
+ // This time zone cannot be represented exactly using the HH:MM format, and
+ // this timestamp uses the closest representable UTC offset.
+ //
+ // Since no current timezone exists at +00:20 it will default to giving the
+ // time in UTC.
+ testRfc3339(
+ "1937-01-01T12:00:27.87+00:20",
+ timezone,
+ [1937, 1, 1, 12, 20, 27, utc, false],
+ "1937-01-01T12:20:27Z"
+ ); // 1937/01/01 12:20:27.87 UTC isDate=0
+ testRfc3339(
+ "1937-01-01T12:00:27.87+00:20",
+ utc,
+ [1937, 1, 1, 12, 20, 27, utc, false],
+ "1937-01-01T12:20:27Z"
+ ); // 1937/01/01 12:20:27.87 UTC isDate=0
+
+ // This represents the leap second inserted at the end of 1990.
+ testRfc3339(
+ "1990-12-31T23:59:60Z",
+ timezone,
+ [1991, 1, 1, 0, 0, 0, utc, false],
+ "1991-01-01T00:00:00Z"
+ ); // 1990/12/31 23:59:60 UTC isDate=0
+ testRfc3339(
+ "1990-12-31T23:59:60Z",
+ utc,
+ [1991, 1, 1, 0, 0, 0, utc, false],
+ "1991-01-01T00:00:00Z"
+ ); // 1990/12/31 23:59:60 UTC isDate=0
+ // This represents the same leap second in Pacific Standard Time, 8
+ // hours behind UTC.
+ testRfc3339(
+ "1990-12-31T15:59:60-08:00",
+ timezone,
+ [1990, 12, 31, 16, 0, 0, dawson, false],
+ "1990-12-31T16:00:00-08:00"
+ ); // 1990/12/31 15:59:60 America/Dawson isDate=0
+ testRfc3339(
+ "1990-12-31T15:59:60-08:00",
+ utc,
+ [1990, 12, 31, 16, 0, 0, dawson, false],
+ "1990-12-31T16:00:00-08:00"
+ ); // 1990/12/31 15:59:60 America/Dawson isDate=0
+}
diff --git a/comm/calendar/test/unit/test_startup_service.js b/comm/calendar/test/unit/test_startup_service.js
new file mode 100644
index 0000000000..cfb3f76571
--- /dev/null
+++ b/comm/calendar/test/unit/test_startup_service.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ let ssvc = Cc["@mozilla.org/calendar/startup-service;1"].getService(Ci.nsIObserver);
+
+ let first = {
+ startup(aListener) {
+ second.canStart = true;
+ aListener.onResult(null, Cr.NS_OK);
+ },
+ shutdown(aListener) {
+ ok(this.canStop);
+ aListener.onResult(null, Cr.NS_OK);
+ },
+ };
+
+ let second = {
+ startup(aListener) {
+ ok(this.canStart);
+ aListener.onResult(null, Cr.NS_OK);
+ },
+ shutdown(aListener) {
+ first.canStop = true;
+ aListener.onResult(null, Cr.NS_OK);
+ },
+ };
+
+ // Change the startup order so we can test our services
+ let oldStartupOrder = ssvc.wrappedJSObject.getStartupOrder;
+ ssvc.wrappedJSObject.getStartupOrder = function () {
+ let origOrder = oldStartupOrder.call(this);
+
+ let notify = origOrder[origOrder.length - 1];
+ return [first, second, notify];
+ };
+
+ // Pretend a startup run
+ ssvc.observe(null, "profile-after-change", null);
+ ok(second.canStart);
+
+ // Pretend a stop run
+ ssvc.observe(null, "profile-before-change", null);
+ ok(first.canStop);
+}
diff --git a/comm/calendar/test/unit/test_storage.js b/comm/calendar/test/unit/test_storage.js
new file mode 100644
index 0000000000..d4a42be428
--- /dev/null
+++ b/comm/calendar/test/unit/test_storage.js
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async () => {
+ await new Promise(resolve => {
+ do_calendar_startup(resolve);
+ });
+
+ let storage = getStorageCal();
+ let str = [
+ "BEGIN:VEVENT",
+ "UID:attachItem",
+ "DTSTART:20120101T010101Z",
+ "ATTACH;FMTTYPE=text/calendar;ENCODING=BASE64;FILENAME=test.ics:http://example.com/test.ics",
+ "ATTENDEE;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=Name;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;X-THING=BAR:mailto:test@example.com",
+ "RELATED-TO;RELTYPE=SIBLING;FOO=BAR:VALUE",
+ "RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=5;BYDAY=MO",
+ "RDATE:20120201T010101Z",
+ "EXDATE:20120301T010101Z",
+ "END:VEVENT",
+ ].join("\r\n");
+
+ let storageItem = createEventFromIcalString(str);
+
+ let addedItemId = (await storage.addItem(storageItem)).id;
+
+ // Make sure the cache is cleared, otherwise we'll get the cached item.
+ delete storage.wrappedJSObject.mItemModel.itemCache[addedItemId];
+
+ let item = await storage.getItem(addedItemId);
+
+ // Check start date
+ equal(item.startDate.compare(cal.createDateTime("20120101T010101Z")), 0);
+
+ // Check attachment
+ let attaches = item.getAttachments();
+ let attach = attaches[0];
+ equal(attaches.length, 1);
+ equal(attach.uri.spec, "http://example.com/test.ics");
+ equal(attach.formatType, "text/calendar");
+ equal(attach.encoding, "BASE64");
+ equal(attach.getParameter("FILENAME"), "test.ics");
+
+ // Check attendee
+ let attendees = item.getAttendees();
+ let attendee = attendees[0];
+ equal(attendees.length, 1);
+ equal(attendee.id, "mailto:test@example.com");
+ equal(attendee.commonName, "Name");
+ equal(attendee.rsvp, "TRUE");
+ equal(attendee.isOrganizer, false);
+ equal(attendee.role, "REQ-PARTICIPANT");
+ equal(attendee.participationStatus, "ACCEPTED");
+ equal(attendee.userType, "INDIVIDUAL");
+ equal(attendee.getProperty("X-THING"), "BAR");
+
+ // Check relation
+ let relations = item.getRelations();
+ let rel = relations[0];
+ equal(relations.length, 1);
+ equal(rel.relType, "SIBLING");
+ equal(rel.relId, "VALUE");
+ equal(rel.getParameter("FOO"), "BAR");
+
+ // Check recurrence item
+ for (let ritem of item.recurrenceInfo.getRecurrenceItems()) {
+ if (ritem instanceof Ci.calIRecurrenceRule) {
+ equal(ritem.type, "MONTHLY");
+ equal(ritem.interval, 2);
+ equal(ritem.count, 5);
+ equal(ritem.isByCount, true);
+ equal(ritem.getComponent("BYDAY").toString(), [2].toString());
+ equal(ritem.isNegative, false);
+ } else if (ritem instanceof Ci.calIRecurrenceDate) {
+ if (ritem.isNegative) {
+ equal(ritem.date.compare(cal.createDateTime("20120301T010101Z")), 0);
+ } else {
+ equal(ritem.date.compare(cal.createDateTime("20120201T010101Z")), 0);
+ }
+ } else {
+ do_throw("Found unknown recurrence item " + ritem);
+ }
+ }
+});
diff --git a/comm/calendar/test/unit/test_storage_connection.js b/comm/calendar/test/unit/test_storage_connection.js
new file mode 100644
index 0000000000..2984fa2dc4
--- /dev/null
+++ b/comm/calendar/test/unit/test_storage_connection.js
@@ -0,0 +1,127 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_setup(async function () {
+ do_get_profile();
+ await new Promise(resolve => cal.manager.startup({ onResult: resolve }));
+});
+
+/**
+ * Tests that local storage calendars share a database connection.
+ */
+add_task(async function testLocal() {
+ let localCalendarA = cal.manager.createCalendar(
+ "storage",
+ Services.io.newURI(`moz-storage-calendar://`)
+ );
+ localCalendarA.id = cal.getUUID();
+ let dbA = localCalendarA.wrappedJSObject.mStorageDb.db;
+
+ let localCalendarB = cal.manager.createCalendar(
+ "storage",
+ Services.io.newURI(`moz-storage-calendar://`)
+ );
+ localCalendarB.id = cal.getUUID();
+ let dbB = localCalendarB.wrappedJSObject.mStorageDb.db;
+
+ Assert.equal(
+ dbA.databaseFile.path,
+ PathUtils.join(PathUtils.profileDir, "calendar-data", "local.sqlite"),
+ "local calendar A uses the right database file"
+ );
+ Assert.equal(
+ dbB.databaseFile.path,
+ PathUtils.join(PathUtils.profileDir, "calendar-data", "local.sqlite"),
+ "local calendar B uses the right database file"
+ );
+ Assert.equal(dbA, dbB, "local calendars share a database connection");
+});
+
+/**
+ * Tests that local storage calendars using the same specified database file share a connection,
+ * and that local storage calendars with a different specified database file do not.
+ */
+add_task(async function testLocalFile() {
+ let testFileA = new FileUtils.File(PathUtils.join(PathUtils.tempDir, "file-a.sqlite"));
+ let testFileB = new FileUtils.File(PathUtils.join(PathUtils.tempDir, "file-b.sqlite"));
+
+ let fileCalendarA = cal.manager.createCalendar("storage", Services.io.newFileURI(testFileA));
+ fileCalendarA.id = cal.getUUID();
+ let dbA = fileCalendarA.wrappedJSObject.mStorageDb.db;
+
+ let fileCalendarB = cal.manager.createCalendar("storage", Services.io.newFileURI(testFileB));
+ fileCalendarB.id = cal.getUUID();
+ let dbB = fileCalendarB.wrappedJSObject.mStorageDb.db;
+
+ let fileCalendarC = cal.manager.createCalendar("storage", Services.io.newFileURI(testFileA));
+ fileCalendarC.id = cal.getUUID();
+ let dbC = fileCalendarC.wrappedJSObject.mStorageDb.db;
+
+ Assert.equal(
+ dbA.databaseFile.path,
+ testFileA.path,
+ "local calendar A uses the right database file"
+ );
+ Assert.equal(
+ dbB.databaseFile.path,
+ testFileB.path,
+ "local calendar B uses the right database file"
+ );
+ Assert.equal(
+ dbC.databaseFile.path,
+ testFileA.path,
+ "local calendar C uses the right database file"
+ );
+ Assert.notEqual(
+ dbA,
+ dbB,
+ "calendars with different file URLs do not share a database connection"
+ );
+ Assert.notEqual(
+ dbB,
+ dbC,
+ "calendars with different file URLs do not share a database connection"
+ );
+ Assert.equal(dbA, dbC, "calendars with matching file URLs share a database connection");
+});
+
+/**
+ * Tests that cached network calendars share a database connection.
+ */
+add_task(async function testNetwork() {
+ // Pretend to be offline so connecting to calendars that don't exist doesn't throw errors.
+ Services.io.offline = true;
+
+ let networkCalendarA = cal.manager.createCalendar(
+ "ics",
+ Services.io.newURI("http://localhost/ics")
+ );
+ networkCalendarA.id = cal.getUUID();
+ networkCalendarA.setProperty("cache.enabled", true);
+ cal.manager.registerCalendar(networkCalendarA);
+ networkCalendarA = cal.manager.getCalendarById(networkCalendarA.id);
+ let dbA = networkCalendarA.wrappedJSObject.mCachedCalendar.wrappedJSObject.mStorageDb.db;
+
+ let networkCalendarB = cal.manager.createCalendar(
+ "caldav",
+ Services.io.newURI("http://localhost/caldav")
+ );
+ networkCalendarB.id = cal.getUUID();
+ networkCalendarB.setProperty("cache.enabled", true);
+ cal.manager.registerCalendar(networkCalendarB);
+ networkCalendarB = cal.manager.getCalendarById(networkCalendarB.id);
+ let dbB = networkCalendarB.wrappedJSObject.mCachedCalendar.wrappedJSObject.mStorageDb.db;
+
+ Assert.equal(
+ dbA.databaseFile.path,
+ PathUtils.join(PathUtils.profileDir, "calendar-data", "cache.sqlite"),
+ "network calendar A uses the right database file"
+ );
+ Assert.equal(
+ dbB.databaseFile.path,
+ PathUtils.join(PathUtils.profileDir, "calendar-data", "cache.sqlite"),
+ "network calendar B uses the right database file"
+ );
+ Assert.equal(dbA, dbB, "network calendars share a database connection");
+});
diff --git a/comm/calendar/test/unit/test_storage_get_items.js b/comm/calendar/test/unit/test_storage_get_items.js
new file mode 100644
index 0000000000..828e59fecd
--- /dev/null
+++ b/comm/calendar/test/unit/test_storage_get_items.js
@@ -0,0 +1,338 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the CalStorageCalendar.getItems method.
+ */
+
+const { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+const { CalTodo } = ChromeUtils.import("resource:///modules/CalTodo.jsm");
+
+do_get_profile();
+
+/**
+ * The bug we are interested in testing requires the calendar to clear its
+ * caches in order to take effect. Since we can't directly access the internals
+ * of the calendar here, we instead provide a custom function that lets us
+ * create more than one calendar with the same id.
+ */
+function createStorageCalendar(id) {
+ let db = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ db.append("test_storage.sqlite");
+ let uri = Services.io.newFileURI(db);
+
+ // Make sure timezone service is initialized
+ Cc["@mozilla.org/calendar/timezone-service;1"].getService(Ci.calIStartupService).startup(null);
+
+ let calendar = Cc["@mozilla.org/calendar/calendar;1?type=storage"].createInstance(
+ Ci.calISyncWriteCalendar
+ );
+
+ calendar.uri = uri;
+ calendar.id = id;
+ return calendar;
+}
+
+/**
+ * Tests that recurring event/todo exceptions have their properties properly
+ * loaded. See bug 1664731.
+ *
+ * @param {number} filterType - Number indicating the filter type.
+ * @param {calIITemBase} originalItem - The original item to add to the calendar.
+ * @param {object} originalProps - The initial properites of originalItem to
+ * expect.
+ * @param {object[]} changedProps - A list containing property values to update
+ * each occurrence with or null. The length indicates how many occurrences to
+ * expect.
+ */
+async function doPropertiesTest(filterType, originalItem, originalProps, changedPropList) {
+ for (let [key, value] of Object.entries(originalProps)) {
+ if (key == "CATEGORIES") {
+ originalItem.setCategories(value);
+ } else {
+ originalItem.setProperty(key, value);
+ }
+ }
+
+ let calId = cal.getUUID();
+ let calendar = createStorageCalendar(calId);
+ await calendar.addItem(originalItem);
+
+ let filter =
+ filterType |
+ Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL |
+ Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES;
+
+ let savedItems = await calendar.getItemsAsArray(
+ filter,
+ 0,
+ cal.createDateTime("20201201T000000Z"),
+ cal.createDateTime("20201231T000000Z")
+ );
+
+ Assert.equal(
+ savedItems.length,
+ changedPropList.length,
+ `created ${changedPropList.length} items successfully`
+ );
+
+ // Ensure all occurrences have the correct properties initially.
+ for (let item of savedItems) {
+ for (let [key, value] of Object.entries(originalProps)) {
+ if (key == "CATEGORIES") {
+ Assert.equal(
+ item.getCategories().join(),
+ value.join(),
+ `item categories are set to ${value}`
+ );
+ } else {
+ Assert.equal(item.getProperty(key), value, `item property "${key}" is set to "${value}"`);
+ }
+ }
+ }
+
+ // Modify the occurrences that have new properties set in changedPropList.
+ for (let idx = 0; idx < changedPropList.length; idx++) {
+ let changedProps = changedPropList[idx];
+ if (changedProps) {
+ let targetOccurrence = savedItems[idx];
+ let targetException = targetOccurrence.clone();
+
+ // Make the changes to the properties.
+ for (let [key, value] of Object.entries(changedProps)) {
+ if (key == "CATEGORIES") {
+ targetException.setCategories(value);
+ } else {
+ targetException.setProperty(key, value);
+ }
+ }
+
+ await calendar.modifyItem(
+ cal.itip.prepareSequence(targetException, targetOccurrence),
+ targetOccurrence
+ );
+
+ // Refresh the saved items list after the change.
+ savedItems = await calendar.getItemsAsArray(
+ filter,
+ 0,
+ cal.createDateTime("20201201T000000Z"),
+ cal.createDateTime("20201231T000000Z")
+ );
+ }
+ }
+
+ // Get a fresh copy of the occurrences by using a new calendar with the
+ // same id.
+ let itemsAfterUpdate = await createStorageCalendar(calId).getItemsAsArray(
+ filter,
+ 0,
+ cal.createDateTime("20201201T000000Z"),
+ cal.createDateTime("20201231T000000Z")
+ );
+
+ Assert.equal(
+ itemsAfterUpdate.length,
+ changedPropList.length,
+ `count of occurrences retrieved after update is ${changedPropList.length}`
+ );
+
+ // Compare each property of each occurrence to ensure the changed
+ // occurrences have the values we expect.
+ for (let i = 0; i < itemsAfterUpdate.length; i++) {
+ let item = itemsAfterUpdate[i];
+ let isException = changedPropList[i] != null;
+ let label = isException ? `modified occurrence ${i}` : `unmodified occurrence ${i}`;
+ let checkedProps = isException ? changedPropList[i] : originalProps;
+
+ for (let [key, value] of Object.entries(checkedProps)) {
+ if (key == "CATEGORIES") {
+ Assert.equal(
+ item.getCategories().join(),
+ value.join(),
+ `item categories has value "${value}"`
+ );
+ } else {
+ Assert.equal(
+ item.getProperty(key),
+ value,
+ `property "${key}" has value "${value}" for "${label}"`
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Test event exceptions load their properties.
+ */
+add_task(async function testEventPropertiesForRecurringExceptionsLoad() {
+ let event = new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ CREATED:20201211T000000Z
+ LAST-MODIFIED:20201211T000000Z
+ DTSTAMP:20201210T080410Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Original Test Event
+ DTSTART:20201211T000000Z
+ DTEND:20201211T110000Z
+ RRULE:FREQ=DAILY;UNTIL=20201215T140000Z
+ END:VEVENT
+ `);
+
+ let originalProps = {
+ DESCRIPTION: "This is a test event.",
+ CATEGORIES: ["Birthday"],
+ LOCATION: "Castara",
+ };
+
+ let changedProps = [
+ null,
+ null,
+ {
+ DESCRIPTION: "This is an edited occurrence.",
+ CATEGORIES: ["Holiday"],
+ LOCATION: "Georgetown",
+ },
+ null,
+ null,
+ ];
+
+ return doPropertiesTest(
+ Ci.calICalendar.ITEM_FILTER_TYPE_EVENT,
+ event,
+ originalProps,
+ changedProps
+ );
+});
+
+/**
+ * Test todo exceptions load their properties.
+ */
+add_task(async function testTodoPropertiesForRecurringExceptionsLoad() {
+ let todo = new CalTodo(CalendarTestUtils.dedent`
+ BEGIN:VTODO
+ CREATED:20201211T000000Z
+ LAST-MODIFIED:20201211T000000Z
+ DTSTAMP:20201210T080410Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Original Test Event
+ DTSTART:20201211T000000Z
+ DTEND:20201211T110000Z
+ RRULE:FREQ=DAILY;UNTIL=20201215T140000Z
+ END:VTODO
+ `);
+
+ let originalProps = {
+ DESCRIPTION: "This is a test todo.",
+ CATEGORIES: ["Birthday"],
+ LOCATION: "Castara",
+ STATUS: "NEEDS-ACTION",
+ };
+
+ let changedProps = [
+ null,
+ null,
+ {
+ DESCRIPTION: "This is an edited occurrence.",
+ CATEGORIES: ["Holiday"],
+ LOCATION: "Georgetown",
+ STATUS: "COMPLETE",
+ },
+ null,
+ null,
+ ];
+
+ return doPropertiesTest(Ci.calICalendar.ITEM_FILTER_TYPE_TODO, todo, originalProps, changedProps);
+});
+
+/**
+ * Tests calling getItems() does not overwrite subsequent event occurrence
+ * exceptions with their parent item. See bug 1686466.
+ */
+add_task(async function testRecurringEventChangesAreNotHiddenByCache() {
+ let event = new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ CREATED:20201211T000000Z
+ LAST-MODIFIED:20201211T000000Z
+ DTSTAMP:20201210T080410Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Original Test Event
+ DTSTART:20201211T000000Z
+ DTEND:20201211T110000Z
+ RRULE:FREQ=DAILY;UNTIL=20201215T140000Z
+ END:VEVENT
+ `);
+
+ let originalProps = {
+ LOCATION: "San Juan",
+ };
+
+ let changedProps = [
+ null,
+ {
+ LOCATION: "Buenos Aries",
+ },
+ {
+ LOCATION: "Bridgetown",
+ },
+ {
+ LOCATION: "Freetown",
+ },
+ null,
+ ];
+
+ return doPropertiesTest(
+ Ci.calICalendar.ITEM_FILTER_TYPE_EVENT,
+ event,
+ originalProps,
+ changedProps,
+ true
+ );
+});
+
+/**
+ * Tests calling getItems() does not overwrite subsequent todo occurrence
+ * exceptions with their parent item. See bug 1686466.
+ */
+add_task(async function testRecurringTodoChangesNotHiddenByCache() {
+ let todo = new CalTodo(CalendarTestUtils.dedent`
+ BEGIN:VTODO
+ CREATED:20201211T000000Z
+ LAST-MODIFIED:20201211T000000Z
+ DTSTAMP:20201210T080410Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Original Test Event
+ DTSTART:20201211T000000Z
+ DTEND:20201211T110000Z
+ RRULE:FREQ=DAILY;UNTIL=20201215T140000Z
+ END:VTODO
+ `);
+
+ let originalProps = {
+ DESCRIPTION: "This is a test todo.",
+ CATEGORIES: ["Birthday"],
+ LOCATION: "Castara",
+ STATUS: "NEEDS-ACTION",
+ };
+
+ let changedProps = [
+ null,
+ {
+ STATUS: "COMPLETE",
+ },
+ {
+ STATUS: "COMPLETE",
+ },
+ {
+ STATUS: "COMPLETE",
+ },
+ null,
+ ];
+
+ return doPropertiesTest(Ci.calICalendar.ITEM_FILTER_TYPE_TODO, todo, originalProps, changedProps);
+});
diff --git a/comm/calendar/test/unit/test_timezone.js b/comm/calendar/test/unit/test_timezone.js
new file mode 100644
index 0000000000..d11b380b52
--- /dev/null
+++ b/comm/calendar/test/unit/test_timezone.js
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function run_test() {
+ do_test_pending();
+ cal.timezoneService.QueryInterface(Ci.calIStartupService).startup({
+ onResult() {
+ really_run_test();
+ do_test_finished();
+ },
+ });
+}
+
+function really_run_test() {
+ let event = new CalEvent();
+
+ let str = [
+ "BEGIN:VCALENDAR",
+ "PRODID:-//RDU Software//NONSGML HandCal//EN",
+ "VERSION:2.0",
+ "BEGIN:VTIMEZONE",
+ "TZID:America/New_York",
+ "BEGIN:STANDARD",
+ "DTSTART:19981025T020000",
+ "TZOFFSETFROM:-0400",
+ "TZOFFSETTO:-0500",
+ "TZNAME:EST",
+ "END:STANDARD",
+ "BEGIN:DAYLIGHT",
+ "DTSTART:19990404T020000",
+ "TZOFFSETFROM:-0500",
+ "TZOFFSETTO:-0400",
+ "TZNAME:EDT",
+ "END:DAYLIGHT",
+ "END:VTIMEZONE",
+ "BEGIN:VEVENT",
+ "DTSTAMP:19980309T231000Z",
+ "UID:guid-1.example.com",
+ "ORGANIZER:mailto:mrbig@example.com",
+ "ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;CUTYPE=GROUP:",
+ " mailto:employee-A@example.com",
+ "DESCRIPTION:Project XYZ Review Meeting",
+ "CATEGORIES:MEETING",
+ "CLASS:PUBLIC",
+ "CREATED:19980309T130000Z",
+ "SUMMARY:XYZ Project Review",
+ "DTSTART;TZID=America/New_York:19980312T083000",
+ "DTEND;TZID=America/New_York:19980312T093000",
+ "LOCATION:1CP Conference Room 4350",
+ "END:VEVENT",
+ "END:VCALENDAR",
+ "",
+ ].join("\r\n");
+
+ let strTz = [
+ "BEGIN:VTIMEZONE",
+ "TZID:America/New_York",
+ "BEGIN:STANDARD",
+ "DTSTART:19981025T020000",
+ "TZOFFSETFROM:-0400",
+ "TZOFFSETTO:-0500",
+ "TZNAME:EST",
+ "END:STANDARD",
+ "BEGIN:DAYLIGHT",
+ "DTSTART:19990404T020000",
+ "TZOFFSETFROM:-0500",
+ "TZOFFSETTO:-0400",
+ "TZNAME:EDT",
+ "END:DAYLIGHT",
+ "END:VTIMEZONE",
+ "",
+ ].join("\r\n");
+
+ event.icalString = str;
+
+ let startDate = event.startDate;
+ let endDate = event.endDate;
+
+ startDate.timezone = cal.timezoneService.getTimezone(startDate.timezone.tzid);
+ endDate.timezone = cal.timezoneService.getTimezone(endDate.timezone.tzid);
+ notEqual(strTz, startDate.timezone.toString());
+}
diff --git a/comm/calendar/test/unit/test_timezone_changes.js b/comm/calendar/test/unit/test_timezone_changes.js
new file mode 100644
index 0000000000..125187ca67
--- /dev/null
+++ b/comm/calendar/test/unit/test_timezone_changes.js
@@ -0,0 +1,93 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const FEBRUARY = 1;
+const OCTOBER = 9;
+const NOVEMBER = 10;
+
+const UTC_MINUS_3 = -3 * 3600;
+const UTC_MINUS_2 = -2 * 3600;
+
+function run_test() {
+ do_calendar_startup(run_next_test);
+}
+
+// This test requires timezone data going back to 2016. It's been kept here as an example.
+/* add_test(function testCaracas() {
+ let time = cal.createDateTime();
+ let zone = cal.timezoneService.getTimezone("America/Caracas");
+
+ for (let month = JANUARY; month <= DECEMBER; month++) {
+ time.resetTo(2015, month, 1, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_430, time.toString());
+ }
+
+ for (let month = JANUARY; month <= APRIL; month++) {
+ time.resetTo(2016, month, 1, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_430, time.toString());
+ }
+
+ time.resetTo(2016, MAY, 1, 1, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_430, time.toString());
+
+ time.resetTo(2016, MAY, 1, 3, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_4, time.toString());
+
+ for (let month = JUNE; month <= DECEMBER; month++) {
+ time.resetTo(2016, month, 1, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_4, time.toString());
+ }
+
+ for (let month = JANUARY; month <= DECEMBER; month++) {
+ time.resetTo(2017, month, 1, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_4, time.toString());
+ }
+
+ run_next_test();
+}); */
+
+// Brazil's rules are complicated. This tests every change in the time range we have data for.
+// Updated for 2019b: Brazil no longer has DST.
+add_test(function testSaoPaulo() {
+ let time = cal.createDateTime();
+ let zone = cal.timezoneService.getTimezone("America/Sao_Paulo");
+
+ time.resetTo(2018, FEBRUARY, 17, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_2, time.toString());
+
+ time.resetTo(2018, FEBRUARY, 18, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ time.resetTo(2018, NOVEMBER, 3, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ time.resetTo(2018, NOVEMBER, 4, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_2, time.toString());
+
+ time.resetTo(2019, FEBRUARY, 16, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_2, time.toString());
+
+ time.resetTo(2019, FEBRUARY, 17, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ time.resetTo(2019, NOVEMBER, 2, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ time.resetTo(2019, NOVEMBER, 3, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ time.resetTo(2020, FEBRUARY, 15, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ time.resetTo(2020, FEBRUARY, 16, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ time.resetTo(2020, OCTOBER, 31, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ time.resetTo(2020, NOVEMBER, 1, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ run_next_test();
+});
diff --git a/comm/calendar/test/unit/test_timezone_definition.js b/comm/calendar/test/unit/test_timezone_definition.js
new file mode 100644
index 0000000000..79cddc2245
--- /dev/null
+++ b/comm/calendar/test/unit/test_timezone_definition.js
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ do_calendar_startup(run_next_test);
+}
+
+// check tz database version
+add_task(async function version_test() {
+ ok(cal.timezoneService.version, "service should provide timezone version");
+});
+
+// check whether all tz definitions have all properties
+add_task(async function zone_test() {
+ function resolveZone(aZoneId) {
+ let timezone = cal.timezoneService.getTimezone(aZoneId);
+ equal(aZoneId, timezone.tzid, "Zone test " + aZoneId);
+ ok(
+ timezone.icalComponent.serializeToICS().startsWith("BEGIN:VTIMEZONE"),
+ "VTIMEZONE test " + aZoneId
+ );
+ }
+
+ let foundZone = false;
+ for (let zone of cal.timezoneService.timezoneIds) {
+ foundZone = true;
+ resolveZone(zone);
+ }
+
+ ok(foundZone, "There is at least one timezone");
+});
diff --git a/comm/calendar/test/unit/test_transaction_manager.js b/comm/calendar/test/unit/test_transaction_manager.js
new file mode 100644
index 0000000000..bd9c591560
--- /dev/null
+++ b/comm/calendar/test/unit/test_transaction_manager.js
@@ -0,0 +1,431 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the CalTransactionManager and the various CalTransaction instances.
+ */
+
+const { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+const { CalTodo } = ChromeUtils.import("resource:///modules/CalTodo.jsm");
+const {
+ CalTransactionManager,
+ CalTransaction,
+ CalBatchTransaction,
+ CalAddTransaction,
+ CalModifyTransaction,
+ CalDeleteTransaction,
+} = ChromeUtils.import("resource:///modules/CalTransactionManager.jsm");
+
+/**
+ * Records the number of times doTransction() and undoTransction() is called.
+ */
+class MockCalTransaction extends CalTransaction {
+ /**
+ * The number of times doTransaction() was called.
+ *
+ * @type {number}
+ */
+ done = 0;
+
+ /**
+ * The number of times undoTransaction() was called.
+ */
+ undone = 0;
+
+ _writable;
+
+ constructor(writable = true) {
+ super();
+ this._writable = writable;
+ }
+
+ canWrite() {
+ return this._writable;
+ }
+
+ async doTransaction() {
+ this.done++;
+ }
+
+ async undoTransaction() {
+ this.undone++;
+ }
+}
+
+/**
+ * Tests a list of CalMockTransactions have the expected "done" and "undone"
+ * values.
+ *
+ * @param {CalMockTransaction[][]} batches The transaction batches to check.
+ * @param {number[][][]} expected - A 3 dimensional array containing
+ * the expected "done" and "undone" values for each transaction in each batch
+ * to be tested.
+ */
+function doBatchTest(batches, expected) {
+ for (let [batch, transactions] of batches.entries()) {
+ for (let [index, trn] of transactions.entries()) {
+ let [doneCount, undoneCount] = expected[batch][index];
+ Assert.equal(
+ trn.done,
+ doneCount,
+ `batch ${batch}, transaction ${index} doTransaction() called ${doneCount} times`
+ );
+ Assert.equal(
+ trn.undone,
+ undoneCount,
+ `batch ${batch}, transaction ${index} undoTransaction() called ${undoneCount} times`
+ );
+ }
+ }
+}
+
+add_setup(async function () {
+ await new Promise(resolve => do_load_calmgr(resolve));
+});
+
+/**
+ * Tests the CalTransactionManager methods work as expected.
+ */
+add_task(async function testCalTransactionManager() {
+ let manager = new CalTransactionManager();
+
+ Assert.ok(!manager.canUndo(), "canUndo() returns false with an empty undo stack");
+ Assert.ok(!manager.canRedo(), "canRedo() returns false with an empty redo stack");
+ Assert.ok(!manager.peekUndoStack(), "peekUndoStack() returns nothing with an empty undo stack");
+ Assert.ok(!manager.peekRedoStack(), "peekRedoStack() returns nothing with an empty redo stack");
+
+ info("calling CalTransactionManager.commit()");
+ let trn = new MockCalTransaction();
+ await manager.commit(trn);
+ Assert.equal(trn.done, 1, "doTransaction() called once");
+ Assert.equal(trn.undone, 0, "undoTransaction() was not called");
+ Assert.ok(manager.canUndo(), "canUndo() returned true");
+ Assert.ok(!manager.canRedo(), "canRedo() returned false");
+ Assert.equal(manager.peekUndoStack(), trn, "peekUndoStack() returned the transaction");
+ Assert.ok(!manager.peekRedoStack(), "peekRedoStack() returned nothing");
+
+ info("calling CalTransactionManager.undo()");
+ await manager.undo();
+ Assert.equal(trn.done, 1, "doTransaction() was not called again");
+ Assert.equal(trn.undone, 1, "undoTransaction() was called once");
+ Assert.ok(!manager.canUndo(), "canUndo() returned false");
+ Assert.ok(manager.canRedo(), "canRedo() returned true");
+ Assert.ok(!manager.peekUndoStack(), "peekUndoStack() returned nothing");
+ Assert.equal(manager.peekRedoStack(), trn, "peekRedoStack() returned the transaction");
+
+ info("calling CalTransactionManager.redo()");
+ await manager.redo();
+ Assert.equal(trn.done, 2, "doTransaction() was called again");
+ Assert.equal(trn.undone, 1, "undoTransaction() was not called again");
+ Assert.ok(manager.canUndo(), "canUndo() returned true");
+ Assert.ok(!manager.canRedo(), "canRedo() returned false");
+ Assert.equal(manager.peekUndoStack(), trn, "peekUndoStack() returned the transaction");
+ Assert.ok(!manager.peekRedoStack(), "peekRedoStack() returned nothing");
+
+ info("testing CalTransactionManager.beginBatch()");
+ manager = new CalTransactionManager();
+
+ let batch = manager.beginBatch();
+ Assert.ok(batch instanceof CalBatchTransaction, "beginBatch() returned a CalBatchTransaction");
+ Assert.equal(manager.undoStack[0], batch, "the CalBatchTransaction is on the undo stack");
+});
+
+/**
+ * Tests the BatchTransaction works as expected.
+ */
+add_task(async function testBatchTransaction() {
+ let batch = new CalBatchTransaction();
+
+ Assert.ok(!batch.canWrite(), "canWrite() returns false for an empty BatchTransaction");
+ await batch.commit(new MockCalTransaction());
+ await batch.commit(new MockCalTransaction(false));
+ await batch.commit(new MockCalTransaction());
+ Assert.ok(!batch.canWrite(), "canWrite() returns false if any transaction is not writable");
+
+ let transactions = [new MockCalTransaction(), new MockCalTransaction(), new MockCalTransaction()];
+ batch = new CalBatchTransaction();
+ for (let trn of transactions) {
+ await batch.commit(trn);
+ }
+
+ Assert.ok(batch.canWrite(), "canWrite() returns true when all transactions are writable");
+ info("testing commit() calls doTransaction() on each transaction in batch");
+ doBatchTest(
+ [transactions],
+ [
+ [
+ [1, 0],
+ [1, 0],
+ [1, 0],
+ ],
+ ]
+ );
+
+ await batch.undoTransaction();
+ info("testing undoTransaction() called on each transaction in batch");
+ doBatchTest(
+ [transactions],
+ [
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ ]
+ );
+
+ await batch.doTransaction();
+ info("testing doTransaction() called again on each transaction in batch");
+ doBatchTest(
+ [transactions],
+ [
+ [
+ [2, 1],
+ [2, 1],
+ [2, 1],
+ ],
+ ]
+ );
+});
+
+/**
+ * Tests that executing multiple batch transactions in sequence works.
+ */
+add_task(async function testSequentialBatchTransactions() {
+ let manager = new CalTransactionManager();
+
+ let batchTransactions = [
+ [new MockCalTransaction(), new MockCalTransaction(), new MockCalTransaction()],
+ [new MockCalTransaction(), new MockCalTransaction(), new MockCalTransaction()],
+ [new MockCalTransaction(), new MockCalTransaction(), new MockCalTransaction()],
+ ];
+
+ let batch0 = manager.beginBatch();
+ for (let trn of batchTransactions[0]) {
+ await batch0.commit(trn);
+ }
+
+ let batch1 = manager.beginBatch();
+ for (let trn of batchTransactions[1]) {
+ await batch1.commit(trn);
+ }
+
+ let batch2 = manager.beginBatch();
+ for (let trn of batchTransactions[2]) {
+ await batch2.commit(trn);
+ }
+
+ doBatchTest(batchTransactions, [
+ [
+ [1, 0],
+ [1, 0],
+ [1, 0],
+ ],
+ [
+ [1, 0],
+ [1, 0],
+ [1, 0],
+ ],
+ [
+ [1, 0],
+ [1, 0],
+ [1, 0],
+ ],
+ ]);
+
+ // Undo the top most batch.
+ await manager.undo();
+ doBatchTest(batchTransactions, [
+ [
+ [1, 0],
+ [1, 0],
+ [1, 0],
+ ],
+ [
+ [1, 0],
+ [1, 0],
+ [1, 0],
+ ],
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ ]);
+
+ // Undo the next batch.
+ await manager.undo();
+ doBatchTest(batchTransactions, [
+ [
+ [1, 0],
+ [1, 0],
+ [1, 0],
+ ],
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ ]);
+
+ // Undo the last batch left.
+ await manager.undo();
+ doBatchTest(batchTransactions, [
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ ]);
+
+ // Redo the first batch.
+ await manager.redo();
+ doBatchTest(batchTransactions, [
+ [
+ [2, 1],
+ [2, 1],
+ [2, 1],
+ ],
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ ]);
+
+ // Redo the second batch.
+ await manager.redo();
+ doBatchTest(batchTransactions, [
+ [
+ [2, 1],
+ [2, 1],
+ [2, 1],
+ ],
+ [
+ [2, 1],
+ [2, 1],
+ [2, 1],
+ ],
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ ]);
+
+ // Redo the last batch.
+ await manager.redo();
+ doBatchTest(batchTransactions, [
+ [
+ [2, 1],
+ [2, 1],
+ [2, 1],
+ ],
+ [
+ [2, 1],
+ [2, 1],
+ [2, 1],
+ ],
+ [
+ [2, 1],
+ [2, 1],
+ [2, 1],
+ ],
+ ]);
+});
+
+/**
+ * Tests CalAddTransaction executes and reverses as expected.
+ */
+add_task(async function testCalAddTransaction() {
+ let calendar = CalendarTestUtils.createCalendar("Test", "memory");
+ let event = new CalEvent();
+ event.id = "test";
+
+ let trn = new CalAddTransaction(event, calendar, null, null);
+ await trn.doTransaction();
+
+ let addedEvent = await calendar.getItem("test");
+ Assert.ok(!!addedEvent, "transaction added event to the calendar");
+
+ await trn.undoTransaction();
+ addedEvent = await calendar.getItem("test");
+ Assert.ok(!addedEvent, "transaction removed event from the calendar");
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+/**
+ * Tests CalModifyTransaction executes and reverses as expected.
+ */
+add_task(async function testCalModifyTransaction() {
+ let calendar = CalendarTestUtils.createCalendar("Test", "memory");
+ let event = new CalEvent();
+ event.id = "test";
+ event.title = "Event";
+
+ let addedEvent = await calendar.addItem(event);
+ Assert.ok(!!addedEvent, "event was added to the calendar");
+
+ let modifiedEvent = addedEvent.clone();
+ modifiedEvent.title = "Modified Event";
+
+ let trn = new CalModifyTransaction(modifiedEvent, calendar, addedEvent, null);
+ await trn.doTransaction();
+ modifiedEvent = await calendar.getItem("test");
+ Assert.ok(!!modifiedEvent);
+ Assert.equal(modifiedEvent.title, "Modified Event", "transaction modified event");
+
+ await trn.undoTransaction();
+ let revertedEvent = await calendar.getItem("test");
+ Assert.ok(!!revertedEvent);
+ Assert.equal(revertedEvent.title, "Event", "transaction reverted event to original state");
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+/**
+ * Tests CalDeleteTransaction executes and reverses as expected.
+ */
+add_task(async function testCalDeleteTransaction() {
+ let calendar = CalendarTestUtils.createCalendar("Test", "memory");
+ let event = new CalEvent();
+ event.id = "test";
+ event.title = "Event";
+
+ let addedEvent = await calendar.addItem(event);
+ Assert.ok(!!addedEvent, "event was added to the calendar");
+
+ let trn = new CalDeleteTransaction(addedEvent, calendar, null, null);
+ await trn.doTransaction();
+
+ let result = await calendar.getItem("test");
+ Assert.ok(!result, "event was deleted from the calendar");
+
+ await trn.undoTransaction();
+ let revertedEvent = await calendar.getItem("test");
+ Assert.ok(!!revertedEvent, "event was restored to the calendar");
+ CalendarTestUtils.removeCalendar(calendar);
+});
diff --git a/comm/calendar/test/unit/test_unifinder_utils.js b/comm/calendar/test/unit/test_unifinder_utils.js
new file mode 100644
index 0000000000..ae44379781
--- /dev/null
+++ b/comm/calendar/test/unit/test_unifinder_utils.js
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+function run_test() {
+ test_get_item_sort_key();
+ test_sort_items();
+}
+
+function test_get_item_sort_key() {
+ let event = new CalEvent(dedent`
+ BEGIN:VEVENT
+ PRIORITY:8
+ SUMMARY:summary
+ DTSTART:20180102T030405Z
+ DTEND:20180607T080910Z
+ CATEGORIES:a,b,c
+ LOCATION:location
+ STATUS:CONFIRMED
+ END:VEVENT
+ `);
+
+ strictEqual(cal.unifinder.getItemSortKey(event, "nothing"), null);
+ equal(cal.unifinder.getItemSortKey(event, "priority"), 8);
+ equal(cal.unifinder.getItemSortKey(event, "title"), "summary");
+ equal(cal.unifinder.getItemSortKey(event, "startDate"), 1514862245000000);
+ equal(cal.unifinder.getItemSortKey(event, "endDate"), 1528358950000000);
+ equal(cal.unifinder.getItemSortKey(event, "categories"), "a, b, c");
+ equal(cal.unifinder.getItemSortKey(event, "location"), "location");
+ equal(cal.unifinder.getItemSortKey(event, "status"), 1);
+
+ let task = new CalTodo(dedent`
+ BEGIN:VTODO
+ DTSTART:20180102T030405Z
+ DUE:20180607T080910Z
+ PERCENT-COMPLETE:20
+ STATUS:COMPLETED
+ END:VTODO
+ `);
+
+ equal(cal.unifinder.getItemSortKey(task, "priority"), 5);
+ strictEqual(cal.unifinder.getItemSortKey(task, "title"), "");
+ equal(cal.unifinder.getItemSortKey(task, "entryDate"), 1514862245000000);
+ equal(cal.unifinder.getItemSortKey(task, "dueDate"), 1528358950000000);
+ equal(cal.unifinder.getItemSortKey(task, "completedDate"), -62168601600000000);
+ equal(cal.unifinder.getItemSortKey(task, "percentComplete"), 20);
+ strictEqual(cal.unifinder.getItemSortKey(task, "categories"), "");
+ strictEqual(cal.unifinder.getItemSortKey(task, "location"), "");
+ equal(cal.unifinder.getItemSortKey(task, "status"), 2);
+
+ let task2 = new CalTodo(dedent`
+ BEGIN:VTODO
+ STATUS:GETTIN' THERE
+ END:VTODO
+ `);
+ equal(cal.unifinder.getItemSortKey(task2, "percentComplete"), 0);
+ equal(cal.unifinder.getItemSortKey(task2, "status"), -1);
+
+ // Default CalTodo objects have the default percentComplete.
+ let task3 = new CalTodo();
+ equal(cal.unifinder.getItemSortKey(task3, "percentComplete"), 0);
+}
+
+function test_sort_items() {
+ // string comparison
+ let summaries = ["", "a", "b"];
+ let items = summaries.map(summary => {
+ return new CalEvent(dedent`
+ BEGIN:VEVENT
+ SUMMARY:${summary}
+ END:VEVENT
+ `);
+ });
+
+ cal.unifinder.sortItems(items, "title", 1);
+ deepEqual(
+ items.map(item => item.title),
+ ["a", "b", null]
+ );
+
+ cal.unifinder.sortItems(items, "title", -1);
+ deepEqual(
+ items.map(item => item.title),
+ [null, "b", "a"]
+ );
+
+ // date comparison
+ let dates = ["20180101T000002Z", "20180101T000000Z", "20180101T000001Z"];
+ items = dates.map(date => {
+ return new CalEvent(dedent`
+ BEGIN:VEVENT
+ DTSTART:${date}
+ END:VEVENT
+ `);
+ });
+
+ cal.unifinder.sortItems(items, "startDate", 1);
+ deepEqual(
+ items.map(item => item.startDate.icalString),
+ ["20180101T000000Z", "20180101T000001Z", "20180101T000002Z"]
+ );
+
+ cal.unifinder.sortItems(items, "startDate", -1);
+ deepEqual(
+ items.map(item => item.startDate.icalString),
+ ["20180101T000002Z", "20180101T000001Z", "20180101T000000Z"]
+ );
+
+ // number comparison
+ let percents = [3, 1, 2];
+ items = percents.map(percent => {
+ return new CalTodo(dedent`
+ BEGIN:VTODO
+ PERCENT-COMPLETE:${percent}
+ END:VTODO
+ `);
+ });
+
+ cal.unifinder.sortItems(items, "percentComplete", 1);
+ deepEqual(
+ items.map(item => item.percentComplete),
+ [1, 2, 3]
+ );
+
+ cal.unifinder.sortItems(items, "percentComplete", -1);
+ deepEqual(
+ items.map(item => item.percentComplete),
+ [3, 2, 1]
+ );
+}
diff --git a/comm/calendar/test/unit/test_utils.js b/comm/calendar/test/unit/test_utils.js
new file mode 100644
index 0000000000..05d7423808
--- /dev/null
+++ b/comm/calendar/test/unit/test_utils.js
@@ -0,0 +1,185 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ test_recentzones();
+ test_formatcss();
+ test_getDefaultStartDate();
+ test_getStartEndProps();
+ test_OperationGroup();
+ test_sameDay();
+ test_binarySearch();
+}
+
+function test_recentzones() {
+ equal(cal.dtz.getRecentTimezones().length, 0);
+ equal(cal.dtz.getRecentTimezones(true).length, 0);
+
+ cal.dtz.saveRecentTimezone("Europe/Berlin");
+
+ let zones = cal.dtz.getRecentTimezones();
+ equal(zones.length, 1);
+ equal(zones[0], "Europe/Berlin");
+ zones = cal.dtz.getRecentTimezones(true);
+ equal(zones.length, 1);
+ equal(zones[0].tzid, "Europe/Berlin");
+
+ cal.dtz.saveRecentTimezone(cal.dtz.defaultTimezone.tzid);
+ equal(cal.dtz.getRecentTimezones().length, 1);
+ equal(cal.dtz.getRecentTimezones(true).length, 1);
+
+ cal.dtz.saveRecentTimezone("Europe/Berlin");
+ equal(cal.dtz.getRecentTimezones().length, 1);
+ equal(cal.dtz.getRecentTimezones(true).length, 1);
+
+ cal.dtz.saveRecentTimezone("America/New_York");
+ equal(cal.dtz.getRecentTimezones().length, 2);
+ equal(cal.dtz.getRecentTimezones(true).length, 2);
+
+ cal.dtz.saveRecentTimezone("Unknown");
+ equal(cal.dtz.getRecentTimezones().length, 3);
+ equal(cal.dtz.getRecentTimezones(true).length, 2);
+}
+
+function test_formatcss() {
+ equal(cal.view.formatStringForCSSRule(" "), "_");
+ equal(cal.view.formatStringForCSSRule("ΓΌ"), "-uxfc-");
+ equal(cal.view.formatStringForCSSRule("a"), "a");
+}
+
+function test_getDefaultStartDate() {
+ function transform(nowString, refDateString) {
+ now = cal.createDateTime(nowString);
+ let refDate = refDateString ? cal.createDateTime(refDateString) : null;
+ return cal.dtz.getDefaultStartDate(refDate);
+ }
+
+ let oldNow = cal.dtz.now;
+ let now = cal.createDateTime("20120101T000000");
+ cal.dtz.now = function () {
+ return now;
+ };
+
+ dump("TT: " + cal.createDateTime("20120101T000000") + "\n");
+ dump("TT: " + cal.dtz.getDefaultStartDate(cal.createDateTime("20120101T000000")) + "\n");
+
+ equal(transform("20120101T000000").icalString, "20120101T010000");
+ equal(transform("20120101T015959").icalString, "20120101T020000");
+ equal(transform("20120101T230000").icalString, "20120101T230000");
+ equal(transform("20120101T235959").icalString, "20120101T230000");
+
+ equal(transform("20120101T000000", "20120202").icalString, "20120202T010000");
+ equal(transform("20120101T015959", "20120202").icalString, "20120202T020000");
+ equal(transform("20120101T230000", "20120202").icalString, "20120202T230000");
+ equal(transform("20120101T235959", "20120202").icalString, "20120202T230000");
+
+ let event = new CalEvent();
+ now = cal.createDateTime("20120101T015959");
+ cal.dtz.setDefaultStartEndHour(event, cal.createDateTime("20120202"));
+ equal(event.startDate.icalString, "20120202T020000");
+ equal(event.endDate.icalString, "20120202T030000");
+
+ let todo = new CalTodo();
+ now = cal.createDateTime("20120101T000000");
+ cal.dtz.setDefaultStartEndHour(todo, cal.createDateTime("20120202"));
+ equal(todo.entryDate.icalString, "20120202T010000");
+
+ cal.dtz.now = oldNow;
+}
+
+function test_getStartEndProps() {
+ equal(cal.dtz.startDateProp(new CalEvent()), "startDate");
+ equal(cal.dtz.endDateProp(new CalEvent()), "endDate");
+ equal(cal.dtz.startDateProp(new CalTodo()), "entryDate");
+ equal(cal.dtz.endDateProp(new CalTodo()), "dueDate");
+
+ throws(() => cal.dtz.startDateProp(null), /NS_ERROR_NOT_IMPLEMENTED/);
+ throws(() => cal.dtz.endDateProp(null), /NS_ERROR_NOT_IMPLEMENTED/);
+}
+
+function test_OperationGroup() {
+ let cancelCalled = false;
+ function cancelFunc() {
+ cancelCalled = true;
+ return true;
+ }
+
+ let group = new cal.data.OperationGroup(cancelFunc);
+
+ ok(group.isEmpty);
+ ok(group.id.endsWith("-0"));
+ equal(group.status, Cr.NS_OK);
+ equal(group.isPending, true);
+
+ let completedOp = { isPending: false };
+
+ group.add(completedOp);
+ ok(group.isEmpty);
+ equal(group.isPending, true);
+
+ let pendingOp1 = {
+ id: 1,
+ isPending: true,
+ cancel() {
+ this.cancelCalled = true;
+ return true;
+ },
+ };
+
+ group.add(pendingOp1);
+ ok(!group.isEmpty);
+ equal(group.isPending, true);
+
+ let pendingOp2 = {
+ id: 2,
+ isPending: true,
+ cancel() {
+ this.cancelCalled = true;
+ return true;
+ },
+ };
+
+ group.add(pendingOp2);
+ group.remove(pendingOp1);
+ ok(!group.isEmpty);
+ equal(group.isPending, true);
+
+ group.cancel();
+
+ equal(group.status, Ci.calIErrors.OPERATION_CANCELLED);
+ ok(!group.isPending);
+ ok(cancelCalled);
+ ok(pendingOp2.cancelCalled);
+}
+
+function test_sameDay() {
+ let createDate = cal.createDateTime.bind(cal);
+
+ ok(cal.dtz.sameDay(createDate("20120101"), createDate("20120101T120000")));
+ ok(cal.dtz.sameDay(createDate("20120101"), createDate("20120101")));
+ ok(!cal.dtz.sameDay(createDate("20120101"), createDate("20120102")));
+ ok(!cal.dtz.sameDay(createDate("20120101T120000"), createDate("20120102T120000")));
+}
+
+function test_binarySearch() {
+ let arr = [2, 5, 7, 9, 20, 27, 34, 39, 41, 53, 62];
+ equal(cal.data.binarySearch(arr, 27), 5); // Center
+ equal(cal.data.binarySearch(arr, 2), 0); // Left most
+ equal(cal.data.binarySearch(arr, 62), 11); // Right most
+
+ equal(cal.data.binarySearch([5], 5), 1); // One element found
+ equal(cal.data.binarySearch([1], 0), 0); // One element insert left
+ equal(cal.data.binarySearch([1], 2), 1); // One element insert right
+}
diff --git a/comm/calendar/test/unit/test_view_utils.js b/comm/calendar/test/unit/test_view_utils.js
new file mode 100644
index 0000000000..da988f8042
--- /dev/null
+++ b/comm/calendar/test/unit/test_view_utils.js
@@ -0,0 +1,127 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ test_not_a_date();
+ test_compare_event_and_todo();
+ test_compare_startdate();
+ test_compare_enddate();
+ test_compare_alldayevent();
+ test_compare_title();
+ test_compare_todo();
+}
+
+function test_not_a_date() {
+ let item = new CalEvent();
+
+ let result = cal.view.compareItems(null, item);
+ equal(result, -1);
+
+ result = cal.view.compareItems(item, null);
+ equal(result, 1);
+}
+
+function test_compare_event_and_todo() {
+ let a = new CalEvent();
+ let b = new CalTodo();
+
+ let result = cal.view.compareItems(a, b);
+ equal(result, 1);
+
+ result = cal.view.compareItems(b, a);
+ equal(result, -1);
+}
+
+function test_compare_startdate() {
+ let a = new CalEvent();
+ a.startDate = createDate(1990, 0, 1, 1);
+ let b = new CalEvent();
+ b.startDate = createDate(2000, 0, 1, 1);
+
+ let result = cal.view.compareItems(a, b);
+ equal(result, -1);
+
+ result = cal.view.compareItems(b, a);
+ equal(result, 1);
+
+ result = cal.view.compareItems(a, a);
+ equal(result, 0);
+}
+
+function test_compare_enddate() {
+ let a = new CalEvent();
+ a.startDate = createDate(1990, 0, 1, 1);
+ a.endDate = createDate(1990, 0, 2, 1);
+ let b = new CalEvent();
+ b.startDate = createDate(1990, 0, 1, 1);
+ b.endDate = createDate(1990, 0, 5, 1);
+
+ let result = cal.view.compareItems(a, b);
+ equal(result, -1);
+
+ result = cal.view.compareItems(b, a);
+ equal(result, 1);
+
+ result = cal.view.compareItems(a, a);
+ equal(result, 0);
+}
+
+function test_compare_alldayevent() {
+ let a = new CalEvent();
+ a.startDate = createDate(1990, 0, 1);
+ let b = new CalEvent();
+ b.startDate = createDate(1990, 0, 1, 1);
+
+ let result = cal.view.compareItems(a, b);
+ equal(result, -1);
+
+ result = cal.view.compareItems(b, a);
+ equal(result, 1);
+
+ result = cal.view.compareItems(a, a);
+ equal(result, 0);
+}
+
+function test_compare_title() {
+ let a = new CalEvent();
+ a.startDate = createDate(1990, 0, 1);
+ a.title = "Abc";
+ let b = new CalEvent();
+ b.startDate = createDate(1990, 0, 1);
+ b.title = "Xyz";
+
+ let result = cal.view.compareItems(a, b);
+ equal(result, -1);
+
+ result = cal.view.compareItems(b, a);
+ equal(result, 1);
+
+ result = cal.view.compareItems(a, a);
+ equal(result, 0);
+}
+
+function test_compare_todo() {
+ let a = new CalTodo();
+ let b = new CalTodo();
+
+ let cmp = cal.view.compareItems(a, b);
+ equal(cmp, 0);
+
+ cmp = cal.view.compareItems(b, a);
+ equal(cmp, 0);
+
+ cmp = cal.view.compareItems(a, a);
+ equal(cmp, 0);
+}
diff --git a/comm/calendar/test/unit/test_webcal.js b/comm/calendar/test/unit/test_webcal.js
new file mode 100644
index 0000000000..77d9576f4b
--- /dev/null
+++ b/comm/calendar/test/unit/test_webcal.js
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+var { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+function run_test() {
+ let httpserv = new HttpServer();
+ httpserv.registerPrefixHandler("/", {
+ handle(request, response) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ equal(request.path, "/test_webcal");
+ },
+ });
+ httpserv.start(-1);
+
+ let baseUri = "://localhost:" + httpserv.identity.primaryPort + "/test_webcal";
+ add_test(check_webcal_uri.bind(null, "webcal" + baseUri));
+ // TODO webcals needs bug 466524 to be fixed
+ // add_test(check_webcal_uri.bind(null, "webcals" + baseUri));
+ add_test(() => httpserv.stop(run_next_test));
+
+ // Now lets go...
+ run_next_test();
+}
+
+function check_webcal_uri(aUri) {
+ let uri = Services.io.newURI(aUri);
+
+ let channel = Services.io.newChannelFromURI(
+ uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+
+ NetUtil.asyncFetch(channel, (data, status, request) => {
+ ok(Components.isSuccessCode(status));
+ run_next_test();
+ });
+}
diff --git a/comm/calendar/test/unit/test_weekinfo_service.js b/comm/calendar/test/unit/test_weekinfo_service.js
new file mode 100644
index 0000000000..9be4d02dd3
--- /dev/null
+++ b/comm/calendar/test/unit/test_weekinfo_service.js
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ // Bug 1239622. The 1st of January after a leap year which ends with
+ // a Thursday belongs to week number 53 unless the start of week is
+ // set on Friday.
+ let wkst_wknum_date = [
+ [1, 53, "20210101T000000Z"], // Year 2021 affected by Bug 1239622
+ [5, 1, "20210101T000000Z"], //
+ [3, 53, "20490101T000000Z"], // Year 2049 affected by Bug 1239622
+ [5, 1, "20490101T000000Z"], //
+ [0, 1, "20170101T000000Z"], // Year that starts on Sunday ...
+ [3, 52, "20180101T000000Z"], // ... Monday
+ [0, 1, "20190101T000000Z"], // ... Tuesday
+ [4, 52, "20200101T000000Z"], // ... Wednesday
+ [0, 1, "20260101T000000Z"], // ... Thursday
+ [0, 53, "20270101T000000Z"], // ... Friday
+ [0, 52, "20280101T000000Z"],
+ ]; // ... Saturday
+
+ let savedWeekStart = Services.prefs.getIntPref("calendar.week.start", 0);
+ for (let [weekStart, weekNumber, dateString] of wkst_wknum_date) {
+ Services.prefs.setIntPref("calendar.week.start", weekStart);
+ let date = cal.createDateTime(dateString);
+ date.isDate = true;
+ let week = cal.weekInfoService.getWeekTitle(date);
+
+ equal(week, weekNumber, "Week number matches for " + dateString);
+ }
+ Services.prefs.setIntPref("calendar.week.start", savedWeekStart);
+}
diff --git a/comm/calendar/test/unit/xpcshell.ini b/comm/calendar/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..2d4639d0b6
--- /dev/null
+++ b/comm/calendar/test/unit/xpcshell.ini
@@ -0,0 +1,82 @@
+[DEFAULT]
+head = head.js
+prefs =
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.itip.updateInvitationForNewAttendeesOnly=true
+support-files = data/**
+tags = calendar
+
+[test_alarm.js]
+[test_alarmservice.js]
+[test_alarmutils.js]
+[test_attachment.js]
+[test_attendee.js]
+[test_auth_utils.js]
+[test_bug1199942.js]
+[test_bug1204255.js]
+[test_bug1209399.js]
+[test_bug1790339.js]
+[test_bug272411.js]
+[test_bug343792.js]
+[test_bug350845.js]
+[test_bug356207.js]
+[test_bug485571.js]
+[test_bug486186.js]
+[test_bug494140.js]
+[test_bug523860.js]
+[test_bug653924.js]
+[test_bug668222.js]
+[test_bug759324.js]
+[test_caldav_requests.js]
+[test_CalendarFileImporter.js]
+[test_calIteratorUtils.js]
+[test_calmgr.js]
+[test_calreadablestreamfactory.js]
+[test_calStorageHelpers.js]
+[test_data_bags.js]
+[test_datetime.js]
+[test_datetime_before_1970.js]
+[test_datetimeformatter.js]
+[test_deleted_items.js]
+[test_duration.js]
+[test_email_utils.js]
+[test_extract.js]
+[test_extract_parser.js]
+[test_extract_parser_parse.js]
+[test_extract_parser_service.js]
+[test_extract_parser_tokenize.js]
+[test_filter.js]
+[test_filter_mixin.js]
+[test_filter_tree_view.js]
+[test_freebusy.js]
+[test_freebusy_service.js]
+[test_hashedarray.js]
+[test_ics.js]
+[test_ics_parser.js]
+[test_ics_service.js]
+[test_imip.js]
+[test_invitationutils.js]
+[test_items.js]
+[test_itip_message_sender.js]
+[test_itip_utils.js]
+[test_l10n_utils.js]
+[test_lenient_parsing.js]
+[test_providers.js]
+[test_recur.js]
+[test_recurrence_utils.js]
+[test_relation.js]
+[test_rfc3339_parser.js]
+[test_startup_service.js]
+[test_storage.js]
+[test_storage_connection.js]
+[test_storage_get_items.js]
+[test_timezone.js]
+[test_timezone_changes.js]
+[test_timezone_definition.js]
+[test_transaction_manager.js]
+[test_unifinder_utils.js]
+[test_utils.js]
+[test_view_utils.js]
+[test_webcal.js]
+[test_weekinfo_service.js]