Bug 1812704 - Wire up the new MigrationWizard to MigrationUtils to let it perform migrations. r=NeilDeakin

Differential Revision: https://phabricator.services.mozilla.com/D167998
This commit is contained in:
Mike Conley 2023-01-31 15:30:58 +00:00
parent f50327e5e8
commit c73afb6eef
11 changed files with 476 additions and 47 deletions

View File

@ -605,6 +605,7 @@ let JSWINDOWACTORS = {
esModuleURI: "resource:///actors/MigrationWizardChild.sys.mjs",
events: {
"MigrationWizard:Init": { wantUntrusted: true },
"MigrationWizard:BeginMigration": { wantsUntrusted: true },
},
},

View File

@ -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/. */
import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs",
});
/**
* A stub of a migrator used for automated testing only.
*/
export class InternalTestingProfileMigrator extends MigratorBase {
static get key() {
return "internal-testing";
}
static get displayNameL10nID() {
return "Internal Testing Migrator";
}
// We will create a single MigratorResource for each resource type that
// just immediately reports a successful migration.
getResources() {
return Object.values(lazy.MigrationUtils.resourceTypes).map(type => {
return {
type,
migrate: callback => {
callback(true /* success */);
},
};
});
}
/**
* Clears the MigratorResources that are normally cached by the
* MigratorBase parent class after a call to getResources. This
* allows our automated tests to try different resource availability
* scenarios between tests.
*/
flushResourceCache() {
this._resourcesByProfile = null;
}
}

View File

@ -103,6 +103,11 @@ const MIGRATOR_MODULES = Object.freeze({
moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
platforms: ["macosx", "win"],
},
InternalTestingProfileMigrator: {
moduleURI: "resource:///modules/InternalTestingProfileMigrator.sys.mjs",
platforms: ["linux", "macosx", "win"],
},
});
/**

View File

@ -21,18 +21,48 @@ export class MigrationWizardChild extends JSWindowActorChild {
* @returns {Promise}
*/
async handleEvent(event) {
if (event.type == "MigrationWizard:Init") {
this.#wizardEl = event.target;
let migrators = await this.sendQuery("GetAvailableMigrators");
switch (event.type) {
case "MigrationWizard:Init": {
this.#wizardEl = event.target;
let migrators = await this.sendQuery("GetAvailableMigrators");
this.setComponentState({
migrators,
page: MigrationWizardConstants.PAGES.SELECTION,
});
this.#wizardEl.dispatchEvent(
new this.contentWindow.CustomEvent("MigrationWizard:Ready", {
bubbles: true,
})
);
break;
}
case "MigrationWizard:BeginMigration": {
await this.sendQuery("Migrate", event.detail);
this.#wizardEl.dispatchEvent(
new this.contentWindow.CustomEvent("MigrationWizard:DoneMigration", {
bubbles: true,
})
);
break;
}
}
}
/**
* General message handler function for messages received from the
* associated MigrationWizardParent JSWindowActor.
*
* @param {ReceiveMessageArgument} message
* The message received from the MigrationWizardParent.
*/
receiveMessage(message) {
if (message.name == "UpdateProgress") {
let progress = message.data;
this.setComponentState({
migrators,
page: MigrationWizardConstants.PAGES.SELECTION,
page: MigrationWizardConstants.PAGES.PROGRESS,
progress,
});
this.#wizardEl.dispatchEvent(
new this.contentWindow.CustomEvent("MigrationWizard:Ready", {
bubbles: true,
})
);
}
}

View File

