feat(video): support videos in remote browser (#4042)

This commit is contained in:
Dmitry Gozman 2020-10-02 17:27:56 -07:00 committed by GitHub
parent 133de10a47
commit e214f795e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 100 additions and 10 deletions

View File

@ -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<void>(resolve => {
readable.on('close', resolve);
readable.on('end', resolve);
readable.on('error', resolve);
});
});
}
}

View File

@ -47,8 +47,6 @@ export class Browser extends ChannelOwner<channels.BrowserChannel, channels.Brow
async newContext(options: BrowserContextOptions = {}): Promise<BrowserContext> {
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<channels.BrowserChannel, channels.Brow
extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined,
};
const context = BrowserContext.from((await this._channel.newContext(contextOptions)).context);
if (this._isRemote)
context._videosPathForRemote = options.videosPath;
this._contexts.add(context);
context._logger = logger || this._logger;
return context;

View File

@ -37,6 +37,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
_timeoutSettings = new TimeoutSettings();
_ownerPage: Page | undefined;
private _closedPromise: Promise<void>;
_videosPathForRemote?: string;
static from(context: channels.BrowserContextChannel): BrowserContext {
return (context as any)._object;

View File

@ -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<BrowserServer>;
@ -183,4 +186,19 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
}
export class RemoteBrowser extends ChannelOwner<channels.RemoteBrowserChannel, channels.RemoteBrowserInitializer> {
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));
}
}

View File

@ -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 = {};

View File

@ -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

View File

@ -89,6 +89,7 @@ export abstract class Browser extends EventEmitter {
_videoStarted(context: BrowserContext, videoId: string, path: string, pageOrError: Promise<Page | Error>) {
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() {

View File

@ -35,7 +35,8 @@ export class Video {
readonly _path: string;
readonly _context: BrowserContext;
readonly _finishedPromise: Promise<void>;
_finishCallback: () => void = () => {};
private _finishCallback: () => void = () => {};
private _callbackOnFinish?: () => Promise<void>;
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<void>) {
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();

View File

@ -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);
});
});