diff --git a/package-lock.json b/package-lock.json index 788051d2..473404c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,7 @@ "stylelint-config-idiomatic-order": "^9.0.0", "stylelint-config-standard": "^33.0.0", "stylelint-config-standard-scss": "^9.0.0", + "undici": "^5.22.1", "unplugin-fonts": "^1.0.3", "vite": "^4.2.0", "vite-plugin-electron": "^0.11.2", @@ -6548,6 +6549,18 @@ "node": ">= 10.0.0" } }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dev": true, + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -15834,6 +15847,15 @@ "duplexer": "~0.1.1" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -16763,6 +16785,18 @@ "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", "dev": true }, + "node_modules/undici": { + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.22.1.tgz", + "integrity": "sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==", + "dev": true, + "dependencies": { + "busboy": "^1.6.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -22355,6 +22389,15 @@ "sax": "^1.2.4" } }, + "busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dev": true, + "requires": { + "streamsearch": "^1.1.0" + } + }, "cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -29185,6 +29228,12 @@ "duplexer": "~0.1.1" } }, + "streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "dev": true + }, "string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -29898,6 +29947,15 @@ "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", "dev": true }, + "undici": { + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.22.1.tgz", + "integrity": "sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==", + "dev": true, + "requires": { + "busboy": "^1.6.0" + } + }, "unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", diff --git a/package.json b/package.json index 8516f3ef..201440c8 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "stylelint-config-idiomatic-order": "^9.0.0", "stylelint-config-standard": "^33.0.0", "stylelint-config-standard-scss": "^9.0.0", + "undici": "^5.22.1", "unplugin-fonts": "^1.0.3", "vite": "^4.2.0", "vite-plugin-electron": "^0.11.2", diff --git a/src/app/components/DownloadItem/DownloadItem.jsx b/src/app/components/DownloadItem/DownloadItem.jsx index dc1cd19b..93f3b620 100644 --- a/src/app/components/DownloadItem/DownloadItem.jsx +++ b/src/app/components/DownloadItem/DownloadItem.jsx @@ -22,6 +22,7 @@ import commafy from '../../utils/commafy' /** * @typedef {Object} DownloadItemProps * @property {String} downloadName The name of the DownloadItem. + * @property {Boolean} loadingMoreFiles Is EDD loading more download files. * @property {Object} progress The progress of the DownloadItem. * @property {String} state The state of the DownloadItem. * @property {Array} actionsList A 2-D array of objects detailing action attributes. @@ -35,6 +36,7 @@ import commafy from '../../utils/commafy' * -

+

{downloadName}

-
- {percent} - % -
- {humanizedDownloadStates[state] && ( -
- { - state === downloadStates.active && ( - - ) - } - { - state === downloadStates.completed && ( - - ) - } - {humanizedDownloadStates[state]} -
- )} -
- {finishedFiles} - {' '} - of - {' '} - {commafy(totalFiles)} - {' '} - files done in - {' '} - {humanizeDuration(totalTime * 1000)} -
+ { + (state !== downloadStates.pending && totalFiles > 0) && ( +
+ {percent} + % +
+ ) + } + { + humanizedDownloadStates[state] && ( +
+ { + state === downloadStates.active && ( + + ) + } + { + state === downloadStates.completed && ( + + ) + } + {humanizedDownloadStates[state]} +
+ ) + } + { + state !== downloadStates.pending && ( +
+ {commafy(finishedFiles)} + { + !loadingMoreFiles && ( + <> + {' '} + of + {' '} + {commafy(totalFiles)} + + ) + } + {' '} + files + { + (state !== downloadStates.pending && totalFiles > 0) && ( + <> + {' '} + done in + {' '} + {humanizeDuration(totalTime * 1000)} + + ) + } + { + loadingMoreFiles && ( + <> + {' '} + (determining file count) + + ) + } +
+ ) + }
{ @@ -161,7 +209,8 @@ const DownloadItem = ({ } DownloadItem.defaultProps = { - actionsList: null + actionsList: null, + loadingMoreFiles: false } DownloadItem.propTypes = { @@ -172,6 +221,7 @@ DownloadItem.propTypes = { totalFiles: PropTypes.number, totalTime: PropTypes.number }).isRequired, + loadingMoreFiles: PropTypes.bool, state: PropTypes.string.isRequired, actionsList: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.string.isRequired, diff --git a/src/app/components/DownloadItem/__tests__/DownloadItem.test.js b/src/app/components/DownloadItem/__tests__/DownloadItem.test.js index dad676d1..74ce091f 100644 --- a/src/app/components/DownloadItem/__tests__/DownloadItem.test.js +++ b/src/app/components/DownloadItem/__tests__/DownloadItem.test.js @@ -3,6 +3,8 @@ import { render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import userEvent from '@testing-library/user-event' +import { FaPause } from 'react-icons/fa' + import DownloadItem from '../DownloadItem' const progressObj = { @@ -13,47 +15,27 @@ const progressObj = { } describe('DownloadItem component', () => { - test('displays the download title', () => { - render( - - ) - - expect(screen.getByText('download-name')).toBeInTheDocument() - }) - - test('displays the download percentage', () => { - render( - - ) - - expect(screen.getByText('0%')).toBeInTheDocument() - }) + describe('when a download is pending', () => { + test('displays the correct download information', () => { + render( + + ) - test('displays a humanized status', () => { - render( - - ) - - expect(screen.getByText('0 of 1 files done in 0 seconds')).toBeInTheDocument() + expect(screen.getByTestId('download-item-name')).toHaveTextContent('download-name') + expect(screen.queryByTestId('download-item-percent')).not.toBeInTheDocument() + expect(screen.queryByTestId('download-item-status-description')).not.toBeInTheDocument() + expect(screen.getByTestId('download-item-state')).toHaveTextContent('Initializing') + }) }) describe('when a download is active', () => { - test('displays the correct download status', () => { + test('displays the correct download information', () => { render( { /> ) - expect(screen.getByText('Downloading')).toBeInTheDocument() + expect(screen.getByTestId('download-item-name')).toHaveTextContent('download-name') + expect(screen.getByTestId('download-item-percent')).toHaveTextContent('0%') + expect(screen.getByTestId('download-item-spinner')).toBeInTheDocument() + expect(screen.getByTestId('download-item-state')).toHaveTextContent('Downloading') + expect(screen.getByTestId('download-item-status-description')).toHaveTextContent('0 of 1 files done in 0 seconds') + }) + + describe('when more files are loading', () => { + test('displays the correct download information', () => { + render( + + ) + + expect(screen.getByTestId('download-item-name')).toHaveTextContent('download-name') + expect(screen.getByTestId('download-item-percent')).toHaveTextContent('0%') + expect(screen.getByTestId('download-item-spinner')).toBeInTheDocument() + expect(screen.getByTestId('download-item-state')).toHaveTextContent('Downloading') + expect(screen.getByTestId('download-item-status-description')).toHaveTextContent('0 files done in 0 seconds (determining file count)') + }) }) describe('when clicking a primary action button', () => { @@ -75,7 +81,7 @@ describe('DownloadItem component', () => { isActive: true, isPrimary: true, callback: jest.fn(), - icon: null + icon: FaPause } ] ] @@ -107,14 +113,14 @@ describe('DownloadItem component', () => { isActive: true, isPrimary: true, callback: jest.fn(), - icon: null + icon: FaPause }, { label: 'test-label-2', isActive: true, isPrimary: false, callback: jest.fn(), - icon: null + icon: FaPause } ] ] @@ -143,7 +149,7 @@ describe('DownloadItem component', () => { isActive: true, isPrimary: true, callback: jest.fn(), - icon: null + icon: FaPause } ] ] @@ -173,7 +179,7 @@ describe('DownloadItem component', () => { isActive: true, isPrimary: false, callback: jest.fn(), - icon: null + icon: FaPause } ] ] @@ -200,7 +206,7 @@ describe('DownloadItem component', () => { }) describe('when a download is paused', () => { - test('displays the correct download status', () => { + test('displays the correct download information', () => { render( { /> ) - expect(screen.getByText('Paused')).toBeInTheDocument() + expect(screen.getByTestId('download-item-name')).toHaveTextContent('download-name') + expect(screen.getByTestId('download-item-percent')).toHaveTextContent('0%') + expect(screen.queryByTestId('download-item-spinner')).not.toBeInTheDocument() + expect(screen.getByTestId('download-item-state')).toHaveTextContent('Paused') + expect(screen.getByTestId('download-item-status-description')).toHaveTextContent('0 of 1 files done in 0 seconds') }) }) describe('when a download is completed', () => { - test('displays the correct download status', () => { + test('displays the correct download information', () => { render( ) - expect(screen.getByText('Completed')).toBeInTheDocument() + expect(screen.getByTestId('download-item-name')).toHaveTextContent('download-name') + expect(screen.getByTestId('download-item-percent')).toHaveTextContent('100%') + expect(screen.queryByTestId('download-item-spinner')).not.toBeInTheDocument() + expect(screen.getByTestId('download-item-state')).toHaveTextContent('Completed') + expect(screen.getByTestId('download-item-status-description')).toHaveTextContent('1 of 1 files done in 0 seconds') }) }) }) diff --git a/src/app/constants/humanizedDownloadStates.js b/src/app/constants/humanizedDownloadStates.js index ce03ff1a..787715c4 100644 --- a/src/app/constants/humanizedDownloadStates.js +++ b/src/app/constants/humanizedDownloadStates.js @@ -1,4 +1,5 @@ const humanizedDownloadStates = { + PENDING: 'Initializing', ACTIVE: 'Downloading', COMPLETED: 'Completed', PAUSED: 'Paused', diff --git a/src/app/dialogs/InitializeDownload/InitializeDownload.jsx b/src/app/dialogs/InitializeDownload/InitializeDownload.jsx index 70d61c8f..7c85283f 100644 --- a/src/app/dialogs/InitializeDownload/InitializeDownload.jsx +++ b/src/app/dialogs/InitializeDownload/InitializeDownload.jsx @@ -129,6 +129,7 @@ const InitializeDownload = ({ variant="danger" Icon={FaBan} onClick={onCancel} + dataTestId="initialize-download-cancel-download" > Cancel diff --git a/src/app/dialogs/InitializeDownload/__tests__/InitializeDownload.test.js b/src/app/dialogs/InitializeDownload/__tests__/InitializeDownload.test.js index 8ac749c2..02aa6b91 100644 --- a/src/app/dialogs/InitializeDownload/__tests__/InitializeDownload.test.js +++ b/src/app/dialogs/InitializeDownload/__tests__/InitializeDownload.test.js @@ -9,12 +9,21 @@ import { ElectronApiContext } from '../../../context/ElectronApiContext' const setup = () => { const setDownloadIds = jest.fn() + const cancelDownloadItem = jest.fn() const chooseDownloadLocation = jest.fn() const beginDownload = jest.fn() const onCloseChooseLocationModal = jest.fn() render( - + { return { beginDownload, + cancelDownloadItem, chooseDownloadLocation } } @@ -77,4 +87,17 @@ describe('InitializeDownload component', () => { makeDefaultDownloadLocation: false }) }) + + test('clicking the cancel button sends a message to the main process', async () => { + const user = userEvent.setup() + + const { cancelDownloadItem } = setup() + + await user.click(screen.getByTestId('initialize-download-cancel-download')) + + expect(cancelDownloadItem).toHaveBeenCalledTimes(1) + expect(cancelDownloadItem).toHaveBeenCalledWith({ + downloadId: 'mock-id' + }) + }) }) diff --git a/src/app/pages/Downloads/Downloads.jsx b/src/app/pages/Downloads/Downloads.jsx index dec215f0..a90a4a3a 100644 --- a/src/app/pages/Downloads/Downloads.jsx +++ b/src/app/pages/Downloads/Downloads.jsx @@ -209,6 +209,7 @@ const Downloads = ({ const { finishedFiles } = progress const shouldShowPause = [ + downloadStates.pending, downloadStates.active ].includes(state) const shouldShowResume = [ @@ -216,6 +217,7 @@ const Downloads = ({ downloadStates.interrupted ].includes(state) const shouldShowCancel = [ + downloadStates.pending, downloadStates.paused, downloadStates.active, downloadStates.error, diff --git a/src/main/eventHandlers/__tests__/cancelDownloadItem.test.ts b/src/main/eventHandlers/__tests__/cancelDownloadItem.test.ts new file mode 100644 index 00000000..ff1294dd --- /dev/null +++ b/src/main/eventHandlers/__tests__/cancelDownloadItem.test.ts @@ -0,0 +1,88 @@ +import cancelDownloadItem from '../cancelDownloadItem' + +describe('cancelDownloadItem', () => { + describe('when downloadId and name are provided', () => { + test('calls currentDownloadItems.cancelItem', () => { + const currentDownloadItems = { + cancelItem: jest.fn() + } + const info = { + downloadId: 'mock-download-id', + name: 'mock-filename.png' + } + const store = { + delete: jest.fn(), + set: jest.fn() + } + + cancelDownloadItem({ + currentDownloadItems, + info, + store + }) + + expect(currentDownloadItems.cancelItem).toHaveBeenCalledTimes(1) + expect(currentDownloadItems.cancelItem).toHaveBeenCalledWith('mock-download-id', 'mock-filename.png') + + expect(store.set).toHaveBeenCalledTimes(1) + expect(store.set).toHaveBeenCalledWith('downloads.mock-download-id.state', 'COMPLETED') + }) + }) + + describe('when only downloadId is provided', () => { + test('calls currentDownloadItems.cancelItem and updates the store', () => { + const currentDownloadItems = { + cancelItem: jest.fn() + } + const info = { + downloadId: 'mock-download-id' + } + const store = { + delete: jest.fn(), + set: jest.fn() + } + + cancelDownloadItem({ + currentDownloadItems, + info, + store + }) + + expect(currentDownloadItems.cancelItem).toHaveBeenCalledTimes(1) + expect(currentDownloadItems.cancelItem).toHaveBeenCalledWith('mock-download-id', undefined) + + expect(store.set).toHaveBeenCalledTimes(1) + expect(store.set).toHaveBeenCalledWith('downloads.mock-download-id.state', 'COMPLETED') + + expect(store.delete).toHaveBeenCalledTimes(1) + expect(store.delete).toHaveBeenCalledWith('downloads.mock-download-id') + }) + }) + + describe('when no downloadId or name is provided', () => { + test('calls currentDownloadItems.cancelItem and updates the store', () => { + const currentDownloadItems = { + cancelItem: jest.fn() + } + const info = {} + const store = { + delete: jest.fn(), + set: jest.fn() + } + + cancelDownloadItem({ + currentDownloadItems, + info, + store + }) + + expect(currentDownloadItems.cancelItem).toHaveBeenCalledTimes(1) + expect(currentDownloadItems.cancelItem).toHaveBeenCalledWith(undefined, undefined) + + expect(store.set).toHaveBeenCalledTimes(0) + + expect(store.delete).toHaveBeenCalledTimes(1) + expect(store.delete).toHaveBeenCalledWith('downloads') + }) + }) +}) diff --git a/src/main/eventHandlers/__tests__/pauseDownloadItem.test.ts b/src/main/eventHandlers/__tests__/pauseDownloadItem.test.ts new file mode 100644 index 00000000..0b573260 --- /dev/null +++ b/src/main/eventHandlers/__tests__/pauseDownloadItem.test.ts @@ -0,0 +1,133 @@ +import pauseDownloadItem from '../pauseDownloadItem' + +describe('pauseDownloadItem', () => { + describe('when downloadId and name are provided', () => { + test('calls currentDownloadItems.pauseItem', () => { + const currentDownloadItems = { + pauseItem: jest.fn() + } + const info = { + downloadId: 'mock-download-id', + name: 'mock-filename.png' + } + const store = { + get: jest.fn(), + set: jest.fn() + } + + pauseDownloadItem({ + currentDownloadItems, + info, + store + }) + + expect(currentDownloadItems.pauseItem).toHaveBeenCalledTimes(1) + expect(currentDownloadItems.pauseItem).toHaveBeenCalledWith('mock-download-id', 'mock-filename.png') + }) + }) + + describe('when only downloadId is provided', () => { + test('calls currentDownloadItems.pauseItem and updates the store', () => { + const currentDownloadItems = { + pauseItem: jest.fn() + } + const info = { + downloadId: 'mock-download-id' + } + const store = { + get: jest.fn(), + set: jest.fn() + } + + pauseDownloadItem({ + currentDownloadItems, + info, + store + }) + + expect(currentDownloadItems.pauseItem).toHaveBeenCalledTimes(1) + expect(currentDownloadItems.pauseItem).toHaveBeenCalledWith('mock-download-id', undefined) + + expect(store.set).toHaveBeenCalledTimes(1) + expect(store.set).toHaveBeenCalledWith('downloads.mock-download-id.state', 'PAUSED') + }) + }) + + describe('when no downloadId or name is provided', () => { + test('calls currentDownloadItems.pauseItem and updates the store', () => { + const currentDownloadItems = { + pauseItem: jest.fn() + } + const info = {} + const store = { + get: jest.fn().mockReturnValue({ + download1: { + files: { + 'file1.png': { + url: 'http://example.com/file1.png', + state: 'COMPLETE', + percent: 100 + } + }, + state: 'COMPLETE' + }, + download2: { + files: { + 'file2.png': { + url: 'http://example.com/file2.png', + state: 'ACTIVE', + percent: 42 + } + }, + state: 'ACTIVE' + }, + download3: { + files: {}, + state: 'PENDING' + } + }), + set: jest.fn() + } + + pauseDownloadItem({ + currentDownloadItems, + info, + store + }) + + expect(currentDownloadItems.pauseItem).toHaveBeenCalledTimes(1) + expect(currentDownloadItems.pauseItem).toHaveBeenCalledWith(undefined, undefined) + + expect(store.get).toHaveBeenCalledTimes(1) + expect(store.get).toHaveBeenCalledWith('downloads') + + expect(store.set).toHaveBeenCalledTimes(1) + expect(store.set).toHaveBeenCalledWith('downloads', { + download1: { + files: { + 'file1.png': { + percent: 100, + state: 'COMPLETE', + url: 'http://example.com/file1.png' + } + }, + state: 'COMPLETE' + }, + download2: { + files: { + 'file2.png': { + percent: 42, + state: 'ACTIVE', + url: 'http://example.com/file2.png' + } + }, + state: 'PAUSED' + }, + download3: { + files: {}, + state: 'PAUSED' + } + }) + }) + }) +}) diff --git a/src/main/eventHandlers/__tests__/resumeDownloadItem.test.ts b/src/main/eventHandlers/__tests__/resumeDownloadItem.test.ts new file mode 100644 index 00000000..4400b8f7 --- /dev/null +++ b/src/main/eventHandlers/__tests__/resumeDownloadItem.test.ts @@ -0,0 +1,178 @@ +import resumeDownloadItem from '../resumeDownloadItem' + +describe('resumeDownloadItem', () => { + describe('when downloadId and name are provided', () => { + test('calls currentDownloadItems.resumeItem', () => { + const currentDownloadItems = { + resumeItem: jest.fn() + } + const info = { + downloadId: 'mock-download-id', + name: 'mock-filename.png' + } + const store = {} + + resumeDownloadItem({ + currentDownloadItems, + info, + store + }) + + expect(currentDownloadItems.resumeItem).toHaveBeenCalledTimes(1) + expect(currentDownloadItems.resumeItem).toHaveBeenCalledWith('mock-download-id', 'mock-filename.png') + }) + }) + + describe('when only downloadId is provided', () => { + describe('when the download does not have files', () => { + test('calls currentDownloadItems.resumeItem and updates the store', () => { + const currentDownloadItems = { + resumeItem: jest.fn() + } + const info = { + downloadId: 'mock-download-id' + } + const store = { + get: jest.fn() + .mockReturnValue({ + files: {} + }), + set: jest.fn() + } + + resumeDownloadItem({ + currentDownloadItems, + info, + store + }) + + expect(currentDownloadItems.resumeItem).toHaveBeenCalledTimes(1) + expect(currentDownloadItems.resumeItem).toHaveBeenCalledWith('mock-download-id', undefined) + + expect(store.get).toHaveBeenCalledTimes(1) + expect(store.get).toHaveBeenCalledWith('downloads.mock-download-id') + + expect(store.set).toHaveBeenCalledTimes(1) + expect(store.set).toHaveBeenCalledWith('downloads.mock-download-id.state', 'PENDING') + }) + }) + + describe('when the download has files', () => { + test('calls currentDownloadItems.resumeItem and updates the store', () => { + const currentDownloadItems = { + resumeItem: jest.fn() + } + const info = { + downloadId: 'mock-download-id' + } + const store = { + get: jest.fn() + .mockReturnValue({ + files: { + 'file1.png': { + url: 'http://example.com/file1.png', + state: 'ACTIVE', + percent: 42 + } + } + }), + set: jest.fn() + } + + resumeDownloadItem({ + currentDownloadItems, + info, + store + }) + + expect(currentDownloadItems.resumeItem).toHaveBeenCalledTimes(1) + expect(currentDownloadItems.resumeItem).toHaveBeenCalledWith('mock-download-id', undefined) + + expect(store.get).toHaveBeenCalledTimes(1) + expect(store.get).toHaveBeenCalledWith('downloads.mock-download-id') + + expect(store.set).toHaveBeenCalledTimes(1) + expect(store.set).toHaveBeenCalledWith('downloads.mock-download-id.state', 'ACTIVE') + }) + }) + }) + + describe('when no downloadId or name is provided', () => { + test('calls currentDownloadItems.resumeItem and updates the store', () => { + const currentDownloadItems = { + resumeItem: jest.fn() + } + const info = {} + const store = { + get: jest.fn() + .mockReturnValue({ + download1: { + files: { + 'file1.png': { + url: 'http://example.com/file1.png', + state: 'COMPLETE', + percent: 100 + } + }, + state: 'COMPLETE' + }, + download2: { + files: { + 'file2.png': { + url: 'http://example.com/file2.png', + state: 'PAUSED', + percent: 42 + } + }, + state: 'PAUSED' + }, + download3: { + files: {}, + state: 'PAUSED' + } + }), + set: jest.fn() + } + + resumeDownloadItem({ + currentDownloadItems, + info, + store + }) + + expect(currentDownloadItems.resumeItem).toHaveBeenCalledTimes(1) + expect(currentDownloadItems.resumeItem).toHaveBeenCalledWith(undefined, undefined) + + expect(store.get).toHaveBeenCalledTimes(1) + expect(store.get).toHaveBeenCalledWith('downloads') + + expect(store.set).toHaveBeenCalledTimes(1) + expect(store.set).toHaveBeenCalledWith('downloads', { + download1: { + files: { + 'file1.png': { + percent: 100, + state: 'COMPLETE', + url: 'http://example.com/file1.png' + } + }, + state: 'COMPLETE' + }, + download2: { + files: { + 'file2.png': { + percent: 42, + state: 'PAUSED', + url: 'http://example.com/file2.png' + } + }, + state: 'ACTIVE' + }, + download3: { + files: {}, + state: 'PENDING' + } + }) + }) + }) +}) diff --git a/src/main/eventHandlers/cancelDownloadItem.ts b/src/main/eventHandlers/cancelDownloadItem.ts new file mode 100644 index 00000000..a1961b51 --- /dev/null +++ b/src/main/eventHandlers/cancelDownloadItem.ts @@ -0,0 +1,32 @@ +// @ts-nocheck + +import downloadStates from '../../app/constants/downloadStates' + +/** + * Cancels a download and updates the store state + * @param {Object} params + * @param {Object} params.currentDownloadItems CurrentDownloadItems class instance that holds all of the active DownloadItems instances + * @param {Object} params.info `info` parameter from ipc message + * @param {Object} params.store `electron-store` instance + */ +const cancelDownloadItem = ({ + currentDownloadItems, + info, + store +}) => { + const { downloadId, name } = info + + if (downloadId) { + store.set(`downloads.${downloadId.replaceAll('.', '\\.')}.state`, downloadStates.completed) + } + + currentDownloadItems.cancelItem(downloadId, name) + + // Cancelling a download will remove it from the list of downloads + // TODO how will this work when cancelling a granule download? I don't think we want to remove single items from a provided list of links + if (downloadId && !name) store.delete(`downloads.${downloadId.replaceAll('.', '\\.')}`) + + if (!downloadId) store.delete('downloads') +} + +export default cancelDownloadItem diff --git a/src/main/eventHandlers/pauseDownloadItem.ts b/src/main/eventHandlers/pauseDownloadItem.ts new file mode 100644 index 00000000..f4c6e430 --- /dev/null +++ b/src/main/eventHandlers/pauseDownloadItem.ts @@ -0,0 +1,40 @@ +// @ts-nocheck + +import downloadStates from '../../app/constants/downloadStates' + +/** + * Pauses a download and updates the store state + * @param {Object} params + * @param {Object} params.currentDownloadItems CurrentDownloadItems class instance that holds all of the active DownloadItems instances + * @param {Object} params.info `info` parameter from ipc message + * @param {Object} params.store `electron-store` instance + */ +const pauseDownloadItem = ({ + currentDownloadItems, + info, + store +}) => { + const { downloadId, name } = info + + currentDownloadItems.pauseItem(downloadId, name) + + if (downloadId && !name) store.set(`downloads.${downloadId.replaceAll('.', '\\.')}.state`, downloadStates.paused) + + if (!downloadId) { + const downloads = store.get('downloads') + + Object.keys(downloads).forEach((downloadId) => { + const download = downloads[downloadId] + const { state } = download + + const newState = state === downloadStates.active || state === downloadStates.pending + ? downloadStates.paused + : state + download.state = newState + }) + + store.set('downloads', downloads) + } +} + +export default pauseDownloadItem diff --git a/src/main/eventHandlers/reportProgress.ts b/src/main/eventHandlers/reportProgress.ts index e43d2c0d..556aca58 100644 --- a/src/main/eventHandlers/reportProgress.ts +++ b/src/main/eventHandlers/reportProgress.ts @@ -15,8 +15,6 @@ const reportProgress = ({ } const progress = Object.keys(downloads) - // Only report progress on downloads that are not `pending` - .filter((key) => downloads[key].state !== downloadStates.pending) // Show the newest downloads first .sort((a, b) => { if (downloads[a].timeStart > downloads[b].timeStart) return -1 @@ -27,7 +25,7 @@ const reportProgress = ({ const download = downloads[downloadId] const { - files, + files = {}, loadingMoreFiles, name: downloadName = downloadId, state, diff --git a/src/main/eventHandlers/resumeDownloadItem.ts b/src/main/eventHandlers/resumeDownloadItem.ts new file mode 100644 index 00000000..3bc4dd8c --- /dev/null +++ b/src/main/eventHandlers/resumeDownloadItem.ts @@ -0,0 +1,59 @@ +// @ts-nocheck + +import downloadStates from '../../app/constants/downloadStates' + +/** + * Resumes a download and updates the store state + * @param {Object} params + * @param {Object} params.currentDownloadItems CurrentDownloadItems class instance that holds all of the active DownloadItems instances + * @param {Object} params.info `info` parameter from ipc message + * @param {Object} params.store `electron-store` instance + */ +const resumeDownloadItem = ({ + currentDownloadItems, + info, + store +}) => { + const { downloadId, name } = info + + currentDownloadItems.resumeItem(downloadId, name) + + if (downloadId && !name) { + const download = store.get(`downloads.${downloadId.replaceAll('.', '\\.')}`) + const { files = {} } = download + const numberFiles = Object.keys(files).length + + // If files exist, put the item into active + let newState = downloadStates.active + + // If no files exist, put the item back into pending + if (numberFiles === 0) newState = downloadStates.pending + + store.set(`downloads.${downloadId.replaceAll('.', '\\.')}.state`, newState) + } + + if (!downloadId) { + const downloads = store.get('downloads') + + Object.keys(downloads).forEach((downloadId) => { + const download = downloads[downloadId] + + const { files = {}, state } = download + const numberFiles = Object.keys(files).length + + // Default to the current state value + let newState = state + + // If the state is paused and there are files, set to active + if (state === downloadStates.paused && numberFiles > 0) newState = downloadStates.active + // If the state is paused and there are no files, set to pending + if (state === downloadStates.paused && numberFiles === 0) newState = downloadStates.pending + + download.state = newState + }) + + store.set('downloads', downloads) + } +} + +export default resumeDownloadItem diff --git a/src/main/main.ts b/src/main/main.ts index e80a9f6f..ce0815f0 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -12,18 +12,19 @@ import Store from 'electron-store' import storageSchema from './storageSchema.json' import beginDownload from './eventHandlers/beginDownload' +import cancelDownloadItem from './eventHandlers/cancelDownloadItem' import chooseDownloadLocation from './eventHandlers/chooseDownloadLocation' import clearDefaultDownload from './eventHandlers/clearDefaultDownload' import copyDownloadPath from './eventHandlers/copyDownloadPath' import openDownloadFolder from './eventHandlers/openDownloadFolder' import openUrl from './eventHandlers/openUrl' +import pauseDownloadItem from './eventHandlers/pauseDownloadItem' import reportProgress from './eventHandlers/reportProgress' +import resumeDownloadItem from './eventHandlers/resumeDownloadItem' import willDownload from './eventHandlers/willDownload' -import windowStateKeeper from './utils/windowStateKeeper' import CurrentDownloadItems from './utils/currentDownloadItems' - -import downloadStates from '../app/constants/downloadStates' +import windowStateKeeper from './utils/windowStateKeeper' const store = new Store({ // TODO set this key before publishing application @@ -136,61 +137,27 @@ const createWindow = () => { }) ipcMain.on('pauseDownloadItem', (event, info) => { - const { downloadId, name } = info - - currentDownloadItems.pauseItem(downloadId, name) - - if (downloadId && !name) store.set(`downloads.${downloadId.replaceAll('.', '\\.')}.state`, downloadStates.paused) - - if (!downloadId) { - const downloads = store.get('downloads') - Object.keys(downloads).forEach((downloadId) => { - const download = downloads[downloadId] - const { state } = download - - const newState = state === downloadStates.active ? downloadStates.paused : state - download.state = newState - }) - store.set('downloads', downloads) - } + pauseDownloadItem({ + currentDownloadItems, + info, + store + }) }) ipcMain.on('cancelDownloadItem', (event, info) => { - const { downloadId, name } = info - - if (downloadId) { - store.set(`downloads.${downloadId.replaceAll('.', '\\.')}.state`, downloadStates.completed) - } - - currentDownloadItems.cancelItem(downloadId, name) - - // Cancelling a download will remove it from the list of downloads - // TODO how will this work when cancelling a granule download? I don't think we want to remove single items from a provided list of links - if (downloadId && !name) store.delete(`downloads.${downloadId.replaceAll('.', '\\.')}`) - - if (!downloadId) store.delete('downloads') + cancelDownloadItem({ + currentDownloadItems, + info, + store + }) }) ipcMain.on('resumeDownloadItem', (event, info) => { - const { downloadId, name } = info - - currentDownloadItems.resumeItem(downloadId, name) - - if (downloadId && !name) store.set(`downloads.${downloadId.replaceAll('.', '\\.')}.state`, downloadStates.active) - - if (!downloadId) { - const downloads = store.get('downloads') - - Object.keys(downloads).forEach((downloadId) => { - const download = downloads[downloadId] - const { state } = download - - const newState = state === downloadStates.paused ? downloadStates.active : state - download.state = newState - }) - - store.set('downloads', downloads) - } + resumeDownloadItem({ + currentDownloadItems, + info, + store + }) }) // Set up an interval to report progress to the renderer process every 1s diff --git a/src/main/utils/__tests__/fetchLinks.test.ts b/src/main/utils/__tests__/fetchLinks.test.ts index e43f4b62..b283452b 100644 --- a/src/main/utils/__tests__/fetchLinks.test.ts +++ b/src/main/utils/__tests__/fetchLinks.test.ts @@ -20,7 +20,7 @@ beforeEach(() => { }) describe('fetchLinks', () => { - test.only('loads the links and calls initializeDownload', async () => { + test('loads the links and calls initializeDownload', async () => { const appWindow = {} const downloadId = 'shortName_versionId' const getLinks = 'http://localhost:3000/granule_links?id=42&flattenLinks=true&linkTypes=data' @@ -98,21 +98,24 @@ describe('fetchLinks', () => { downloadIds: ['shortName_versionId-20230501_000000'] })) - expect(store.set).toHaveBeenCalledTimes(3) + expect(store.set).toHaveBeenCalledTimes(4) expect(store.set).toHaveBeenCalledWith( 'downloads.shortName_versionId-20230501_000000', { - files: { - 'file1.png': { - percent: 0, - state: 'PENDING', - url: 'https://example.com/file1.png' - } - }, loadingMoreFiles: true, state: 'PENDING' } ) + expect(store.set).toHaveBeenCalledWith( + 'downloads.shortName_versionId-20230501_000000.files', + { + 'file1.png': { + percent: 0, + state: 'PENDING', + url: 'https://example.com/file1.png' + } + } + ) expect(store.set).toHaveBeenCalledWith( 'downloads.shortName_versionId-20230501_000000.files', { @@ -131,7 +134,7 @@ describe('fetchLinks', () => { expect(store.set).toHaveBeenCalledWith('downloads.shortName_versionId-20230501_000000.loadingMoreFiles', false) }) - test.only('does not call initializeDownload on error', async () => { + test('saves the error on error', async () => { const appWindow = {} const downloadId = 'shortName_versionId' const getLinks = 'http://localhost:3000/granule_links?id=42&flattenLinks=true&linkTypes=data' @@ -139,10 +142,14 @@ describe('fetchLinks', () => { set: jest.fn(), get: jest.fn() .mockReturnValueOnce({ - 'file1.png': { - percent: 0, - state: 'PENDING', - url: 'https://example.com/file1.png' + loadingMoreFiles: true, + state: 'PENDING', + files: { + 'file1.png': { + percent: 0, + state: 'PENDING', + url: 'https://example.com/file1.png' + } } }) } @@ -174,6 +181,31 @@ describe('fetchLinks', () => { expect(initializeDownload).toHaveBeenCalledTimes(0) - expect(store.set).toHaveBeenCalledTimes(0) + expect(store.get).toHaveBeenCalledTimes(1) + expect(store.get).toHaveBeenCalledWith('downloads.shortName_versionId-20230501_000000') + + expect(store.set).toHaveBeenCalledTimes(2) + expect(store.set).toHaveBeenCalledWith( + 'downloads.shortName_versionId-20230501_000000', + { + loadingMoreFiles: true, + state: 'PENDING' + } + ) + expect(store.set).toHaveBeenCalledWith( + 'downloads.shortName_versionId-20230501_000000', + { + files: { + 'file1.png': { + percent: 0, + state: 'PENDING', + url: 'https://example.com/file1.png' + } + }, + loadingMoreFiles: false, + state: 'ERROR', + error: 'error' + } + ) }) }) diff --git a/src/main/utils/fetchLinks.ts b/src/main/utils/fetchLinks.ts index 7e46930c..29dfeab9 100644 --- a/src/main/utils/fetchLinks.ts +++ b/src/main/utils/fetchLinks.ts @@ -7,8 +7,6 @@ import initializeDownload from './initializeDownload' import downloadStates from '../../app/constants/downloadStates' -// import beginDownload from '../eventHandlers/beginDownload' - // TODO? find a way to still use test downloads // const { downloads } = require('../../test-download-files.json') // const { downloads } = require('../../test-download-files-one-collection.json') @@ -44,12 +42,6 @@ const fetchLinks = async ({ token, appWindow }) => { - // TODO add docs file(s) for this request/response format - // Fetch the first page of links from `url` - - // If the response contains a `cursor`, append it to the URL parameters and keep requesting until no items come back - // If the response does not contain a `cursor` increment the pageNum parameter and keep requesting until no items come back - const now = new Date() .toISOString() .replace(/(:|-)/g, '') @@ -58,6 +50,12 @@ const fetchLinks = async ({ const downloadIdWithTime = `${downloadId.replaceAll('.', '\\.')}-${now}` + // Create a download in the store with the first page of links + store.set(`downloads.${downloadIdWithTime}`, { + loadingMoreFiles: true, + state: downloadStates.pending + }) + let finished = false let pageNum = 1 let cursor @@ -108,12 +106,7 @@ const fetchLinks = async ({ // If this is the first response back, create a download in the store if (pageNum === 1) { - // Create a download in the store with the first page of links - store.set(`downloads.${downloadIdWithTime}`, { - state: downloadStates.pending, - loadingMoreFiles: !done, - files: formatLinks(links) - }) + store.set(`downloads.${downloadIdWithTime}.files`, formatLinks(links)) // Initialize download will let the renderer process know to start a download initializeDownload({ @@ -131,13 +124,19 @@ const fetchLinks = async ({ }) } - if (done) finished = true + finished = done cursor = responseCursor pageNum += 1 } } catch (error) { - // TODO what do we need to do here? - console.log('🚀 ~ file: fetchLinks.ts:43 ~ fetchLinks ~ error:', error) + const download = store.get(`downloads.${downloadIdWithTime}`) + + store.set(`downloads.${downloadIdWithTime}`, { + ...download, + loadingMoreFiles: false, + state: downloadStates.error, + error: error.message + }) } }