From e214f795e014028a6059709c01a697c8d49d7689 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 2 Oct 2020 17:27:56 -0700 Subject: [PATCH] feat(video): support videos in remote browser (#4042) --- src/browserServerImpl.ts | 37 +++++++++++++++++++++++++++----- src/client/browser.ts | 4 ++-- src/client/browserContext.ts | 1 + src/client/browserType.ts | 20 ++++++++++++++++- src/protocol/channels.ts | 5 +++++ src/protocol/protocol.yml | 9 ++++++++ src/server/browser.ts | 3 ++- src/server/browserContext.ts | 14 +++++++++++- test/browsertype-connect.spec.ts | 17 +++++++++++++++ 9 files changed, 100 insertions(+), 10 deletions(-) diff --git a/src/browserServerImpl.ts b/src/browserServerImpl.ts index dcec82bf3b..159a5c86d1 100644 --- a/src/browserServerImpl.ts +++ b/src/browserServerImpl.ts @@ -17,6 +17,7 @@ import { LaunchServerOptions } from './client/types'; import { BrowserType } from './server/browserType'; import * as ws from 'ws'; +import * as fs from 'fs'; import { Browser } from './server/browser'; import { ChildProcess } from 'child_process'; import { EventEmitter } from 'ws'; @@ -29,6 +30,8 @@ import { envObjectToArray } from './client/clientHelper'; import { createGuid } from './utils/utils'; import { SelectorsDispatcher } from './dispatchers/selectorsDispatcher'; import { Selectors } from './server/selectors'; +import { BrowserContext, Video } from './server/browserContext'; +import { StreamDispatcher } from './dispatchers/streamDispatcher'; export class BrowserServerLauncherImpl implements BrowserServerLauncher { private _browserType: BrowserType; @@ -109,23 +112,27 @@ export class BrowserServerImpl extends EventEmitter implements BrowserServer { socket.on('error', () => {}); const selectors = new Selectors(); const scope = connection.rootDispatcher(); - const browser = new ConnectedBrowser(scope, this._browser, selectors); - new RemoteBrowserDispatcher(scope, browser, selectors); + const remoteBrowser = new RemoteBrowserDispatcher(scope, this._browser, selectors); socket.on('close', () => { // Avoid sending any more messages over closed socket. connection.onmessage = () => {}; // Cleanup contexts upon disconnect. - browser.close().catch(e => {}); + remoteBrowser.connectedBrowser.close().catch(e => {}); }); } } class RemoteBrowserDispatcher extends Dispatcher<{}, channels.RemoteBrowserInitializer> implements channels.PlaywrightChannel { - constructor(scope: DispatcherScope, browser: ConnectedBrowser, selectors: Selectors) { + readonly connectedBrowser: ConnectedBrowser; + + constructor(scope: DispatcherScope, browser: Browser, selectors: Selectors) { + const connectedBrowser = new ConnectedBrowser(scope, browser, selectors); super(scope, {}, 'RemoteBrowser', { selectors: new SelectorsDispatcher(scope, selectors), - browser, + browser: connectedBrowser, }, false, 'remoteBrowser'); + this.connectedBrowser = connectedBrowser; + connectedBrowser._remoteBrowser = this; } } @@ -133,6 +140,7 @@ class ConnectedBrowser extends BrowserDispatcher { private _contexts: BrowserContextDispatcher[] = []; private _selectors: Selectors; _closed = false; + _remoteBrowser?: RemoteBrowserDispatcher; constructor(scope: DispatcherScope, browser: Browser, selectors: Selectors) { super(scope, browser); @@ -140,8 +148,13 @@ class ConnectedBrowser extends BrowserDispatcher { } async newContext(params: channels.BrowserNewContextParams): Promise<{ context: channels.BrowserContextChannel }> { + if (params.videosPath) { + // TODO: we should create a separate temp directory or accept a launchServer parameter. + params.videosPath = this._object._options.downloadsPath; + } const result = await super.newContext(params); const dispatcher = result.context as BrowserContextDispatcher; + dispatcher._object.on(BrowserContext.Events.VideoStarted, (video: Video) => this._sendVideo(dispatcher, video)); dispatcher._object._setSelectors(this._selectors); this._contexts.push(dispatcher); return result; @@ -162,4 +175,18 @@ class ConnectedBrowser extends BrowserDispatcher { super._didClose(); } } + + private _sendVideo(contextDispatcher: BrowserContextDispatcher, video: Video) { + video._waitForCallbackOnFinish(async () => { + const readable = fs.createReadStream(video._path); + await new Promise(f => readable.on('readable', f)); + const stream = new StreamDispatcher(this._remoteBrowser!._scope, readable); + this._remoteBrowser!._dispatchEvent('video', { stream, context: contextDispatcher }); + await new Promise(resolve => { + readable.on('close', resolve); + readable.on('end', resolve); + readable.on('error', resolve); + }); + }); + } } diff --git a/src/client/browser.ts b/src/client/browser.ts index 53b0f05865..bc31095803 100644 --- a/src/client/browser.ts +++ b/src/client/browser.ts @@ -47,8 +47,6 @@ export class Browser extends ChannelOwner { const logger = options.logger; return this._wrapApiCall('browser.newContext', async () => { - if (this._isRemote && options.videosPath) - throw new Error(`"videosPath" is not supported in connected browser`); if (this._isRemote && options._tracePath) throw new Error(`"_tracePath" is not supported in connected browser`); if (options.extraHTTPHeaders) @@ -60,6 +58,8 @@ export class Browser extends ChannelOwner; + _videosPathForRemote?: string; static from(context: channels.BrowserContextChannel): BrowserContext { return (context as any)._object; diff --git a/src/client/browserType.ts b/src/client/browserType.ts index f0d5ac8567..193d4ac68a 100644 --- a/src/client/browserType.ts +++ b/src/client/browserType.ts @@ -20,6 +20,8 @@ import { BrowserContext } from './browserContext'; import { ChannelOwner } from './channelOwner'; import { LaunchOptions, LaunchServerOptions, ConnectOptions, LaunchPersistentContextOptions } from './types'; import * as WebSocket from 'ws'; +import * as path from 'path'; +import * as fs from 'fs'; import { Connection } from './connection'; import { serializeError } from '../protocol/serializers'; import { Events } from './events'; @@ -27,9 +29,10 @@ import { TimeoutSettings } from '../utils/timeoutSettings'; import { ChildProcess } from 'child_process'; import { envObjectToArray } from './clientHelper'; import { validateHeaders } from './network'; -import { assert, makeWaitForNextTask, headersObjectToArray } from '../utils/utils'; +import { assert, makeWaitForNextTask, headersObjectToArray, createGuid, mkdirIfNeeded } from '../utils/utils'; import { SelectorsOwner, sharedSelectors } from './selectors'; import { kBrowserClosedError } from '../utils/errors'; +import { Stream } from './stream'; export interface BrowserServerLauncher { launchServer(options?: LaunchServerOptions): Promise; @@ -183,4 +186,19 @@ export class BrowserType extends ChannelOwner { + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.RemoteBrowserInitializer) { + super(parent, type, guid, initializer); + this._channel.on('video', ({ context, stream }) => this._onVideo(BrowserContext.from(context), Stream.from(stream))); + } + + private async _onVideo(context: BrowserContext, stream: Stream) { + if (!context._videosPathForRemote) { + stream._channel.close().catch(e => null); + return; + } + + const videoFile = path.join(context._videosPathForRemote, createGuid() + '.webm'); + await mkdirIfNeeded(videoFile); + stream.stream().pipe(fs.createWriteStream(videoFile)); + } } diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 9a25b58551..fd03c66437 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -120,7 +120,12 @@ export type RemoteBrowserInitializer = { selectors: SelectorsChannel, }; export interface RemoteBrowserChannel extends Channel { + on(event: 'video', callback: (params: RemoteBrowserVideoEvent) => void): this; } +export type RemoteBrowserVideoEvent = { + context: BrowserContextChannel, + stream: StreamChannel, +}; // ----------- Selectors ----------- export type SelectorsInitializer = {}; diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 602ea6d8e6..d4ef239bee 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -167,6 +167,15 @@ RemoteBrowser: browser: Browser selectors: Selectors + events: + + # Video stream blocks owner context from closing until the stream is closed. + # Make sure to close the stream! + video: + parameters: + context: BrowserContext + stream: Stream + Selectors: type: interface diff --git a/src/server/browser.ts b/src/server/browser.ts index b6f53ecfdb..783e115982 100644 --- a/src/server/browser.ts +++ b/src/server/browser.ts @@ -89,6 +89,7 @@ export abstract class Browser extends EventEmitter { _videoStarted(context: BrowserContext, videoId: string, path: string, pageOrError: Promise) { const video = new Video(context, videoId, path); this._idToVideo.set(videoId, video); + context.emit(BrowserContext.Events.VideoStarted, video); pageOrError.then(pageOrError => { if (pageOrError instanceof Page) pageOrError.emit(Page.Events.VideoStarted, video); @@ -98,7 +99,7 @@ export abstract class Browser extends EventEmitter { _videoFinished(videoId: string) { const video = this._idToVideo.get(videoId)!; this._idToVideo.delete(videoId); - video._finishCallback(); + video._finish(); } _didClose() { diff --git a/src/server/browserContext.ts b/src/server/browserContext.ts index 2dbc069a12..a205a7b250 100644 --- a/src/server/browserContext.ts +++ b/src/server/browserContext.ts @@ -35,7 +35,8 @@ export class Video { readonly _path: string; readonly _context: BrowserContext; readonly _finishedPromise: Promise; - _finishCallback: () => void = () => {}; + private _finishCallback: () => void = () => {}; + private _callbackOnFinish?: () => Promise; constructor(context: BrowserContext, videoId: string, path: string) { this._videoId = videoId; @@ -43,6 +44,16 @@ export class Video { this._context = context; this._finishedPromise = new Promise(fulfill => this._finishCallback = fulfill); } + + async _finish() { + if (this._callbackOnFinish) + await this._callbackOnFinish(); + this._finishCallback(); + } + + _waitForCallbackOnFinish(callback: () => Promise) { + this._callbackOnFinish = callback; + } } export type ActionMetadata = { @@ -78,6 +89,7 @@ export abstract class BrowserContext extends EventEmitter { static Events = { Close: 'close', Page: 'page', + VideoStarted: 'videostarted', }; readonly _timeoutSettings = new TimeoutSettings(); diff --git a/test/browsertype-connect.spec.ts b/test/browsertype-connect.spec.ts index df180c103e..f1b81150ca 100644 --- a/test/browsertype-connect.spec.ts +++ b/test/browsertype-connect.spec.ts @@ -16,6 +16,7 @@ */ import { serverFixtures } from './remoteServer.fixture'; +import * as fs from 'fs'; const { it, expect, describe } = serverFixtures; describe('connect', (suite, { wire }) => { @@ -232,4 +233,20 @@ describe('connect', (suite, { wire }) => { ]); await page.close(); }); + + it('should save videos from remote browser', async ({browserType, remoteServer, testOutputPath}) => { + const remote = await browserType.connect({ wsEndpoint: remoteServer.wsEndpoint() }); + const videosPath = testOutputPath(); + const context = await remote.newContext({ + videosPath, + videoSize: { width: 320, height: 240 }, + }); + const page = await context.newPage(); + await page.evaluate(() => document.body.style.backgroundColor = 'red'); + await new Promise(r => setTimeout(r, 1000)); + await context.close(); + + const files = fs.readdirSync(videosPath); + expect(files.some(file => file.endsWith('webm'))).toBe(true); + }); });