mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(video): support videos in remote browser (#4042)
This commit is contained in:
parent
133de10a47
commit
e214f795e0
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = {};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user