diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/components/contextualidentity/tests | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/contextualidentity/tests')
7 files changed, 704 insertions, 0 deletions
diff --git a/toolkit/components/contextualidentity/tests/browser/browser.toml b/toolkit/components/contextualidentity/tests/browser/browser.toml new file mode 100644 index 0000000000..0678c38171 --- /dev/null +++ b/toolkit/components/contextualidentity/tests/browser/browser.toml @@ -0,0 +1,3 @@ +[DEFAULT] + +["browser_closeContainerTabs.js"] diff --git a/toolkit/components/contextualidentity/tests/browser/browser_closeContainerTabs.js b/toolkit/components/contextualidentity/tests/browser/browser_closeContainerTabs.js new file mode 100644 index 0000000000..16fa28e306 --- /dev/null +++ b/toolkit/components/contextualidentity/tests/browser/browser_closeContainerTabs.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PromptTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromptTestUtils.sys.mjs" +); + +const BUILDER_URL = "https://example.com/document-builder.sjs?html="; +const PAGE_MARKUP = ` +<html> +<head> + <script> + window.onbeforeunload = function() { + return true; + }; + </script> +</head> +<body>TEST PAGE</body> +</html> +`; +const TEST_URL = BUILDER_URL + encodeURI(PAGE_MARKUP); + +add_task(async function test_skipPermitUnload() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.require_user_interaction_for_beforeunload", false]], + }); + + info("Create a test user context"); + const userContextId = ContextualIdentityService.create("test").userContextId; + + // Run tests in a new window to avoid affecting the main test window. + const win = await BrowserTestUtils.openNewBrowserWindow(); + registerCleanupFunction(async () => { + await BrowserTestUtils.closeWindow(win); + }); + + info("Create a tab owned by the test user context"); + const tab = await BrowserTestUtils.addTab(win.gBrowser, TEST_URL, { + userContextId, + }); + registerCleanupFunction(() => win.gBrowser.removeTab(tab)); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + info("Call closeContainerTabs without skipPermitUnload"); + const unloadDialogPromise = PromptTestUtils.handleNextPrompt( + tab.linkedBrowser, + { + modalType: Ci.nsIPrompt.MODAL_TYPE_CONTENT, + promptType: "confirmEx", + }, + // Click the cancel button. + { buttonNumClick: 1 } + ); + + const tabCountBeforeClose = win.gBrowser.tabs.length; + ContextualIdentityService.closeContainerTabs(userContextId); + + info("Wait for the unload dialog"); + await unloadDialogPromise; + is( + win.gBrowser.tabs.length, + tabCountBeforeClose, + "Tab was not closed because of the before unload prompt" + ); + + info("Call closeContainerTabs with skipPermitUnload"); + const closePromise = BrowserTestUtils.waitForTabClosing(tab); + ContextualIdentityService.closeContainerTabs(userContextId, { + skipPermitUnload: true, + }); + + info("Wait for the tab to be closed"); + await closePromise; + is( + win.gBrowser.tabs.length, + tabCountBeforeClose - 1, + "Tab was closed after ignoring the before unload prompt" + ); +}); diff --git a/toolkit/components/contextualidentity/tests/unit/test_basic.html b/toolkit/components/contextualidentity/tests/unit/test_basic.html new file mode 100644 index 0000000000..fbdbc772a9 --- /dev/null +++ b/toolkit/components/contextualidentity/tests/unit/test_basic.html @@ -0,0 +1,71 @@ +"use strict"; + +do_get_profile(); + +const { ContextualIdentityService } = ChromeUtils.importESModule( + "resource://gre/modules/ContextualIdentityService.sys.mjs" +); + +const TEST_STORE_FILE_NAME = "test-containers.json"; + +let cis; + +// Basic tests +add_task(function() { + ok(!!ContextualIdentityService, "ContextualIdentityService exists"); + + cis = ContextualIdentityService.createNewInstanceForTesting(TEST_STORE_FILE_NAME); + ok(!!cis, "We have our instance of ContextualIdentityService"); + + equal(cis.getPublicIdentities().length, 4, "By default, 4 containers."); + equal(cis.getPublicIdentityFromId(0), null, "No identity with id 0"); + + ok(!!cis.getPublicIdentityFromId(1), "Identity 1 exists"); + ok(!!cis.getPublicIdentityFromId(2), "Identity 2 exists"); + ok(!!cis.getPublicIdentityFromId(3), "Identity 3 exists"); + ok(!!cis.getPublicIdentityFromId(4), "Identity 4 exists"); +}); + +// Create a new identity +add_task(function() { + equal(cis.getPublicIdentities().length, 4, "By default, 4 containers."); + + let identity = cis.create("New Container", "Icon", "Color"); + ok(!!identity, "New container created"); + equal(identity.name, "New Container", "Name matches"); + equal(identity.icon, "Icon", "Icon matches"); + equal(identity.color, "Color", "Color matches"); + + equal(cis.getPublicIdentities().length, 5, "Expected 5 containers."); + + ok(!!cis.getPublicIdentityFromId(identity.userContextId), "Identity exists"); + equal(cis.getPublicIdentityFromId(identity.userContextId).name, "New Container", "Identity name is OK"); + equal(cis.getPublicIdentityFromId(identity.userContextId).icon, "Icon", "Identity icon is OK"); + equal(cis.getPublicIdentityFromId(identity.userContextId).color, "Color", "Identity color is OK"); + equal(cis.getUserContextLabel(identity.userContextId), "New Container", "Identity label is OK"); +}); + +// Remove an identity +add_task(function() { + equal(cis.getPublicIdentities().length, 5, "Expected 5 containers."); + + equal(cis.remove(-1), false, "cis.remove() returns false if identity doesn't exist."); + equal(cis.remove(1), true, "cis.remove() returns true if identity exists."); + + equal(cis.getPublicIdentities().length, 4, "Expected 4 containers."); +}); + +// Update an identity +add_task(function() { + ok(!!cis.getPublicIdentityFromId(2), "Identity 2 exists"); + + equal(cis.update(-1, "Container", "Icon", "Color"), false, "Update returns true if everything is OK"); + + equal(cis.update(2, "Container", "Icon", "Color"), true, "Update returns true if everything is OK"); + + ok(!!cis.getPublicIdentityFromId(2), "Identity exists"); + equal(cis.getPublicIdentityFromId(2).name, "Container", "Identity name is OK"); + equal(cis.getPublicIdentityFromId(2).icon, "Icon", "Identity icon is OK"); + equal(cis.getPublicIdentityFromId(2).color, "Color", "Identity color is OK"); + equal(cis.getUserContextLabel(2), "Container", "Identity label is OK"); +}); diff --git a/toolkit/components/contextualidentity/tests/unit/test_basic.js b/toolkit/components/contextualidentity/tests/unit/test_basic.js new file mode 100644 index 0000000000..0ee1869371 --- /dev/null +++ b/toolkit/components/contextualidentity/tests/unit/test_basic.js @@ -0,0 +1,233 @@ +"use strict"; + +const profileDir = do_get_profile(); + +const { ContextualIdentityService } = ChromeUtils.importESModule( + "resource://gre/modules/ContextualIdentityService.sys.mjs" +); + +const TEST_STORE_FILE_PATH = PathUtils.join( + profileDir.path, + "test-containers.json" +); + +let cis; + +// Basic tests +add_task(function () { + ok(!!ContextualIdentityService, "ContextualIdentityService exists"); + + cis = + ContextualIdentityService.createNewInstanceForTesting(TEST_STORE_FILE_PATH); + ok(!!cis, "We have our instance of ContextualIdentityService"); + + equal(cis.getPublicIdentities().length, 4, "By default, 4 containers."); + equal(cis.getPublicIdentityFromId(0), null, "No identity with id 0"); + + ok(!!cis.getPublicIdentityFromId(1), "Identity 1 exists"); + ok(!!cis.getPublicIdentityFromId(2), "Identity 2 exists"); + ok(!!cis.getPublicIdentityFromId(3), "Identity 3 exists"); + ok(!!cis.getPublicIdentityFromId(4), "Identity 4 exists"); + + Assert.deepEqual( + cis.getPublicUserContextIds(), + cis.getPublicIdentities().map(ident => ident.userContextId), + "getPublicUserContextIds has matching user context IDs" + ); +}); + +// Make sure we are not allowed to only use whitespaces as a container name +add_task(function () { + Assert.throws( + () => + cis.create( + "\u0009\u000B\u000C\u0020\u00A0\uFEFF\u000A\u000D\u2028\u2029", + "icon", + "color" + ), + /Contextual identity names cannot contain only whitespace./, + "Contextual identity names cannot contain only whitespace." + ); +}); + +add_task(function () { + ok(!!cis.getPublicIdentityFromId(2), "Identity 2 exists"); + Assert.throws( + () => + cis.update( + 2, + "\u0009\u000B\u000C\u0020\u00A0\uFEFF\u000A\u000D\u2028\u2029", + "icon", + "color" + ), + /Contextual identity names cannot contain only whitespace./, + "Contextual identity names cannot contain only whitespace." + ); +}); + +// Create a new identity +add_task(function () { + equal(cis.getPublicIdentities().length, 4, "By default, 4 containers."); + + let identity = cis.create("New Container", "Icon", "Color"); + ok(!!identity, "New container created"); + equal(identity.name, "New Container", "Name matches"); + equal(identity.icon, "Icon", "Icon matches"); + equal(identity.color, "Color", "Color matches"); + + equal(cis.getPublicIdentities().length, 5, "Expected 5 containers."); + + ok(!!cis.getPublicIdentityFromId(identity.userContextId), "Identity exists"); + equal( + cis.getPublicIdentityFromId(identity.userContextId).name, + "New Container", + "Identity name is OK" + ); + equal( + cis.getPublicIdentityFromId(identity.userContextId).icon, + "Icon", + "Identity icon is OK" + ); + equal( + cis.getPublicIdentityFromId(identity.userContextId).color, + "Color", + "Identity color is OK" + ); + equal( + cis.getUserContextLabel(identity.userContextId), + "New Container", + "Identity label is OK" + ); + + Assert.deepEqual( + cis.getPublicUserContextIds(), + cis.getPublicIdentities().map(ident => ident.userContextId), + "getPublicUserContextIds has matching user context IDs" + ); + + // Remove an identity + equal( + cis.remove(-1), + false, + "cis.remove() returns false if identity doesn't exist." + ); + equal(cis.remove(1), true, "cis.remove() returns true if identity exists."); + + equal(cis.getPublicIdentities().length, 4, "Expected 4 containers."); + Assert.deepEqual( + cis.getPublicUserContextIds(), + cis.getPublicIdentities().map(ident => ident.userContextId), + "getPublicUserContextIds has matching user context IDs" + ); +}); + +// Reorder identities +add_task(function () { + equal(cis.getPublicIdentities().length, 4, "By default, 4 containers."); + // Get whatever the initial order is + const [id0, id1, id2, id3] = cis.getPublicUserContextIds(); + + ok(cis.move([id3], 0), "Moving one valid id"); + Assert.deepEqual( + cis.getPublicUserContextIds(), + [id3, id0, id1, id2], + "Moving one valid ID works" + ); + + ok(cis.move([id1, id2], 1), "Moving several valid ids"); + Assert.deepEqual( + cis.getPublicUserContextIds(), + [id3, id1, id2, id0], + "Moving several valid IDs works" + ); + + ok(!cis.move([100], 0), "Moving non-existing ids"); + Assert.deepEqual( + cis.getPublicUserContextIds(), + [id3, id1, id2, id0], + "Moving only non-existing IDs leaves list unchanged" + ); + + ok(cis.move([100, id1], 1), "Moving non-existing and existing ids"); + Assert.deepEqual( + cis.getPublicUserContextIds(), + [id3, id1, id2, id0], + "Moving existing and non-existing IDs ignores non-existing ones" + ); + + ok(cis.move([id1, 100], 1), "Moving existing and non-existing ids"); + Assert.deepEqual( + cis.getPublicUserContextIds(), + [id3, id1, id2, id0], + "Moving existing and non-existing IDs ignores non-existing ones" + ); + + ok(cis.move([id2], -1), "Moving to -1"); + Assert.deepEqual( + cis.getPublicUserContextIds(), + [id3, id1, id0, id2], + "Moving to -1 works" + ); + + ok(!cis.move([id3], -10), "Moving to other negative positions"); + Assert.deepEqual( + cis.getPublicUserContextIds(), + [id3, id1, id0, id2], + "Moving to other negative positions leaves list unchanged" + ); + + ok(cis.move([id3], 3), "Moving to last public position"); + Assert.deepEqual( + cis.getPublicUserContextIds(), + [id1, id0, id2, id3], + "Moving to last position correctly skips private context ids" + ); + + ok(cis.move([id1, id2], 1), "Moving past current position"); + Assert.deepEqual( + cis.getPublicUserContextIds(), + [id0, id1, id2, id3], + "Target position is index in resulting list" + ); + + ok(cis.move([id2, id2], 2), "Moving duplicate ids"); + Assert.deepEqual( + cis.getPublicUserContextIds(), + [id0, id1, id2, id3], + "Resulting list does not contain duplicate ids" + ); + + ok(!cis.move([], 2), "Moving empty list"); + Assert.deepEqual( + cis.getPublicUserContextIds(), + [id0, id1, id2, id3], + "Resulting list does not contain duplicate ids" + ); +}); + +// Update an identity +add_task(function () { + ok(!!cis.getPublicIdentityFromId(2), "Identity 2 exists"); + + equal( + cis.update(-1, "Container", "Icon", "Color"), + false, + "Update returns false if the identity doesn't exist" + ); + + equal( + cis.update(2, "Container", "Icon", "Color"), + true, + "Update returns true if everything is OK" + ); + + ok(!!cis.getPublicIdentityFromId(2), "Identity exists"); + equal( + cis.getPublicIdentityFromId(2).name, + "Container", + "Identity name is OK" + ); + equal(cis.getPublicIdentityFromId(2).icon, "Icon", "Identity icon is OK"); + equal(cis.getPublicIdentityFromId(2).color, "Color", "Identity color is OK"); + equal(cis.getUserContextLabel(2), "Container", "Identity label is OK"); +}); diff --git a/toolkit/components/contextualidentity/tests/unit/test_corruptedFile.js b/toolkit/components/contextualidentity/tests/unit/test_corruptedFile.js new file mode 100644 index 0000000000..9dfa13ae2e --- /dev/null +++ b/toolkit/components/contextualidentity/tests/unit/test_corruptedFile.js @@ -0,0 +1,169 @@ +"use strict"; + +const profileDir = do_get_profile(); + +const { ContextualIdentityService } = ChromeUtils.importESModule( + "resource://gre/modules/ContextualIdentityService.sys.mjs" +); + +const TEST_STORE_FILE_PATH = PathUtils.join( + profileDir.path, + "test-containers.json" +); + +const BASE_URL = "http://example.org/"; + +const COOKIE = { + host: BASE_URL, + path: "/", + name: "test", + value: "yes", + isSecure: false, + isHttpOnly: false, + isSession: true, + expiry: 2145934800, +}; + +function createCookie(userContextId) { + Services.cookies.add( + COOKIE.host, + COOKIE.path, + COOKIE.name, + COOKIE.value, + COOKIE.isSecure, + COOKIE.isHttpOnly, + COOKIE.isSession, + COOKIE.expiry, + { userContextId }, + Ci.nsICookie.SAMESITE_NONE, + Ci.nsICookie.SCHEME_HTTP + ); +} + +function hasCookie(userContextId) { + let found = false; + for (let cookie of Services.cookies.getCookiesFromHost(BASE_URL, { + userContextId, + })) { + if (cookie.originAttributes.userContextId == userContextId) { + found = true; + break; + } + } + return found; +} + +// Correpted file should delete all. +add_task(async function corruptedFile() { + const thumbnailPrivateId = ContextualIdentityService._defaultIdentities + .filter(identity => identity.name === "userContextIdInternal.thumbnail") + .pop().userContextId; + + const webextStoragePrivateId = ContextualIdentityService._defaultIdentities + .filter( + identity => identity.name === "userContextIdInternal.webextStorageLocal" + ) + .pop().userContextId; + + // Create a cookie in the default Firefox identity (userContextId 0). + createCookie(0); + + // Create a cookie in the userContextId 1. + createCookie(1); + + // Create a cookie in the thumbnail private userContextId. + createCookie(thumbnailPrivateId); + + // Create a cookie in the extension storage private userContextId. + createCookie(webextStoragePrivateId); + + ok(hasCookie(0), "We have the new cookie the default firefox identity!"); + ok(hasCookie(1), "We have the new cookie in a public identity!"); + ok( + hasCookie(thumbnailPrivateId), + "We have the new cookie in the thumbnail private identity!" + ); + ok( + hasCookie(webextStoragePrivateId), + "We have the new cookie in the extension storage private identity!" + ); + + // Let's create a corrupted file. + await IOUtils.writeUTF8(TEST_STORE_FILE_PATH, "{ vers", { + tmpPath: TEST_STORE_FILE_PATH + ".tmp", + }); + + let cis = + ContextualIdentityService.createNewInstanceForTesting(TEST_STORE_FILE_PATH); + ok(!!cis, "We have our instance of ContextualIdentityService"); + + equal( + cis.getPublicIdentities().length, + 4, + "We should have the default public identities" + ); + + Assert.deepEqual( + cis.getPublicUserContextIds(), + cis.getPublicIdentities().map(identity => identity.userContextId), + "getPublicUserContextIds has matching user context IDs" + ); + + // Verify that when the containers.json file is being rebuilt, the computed lastUserContextId + // is the expected one. + equal( + cis._lastUserContextId, + thumbnailPrivateId, + "Expect cis._lastUserContextId to be equal to the thumbnails userContextId" + ); + + const privThumbnailIdentity = cis.getPrivateIdentity( + "userContextIdInternal.thumbnail" + ); + equal( + privThumbnailIdentity && privThumbnailIdentity.userContextId, + thumbnailPrivateId, + "We should have the default thumbnail private identity" + ); + + const privWebextStorageIdentity = cis.getPrivateIdentity( + "userContextIdInternal.webextStorageLocal" + ); + equal( + privWebextStorageIdentity && privWebextStorageIdentity.userContextId, + webextStoragePrivateId, + "We should have the default extensions storage.local private identity" + ); + + // Cookie is gone! + ok( + !hasCookie(1), + "We should not have the new cookie in the userContextId 1!" + ); + + // The data stored in the Firefox default userContextId (0), should have not be cleared. + ok( + hasCookie(0), + "We should not have the new cookie in the default Firefox identity!" + ); + + // The data stored in the non-public userContextId (e.g. thumbnails private identity) + // should have not be cleared. + ok( + hasCookie(thumbnailPrivateId), + "We should have the new cookie in the thumbnail private userContextId!" + ); + ok( + hasCookie(webextStoragePrivateId), + "We should have the new cookie in the extension storage private userContextId!" + ); + + // Verify the version of the newly created containers.json file. + cis.save(); + const stateFileText = await IOUtils.readUTF8(TEST_STORE_FILE_PATH); + equal( + JSON.parse(stateFileText).version, + cis.LAST_CONTAINERS_JSON_VERSION, + "Expect the new containers.json file to have the expected version" + ); +}); diff --git a/toolkit/components/contextualidentity/tests/unit/test_migratedFile.js b/toolkit/components/contextualidentity/tests/unit/test_migratedFile.js new file mode 100644 index 0000000000..2c7a37f19b --- /dev/null +++ b/toolkit/components/contextualidentity/tests/unit/test_migratedFile.js @@ -0,0 +1,136 @@ +"use strict"; + +const profileDir = do_get_profile(); + +const { ContextualIdentityService } = ChromeUtils.importESModule( + "resource://gre/modules/ContextualIdentityService.sys.mjs" +); + +const TEST_STORE_FILE_PATH = PathUtils.join( + profileDir.path, + "test-containers.json" +); + +// Test the containers JSON file migrations. +add_task(async function migratedFile() { + // Let's create a file that has to be migrated. + const oldFileData = { + version: 2, + lastUserContextId: 6, + identities: [ + { + userContextId: 1, + public: true, + icon: "fingerprint", + color: "blue", + l10nID: "userContextPersonal.label", + accessKey: "userContextPersonal.accesskey", + }, + { + userContextId: 2, + public: true, + icon: "briefcase", + color: "orange", + l10nID: "userContextWork.label", + accessKey: "userContextWork.accesskey", + }, + { + userContextId: 3, + public: true, + icon: "dollar", + color: "green", + l10nID: "userContextBanking.label", + accessKey: "userContextBanking.accesskey", + }, + { + userContextId: 4, + public: true, + icon: "cart", + color: "pink", + l10nID: "userContextShopping.label", + accessKey: "userContextShopping.accesskey", + }, + { + userContextId: 5, + public: false, + icon: "", + color: "", + name: "userContextIdInternal.thumbnail", + accessKey: "", + }, + { + userContextId: 6, + public: true, + icon: "cart", + color: "ping", + name: "Custom user-created identity", + }, + ], + }; + + await IOUtils.writeJSON(TEST_STORE_FILE_PATH, oldFileData, { + tmpPath: TEST_STORE_FILE_PATH + ".tmp", + }); + + let cis = + ContextualIdentityService.createNewInstanceForTesting(TEST_STORE_FILE_PATH); + ok(!!cis, "We have our instance of ContextualIdentityService"); + + // Check that the custom user-created identity exists. + + const expectedPublicLength = oldFileData.identities.filter( + identity => identity.public + ).length; + const publicIdentities = cis.getPublicIdentities(); + const oldLastIdentity = + oldFileData.identities[oldFileData.identities.length - 1]; + const customUserCreatedIdentity = publicIdentities + .filter(identity => identity.name === oldLastIdentity.name) + .pop(); + + equal( + publicIdentities.length, + expectedPublicLength, + "We should have the expected number of public identities" + ); + ok(!!customUserCreatedIdentity, "Got the custom user-created identity"); + + Assert.deepEqual( + cis.getPublicUserContextIds(), + cis.getPublicIdentities().map(identity => identity.userContextId), + "getPublicUserContextIds has matching user context IDs" + ); + + // Check that the reserved userContextIdInternal.webextStorageLocal identity exists. + + const webextStorageLocalPrivateId = + ContextualIdentityService._defaultIdentities + .filter( + identity => identity.name === "userContextIdInternal.webextStorageLocal" + ) + .pop().userContextId; + + const privWebExtStorageLocal = cis.getPrivateIdentity( + "userContextIdInternal.webextStorageLocal" + ); + equal( + privWebExtStorageLocal && privWebExtStorageLocal.userContextId, + webextStorageLocalPrivateId, + "We should have the default userContextIdInternal.webextStorageLocal private identity" + ); + + // Check that all StringBundle references are replaced by Fluent references. + + equal( + cis + .getPublicIdentities() + .filter(identity => identity.l10nID || identity.accessKey).length, + 0, + "No StringBundle l10nID or accessKey should be set" + ); + equal( + cis.getPublicIdentities().filter(identity => identity.l10nId).length, + oldFileData.identities.filter(identity => identity.l10nID).length, + "All StringBundle references should be replaced by Fluent references" + ); +}); diff --git a/toolkit/components/contextualidentity/tests/unit/xpcshell.toml b/toolkit/components/contextualidentity/tests/unit/xpcshell.toml new file mode 100644 index 0000000000..733734eba2 --- /dev/null +++ b/toolkit/components/contextualidentity/tests/unit/xpcshell.toml @@ -0,0 +1,11 @@ +[DEFAULT] +firefox-appdir = "browser" + +["test_basic.js"] +skip-if = ["appname == 'thunderbird'"] + +["test_corruptedFile.js"] +skip-if = ["appname == 'thunderbird'"] + +["test_migratedFile.js"] +skip-if = ["appname == 'thunderbird'"] |