feat(screencast): add expreimental public API on context (#3766)

This commit is contained in:
Yury Semikhatsky 2020-09-04 22:37:38 -07:00 committed by GitHub
parent f6aab9e5bd
commit 66985fc5f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 289 additions and 132 deletions

View File

@ -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);
```
<!-- GEN:toc -->
- [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)
<!-- GEN:stop -->
#### 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`.

View File

@ -48,7 +48,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
const base = new EventEmitter();
this._channel = new Proxy(base, {
get: (obj: any, prop) => {
if (String(prop).startsWith('_'))
if (String(prop).startsWith('_') && String(prop) !== '_enableScreencast' && String(prop) !== '_disableScreencast')
return obj[prop];
if (prop === 'then')
return obj.then;

View File

@ -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<channels.Channel, {}> {
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;

View File

@ -50,6 +50,7 @@ export const Events = {
Load: 'load',
Popup: 'popup',
Worker: 'worker',
_VideoStarted: '_videostarted',
},
Worker: {

View File

@ -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<channels.PagePdfParams, 'width' | 'height' | 'margin'> & {
width?: string | number,
@ -122,6 +123,7 @@ export class Page extends ChannelOwner<channels.PageChannel, channels.PageInitia
this._channel.on('response', ({ response }) => 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<channels.PageChannel, channels.PageInitia
this.emit(Events.Page.Worker, worker);
}
private _onVideoStarted(params: channels.PageVideoStartedEvent): void {
this.emit(Events.Page._VideoStarted, Video.from(params.video));
}
_onClose() {
this._closed = true;
this._browserContext._pages.delete(this);

View File

@ -82,6 +82,7 @@ export type LaunchServerOptions = {
password?: string
},
downloadsPath?: string,
_videosPath?: string,
chromiumSandbox?: boolean,
port?: number,
logger?: Logger,

39
src/client/video.ts Normal file
View File

@ -0,0 +1,39 @@
/**
* 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 { Browser } from './browser';
import { BrowserContext } from './browserContext';
import { ChannelOwner } from './channelOwner';
export class Video extends ChannelOwner<channels.VideoChannel, channels.VideoInitializer> {
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<string> {
if (this._browser && this._browser._isRemote)
throw new Error(`Path is not available when using browserType.connect().`);
return (await this._channel.path()).value;
}
}

View File

@ -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<any> {
sendMessageToClient(guid: string, method: string, params: any, disallowDispatchers?: boolean) {
const allowDispatchers = !disallowDispatchers;
this.onmessage({ guid, method, params: this._replaceDispatchersWithGuids(params, allowDispatchers) });
}

View File

@ -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<Page, channels.PageInitializer> implements channels.PageChannel {
private _page: Page;
@ -48,7 +49,7 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageInitializer> 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<Page, channels.PageInitializer> 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) }));
}

View File

@ -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<Video, channels.VideoInitializer> implements channels.VideoChannel {
constructor(scope: DispatcherScope, screencast: Video) {
super(scope, screencast, 'Video', {});
}
async path(): Promise<channels.VideoPathResult> {
return { value: await this._object.path() };
}
}

View File

@ -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<PageSetDefaultNavigationTimeoutNoReplyResult>;
setDefaultTimeoutNoReply(params: PageSetDefaultTimeoutNoReplyParams): Promise<PageSetDefaultTimeoutNoReplyResult>;
@ -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<VideoPathResult>;
}
export type VideoPathParams = {};
export type VideoPathOptions = {};
export type VideoPathResult = {
value: string,
};
// ----------- Download -----------
export type DownloadInitializer = {
url: string,

View File

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

View File

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

View File

@ -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<string, Download>();
_defaultContext: BrowserContext | null = null;
private _startedClosing = false;
private readonly _idToScreencast = new Map<string, Screencast>();
private readonly _idToVideo = new Map<string, Video>();
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() {

View File

@ -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<void>;
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<string | null> {
async path(): Promise<string> {
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<string, PageBinding>();
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);

View File

@ -41,6 +41,7 @@ const mkdirAsync = util.promisify(fs.mkdir);
const mkdtempAsync = util.promisify(fs.mkdtemp);
const existsAsync = (path: string): Promise<boolean> => 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<Browser> {
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[];

View File

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

View File

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

View File

@ -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<string, dom.FrameExecutionContext>;
private _eventListeners: RegisteredListener[];
private _workers = new Map<string, { frameId: string, session: FFSession }>();
private readonly _idToScreencast = new Map<string, Screencast>();
private readonly _idToScreencast = new Map<string, Video>();
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) {

View File

@ -114,6 +114,7 @@ export class Page extends EventEmitter {
Load: 'load',
Popup: 'popup',
Worker: 'worker',
VideoStarted: 'videostarted',
};
private _closedState: 'open' | 'closing' | 'closed' = 'open';

View File

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

View File

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

View File

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

View File

@ -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<any>,
new Promise<any>(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<any>,
const [video] = await Promise.all([
new Promise<any>(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<any>,
new Promise<any>(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<any>(r => context.on('page', page => page.on('_videostarted', r))),
new Promise<Page>(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<any>,
new Promise(resolve => context.on('page', resolve)) as Promise<any>,
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);
}
});
});
});