@ -15,6 +15,12 @@ XPCOMUtils.defineLazyGetter(lazy, "gFluentStrings", function() {
]);
});
ChromeUtils.defineESModuleGetters(lazy, {
InternalTestingProfileMigrator:
"resource:///modules/InternalTestingProfileMigrator.sys.mjs",
PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
});
/**
* This class is responsible for communicating with MigrationUtils to do the
* actual heavy-lifting of any kinds of migration work, based on messages from
@ -44,22 +50,131 @@ export class MigrationWizardParent extends JSWindowActorParent {
);
}
if (message.name == "GetAvailableMigrators") {
let availableMigrators = [];
for (const key of MigrationUtils.availableMigratorKeys) {
availableMigrators.push(this.#getMigratorAndProfiles(key));
switch (message.name) {
case "GetAvailableMigrators": {
let availableMigrators = [];
for (const key of MigrationUtils.availableMigratorKeys) {
availableMigrators.push(this.#getMigratorAndProfiles(key));
}
// Wait for all getMigrator calls to resolve in parallel
let results = await Promise.all(availableMigrators);
// Each migrator might give us a single MigratorProfileInstance,
// or an Array of them, so we flatten them out and filter out
// any that ended up going wrong and returning null from the
// #getMigratorAndProfiles call.
return results.flat().filter(result => result);
}
case "Migrate": {
await this.#doMigration(
message.data.key,
message.data.resourceTypes,
message.data.profile
);
}
// Wait for all getMigrator calls to resolve in parallel
let results = await Promise.all(availableMigrators);
// Each migrator might give us a single MigratorProfileInstance,
// or an Array of them, so we flatten them out and filter out
// any that ended up going wrong and returning null from the
// #getMigratorAndProfiles call.
return results.flat().filter(result => result);
}
return null;
}
/**
* Calls into MigrationUtils to perform a migration given the parameters
* sent via the wizard.
*
* @param {string} migratorKey
* The unique identification key for a migrator.
* @param {string[]} resourceTypes
* An array of strings, where each string represents a resource type
* that can be imported for this migrator and profile. The strings
* should be one of the key values of
* MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.
* @param {object|null} profileObj
* A description of the user profile that the migrator can import.
* @param {string} profileObj.id
* A unique ID for the user profile.
* @param {string} profileObj.name
* The display name for the user profile.
* @returns {Promise<undefined>}
* Resolves once the Migration:Ended observer notification has fired.
*/
async #doMigration(migratorKey, resourceTypes, profileObj) {
let migrator = await MigrationUtils.getMigrator(migratorKey);
let resourceTypesToMigrate = 0;
let progress = {};
for (let resourceType of resourceTypes) {
resourceTypesToMigrate |= MigrationUtils.resourceTypes[resourceType];
progress[resourceType] = {
inProgress: true,
message: "",
};
}
this.sendAsyncMessage("UpdateProgress", progress);
let observer = {
observe: (subject, topic, resourceType) => {
if (topic == "Migration:Ended") {
observer.migrationDefer.resolve();
return;
}
// Unfortunately, MigratorBase hands us the string representation
// of the numeric value of the MigrationUtils.resourceType from this
// observer. For now, we'll just do a look-up to map it to the right
// constant.
let resourceTypeNum = parseInt(resourceType, 10);
let foundResourceTypeName;
for (let resourceTypeName in MigrationUtils.resourceTypes) {
if (
MigrationUtils.resourceTypes[resourceTypeName] == resourceTypeNum
) {
foundResourceTypeName = resourceTypeName;
break;
}
}
if (!foundResourceTypeName) {
console.error(
"Could not find a resource type for value: ",
resourceType
);
} else {
// For now, we ignore errors in migration, and simply display
// the success state.
progress[foundResourceTypeName] = {
inProgress: false,
message: "",
};
this.sendAsyncMessage("UpdateProgress", progress);
}
},
migrationDefer: lazy.PromiseUtils.defer(),
QueryInterface: ChromeUtils.generateQI([
Ci.nsIObserver,
Ci.nsISupportsWeakReference,
]),
};
Services.obs.addObserver(observer, "Migration:ItemAfterMigrate", true);
Services.obs.addObserver(observer, "Migration:ItemError", true);
Services.obs.addObserver(observer, "Migration:Ended", true);
try {
// The MigratorBase API is somewhat awkward - we must wait for an observer
// notification with topic Migration:Ended to know when the migration
// finishes.
migrator.migrate(resourceTypesToMigrate, false, profileObj);
await observer.migrationDefer.promise;
} finally {
Services.obs.removeObserver(observer, "Migration:ItemAfterMigrate");
Services.obs.removeObserver(observer, "Migration:ItemError");
Services.obs.removeObserver(observer, "Migration:Ended");
}
}
/**
* @typedef {object} MigratorProfileInstance
* An object that describes a single user profile (or the default
@ -146,11 +261,22 @@ export class MigrationWizardParent extends JSWindowActorParent {
}
}
let displayName;
if (migrator.constructor.key == lazy.InternalTestingProfileMigrator.key) {
// In the case of the InternalTestingProfileMigrator, which is never seen
// by users outside of testing, we don't make our localization community
// localize it's display name, and just display the ID instead.
displayName = migrator.constructor.displayNameL10nID;
} else {
displayName = await lazy.gFluentStrings.formatValue(
migrator.constructor.displayNameL10nID
);
}
return {
key: migrator.constructor.key,
displayName: await lazy.gFluentStrings.formatValue(
migrator.constructor.displayNameL10nID
),
displayName,
resourceTypes: availableResourceTypes,
profile: profileObj,
};

View File

@ -30,10 +30,17 @@ export const MigrationWizardConstants = Object.freeze({
// COOKIE resource migration is going to be removed, so we don't include
// it here.
HISTORY: "history",
FORMDATA: "form-autofill",
PASSWORDS: "logins-and-passwords",
BOOKMARKS: "bookmarks",
// This is a little silly, but JavaScript doesn't have a notion of
// enums. The advantage of this set-up is that these constants values
// can be used to access the MigrationUtils.resourceTypes constants,
// are reasonably readable as DOM attributes, and easily serialize /
// deserialize.
HISTORY: "HISTORY",
FORMDATA: "FORMDATA",
PASSWORDS: "PASSWORDS",
BOOKMARKS: "BOOKMARKS",
// We don't yet show OTHERDATA or SESSION resources.
}),
});

View File

@ -19,6 +19,7 @@ export class MigrationWizard extends HTMLElement {
#browserProfileSelector = null;
#resourceTypeList = null;
#shadowRoot = null;
#importButton = null;
static get markup() {
return `
@ -31,47 +32,47 @@ export class MigrationWizard extends HTMLElement {
<select id="browser-profile-selector">
</select>
<fieldset id="resource-type-list">
<label id="bookmarks">
<label id="bookmarks" data-resource-type="BOOKMARKS"/>
<input type="checkbox"/><span data-l10n-id="migration-bookmarks-option-label"></span>
</label>
<label id="logins-and-passwords">
<label id="logins-and-passwords" data-resource-type="PASSWORDS">
<input type="checkbox"/><span data-l10n-id="migration-logins-and-passwords-option-label"></span>
</label>
<label id="history">
<label id="history" data-resource-type="HISTORY">
<input type="checkbox"/><span data-l10n-id="migration-history-option-label"></span>
</label>
<label id="form-autofill">
<label id="form-autofill" data-resource-type="FORMDATA">
<input type="checkbox"/><span data-l10n-id="migration-form-autofill-option-label"></span>
</label>
</fieldset>
<moz-button-group class="buttons">
<button class="cancel-close" data-l10n-id="migration-cancel-button-label"></button>
<button class="primary" data-l10n-id="migration-import-button-label"></button>
<button id="import" class="primary" data-l10n-id="migration-import-button-label"></button>
</moz-button-group>
</div>
<div name="page-progress">
<h3 id="progress-header" data-l10n-id="migration-wizard-progress-header"></h3>
<div class="resource-progress">
<div data-resource-type="bookmarks" class="resource-progress-group">
<div data-resource-type="BOOKMARKS" class="resource-progress-group">
<span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span>
<span data-l10n-id="migration-bookmarks-option-label"></span>
<span class="success-text">&nbsp;</span>
</div>
<div data-resource-type="logins-and-passwords" class="resource-progress-group">
<div data-resource-type="PASSWORDS" class="resource-progress-group">
<span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span>
<span data-l10n-id="migration-logins-and-passwords-option-label"></span>
<span class="success-text">&nbsp;</span>
</div>
<div data-resource-type="history" class="resource-progress-group">
<div data-resource-type="HISTORY" class="resource-progress-group">
<span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span>
<span data-l10n-id="migration-history-option-label"></span>
<span class="success-text">&nbsp;</span>
</div>
<div data-resource-type="form-autofill" class="resource-progress-group">
<div data-resource-type="FORMDATA" class="resource-progress-group">
<span class="progress-icon-parent"><span class="progress-icon" role="img"></span></span>
<span data-l10n-id="migration-form-autofill-option-label"></span>
<span class="success-text">&nbsp;</span>
@ -129,6 +130,9 @@ export class MigrationWizard extends HTMLElement {
button.addEventListener("click", this);
}
this.#importButton = shadow.querySelector("#import");
this.#importButton.addEventListener("click", this);
this.#browserProfileSelector.addEventListener("change", this);
this.#resourceTypeList = shadow.querySelector("#resource-type-list");
this.#shadowRoot = shadow;
@ -180,10 +184,8 @@ export class MigrationWizard extends HTMLElement {
}
for (let resourceType of resourceTypes) {
let resourceID =
MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES[resourceType];
let resourceLabel = this.#resourceTypeList.querySelector(
`#${resourceID}`
`label[data-resource-type="${resourceType}"]`
);
if (resourceLabel) {
resourceLabel.hidden = false;
@ -324,10 +326,45 @@ export class MigrationWizard extends HTMLElement {
});
}
/**
* Takes the current state of the selections page and bundles them
* up into a MigrationWizard:BeginMigration event that can be handled
* externally to perform the actual migration.
*/
#doImport() {
let option = this.#browserProfileSelector.options[
this.#browserProfileSelector.selectedIndex
];
let key = option.value;
let profile = option.profile;
let resourceTypeFields = this.#resourceTypeList.querySelectorAll(
"label[data-resource-type]"
);
let resourceTypes = [];
for (let resourceTypeField of resourceTypeFields) {
if (resourceTypeField.control.checked) {
resourceTypes.push(resourceTypeField.dataset.resourceType);
}
}
this.dispatchEvent(
new CustomEvent("MigrationWizard:BeginMigration", {
bubbles: true,
detail: {
key,
profile,
resourceTypes,
},
})
);
}
handleEvent(event) {
switch (event.type) {
case "click": {
if (event.target.classList.contains("cancel-close")) {
if (event.target == this.#importButton) {
this.#doImport();
} else if (event.target.classList.contains("cancel-close")) {
this.dispatchEvent(
new CustomEvent("MigrationWizard:Close", { bubbles: true })
);

View File

@ -26,6 +26,7 @@ EXTRA_JS_MODULES += [
"ChromeMigrationUtils.sys.mjs",
"ChromeProfileMigrator.sys.mjs",
"FirefoxProfileMigrator.sys.mjs",
"InternalTestingProfileMigrator.sys.mjs",
"MigrationUtils.sys.mjs",
"MigratorBase.sys.mjs",
"ProfileMigrator.sys.mjs",

View File

@ -2,7 +2,9 @@
head = head.js
prefs =
browser.migrate.content-modal.enabled=true
browser.migrate.internal-testing.enabled=true
[browser_dialog_cancel_close.js]
[browser_dialog_open.js]
[browser_dialog_resize.js]
[browser_do_migration.js]

View File

@ -0,0 +1,174 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
const { InternalTestingProfileMigrator } = ChromeUtils.importESModule(
"resource:///modules/InternalTestingProfileMigrator.sys.mjs"
);
/**
* A helper function that prepares the InternalTestingProfileMigrator
* with some set of fake available resources, and resolves a Promise
* when the InternalTestingProfileMigrator is used for a migration.
*
* @param {number[]} availableResourceTypes
* An array of resource types from MigrationUtils.resourcesTypes.
* A single MigrationResource will be created per type, with a
* no-op migrate function.
* @param {number[]} expectedResourceTypes
* An array of resource types from MigrationUtils.resourceTypes.
* These are the resource types that are expected to be passed
* to the InternalTestingProfileMigrator.migrate function.
* @param {object|string} expectedProfile
* The profile object or string that is expected to be passed
* to the InternalTestingProfileMigrator.migrate function.
* @returns {Promise<undefined>}
*/
async function waitForTestMigration(
availableResourceTypes,
expectedResourceTypes,
expectedProfile
) {
let sandbox = sinon.createSandbox();
// Fake out the getResources method of the migrator so that we return
// a single fake MigratorResource per availableResourceType.
sandbox
.stub(InternalTestingProfileMigrator.prototype, "getResources")
.callsFake(() => {
return Promise.resolve(
availableResourceTypes.map(resourceType => {
return {
type: resourceType,
migrate: () => {},
};
})
);
});
// Fake out the migrate method of the migrator and assert that the
// next time it's called, its arguments match our expectations.
return new Promise(resolve => {
sandbox
.stub(InternalTestingProfileMigrator.prototype, "migrate")
.callsFake((aResourceTypes, aStartup, aProfile) => {
Assert.ok(
!aStartup,
"Migrator should not have been called as a startup migration."
);
Assert.deepEqual(
aResourceTypes,
expectedResourceTypes,
"Got the expected resource types"
);
Assert.deepEqual(
aProfile,
expectedProfile,
"Got the expected profile object"
);
Services.obs.notifyObservers(null, "Migration:Ended");
resolve();
});
}).finally(async () => {
sandbox.restore();
// MigratorBase caches resources fetched by the getResources method
// as a performance optimization. In order to allow different tests
// to have different available resources, we call into a special
// method of InternalTestingProfileMigrator that clears that
// cache.
let migrator = await MigrationUtils.getMigrator(
InternalTestingProfileMigrator.key
);
migrator.flushResourceCache();
});
}
/**
* Takes a MigrationWizard element and chooses the
* InternalTestingProfileMigrator as the browser to migrate from. Then, it
* checks the checkboxes associated with the selectedResourceTypes and
* unchecks the rest before clicking the "Import" button.
*
* @param {Element} wizard
* The MigrationWizard element.
* @param {string[]} selectedResourceTypes
* An array of resource type strings from
* MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.
*/
function selectResourceTypesAndStartMigration(wizard, selectedResourceTypes) {
let shadow = wizard.openOrClosedShadowRoot;
// First, select the InternalTestingProfileMigrator browser.
let selector = shadow.querySelector("#browser-profile-selector");
selector.value = InternalTestingProfileMigrator.key;
// Apparently we have to dispatch our own "change" events for <select>
// dropdowns.
selector.dispatchEvent(new CustomEvent("change", { bubbles: true }));
// And then check the right checkboxes for the resource types.
let resourceTypeList = shadow.querySelector("#resource-type-list");
for (let resourceType in MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES) {
let node = resourceTypeList.querySelector(
`label[data-resource-type="${resourceType}"]`
);
node.control.checked = selectedResourceTypes.includes(resourceType);
}
let importButton = shadow.querySelector("#import");
importButton.click();
}
/**
* Tests that the MigrationWizard can be used to successfully migrate
* using the InternalTestingProfileMigrator in a few scenarios.
*/
add_task(async function test_successful_migrations() {
// Scenario 1: A single resource type is available.
let migration = waitForTestMigration(
[MigrationUtils.resourceTypes.BOOKMARKS],
[MigrationUtils.resourceTypes.BOOKMARKS],
null
);
await withMigrationWizardSubdialog(async subdialogWin => {
let dialogBody = subdialogWin.document.body;
let wizard = dialogBody.querySelector("#wizard");
let wizardDone = BrowserTestUtils.waitForEvent(
wizard,
"MigrationWizard:DoneMigration"
);
selectResourceTypesAndStartMigration(wizard, [
MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS,
]);
await migration;
await wizardDone;
});
// Scenario 2: Several resource types are available, but only 1
// is checked / expected.
migration = waitForTestMigration(
[
MigrationUtils.resourceTypes.BOOKMARKS,
MigrationUtils.resourceTypes.PASSWORDS,
],
[MigrationUtils.resourceTypes.PASSWORDS],
null
);
await withMigrationWizardSubdialog(async subdialogWin => {
let dialogBody = subdialogWin.document.body;
let wizard = dialogBody.querySelector("#wizard");
let wizardDone = BrowserTestUtils.waitForEvent(
wizard,
"MigrationWizard:DoneMigration"
);
selectResourceTypesAndStartMigration(wizard, [
MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS,
]);
await migration;
await wizardDone;
});
});

View File

@ -102,13 +102,12 @@
// dropdowns.
selector.dispatchEvent(new CustomEvent("change", { "bubbles" : true }));
for (let displayedResourceType in MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES) {
let resourceTypeID = MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES[displayedResourceType];
let node = resourceTypeList.querySelector(`#${resourceTypeID}`);
if (migratorInstance.resourceTypes.includes(displayedResourceType)) {
ok(!isHidden(node), `Selection for ${displayedResourceType} should be shown.`);
for (let resourceType in MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES) {
let node = resourceTypeList.querySelector(`label[data-resource-type="${resourceType}"]`);
if (migratorInstance.resourceTypes.includes(resourceType)) {
ok(!isHidden(node), `Selection for ${resourceType} should be shown.`);
} else {
ok(isHidden(node), `Selection for ${displayedResourceType} should be hidden.`);
ok(isHidden(node), `Selection for ${resourceType} should be hidden.`);
}
}
}