Skip to content

Commit e214f79

Browse files
authored
feat(video): support videos in remote browser (#4042)
1 parent 133de10 commit e214f79

File tree

9 files changed

+100
-10
lines changed

9 files changed

+100
-10
lines changed

src/browserServerImpl.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import { LaunchServerOptions } from './client/types';
1818
import { BrowserType } from './server/browserType';
1919
import * as ws from 'ws';
20+
import * as fs from 'fs';
2021
import { Browser } from './server/browser';
2122
import { ChildProcess } from 'child_process';
2223
import { EventEmitter } from 'ws';
@@ -29,6 +30,8 @@ import { envObjectToArray } from './client/clientHelper';
2930
import { createGuid } from './utils/utils';
3031
import { SelectorsDispatcher } from './dispatchers/selectorsDispatcher';
3132
import { Selectors } from './server/selectors';
33+
import { BrowserContext, Video } from './server/browserContext';
34+
import { StreamDispatcher } from './dispatchers/streamDispatcher';
3235

3336
export class BrowserServerLauncherImpl implements BrowserServerLauncher {
3437
private _browserType: BrowserType;
@@ -109,39 +112,49 @@ export class BrowserServerImpl extends EventEmitter implements BrowserServer {
109112
socket.on('error', () => {});
110113
const selectors = new Selectors();
111114
const scope = connection.rootDispatcher();
112-
const browser = new ConnectedBrowser(scope, this._browser, selectors);
113-
new RemoteBrowserDispatcher(scope, browser, selectors);
115+
const remoteBrowser = new RemoteBrowserDispatcher(scope, this._browser, selectors);
114116
socket.on('close', () => {
115117
// Avoid sending any more messages over closed socket.
116118
connection.onmessage = () => {};
117119
// Cleanup contexts upon disconnect.
118-
browser.close().catch(e => {});
120+
remoteBrowser.connectedBrowser.close().catch(e => {});
119121
});
120122
}
121123
}
122124

123125
class RemoteBrowserDispatcher extends Dispatcher<{}, channels.RemoteBrowserInitializer> implements channels.PlaywrightChannel {
124-
constructor(scope: DispatcherScope, browser: ConnectedBrowser, selectors: Selectors) {
126+
readonly connectedBrowser: ConnectedBrowser;
127+
128+
constructor(scope: DispatcherScope, browser: Browser, selectors: Selectors) {
129+
const connectedBrowser = new ConnectedBrowser(scope, browser, selectors);
125130
super(scope, {}, 'RemoteBrowser', {
126131
selectors: new SelectorsDispatcher(scope, selectors),
127-
browser,
132+
browser: connectedBrowser,
128133
}, false, 'remoteBrowser');
134+
this.connectedBrowser = connectedBrowser;
135+
connectedBrowser._remoteBrowser = this;
129136
}
130137
}
131138

132139
class ConnectedBrowser extends BrowserDispatcher {
133140
private _contexts: BrowserContextDispatcher[] = [];
134141
private _selectors: Selectors;
135142
_closed = false;
143+
_remoteBrowser?: RemoteBrowserDispatcher;
136144

137145
constructor(scope: DispatcherScope, browser: Browser, selectors: Selectors) {
138146
super(scope, browser);
139147
this._selectors = selectors;
140148
}
141149

142150
async newContext(params: channels.BrowserNewContextParams): Promise<{ context: channels.BrowserContextChannel }> {
151+
if (params.videosPath) {
152+
// TODO: we should create a separate temp directory or accept a launchServer parameter.
153+
params.videosPath = this._object._options.downloadsPath;
154+
}
143155
const result = await super.newContext(params);
144156
const dispatcher = result.context as BrowserContextDispatcher;
157+
dispatcher._object.on(BrowserContext.Events.VideoStarted, (video: Video) => this._sendVideo(dispatcher, video));
145158
dispatcher._object._setSelectors(this._selectors);
146159
this._contexts.push(dispatcher);
147160
return result;
@@ -162,4 +175,18 @@ class ConnectedBrowser extends BrowserDispatcher {
162175
super._didClose();
163176
}
164177
}
178+
179+
private _sendVideo(contextDispatcher: BrowserContextDispatcher, video: Video) {
180+
video._waitForCallbackOnFinish(async () => {
181+
const readable = fs.createReadStream(video._path);
182+
await new Promise(f => readable.on('readable', f));
183+
const stream = new StreamDispatcher(this._remoteBrowser!._scope, readable);
184+
this._remoteBrowser!._dispatchEvent('video', { stream, context: contextDispatcher });
185+
await new Promise<void>(resolve => {
186+
readable.on('close', resolve);
187+
readable.on('end', resolve);
188+
readable.on('error', resolve);
189+
});
190+
});
191+
}
165192
}

src/client/browser.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,6 @@ export class Browser extends ChannelOwner<channels.BrowserChannel, channels.Brow
4747
async newContext(options: BrowserContextOptions = {}): Promise<BrowserContext> {
4848
const logger = options.logger;
4949
return this._wrapApiCall('browser.newContext', async () => {
50-
if (this._isRemote && options.videosPath)
51-
throw new Error(`"videosPath" is not supported in connected browser`);
5250
if (this._isRemote && options._tracePath)
5351
throw new Error(`"_tracePath" is not supported in connected browser`);
5452
if (options.extraHTTPHeaders)
@@ -60,6 +58,8 @@ export class Browser extends ChannelOwner<channels.BrowserChannel, channels.Brow
6058
extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined,
6159
};
6260
const context = BrowserContext.from((await this._channel.newContext(contextOptions)).context);
61+
if (this._isRemote)
62+
context._videosPathForRemote = options.videosPath;
6363
this._contexts.add(context);
6464
context._logger = logger || this._logger;
6565
return context;

src/client/browserContext.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
3737
_timeoutSettings = new TimeoutSettings();
3838
_ownerPage: Page | undefined;
3939
private _closedPromise: Promise<void>;
40+
_videosPathForRemote?: string;
4041

4142
static from(context: channels.BrowserContextChannel): BrowserContext {
4243
return (context as any)._object;

src/client/browserType.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,19 @@ import { BrowserContext } from './browserContext';
2020
import { ChannelOwner } from './channelOwner';
2121
import { LaunchOptions, LaunchServerOptions, ConnectOptions, LaunchPersistentContextOptions } from './types';
2222
import * as WebSocket from 'ws';
23+
import * as path from 'path';
24+
import * as fs from 'fs';
2325
import { Connection } from './connection';
2426
import { serializeError } from '../protocol/serializers';
2527
import { Events } from './events';
2628
import { TimeoutSettings } from '../utils/timeoutSettings';
2729
import { ChildProcess } from 'child_process';
2830
import { envObjectToArray } from './clientHelper';
2931
import { validateHeaders } from './network';
30-
import { assert, makeWaitForNextTask, headersObjectToArray } from '../utils/utils';
32+
import { assert, makeWaitForNextTask, headersObjectToArray, createGuid, mkdirIfNeeded } from '../utils/utils';
3133
import { SelectorsOwner, sharedSelectors } from './selectors';
3234
import { kBrowserClosedError } from '../utils/errors';
35+
import { Stream } from './stream';
3336

3437
export interface BrowserServerLauncher {
3538
launchServer(options?: LaunchServerOptions): Promise<BrowserServer>;
@@ -183,4 +186,19 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel, chann
183186
}
184187

185188
export class RemoteBrowser extends ChannelOwner<channels.RemoteBrowserChannel, channels.RemoteBrowserInitializer> {
189+
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.RemoteBrowserInitializer) {
190+
super(parent, type, guid, initializer);
191+
this._channel.on('video', ({ context, stream }) => this._onVideo(BrowserContext.from(context), Stream.from(stream)));
192+
}
193+
194+
private async _onVideo(context: BrowserContext, stream: Stream) {
195+
if (!context._videosPathForRemote) {
196+
stream._channel.close().catch(e => null);
197+
return;
198+
}
199+
200+
const videoFile = path.join(context._videosPathForRemote, createGuid() + '.webm');
201+
await mkdirIfNeeded(videoFile);
202+
stream.stream().pipe(fs.createWriteStream(videoFile));
203+
}
186204
}

src/protocol/channels.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,12 @@ export type RemoteBrowserInitializer = {
120120
selectors: SelectorsChannel,
121121
};
122122
export interface RemoteBrowserChannel extends Channel {
123+
on(event: 'video', callback: (params: RemoteBrowserVideoEvent) => void): this;
123124
}
125+
export type RemoteBrowserVideoEvent = {
126+
context: BrowserContextChannel,
127+
stream: StreamChannel,
128+
};
124129

125130
// ----------- Selectors -----------
126131
export type SelectorsInitializer = {};

src/protocol/protocol.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,15 @@ RemoteBrowser:
167167
browser: Browser
168168
selectors: Selectors
169169

170+
events:
171+
172+
# Video stream blocks owner context from closing until the stream is closed.
173+
# Make sure to close the stream!
174+
video:
175+
parameters:
176+
context: BrowserContext
177+
stream: Stream
178+
170179

171180
Selectors:
172181
type: interface

src/server/browser.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export abstract class Browser extends EventEmitter {
8989
_videoStarted(context: BrowserContext, videoId: string, path: string, pageOrError: Promise<Page | Error>) {
9090
const video = new Video(context, videoId, path);
9191
this._idToVideo.set(videoId, video);
92+
context.emit(BrowserContext.Events.VideoStarted, video);
9293
pageOrError.then(pageOrError => {
9394
if (pageOrError instanceof Page)
9495
pageOrError.emit(Page.Events.VideoStarted, video);
@@ -98,7 +99,7 @@ export abstract class Browser extends EventEmitter {
9899
_videoFinished(videoId: string) {
99100
const video = this._idToVideo.get(videoId)!;
100101
this._idToVideo.delete(videoId);
101-
video._finishCallback();
102+
video._finish();
102103
}
103104

104105
_didClose() {

src/server/browserContext.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,25 @@ export class Video {
3535
readonly _path: string;
3636
readonly _context: BrowserContext;
3737
readonly _finishedPromise: Promise<void>;
38-
_finishCallback: () => void = () => {};
38+
private _finishCallback: () => void = () => {};
39+
private _callbackOnFinish?: () => Promise<void>;
3940

4041
constructor(context: BrowserContext, videoId: string, path: string) {
4142
this._videoId = videoId;
4243
this._path = path;
4344
this._context = context;
4445
this._finishedPromise = new Promise(fulfill => this._finishCallback = fulfill);
4546
}
47+
48+
async _finish() {
49+
if (this._callbackOnFinish)
50+
await this._callbackOnFinish();
51+
this._finishCallback();
52+
}
53+
54+
_waitForCallbackOnFinish(callback: () => Promise<void>) {
55+
this._callbackOnFinish = callback;
56+
}
4657
}
4758

4859
export type ActionMetadata = {
@@ -78,6 +89,7 @@ export abstract class BrowserContext extends EventEmitter {
7889
static Events = {
7990
Close: 'close',
8091
Page: 'page',
92+
VideoStarted: 'videostarted',
8193
};
8294

8395
readonly _timeoutSettings = new TimeoutSettings();

test/browsertype-connect.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717

1818
import { serverFixtures } from './remoteServer.fixture';
19+
import * as fs from 'fs';
1920
const { it, expect, describe } = serverFixtures;
2021

2122
describe('connect', (suite, { wire }) => {
@@ -232,4 +233,20 @@ describe('connect', (suite, { wire }) => {
232233
]);
233234
await page.close();
234235
});
236+
237+
it('should save videos from remote browser', async ({browserType, remoteServer, testOutputPath}) => {
238+
const remote = await browserType.connect({ wsEndpoint: remoteServer.wsEndpoint() });
239+
const videosPath = testOutputPath();
240+
const context = await remote.newContext({
241+
videosPath,
242+
videoSize: { width: 320, height: 240 },
243+
});
244+
const page = await context.newPage();
245+
await page.evaluate(() => document.body.style.backgroundColor = 'red');
246+
await new Promise(r => setTimeout(r, 1000));
247+
await context.close();
248+
249+
const files = fs.readdirSync(videosPath);
250+
expect(files.some(file => file.endsWith('webm'))).toBe(true);
251+
});
235252
});

0 commit comments

Comments
 (0)