fix(downloads): make path/saveAs work when connected remotely (#3634)

- saveAs uses a stream internally and pipes it to the local file;
- path throws an error when called on a remote browser.
This commit is contained in:
Dmitry Gozman 2020-08-26 12:46:30 -07:00 committed by GitHub
parent a87614a266
commit 8d7ec3aca3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 185 additions and 87 deletions

View file

@ -30,6 +30,7 @@ export class Browser extends ChannelOwner<channels.BrowserChannel, channels.Brow
private _isClosedOrClosing = false; private _isClosedOrClosing = false;
private _closedPromise: Promise<void>; private _closedPromise: Promise<void>;
readonly _browserType: BrowserType; readonly _browserType: BrowserType;
_isRemote = false;
static from(browser: channels.BrowserChannel): Browser { static from(browser: channels.BrowserChannel): Browser {
return (browser as any)._object; return (browser as any)._object;

View file

@ -146,6 +146,7 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
ws.addEventListener('open', async () => { ws.addEventListener('open', async () => {
const browser = (await connection.waitForObjectWithKnownName('connectedBrowser')) as Browser; const browser = (await connection.waitForObjectWithKnownName('connectedBrowser')) as Browser;
browser._logger = logger; browser._logger = logger;
browser._isRemote = true;
const closeListener = () => { const closeListener = () => {
// Emulate all pages, contexts and the browser closing upon disconnect. // Emulate all pages, contexts and the browser closing upon disconnect.
for (const context of browser.contexts()) { for (const context of browser.contexts()) {

View file

@ -18,14 +18,21 @@ import * as channels from '../protocol/channels';
import { ChannelOwner } from './channelOwner'; import { ChannelOwner } from './channelOwner';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { Stream } from './stream'; import { Stream } from './stream';
import { Browser } from './browser';
import { BrowserContext } from './browserContext';
import * as fs from 'fs';
import { mkdirIfNeeded } from '../utils/utils';
export class Download extends ChannelOwner<channels.DownloadChannel, channels.DownloadInitializer> { export class Download extends ChannelOwner<channels.DownloadChannel, channels.DownloadInitializer> {
private _browser: Browser | undefined;
static from(download: channels.DownloadChannel): Download { static from(download: channels.DownloadChannel): Download {
return (download as any)._object; return (download as any)._object;
} }
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.DownloadInitializer) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.DownloadInitializer) {
super(parent, type, guid, initializer); super(parent, type, guid, initializer);
this._browser = (parent as BrowserContext)._browser;
} }
url(): string { url(): string {
@ -37,12 +44,26 @@ export class Download extends ChannelOwner<channels.DownloadChannel, channels.Do
} }
async path(): Promise<string | null> { async path(): Promise<string | null> {
if (this._browser && this._browser._isRemote)
throw new Error(`Path is not available when using browserType.connect(). Use download.saveAs() to save a local copy.`);
return (await this._channel.path()).value || null; return (await this._channel.path()).value || null;
} }
async saveAs(path: string): Promise<void> { async saveAs(path: string): Promise<void> {
return this._wrapApiCall('download.saveAs', async () => { return this._wrapApiCall('download.saveAs', async () => {
await this._channel.saveAs({ path }); if (!this._browser || !this._browser._isRemote) {
await this._channel.saveAs({ path });
return;
}
const result = await this._channel.saveAsStream();
const stream = Stream.from(result.stream);
await mkdirIfNeeded(path);
await new Promise((resolve, reject) => {
stream.stream().pipe(fs.createWriteStream(path))
.on('finish' as any, resolve)
.on('error' as any, reject);
});
}); });
} }

View file

@ -47,4 +47,10 @@ class StreamImpl extends Readable {
else else
this.push(null); this.push(null);
} }
_destroy(error: Error | null, callback: (error: Error | null) => void): void {
// Stream might be destroyed after the connection was closed.
this._channel.close().catch(e => null);
super._destroy(error, callback);
}
} }

View file

@ -18,6 +18,9 @@ import { Download } from '../server/download';
import * as channels from '../protocol/channels'; import * as channels from '../protocol/channels';
import { Dispatcher, DispatcherScope } from './dispatcher'; import { Dispatcher, DispatcherScope } from './dispatcher';
import { StreamDispatcher } from './streamDispatcher'; import { StreamDispatcher } from './streamDispatcher';
import * as fs from 'fs';
import * as util from 'util';
import { mkdirIfNeeded } from '../utils/utils';
export class DownloadDispatcher extends Dispatcher<Download, channels.DownloadInitializer> implements channels.DownloadChannel { export class DownloadDispatcher extends Dispatcher<Download, channels.DownloadInitializer> implements channels.DownloadChannel {
constructor(scope: DispatcherScope, download: Download) { constructor(scope: DispatcherScope, download: Download) {
@ -28,20 +31,63 @@ export class DownloadDispatcher extends Dispatcher<Download, channels.DownloadIn
} }
async path(): Promise<channels.DownloadPathResult> { async path(): Promise<channels.DownloadPathResult> {
const path = await this._object.path(); const path = await this._object.localPath();
return { value: path || undefined }; return { value: path || undefined };
} }
async saveAs(params: channels.DownloadSaveAsParams): Promise<void> { async saveAs(params: channels.DownloadSaveAsParams): Promise<channels.DownloadSaveAsResult> {
await this._object.saveAs(params.path); return await new Promise((resolve, reject) => {
this._object.saveAs(async (localPath, error) => {
if (error !== undefined) {
reject(error);
return;
}
try {
await mkdirIfNeeded(params.path);
await util.promisify(fs.copyFile)(localPath, params.path);
resolve();
} catch (e) {
reject(e);
}
});
});
}
async saveAsStream(): Promise<channels.DownloadSaveAsStreamResult> {
return await new Promise((resolve, reject) => {
this._object.saveAs(async (localPath, error) => {
if (error !== undefined) {
reject(error);
return;
}
try {
const readable = fs.createReadStream(localPath);
await new Promise(f => readable.on('readable', f));
const stream = new StreamDispatcher(this._scope, readable);
// Resolve with a stream, so that client starts saving the data.
resolve({ stream });
// Block the download until the stream is consumed.
await new Promise<void>(resolve => {
readable.on('close', resolve);
readable.on('end', resolve);
readable.on('error', resolve);
});
} catch (e) {
reject(e);
}
});
});
} }
async stream(): Promise<channels.DownloadStreamResult> { async stream(): Promise<channels.DownloadStreamResult> {
const stream = await this._object.createReadStream(); const fileName = await this._object.localPath();
if (!stream) if (!fileName)
return {}; return {};
await new Promise(f => stream.on('readable', f)); const readable = fs.createReadStream(fileName);
return { stream: new StreamDispatcher(this._scope, stream) }; await new Promise(f => readable.on('readable', f));
return { stream: new StreamDispatcher(this._scope, readable) };
} }
async failure(): Promise<channels.DownloadFailureResult> { async failure(): Promise<channels.DownloadFailureResult> {

View file

@ -27,4 +27,8 @@ export class StreamDispatcher extends Dispatcher<stream.Readable, channels.Strea
const buffer = this._object.read(Math.min(this._object.readableLength, params.size || this._object.readableLength)); const buffer = this._object.read(Math.min(this._object.readableLength, params.size || this._object.readableLength));
return { binary: buffer ? buffer.toString('base64') : '' }; return { binary: buffer ? buffer.toString('base64') : '' };
} }
async close() {
this._object.destroy();
}
} }

View file

@ -2104,6 +2104,7 @@ export type DownloadInitializer = {
export interface DownloadChannel extends Channel { export interface DownloadChannel extends Channel {
path(params?: DownloadPathParams): Promise<DownloadPathResult>; path(params?: DownloadPathParams): Promise<DownloadPathResult>;
saveAs(params: DownloadSaveAsParams): Promise<DownloadSaveAsResult>; saveAs(params: DownloadSaveAsParams): Promise<DownloadSaveAsResult>;
saveAsStream(params?: DownloadSaveAsStreamParams): Promise<DownloadSaveAsStreamResult>;
failure(params?: DownloadFailureParams): Promise<DownloadFailureResult>; failure(params?: DownloadFailureParams): Promise<DownloadFailureResult>;
stream(params?: DownloadStreamParams): Promise<DownloadStreamResult>; stream(params?: DownloadStreamParams): Promise<DownloadStreamResult>;
delete(params?: DownloadDeleteParams): Promise<DownloadDeleteResult>; delete(params?: DownloadDeleteParams): Promise<DownloadDeleteResult>;
@ -2120,6 +2121,11 @@ export type DownloadSaveAsOptions = {
}; };
export type DownloadSaveAsResult = void; export type DownloadSaveAsResult = void;
export type DownloadSaveAsStreamParams = {};
export type DownloadSaveAsStreamOptions = {};
export type DownloadSaveAsStreamResult = {
stream: StreamChannel,
};
export type DownloadFailureParams = {}; export type DownloadFailureParams = {};
export type DownloadFailureOptions = {}; export type DownloadFailureOptions = {};
export type DownloadFailureResult = { export type DownloadFailureResult = {
@ -2138,6 +2144,7 @@ export type DownloadDeleteResult = void;
export type StreamInitializer = {}; export type StreamInitializer = {};
export interface StreamChannel extends Channel { export interface StreamChannel extends Channel {
read(params: StreamReadParams): Promise<StreamReadResult>; read(params: StreamReadParams): Promise<StreamReadResult>;
close(params?: StreamCloseParams): Promise<StreamCloseResult>;
} }
export type StreamReadParams = { export type StreamReadParams = {
size?: number, size?: number,
@ -2148,6 +2155,9 @@ export type StreamReadOptions = {
export type StreamReadResult = { export type StreamReadResult = {
binary: Binary, binary: Binary,
}; };
export type StreamCloseParams = {};
export type StreamCloseOptions = {};
export type StreamCloseResult = void;
// ----------- CDPSession ----------- // ----------- CDPSession -----------
export type CDPSessionInitializer = {}; export type CDPSessionInitializer = {};

View file

@ -1769,10 +1769,16 @@ Download:
returns: returns:
value: string? value: string?
# Blocks path/failure/delete/context.close until saved to the local |path|.
saveAs: saveAs:
parameters: parameters:
path: string path: string
# Blocks path/failure/delete/context.close until the stream is closed.
saveAsStream:
returns:
stream: Stream
failure: failure:
returns: returns:
error: string? error: string?
@ -1796,6 +1802,7 @@ Stream:
returns: returns:
binary: binary binary: binary
close:
CDPSession: CDPSession:

View file

@ -804,12 +804,14 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
scheme.DownloadSaveAsParams = tObject({ scheme.DownloadSaveAsParams = tObject({
path: tString, path: tString,
}); });
scheme.DownloadSaveAsStreamParams = tOptional(tObject({}));
scheme.DownloadFailureParams = tOptional(tObject({})); scheme.DownloadFailureParams = tOptional(tObject({}));
scheme.DownloadStreamParams = tOptional(tObject({})); scheme.DownloadStreamParams = tOptional(tObject({}));
scheme.DownloadDeleteParams = tOptional(tObject({})); scheme.DownloadDeleteParams = tOptional(tObject({}));
scheme.StreamReadParams = tObject({ scheme.StreamReadParams = tObject({
size: tOptional(tNumber), size: tOptional(tNumber),
}); });
scheme.StreamCloseParams = tOptional(tObject({}));
scheme.CDPSessionSendParams = tObject({ scheme.CDPSessionSendParams = tObject({
method: tString, method: tString,
params: tOptional(tAny), params: tOptional(tAny),

View file

@ -18,15 +18,16 @@ import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
import * as util from 'util'; import * as util from 'util';
import { Page } from './page'; import { Page } from './page';
import { Readable } from 'stream'; import { assert } from '../utils/utils';
import { assert, mkdirIfNeeded } from '../utils/utils';
type SaveCallback = (localPath: string, error?: string) => Promise<void>;
export class Download { export class Download {
private _downloadsPath: string; private _downloadsPath: string;
private _uuid: string; private _uuid: string;
private _finishedCallback: () => void; private _finishedCallback: () => void;
private _finishedPromise: Promise<void>; private _finishedPromise: Promise<void>;
private _saveAsRequests: { fulfill: () => void; reject: (error?: any) => void; path: string }[] = []; private _saveCallbacks: SaveCallback[] = [];
private _finished: boolean = false; private _finished: boolean = false;
private _page: Page; private _page: Page;
private _acceptDownloads: boolean; private _acceptDownloads: boolean;
@ -63,7 +64,7 @@ export class Download {
return this._suggestedFilename!; return this._suggestedFilename!;
} }
async path(): Promise<string | null> { async localPath(): Promise<string | null> {
if (!this._acceptDownloads) if (!this._acceptDownloads)
throw new Error('Pass { acceptDownloads: true } when you are creating your browser context.'); throw new Error('Pass { acceptDownloads: true } when you are creating your browser context.');
const fileName = path.join(this._downloadsPath, this._uuid); const fileName = path.join(this._downloadsPath, this._uuid);
@ -73,7 +74,7 @@ export class Download {
return fileName; return fileName;
} }
async saveAs(path: string) { saveAs(saveCallback: SaveCallback) {
if (!this._acceptDownloads) if (!this._acceptDownloads)
throw new Error('Pass { acceptDownloads: true } when you are creating your browser context.'); throw new Error('Pass { acceptDownloads: true } when you are creating your browser context.');
if (this._deleted) if (this._deleted)
@ -82,17 +83,10 @@ export class Download {
throw new Error('Download not found on disk. Check download.failure() for details.'); throw new Error('Download not found on disk. Check download.failure() for details.');
if (this._finished) { if (this._finished) {
await this._saveAs(path); saveCallback(path.join(this._downloadsPath, this._uuid));
return; return;
} }
this._saveCallbacks.push(saveCallback);
return new Promise((fulfill, reject) => this._saveAsRequests.push({fulfill, reject, path}));
}
async _saveAs(downloadPath: string) {
const fileName = path.join(this._downloadsPath, this._uuid);
await mkdirIfNeeded(downloadPath);
await util.promisify(fs.copyFile)(fileName, downloadPath);
} }
async failure(): Promise<string | null> { async failure(): Promise<string | null> {
@ -102,15 +96,10 @@ export class Download {
return this._failure; return this._failure;
} }
async createReadStream(): Promise<Readable | null> {
const fileName = await this.path();
return fileName ? fs.createReadStream(fileName) : null;
}
async delete(): Promise<void> { async delete(): Promise<void> {
if (!this._acceptDownloads) if (!this._acceptDownloads)
return; return;
const fileName = await this.path(); const fileName = await this.localPath();
if (this._deleted) if (this._deleted)
return; return;
this._deleted = true; this._deleted = true;
@ -123,18 +112,14 @@ export class Download {
this._failure = error || null; this._failure = error || null;
if (error) { if (error) {
for (const { reject } of this._saveAsRequests) for (const callback of this._saveCallbacks)
reject(error); callback('', error);
} else { } else {
for (const { fulfill, reject, path } of this._saveAsRequests) { const fullPath = path.join(this._downloadsPath, this._uuid);
try { for (const callback of this._saveCallbacks)
await this._saveAs(path); await callback(fullPath);
fulfill();
} catch (err) {
reject(err);
}
}
} }
this._saveCallbacks = [];
this._finishedCallback(); this._finishedCallback();
} }

View file

@ -1,46 +0,0 @@
/**
* Copyright Microsoft Corporation. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { options } from './playwright.fixtures';
import './remoteServer.fixture';
import utils from './utils';
it.skip(options.WIRE).slow()('should connect to server from another process', async({ browserType, remoteServer }) => {
const browser = await browserType.connect({ wsEndpoint: remoteServer.wsEndpoint() });
const page = await browser.newPage();
expect(await page.evaluate('2 + 3')).toBe(5);
await browser.close();
});
it.skip(options.WIRE).fail(true).slow()('should respect selectors in another process', async({ playwright, browserType, remoteServer }) => {
const mycss = () => ({
create(root, target) {},
query(root, selector) {
return root.querySelector(selector);
},
queryAll(root: HTMLElement, selector: string) {
return Array.from(root.querySelectorAll(selector));
}
});
await utils.registerEngine(playwright, 'mycss', mycss);
const browser = await browserType.connect({ wsEndpoint: remoteServer.wsEndpoint() });
const page = await browser.newPage();
await page.setContent(`<div>hello</div>`);
expect(await page.innerHTML('css=div')).toBe('hello');
expect(await page.innerHTML('mycss=div')).toBe('hello');
await browser.close();
});

View file

@ -17,6 +17,7 @@
import { options } from './playwright.fixtures'; import { options } from './playwright.fixtures';
import utils from './utils'; import utils from './utils';
import './remoteServer.fixture';
it.skip(options.WIRE).slow()('should be able to reconnect to a browser', async({browserType, defaultBrowserOptions, server}) => { it.skip(options.WIRE).slow()('should be able to reconnect to a browser', async({browserType, defaultBrowserOptions, server}) => {
const browserServer = await browserType.launchServer(defaultBrowserOptions); const browserServer = await browserType.launchServer(defaultBrowserOptions);
@ -37,6 +38,13 @@ it.skip(options.WIRE).slow()('should be able to reconnect to a browser', async({
await browserServer.close(); await browserServer.close();
}); });
it.skip(options.WIRE)('should connect to a remote server', async({ browserType, remoteServer }) => {
const browser = await browserType.connect({ wsEndpoint: remoteServer.wsEndpoint() });
const page = await browser.newPage();
expect(await page.evaluate('2 + 3')).toBe(5);
await browser.close();
});
it.skip(options.WIRE).fail(options.CHROMIUM && WIN).slow()('should handle exceptions during connect', async({browserType, defaultBrowserOptions}) => { it.skip(options.WIRE).fail(options.CHROMIUM && WIN).slow()('should handle exceptions during connect', async({browserType, defaultBrowserOptions}) => {
const browserServer = await browserType.launchServer(defaultBrowserOptions); const browserServer = await browserType.launchServer(defaultBrowserOptions);
const __testHookBeforeCreateBrowser = () => { throw new Error('Dummy') }; const __testHookBeforeCreateBrowser = () => { throw new Error('Dummy') };
@ -87,3 +95,23 @@ it.skip(options.WIRE)('should respect selectors', async({playwright, browserType
expect(await page.innerHTML('mycss=div')).toBe('hello'); expect(await page.innerHTML('mycss=div')).toBe('hello');
await browserServer.close(); await browserServer.close();
}); });
it.skip(options.WIRE).fail(true)('should respect selectors when connected remotely', async({ playwright, browserType, remoteServer }) => {
const mycss = () => ({
create(root, target) {},
query(root, selector) {
return root.querySelector(selector);
},
queryAll(root: HTMLElement, selector: string) {
return Array.from(root.querySelectorAll(selector));
}
});
await utils.registerEngine(playwright, 'mycss', mycss);
const browser = await browserType.connect({ wsEndpoint: remoteServer.wsEndpoint() });
const page = await browser.newPage();
await page.setContent(`<div>hello</div>`);
expect(await page.innerHTML('css=div')).toBe('hello');
expect(await page.innerHTML('mycss=div')).toBe('hello');
await browser.close();
});

View file

@ -15,6 +15,7 @@
*/ */
import { options } from './playwright.fixtures'; import { options } from './playwright.fixtures';
import './remoteServer.fixture';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@ -143,6 +144,23 @@ it('should create subdirectories when saving to non-existent user-specified path
await page.close(); await page.close();
}); });
it.skip(options.WIRE)('should save when connected remotely', async({tmpDir, server, browserType, remoteServer}) => {
const browser = await browserType.connect({ wsEndpoint: remoteServer.wsEndpoint() });
const page = await browser.newPage({ acceptDownloads: true });
await page.setContent(`<a href="${server.PREFIX}/download">download</a>`);
const [ download ] = await Promise.all([
page.waitForEvent('download'),
page.click('a')
]);
const nestedPath = path.join(tmpDir, "these", "are", "directories", "download.txt");
await download.saveAs(nestedPath);
expect(fs.existsSync(nestedPath)).toBeTruthy();
expect(fs.readFileSync(nestedPath).toString()).toBe('Hello world');
const error = await download.path().catch(e => e);
expect(error.message).toContain('Path is not available when using browserType.connect(). Use download.saveAs() to save a local copy.');
await browser.close();
});
it('should error when saving with downloads disabled', async({tmpDir, browser, server}) => { it('should error when saving with downloads disabled', async({tmpDir, browser, server}) => {
const page = await browser.newPage({ acceptDownloads: false }); const page = await browser.newPage({ acceptDownloads: false });
await page.setContent(`<a href="${server.PREFIX}/download">download</a>`); await page.setContent(`<a href="${server.PREFIX}/download">download</a>`);
@ -170,6 +188,21 @@ it('should error when saving after deletion', async({tmpDir, browser, server}) =
await page.close(); await page.close();
}); });
it.skip(options.WIRE)('should error when saving after deletion when connected remotely', async({tmpDir, server, browserType, remoteServer}) => {
const browser = await browserType.connect({ wsEndpoint: remoteServer.wsEndpoint() });
const page = await browser.newPage({ acceptDownloads: true });
await page.setContent(`<a href="${server.PREFIX}/download">download</a>`);
const [ download ] = await Promise.all([
page.waitForEvent('download'),
page.click('a')
]);
const userPath = path.join(tmpDir, "download.txt");
await download.delete();
const { message } = await download.saveAs(userPath).catch(e => e);
expect(message).toContain('Download already deleted. Save before deleting.');
await browser.close();
});
it('should report non-navigation downloads', async({browser, server}) => { it('should report non-navigation downloads', async({browser, server}) => {
// Mac WebKit embedder does not download in this case, although Safari does. // Mac WebKit embedder does not download in this case, although Safari does.
server.setRoute('/download', (req, res) => { server.setRoute('/download', (req, res) => {

View file

@ -109,7 +109,7 @@ class RemoteServer {
return await this._exitPromise; return await this._exitPromise;
} }
async _close() { async close() {
if (!this._didExit) if (!this._didExit)
this._child.kill(); this._child.kill();
return await this.childExitCode(); return await this.childExitCode();
@ -120,12 +120,12 @@ registerFixture('remoteServer', async ({browserType, defaultBrowserOptions}, tes
const remoteServer = new RemoteServer(); const remoteServer = new RemoteServer();
await remoteServer._start(browserType, defaultBrowserOptions); await remoteServer._start(browserType, defaultBrowserOptions);
await test(remoteServer); await test(remoteServer);
await remoteServer._close(); await remoteServer.close();
}); });
registerFixture('stallingRemoteServer', async ({browserType, defaultBrowserOptions}, test) => { registerFixture('stallingRemoteServer', async ({browserType, defaultBrowserOptions}, test) => {
const remoteServer = new RemoteServer(); const remoteServer = new RemoteServer();
await remoteServer._start(browserType, defaultBrowserOptions, { stallOnClose: true }); await remoteServer._start(browserType, defaultBrowserOptions, { stallOnClose: true });
await test(remoteServer); await test(remoteServer);
await remoteServer._close(); await remoteServer.close();
}); });