diff --git a/docs/api.md b/docs/api.md index ef2284f67a..b4a730c45d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -3182,6 +3182,7 @@ const path = await download.path(); - [download.delete()](#downloaddelete) - [download.failure()](#downloadfailure) - [download.path()](#downloadpath) +- [download.saveAs(path)](#downloadsaveaspath) - [download.suggestedFilename()](#downloadsuggestedfilename) - [download.url()](#downloadurl) @@ -3206,6 +3207,12 @@ Returns download error if any. Returns path to the downloaded file in case of successful download. +#### download.saveAs(path) +- `path` <[string]> Path where the download should be saved. The directory structure MUST exist as `saveAs` will not create it. +- returns: <[Promise]> + +Saves the download to a user-specified path. + #### download.suggestedFilename() - returns: <[string]> diff --git a/src/download.ts b/src/download.ts index dbf95d20f9..38ed85d4ce 100644 --- a/src/download.ts +++ b/src/download.ts @@ -27,6 +27,8 @@ export class Download { private _uuid: string; private _finishedCallback: () => void; private _finishedPromise: Promise; + private _saveAsRequests: { fulfill: () => void; reject: (error?: any) => void; path: string }[] = []; + private _loaded: boolean = false; private _page: Page; private _acceptDownloads: boolean; private _failure: string | null = null; @@ -72,6 +74,26 @@ export class Download { return fileName; } + async saveAs(path: string) { + if (this._loaded) { + await this._saveAs(path); + return; + } + + return new Promise((fulfill, reject) => this._saveAsRequests.push({fulfill, reject, path})); + } + + async _saveAs(dlPath: string) { + if (!this._acceptDownloads) + throw new Error('Pass { acceptDownloads: true } when you are creating your browser context.'); + const fileName = path.join(this._downloadsPath, this._uuid); + if (this._failure) + throw new Error('Download not found on disk. Check download.failure() for details.'); + if (this._deleted) + throw new Error('Download already deleted. Save before deleting.'); + await util.promisify(fs.copyFile)(fileName, dlPath); + } + async failure(): Promise { if (!this._acceptDownloads) return 'Pass { acceptDownloads: true } when you are creating your browser context.'; @@ -95,7 +117,26 @@ export class Download { await util.promisify(fs.unlink)(fileName).catch(e => {}); } - _reportFinished(error?: string) { + async _reportFinished(error?: string) { + if (error) { + for (const { reject } of this._saveAsRequests) { + if (!this._acceptDownloads) + reject(new Error('Pass { acceptDownloads: true } when you are creating your browser context.')); + else + reject(error); + } + } else { + for (const { fulfill, reject, path } of this._saveAsRequests) { + try { + await this._saveAs(path); + fulfill(); + } catch (err) { + reject(err); + } + } + } + + this._loaded = true; this._failure = error || null; this._finishedCallback(); } diff --git a/src/rpc/channels.ts b/src/rpc/channels.ts index 310622f747..5d8c129d68 100644 --- a/src/rpc/channels.ts +++ b/src/rpc/channels.ts @@ -1530,6 +1530,7 @@ export type DownloadInitializer = { }; export interface DownloadChannel extends Channel { path(params?: DownloadPathParams): Promise; + saveAs(params: DownloadSaveAsParams): Promise; failure(params?: DownloadFailureParams): Promise; stream(params?: DownloadStreamParams): Promise; delete(params?: DownloadDeleteParams): Promise; @@ -1538,6 +1539,10 @@ export type DownloadPathParams = {}; export type DownloadPathResult = { value?: string, }; +export type DownloadSaveAsParams = { + path: string, +}; +export type DownloadSaveAsResult = void; export type DownloadFailureParams = {}; export type DownloadFailureResult = { error?: string, diff --git a/src/rpc/client/download.ts b/src/rpc/client/download.ts index 648954d1a3..080de5b359 100644 --- a/src/rpc/client/download.ts +++ b/src/rpc/client/download.ts @@ -40,6 +40,12 @@ export class Download extends ChannelOwner return (await this._channel.path()).value || null; } + async saveAs(path: string): Promise { + return this._wrapApiCall('download.saveAs', async () => { + await this._channel.saveAs({ path }); + }); + } + async failure(): Promise { return (await this._channel.failure()).error || null; } diff --git a/src/rpc/protocol.pdl b/src/rpc/protocol.pdl index 4b0d375ff6..b71aec53a2 100644 --- a/src/rpc/protocol.pdl +++ b/src/rpc/protocol.pdl @@ -1390,6 +1390,10 @@ interface Download returns value?: string + command saveAs + parameters + path: string + command failure returns error?: string @@ -1474,4 +1478,3 @@ interface ElectronApplication handle: JSHandle command close - diff --git a/src/rpc/server/downloadDispatcher.ts b/src/rpc/server/downloadDispatcher.ts index be3d5c9cd4..fe0f961f0f 100644 --- a/src/rpc/server/downloadDispatcher.ts +++ b/src/rpc/server/downloadDispatcher.ts @@ -32,6 +32,10 @@ export class DownloadDispatcher extends Dispatcher { + await this._object.saveAs(params.path); + } + async stream(): Promise<{ stream?: StreamChannel }> { const stream = await this._object.createReadStream(); if (!stream) diff --git a/test/download.jest.js b/test/download.jest.js index 5c0a1969dd..6289ebfabb 100644 --- a/test/download.jest.js +++ b/test/download.jest.js @@ -16,8 +16,23 @@ const fs = require('fs'); const path = require('path'); +const util = require('util'); +const os = require('os'); +const removeFolder = require('rimraf'); +const mkdtempAsync = util.promisify(fs.mkdtemp); +const removeFolderAsync = util.promisify(removeFolder); + const {FFOX, CHROMIUM, WEBKIT, HEADLESS} = testOptions; +registerFixture('persistentDirectory', async ({}, test) => { + const persistentDirectory = await mkdtempAsync(path.join(os.tmpdir(), 'playwright-test-')); + try { + await test(persistentDirectory); + } finally { + await removeFolderAsync(persistentDirectory); + } +}); + describe('Download', function() { beforeEach(async ({server}) => { server.setRoute('/download', (req, res) => { @@ -57,6 +72,110 @@ describe('Download', function() { expect(fs.readFileSync(path).toString()).toBe('Hello world'); await page.close(); }); + it('should save to user-specified path', async({persistentDirectory, browser, server}) => { + const page = await browser.newPage({ acceptDownloads: true }); + await page.setContent(`download`); + const [ download ] = await Promise.all([ + page.waitForEvent('download'), + page.click('a') + ]); + const userPath = path.join(persistentDirectory, "download.txt"); + await download.saveAs(userPath); + expect(fs.existsSync(userPath)).toBeTruthy(); + expect(fs.readFileSync(userPath).toString()).toBe('Hello world'); + await page.close(); + }); + it('should save to user-specified path without updating original path', async({persistentDirectory, browser, server}) => { + const page = await browser.newPage({ acceptDownloads: true }); + await page.setContent(`download`); + const [ download ] = await Promise.all([ + page.waitForEvent('download'), + page.click('a') + ]); + const userPath = path.join(persistentDirectory, "download.txt"); + await download.saveAs(userPath); + expect(fs.existsSync(userPath)).toBeTruthy(); + expect(fs.readFileSync(userPath).toString()).toBe('Hello world'); + + const originalPath = await download.path(); + expect(fs.existsSync(originalPath)).toBeTruthy(); + expect(fs.readFileSync(originalPath).toString()).toBe('Hello world'); + await page.close(); + }); + it('should save to two different paths with multiple saveAs calls', async({persistentDirectory, browser, server}) => { + const page = await browser.newPage({ acceptDownloads: true }); + await page.setContent(`download`); + const [ download ] = await Promise.all([ + page.waitForEvent('download'), + page.click('a') + ]); + const userPath = path.join(persistentDirectory, "download.txt"); + await download.saveAs(userPath); + expect(fs.existsSync(userPath)).toBeTruthy(); + expect(fs.readFileSync(userPath).toString()).toBe('Hello world'); + + const anotherUserPath = path.join(persistentDirectory, "download (2).txt"); + await download.saveAs(anotherUserPath); + expect(fs.existsSync(anotherUserPath)).toBeTruthy(); + expect(fs.readFileSync(anotherUserPath).toString()).toBe('Hello world'); + await page.close(); + }); + it('should save to overwritten filepath', async({persistentDirectory, browser, server}) => { + const page = await browser.newPage({ acceptDownloads: true }); + await page.setContent(`download`); + const [ download ] = await Promise.all([ + page.waitForEvent('download'), + page.click('a') + ]); + const userPath = path.join(persistentDirectory, "download.txt"); + await download.saveAs(userPath); + expect((await util.promisify(fs.readdir)(persistentDirectory)).length).toBe(1); + await download.saveAs(userPath); + expect((await util.promisify(fs.readdir)(persistentDirectory)).length).toBe(1); + expect(fs.existsSync(userPath)).toBeTruthy(); + expect(fs.readFileSync(userPath).toString()).toBe('Hello world'); + await page.close(); + }); + it('should error when saving to non-existent user-specified path', async({persistentDirectory, browser, server}) => { + const page = await browser.newPage({ acceptDownloads: true }); + await page.setContent(`download`); + const [ download ] = await Promise.all([ + page.waitForEvent('download'), + page.click('a') + ]); + const nonExistentUserPath = path.join(persistentDirectory, "does-not-exist","download.txt"); + const { message } = await download.saveAs(nonExistentUserPath).catch(e => e); + expect(message).toContain('ENOENT'); + expect(message).toContain('copyfile'); + expect(message).toContain('no such file or directory'); + expect(message).toContain('does-not-exist'); + await page.close(); + }); + it('should error when saving with downloads disabled', async({persistentDirectory, browser, server}) => { + const page = await browser.newPage({ acceptDownloads: false }); + await page.setContent(`download`); + const [ download ] = await Promise.all([ + page.waitForEvent('download'), + page.click('a') + ]); + const userPath = path.join(persistentDirectory, "download.txt"); + const { message } = await download.saveAs(userPath).catch(e => e); + expect(message).toContain('Pass { acceptDownloads: true } when you are creating your browser context'); + await page.close(); + }); + it('should error when saving after deletion', async({persistentDirectory, browser, server}) => { + const page = await browser.newPage({ acceptDownloads: true }); + await page.setContent(`download`); + const [ download ] = await Promise.all([ + page.waitForEvent('download'), + page.click('a') + ]); + const userPath = path.join(persistentDirectory, "download.txt"); + await download.delete(); + const { message } = await download.saveAs(userPath).catch(e => e); + expect(message).toContain('Download already deleted. Save before deleting.'); + await page.close(); + }); it('should report non-navigation downloads', async({browser, server}) => { // Mac WebKit embedder does not download in this case, although Safari does. server.setRoute('/download', (req, res) => {