From 66985fc5f6f5fab74f05ab1094f060dfeb6b49bf Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 4 Sep 2020 22:37:38 -0700 Subject: [PATCH] feat(screencast): add expreimental public API on context (#3766) --- docs/api.md | 19 ++++++++ src/client/channelOwner.ts | 2 +- src/client/connection.ts | 4 ++ src/client/events.ts | 1 + src/client/page.ts | 6 +++ src/client/types.ts | 1 + src/client/video.ts | 39 +++++++++++++++++ src/dispatchers/dispatcher.ts | 2 +- src/dispatchers/pageDispatcher.ts | 4 +- src/dispatchers/videoDispatcher.ts | 29 +++++++++++++ src/protocol/channels.ts | 27 ++++++++++++ src/protocol/protocol.yml | 22 ++++++++++ src/protocol/validator.ts | 7 +++ src/server/browser.ts | 21 ++++----- src/server/browserContext.ts | 39 ++++++----------- src/server/browserType.ts | 29 ++++++++----- src/server/chromium/crPage.ts | 14 +++--- src/server/firefox/ffBrowser.ts | 17 +++++--- src/server/firefox/ffPage.ts | 10 +++-- src/server/page.ts | 1 + src/server/types.ts | 14 +++--- src/server/webkit/wkBrowser.ts | 2 +- src/server/webkit/wkPage.ts | 42 ++++++++++-------- test/screencast.spec.ts | 69 ++++++++++++++---------------- 24 files changed, 289 insertions(+), 132 deletions(-) create mode 100644 src/client/video.ts create mode 100644 src/dispatchers/videoDispatcher.ts diff --git a/docs/api.md b/docs/api.md index df732e45c7..a450e793d0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -220,6 +220,9 @@ Indicates that the browser is connected. - `password` <[string]> - `colorScheme` <"light"|"dark"|"no-preference"> Emulates `'prefers-colors-scheme'` media feature, supported values are `'light'`, `'dark'`, `'no-preference'`. See [page.emulateMedia(options)](#pageemulatemediaoptions) for more details. Defaults to '`light`'. - `logger` <[Logger]> Logger sink for Playwright logging. + - `_recordVideos` <[Object]> **experimental** Enables automatic video recording for new pages. The video will have frames with the provided dimensions. Actual picture of the page will be scaled down if necessary to fit specified size. + - `width` <[number]> Video frame width. + - `height` <[number]> Video frame height. - returns: <[Promise]<[BrowserContext]>> Creates a new browser context. It won't share cookies/cache with other browser contexts. @@ -262,6 +265,9 @@ Creates a new browser context. It won't share cookies/cache with other browser c - `password` <[string]> - `colorScheme` <"light"|"dark"|"no-preference"> Emulates `'prefers-colors-scheme'` media feature, supported values are `'light'`, `'dark'`, `'no-preference'`. See [page.emulateMedia(options)](#pageemulatemediaoptions) for more details. Defaults to '`light`'. - `logger` <[Logger]> Logger sink for Playwright logging. + - `_recordVideos` <[Object]> **experimental** Enables automatic video recording for the new page. The video will have frames with the provided dimensions. Actual picture of the page will be scaled down if necessary to fit specified size. + - `width` <[number]> Video frame width. + - `height` <[number]> Video frame height. - returns: <[Promise]<[Page]>> Creates a new page in a new browser context. Closing this page will close the context as well. @@ -684,6 +690,7 @@ page.removeListener('request', logRequest); ``` +- [event: '_videostarted'](#event-videostarted) - [event: 'close'](#event-close-1) - [event: 'console'](#event-console) - [event: 'crash'](#event-crash) @@ -770,6 +777,12 @@ page.removeListener('request', logRequest); - [page.workers()](#pageworkers) +#### event: '_videostarted' +- <[Object]> Video object. + +**experimental** +Emitted when video recording has started for this page. The event will fire only if [`_recordVideos`](#browsernewcontextoptions) option is configured on the parent context. + #### event: 'close' Emitted when the page closes. @@ -4157,6 +4170,7 @@ This methods attaches Playwright to an existing browser instance. - `username` <[string]> Optional username to use if HTTP proxy requires authentication. - `password` <[string]> Optional password to use if HTTP proxy requires authentication. - `downloadsPath` <[string]> If specified, accepted downloads are downloaded into this folder. Otherwise, temporary folder is created and is deleted when browser is closed. + - `_videosPath` <[string]> **experimental** If specified, recorded videos are saved into this folder. Otherwise, temporary folder is created and is deleted when browser is closed. - `chromiumSandbox` <[boolean]> Enable Chromium sandboxing. Defaults to `true`. - `firefoxUserPrefs` <[Object]<[string], [string]|[number]|[boolean]>> Firefox user preferences. Learn more about the Firefox user preferences at [`about:config`](https://support.mozilla.org/en-US/kb/about-config-editor-firefox). - `handleSIGINT` <[boolean]> Close the browser process on Ctrl-C. Defaults to `true`. @@ -4231,6 +4245,10 @@ const browser = await chromium.launch({ // Or 'firefox' or 'webkit'. - `username` <[string]> - `password` <[string]> - `colorScheme` <"light"|"dark"|"no-preference"> Emulates `'prefers-colors-scheme'` media feature, supported values are `'light'`, `'dark'`, `'no-preference'`. See [page.emulateMedia(options)](#pageemulatemediaoptions) for more details. Defaults to '`light`'. + - `_videosPath` <[string]> **experimental** If specified, recorded videos are saved into this folder. Otherwise, temporary folder is created and is deleted when browser is closed. + - `_recordVideos` <[Object]> **experimental** Enables automatic video recording for the new page. The video will have frames with the provided dimensions. Actual picture of the page will be scaled down if necessary to fit specified size. + - `width` <[number]> Video frame width. + - `height` <[number]> Video frame height. - returns: <[Promise]<[BrowserContext]>> Promise that resolves to the persistent browser context instance. Launches browser that uses persistent storage located at `userDataDir` and returns the only context. Closing this context will automatically close the browser. @@ -4248,6 +4266,7 @@ Launches browser that uses persistent storage located at `userDataDir` and retur - `username` <[string]> Optional username to use if HTTP proxy requires authentication. - `password` <[string]> Optional password to use if HTTP proxy requires authentication. - `downloadsPath` <[string]> If specified, accepted downloads are downloaded into this folder. Otherwise, temporary folder is created and is deleted when browser is closed. + - `_videosPath` <[string]> **experimental** If specified, recorded videos are saved into this folder. Otherwise, temporary folder is created and is deleted when browser is closed. - `chromiumSandbox` <[boolean]> Enable Chromium sandboxing. Defaults to `true`. - `firefoxUserPrefs` <[Object]<[string], [string]|[number]|[boolean]>> Firefox user preferences. Learn more about the Firefox user preferences at [`about:config`](https://support.mozilla.org/en-US/kb/about-config-editor-firefox). - `handleSIGINT` <[boolean]> Close the browser process on Ctrl-C. Defaults to `true`. diff --git a/src/client/channelOwner.ts b/src/client/channelOwner.ts index 81e1a0f68b..b607564bc5 100644 --- a/src/client/channelOwner.ts +++ b/src/client/channelOwner.ts @@ -48,7 +48,7 @@ export abstract class ChannelOwner { - if (String(prop).startsWith('_')) + if (String(prop).startsWith('_') && String(prop) !== '_enableScreencast' && String(prop) !== '_disableScreencast') return obj[prop]; if (prop === 'then') return obj.then; diff --git a/src/client/connection.ts b/src/client/connection.ts index 53557b81c1..c092b8d8ca 100644 --- a/src/client/connection.ts +++ b/src/client/connection.ts @@ -40,6 +40,7 @@ import { WebKitBrowser } from './webkitBrowser'; import { FirefoxBrowser } from './firefoxBrowser'; import { debugLogger } from '../utils/debugLogger'; import { SelectorsOwner } from './selectors'; +import { Video } from './video'; class Root extends ChannelOwner { constructor(connection: Connection) { @@ -207,6 +208,9 @@ export class Connection { case 'Route': result = new Route(parent, type, guid, initializer); break; + case 'Video': + result = new Video(parent, type, guid, initializer); + break; case 'Stream': result = new Stream(parent, type, guid, initializer); break; diff --git a/src/client/events.ts b/src/client/events.ts index 2c02e19ef8..6b0a733ec6 100644 --- a/src/client/events.ts +++ b/src/client/events.ts @@ -50,6 +50,7 @@ export const Events = { Load: 'load', Popup: 'popup', Worker: 'worker', + _VideoStarted: '_videostarted', }, Worker: { diff --git a/src/client/page.ts b/src/client/page.ts index fd0bed3493..47e074afe2 100644 --- a/src/client/page.ts +++ b/src/client/page.ts @@ -42,6 +42,7 @@ import * as util from 'util'; import { Size, URLMatch, Headers, LifecycleEvent, WaitForEventOptions, SelectOption, SelectOptionOptions, FilePayload, WaitForFunctionOptions } from './types'; import { evaluationScript, urlMatches } from './clientHelper'; import { isString, isRegExp, isObject, mkdirIfNeeded, headersObjectToArray } from '../utils/utils'; +import { Video } from './video'; type PDFOptions = Omit & { width?: string | number, @@ -122,6 +123,7 @@ export class Page extends ChannelOwner this.emit(Events.Page.Response, Response.from(response))); this._channel.on('route', ({ route, request }) => this._onRoute(Route.from(route), Request.from(request))); this._channel.on('worker', ({ worker }) => this._onWorker(Worker.from(worker))); + this._channel.on('videoStarted', params => this._onVideoStarted(params)); if (this._browserContext._browserName === 'chromium') { this.coverage = new ChromiumCoverage(this._channel); @@ -175,6 +177,10 @@ export class Page extends ChannelOwner { + private _browser: Browser | undefined; + + static from(channel: channels.VideoChannel): Video { + return (channel as any)._object; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.VideoInitializer) { + super(parent, type, guid, initializer); + this._browser = (parent as BrowserContext)._browser; + } + + async path(): Promise { + if (this._browser && this._browser._isRemote) + throw new Error(`Path is not available when using browserType.connect().`); + return (await this._channel.path()).value; + } +} diff --git a/src/dispatchers/dispatcher.ts b/src/dispatchers/dispatcher.ts index d9f5bf1fbe..7fbd537904 100644 --- a/src/dispatchers/dispatcher.ts +++ b/src/dispatchers/dispatcher.ts @@ -117,7 +117,7 @@ export class DispatcherConnection { onmessage = (message: object) => {}; private _validateParams: (type: string, method: string, params: any) => any; - async sendMessageToClient(guid: string, method: string, params: any, disallowDispatchers?: boolean): Promise { + sendMessageToClient(guid: string, method: string, params: any, disallowDispatchers?: boolean) { const allowDispatchers = !disallowDispatchers; this.onmessage({ guid, method, params: this._replaceDispatchersWithGuids(params, allowDispatchers) }); } diff --git a/src/dispatchers/pageDispatcher.ts b/src/dispatchers/pageDispatcher.ts index e2782094ba..8af9e67a2c 100644 --- a/src/dispatchers/pageDispatcher.ts +++ b/src/dispatchers/pageDispatcher.ts @@ -30,6 +30,7 @@ import { serializeResult, parseArgument } from './jsHandleDispatcher'; import { ElementHandleDispatcher, createHandle } from './elementHandlerDispatcher'; import { FileChooser } from '../server/fileChooser'; import { CRCoverage } from '../server/chromium/crCoverage'; +import { VideoDispatcher } from './videoDispatcher'; export class PageDispatcher extends Dispatcher implements channels.PageChannel { private _page: Page; @@ -48,7 +49,7 @@ export class PageDispatcher extends Dispatcher i page.on(Page.Events.Crash, () => this._dispatchEvent('crash')); page.on(Page.Events.DOMContentLoaded, () => this._dispatchEvent('domcontentloaded')); page.on(Page.Events.Dialog, dialog => this._dispatchEvent('dialog', { dialog: new DialogDispatcher(this._scope, dialog) })); - page.on(Page.Events.Download, dialog => this._dispatchEvent('download', { download: new DownloadDispatcher(this._scope, dialog) })); + page.on(Page.Events.Download, download => this._dispatchEvent('download', { download: new DownloadDispatcher(this._scope, download) })); this._page.on(Page.Events.FileChooser, (fileChooser: FileChooser) => this._dispatchEvent('fileChooser', { element: new ElementHandleDispatcher(this._scope, fileChooser.element()), isMultiple: fileChooser.isMultiple() @@ -65,6 +66,7 @@ export class PageDispatcher extends Dispatcher i })); page.on(Page.Events.RequestFinished, request => this._dispatchEvent('requestFinished', { request: RequestDispatcher.from(scope, request) })); page.on(Page.Events.Response, response => this._dispatchEvent('response', { response: new ResponseDispatcher(this._scope, response) })); + page.on(Page.Events.VideoStarted, screencast => this._dispatchEvent('videoStarted', { video: new VideoDispatcher(this._scope, screencast) })); page.on(Page.Events.Worker, worker => this._dispatchEvent('worker', { worker: new WorkerDispatcher(this._scope, worker) })); } diff --git a/src/dispatchers/videoDispatcher.ts b/src/dispatchers/videoDispatcher.ts new file mode 100644 index 0000000000..eb79d94587 --- /dev/null +++ b/src/dispatchers/videoDispatcher.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the 'License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as channels from '../protocol/channels'; +import { Video } from '../server/browserContext'; +import { Dispatcher, DispatcherScope } from './dispatcher'; + +export class VideoDispatcher extends Dispatcher implements channels.VideoChannel { + constructor(scope: DispatcherScope, screencast: Video) { + super(scope, screencast, 'Video', {}); + } + + async path(): Promise { + return { value: await this._object.path() }; + } +} diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 01ec9a7eff..3fe9d0cec0 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -164,6 +164,7 @@ export type BrowserTypeLaunchParams = { password?: string, }, downloadsPath?: string, + _videosPath?: string, firefoxUserPrefs?: any, chromiumSandbox?: boolean, slowMo?: number, @@ -190,6 +191,7 @@ export type BrowserTypeLaunchOptions = { password?: string, }, downloadsPath?: string, + _videosPath?: string, firefoxUserPrefs?: any, chromiumSandbox?: boolean, slowMo?: number, @@ -220,6 +222,7 @@ export type BrowserTypeLaunchPersistentContextParams = { password?: string, }, downloadsPath?: string, + _videosPath?: string, chromiumSandbox?: boolean, slowMo?: number, noDefaultViewport?: boolean, @@ -276,6 +279,7 @@ export type BrowserTypeLaunchPersistentContextOptions = { password?: string, }, downloadsPath?: string, + _videosPath?: string, chromiumSandbox?: boolean, slowMo?: number, noDefaultViewport?: boolean, @@ -363,6 +367,10 @@ export type BrowserNewContextParams = { hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', acceptDownloads?: boolean, + _recordVideos?: { + width: number, + height: number, + }, }; export type BrowserNewContextOptions = { noDefaultViewport?: boolean, @@ -396,6 +404,10 @@ export type BrowserNewContextOptions = { hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', acceptDownloads?: boolean, + _recordVideos?: { + width: number, + height: number, + }, }; export type BrowserNewContextResult = { context: BrowserContextChannel, @@ -645,6 +657,7 @@ export interface PageChannel extends Channel { on(event: 'requestFinished', callback: (params: PageRequestFinishedEvent) => void): this; on(event: 'response', callback: (params: PageResponseEvent) => void): this; on(event: 'route', callback: (params: PageRouteEvent) => void): this; + on(event: 'videoStarted', callback: (params: PageVideoStartedEvent) => void): this; on(event: 'worker', callback: (params: PageWorkerEvent) => void): this; setDefaultNavigationTimeoutNoReply(params: PageSetDefaultNavigationTimeoutNoReplyParams): Promise; setDefaultTimeoutNoReply(params: PageSetDefaultTimeoutNoReplyParams): Promise; @@ -727,6 +740,9 @@ export type PageRouteEvent = { route: RouteChannel, request: RequestChannel, }; +export type PageVideoStartedEvent = { + video: VideoChannel, +}; export type PageWorkerEvent = { worker: WorkerChannel, }; @@ -2107,6 +2123,17 @@ export type DialogDismissParams = {}; export type DialogDismissOptions = {}; export type DialogDismissResult = void; +// ----------- Video ----------- +export type VideoInitializer = {}; +export interface VideoChannel extends Channel { + path(params?: VideoPathParams): Promise; +} +export type VideoPathParams = {}; +export type VideoPathOptions = {}; +export type VideoPathResult = { + value: string, +}; + // ----------- Download ----------- export type DownloadInitializer = { url: string, diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 5951ebf18b..c298019170 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -212,6 +212,7 @@ BrowserType: username: string? password: string? downloadsPath: string? + _videosPath: string? firefoxUserPrefs: json? chromiumSandbox: boolean? slowMo: number? @@ -250,6 +251,7 @@ BrowserType: username: string? password: string? downloadsPath: string? + _videosPath: string? chromiumSandbox: boolean? slowMo: number? noDefaultViewport: boolean? @@ -357,6 +359,11 @@ Browser: - light - no-preference acceptDownloads: boolean? + _recordVideos: + type: object? + properties: + width: number + height: number returns: context: BrowserContext @@ -892,6 +899,10 @@ Page: route: Route request: Request + videoStarted: + parameters: + video: Video + worker: parameters: worker: Worker @@ -1771,6 +1782,17 @@ Dialog: +Video: + type: interface + + commands: + + path: + returns: + value: string + + + Download: type: interface diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index 9d6d4f5e46..892a24ff76 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -118,6 +118,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { password: tOptional(tString), })), downloadsPath: tOptional(tString), + _videosPath: tOptional(tString), firefoxUserPrefs: tOptional(tAny), chromiumSandbox: tOptional(tBoolean), slowMo: tOptional(tNumber), @@ -145,6 +146,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { password: tOptional(tString), })), downloadsPath: tOptional(tString), + _videosPath: tOptional(tString), chromiumSandbox: tOptional(tBoolean), slowMo: tOptional(tNumber), noDefaultViewport: tOptional(tBoolean), @@ -212,6 +214,10 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { hasTouch: tOptional(tBoolean), colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference'])), acceptDownloads: tOptional(tBoolean), + _recordVideos: tOptional(tObject({ + width: tNumber, + height: tNumber, + })), }); scheme.BrowserCrNewBrowserCDPSessionParams = tOptional(tObject({})); scheme.BrowserCrStartTracingParams = tObject({ @@ -799,6 +805,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { promptText: tOptional(tString), }); scheme.DialogDismissParams = tOptional(tObject({})); + scheme.VideoPathParams = tOptional(tObject({})); scheme.DownloadPathParams = tOptional(tObject({})); scheme.DownloadSaveAsParams = tObject({ path: tString, diff --git a/src/server/browser.ts b/src/server/browser.ts index c95c5af410..85d0477b01 100644 --- a/src/server/browser.ts +++ b/src/server/browser.ts @@ -15,7 +15,7 @@ */ import * as types from './types'; -import { BrowserContext, Screencast } from './browserContext'; +import { BrowserContext, Video } from './browserContext'; import { Page } from './page'; import { EventEmitter } from 'events'; import { Download } from './download'; @@ -32,6 +32,7 @@ export interface BrowserProcess { export type BrowserOptions = types.UIOptions & { name: string, downloadsPath?: string, + _videosPath?: string, headful?: boolean, persistent?: types.BrowserContextOptions, // Undefined means no persistent context. browserProcess: BrowserProcess, @@ -47,7 +48,7 @@ export abstract class Browser extends EventEmitter { private _downloads = new Map(); _defaultContext: BrowserContext | null = null; private _startedClosing = false; - private readonly _idToScreencast = new Map(); + private readonly _idToVideo = new Map(); constructor(options: BrowserOptions) { super(); @@ -86,16 +87,16 @@ export abstract class Browser extends EventEmitter { this._downloads.delete(uuid); } - _screencastStarted(screencastId: string, file: string, page: Page) { - const screencast = new Screencast(file, page); - this._idToScreencast.set(screencastId, screencast); - page._browserContext.emit(BrowserContext.Events.ScreencastStarted, screencast); + _videoStarted(videoId: string, file: string): Video { + const video = new Video(file); + this._idToVideo.set(videoId, video); + return video; } - _screencastFinished(screencastId: string) { - const screencast = this._idToScreencast.get(screencastId); - this._idToScreencast.delete(screencastId); - screencast!._finishCallback(); + _videoFinished(videoId: string) { + const video = this._idToVideo.get(videoId); + this._idToVideo.delete(videoId); + video!._finishCallback(); } _didClose() { diff --git a/src/server/browserContext.ts b/src/server/browserContext.ts index b55a7cb3c0..8c55acee86 100644 --- a/src/server/browserContext.ts +++ b/src/server/browserContext.ts @@ -15,33 +15,29 @@ * limitations under the License. */ -import * as fs from 'fs'; -import { helper } from './helper'; -import * as network from './network'; -import * as path from 'path'; -import { Page, PageBinding } from './page'; -import { TimeoutSettings } from '../utils/timeoutSettings'; -import * as frames from './frames'; -import * as types from './types'; -import { Download } from './download'; -import { Browser } from './browser'; import { EventEmitter } from 'events'; +import { TimeoutSettings } from '../utils/timeoutSettings'; +import { Browser } from './browser'; +import { Download } from './download'; +import * as frames from './frames'; +import { helper } from './helper'; +import { instrumentingAgents } from './instrumentation'; +import * as network from './network'; +import { Page, PageBinding } from './page'; import { Progress } from './progress'; import { Selectors, serverSelectors } from './selectors'; -import { instrumentingAgents } from './instrumentation'; +import * as types from './types'; -export class Screencast { - readonly page: Page; +export class Video { private readonly _path: string; _finishCallback: () => void = () => {}; private readonly _finishedPromise: Promise; - constructor(path: string, page: Page) { + constructor(path: string) { this._path = path; - this.page = page; this._finishedPromise = new Promise(fulfill => this._finishCallback = fulfill); } - async path(): Promise { + async path(): Promise { await this._finishedPromise; return this._path; } @@ -51,13 +47,11 @@ export abstract class BrowserContext extends EventEmitter { static Events = { Close: 'close', Page: 'page', - ScreencastStarted: 'screencaststarted', }; readonly _timeoutSettings = new TimeoutSettings(); readonly _pageBindings = new Map(); readonly _options: types.BrowserContextOptions; - _screencastOptions: types.ContextScreencastOptions | null = null; _requestInterceptor?: network.RouteHandler; private _isPersistentContext: boolean; private _closedStatus: 'open' | 'closing' | 'closed' = 'open'; @@ -174,15 +168,6 @@ export abstract class BrowserContext extends EventEmitter { this._timeoutSettings.setDefaultTimeout(timeout); } - async _enableScreencast(options: types.ContextScreencastOptions) { - this._screencastOptions = options; - fs.mkdirSync(path.dirname(options.dir), {recursive: true}); - } - - _disableScreencast() { - this._screencastOptions = null; - } - async _loadDefaultContext(progress: Progress) { if (!this.pages().length) { const waitForEvent = helper.waitForEvent(progress, this, BrowserContext.Events.Page); diff --git a/src/server/browserType.ts b/src/server/browserType.ts index 2ebea54f04..7e82698775 100644 --- a/src/server/browserType.ts +++ b/src/server/browserType.ts @@ -41,6 +41,7 @@ const mkdirAsync = util.promisify(fs.mkdir); const mkdtempAsync = util.promisify(fs.mkdtemp); const existsAsync = (path: string): Promise => new Promise(resolve => fs.stat(path, err => resolve(!err))); const DOWNLOADS_FOLDER = path.join(os.tmpdir(), 'playwright_downloads-'); +const VIDEOS_FOLDER = path.join(os.tmpdir(), 'playwright_videos-'); type WebSocketNotPipe = { webSocketRegex: RegExp, stream: 'stdout' | 'stderr' }; @@ -89,7 +90,7 @@ export abstract class BrowserTypeBase implements BrowserType { async _innerLaunch(progress: Progress, options: types.LaunchOptions, persistent: types.BrowserContextOptions | undefined, userDataDir?: string): Promise { options.proxy = options.proxy ? normalizeProxySettings(options.proxy) : undefined; - const { browserProcess, downloadsPath, transport } = await this._launchProcess(progress, options, !!persistent, userDataDir); + const { browserProcess, downloadsPath, _videosPath, transport } = await this._launchProcess(progress, options, !!persistent, userDataDir); if ((options as any).__testHookBeforeCreateBrowser) await (options as any).__testHookBeforeCreateBrowser(); const browserOptions: BrowserOptions = { @@ -98,6 +99,7 @@ export abstract class BrowserTypeBase implements BrowserType { persistent, headful: !options.headless, downloadsPath, + _videosPath, browserProcess, proxy: options.proxy, }; @@ -109,7 +111,7 @@ export abstract class BrowserTypeBase implements BrowserType { return browser; } - private async _launchProcess(progress: Progress, options: types.LaunchOptions, isPersistent: boolean, userDataDir?: string): Promise<{ browserProcess: BrowserProcess, downloadsPath: string, transport: ConnectionTransport }> { + private async _launchProcess(progress: Progress, options: types.LaunchOptions, isPersistent: boolean, userDataDir?: string): Promise<{ browserProcess: BrowserProcess, downloadsPath: string, _videosPath: string, transport: ConnectionTransport }> { const { ignoreDefaultArgs, ignoreAllDefaultArgs, @@ -123,14 +125,19 @@ export abstract class BrowserTypeBase implements BrowserType { const env = options.env ? envArrayToObject(options.env) : process.env; const tempDirectories = []; - let downloadsPath: string; - if (options.downloadsPath) { - downloadsPath = options.downloadsPath; - await mkdirAsync(options.downloadsPath, { recursive: true }); - } else { - downloadsPath = await mkdtempAsync(DOWNLOADS_FOLDER); - tempDirectories.push(downloadsPath); - } + const ensurePath = async (tmpPrefix: string, pathFromOptions?: string) => { + let dir; + if (pathFromOptions) { + dir = pathFromOptions; + await mkdirAsync(pathFromOptions, { recursive: true }); + } else { + dir = await mkdtempAsync(tmpPrefix); + tempDirectories.push(dir); + } + return dir; + }; + const downloadsPath = await ensurePath(DOWNLOADS_FOLDER, options.downloadsPath); + const _videosPath = await ensurePath(VIDEOS_FOLDER, options._videosPath); if (!userDataDir) { userDataDir = await mkdtempAsync(path.join(os.tmpdir(), `playwright_${this._name}dev_profile-`)); @@ -204,7 +211,7 @@ export abstract class BrowserTypeBase implements BrowserType { const stdio = launchedProcess.stdio as unknown as [NodeJS.ReadableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.ReadableStream]; transport = new PipeTransport(stdio[3], stdio[4]); } - return { browserProcess, downloadsPath, transport }; + return { browserProcess, downloadsPath, _videosPath, transport }; } abstract _defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[]; diff --git a/src/server/chromium/crPage.ts b/src/server/chromium/crPage.ts index 805a04e3a0..214e3ba25b 100644 --- a/src/server/chromium/crPage.ts +++ b/src/server/chromium/crPage.ts @@ -460,10 +460,10 @@ class FrameSession { promises.push(this._evaluateOnNewDocument(source)); for (const source of this._crPage._page._evaluateOnNewDocumentSources) promises.push(this._evaluateOnNewDocument(source)); - if (this._crPage._browserContext._screencastOptions) { - const contextOptions = this._crPage._browserContext._screencastOptions; + if (this._crPage._browserContext._options._recordVideos) { + const contextOptions = this._crPage._browserContext._options._recordVideos; const screencastId = createGuid(); - const outputFile = path.join(contextOptions.dir, screencastId + '.webm'); + const outputFile = path.join(this._crPage._browserContext._browser._options._videosPath!, screencastId + '.webm'); const options = Object.assign({}, contextOptions, {outputFile}); promises.push(this._startScreencast(screencastId, options)); } @@ -764,7 +764,11 @@ class FrameSession { this._screencastState = 'started'; this._videoRecorder = videoRecorder; this._screencastId = screencastId; - this._crPage._browserContext._browser._screencastStarted(screencastId, options.outputFile, this._page); + const video = this._crPage._browserContext._browser._videoStarted(screencastId, options.outputFile); + this._crPage.pageOrError().then(pageOrError => { + if (pageOrError instanceof Page) + pageOrError.emit(Page.Events.VideoStarted, video); + }).catch(() => {}); } catch (e) { videoRecorder.stop().catch(() => {}); throw e; @@ -783,7 +787,7 @@ class FrameSession { this._screencastId = null; this._screencastState = 'stopped'; await recorder.stop().catch(() => {}); - this._crPage._browserContext._browser._screencastFinished(screencastId); + this._crPage._browserContext._browser._videoFinished(screencastId); } } diff --git a/src/server/firefox/ffBrowser.ts b/src/server/firefox/ffBrowser.ts index d05f5aeb4b..c5607f039a 100644 --- a/src/server/firefox/ffBrowser.ts +++ b/src/server/firefox/ffBrowser.ts @@ -15,10 +15,10 @@ * limitations under the License. */ +import { assert } from '../../utils/utils'; import { Browser, BrowserOptions } from '../browser'; import { assertBrowserContextIsNotOwned, BrowserContext, validateBrowserContextOptions, verifyGeolocation } from '../browserContext'; import { helper, RegisteredListener } from '../helper'; -import { assert } from '../../utils/utils'; import * as network from '../network'; import { Page, PageBinding } from '../page'; import { ConnectionTransport } from '../transport'; @@ -164,7 +164,7 @@ export class FFBrowser extends Browser { } _onScreencastFinished(payload: Protocol.Browser.screencastFinishedPayload) { - this._screencastFinished(payload.screencastId); + this._videoFinished(payload.screencastId); } } @@ -222,6 +222,14 @@ export class FFBrowserContext extends BrowserContext { promises.push(this.setOffline(this._options.offline)); if (this._options.colorScheme) promises.push(this._browser._connection.send('Browser.setColorScheme', { browserContextId, colorScheme: this._options.colorScheme })); + if (this._options._recordVideos) { + await this._browser._connection.send('Browser.setScreencastOptions', { + ...this._options._recordVideos, + dir: this._browser._options._videosPath!, + browserContextId: this._browserContextId + }); + } + await Promise.all(promises); } @@ -326,11 +334,6 @@ export class FFBrowserContext extends BrowserContext { await this._browser._connection.send('Browser.setRequestInterception', { browserContextId: this._browserContextId, enabled: !!this._requestInterceptor }); } - async _enableScreencast(options: types.ContextScreencastOptions): Promise { - await super._enableScreencast(options); - await this._browser._connection.send('Browser.setScreencastOptions', Object.assign({}, options, { browserContextId: this._browserContextId})); - } - async _doClose() { assert(this._browserContextId); await this._browser._connection.send('Browser.removeBrowserContext', { browserContextId: this._browserContextId }); diff --git a/src/server/firefox/ffPage.ts b/src/server/firefox/ffPage.ts index bea2af0284..5fa1ab35ec 100644 --- a/src/server/firefox/ffPage.ts +++ b/src/server/firefox/ffPage.ts @@ -31,7 +31,7 @@ import { RawKeyboardImpl, RawMouseImpl } from './ffInput'; import { FFNetworkManager } from './ffNetworkManager'; import { Protocol } from './protocol'; import { rewriteErrorMessage } from '../../utils/stackTrace'; -import { Screencast } from '../browserContext'; +import { Video } from '../browserContext'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; @@ -50,7 +50,7 @@ export class FFPage implements PageDelegate { private readonly _contextIdToContext: Map; private _eventListeners: RegisteredListener[]; private _workers = new Map(); - private readonly _idToScreencast = new Map(); + private readonly _idToScreencast = new Map(); constructor(session: FFSession, browserContext: FFBrowserContext, opener: FFPage | null) { this._session = session; @@ -258,7 +258,11 @@ export class FFPage implements PageDelegate { } _onScreencastStarted(event: Protocol.Page.screencastStartedPayload) { - this._browserContext._browser._screencastStarted(event.screencastId, event.file, this._page); + const video = this._browserContext._browser._videoStarted(event.screencastId, event.file); + this.pageOrError().then(pageOrError => { + if (pageOrError instanceof Page) + pageOrError.emit(Page.Events.VideoStarted, video); + }).catch(() => {}); } async exposeBinding(binding: PageBinding) { diff --git a/src/server/page.ts b/src/server/page.ts index d9f0dae34b..6fcc8b9902 100644 --- a/src/server/page.ts +++ b/src/server/page.ts @@ -114,6 +114,7 @@ export class Page extends EventEmitter { Load: 'load', Popup: 'popup', Worker: 'worker', + VideoStarted: 'videostarted', }; private _closedState: 'open' | 'closing' | 'closed' = 'open'; diff --git a/src/server/types.ts b/src/server/types.ts index 3d2f474c27..08a6f178b9 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -52,19 +52,12 @@ export type ScreenshotOptions = ElementScreenshotOptions & { clip?: Rect, }; -export type ScreencastOptions = { +export type PageScreencastOptions = { width: number, height: number, -}; - -export type PageScreencastOptions = ScreencastOptions & { outputFile: string, }; -export type ContextScreencastOptions = ScreencastOptions & { - dir: string, -}; - export type URLMatch = string | RegExp | ((url: URL) => boolean); export type Credentials = { @@ -245,6 +238,10 @@ export type BrowserContextOptions = { hasTouch?: boolean, colorScheme?: ColorScheme, acceptDownloads?: boolean, + _recordVideos?: { + width: number, + height: number + } }; export type EnvArray = { name: string, value: string }[]; @@ -263,6 +260,7 @@ type LaunchOptionsBase = { devtools?: boolean, proxy?: ProxySettings, downloadsPath?: string, + _videosPath?: string, chromiumSandbox?: boolean, slowMo?: number, }; diff --git a/src/server/webkit/wkBrowser.ts b/src/server/webkit/wkBrowser.ts index 7096eab568..649fc097d5 100644 --- a/src/server/webkit/wkBrowser.ts +++ b/src/server/webkit/wkBrowser.ts @@ -126,7 +126,7 @@ export class WKBrowser extends Browser { } _onScreencastFinished(payload: Protocol.Playwright.screencastFinishedPayload) { - this._screencastFinished(payload.screencastId); + this._videoFinished(payload.screencastId); } _onPageProxyCreated(event: Protocol.Playwright.pageProxyCreatedPayload) { diff --git a/src/server/webkit/wkPage.ts b/src/server/webkit/wkPage.ts index 1e1f296903..f1590929b0 100644 --- a/src/server/webkit/wkPage.ts +++ b/src/server/webkit/wkPage.ts @@ -15,28 +15,28 @@ * limitations under the License. */ +import * as jpeg from 'jpeg-js'; +import * as path from 'path'; +import * as png from 'pngjs'; +import { assert, createGuid, debugAssert, headersArrayToObject } from '../../utils/utils'; +import * as accessibility from '../accessibility'; +import * as dialog from '../dialog'; +import * as dom from '../dom'; import * as frames from '../frames'; import { helper, RegisteredListener } from '../helper'; -import * as dom from '../dom'; +import { JSHandle } from '../javascript'; import * as network from '../network'; +import { Page, PageBinding, PageDelegate } from '../page'; +import * as types from '../types'; +import { Protocol } from './protocol'; +import { getAccessibilityTree } from './wkAccessibility'; +import { WKBrowserContext } from './wkBrowser'; import { WKSession } from './wkConnection'; import { WKExecutionContext } from './wkExecutionContext'; +import { RawKeyboardImpl, RawMouseImpl } from './wkInput'; import { WKInterceptableRequest } from './wkInterceptableRequest'; -import { WKWorkers } from './wkWorkers'; -import { Page, PageDelegate, PageBinding } from '../page'; -import * as path from 'path'; -import { Protocol } from './protocol'; -import * as dialog from '../dialog'; -import { RawMouseImpl, RawKeyboardImpl } from './wkInput'; -import * as types from '../types'; -import * as accessibility from '../accessibility'; -import { getAccessibilityTree } from './wkAccessibility'; import { WKProvisionalPage } from './wkProvisionalPage'; -import { WKBrowserContext } from './wkBrowser'; -import * as jpeg from 'jpeg-js'; -import * as png from 'pngjs'; -import { JSHandle } from '../javascript'; -import { assert, createGuid, debugAssert, headersArrayToObject } from '../../utils/utils'; +import { WKWorkers } from './wkWorkers'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; const BINDING_CALL_MESSAGE = '__playwright_binding_call__'; @@ -113,9 +113,9 @@ export class WKPage implements PageDelegate { for (const [key, value] of this._browserContext._permissions) this._grantPermissions(key, value); } - if (this._browserContext._screencastOptions) { - const contextOptions = this._browserContext._screencastOptions; - const outputFile = path.join(contextOptions.dir, createGuid() + '.webm'); + if (this._browserContext._options._recordVideos) { + const contextOptions = this._browserContext._options._recordVideos; + const outputFile = path.join(this._browserContext._browser._options._videosPath!, createGuid() + '.webm'); const options = Object.assign({}, contextOptions, {outputFile}); promises.push(this.startScreencast(options)); } @@ -721,7 +721,11 @@ export class WKPage implements PageDelegate { width: options.width, height: options.height, }) as any; - this._browserContext._browser._screencastStarted(screencastId, options.outputFile, this._page); + const video = this._browserContext._browser._videoStarted(screencastId, options.outputFile); + this.pageOrError().then(pageOrError => { + if (pageOrError instanceof Page) + pageOrError.emit(Page.Events.VideoStarted, video); + }).catch(() => {}); } catch (e) { this._recordingVideoFile = null; throw e; diff --git a/test/screencast.spec.ts b/test/screencast.spec.ts index f97f5dde80..3767023a09 100644 --- a/test/screencast.spec.ts +++ b/test/screencast.spec.ts @@ -22,7 +22,6 @@ import fs from 'fs'; import path from 'path'; import { TestServer } from '../utils/testserver'; - declare global { interface TestState { videoPlayer: VideoPlayer; @@ -244,19 +243,15 @@ describe('screencast', suite => { } }); - it('should sutomatically start/finish when new page is created/closed', test => { + it('should automatically start/finish when new page is created/closed', test => { test.flaky(options.FIREFOX, 'Even slow is not slow enough'); - }, async ({browser, tmpDir, toImpl}) => { - // Use server side of the context. All the code below also uses server side APIs. - const context = toImpl(await browser.newContext()); - await context._enableScreencast({width: 320, height: 240, dir: tmpDir}); - expect(context._screencastOptions).toBeTruthy(); - + }, async ({browserType, tmpDir}) => { + const browser = await browserType.launch({_videosPath: tmpDir}); + const context = await browser.newContext({_recordVideos: {width: 320, height: 240}}); const [screencast, newPage] = await Promise.all([ - new Promise(resolve => context.on('screencaststarted', resolve)) as Promise, + new Promise(r => context.on('page', page => page.on('_videostarted', r))), context.newPage(), ]); - expect(screencast.page === newPage).toBe(true); const [videoFile] = await Promise.all([ screencast.path(), @@ -264,50 +259,48 @@ describe('screencast', suite => { ]); expect(path.dirname(videoFile)).toBe(tmpDir); await context.close(); + await browser.close(); }); - it('should finish when contex closes', async ({browser, tmpDir, toImpl}) => { - // Use server side of the context. All the code below also uses server side APIs. - const context = toImpl(await browser.newContext()); - await context._enableScreencast({width: 320, height: 240, dir: tmpDir}); - expect(context._screencastOptions).toBeTruthy(); + it('should finish when contex closes', async ({browserType, tmpDir}) => { + const browser = await browserType.launch({_videosPath: tmpDir}); + const context = await browser.newContext({_recordVideos: {width: 320, height: 240}}); - const [screencast, newPage] = await Promise.all([ - new Promise(resolve => context.on('screencaststarted', resolve)) as Promise, + const [video] = await Promise.all([ + new Promise(r => context.on('page', page => page.on('_videostarted', r))), context.newPage(), ]); - expect(screencast.page === newPage).toBe(true); const [videoFile] = await Promise.all([ - screencast.path(), + video.path(), context.close(), ]); expect(path.dirname(videoFile)).toBe(tmpDir); + + await browser.close(); }); - it('should fire start event for popups', async ({browser, tmpDir, server, toImpl}) => { - // Use server side of the context. All the code below also uses server side APIs. - const context = toImpl(await browser.newContext()); - await context._enableScreencast({width: 640, height: 480, dir: tmpDir}); - expect(context._screencastOptions).toBeTruthy(); + it('should fire start event for popups', async ({browserType, tmpDir, server}) => { + const browser = await browserType.launch({_videosPath: tmpDir}); + const context = await browser.newContext({_recordVideos: {width: 320, height: 240}}); const [page] = await Promise.all([ context.newPage(), - new Promise(resolve => context.on('screencaststarted', resolve)) as Promise, + new Promise(r => context.on('page', page => page.on('_videostarted', r))), ]); - await page.mainFrame().goto(server.EMPTY_PAGE); + await page.goto(server.EMPTY_PAGE); + const [video, popup] = await Promise.all([ + new Promise(r => context.on('page', page => page.on('_videostarted', r))), + new Promise(resolve => context.on('page', resolve)), + page.evaluate(() => { window.open('about:blank'); }) + ]); + const [videoFile] = await Promise.all([ + video.path(), + popup.close() + ]); + expect(path.dirname(videoFile)).toBe(tmpDir); - const [screencast, popup] = await Promise.all([ - new Promise(resolve => context.on('screencaststarted', resolve)) as Promise, - new Promise(resolve => context.on('page', resolve)) as Promise, - page.mainFrame()._evaluateExpression(() => { - const win = window.open('about:blank'); - win.close(); - }, true) - ]); - expect(screencast.page === popup).toBe(true); - expect(path.dirname(await screencast.path())).toBe(tmpDir); - await context.close(); + await browser.close(); }); it('should scale frames down to the requested size ', async ({page, videoPlayer, tmpDir, server, toImpl}) => { @@ -350,4 +343,4 @@ describe('screencast', suite => { expectAll(pixels, almostRed); } }); -}); +}); \ No newline at end of file