500 lines
15 KiB
JavaScript
500 lines
15 KiB
JavaScript
/* Any copyright is dedicated to the Public Domain.
|
|
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
|
"use strict";
|
|
|
|
const { AMBrowserExtensionsImport } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/AddonManager.sys.mjs"
|
|
);
|
|
|
|
const mockAddonRepository = ({
|
|
addons = [],
|
|
expectedBrowserID = null,
|
|
expectedExtensionIDs = null,
|
|
matchedIDs = [],
|
|
unmatchedIDs = [],
|
|
}) => {
|
|
return {
|
|
async getMappedAddons(browserID, extensionIDs) {
|
|
if (expectedBrowserID) {
|
|
Assert.equal(browserID, expectedBrowserID, "expected browser ID");
|
|
}
|
|
if (expectedExtensionIDs) {
|
|
Assert.deepEqual(
|
|
extensionIDs,
|
|
expectedExtensionIDs,
|
|
"expected extension IDs"
|
|
);
|
|
}
|
|
|
|
return Promise.resolve({
|
|
addons,
|
|
matchedIDs,
|
|
unmatchedIDs,
|
|
});
|
|
},
|
|
};
|
|
};
|
|
|
|
const assertStageInstallsResult = (result, importedAddonIDs) => {
|
|
// Sort the results to always assert the elements in the same order.
|
|
result.importedAddonIDs.sort();
|
|
Assert.deepEqual(result, { importedAddonIDs }, "expected results");
|
|
Assert.ok(
|
|
AMBrowserExtensionsImport.hasPendingImportedAddons,
|
|
"expected pending imported add-ons"
|
|
);
|
|
};
|
|
|
|
const cancelInstalls = async importedAddonIDs => {
|
|
const promiseTopic = TestUtils.topicObserved(
|
|
"webextension-imported-addons-cancelled"
|
|
);
|
|
// We want to verify that we received a `onInstallCancelled` event per
|
|
// (cancelled) install (i.e. per imported add-on).
|
|
const cancelledPromises = importedAddonIDs.map(id =>
|
|
AddonTestUtils.promiseInstallEvent(
|
|
"onInstallCancelled",
|
|
(install, cancelledByUser) => {
|
|
Assert.equal(cancelledByUser, false, "Not user-cancelled");
|
|
return install.addon.id == id;
|
|
}
|
|
)
|
|
);
|
|
await AMBrowserExtensionsImport.cancelInstalls();
|
|
await Promise.all([promiseTopic, ...cancelledPromises]);
|
|
Assert.ok(
|
|
!AMBrowserExtensionsImport.hasPendingImportedAddons,
|
|
"expected no pending imported add-ons"
|
|
);
|
|
};
|
|
|
|
const TEST_SERVER = createHttpServer({ hosts: ["example.com"] });
|
|
|
|
const ADDONS = {
|
|
ext1: {
|
|
manifest: {
|
|
name: "Ext 1",
|
|
version: "1.0",
|
|
browser_specific_settings: { gecko: { id: "ff@ext-1" } },
|
|
},
|
|
},
|
|
ext2: {
|
|
manifest: {
|
|
name: "Ext 2",
|
|
version: "1.0",
|
|
browser_specific_settings: { gecko: { id: "ff@ext-2" } },
|
|
},
|
|
},
|
|
};
|
|
// Populated in `setup()`.
|
|
const XPIS = {};
|
|
// Populated in `setup()`.
|
|
const ADDON_SEARCH_RESULTS = {};
|
|
|
|
AddonTestUtils.init(this);
|
|
AddonTestUtils.overrideCertDB();
|
|
AddonTestUtils.createAppInfo(
|
|
"xpcshell@tests.mozilla.org",
|
|
"XPCShell",
|
|
"42",
|
|
"42"
|
|
);
|
|
|
|
add_setup(async function setup() {
|
|
for (const [name, data] of Object.entries(ADDONS)) {
|
|
XPIS[name] = AddonTestUtils.createTempWebExtensionFile(data);
|
|
TEST_SERVER.registerFile(`/addons/${name}.xpi`, XPIS[name]);
|
|
|
|
ADDON_SEARCH_RESULTS[name] = {
|
|
id: data.manifest.browser_specific_settings.gecko.id,
|
|
name: data.name,
|
|
version: data.version,
|
|
sourceURI: Services.io.newURI(`http://example.com/addons/${name}.xpi`),
|
|
icons: {},
|
|
};
|
|
}
|
|
|
|
await AddonTestUtils.promiseStartupManager();
|
|
|
|
// FOG needs a profile directory to put its data in.
|
|
const profileDir = do_get_profile();
|
|
|
|
// FOG needs to be initialized in order for data to flow.
|
|
Services.fog.initializeFOG();
|
|
|
|
// When we stage installs and then cancel them, `XPIInstall` won't be able to
|
|
// remove the staging directory (which is expected to be empty) until the
|
|
// next restart. This causes an `AddonTestUtils` assertion to fail because we
|
|
// don't expect any staging directory at the end of the tests. That's why we
|
|
// remove this directory in the cleanup function defined below.
|
|
//
|
|
// We only remove the staging directory and that will only works if the
|
|
// directory is empty, otherwise an unchaught error will be thrown (on
|
|
// purpose).
|
|
registerCleanupFunction(() => {
|
|
const stagingDir = profileDir.clone();
|
|
stagingDir.append("extensions");
|
|
stagingDir.append("staged");
|
|
stagingDir.exists() && stagingDir.remove(/* recursive */ false);
|
|
|
|
// Clear the add-on repository override.
|
|
AMBrowserExtensionsImport._addonRepository = null;
|
|
});
|
|
});
|
|
|
|
add_task(async function test_stage_and_complete_installs() {
|
|
const browserID = "some-browser-id";
|
|
const extensionIDs = ["ext-1", "ext-2"];
|
|
AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
|
|
addons: Object.values(ADDON_SEARCH_RESULTS),
|
|
expectedBrowserID: browserID,
|
|
expectedExtensionIDs: extensionIDs,
|
|
});
|
|
const importedAddonIDs = ["ff@ext-1", "ff@ext-2"];
|
|
|
|
let promiseTopic = TestUtils.topicObserved(
|
|
"webextension-imported-addons-pending"
|
|
);
|
|
const result = await AMBrowserExtensionsImport.stageInstalls(
|
|
browserID,
|
|
extensionIDs
|
|
);
|
|
await promiseTopic;
|
|
assertStageInstallsResult(result, importedAddonIDs);
|
|
|
|
// Make sure the prompt handler is the one from `AMBrowserExtensionsImport`
|
|
// since we don't want to show a permission prompt during an import.
|
|
for (const install of AMBrowserExtensionsImport._pendingInstallsMap.values()) {
|
|
Assert.equal(
|
|
install.promptHandler,
|
|
AMBrowserExtensionsImport._installPromptHandler,
|
|
"expected prompt handler to be the one set by AMBrowserExtensionsImport"
|
|
);
|
|
}
|
|
|
|
promiseTopic = TestUtils.topicObserved(
|
|
"webextension-imported-addons-complete"
|
|
);
|
|
const endedPromises = importedAddonIDs.map(id =>
|
|
AddonTestUtils.promiseInstallEvent(
|
|
"onInstallEnded",
|
|
install => install.addon.id == id
|
|
)
|
|
);
|
|
await AMBrowserExtensionsImport.completeInstalls();
|
|
await Promise.all([promiseTopic, ...endedPromises]);
|
|
|
|
Assert.ok(
|
|
!AMBrowserExtensionsImport.hasPendingImportedAddons,
|
|
"expected no pending imported add-ons"
|
|
);
|
|
Assert.ok(
|
|
!AMBrowserExtensionsImport._canCompleteOrCancelInstalls &&
|
|
!AMBrowserExtensionsImport._importInProgress,
|
|
"expected internal state to be consistent"
|
|
);
|
|
|
|
for (const id of importedAddonIDs) {
|
|
const addon = await AddonManager.getAddonByID(id);
|
|
Assert.ok(addon.isActive, `expected add-on "${id}" to be enabled`);
|
|
await addon.uninstall();
|
|
}
|
|
});
|
|
|
|
add_task(async function test_stage_and_cancel_installs() {
|
|
const browserID = "some-browser-id";
|
|
const extensionIDs = ["ext-1", "ext-2"];
|
|
AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
|
|
addons: Object.values(ADDON_SEARCH_RESULTS),
|
|
expectedBrowserID: browserID,
|
|
expectedExtensionIDs: extensionIDs,
|
|
});
|
|
const importedAddonIDs = ["ff@ext-1", "ff@ext-2"];
|
|
|
|
const promiseTopic = TestUtils.topicObserved(
|
|
"webextension-imported-addons-pending"
|
|
);
|
|
const result = await AMBrowserExtensionsImport.stageInstalls(
|
|
browserID,
|
|
extensionIDs
|
|
);
|
|
await promiseTopic;
|
|
assertStageInstallsResult(result, importedAddonIDs);
|
|
|
|
await cancelInstalls(importedAddonIDs);
|
|
});
|
|
|
|
add_task(async function test_stageInstalls_telemetry() {
|
|
const browserID = "some-browser-id";
|
|
const extensionIDs = ["ext-1", "ext-2"];
|
|
const unmatchedIDs = ["unmatched-1", "unmatched-2"];
|
|
AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
|
|
addons: Object.values(ADDON_SEARCH_RESULTS),
|
|
expectedBrowserID: browserID,
|
|
expectedExtensionIDs: extensionIDs,
|
|
matchedIDs: ["ext-1", "ext-2"],
|
|
unmatchedIDs,
|
|
});
|
|
const importedAddonIDs = ["ff@ext-1", "ff@ext-2"];
|
|
|
|
const promiseTopic = TestUtils.topicObserved(
|
|
"webextension-imported-addons-pending"
|
|
);
|
|
const result = await AMBrowserExtensionsImport.stageInstalls(
|
|
browserID,
|
|
extensionIDs
|
|
);
|
|
await promiseTopic;
|
|
assertStageInstallsResult(result, importedAddonIDs);
|
|
|
|
Assert.deepEqual(
|
|
Glean.browserMigration.matchedExtensions.testGetValue(),
|
|
extensionIDs
|
|
);
|
|
Assert.deepEqual(
|
|
Glean.browserMigration.unmatchedExtensions.testGetValue(),
|
|
unmatchedIDs
|
|
);
|
|
|
|
await cancelInstalls(importedAddonIDs);
|
|
});
|
|
|
|
add_task(async function test_call_stageInstalls_twice() {
|
|
const browserID = "some-browser-id";
|
|
const extensionIDs = ["ext-1"];
|
|
AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
|
|
// Only return one extension.
|
|
addons: Object.values(ADDON_SEARCH_RESULTS).slice(0, 1),
|
|
expectedBrowserID: browserID,
|
|
expectedExtensionIDs: extensionIDs,
|
|
});
|
|
const importedAddonIDs = ["ff@ext-1"];
|
|
|
|
const promiseTopic = TestUtils.topicObserved(
|
|
"webextension-imported-addons-pending"
|
|
);
|
|
let result = await AMBrowserExtensionsImport.stageInstalls(
|
|
browserID,
|
|
extensionIDs
|
|
);
|
|
await promiseTopic;
|
|
assertStageInstallsResult(result, importedAddonIDs);
|
|
|
|
await Assert.rejects(
|
|
AMBrowserExtensionsImport.stageInstalls(browserID, []),
|
|
/Cannot stage installs because there are pending imported add-ons/,
|
|
"expected rejection because there are pending imported add-ons"
|
|
);
|
|
|
|
// Cancel the installs for the previous import.
|
|
await cancelInstalls(importedAddonIDs);
|
|
|
|
// We should now be able to stage installs again.
|
|
result = await AMBrowserExtensionsImport.stageInstalls(
|
|
browserID,
|
|
extensionIDs
|
|
);
|
|
assertStageInstallsResult(result, importedAddonIDs);
|
|
|
|
await cancelInstalls(importedAddonIDs);
|
|
});
|
|
|
|
add_task(async function test_call_stageInstalls_no_addons() {
|
|
const browserID = "some-browser-id";
|
|
const extensionIDs = ["ext-123456"];
|
|
AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
|
|
// Returns no mapped add-ons.
|
|
addons: [],
|
|
expectedBrowserID: browserID,
|
|
expectedExtensionIDs: extensionIDs,
|
|
});
|
|
|
|
const result = await AMBrowserExtensionsImport.stageInstalls(
|
|
browserID,
|
|
extensionIDs
|
|
);
|
|
|
|
Assert.deepEqual(result, { importedAddonIDs: [] }, "expected result");
|
|
Assert.ok(
|
|
!AMBrowserExtensionsImport.hasPendingImportedAddons,
|
|
"expected no pending imported add-ons"
|
|
);
|
|
Assert.ok(
|
|
!AMBrowserExtensionsImport._canCompleteOrCancelInstalls &&
|
|
!AMBrowserExtensionsImport._importInProgress,
|
|
"expected internal state to be consistent"
|
|
);
|
|
});
|
|
|
|
add_task(async function test_import_twice() {
|
|
const browserID = "some-browser-id";
|
|
const extensionIDs = ["ext-1", "ext-2"];
|
|
AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
|
|
addons: Object.values(ADDON_SEARCH_RESULTS),
|
|
expectedBrowserID: browserID,
|
|
expectedExtensionIDs: extensionIDs,
|
|
});
|
|
const importedAddonIDs = ["ff@ext-1", "ff@ext-2"];
|
|
|
|
let promiseTopic = TestUtils.topicObserved(
|
|
"webextension-imported-addons-pending"
|
|
);
|
|
let result = await AMBrowserExtensionsImport.stageInstalls(
|
|
browserID,
|
|
extensionIDs
|
|
);
|
|
await promiseTopic;
|
|
assertStageInstallsResult(result, importedAddonIDs);
|
|
|
|
// Finalize the installs.
|
|
promiseTopic = TestUtils.topicObserved(
|
|
"webextension-imported-addons-complete"
|
|
);
|
|
const endedPromises = importedAddonIDs.map(id =>
|
|
AddonTestUtils.promiseInstallEvent(
|
|
"onInstallEnded",
|
|
install => install.addon.id == id
|
|
)
|
|
);
|
|
await AMBrowserExtensionsImport.completeInstalls();
|
|
await Promise.all([promiseTopic, ...endedPromises]);
|
|
|
|
// Try to import the same add-ons again. Because these add-ons are already
|
|
// installed, we shouldn't re-import them again.
|
|
result = await AMBrowserExtensionsImport.stageInstalls(
|
|
browserID,
|
|
extensionIDs
|
|
);
|
|
Assert.deepEqual(result, { importedAddonIDs: [] }, "expected result");
|
|
Assert.ok(
|
|
!AMBrowserExtensionsImport.hasPendingImportedAddons,
|
|
"expected no pending imported add-ons"
|
|
);
|
|
Assert.ok(
|
|
!AMBrowserExtensionsImport._canCompleteOrCancelInstalls &&
|
|
!AMBrowserExtensionsImport._importInProgress,
|
|
"expected internal state to be consistent"
|
|
);
|
|
|
|
for (const id of importedAddonIDs) {
|
|
const addon = await AddonManager.getAddonByID(id);
|
|
Assert.ok(addon.isActive, `expected add-on "${id}" to be enabled`);
|
|
await addon.uninstall();
|
|
}
|
|
});
|
|
|
|
add_task(async function test_call_cancelInstalls_without_pending_import() {
|
|
await Assert.rejects(
|
|
AMBrowserExtensionsImport.cancelInstalls(),
|
|
/No import in progress/,
|
|
"expected an error"
|
|
);
|
|
});
|
|
|
|
add_task(async function test_call_completeInstalls_without_pending_import() {
|
|
await Assert.rejects(
|
|
AMBrowserExtensionsImport.completeInstalls(),
|
|
/No import in progress/,
|
|
"expected an error"
|
|
);
|
|
});
|
|
|
|
add_task(async function test_stage_installs_with_download_aborted() {
|
|
const browserID = "some-browser-id";
|
|
const extensionIDs = ["ext-1", "ext-2"];
|
|
AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
|
|
addons: Object.values(ADDON_SEARCH_RESULTS),
|
|
expectedBrowserID: browserID,
|
|
expectedExtensionIDs: extensionIDs,
|
|
});
|
|
const importedAddonIDs = ["ff@ext-2"];
|
|
|
|
// This listener will be triggered once (for the first imported add-on). Its
|
|
// goal is to cancel the download of an imported add-on and make sure it
|
|
// doesn't break everything. We still expect the second add-on to import to
|
|
// be staged for install.
|
|
const onNewInstall = AddonTestUtils.promiseInstallEvent(
|
|
"onNewInstall",
|
|
install => {
|
|
install.addListener({
|
|
onDownloadStarted: () => false,
|
|
});
|
|
return true;
|
|
}
|
|
);
|
|
const promiseTopic = TestUtils.topicObserved(
|
|
"webextension-imported-addons-pending"
|
|
);
|
|
const result = await AMBrowserExtensionsImport.stageInstalls(
|
|
browserID,
|
|
extensionIDs
|
|
);
|
|
await Promise.all([onNewInstall, promiseTopic]);
|
|
assertStageInstallsResult(result, importedAddonIDs);
|
|
|
|
Assert.ok(
|
|
AMBrowserExtensionsImport.hasPendingImportedAddons,
|
|
"expected pending imported add-ons"
|
|
);
|
|
Assert.ok(
|
|
AMBrowserExtensionsImport._canCompleteOrCancelInstalls &&
|
|
AMBrowserExtensionsImport._importInProgress,
|
|
"expected internal state to be consistent"
|
|
);
|
|
|
|
// Let's cancel the pending installs.
|
|
await cancelInstalls(importedAddonIDs);
|
|
});
|
|
|
|
add_task(async function test_stageInstalls_then_restart_addonManager() {
|
|
const browserID = "some-browser-id";
|
|
const extensionIDs = ["ext-1", "ext-2"];
|
|
const EXPECTED_SOURCE_URI_SPECS = {
|
|
["ff@ext-1"]: "http://example.com/addons/ext1.xpi",
|
|
["ff@ext-2"]: "http://example.com/addons/ext2.xpi",
|
|
};
|
|
AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
|
|
addons: Object.values(ADDON_SEARCH_RESULTS),
|
|
expectedBrowserID: browserID,
|
|
expectedExtensionIDs: extensionIDs,
|
|
});
|
|
const importedAddonIDs = ["ff@ext-1", "ff@ext-2"];
|
|
|
|
let promiseTopic = TestUtils.topicObserved(
|
|
"webextension-imported-addons-pending"
|
|
);
|
|
let result = await AMBrowserExtensionsImport.stageInstalls(
|
|
browserID,
|
|
extensionIDs
|
|
);
|
|
await promiseTopic;
|
|
assertStageInstallsResult(result, importedAddonIDs);
|
|
|
|
// We restart the add-ons manager to simulate a browser restart. It isn't
|
|
// quite the same but that should be enough.
|
|
await AddonTestUtils.promiseRestartManager();
|
|
|
|
for (const id of importedAddonIDs) {
|
|
const addon = await AddonManager.getAddonByID(id);
|
|
Assert.ok(addon.isActive, `expected add-on "${id}" to be enabled`);
|
|
// Verify that the sourceURI and installTelemetryInfo also match
|
|
// the values expected for the addons installed from the browser
|
|
// imports install flow.
|
|
Assert.deepEqual(
|
|
{
|
|
id: addon.id,
|
|
sourceURI: addon.sourceURI?.spec,
|
|
installTelemetryInfo: addon.installTelemetryInfo,
|
|
},
|
|
{
|
|
id,
|
|
sourceURI: EXPECTED_SOURCE_URI_SPECS[id],
|
|
installTelemetryInfo: {
|
|
source: AMBrowserExtensionsImport.TELEMETRY_SOURCE,
|
|
},
|
|
},
|
|
"Got the expected AddonWrapper properties"
|
|
);
|
|
await addon.uninstall();
|
|
}
|
|
});
|