Skip to content

Commit d8a17fb

Browse files
authored
api(download): Add saveAs helper (#2872)
1 parent 4db035d commit d8a17fb

File tree

7 files changed

+187
-2
lines changed

7 files changed

+187
-2
lines changed

docs/api.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3182,6 +3182,7 @@ const path = await download.path();
31823182
- [download.delete()](#downloaddelete)
31833183
- [download.failure()](#downloadfailure)
31843184
- [download.path()](#downloadpath)
3185+
- [download.saveAs(path)](#downloadsaveaspath)
31853186
- [download.suggestedFilename()](#downloadsuggestedfilename)
31863187
- [download.url()](#downloadurl)
31873188
<!-- GEN:stop -->
@@ -3206,6 +3207,12 @@ Returns download error if any.
32063207

32073208
Returns path to the downloaded file in case of successful download.
32083209

3210+
#### download.saveAs(path)
3211+
- `path` <[string]> Path where the download should be saved. The directory structure MUST exist as `saveAs` will not create it.
3212+
- returns: <[Promise]>
3213+
3214+
Saves the download to a user-specified path.
3215+
32093216
#### download.suggestedFilename()
32103217
- returns: <[string]>
32113218

src/download.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export class Download {
2727
private _uuid: string;
2828
private _finishedCallback: () => void;
2929
private _finishedPromise: Promise<void>;
30+
private _saveAsRequests: { fulfill: () => void; reject: (error?: any) => void; path: string }[] = [];
31+
private _loaded: boolean = false;
3032
private _page: Page;
3133
private _acceptDownloads: boolean;
3234
private _failure: string | null = null;
@@ -72,6 +74,26 @@ export class Download {
7274
return fileName;
7375
}
7476

77+
async saveAs(path: string) {
78+
if (this._loaded) {
79+
await this._saveAs(path);
80+
return;
81+
}
82+
83+
return new Promise((fulfill, reject) => this._saveAsRequests.push({fulfill, reject, path}));
84+
}
85+
86+
async _saveAs(dlPath: string) {
87+
if (!this._acceptDownloads)
88+
throw new Error('Pass { acceptDownloads: true } when you are creating your browser context.');
89+
const fileName = path.join(this._downloadsPath, this._uuid);
90+
if (this._failure)
91+
throw new Error('Download not found on disk. Check download.failure() for details.');
92+
if (this._deleted)
93+
throw new Error('Download already deleted. Save before deleting.');
94+
await util.promisify(fs.copyFile)(fileName, dlPath);
95+
}
96+
7597
async failure(): Promise<string | null> {
7698
if (!this._acceptDownloads)
7799
return 'Pass { acceptDownloads: true } when you are creating your browser context.';
@@ -95,7 +117,26 @@ export class Download {
95117
await util.promisify(fs.unlink)(fileName).catch(e => {});
96118
}
97119

98-
_reportFinished(error?: string) {
120+
async _reportFinished(error?: string) {
121+
if (error) {
122+
for (const { reject } of this._saveAsRequests) {
123+
if (!this._acceptDownloads)
124+
reject(new Error('Pass { acceptDownloads: true } when you are creating your browser context.'));
125+
else
126+
reject(error);
127+
}
128+
} else {
129+
for (const { fulfill, reject, path } of this._saveAsRequests) {
130+
try {
131+
await this._saveAs(path);
132+
fulfill();
133+
} catch (err) {
134+
reject(err);
135+
}
136+
}
137+
}
138+
139+
this._loaded = true;
99140
this._failure = error || null;
100141
this._finishedCallback();
101142
}

src/rpc/channels.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1530,6 +1530,7 @@ export type DownloadInitializer = {
15301530
};
15311531
export interface DownloadChannel extends Channel {
15321532
path(params?: DownloadPathParams): Promise<DownloadPathResult>;
1533+
saveAs(params: DownloadSaveAsParams): Promise<DownloadSaveAsResult>;
15331534
failure(params?: DownloadFailureParams): Promise<DownloadFailureResult>;
15341535
stream(params?: DownloadStreamParams): Promise<DownloadStreamResult>;
15351536
delete(params?: DownloadDeleteParams): Promise<DownloadDeleteResult>;
@@ -1538,6 +1539,10 @@ export type DownloadPathParams = {};
15381539
export type DownloadPathResult = {
15391540
value?: string,
15401541
};
1542+
export type DownloadSaveAsParams = {
1543+
path: string,
1544+
};
1545+
export type DownloadSaveAsResult = void;
15411546
export type DownloadFailureParams = {};
15421547
export type DownloadFailureResult = {
15431548
error?: string,

src/rpc/client/download.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ export class Download extends ChannelOwner<DownloadChannel, DownloadInitializer>
4040
return (await this._channel.path()).value || null;
4141
}
4242

43+
async saveAs(path: string): Promise<void> {
44+
return this._wrapApiCall('download.saveAs', async () => {
45+
await this._channel.saveAs({ path });
46+
});
47+
}
48+
4349
async failure(): Promise<string | null> {
4450
return (await this._channel.failure()).error || null;
4551
}

src/rpc/protocol.pdl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1390,6 +1390,10 @@ interface Download
13901390
returns
13911391
value?: string
13921392

1393+
command saveAs
1394+
parameters
1395+
path: string
1396+
13931397
command failure
13941398
returns
13951399
error?: string
@@ -1474,4 +1478,3 @@ interface ElectronApplication
14741478
handle: JSHandle
14751479

14761480
command close
1477-

src/rpc/server/downloadDispatcher.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ export class DownloadDispatcher extends Dispatcher<Download, DownloadInitializer
3232
return { value: path || undefined };
3333
}
3434

35+
async saveAs(params: { path: string }): Promise<void> {
36+
await this._object.saveAs(params.path);
37+
}
38+
3539
async stream(): Promise<{ stream?: StreamChannel }> {
3640
const stream = await this._object.createReadStream();
3741
if (!stream)

test/download.jest.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,23 @@
1616

1717
const fs = require('fs');
1818
const path = require('path');
19+
const util = require('util');
20+
const os = require('os');
21+
const removeFolder = require('rimraf');
22+
const mkdtempAsync = util.promisify(fs.mkdtemp);
23+
const removeFolderAsync = util.promisify(removeFolder);
24+
1925
const {FFOX, CHROMIUM, WEBKIT, HEADLESS} = testOptions;
2026

27+
registerFixture('persistentDirectory', async ({}, test) => {
28+
const persistentDirectory = await mkdtempAsync(path.join(os.tmpdir(), 'playwright-test-'));
29+
try {
30+
await test(persistentDirectory);
31+
} finally {
32+
await removeFolderAsync(persistentDirectory);
33+
}
34+
});
35+
2136
describe('Download', function() {
2237
beforeEach(async ({server}) => {
2338
server.setRoute('/download', (req, res) => {
@@ -57,6 +72,110 @@ describe('Download', function() {
5772
expect(fs.readFileSync(path).toString()).toBe('Hello world');
5873
await page.close();
5974
});
75+
it('should save to user-specified path', async({persistentDirectory, browser, server}) => {
76+
const page = await browser.newPage({ acceptDownloads: true });
77+
await page.setContent(`<a href="${server.PREFIX}/download">download</a>`);
78+
const [ download ] = await Promise.all([
79+
page.waitForEvent('download'),
80+
page.click('a')
81+
]);
82+
const userPath = path.join(persistentDirectory, "download.txt");
83+
await download.saveAs(userPath);
84+
expect(fs.existsSync(userPath)).toBeTruthy();
85+
expect(fs.readFileSync(userPath).toString()).toBe('Hello world');
86+
await page.close();
87+
});
88+
it('should save to user-specified path without updating original path', async({persistentDirectory, browser, server}) => {
89+
const page = await browser.newPage({ acceptDownloads: true });
90+
await page.setContent(`<a href="${server.PREFIX}/download">download</a>`);
91+
const [ download ] = await Promise.all([
92+
page.waitForEvent('download'),
93+
page.click('a')
94+
]);
95+
const userPath = path.join(persistentDirectory, "download.txt");
96+
await download.saveAs(userPath);
97+
expect(fs.existsSync(userPath)).toBeTruthy();
98+
expect(fs.readFileSync(userPath).toString()).toBe('Hello world');
99+
100+
const originalPath = await download.path();
101+
expect(fs.existsSync(originalPath)).toBeTruthy();
102+
expect(fs.readFileSync(originalPath).toString()).toBe('Hello world');
103+
await page.close();
104+
});
105+
it('should save to two different paths with multiple saveAs calls', async({persistentDirectory, browser, server}) => {
106+
const page = await browser.newPage({ acceptDownloads: true });
107+
await page.setContent(`<a href="${server.PREFIX}/download">download</a>`);
108+
const [ download ] = await Promise.all([
109+
page.waitForEvent('download'),
110+
page.click('a')
111+
]);
112+
const userPath = path.join(persistentDirectory, "download.txt");
113+
await download.saveAs(userPath);
114+
expect(fs.existsSync(userPath)).toBeTruthy();
115+
expect(fs.readFileSync(userPath).toString()).toBe('Hello world');
116+
117+
const anotherUserPath = path.join(persistentDirectory, "download (2).txt");
118+
await download.saveAs(anotherUserPath);
119+
expect(fs.existsSync(anotherUserPath)).toBeTruthy();
120+
expect(fs.readFileSync(anotherUserPath).toString()).toBe('Hello world');
121+
await page.close();
122+
});
123+
it('should save to overwritten filepath', async({persistentDirectory, browser, server}) => {
124+
const page = await browser.newPage({ acceptDownloads: true });
125+
await page.setContent(`<a href="${server.PREFIX}/download">download</a>`);
126+
const [ download ] = await Promise.all([
127+
page.waitForEvent('download'),
128+
page.click('a')
129+
]);
130+
const userPath = path.join(persistentDirectory, "download.txt");
131+
await download.saveAs(userPath);
132+
expect((await util.promisify(fs.readdir)(persistentDirectory)).length).toBe(1);
133+
await download.saveAs(userPath);
134+
expect((await util.promisify(fs.readdir)(persistentDirectory)).length).toBe(1);
135+
expect(fs.existsSync(userPath)).toBeTruthy();
136+
expect(fs.readFileSync(userPath).toString()).toBe('Hello world');
137+
await page.close();
138+
});
139+
it('should error when saving to non-existent user-specified path', async({persistentDirectory, browser, server}) => {
140+
const page = await browser.newPage({ acceptDownloads: true });
141+
await page.setContent(`<a href="${server.PREFIX}/download">download</a>`);
142+
const [ download ] = await Promise.all([
143+
page.waitForEvent('download'),
144+
page.click('a')
145+
]);
146+
const nonExistentUserPath = path.join(persistentDirectory, "does-not-exist","download.txt");
147+
const { message } = await download.saveAs(nonExistentUserPath).catch(e => e);
148+
expect(message).toContain('ENOENT');
149+
expect(message).toContain('copyfile');
150+
expect(message).toContain('no such file or directory');
151+
expect(message).toContain('does-not-exist');
152+
await page.close();
153+
});
154+
it('should error when saving with downloads disabled', async({persistentDirectory, browser, server}) => {
155+
const page = await browser.newPage({ acceptDownloads: false });
156+
await page.setContent(`<a href="${server.PREFIX}/download">download</a>`);
157+
const [ download ] = await Promise.all([
158+
page.waitForEvent('download'),
159+
page.click('a')
160+
]);
161+
const userPath = path.join(persistentDirectory, "download.txt");
162+
const { message } = await download.saveAs(userPath).catch(e => e);
163+
expect(message).toContain('Pass { acceptDownloads: true } when you are creating your browser context');
164+
await page.close();
165+
});
166+
it('should error when saving after deletion', async({persistentDirectory, browser, server}) => {
167+
const page = await browser.newPage({ acceptDownloads: true });
168+
await page.setContent(`<a href="${server.PREFIX}/download">download</a>`);
169+
const [ download ] = await Promise.all([
170+
page.waitForEvent('download'),
171+
page.click('a')
172+
]);
173+
const userPath = path.join(persistentDirectory, "download.txt");
174+
await download.delete();
175+
const { message } = await download.saveAs(userPath).catch(e => e);
176+
expect(message).toContain('Download already deleted. Save before deleting.');
177+
await page.close();
178+
});
60179
it('should report non-navigation downloads', async({browser, server}) => {
61180
// Mac WebKit embedder does not download in this case, although Safari does.
62181
server.setRoute('/download', (req, res) => {

0 commit comments

Comments
 (0)