From 90827c54a134a2f00437c413d603a4afee5d83aa Mon Sep 17 00:00:00 2001 From: Nicholas Ellul Date: Thu, 27 Jul 2023 13:33:36 -0400 Subject: [PATCH] Add improved downloading logic when exporting state logs (#19872) * Add improved downloading logic when exporting state logs * Make test for state logs download only apply to firefox * Remove eslint override * Add file extension to test * Move make jest global.Blob accessible to window --- test/e2e/tests/backup-restore.spec.js | 8 ++ test/e2e/tests/state-logs.spec.js | 5 + ui/helpers/utils/export-utils.js | 94 +++++++++++++++++-- ui/helpers/utils/export-utils.test.js | 68 ++++++++++++++ .../advanced-tab/advanced-tab.component.js | 13 ++- 5 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 ui/helpers/utils/export-utils.test.js diff --git a/test/e2e/tests/backup-restore.spec.js b/test/e2e/tests/backup-restore.spec.js index d52cf1fa6..dfba955c6 100644 --- a/test/e2e/tests/backup-restore.spec.js +++ b/test/e2e/tests/backup-restore.spec.js @@ -56,6 +56,10 @@ describe('Backup and Restore', function () { ], }; it('should backup the account settings', async function () { + if (process.env.SELENIUM_BROWSER === 'chrome') { + // Chrome shows OS level download prompt which can't be dismissed by Selenium + this.skip(); + } await withFixtures( { fixtures: new FixtureBuilder().build(), @@ -97,6 +101,10 @@ describe('Backup and Restore', function () { }); it('should restore the account settings', async function () { + if (process.env.SELENIUM_BROWSER === 'chrome') { + // Chrome shows OS level download prompt which can't be dismissed by Selenium + this.skip(); + } await withFixtures( { fixtures: new FixtureBuilder().build(), diff --git a/test/e2e/tests/state-logs.spec.js b/test/e2e/tests/state-logs.spec.js index f08a38fd1..8cccbcf91 100644 --- a/test/e2e/tests/state-logs.spec.js +++ b/test/e2e/tests/state-logs.spec.js @@ -30,7 +30,12 @@ describe('State logs', function () { }, ], }; + it('should download state logs for the account', async function () { + if (process.env.SELENIUM_BROWSER === 'chrome') { + // Chrome shows OS level download prompt which can't be dismissed by Selenium + this.skip(); + } await withFixtures( { fixtures: new FixtureBuilder().build(), diff --git a/ui/helpers/utils/export-utils.js b/ui/helpers/utils/export-utils.js index d9f65e946..993d2c90a 100644 --- a/ui/helpers/utils/export-utils.js +++ b/ui/helpers/utils/export-utils.js @@ -1,11 +1,93 @@ -import { getRandomFileName } from './util'; +/** + * @enum { string } + */ +export const ExportableContentType = { + JSON: 'application/json', + TXT: 'text/plain', +}; -export function exportAsFile(filename, data, type = 'text/csv') { +/** + * @enum { string } + */ +const ExtensionForContentType = { + [ExportableContentType.JSON]: '.json', + [ExportableContentType.TXT]: '.txt', +}; + +/** + * Export data as a file. + * + * @param {string} filename - The name of the file to export. + * @param {string} data - The data to export. + * @param {ExportableContentType} contentType - The content type of the file to export. + */ +export async function exportAsFile(filename, data, contentType) { + if (!ExtensionForContentType[contentType]) { + throw new Error(`Unsupported file type: ${contentType}`); + } + + if (supportsShowSaveFilePicker()) { + // Preferred method for downloads + await saveFileUsingFilePicker(filename, data, contentType); + } else { + saveFileUsingDataUri(filename, data, contentType); + } +} +/** + * Notes if the browser supports the File System Access API. + * + * @returns {boolean} + */ +function supportsShowSaveFilePicker() { + return ( + typeof window !== 'undefined' && + typeof window.showSaveFilePicker !== 'undefined' && + typeof window.Blob !== 'undefined' + ); +} + +/** + * Saves a file using the File System Access API. + * + * @param {string} filename - The name of the file to export. + * @param {string} data - The data to export. + * @param {ExportableContentType} contentType - The content type of the file to export. + * @returns {Promise} + */ +async function saveFileUsingFilePicker(filename, data, contentType) { + const blob = new window.Blob([data], { contentType }); + const fileExtension = ExtensionForContentType[contentType]; + + const handle = await window.showSaveFilePicker({ + suggestedName: filename, + types: [ + { + description: filename, + accept: { + [contentType]: [fileExtension], + }, + }, + ], + }); + + const writable = await handle.createWritable(); + await writable.write(blob); + await writable.close(); +} + +/** + * Saves a file using a data URI. + * This is a fallback for browsers that do not support the File System Access API. + * This method is less preferred because it requires the entire file to be encoded in a data URI. + * + * @param {string} filename - The name of the file to export. + * @param {string} data - The data to export. + * @param {ExportableContentType} contentType - The content type of the file to export. + */ +function saveFileUsingDataUri(filename, data, contentType) { const b64 = Buffer.from(data, 'utf8').toString('base64'); - // eslint-disable-next-line no-param-reassign - filename = filename || getRandomFileName(); - const elem = window.document.createElement('a'); - elem.href = `data:${type};Base64,${b64}`; + const elem = document.createElement('a'); + elem.href = `data:${contentType};Base64,${b64}`; elem.download = filename; document.body.appendChild(elem); elem.click(); diff --git a/ui/helpers/utils/export-utils.test.js b/ui/helpers/utils/export-utils.test.js new file mode 100644 index 000000000..a5ac3c2aa --- /dev/null +++ b/ui/helpers/utils/export-utils.test.js @@ -0,0 +1,68 @@ +import { exportAsFile, ExportableContentType } from './export-utils'; + +describe('exportAsFile', () => { + let windowSpy; + + beforeEach(() => { + windowSpy = jest.spyOn(window, 'window', 'get'); + }); + + afterEach(() => { + windowSpy.mockRestore(); + }); + + describe('when showSaveFilePicker is supported', () => { + it('uses .json file extension when content type is JSON', async () => { + const showSaveFilePicker = mockShowSaveFilePicker(); + const filename = 'test.json'; + const data = '{file: "content"}'; + windowSpy.mockImplementation(() => ({ + showSaveFilePicker, + Blob: global.Blob, + })); + + await exportAsFile(filename, data, ExportableContentType.JSON); + + expect(showSaveFilePicker).toHaveBeenCalledWith({ + suggestedName: filename, + types: [ + { + description: filename, + accept: { 'application/json': ['.json'] }, + }, + ], + }); + }); + + it('uses .txt file extension when content type is TXT', async () => { + const showSaveFilePicker = mockShowSaveFilePicker(); + const filename = 'test.txt'; + const data = 'file content'; + + windowSpy.mockImplementation(() => ({ + showSaveFilePicker, + Blob: global.Blob, + })); + + await exportAsFile(filename, data, ExportableContentType.TXT); + + expect(showSaveFilePicker).toHaveBeenCalledWith({ + suggestedName: filename, + types: [ + { + description: filename, + accept: { 'text/plain': ['.txt'] }, + }, + ], + }); + }); + }); +}); + +function mockShowSaveFilePicker() { + return jest.fn().mockResolvedValueOnce({ + createWritable: jest + .fn() + .mockResolvedValueOnce({ write: jest.fn(), close: jest.fn() }), + }); +} diff --git a/ui/pages/settings/advanced-tab/advanced-tab.component.js b/ui/pages/settings/advanced-tab/advanced-tab.component.js index cb3a701ee..a92451cf7 100644 --- a/ui/pages/settings/advanced-tab/advanced-tab.component.js +++ b/ui/pages/settings/advanced-tab/advanced-tab.component.js @@ -23,7 +23,10 @@ import { MetaMetricsEventName, } from '../../../../shared/constants/metametrics'; import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../../shared/constants/preferences'; -import { exportAsFile } from '../../../helpers/utils/export-utils'; +import { + exportAsFile, + ExportableContentType, +} from '../../../helpers/utils/export-utils'; import ActionableMessage from '../../../components/ui/actionable-message'; import ZENDESK_URLS from '../../../helpers/constants/zendesk-url'; import { BannerAlert } from '../../../components/component-library'; @@ -150,7 +153,7 @@ export default class AdvancedTab extends PureComponent { backupUserData = async () => { const { fileName, data } = await this.props.backupUserData(); - exportAsFile(fileName, data); + exportAsFile(fileName, data, ExportableContentType.JSON); this.context.trackEvent({ event: 'User Data Exported', @@ -185,7 +188,11 @@ export default class AdvancedTab extends PureComponent { if (err) { displayWarning(t('stateLogError')); } else { - exportAsFile(`${t('stateLogFileName')}.json`, result); + exportAsFile( + `${t('stateLogFileName')}.json`, + result, + ExportableContentType.JSON, + ); } }); }}