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 { LaunchServerOptions } from './client/types';
|
||||||
import { BrowserType } from './server/browserType';
|
import { BrowserType } from './server/browserType';
|
||||||
import * as ws from 'ws';
|
import * as ws from 'ws';
|
||||||
|
import * as fs from 'fs';
|
||||||
import { Browser } from './server/browser';
|
import { Browser } from './server/browser';
|
||||||
import { ChildProcess } from 'child_process';
|
import { ChildProcess } from 'child_process';
|
||||||
import { EventEmitter } from 'ws';
|
import { EventEmitter } from 'ws';
|
||||||
@ -29,6 +30,8 @@ import { envObjectToArray } from './client/clientHelper';
|
|||||||
import { createGuid } from './utils/utils';
|
import { createGuid } from './utils/utils';
|
||||||
import { SelectorsDispatcher } from './dispatchers/selectorsDispatcher';
|
import { SelectorsDispatcher } from './dispatchers/selectorsDispatcher';
|
||||||
import { Selectors } from './server/selectors';
|
import { Selectors } from './server/selectors';
|
||||||
|
import { BrowserContext, Video } from './server/browserContext';
|
||||||
|
import { StreamDispatcher } from './dispatchers/streamDispatcher';
|
||||||
|
|
||||||
export class BrowserServerLauncherImpl implements BrowserServerLauncher {
|
export class BrowserServerLauncherImpl implements BrowserServerLauncher {
|
||||||
private _browserType: BrowserType;
|
private _browserType: BrowserType;
|
||||||
@ -109,23 +112,27 @@ export class BrowserServerImpl extends EventEmitter implements BrowserServer {
|
|||||||
socket.on('error', () => {});
|
socket.on('error', () => {});
|
||||||
const selectors = new Selectors();
|
const selectors = new Selectors();
|
||||||
const scope = connection.rootDispatcher();
|
const scope = connection.rootDispatcher();
|
||||||
const browser = new ConnectedBrowser(scope, this._browser, selectors);
|
const remoteBrowser = new RemoteBrowserDispatcher(scope, this._browser, selectors);
|
||||||
new RemoteBrowserDispatcher(scope, browser, selectors);
|
|
||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
// Avoid sending any more messages over closed socket.
|
// Avoid sending any more messages over closed socket.
|
||||||
connection.onmessage = () => {};
|
connection.onmessage = () => {};
|
||||||
// Cleanup contexts upon disconnect.
|
// Cleanup contexts upon disconnect.
|
||||||
browser.close().catch(e => {});
|
remoteBrowser.connectedBrowser.close().catch(e => {});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class RemoteBrowserDispatcher extends Dispatcher<{}, channels.RemoteBrowserInitializer> implements channels.PlaywrightChannel {
|
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', {
|
super(scope, {}, 'RemoteBrowser', {
|
||||||
selectors: new SelectorsDispatcher(scope, selectors),
|
selectors: new SelectorsDispatcher(scope, selectors),
|
||||||
browser,
|
browser: connectedBrowser,
|
||||||
}, false, 'remoteBrowser');
|
}, false, 'remoteBrowser');
|
||||||
|
this.connectedBrowser = connectedBrowser;
|
||||||
|
connectedBrowser._remoteBrowser = this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,6 +140,7 @@ class ConnectedBrowser extends BrowserDispatcher {
|
|||||||
private _contexts: BrowserContextDispatcher[] = [];
|
private _contexts: BrowserContextDispatcher[] = [];
|
||||||
private _selectors: Selectors;
|
private _selectors: Selectors;
|
||||||
_closed = false;
|
_closed = false;
|
||||||
|
_remoteBrowser?: RemoteBrowserDispatcher;
|
||||||
|
|
||||||
constructor(scope: DispatcherScope, browser: Browser, selectors: Selectors) {
|
constructor(scope: DispatcherScope, browser: Browser, selectors: Selectors) {
|
||||||
super(scope, browser);
|
super(scope, browser);
|
||||||
@ -140,8 +148,13 @@ class ConnectedBrowser extends BrowserDispatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async newContext(params: channels.BrowserNewContextParams): Promise<{ context: channels.BrowserContextChannel }> {
|
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 result = await super.newContext(params);
|
||||||
const dispatcher = result.context as BrowserContextDispatcher;
|
const dispatcher = result.context as BrowserContextDispatcher;
|
||||||
|
dispatcher._object.on(BrowserContext.Events.VideoStarted, (video: Video) => this._sendVideo(dispatcher, video));
|
||||||
dispatcher._object._setSelectors(this._selectors);
|
dispatcher._object._setSelectors(this._selectors);
|
||||||
this._contexts.push(dispatcher);
|
this._contexts.push(dispatcher);
|
||||||
return result;
|
return result;
|
||||||
@ -162,4 +175,18 @@ class ConnectedBrowser extends BrowserDispatcher {
|
|||||||
super._didClose();
|
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> {
|
async newContext(options: BrowserContextOptions = {}): Promise<BrowserContext> {
|
||||||
const logger = options.logger;
|
const logger = options.logger;
|
||||||
return this._wrapApiCall('browser.newContext', async () => {
|
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)
|
if (this._isRemote && options._tracePath)
|
||||||
throw new Error(`"_tracePath" is not supported in connected browser`);
|
throw new Error(`"_tracePath" is not supported in connected browser`);
|
||||||
if (options.extraHTTPHeaders)
|
if (options.extraHTTPHeaders)
|
||||||
@ -60,6 +58,8 @@ export class Browser extends ChannelOwner<channels.BrowserChannel, channels.Brow
|
|||||||
extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined,
|
extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined,
|
||||||
};
|
};
|
||||||
const context = BrowserContext.from((await this._channel.newContext(contextOptions)).context);
|
const context = BrowserContext.from((await this._channel.newContext(contextOptions)).context);
|
||||||
|
if (this._isRemote)
|
||||||
|
context._videosPathForRemote = options.videosPath;
|
||||||
this._contexts.add(context);
|
this._contexts.add(context);
|
||||||
context._logger = logger || this._logger;
|
context._logger = logger || this._logger;
|
||||||
return context;
|
return context;
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
|
|||||||
_timeoutSettings = new TimeoutSettings();
|
_timeoutSettings = new TimeoutSettings();
|
||||||
_ownerPage: Page | undefined;
|
_ownerPage: Page | undefined;
|
||||||
private _closedPromise: Promise<void>;
|
private _closedPromise: Promise<void>;
|
||||||
|
_videosPathForRemote?: string;
|
||||||
|
|
||||||
static from(context: channels.BrowserContextChannel): BrowserContext {
|
static from(context: channels.BrowserContextChannel): BrowserContext {
|
||||||
return (context as any)._object;
|
return (context as any)._object;
|
||||||
|
|||||||
@ -20,6 +20,8 @@ import { BrowserContext } from './browserContext';
|
|||||||
import { ChannelOwner } from './channelOwner';
|
import { ChannelOwner } from './channelOwner';
|
||||||
import { LaunchOptions, LaunchServerOptions, ConnectOptions, LaunchPersistentContextOptions } from './types';
|
import { LaunchOptions, LaunchServerOptions, ConnectOptions, LaunchPersistentContextOptions } from './types';
|
||||||
import * as WebSocket from 'ws';
|
import * as WebSocket from 'ws';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
import { Connection } from './connection';
|
import { Connection } from './connection';
|
||||||
import { serializeError } from '../protocol/serializers';
|
import { serializeError } from '../protocol/serializers';
|
||||||
import { Events } from './events';
|
import { Events } from './events';
|
||||||
@ -27,9 +29,10 @@ import { TimeoutSettings } from '../utils/timeoutSettings';
|
|||||||
import { ChildProcess } from 'child_process';
|
import { ChildProcess } from 'child_process';
|
||||||
import { envObjectToArray } from './clientHelper';
|
import { envObjectToArray } from './clientHelper';
|
||||||
import { validateHeaders } from './network';
|
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 { SelectorsOwner, sharedSelectors } from './selectors';
|
||||||
import { kBrowserClosedError } from '../utils/errors';
|
import { kBrowserClosedError } from '../utils/errors';
|
||||||
|
import { Stream } from './stream';
|
||||||
|
|
||||||
export interface BrowserServerLauncher {
|
export interface BrowserServerLauncher {
|
||||||
launchServer(options?: LaunchServerOptions): Promise<BrowserServer>;
|
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> {
|
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,
|
selectors: SelectorsChannel,
|
||||||
};
|
};
|
||||||
export interface RemoteBrowserChannel extends Channel {
|
export interface RemoteBrowserChannel extends Channel {
|
||||||
|
on(event: 'video', callback: (params: RemoteBrowserVideoEvent) => void): this;
|
||||||
}
|
}
|
||||||
|
export type RemoteBrowserVideoEvent = {
|
||||||
|
context: BrowserContextChannel,
|
||||||
|
stream: StreamChannel,
|
||||||
|
};
|
||||||
|
|
||||||
// ----------- Selectors -----------
|
// ----------- Selectors -----------
|
||||||
export type SelectorsInitializer = {};
|
export type SelectorsInitializer = {};
|
||||||
|
|||||||
@ -167,6 +167,15 @@ RemoteBrowser:
|
|||||||
browser: Browser
|
browser: Browser
|
||||||
selectors: Selectors
|
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:
|
Selectors:
|
||||||
type: interface
|
type: interface
|
||||||
|
|||||||
@ -89,6 +89,7 @@ export abstract class Browser extends EventEmitter {
|
|||||||
_videoStarted(context: BrowserContext, videoId: string, path: string, pageOrError: Promise<Page | Error>) {
|
_videoStarted(context: BrowserContext, videoId: string, path: string, pageOrError: Promise<Page | Error>) {
|
||||||
const video = new Video(context, videoId, path);
|
const video = new Video(context, videoId, path);
|
||||||
this._idToVideo.set(videoId, video);
|
this._idToVideo.set(videoId, video);
|
||||||
|
context.emit(BrowserContext.Events.VideoStarted, video);
|
||||||
pageOrError.then(pageOrError => {
|
pageOrError.then(pageOrError => {
|
||||||
if (pageOrError instanceof Page)
|
if (pageOrError instanceof Page)
|
||||||
pageOrError.emit(Page.Events.VideoStarted, video);
|
pageOrError.emit(Page.Events.VideoStarted, video);
|
||||||
@ -98,7 +99,7 @@ export abstract class Browser extends EventEmitter {
|
|||||||
_videoFinished(videoId: string) {
|
_videoFinished(videoId: string) {
|
||||||
const video = this._idToVideo.get(videoId)!;
|
const video = this._idToVideo.get(videoId)!;
|
||||||
this._idToVideo.delete(videoId);
|
this._idToVideo.delete(videoId);
|
||||||
video._finishCallback();
|
video._finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
_didClose() {
|
_didClose() {
|
||||||
|
|||||||
@ -35,7 +35,8 @@ export class Video {
|
|||||||
readonly _path: string;
|
readonly _path: string;
|
||||||
readonly _context: BrowserContext;
|
readonly _context: BrowserContext;
|
||||||
readonly _finishedPromise: Promise<void>;
|
readonly _finishedPromise: Promise<void>;
|
||||||
_finishCallback: () => void = () => {};
|
private _finishCallback: () => void = () => {};
|
||||||
|
private _callbackOnFinish?: () => Promise<void>;
|
||||||
|
|
||||||
constructor(context: BrowserContext, videoId: string, path: string) {
|
constructor(context: BrowserContext, videoId: string, path: string) {
|
||||||
this._videoId = videoId;
|
this._videoId = videoId;
|
||||||
@ -43,6 +44,16 @@ export class Video {
|
|||||||
this._context = context;
|
this._context = context;
|
||||||
this._finishedPromise = new Promise(fulfill => this._finishCallback = fulfill);
|
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 = {
|
export type ActionMetadata = {
|
||||||
@ -78,6 +89,7 @@ export abstract class BrowserContext extends EventEmitter {
|
|||||||
static Events = {
|
static Events = {
|
||||||
Close: 'close',
|
Close: 'close',
|
||||||
Page: 'page',
|
Page: 'page',
|
||||||
|
VideoStarted: 'videostarted',
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly _timeoutSettings = new TimeoutSettings();
|
readonly _timeoutSettings = new TimeoutSettings();
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { serverFixtures } from './remoteServer.fixture';
|
import { serverFixtures } from './remoteServer.fixture';
|
||||||
|
import * as fs from 'fs';
|
||||||
const { it, expect, describe } = serverFixtures;
|
const { it, expect, describe } = serverFixtures;
|
||||||
|
|
||||||
describe('connect', (suite, { wire }) => {
|
describe('connect', (suite, { wire }) => {
|
||||||
@ -232,4 +233,20 @@ describe('connect', (suite, { wire }) => {
|
|||||||
]);
|
]);
|
||||||
await page.close();
|
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