commit 2f46db151480d329d3512befea4df75ccf6538c7
parent 199c36415319b72ed07be35532dbe269e8b31950
Author: Jason Prickett <jprickett@mozilla.com>
Date: Wed, 22 Oct 2025 16:53:01 +0000
Bug 1994820 - Add error handling to getBackupFileInfo and error display for restore component when used in about:welcome r=omc-reviewers,kpatenio,mviar
Differential Revision: https://phabricator.services.mozilla.com/D269270
Diffstat:
5 files changed, 377 insertions(+), 29 deletions(-)
diff --git a/browser/components/backup/BackupService.sys.mjs b/browser/components/backup/BackupService.sys.mjs
@@ -3807,13 +3807,31 @@ export class BackupService extends EventTarget {
*/
async getBackupFileInfo(backupFilePath) {
lazy.logConsole.debug(`Getting info from backup file at ${backupFilePath}`);
- let { archiveJSON, isEncrypted } = await this.sampleArchive(backupFilePath);
- this.#_state.backupFileInfo = {
- isEncrypted,
- date: archiveJSON?.meta?.date,
- deviceName: archiveJSON?.meta?.deviceName,
- };
- this.#_state.backupFileToRestore = backupFilePath;
+ try {
+ let { archiveJSON, isEncrypted } =
+ await this.sampleArchive(backupFilePath);
+ this.#_state.backupFileInfo = {
+ isEncrypted,
+ date: archiveJSON?.meta?.date,
+ deviceName: archiveJSON?.meta?.deviceName,
+ };
+ this.#_state.backupFileToRestore = backupFilePath;
+ // Clear any existing recovery error from state since we've successfully got our file info
+ this.setRecoveryError(ERRORS.NONE);
+ } catch (error) {
+ this.setRecoveryError(error.cause);
+ // Nullify the file info when we catch errors that indicate the file is invalid
+ switch (error.cause) {
+ case ERRORS.FILE_SYSTEM_ERROR:
+ case ERRORS.CORRUPTED_ARCHIVE:
+ case ERRORS.UNSUPPORTED_BACKUP_VERSION:
+ this.#_state.backupFileInfo = null;
+ this.#_state.backupFileToRestore = null;
+ break;
+ default:
+ break;
+ }
+ }
this.stateUpdate();
}
diff --git a/browser/components/backup/content/restore-from-backup.css b/browser/components/backup/content/restore-from-backup.css
@@ -138,6 +138,7 @@
.field-error {
color: var(--text-color-error);
font-size: var(--font-size-small);
+ margin-block-start: var(--space-xsmall);
}
}
@@ -169,6 +170,10 @@
}
}
+ .field-error {
+ text-align: start;
+ }
+
@media (width <= 800px) {
#restore-from-backup-button-group {
justify-content: center;
diff --git a/browser/components/backup/content/restore-from-backup.mjs b/browser/components/backup/content/restore-from-backup.mjs
@@ -276,6 +276,37 @@ export default class RestoreFromBackup extends MozLitElement {
}
}
+ renderBackupFileInfo(backupFileInfo) {
+ return html`<p
+ id="restore-from-backup-backup-found-info"
+ data-l10n-id="backup-file-creation-date-and-device"
+ data-l10n-args=${JSON.stringify({
+ machineName: backupFileInfo.deviceName ?? "",
+ date: backupFileInfo.date ? new Date(backupFileInfo.date).getTime() : 0,
+ })}
+ ></p>`;
+ }
+
+ renderBackupFileStatus() {
+ const { backupFileInfo, recoveryErrorCode } = this.backupServiceState || {};
+
+ // We have errors and are embedded in about:welcome
+ if (recoveryErrorCode && this.aboutWelcomeEmbedded) {
+ return this.genericFileErrorTemplate();
+ }
+
+ // No backup file selected
+ if (!backupFileInfo) {
+ return this.getSupportLinkAnchor({
+ id: "restore-from-backup-no-backup-file-link",
+ l10nId: "restore-from-backup-no-backup-file-link",
+ });
+ }
+
+ // Backup file found and no error
+ return this.renderBackupFileInfo(backupFileInfo);
+ }
+
controlsTemplate() {
let iconURL = this.#placeholderFileIconURL;
if (
@@ -302,27 +333,7 @@ export default class RestoreFromBackup extends MozLitElement {
></moz-button>
</div>
- ${!this.backupServiceState?.backupFileInfo
- ? this.getSupportLinkAnchor({
- id: "restore-from-backup-no-backup-file-link",
- l10nId: "restore-from-backup-no-backup-file-link",
- })
- : null}
- ${this.backupServiceState?.backupFileInfo
- ? html`<p
- id="restore-from-backup-backup-found-info"
- data-l10n-id="backup-file-creation-date-and-device"
- data-l10n-args=${JSON.stringify({
- machineName:
- this.backupServiceState.backupFileInfo.deviceName ?? "",
- date: this.backupServiceState.backupFileInfo.date
- ? new Date(
- this.backupServiceState.backupFileInfo.date
- ).getTime()
- : 0,
- })}
- ></p>`
- : null}
+ ${this.renderBackupFileStatus()}
</fieldset>
<fieldset id="password-entry-controls">
@@ -340,6 +351,21 @@ export default class RestoreFromBackup extends MozLitElement {
);
const backupFileName = this.backupServiceState?.backupFileToRestore || "";
+ // Determine the ID of the element that will be rendered by renderBackupFileStatus()
+ // to reference with aria-describedby
+ let describedBy = "";
+ const { backupFileInfo, recoveryErrorCode } = this.backupServiceState || {};
+
+ if (this.aboutWelcomeEmbedded) {
+ if (recoveryErrorCode) {
+ describedBy = "backup-generic-file-error";
+ } else if (!backupFileInfo) {
+ describedBy = "restore-from-backup-no-backup-file-link";
+ } else {
+ describedBy = "restore-from-backup-backup-found-info";
+ }
+ }
+
if (this.aboutWelcomeEmbedded) {
return html`
<textarea
@@ -349,6 +375,7 @@ export default class RestoreFromBackup extends MozLitElement {
.value=${backupFileName}
style=${styles}
@input=${this.handleTextareaResize}
+ aria-describedby=${describedBy}
></textarea>
`;
}
@@ -416,7 +443,8 @@ export default class RestoreFromBackup extends MozLitElement {
>
${this.aboutWelcomeEmbedded ? null : this.headerTemplate()}
<main id="restore-from-backup-content">
- ${this.backupServiceState?.recoveryErrorCode
+ ${!this.aboutWelcomeEmbedded &&
+ this.backupServiceState?.recoveryErrorCode
? this.errorTemplate()
: null}
${!this.aboutWelcomeEmbedded &&
@@ -502,6 +530,29 @@ export default class RestoreFromBackup extends MozLitElement {
`;
}
+ genericFileErrorTemplate() {
+ // We handle incorrect password errors in the password input
+ if (this.isIncorrectPassword) {
+ return null;
+ }
+
+ // Note: the l10n id used here is interim, and will be updated in Bug 1994877
+ return html`
+ <span
+ id="backup-generic-file-error"
+ class="field-error"
+ data-l10n-id="restored-from-backup-error-subtitle"
+ >
+ <a
+ id="backup-generic-error-link"
+ slot="support-link"
+ data-l10n-name="restore-problems"
+ href=${this.getSupportURLWithUTM("firefox-backup")}
+ ></a>
+ </span>
+ `;
+ }
+
render() {
this.applyContentCustomizations();
return html`
diff --git a/browser/components/backup/tests/chrome/test_restore_from_backup.html b/browser/components/backup/tests/chrome/test_restore_from_backup.html
@@ -354,6 +354,166 @@
restoreFromBackup.remove();
});
+
+ /**
+ * Tests that the correct status is displayed under the input
+ * for different backup file info states.
+ */
+ add_task(async function test_backup_file_status_rendering() {
+ let content = document.getElementById("content");
+ let restoreFromBackup = document.createElement("restore-from-backup");
+ content.appendChild(restoreFromBackup);
+
+ // Test that when no backup file is selected, the support link is displayed
+ restoreFromBackup.backupServiceState = {
+ ...restoreFromBackup.backupServiceState,
+ backupFileInfo: null,
+ recoveryErrorCode: ERRORS.NONE,
+ };
+ await restoreFromBackup.updateComplete;
+
+ let noBackupLink = restoreFromBackup.shadowRoot.querySelector(
+ "#restore-from-backup-no-backup-file-link"
+ );
+ ok(noBackupLink, "Should show support link when no backup file is selected");
+
+ let backupInfo = restoreFromBackup.shadowRoot.querySelector(
+ "#restore-from-backup-backup-found-info"
+ );
+ ok(!backupInfo, "Should not show backup info when no backup file is selected");
+
+ // Test that when a backup file is selected, the backup info is displayed
+ const IS_ENCRYPTED = true;
+ const DATE = new Date("2024-01-01T00:00:00.000Z");
+ const DEVICE_NAME = "test-device";
+
+ restoreFromBackup.backupServiceState = {
+ ...restoreFromBackup.backupServiceState,
+ backupFileInfo: {
+ isEncrypted: IS_ENCRYPTED,
+ date: DATE,
+ deviceName: DEVICE_NAME
+ },
+ recoveryErrorCode: ERRORS.NONE,
+ };
+ await restoreFromBackup.updateComplete;
+
+ noBackupLink = restoreFromBackup.shadowRoot.querySelector(
+ "#restore-from-backup-no-backup-file-link"
+ );
+ ok(!noBackupLink, "Should not show support link when backup file is found");
+
+ backupInfo = restoreFromBackup.shadowRoot.querySelector(
+ "#restore-from-backup-backup-found-info"
+ );
+ ok(backupInfo, "Should show backup info when backup file is found");
+ is(backupInfo.getAttribute("data-l10n-id"), "backup-file-creation-date-and-device",
+ "Should have correct l10n id for backup info");
+
+ // Test that when embedded in about:welcome, if an error occurs,
+ // the generic file error template is displayed
+ restoreFromBackup.aboutWelcomeEmbedded = true;
+ restoreFromBackup.backupServiceState = {
+ ...restoreFromBackup.backupServiceState,
+ backupFileInfo: null,
+ recoveryErrorCode: ERRORS.CORRUPTED_ARCHIVE,
+ };
+ await restoreFromBackup.updateComplete;
+
+ let errorTemplate = restoreFromBackup.shadowRoot.querySelector(
+ "#backup-generic-file-error"
+ );
+ ok(errorTemplate, "Should show error template in embedded context with error");
+
+ // Test that when not embedded in about:welcome, if an error occurs,
+ // the support link is displayed instead of the generic file error template
+ restoreFromBackup.aboutWelcomeEmbedded = false;
+ restoreFromBackup.backupServiceState = {
+ ...restoreFromBackup.backupServiceState,
+ backupFileInfo: null,
+ recoveryErrorCode: ERRORS.FILE_SYSTEM_ERROR,
+ };
+ await restoreFromBackup.updateComplete;
+
+ errorTemplate = restoreFromBackup.shadowRoot.querySelector(
+ "#backup-generic-file-error"
+ );
+ ok(!errorTemplate, "Should not show generic file error template when not embedded in about:welcome");
+
+ noBackupLink = restoreFromBackup.shadowRoot.querySelector(
+ "#restore-from-backup-no-backup-file-link"
+ );
+ ok(noBackupLink, "Should show support link when not embedded in about:welcome, with error");
+
+ restoreFromBackup.remove();
+ });
+
+ /**
+ * Tests that when embedded in about:welcome, the textarea's aria-describedby
+ * attribute correctly references the appropriate element based on the displayed status.
+ */
+ add_task(async function test_textarea_aria_describedby_accessibility() {
+ let content = document.getElementById("content");
+ let restoreFromBackup = document.createElement("restore-from-backup");
+ content.appendChild(restoreFromBackup);
+
+ restoreFromBackup.aboutWelcomeEmbedded = true;
+ await restoreFromBackup.updateComplete;
+
+ let textarea = restoreFromBackup.shadowRoot.querySelector("#backup-filepicker-input");
+ ok(textarea, "Textarea should be present when aboutWelcomeEmbedded is true");
+
+ // Test that when there is no backup file info, we should reference no-backup-file-link
+ restoreFromBackup.backupServiceState = {
+ ...restoreFromBackup.backupServiceState,
+ backupFileInfo: null,
+ recoveryErrorCode: ERRORS.NONE,
+ };
+ await restoreFromBackup.updateComplete;
+
+ let ariaDescribedBy = textarea.getAttribute("aria-describedby");
+ is(ariaDescribedBy, "restore-from-backup-no-backup-file-link",
+ "aria-describedby should reference no-backup-file-link when no backup info");
+
+ let referencedElement = restoreFromBackup.shadowRoot.querySelector("#restore-from-backup-no-backup-file-link");
+ ok(referencedElement, "Referenced element should exist");
+
+ // Test that when a backup file is found, we should reference backup-found-info
+ restoreFromBackup.backupServiceState = {
+ ...restoreFromBackup.backupServiceState,
+ backupFileInfo: {
+ date: new Date(),
+ deviceName: "test-device",
+ isEncrypted: false,
+ },
+ recoveryErrorCode: ERRORS.NONE,
+ };
+ await restoreFromBackup.updateComplete;
+
+ ariaDescribedBy = textarea.getAttribute("aria-describedby");
+ is(ariaDescribedBy, "restore-from-backup-backup-found-info",
+ "aria-describedby should reference backup-found-info when backup file is found");
+
+ referencedElement = restoreFromBackup.shadowRoot.querySelector("#restore-from-backup-backup-found-info");
+ ok(referencedElement, "Referenced element should exist");
+
+ // Test that when we're in an error state, we should reference the generic-file-error
+ restoreFromBackup.backupServiceState = {
+ ...restoreFromBackup.backupServiceState,
+ backupFileInfo: null,
+ recoveryErrorCode: ERRORS.CORRUPTED_ARCHIVE,
+ };
+ await restoreFromBackup.updateComplete;
+
+ ariaDescribedBy = textarea.getAttribute("aria-describedby");
+ is(ariaDescribedBy, "backup-generic-file-error",
+ "aria-describedby should reference generic-file-error when there's a recovery error");
+
+ referencedElement = restoreFromBackup.shadowRoot.querySelector("#backup-generic-file-error");
+ ok(referencedElement, "Referenced element should exist");
+
+ restoreFromBackup.remove();
+ });
</script>
</head>
<body>
diff --git a/browser/components/backup/tests/xpcshell/test_BackupService.js b/browser/components/backup/tests/xpcshell/test_BackupService.js
@@ -18,6 +18,9 @@ const { UIState } = ChromeUtils.importESModule(
const { ClientID } = ChromeUtils.importESModule(
"resource://gre/modules/ClientID.sys.mjs"
);
+const { ERRORS } = ChromeUtils.importESModule(
+ "chrome://browser/content/backup/backup-constants.mjs"
+);
const LAST_BACKUP_TIMESTAMP_PREF_NAME =
"browser.backup.scheduled.last-backup-timestamp";
@@ -932,3 +935,114 @@ add_task(async function test__deleteLastBackup_file_does_not_exist() {
await maybeRemovePath(lastBackupFilePath);
});
});
+
+/**
+ * Tests that getBackupFileInfo properly handles errors, and clears file info
+ * for errors that indicate that the file is invalid.
+ */
+add_task(async function test_getBackupFileInfo_error_handling() {
+ let sandbox = sinon.createSandbox();
+
+ const testCases = [
+ // Errors that should clear backupFileInfo
+ { error: ERRORS.FILE_SYSTEM_ERROR, shouldClear: true },
+ { error: ERRORS.CORRUPTED_ARCHIVE, shouldClear: true },
+ { error: ERRORS.UNSUPPORTED_BACKUP_VERSION, shouldClear: true },
+ // Errors that shouldn't clear backupFileInfo
+ { error: ERRORS.INTERNAL_ERROR, shouldClear: false },
+ { error: ERRORS.UNINITIALIZED, shouldClear: false },
+ { error: ERRORS.INVALID_PASSWORD, shouldClear: false },
+ ];
+
+ for (const testCase of testCases) {
+ let bs = new BackupService();
+
+ const DATE = "2024-06-25T21:59:11.777Z";
+ const IS_ENCRYPTED = true;
+ const DEVICE_NAME = "test-device";
+
+ let fakeSampleArchiveResult = {
+ isEncrypted: IS_ENCRYPTED,
+ startByteOffset: 26985,
+ contentType: "multipart/mixed",
+ archiveJSON: {
+ version: 1,
+ meta: { date: DATE, deviceName: DEVICE_NAME },
+ encConfig: {},
+ },
+ };
+
+ sandbox
+ .stub(BackupService.prototype, "sampleArchive")
+ .resolves(fakeSampleArchiveResult);
+ await bs.getBackupFileInfo("test-backup.html");
+
+ // Verify initial state was set
+ Assert.deepEqual(
+ bs.state.backupFileInfo,
+ {
+ isEncrypted: IS_ENCRYPTED,
+ date: DATE,
+ deviceName: DEVICE_NAME,
+ },
+ "Initial state should be set correctly"
+ );
+ Assert.strictEqual(
+ bs.state.backupFileToRestore,
+ "test-backup.html",
+ "Initial backupFileToRestore should be set correctly"
+ );
+
+ // Test when sampleArchive throws an error
+ sandbox.restore();
+ sandbox
+ .stub(BackupService.prototype, "sampleArchive")
+ .rejects(new Error("Test error", { cause: testCase.error }));
+ const setRecoveryErrorStub = sandbox.stub(bs, "setRecoveryError");
+
+ try {
+ await bs.getBackupFileInfo("test-backup.html");
+ } catch (error) {
+ Assert.ok(
+ false,
+ `Expected getBackupFileInfo to throw for error ${testCase.error}`
+ );
+ }
+
+ Assert.ok(
+ setRecoveryErrorStub.calledOnceWith(testCase.error),
+ `setRecoveryError should be called with ${testCase.error}`
+ );
+
+ // backupFileInfo should be either cleared or preserved based on error type
+ if (testCase.shouldClear) {
+ Assert.strictEqual(
+ bs.state.backupFileInfo,
+ null,
+ `backupFileInfo should be cleared for error ${testCase.error}`
+ );
+ Assert.strictEqual(
+ bs.state.backupFileToRestore,
+ null,
+ `backupFileToRestore should be cleared for error ${testCase.error}`
+ );
+ } else {
+ Assert.deepEqual(
+ bs.state.backupFileInfo,
+ {
+ isEncrypted: IS_ENCRYPTED,
+ date: DATE,
+ deviceName: DEVICE_NAME,
+ },
+ `backupFileInfo should be preserved for error ${testCase.error}`
+ );
+ Assert.strictEqual(
+ bs.state.backupFileToRestore,
+ "test-backup.html",
+ `backupFileToRestore should be preserved for error ${testCase.error}`
+ );
+ }
+
+ sandbox.restore();
+ }
+});